fix(payment): allow Stripe after closing QRIS

This commit is contained in:
Codex
2026-07-29 14:31:49 +08:00
parent 638fc054b1
commit ce3f0af82d
5 changed files with 103 additions and 14 deletions
+54 -4
View File
@@ -85,11 +85,14 @@ async function registerIndonesiaPaymentMocks(page: Page) {
}); });
await page.route("**/api/payment/create-order", async (route) => { await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1; createOrderCount += 1;
const body = route.request().postDataJSON() as { planId: string }; const body = route.request().postDataJSON() as {
planId: string;
payChannel: "ezpay" | "stripe";
};
const plan = const plan =
idrPlans.plans.find((item) => item.planId === body.planId) ?? idrPlans.plans.find((item) => item.planId === body.planId) ??
idrGiftCatalog.plans.find((item) => item.planId === body.planId); idrGiftCatalog.plans.find((item) => item.planId === body.planId);
const orderId = `order_qris_${body.planId}`; const orderId = `order_${body.payChannel}_${body.planId}`;
orderStatuses.set(orderId, "pending"); orderStatuses.set(orderId, "pending");
orderPlans.set(orderId, { orderPlans.set(orderId, {
planId: body.planId, planId: body.planId,
@@ -98,7 +101,13 @@ async function registerIndonesiaPaymentMocks(page: Page) {
await route.fulfill({ await route.fulfill({
json: apiEnvelope({ json: apiEnvelope({
orderId, orderId,
payParams: { payParams:
body.payChannel === "stripe"
? {
provider: "stripe",
clientSecret: "pi_e2e_secret_mock",
}
: {
provider: "ezpay", provider: "ezpay",
countryCode: "ID", countryCode: "ID",
channelCode: "ID_QRIS_DYNAMIC_QR", channelCode: "ID_QRIS_DYNAMIC_QR",
@@ -183,7 +192,7 @@ async function expectQrisOrder(
await expect( await expect(
dialog.getByRole("img", { name: "QRIS payment QR code" }), dialog.getByRole("img", { name: "QRIS payment QR code" }),
).toBeVisible(); ).toBeVisible();
return `order_qris_${planId}`; return `order_ezpay_${planId}`;
} }
async function expectCheckoutButtonLayout(page: Page) { async function expectCheckoutButtonLayout(page: Page) {
@@ -299,6 +308,47 @@ test("closing QRIS restores a resumable checkout without creating a duplicate or
).toBeVisible({ timeout: 10_000 }); ).toBeVisible({ timeout: 10_000 });
}); });
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
/Rp\s*88[.,]900/,
);
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const stripeButton = page.getByRole("button", {
name: "Stripe",
exact: true,
});
await expect(stripeButton).toBeEnabled();
await stripeButton.click();
await expect(stripeButton).toHaveAttribute("aria-pressed", "true");
await expect(
page.getByRole("button", { name: "Resume QRIS payment" }),
).toHaveCount(0);
const stripeRequestPromise = page.waitForRequest(
"**/api/payment/create-order",
);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const stripeRequest = await stripeRequestPromise;
expect(stripeRequest.postDataJSON()).toMatchObject({
planId: "dol_1000",
payChannel: "stripe",
recipientCharacterId: "elio",
});
expect(payment.getCreateOrderCount()).toBe(2);
});
test("payment issue submits Other to the feedback API without creating an order", async ({ test("payment issue submits Other to the feedback API without creating an order", async ({
page, page,
}) => { }) => {
+1 -1
View File
@@ -286,7 +286,7 @@ export function SubscriptionScreen({
<PaymentMethodSelector <PaymentMethodSelector
config={renderedPaymentMethodConfig} config={renderedPaymentMethodConfig}
value={payment.payChannel} value={payment.payChannel}
disabled={isPaymentBusy} disabled={payment.isCreatingOrder}
caption={ caption={
paymentMethodConfig.ezpayDisplayName === "QRIS" paymentMethodConfig.ezpayDisplayName === "QRIS"
? "QRIS by default in Indonesia" ? "QRIS by default in Indonesia"
+1 -1
View File
@@ -292,7 +292,7 @@ export function TipScreen({
config={renderedPaymentMethodConfig} config={renderedPaymentMethodConfig}
value={payment.payChannel} value={payment.payChannel}
density="compact" density="compact"
disabled={isPaymentBusy} disabled={payment.isCreatingOrder}
className={styles.paymentMethodSlot} className={styles.paymentMethodSlot}
analyticsKey="tip.payment_method" analyticsKey="tip.payment_method"
onChange={handlePaymentMethodChange} onChange={handlePaymentMethodChange}
@@ -301,6 +301,37 @@ describe("payment order flow", () => {
actor.stop(); actor.stop();
}); });
it("can abandon a pending QRIS order and switch to Stripe", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy, orderStatus: "pending" }),
).start();
await initialize(actor);
actor.send({ type: "PaymentPayChannelChanged", payChannel: "ezpay" });
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
actor.send({ type: "PaymentPayChannelChanged", payChannel: "stripe" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context).toMatchObject({
payChannel: "stripe",
currentOrderId: null,
payParams: null,
orderStatus: null,
orderPollingStartedAt: null,
});
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
expect(createOrderSpy).toHaveBeenNthCalledWith(2, {
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
});
actor.stop();
});
it("moves to failed when polling reports failure", async () => { it("moves to failed when polling reports failure", async () => {
const actor = createActor( const actor = createActor(
createTestPaymentMachine({ orderStatus: "failed" }), createTestPaymentMachine({ orderStatus: "failed" }),
+8
View File
@@ -265,6 +265,10 @@ const pollingOrderState = paymentMachineSetup.createStateConfig({
}, },
}, },
on: { on: {
PaymentPayChannelChanged: {
target: "ready",
actions: "changePayChannel",
},
PaymentReset: { PaymentReset: {
target: "ready", target: "ready",
actions: "resetOrder", actions: "resetOrder",
@@ -277,6 +281,10 @@ const waitingForPaymentState = paymentMachineSetup.createStateConfig({
[POLL_DELAY_MS]: "pollingOrder", [POLL_DELAY_MS]: "pollingOrder",
}, },
on: { on: {
PaymentPayChannelChanged: {
target: "ready",
actions: "changePayChannel",
},
PaymentReset: { PaymentReset: {
target: "ready", target: "ready",
actions: "resetOrder", actions: "resetOrder",