import { expect, test, type Page } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { apiEnvelope } from "@e2e/fixtures/data/common"; import { e2eEmailUser } from "@e2e/fixtures/data/user"; import { clearBrowserState, seedEmailSession, } from "@e2e/fixtures/test-helpers"; const idrPlans = { plans: [ { planId: "vip_monthly", planName: "Monthly", orderType: "vip_monthly", amountCents: 19_590_000, originalAmountCents: 19_590_000, dailyPriceCents: 653_000, currency: "IDR", vipDays: 30, dolAmount: null, creditBalance: 3_000, }, { planId: "dol_1000", planName: "1,000 Credits", orderType: "dol", amountCents: 8_890_000, originalAmountCents: null, dailyPriceCents: null, currency: "IDR", vipDays: null, dolAmount: 1_000, creditBalance: 1_000, }, ], }; const idrGiftCatalog = { characterId: "elio", categories: [ { category: "coffee", name: "Coffee", productCount: 1, imageUrl: null, }, ], plans: [ { planId: "tip_coffee_usd_4_99", planName: "Velvet Espresso", orderType: "tip", tipType: "coffee_small", category: "coffee", characterId: "elio", description: "Buy Elio a Velvet Espresso", imageUrl: null, amountCents: 8_929_900, currency: "IDR", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0, promotionType: null, }, ], }; async function registerIndonesiaPaymentMocks(page: Page) { const orderStatuses = new Map(); const orderPlans = new Map(); let createOrderCount = 0; await page.route("**/api/user/profile", async (route) => { await route.fulfill({ json: apiEnvelope({ ...e2eEmailUser, countryCode: "ID" }), }); }); await page.route("**/api/payment/plans**", async (route) => { await route.fulfill({ json: apiEnvelope(idrPlans) }); }); await page.route("**/api/payment/gift-products**", async (route) => { await route.fulfill({ json: apiEnvelope(idrGiftCatalog) }); }); await page.route("**/api/payment/create-order", async (route) => { createOrderCount += 1; 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_${body.payChannel}_${body.planId}`; orderStatuses.set(orderId, "pending"); orderPlans.set(orderId, { planId: body.planId, orderType: plan?.orderType ?? "dol", }); await route.fulfill({ json: apiEnvelope({ orderId, 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", }, }), }); }); await page.route("**/api/payment/order-status**", async (route) => { const url = new URL(route.request().url()); const orderId = url.searchParams.get("order_id") ?? ""; const plan = orderPlans.get(orderId); await route.fulfill({ json: apiEnvelope({ orderId, status: orderStatuses.get(orderId) ?? "pending", orderType: plan?.orderType ?? "dol", planId: plan?.planId ?? null, creditsAdded: 0, }), }); }); await page.route("**/api/payment/tip-message", async (route) => { const body = route.request().postDataJSON() as { orderId: string }; await route.fulfill({ json: apiEnvelope({ orderId: body.orderId, characterId: "elio", planId: "tip_coffee_usd_4_99", productName: "Velvet Espresso", tipCount: 1, poolIndex: 0, message: "Thank you. Your gift made me smile.", }), }); }); return { getCreateOrderCount() { return createOrderCount; }, markPaid(orderId: string) { orderStatuses.set(orderId, "paid"); }, }; } async function prepareIndonesiaUser(page: Page) { await seedEmailSession(page); await page.evaluate(() => { const rawUser = localStorage.getItem("cozsweet:user"); const user = rawUser ? JSON.parse(rawUser) : {}; localStorage.setItem( "cozsweet:user", JSON.stringify({ ...user, countryCode: "ID" }), ); }); } async function expectQrisOrder( page: Page, buttonName: RegExp, planId: string, formattedAmount: RegExp, ) { const requestPromise = page.waitForRequest("**/api/payment/create-order"); await page.getByRole("button", { name: buttonName }).click(); const request = await requestPromise; expect(request.postDataJSON()).toMatchObject({ planId, payChannel: "ezpay", recipientCharacterId: "elio", }); const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" }); await expect(dialog).toBeVisible(); await expect(dialog).toContainText(formattedAmount); await expect(dialog).toContainText("Waiting for payment"); await expect( dialog.getByRole("img", { name: "QRIS payment QR code" }), ).toBeVisible(); return `order_ezpay_${planId}`; } async function expectCheckoutButtonLayout(page: Page) { const viewport = page.viewportSize(); const checkoutBox = await page .getByRole("button", { name: "Pay and Top Up" }) .boundingBox(); const stripeBox = await page.getByRole("button", { name: "Stripe" }).boundingBox(); expect(viewport).not.toBeNull(); expect(checkoutBox).not.toBeNull(); expect(stripeBox).not.toBeNull(); if (!viewport || !checkoutBox || !stripeBox) return; expect(checkoutBox.y + checkoutBox.height).toBeLessThanOrEqual(viewport.height); expect(stripeBox.y + stripeBox.height).toBeLessThanOrEqual(checkoutBox.y); } test.beforeEach(async ({ baseURL, context, page }) => { await clearBrowserState(context, page, baseURL); await mockCoreApis(page); }); test("Indonesia VIP defaults to QRIS and completes after status polling", async ({ page, }) => { const payment = await registerIndonesiaPaymentMocks(page); await prepareIndonesiaUser(page); await page.goto("/subscription?type=vip&character=elio"); await expect(page.getByText("Choose who you want to support")).toHaveCount(0); await expect(page.getByText("Supporting Elio")).toHaveCount(0); await expect( page.getByRole("button", { name: "Elio", exact: true }), ).toHaveCount(0); await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute( "aria-pressed", "true", ); const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" }); await expect(checkoutButton).toBeDisabled(); await expectCheckoutButtonLayout(page); await page.getByRole("button", { name: /Monthly, 195900 IDR/i }).click(); const renewalDialog = page.getByRole("dialog", { name: "Automatic Renewal Confirmation", }); await expect(renewalDialog).toBeVisible(); expect(payment.getCreateOrderCount()).toBe(0); await renewalDialog.getByRole("button", { name: "Confirm" }).click(); await expect(renewalDialog).toBeHidden(); await expect(checkoutButton).toBeEnabled(); expect(payment.getCreateOrderCount()).toBe(0); const orderId = await expectQrisOrder( page, /Pay and Top Up/i, "vip_monthly", /Rp\s*195[.,]900/, ); expect(payment.getCreateOrderCount()).toBe(1); payment.markPaid(orderId); const success = page.getByRole("alertdialog", { name: "Payment successful" }); await expect(success).toBeVisible({ timeout: 10_000 }); await expect( page.getByRole("dialog", { name: "Scan to pay with QRIS" }), ).toBeHidden(); }); test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => { const payment = await registerIndonesiaPaymentMocks(page); await prepareIndonesiaUser(page); await page.goto("/subscription?type=topup&character=elio"); await expect(page.getByText("Supporting Elio")).toHaveCount(0); await expectCheckoutButtonLayout(page); const orderId = await expectQrisOrder( page, /Pay and Top Up/i, "dol_1000", /Rp\s*88[.,]900/, ); payment.markPaid(orderId); await expect( page.getByRole("alertdialog", { name: "Payment successful" }), ).toBeVisible({ timeout: 10_000 }); }); test("closing QRIS restores payment selection and allows switching to Stripe", 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(); await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled(); await page.getByRole("button", { name: "Stripe" }).click(); await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute( "aria-pressed", "true", ); await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled(); expect(payment.getCreateOrderCount()).toBe(1); }); 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, }) => { const payment = await registerIndonesiaPaymentMocks(page); await prepareIndonesiaUser(page); let feedbackBody = ""; let idempotencyKey = ""; await page.route("**/api/feedback", async (route) => { feedbackBody = route.request().postData() ?? ""; idempotencyKey = route.request().headers()["idempotency-key"] ?? ""; await route.fulfill({ json: apiEnvelope({ feedbackId: "feedback-payment-1", duplicate: false }), }); }); await page.goto("/subscription?type=topup&character=elio"); await page.getByRole("button", { name: "Payment issue?" }).click(); const dialog = page.getByRole("dialog", { name: "What problem did you encounter?", }); await expect(dialog).toBeVisible(); await dialog.getByRole("radio", { name: "Other" }).check(); await dialog .getByLabel("Please describe the issue") .fill("QRIS did not open correctly."); await dialog.getByRole("button", { name: "Submit" }).click(); await expect(page.getByRole("status")).toHaveText( "Thanks. Your payment issue has been submitted.", ); await expect(dialog).toBeHidden(); expect(payment.getCreateOrderCount()).toBe(0); expect(idempotencyKey).toMatch(/^payment_feedback_[A-Za-z0-9_-]+$/); expect(feedbackBody).toContain('name="category"'); expect(feedbackBody).toContain("payment"); expect(feedbackBody).toContain( "Payment issue: Other\r\nDetails: QRIS did not open correctly.", ); expect(feedbackBody).toContain('name="context"'); expect(feedbackBody).toContain('"paymentIssueReason":"other"'); expect(feedbackBody).toContain('"characterId":"elio"'); }); test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({ page, }) => { const payment = await registerIndonesiaPaymentMocks(page); await prepareIndonesiaUser(page); await page.goto("/characters/elio/tip"); await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible(); await expectQrisOrder( page, /Order and Buy/i, "tip_coffee_usd_4_99", /Rp\s*89[.,]299/, ); const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" }); await dialog.getByRole("button", { name: "Close" }).click(); await expect(dialog).toBeHidden(); await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled(); await page.getByRole("button", { name: "Stripe" }).click(); await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute( "aria-pressed", "true", ); expect(payment.getCreateOrderCount()).toBe(1); });