fix(payment): allow renewing active subscriptions

This commit is contained in:
2026-06-22 18:48:21 +08:00
parent 2cfbaba058
commit 9a3c8f0d12
3 changed files with 92 additions and 35 deletions
@@ -11,13 +11,14 @@ import {
usePaymentDispatch, usePaymentDispatch,
usePaymentState, usePaymentState,
} from "@/stores/payment/payment-context"; } from "@/stores/payment/payment-context";
import { AppEnvUtil } from "@/utils"; import { AppEnvUtil, Logger, Result } from "@/utils";
import { StripePaymentDialog } from "./stripe-payment-dialog"; import { StripePaymentDialog } from "./stripe-payment-dialog";
import { SubscriptionCtaButton } from "./subscription-cta-button"; import { SubscriptionCtaButton } from "./subscription-cta-button";
import styles from "./subscription-cta-button.module.css"; import styles from "./subscription-cta-button.module.css";
const EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS = 5000; const EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS = 5000;
const log = new Logger("SubscriptionCheckoutButton");
export interface SubscriptionCheckoutButtonProps { export interface SubscriptionCheckoutButtonProps {
/** 是否可用(未选套餐 / 未勾选协议 → false) */ /** 是否可用(未选套餐 / 未勾选协议 → false) */
@@ -107,9 +108,7 @@ export function SubscriptionCheckoutButton({
payment.isCreatingOrder || payment.isCreatingOrder ||
payment.isPollingOrder || payment.isPollingOrder ||
payment.isCheckingVipStatus; payment.isCheckingVipStatus;
const label = payment.isPaid const label = payment.isPollingOrder
? "Activated"
: payment.isPollingOrder
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
? "Creating order..." ? "Creating order..."
@@ -246,23 +245,64 @@ async function redirectToEzpay({
delayMs = 0, delayMs = 0,
setTimeoutId, setTimeoutId,
}: RedirectToEzpayInput): Promise<void> { }: RedirectToEzpayInput): Promise<void> {
log.debug("[subscription-checkout] redirectToEzpay START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
delayMs,
paymentUrl,
});
if (orderId) { if (orderId) {
await PendingPaymentOrderStorage.setPendingOrder({ const saveResult = await PendingPaymentOrderStorage.setPendingOrder({
orderId, orderId,
payChannel: "ezpay", payChannel: "ezpay",
subscriptionType, subscriptionType,
createdAt: Date.now(), createdAt: Date.now(),
}); });
if (Result.isOk(saveResult)) {
log.debug("[subscription-checkout] pending ezpay order saved", {
orderId,
subscriptionType,
});
} else {
log.error("[subscription-checkout] pending ezpay order save failed", {
orderId,
subscriptionType,
error: saveResult.error,
});
}
} else {
log.warn("[subscription-checkout] skip pending ezpay order save: missing orderId", {
subscriptionType,
paymentUrl,
});
} }
if (delayMs > 0) { if (delayMs > 0) {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
log.debug("[subscription-checkout] redirectToEzpay DELAY elapsed", {
orderId,
subscriptionType,
paymentUrl,
});
window.location.href = paymentUrl; window.location.href = paymentUrl;
}, delayMs); }, delayMs);
setTimeoutId?.(timeoutId); setTimeoutId?.(timeoutId);
log.debug("[subscription-checkout] redirectToEzpay DELAY scheduled", {
orderId,
subscriptionType,
delayMs,
timeoutId,
});
return; return;
} }
log.debug("[subscription-checkout] redirectToEzpay NOW", {
orderId,
subscriptionType,
paymentUrl,
});
window.location.href = paymentUrl; window.location.href = paymentUrl;
} }
+16 -30
View File
@@ -32,7 +32,6 @@ import {
SubscriptionBanner, SubscriptionBanner,
SubscriptionBenefitsCard, SubscriptionBenefitsCard,
SubscriptionCheckoutButton, SubscriptionCheckoutButton,
SubscriptionCtaButton,
SubscriptionPaymentMethod, SubscriptionPaymentMethod,
SubscriptionPlanCard, SubscriptionPlanCard,
SubscriptionUserRow, SubscriptionUserRow,
@@ -213,11 +212,6 @@ export function SubscriptionScreen({
payment.isCheckingVipStatus; payment.isCheckingVipStatus;
const canActivate = const canActivate =
selectedPlan !== null && payment.agreed && !isPaymentBusy; selectedPlan !== null && payment.agreed && !isPaymentBusy;
const isVip =
subscriptionType === "vip" &&
(user.currentUser?.isVip === true ||
payment.vipStatus?.isVip === true ||
payment.isPaid);
const name = user.currentUser?.username ?? FALLBACK_USERNAME; const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.currentUser?.avatarUrl ?? null; const avatarUrl = user.currentUser?.avatarUrl ?? null;
@@ -312,32 +306,24 @@ export function SubscriptionScreen({
)} )}
</section> </section>
{!isVip ? ( <section className={styles.paymentMethodSlot}>
<section className={styles.paymentMethodSlot}> <SubscriptionPaymentMethod
<SubscriptionPaymentMethod value={payment.payChannel}
value={payment.payChannel} disabled={isPaymentBusy}
disabled={isPaymentBusy} onChange={(payChannel) =>
onChange={(payChannel) => paymentDispatch({
paymentDispatch({ type: "PaymentPayChannelChanged",
type: "PaymentPayChannelChanged", payChannel,
payChannel, })
}) }
} />
/> </section>
</section>
) : null}
<div className={styles.ctaSlot}> <div className={styles.ctaSlot}>
{isVip ? ( <SubscriptionCheckoutButton
<SubscriptionCtaButton type="button" disabled> disabled={!canActivate}
VIP Activated subscriptionType={subscriptionType}
</SubscriptionCtaButton> />
) : (
<SubscriptionCheckoutButton
disabled={!canActivate}
subscriptionType={subscriptionType}
/>
)}
</div> </div>
<div className={styles.agreementSlot}> <div className={styles.agreementSlot}>
+31
View File
@@ -270,6 +270,37 @@ export const paymentMachine = setup({
paid: { paid: {
on: { on: {
PaymentPlanSelected: {
target: "ready",
actions: assign(({ event }) => ({
...resetOrderState(),
selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId),
})),
},
PaymentPayChannelChanged: {
target: "ready",
actions: assign(({ event }) => ({
...resetOrderState(),
payChannel: event.payChannel,
errorMessage: null,
})),
},
PaymentCreateOrderSubmitted: [
{
guard: ({ context }) =>
context.agreed && context.selectedPlanId.length > 0,
target: "creatingOrder",
actions: assign(resetOrderState),
},
{
target: "ready",
actions: assign({
errorMessage:
"Choose a plan and accept the agreement before continuing.",
}),
},
],
PaymentReset: { PaymentReset: {
target: "ready", target: "ready",
actions: assign(resetOrderState), actions: assign(resetOrderState),