diff --git a/e2e/specs/mock/payment/indonesia-qris.spec.ts b/e2e/specs/mock/payment/indonesia-qris.spec.ts index f2571bbc..f4247b3c 100644 --- a/e2e/specs/mock/payment/indonesia-qris.spec.ts +++ b/e2e/specs/mock/payment/indonesia-qris.spec.ts @@ -85,11 +85,14 @@ async function registerIndonesiaPaymentMocks(page: Page) { }); await page.route("**/api/payment/create-order", async (route) => { createOrderCount += 1; - const body = route.request().postDataJSON() as { planId: string }; + const body = route.request().postDataJSON() as { + planId: string; + payChannel: "ezpay" | "stripe"; + }; const plan = idrPlans.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"); orderPlans.set(orderId, { planId: body.planId, @@ -98,15 +101,21 @@ async function registerIndonesiaPaymentMocks(page: Page) { await route.fulfill({ json: apiEnvelope({ orderId, - payParams: { - provider: "ezpay", - countryCode: "ID", - channelCode: "ID_QRIS_DYNAMIC_QR", - channelType: "QR", - payData: "00020101021226670016COM.NOBUBANK.WWW", - firstChargeAmountCents: plan?.amountCents ?? 0, - currency: "IDR", - }, + payParams: + body.payChannel === "stripe" + ? { + provider: "stripe", + clientSecret: "pi_e2e_secret_mock", + } + : { + provider: "ezpay", + countryCode: "ID", + channelCode: "ID_QRIS_DYNAMIC_QR", + channelType: "QR", + payData: "00020101021226670016COM.NOBUBANK.WWW", + firstChargeAmountCents: plan?.amountCents ?? 0, + currency: "IDR", + }, }), }); }); @@ -183,7 +192,7 @@ async function expectQrisOrder( await expect( dialog.getByRole("img", { name: "QRIS payment QR code" }), ).toBeVisible(); - return `order_qris_${planId}`; + return `order_ezpay_${planId}`; } 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 }); }); +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 ({ page, }) => { diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 06723252..3da7a059 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -286,7 +286,7 @@ export function SubscriptionScreen({ { actor.stop(); }); + it("can abandon a pending QRIS order and switch to Stripe", async () => { + const createOrderSpy = vi.fn(); + 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 () => { const actor = createActor( createTestPaymentMachine({ orderStatus: "failed" }), diff --git a/src/stores/payment/machine/order-flow.ts b/src/stores/payment/machine/order-flow.ts index 4b2efa25..ae4a0317 100644 --- a/src/stores/payment/machine/order-flow.ts +++ b/src/stores/payment/machine/order-flow.ts @@ -265,6 +265,10 @@ const pollingOrderState = paymentMachineSetup.createStateConfig({ }, }, on: { + PaymentPayChannelChanged: { + target: "ready", + actions: "changePayChannel", + }, PaymentReset: { target: "ready", actions: "resetOrder", @@ -277,6 +281,10 @@ const waitingForPaymentState = paymentMachineSetup.createStateConfig({ [POLL_DELAY_MS]: "pollingOrder", }, on: { + PaymentPayChannelChanged: { + target: "ready", + actions: "changePayChannel", + }, PaymentReset: { target: "ready", actions: "resetOrder",