Files
cozsweet-frontend-nextjs/src/stores/payment/machine/order-flow.ts
T

310 lines
8.1 KiB
TypeScript

import type { DoneActorEvent, ErrorActorEvent } from "xstate";
import type {
CreatePaymentOrderResponse,
PaymentOrderStatusResponse,
} from "@/data/schemas/payment";
import { behaviorAnalytics } from "@/lib/analytics";
import {
getCurrentOrderPlan,
getSelectedPlan,
hasOrderPollingTimedOut,
PAYMENT_TIMEOUT_ERROR_MESSAGE,
toPaymentErrorMessage,
} from "../helper/order";
import {
catalogMachineSetup,
idleState,
loadingCachedPlansState,
loadingPlansState,
readyState,
refreshingPlansState,
} from "./catalog-flow";
import { createPaymentActorActionSetup } from "./setup";
const POLL_DELAY_MS = 4000;
const trackCreateOrderStartAction = catalogMachineSetup.createAction(
({ context }) => {
const plan = getSelectedPlan(context);
if (plan) behaviorAnalytics.createOrderStart(plan, context.payChannel);
},
);
const trackReturnedPollStartAction = catalogMachineSetup.createAction(
({ event }) => {
if (event.type === "PaymentReturned") {
behaviorAnalytics.statusPollStart(event.orderId);
}
},
);
const applyReturnedOrderAction = catalogMachineSetup.assign(({ event }) => {
if (event.type !== "PaymentReturned") return {};
return {
currentOrderId: event.orderId,
currentOrderPlanId: null,
payParams: null,
orderStatus: "pending",
orderPollingStartedAt: event.createdAt ?? Date.now(),
errorMessage: null,
};
});
const applyLaunchFailureAction = catalogMachineSetup.assign(({ event }) =>
event.type === "PaymentLaunchFailed"
? { errorMessage: event.errorMessage }
: {},
);
export const paymentMachineSetup = catalogMachineSetup.extend({
actions: {
trackCreateOrderStart: trackCreateOrderStartAction,
trackReturnedPollStart: trackReturnedPollStartAction,
applyReturnedOrder: applyReturnedOrderAction,
applyLaunchFailure: applyLaunchFailureAction,
},
});
const createOrderDoneSetup =
createPaymentActorActionSetup<
DoneActorEvent<CreatePaymentOrderResponse>
>();
const pollOrderDoneSetup =
createPaymentActorActionSetup<
DoneActorEvent<PaymentOrderStatusResponse>
>();
const orderErrorSetup = createPaymentActorActionSetup<ErrorActorEvent>();
const trackCreateOrderSuccessAction = createOrderDoneSetup.createAction(
({ context, event }) => {
const plan = getSelectedPlan(context);
if (plan) {
behaviorAnalytics.createOrderSuccess(
plan,
event.output.orderId,
context.payChannel,
);
}
behaviorAnalytics.statusPollStart(event.output.orderId, plan);
},
);
const applyCreateOrderSuccessAction = createOrderDoneSetup.assign(
({ context, event }) => ({
currentOrderId: event.output.orderId,
currentOrderPlanId: context.selectedPlanId,
payParams: event.output.payParams,
orderStatus: "pending",
orderPollingStartedAt: Date.now(),
errorMessage: null,
launchNonce: context.launchNonce + 1,
}),
);
const trackCreateOrderFailureAction = orderErrorSetup.createAction(
({ context }) => {
const plan = getSelectedPlan(context);
if (plan) behaviorAnalytics.createOrderFailed(plan, context.payChannel);
},
);
const applyOrderErrorAction = orderErrorSetup.assign(({ event }) => ({
errorMessage: toPaymentErrorMessage(event.error),
}));
const trackPollUpdateAction = pollOrderDoneSetup.createAction(
({ context, event }) =>
behaviorAnalytics.statusPollUpdate(
context.currentOrderId ?? "",
event.output.status,
getCurrentOrderPlan(context),
),
);
const trackPollTimeoutAction = pollOrderDoneSetup.createAction(
({ context, event }) => {
const orderId = context.currentOrderId ?? "";
const plan = getCurrentOrderPlan(context);
behaviorAnalytics.statusPollUpdate(orderId, event.output.status, plan);
behaviorAnalytics.statusPollTimeout(orderId, plan);
},
);
const applyPaidOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: null,
}));
const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: "Payment failed or was cancelled.",
}));
const applyTimedOutOrderAction = pollOrderDoneSetup.assign({
orderStatus: "failed",
orderPollingStartedAt: null,
errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE,
});
const applyPendingOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
orderStatus: event.output.status,
errorMessage: null,
}));
const creatingOrderState = paymentMachineSetup.createStateConfig({
entry: "trackCreateOrderStart",
invoke: {
src: "createOrder",
input: ({ context, event }) => ({
planId: context.selectedPlanId,
payChannel: context.payChannel,
autoRenew: context.autoRenew,
...(event.type === "PaymentCreateOrderSubmitted" &&
event.recipientCharacterId
? { recipientCharacterId: event.recipientCharacterId }
: {}),
}),
onDone: {
target: "pollingOrder",
actions: [
trackCreateOrderSuccessAction,
applyCreateOrderSuccessAction,
],
},
onError: {
target: "failed",
actions: [trackCreateOrderFailureAction, applyOrderErrorAction],
},
},
});
const pollingOrderState = paymentMachineSetup.createStateConfig({
invoke: {
src: "pollOrderStatus",
input: ({ context }) => ({ orderId: context.currentOrderId ?? "" }),
onDone: [
{
guard: ({ event }) => event.output.status === "paid",
target: "paid",
actions: [trackPollUpdateAction, applyPaidOrderAction],
},
{
guard: ({ event }) => event.output.status === "failed",
target: "failed",
actions: [trackPollUpdateAction, applyFailedOrderAction],
},
{
guard: ({ context }) =>
hasOrderPollingTimedOut(context.orderPollingStartedAt),
target: "failed",
actions: [trackPollTimeoutAction, applyTimedOutOrderAction],
},
{
target: "waitingForPayment",
actions: [trackPollUpdateAction, applyPendingOrderAction],
},
],
onError: {
target: "failed",
actions: applyOrderErrorAction,
},
},
on: {
PaymentReset: {
target: "ready",
actions: "resetOrder",
},
},
});
const waitingForPaymentState = paymentMachineSetup.createStateConfig({
after: {
[POLL_DELAY_MS]: "pollingOrder",
},
on: {
PaymentReset: {
target: "ready",
actions: "resetOrder",
},
},
});
const paidState = paymentMachineSetup.createStateConfig({
on: {
PaymentPlanSelected: {
target: "ready",
actions: "selectPlan",
},
PaymentPayChannelChanged: {
target: "ready",
actions: "changePayChannel",
},
PaymentCreateOrderSubmitted: [
{
guard: "canCreateOrder",
target: "creatingOrder",
actions: "resetOrder",
},
{
target: "ready",
actions: "showCreateOrderValidationError",
},
],
PaymentReset: {
target: "ready",
actions: "resetOrder",
},
},
});
const failedState = paymentMachineSetup.createStateConfig({
on: {
PaymentCreateOrderSubmitted: [
{
guard: "canCreateOrder",
target: "creatingOrder",
actions: "resetOrder",
},
],
PaymentErrorCleared: {
target: "ready",
actions: "clearPaymentError",
},
PaymentReset: {
target: "ready",
actions: "resetOrder",
},
},
});
export const paymentRootStateConfig = paymentMachineSetup.createStateConfig({
initial: "idle",
states: {
idle: idleState,
loadingCachedPlans: loadingCachedPlansState,
loadingPlans: loadingPlansState,
refreshingPlans: refreshingPlansState,
ready: readyState,
creatingOrder: creatingOrderState,
pollingOrder: pollingOrderState,
waitingForPayment: waitingForPaymentState,
paid: paidState,
failed: failedState,
},
on: {
PaymentFirstRechargeConsumed: { actions: "consumeFirstRecharge" },
PaymentReturned: {
target: ".pollingOrder",
actions: ["trackReturnedPollStart", "applyReturnedOrder"],
},
PaymentLaunchFailed: {
target: ".failed",
actions: "applyLaunchFailure",
},
},
});