feat(payment): support user-bound seven-discount offers

This commit is contained in:
Codex
2026-07-23 16:11:20 +08:00
parent 05f625dd0b
commit 46588bd98c
39 changed files with 418 additions and 28 deletions
@@ -19,6 +19,7 @@ import type { PaymentPlanCatalog } from "@/stores/payment/payment-state";
export interface PaymentPlansActorInput {
catalog: PaymentPlanCatalog;
commercialOfferId?: string | null;
}
export interface CreateOrderInput {
@@ -26,6 +27,7 @@ export interface CreateOrderInput {
payChannel: PayChannel;
autoRenew: boolean;
recipientCharacterId?: string;
commercialOfferId?: string;
}
export type CreateOrderSpy = (input: CreateOrderInput) => void;
@@ -81,6 +81,30 @@ describe("payment order flow", () => {
actor.stop();
});
it("carries the server-issued commercial offer into order creation", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
).start();
actor.send({
type: "PaymentInit",
planId: "vip_monthly",
commercialOfferId: "offer-user-bound-1",
});
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
expect(createOrderSpy).toHaveBeenCalledWith({
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
commercialOfferId: "offer-user-bound-1",
});
actor.stop();
});
it("loads a stable Tip message after payment and clears it on reset", async () => {
const loadTipMessage = vi.fn();
const actor = createActor(
+4 -2
View File
@@ -52,7 +52,7 @@ export function hydratePlansResponseState(
selectedPlanId: string,
): Pick<
PaymentState,
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
"plans" | "isFirstRecharge" | "commercialOffer" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const plans = normalizeFirstRechargePlans(
response.plans,
@@ -61,6 +61,7 @@ export function hydratePlansResponseState(
return {
...hydratePlansState(plans, selectedPlanId),
isFirstRecharge: response.isFirstRecharge,
commercialOffer: response.commercialOffer,
};
}
@@ -89,7 +90,7 @@ export function refreshPlansResponseState(
selectedPlanId: string,
): Pick<
PaymentState,
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
"plans" | "isFirstRecharge" | "commercialOffer" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const plans = normalizeFirstRechargePlans(
response.plans,
@@ -98,6 +99,7 @@ export function refreshPlansResponseState(
return {
...refreshPlansState(plans, selectedPlanId),
isFirstRecharge: response.isFirstRecharge,
commercialOffer: response.commercialOffer,
};
}
@@ -15,6 +15,7 @@ export const createPaymentOrderActor = fromPromise<
payChannel: PayChannel;
autoRenew: boolean;
recipientCharacterId?: string;
commercialOfferId?: string;
}
>(async ({ input }) => {
const result = await getPaymentRepository().createOrder(
@@ -22,6 +23,7 @@ export const createPaymentOrderActor = fromPromise<
input.payChannel,
input.autoRenew,
input.recipientCharacterId,
input.commercialOfferId,
);
if (Result.isErr(result)) throw result.error;
return result.data;
+4 -4
View File
@@ -8,9 +8,9 @@ import type { PaymentPlanCatalog } from "../../payment-state";
export const loadCachedPaymentPlansActor = fromPromise<
PaymentPlansResponse | null,
{ catalog: PaymentPlanCatalog }
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null }
>(async ({ input }) => {
if (input.catalog === "tip") return null;
if (input.catalog === "tip" || input.commercialOfferId) return null;
const result = await getPaymentRepository().getCachedPlans();
if (Result.isErr(result)) throw result.error;
return result.data;
@@ -18,13 +18,13 @@ export const loadCachedPaymentPlansActor = fromPromise<
export const refreshPaymentPlansActor = fromPromise<
PaymentPlansResponse,
{ catalog: PaymentPlanCatalog }
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null }
>(async ({ input }) => {
const paymentRepo = getPaymentRepository();
if (input.catalog === "tip") {
throw new Error("Gift catalogs must use the gift products actor.");
}
const result = await paymentRepo.getPlans();
const result = await paymentRepo.getPlans(input.commercialOfferId ?? undefined);
if (Result.isErr(result)) throw result.error;
return result.data;
});
+20 -5
View File
@@ -35,24 +35,30 @@ function initializeCatalogState(
planCatalog === "tip" &&
((event.category ?? null) !== context.selectedGiftCategory ||
(event.planId ?? null) !== (context.selectedPlanId || null));
const commercialOfferId =
planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
const offerChanged = commercialOfferId !== context.commercialOfferId;
return {
planCatalog,
...(event.payChannel ? { payChannel: event.payChannel } : {}),
giftCharacterId,
commercialOfferId,
requestedGiftCategory:
planCatalog === "tip" ? (event.category ?? null) : null,
requestedGiftPlanId:
planCatalog === "tip" ? (event.planId ?? null) : null,
...(catalogChanged || characterChanged || selectionChanged
...(catalogChanged || characterChanged || selectionChanged || offerChanged
? {
...resetOrderState(),
plans: [],
giftCategories: [],
giftProducts: [],
selectedGiftCategory: null,
selectedPlanId: "",
selectedPlanId:
planCatalog === "default" ? (event.planId ?? "") : "",
isFirstRecharge: false,
commercialOffer: null,
autoRenew: planCatalog !== "tip",
}
: {}),
@@ -207,7 +213,10 @@ export const loadingCachedPlansState =
catalogMachineSetup.createStateConfig({
invoke: {
src: "loadCachedPlans",
input: ({ context }) => ({ catalog: context.planCatalog }),
input: ({ context }) => ({
catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId,
}),
onDone: [
{
guard: ({ event }) => (event.output?.plans.length ?? 0) > 0,
@@ -223,7 +232,10 @@ export const loadingCachedPlansState =
export const loadingPlansState = catalogMachineSetup.createStateConfig({
invoke: {
src: "refreshPlans",
input: ({ context }) => ({ catalog: context.planCatalog }),
input: ({ context }) => ({
catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId,
}),
onDone: {
target: "ready",
actions: hydratePlansAction,
@@ -238,7 +250,10 @@ export const loadingPlansState = catalogMachineSetup.createStateConfig({
export const refreshingPlansState = catalogMachineSetup.createStateConfig({
invoke: {
src: "refreshPlans",
input: ({ context }) => ({ catalog: context.planCatalog }),
input: ({ context }) => ({
catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId,
}),
onDone: {
target: "ready",
actions: refreshPlansAction,
+3
View File
@@ -195,6 +195,9 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
event.recipientCharacterId
? { recipientCharacterId: event.recipientCharacterId }
: {}),
...(context.commercialOfferId
? { commercialOfferId: context.commercialOfferId }
: {}),
}),
onDone: {
target: "pollingOrder",
+2
View File
@@ -19,6 +19,7 @@ export interface PaymentContextState {
giftProducts: MachineContext["giftProducts"];
selectedGiftCategory: string | null;
isFirstRecharge: MachineContext["isFirstRecharge"];
commercialOffer: MachineContext["commercialOffer"];
selectedPlanId: string;
payChannel: MachineContext["payChannel"];
autoRenew: boolean;
@@ -74,6 +75,7 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
giftProducts: state.context.giftProducts,
selectedGiftCategory: state.context.selectedGiftCategory,
isFirstRecharge: state.context.isFirstRecharge,
commercialOffer: state.context.commercialOffer,
selectedPlanId: state.context.selectedPlanId,
payChannel: state.context.payChannel,
autoRenew: state.context.autoRenew,
+1
View File
@@ -10,6 +10,7 @@ export type PaymentEvent =
characterId?: string;
category?: string | null;
planId?: string | null;
commercialOfferId?: string | null;
}
| { type: "PaymentPlanSelected"; planId: string }
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
+5
View File
@@ -5,6 +5,7 @@ import type {
PayChannel,
PaymentOrderStatus,
PaymentPlan,
CommercialOfferSummary,
TipMessageResponse,
} from "@/data/schemas/payment";
@@ -20,6 +21,8 @@ export interface PaymentState {
requestedGiftCategory: string | null;
requestedGiftPlanId: string | null;
isFirstRecharge: boolean;
commercialOfferId: string | null;
commercialOffer: CommercialOfferSummary | null;
selectedPlanId: string;
payChannel: PayChannel;
autoRenew: boolean;
@@ -45,6 +48,8 @@ export const initialState: PaymentState = {
requestedGiftCategory: null,
requestedGiftPlanId: null,
isFirstRecharge: false,
commercialOfferId: null,
commercialOffer: null,
selectedPlanId: "",
payChannel: "stripe",
autoRenew: true,