fix: separate HWID device renewal flows

Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout.

Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows.

Add admin extension controls, docs, demo data, and regression coverage.
This commit is contained in:
3252a8
2026-06-03 23:51:46 +03:00
parent a06884d816
commit fbb89793cb
48 changed files with 2410 additions and 224 deletions
+131 -6
View File
@@ -10,6 +10,7 @@
import { Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import Checkbox from "$components/ui/checkbox.svelte";
import Dialog from "$components/ui/dialog.svelte";
import EmailCodeScreen from "./auth/EmailCodeScreen.svelte";
import Input from "$components/ui/input.svelte";
@@ -52,6 +53,7 @@
export let selectedTariff = null;
export let selectedTariffKey = "";
export let selectedTariffPlans = [];
export let renewHwidDevices = true;
export let setPasswordBusy = false;
export let setPasswordCode = "";
export let setPasswordConfirm = "";
@@ -72,6 +74,81 @@
function priceLabel(plan) {
return priceLabelFn(plan, selectedMethod);
}
function methodUsesStars() {
return String(selectedMethod || "")
.toLowerCase()
.includes("stars");
}
function hwidRenewalFor(plan) {
return plan?.hwid_renewal?.available ? plan.hwid_renewal : null;
}
function isSubscriptionPlan(plan) {
const saleMode = String(plan?.sale_mode || "subscription").toLowerCase();
return saleMode === "subscription";
}
function hwidRenewalAvailableForMethod(plan) {
const renewal = hwidRenewalFor(plan);
if (!subscription?.active || !isSubscriptionPlan(plan) || !renewal) return false;
if (methodUsesStars()) return Number(renewal.stars_price || 0) > 0;
return Number(renewal.price || 0) > 0;
}
function planWithSelectedHwidRenewal(plan) {
if (!plan || !renewHwidDevices || !hwidRenewalAvailableForMethod(plan)) return plan;
const renewal = hwidRenewalFor(plan);
const withRenewal = {
...plan,
price: Number(plan.price || 0) + Number(renewal.price || 0),
};
if (Number(plan.stars_price || 0) > 0 && Number(renewal.stars_price || 0) > 0) {
withRenewal.stars_price = Number(plan.stars_price || 0) + Number(renewal.stars_price || 0);
}
return withRenewal;
}
function paymentPriceLabel(plan) {
return priceLabelFn(planWithSelectedHwidRenewal(plan), selectedMethod);
}
function hwidRenewalPriceLabel(plan = selectedPlan) {
const renewal = hwidRenewalFor(plan);
if (!renewal) return "";
return priceLabelFn(
{
price: renewal.price || 0,
stars_price: renewal.stars_price,
currency: renewal.currency || plan?.currency,
},
selectedMethod
);
}
function showHwidRenewalBlock() {
return hwidRenewalAvailableForMethod(selectedPlan);
}
function showHwidRenewalUnavailableNote() {
return Boolean(
subscription?.active &&
Number(subscription?.extra_hwid_devices || 0) > 0 &&
isSubscriptionPlan(selectedPlan) &&
!showHwidRenewalBlock()
);
}
function hwidRenewalCount(plan = selectedPlan) {
return Number(hwidRenewalFor(plan)?.device_count || subscription?.extra_hwid_devices || 0);
}
function hwidRenewalHint(plan = selectedPlan) {
const renewal = hwidRenewalFor(plan);
if (renewal?.valid_from_text && renewal?.valid_until_text) {
return t("wa_hwid_devices_renewal_checkbox_hint", {
from: renewal.valid_from_text,
to: renewal.valid_until_text,
});
}
return t("wa_hwid_devices_renewal_checkbox_hint_short");
}
function showHwidDesyncNotice() {
return Boolean(
subscription?.device_topup_renewal_available &&
subscription?.extra_hwid_devices_valid_until_text
);
}
function planKey(plan) {
return planKeyFn(plan);
}
@@ -197,10 +274,34 @@
<p>{subscriptionPurchaseDescription}</p>
</div>
{/if}
{#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0}
{#if showHwidRenewalBlock()}
<label class="hwid-renewal-option">
<Checkbox
checked={renewHwidDevices}
ariaLabel={t("wa_hwid_devices_renewal_checkbox_aria")}
onCheckedChange={(checked) => (renewHwidDevices = checked)}
/>
<span>
<strong>
{t("wa_hwid_devices_renewal_checkbox", {
count: hwidRenewalCount(),
price: hwidRenewalPriceLabel(),
})}
</strong>
<small>{hwidRenewalHint()}</small>
{#if showHwidDesyncNotice()}
<small class="hwid-renewal-warning">
{t("wa_hwid_devices_desync_notice", {
date: subscription.extra_hwid_devices_valid_until_text,
})}
</small>
{/if}
</span>
</label>
{:else if showHwidRenewalUnavailableNote()}
<div class="subscription-purchase-description">
<p>
{t("wa_hwid_devices_renewal_notice", {
{t("wa_hwid_devices_renewal_unavailable", {
count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text || "",
})}
@@ -243,7 +344,7 @@
disabled={!selectedPlan || !methods.length || payBusy}
>
{t("wa_pay")}
{selectedPlan ? priceLabel(selectedPlan) : ""}
{selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
@@ -261,10 +362,34 @@
<p>{subscriptionPurchaseDescription}</p>
</div>
{/if}
{#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0}
{#if showHwidRenewalBlock()}
<label class="hwid-renewal-option">
<Checkbox
checked={renewHwidDevices}
ariaLabel={t("wa_hwid_devices_renewal_checkbox_aria")}
onCheckedChange={(checked) => (renewHwidDevices = checked)}
/>
<span>
<strong>
{t("wa_hwid_devices_renewal_checkbox", {
count: hwidRenewalCount(),
price: hwidRenewalPriceLabel(),
})}
</strong>
<small>{hwidRenewalHint()}</small>
{#if showHwidDesyncNotice()}
<small class="hwid-renewal-warning">
{t("wa_hwid_devices_desync_notice", {
date: subscription.extra_hwid_devices_valid_until_text,
})}
</small>
{/if}
</span>
</label>
{:else if showHwidRenewalUnavailableNote()}
<div class="subscription-purchase-description">
<p>
{t("wa_hwid_devices_renewal_notice", {
{t("wa_hwid_devices_renewal_unavailable", {
count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text || "",
})}
@@ -310,7 +435,7 @@
disabled={!selectedPlan || !methods.length || payBusy}
>
{t("wa_pay")}
{selectedPlan ? priceLabel(selectedPlan) : ""}
{selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
<LockKeyhole size={17} />
</Button>
{/if}
+16 -16
View File
@@ -127,6 +127,19 @@
: "";
}
function deviceTopupPlanTitle(plan) {
return t("wa_hwid_devices_package", {
count: Number(plan?.device_count || plan?.months || 0),
});
}
function deviceTopupPlanHint(plan) {
if (plan?.valid_until_text) {
return t("wa_hwid_devices_active_until", { date: plan.valid_until_text });
}
return plan?.subtitle || deviceTopupOptions?.tariff_name || "";
}
function tariffChangeModalDescription() {
if (!changeOptions) return "";
return changeOptions?.current
@@ -369,16 +382,7 @@
{#if !deviceTopupOptions}
<DialogOptionsSkeleton label={t("wa_tariff_options_loading")} rows={3} />
{:else if deviceTopupOptions?.plans?.length}
{#if deviceTopupOptions?.renewal_available}
<div class="topup-carryover-note">
<p>
{t("wa_hwid_devices_renewal_offer", {
count: Number(deviceTopupOptions.renewal_recommended_count || 0),
date: deviceTopupOptions.extra_hwid_devices_valid_until_text || "",
})}
</p>
</div>
{:else if Number(deviceTopupOptions?.extra_hwid_devices || 0) > 0 && deviceTopupOptions?.extra_hwid_devices_valid_until_text}
{#if Number(deviceTopupOptions?.extra_hwid_devices || 0) > 0 && deviceTopupOptions?.extra_hwid_devices_valid_until_text}
<div class="topup-carryover-note">
<p>
{t("wa_hwid_devices_valid_until", {
@@ -397,12 +401,8 @@
onclick={() => (selectedDeviceTopupPlan = plan)}
>
<span class="option-row-main">
<strong
>{t("wa_hwid_devices_package", {
count: Number(plan.device_count || plan.months || 0),
})}</strong
>
<small>{plan.subtitle || deviceTopupOptions.tariff_name}</small>
<strong>{deviceTopupPlanTitle(plan)}</strong>
<small>{deviceTopupPlanHint(plan)}</small>
</span>
<span class="option-row-meta">
<em>{priceLabel(plan)}</em>