feat(payment): support user-bound seven-discount offers
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -195,6 +195,9 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
|
||||
event.recipientCharacterId
|
||||
? { recipientCharacterId: event.recipientCharacterId }
|
||||
: {}),
|
||||
...(context.commercialOfferId
|
||||
? { commercialOfferId: context.commercialOfferId }
|
||||
: {}),
|
||||
}),
|
||||
onDone: {
|
||||
target: "pollingOrder",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user