From 2c537d4da632f61c023bd1167a16534af63dba68 Mon Sep 17 00:00:00 2001 From: Volodymyr Kartavyi Date: Wed, 1 Apr 2026 21:00:52 +0200 Subject: [PATCH 1/3] Add locale to checkout start payloads (WEB-4004) Pass the resolved checkout locale through to /checkout/start so backend systems can persist it for lifecycle email localization. - Add locale field to CheckoutStartParams, CheckoutStartRequestParams, and PaddleStartCheckoutParams - All 4 UI flows (WebBilling, Stripe, Stripe Express, Paddle) now send selectedLocale to the backend - Locale is omitted from the request body when not set (backward compat) --- src/helpers/purchase-operation-helper.ts | 5 ++ src/networking/backend.ts | 8 ++++ src/paddle/paddle-service.ts | 3 ++ .../helpers/purchase-operation-helper.test.ts | 22 +++++++++ src/tests/networking/backend.test.ts | 47 +++++++++++++++++++ src/tests/paddle/paddle-service.test.ts | 17 +++++++ src/tests/ui/paddle-purchases-ui.test.ts | 1 + .../ui/stripe-checkout-purchases-ui.test.ts | 5 ++ .../express-purchase-button.svelte | 1 + src/ui/paddle-purchases-ui.svelte | 1 + src/ui/purchases-ui.svelte | 1 + src/ui/stripe-checkout-purchases-ui.svelte | 1 + 12 files changed, 112 insertions(+) diff --git a/src/helpers/purchase-operation-helper.ts b/src/helpers/purchase-operation-helper.ts index fd1ae3acc..c1c283034 100644 --- a/src/helpers/purchase-operation-helper.ts +++ b/src/helpers/purchase-operation-helper.ts @@ -121,6 +121,9 @@ interface CheckoutStartParams { // Customer data customerEmail?: string; metadata?: PurchaseMetadata; + // Resolved from selectedLocale/defaultLocale at the public API layer. + // Future: consider adding localeSource?: "selected" | "browser". + locale?: string; } export interface OperationSessionSuccessfulResult { @@ -184,6 +187,7 @@ export class PurchaseOperationHelper { paywallId, customerEmail, metadata, + locale, }: CheckoutStartParams): Promise { try { const traceId = this.eventsTracker.getTraceId(); @@ -200,6 +204,7 @@ export class PurchaseOperationHelper { paywallId, customerEmail, metadata, + locale, }); this.operationSessionId = checkoutStartResponse.operation_session_id; return checkoutStartResponse; diff --git a/src/networking/backend.ts b/src/networking/backend.ts index 9d7c8d1a5..24cf3729d 100644 --- a/src/networking/backend.ts +++ b/src/networking/backend.ts @@ -49,6 +49,8 @@ interface CheckoutStartRequestParams { // Customer data customerEmail?: string; metadata?: PurchaseMetadata; + // Locale for lifecycle emails. + locale?: string; } export class Backend { @@ -189,6 +191,7 @@ export class Backend { paywallId, customerEmail, metadata, + locale, }: CheckoutStartRequestParams): Promise { type CheckoutStartRequestBody = { app_user_id: string; @@ -206,6 +209,7 @@ export class Backend { email?: string; metadata?: PurchaseMetadata; trace_id: string; + locale?: string; paywall?: { paywall_id: string; }; @@ -256,6 +260,10 @@ export class Backend { }; } + if (locale) { + requestBody.locale = locale; + } + return (await performRequest( new CheckoutStartEndpoint(), { diff --git a/src/paddle/paddle-service.ts b/src/paddle/paddle-service.ts index b1817cfb4..2524f3475 100644 --- a/src/paddle/paddle-service.ts +++ b/src/paddle/paddle-service.ts @@ -56,6 +56,7 @@ interface PaddleStartCheckoutParams { purchaseOption: PurchaseOption; customerEmail?: string; metadata?: PurchaseMetadata; + locale?: string; } export class PaddleService { @@ -127,6 +128,7 @@ export class PaddleService { purchaseOption, customerEmail, metadata, + locale, }: PaddleStartCheckoutParams): Promise { try { const traceId = this.eventsTracker.getTraceId(); @@ -139,6 +141,7 @@ export class PaddleService { traceId, customerEmail: customerEmail ?? undefined, metadata, + locale, }); await this.initializePaddle( diff --git a/src/tests/helpers/purchase-operation-helper.test.ts b/src/tests/helpers/purchase-operation-helper.test.ts index f2bcb1c00..c1e65fc50 100644 --- a/src/tests/helpers/purchase-operation-helper.test.ts +++ b/src/tests/helpers/purchase-operation-helper.test.ts @@ -245,6 +245,28 @@ describe("PurchaseOperationHelper", () => { }); }); + test("checkoutStart passes locale to backend when provided", async () => { + const mockPostCheckoutStart = vi + .spyOn(backend, "postCheckoutStart") + .mockResolvedValue(checkoutStartResponse); + + await purchaseOperationHelper.checkoutStart({ + appUserId: "test-app-user-id", + productId: "test-product-id", + purchaseOption: { id: "test-option-id", priceId: "test-price-id" }, + presentedOfferingContext: { + offeringIdentifier: "test-offering-id", + targetingContext: null, + placementIdentifier: null, + }, + locale: "es", + }); + + expect(mockPostCheckoutStart).toHaveBeenCalledWith( + expect.objectContaining({ locale: "es" }), + ); + }); + test("prepareCheckout returns the backend response", async () => { setCheckoutPrepareResponse( HttpResponse.json(checkoutPrepareResponse, { diff --git a/src/tests/networking/backend.test.ts b/src/tests/networking/backend.test.ts index 7198bebac..46542b7b6 100644 --- a/src/tests/networking/backend.test.ts +++ b/src/tests/networking/backend.test.ts @@ -819,6 +819,53 @@ describe("postCheckoutStart request", () => { expect(requestBody.paywall).toBeUndefined(); }); + test("includes locale in request when provided", async () => { + setCheckoutStartResponse( + HttpResponse.json(checkoutStartResponse, { status: 200 }), + ); + + await backend.postCheckoutStart({ + appUserId: "someAppUserId", + productId: "monthly", + presentedOfferingContext: { + offeringIdentifier: "offering_1", + targetingContext: null, + placementIdentifier: null, + }, + purchaseOption: { id: "base_option", priceId: "test_price_id" }, + traceId: "test-trace-id", + locale: "es", + }); + + expect(purchaseMethodAPIMock).toHaveBeenCalledTimes(1); + const request = purchaseMethodAPIMock.mock.calls[0][0].request; + const requestBody = await request.json(); + expect(requestBody.locale).toBe("es"); + }); + + test("omits locale from request when not provided", async () => { + setCheckoutStartResponse( + HttpResponse.json(checkoutStartResponse, { status: 200 }), + ); + + await backend.postCheckoutStart({ + appUserId: "someAppUserId", + productId: "monthly", + presentedOfferingContext: { + offeringIdentifier: "offering_1", + targetingContext: null, + placementIdentifier: null, + }, + purchaseOption: { id: "base_option", priceId: "test_price_id" }, + traceId: "test-trace-id", + }); + + expect(purchaseMethodAPIMock).toHaveBeenCalledTimes(1); + const request = purchaseMethodAPIMock.mock.calls[0][0].request; + const requestBody = await request.json(); + expect(requestBody.locale).toBeUndefined(); + }); + test("throws an error if the backend returns a server error", async () => { setCheckoutStartResponse( HttpResponse.json(null, { status: StatusCodes.INTERNAL_SERVER_ERROR }), diff --git a/src/tests/paddle/paddle-service.test.ts b/src/tests/paddle/paddle-service.test.ts index 0bf258772..359a5d80f 100644 --- a/src/tests/paddle/paddle-service.test.ts +++ b/src/tests/paddle/paddle-service.test.ts @@ -317,6 +317,23 @@ describe("PaddleService", () => { paddleService.startCheckout(startCheckoutArgs), ).rejects.toThrow(expectedError); }); + + test("passes locale to backend when provided", async () => { + vi.mocked(initPaddle).mockResolvedValue(mockPaddleInstance); + + const mockPostCheckoutStart = vi + .spyOn(backend, "postCheckoutStart") + .mockResolvedValue(paddleCheckoutStartResponse); + + await paddleService.startCheckout({ + ...startCheckoutArgs, + locale: "es", + }); + + expect(mockPostCheckoutStart).toHaveBeenCalledWith( + expect.objectContaining({ locale: "es" }), + ); + }); }); describe("purchase", () => { diff --git a/src/tests/ui/paddle-purchases-ui.test.ts b/src/tests/ui/paddle-purchases-ui.test.ts index 6e50a07e9..dafd5062a 100644 --- a/src/tests/ui/paddle-purchases-ui.test.ts +++ b/src/tests/ui/paddle-purchases-ui.test.ts @@ -116,6 +116,7 @@ describe("PaddlePurchasesUI", () => { purchaseOption: subscriptionOption, customerEmail: undefined, metadata: undefined, + locale: "en", }); }); diff --git a/src/tests/ui/stripe-checkout-purchases-ui.test.ts b/src/tests/ui/stripe-checkout-purchases-ui.test.ts index f8b6a5bbe..535e12c65 100644 --- a/src/tests/ui/stripe-checkout-purchases-ui.test.ts +++ b/src/tests/ui/stripe-checkout-purchases-ui.test.ts @@ -113,6 +113,7 @@ describe("StripeCheckoutPurchasesUi", () => { customerEmail: "test@example.com", metadata: { utm_term: "something" }, workflowPurchaseContext: { stepId: "test-step-123" }, + locale: "en", }); }); }); @@ -137,6 +138,7 @@ describe("StripeCheckoutPurchasesUi", () => { rcPackage.webBillingProduct.presentedOfferingContext, customerEmail: "test@example.com", metadata: { utm_term: "something" }, + locale: "en", }); }); }); @@ -162,6 +164,7 @@ describe("StripeCheckoutPurchasesUi", () => { rcPackage.webBillingProduct.presentedOfferingContext, customerEmail: undefined, metadata: { utm_term: "something" }, + locale: "en", }); }); }); @@ -208,6 +211,7 @@ describe("StripeCheckoutPurchasesUi", () => { customerEmail: "test@example.com", metadata: { utm_term: "something" }, paywallId: "paywall-abc-123", + locale: "en", }); }); }); @@ -232,6 +236,7 @@ describe("StripeCheckoutPurchasesUi", () => { rcPackage.webBillingProduct.presentedOfferingContext, customerEmail: "test@example.com", metadata: { utm_term: "something" }, + locale: "en", }); }); }); diff --git a/src/ui/express-purchase-button/express-purchase-button.svelte b/src/ui/express-purchase-button/express-purchase-button.svelte index baf8a78c1..e07a51667 100644 --- a/src/ui/express-purchase-button/express-purchase-button.svelte +++ b/src/ui/express-purchase-button/express-purchase-button.svelte @@ -290,6 +290,7 @@ rcPackage.webBillingProduct.presentedOfferingContext, customerEmail, metadata, + locale: translator.selectedLocale, }); const managementUrl = checkoutStartResult.management_url; diff --git a/src/ui/paddle-purchases-ui.svelte b/src/ui/paddle-purchases-ui.svelte index 6984e7261..7f8036f4d 100644 --- a/src/ui/paddle-purchases-ui.svelte +++ b/src/ui/paddle-purchases-ui.svelte @@ -145,6 +145,7 @@ purchaseOption, customerEmail, metadata, + locale: selectedLocale, }); isSandbox = startResponse.paddle_billing_params.is_sandbox; } catch (e) { diff --git a/src/ui/purchases-ui.svelte b/src/ui/purchases-ui.svelte index 25b240be9..e3466cf40 100644 --- a/src/ui/purchases-ui.svelte +++ b/src/ui/purchases-ui.svelte @@ -168,6 +168,7 @@ metadata, workflowPurchaseContext, paywallId, + locale: selectedLocale, }) .then((result) => { lastError = null; diff --git a/src/ui/stripe-checkout-purchases-ui.svelte b/src/ui/stripe-checkout-purchases-ui.svelte index 75723e559..250d63495 100644 --- a/src/ui/stripe-checkout-purchases-ui.svelte +++ b/src/ui/stripe-checkout-purchases-ui.svelte @@ -182,6 +182,7 @@ metadata, workflowPurchaseContext, paywallId, + locale: selectedLocale, }); if (!result.stripe_billing_params) { From 28a5299da025431eea5d58f6d7a3bc8bd03a80de Mon Sep 17 00:00:00 2001 From: Volodymyr Kartavyi Date: Wed, 8 Apr 2026 14:29:47 +0200 Subject: [PATCH 2/3] Retrigger CI From 045e193ef9a05690a11f9e1716aef697ba83176c Mon Sep 17 00:00:00 2001 From: Volodymyr Kartavyi Date: Thu, 9 Apr 2026 01:19:20 +0200 Subject: [PATCH 3/3] Add locale to checkout/complete + explicit locale tests Address review feedback from vicfergar: - Send locale in /checkout/complete request body (same pattern as email) - Add explicit locale test for StripeCheckoutPurchasesUi - Add locale tests for postCheckoutComplete (include/omit) The complete endpoint needs locale for the checkout portal flow where locale is only known at completion time, not at start. --- src/helpers/purchase-operation-helper.ts | 11 +++++-- src/networking/backend.ts | 6 ++++ src/tests/networking/backend.test.ts | 30 +++++++++++++++++++ .../ui/stripe-checkout-purchases-ui.test.ts | 21 +++++++++++++ .../express-purchase-button.svelte | 6 ++-- src/ui/pages/payment-entry-page.svelte | 6 ++-- 6 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/helpers/purchase-operation-helper.ts b/src/helpers/purchase-operation-helper.ts index c1c283034..c9a86fb36 100644 --- a/src/helpers/purchase-operation-helper.ts +++ b/src/helpers/purchase-operation-helper.ts @@ -263,7 +263,10 @@ export class PurchaseOperationHelper { } } - async checkoutComplete(email?: string): Promise { + async checkoutComplete( + email?: string, + locale?: string, + ): Promise { const operationSessionId = this.operationSessionId; if (!operationSessionId) { throw new PurchaseFlowError( @@ -273,7 +276,11 @@ export class PurchaseOperationHelper { } try { - return await this.backend.postCheckoutComplete(operationSessionId, email); + return await this.backend.postCheckoutComplete( + operationSessionId, + email, + locale, + ); } catch (error) { if (error instanceof PurchasesError) { throw PurchaseFlowError.fromPurchasesError( diff --git a/src/networking/backend.ts b/src/networking/backend.ts index 24cf3729d..18ef45bda 100644 --- a/src/networking/backend.ts +++ b/src/networking/backend.ts @@ -307,15 +307,21 @@ export class Backend { async postCheckoutComplete( operationSessionId: string, email?: string, + locale?: string, ): Promise { type CheckoutCompleteRequestBody = { email?: string; + locale?: string; }; const requestBody: CheckoutCompleteRequestBody = { email: email, }; + if (locale) { + requestBody.locale = locale; + } + return await performRequest< CheckoutCompleteRequestBody, CheckoutCompleteResponse diff --git a/src/tests/networking/backend.test.ts b/src/tests/networking/backend.test.ts index 46542b7b6..b87469d61 100644 --- a/src/tests/networking/backend.test.ts +++ b/src/tests/networking/backend.test.ts @@ -1039,6 +1039,36 @@ describe("postCheckoutComplete request", () => { expect(result).toEqual(checkoutCompleteResponse); }); + test("includes locale in request when provided", async () => { + setCheckoutCompleteResponse( + HttpResponse.json(checkoutCompleteResponse, { status: 200 }), + ); + + await backend.postCheckoutComplete( + "someOperationSessionId", + undefined, + "es", + ); + + expect(purchaseMethodAPIMock).toHaveBeenCalledTimes(1); + const request = purchaseMethodAPIMock.mock.calls[0][0].request; + const requestBody = await request.json(); + expect(requestBody.locale).toBe("es"); + }); + + test("omits locale from request when not provided", async () => { + setCheckoutCompleteResponse( + HttpResponse.json(checkoutCompleteResponse, { status: 200 }), + ); + + await backend.postCheckoutComplete("someOperationSessionId"); + + expect(purchaseMethodAPIMock).toHaveBeenCalledTimes(1); + const request = purchaseMethodAPIMock.mock.calls[0][0].request; + const requestBody = await request.json(); + expect(requestBody.locale).toBeUndefined(); + }); + test("throws an error if the backend returns a server error", async () => { setCheckoutCompleteResponse( HttpResponse.json(null, { status: StatusCodes.INTERNAL_SERVER_ERROR }), diff --git a/src/tests/ui/stripe-checkout-purchases-ui.test.ts b/src/tests/ui/stripe-checkout-purchases-ui.test.ts index 535e12c65..c10287ab1 100644 --- a/src/tests/ui/stripe-checkout-purchases-ui.test.ts +++ b/src/tests/ui/stripe-checkout-purchases-ui.test.ts @@ -241,6 +241,27 @@ describe("StripeCheckoutPurchasesUi", () => { }); }); + test("passes selectedLocale as locale to checkoutStart", async () => { + const checkoutStartSpy = vi + .spyOn(purchaseOperationHelperMock, "checkoutStart") + .mockResolvedValue(checkoutStartResponseWithoutStripeParams); + + render(StripeCheckoutPurchasesUi, { + props: { + ...baseProps, + selectedLocale: "es", + }, + }); + + await waitFor(() => { + expect(checkoutStartSpy).toHaveBeenCalledWith( + expect.objectContaining({ + locale: "es", + }), + ); + }); + }); + test("shows sandbox banner when stripe billing environment is sandbox", async () => { vi.spyOn(purchaseOperationHelperMock, "checkoutStart").mockResolvedValue( createCheckoutStartResponseWithStripeParams("sandbox"), diff --git a/src/ui/express-purchase-button/express-purchase-button.svelte b/src/ui/express-purchase-button/express-purchase-button.svelte index e07a51667..41f672b5d 100644 --- a/src/ui/express-purchase-button/express-purchase-button.svelte +++ b/src/ui/express-purchase-button/express-purchase-button.svelte @@ -196,8 +196,10 @@ await StripeService.submitElements(elements); - const completeResponse = - await purchaseOperationHelper.checkoutComplete(email); + const completeResponse = await purchaseOperationHelper.checkoutComplete( + email, + translator.selectedLocale, + ); const newClientSecret = completeResponse?.gateway_params?.client_secret; if (!newClientSecret) { diff --git a/src/ui/pages/payment-entry-page.svelte b/src/ui/pages/payment-entry-page.svelte index 0999cc6e8..4b624ee36 100644 --- a/src/ui/pages/payment-entry-page.svelte +++ b/src/ui/pages/payment-entry-page.svelte @@ -464,8 +464,10 @@ // Helper function to complete the checkout async function completeCheckout(): Promise { - const completeResponse = - await purchaseOperationHelper.checkoutComplete(email); + const completeResponse = await purchaseOperationHelper.checkoutComplete( + email, + $translator.selectedLocale, + ); const newClientSecret = completeResponse?.gateway_params?.client_secret; if (newClientSecret) clientSecret = newClientSecret; }