fix(api): drop localized card checks and require pix for brazil (#3203)

This commit is contained in:
Hampus
2026-10-04 18:03:14 +02:00
committed by GitHub
parent 5ca458dada
commit 12a407aca8
51 changed files with 1378 additions and 6651 deletions
-189
View File
@@ -13398,115 +13398,6 @@
}
}
},
"/stripe/checkout/subscription/preapproval": {
"post": {
"operationId": "create_localized_card_preapproval_session",
"summary": "Create localized card preapproval session",
"tags": ["Billing"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UrlResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Initiates a Stripe Checkout setup-mode session to preapprove a local card before continuing to paid localized checkout.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateCheckoutSessionRequest"}}}
}
}
},
"/stripe/checkout/subscription/preapproval/continue": {
"post": {
"operationId": "continue_localized_card_preapproval_session",
"summary": "Continue localized card preapproval session",
"tags": ["Billing"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/LocalizedCardPreapprovalContinueResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Checks the status of a localized card preapproval flow and returns the paid Stripe Checkout URL when it is ready.",
"requestBody": {
"required": true,
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/LocalizedCardPreapprovalContinueRequest"}}
}
}
}
},
"/stripe/webhook": {
"post": {
"operationId": "process_stripe_webhook",
@@ -25771,21 +25662,6 @@
"required": ["received"],
"additionalProperties": false
},
"LocalizedCardPreapprovalContinueRequest": {
"type": "object",
"properties": {
"token": {"description": "Continuation token for the localized card preapproval flow", "type": "string"}
},
"required": ["token"]
},
"LocalizedCardPreapprovalContinueResponse": {
"oneOf": [
{"$ref": "#/components/schemas/PendingLocalizedCardPreapprovalContinueResponse"},
{"$ref": "#/components/schemas/ReadyLocalizedCardPreapprovalContinueResponse"},
{"$ref": "#/components/schemas/RejectedLocalizedCardPreapprovalContinueResponse"},
{"$ref": "#/components/schemas/ExpiredLocalizedCardPreapprovalContinueResponse"}
]
},
"CreateCheckoutSessionRequest": {
"type": "object",
"properties": {
@@ -35251,71 +35127,6 @@
"type": "string"
},
"CheckoutPaymentMethodEnum": {"type": "string", "enum": ["card", "pix", "upi"]},
"ExpiredLocalizedCardPreapprovalContinueResponse": {
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "expired",
"description": "The preapproval token has expired or is unknown"
}
},
"required": ["status"],
"additionalProperties": false
},
"RejectedLocalizedCardPreapprovalContinueResponse": {
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "rejected",
"description": "The preapproval failed and the paid checkout should not continue"
},
"reason": {
"type": "string",
"enum": [
"country_mismatch",
"missing_customer",
"missing_payment_method",
"missing_setup_intent",
"payment_method_not_card",
"unknown"
],
"description": "The reason the preapproval was rejected"
},
"actual_country": {
"description": "The detected card issuing country when available",
"anyOf": [{"type": "string", "minLength": 2, "maxLength": 2}, {"type": "null"}]
}
},
"required": ["status", "reason"],
"additionalProperties": false
},
"ReadyLocalizedCardPreapprovalContinueResponse": {
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "ready",
"description": "The preapproval succeeded and the paid checkout URL is ready"
},
"url": {"type": "string", "description": "The URL to redirect to"}
},
"required": ["status", "url"],
"additionalProperties": false
},
"PendingLocalizedCardPreapprovalContinueResponse": {
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "pending",
"description": "The preapproval result is still being processed"
}
},
"required": ["status"],
"additionalProperties": false
},
"GifCategoryTagResponse": {
"type": "object",
"properties": {
@@ -36,14 +36,6 @@ export const IntegrationRateLimitConfigs = {
bucket: 'stripe:checkout:subscription',
config: {limit: 3, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL: {
bucket: 'stripe:checkout:subscription:preapproval',
config: {limit: 5, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL_CONTINUE: {
bucket: 'stripe:checkout:subscription:preapproval:continue',
config: {limit: 30, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
STRIPE_CHECKOUT_GIFT: {
bucket: 'stripe:checkout:gift',
config: {limit: 3, windowMs: ms('1 minute')},
@@ -29,8 +29,6 @@ import {
import {
ChangeSubscriptionRequest,
CurrentSubscriptionPriceResponse,
LocalizedCardPreapprovalContinueRequest,
LocalizedCardPreapprovalContinueResponse,
PriceIdsQueryRequest,
PriceIdsResponse,
SelfServeRefundEligibilityResponse,
@@ -140,60 +138,6 @@ export function StripeController(app: HonoApp) {
return ctx.json({url: checkoutUrl});
},
);
app.post(
'/stripe/checkout/subscription/preapproval',
BillingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'create_localized_card_preapproval_session',
summary: 'Create localized card preapproval session',
description:
'Initiates a Stripe Checkout setup-mode session to preapprove a local card before continuing to paid localized checkout.',
responseSchema: UrlResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Billing',
}),
Validator('json', CreateCheckoutSessionRequest),
async (ctx) => {
const {price_id, country_code, client_geoip_country_code, eu_withdrawal_waiver_accepted, is_business} =
ctx.req.valid('json');
const userId = ctx.get('user').id;
const checkoutUrl = await ctx.get('stripeService').createLocalizedCardPreapprovalSession({
userId,
priceId: price_id,
countryCode: country_code,
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
isBusiness: is_business,
});
return ctx.json({url: checkoutUrl});
},
);
app.post(
'/stripe/checkout/subscription/preapproval/continue',
BillingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL_CONTINUE),
OpenAPI({
operationId: 'continue_localized_card_preapproval_session',
summary: 'Continue localized card preapproval session',
description:
'Checks the status of a localized card preapproval flow and returns the paid Stripe Checkout URL when it is ready.',
responseSchema: LocalizedCardPreapprovalContinueResponse,
statusCode: 200,
security: [],
tags: 'Billing',
}),
Validator('json', LocalizedCardPreapprovalContinueRequest),
async (ctx) => {
const {token} = ctx.req.valid('json');
const result = await ctx.get('stripeService').continueLocalizedCardPreapproval(token);
return ctx.json(result);
},
);
app.post(
'/stripe/checkout/gift',
BillingRouteAvailable,
+1 -23
View File
@@ -14,10 +14,7 @@ import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlem
import {getProductRegistry, type ProductRegistry} from '@app/api/stripe/ProductRegistry';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {PremiumStateService} from '@app/api/stripe/services/PremiumStateService';
import type {
ContinueLocalizedCardPreapprovalResult,
CreateCheckoutSessionParams,
} from '@app/api/stripe/services/StripeCheckoutService';
import type {CreateCheckoutSessionParams} from '@app/api/stripe/services/StripeCheckoutService';
import {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
import {StripeGiftService} from '@app/api/stripe/services/StripeGiftService';
import {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
@@ -139,25 +136,6 @@ export class StripeService {
return `${Config.endpoints.webApp}/premium-callback?status=success`;
}
async createLocalizedCardPreapprovalSession(
params: Pick<
CreateCheckoutSessionParams,
| 'clientGeoipCountryCode'
| 'countryCode'
| 'euWithdrawalWaiverAccepted'
| 'isBusiness'
| 'priceId'
| 'purchaseGeoipCountryCode'
| 'userId'
>,
): Promise<string> {
return this.checkoutService.createLocalizedCardPreapprovalSession(params);
}
async continueLocalizedCardPreapproval(token: string): Promise<ContinueLocalizedCardPreapprovalResult> {
return this.checkoutService.continueLocalizedCardPreapproval(token);
}
async createCustomerPortalSession(userId: UserID): Promise<string> {
return this.checkoutService.createCustomerPortalSession(userId);
}
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import type {UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {getContentMessage} from '@app/api/content_i18n/ContentI18n';
import type {UserRow} from '@app/api/database/types/UserTypes';
@@ -95,6 +94,10 @@ export interface CreateCheckoutSessionParams {
const PIX_UPI_MANDATE_HEADROOM_MULTIPLIER = 1.25;
const LOCAL_PAYMENT_METHOD_BY_CURRENCY: Partial<Record<Currency, CheckoutPaymentMethod>> = {
BRL: 'pix',
};
interface ResolvedPriceIds {
monthly: string | null;
yearly: string | null;
@@ -118,51 +121,6 @@ interface EuWithdrawalWaiverContext {
required: boolean;
}
type LocalizedCardPreapprovalStatus = 'approved' | 'checkout_created' | 'pending' | 'rejected';
type LocalizedCardPreapprovalRejectedReason =
| 'country_mismatch'
| 'missing_customer'
| 'missing_payment_method'
| 'missing_setup_intent'
| 'payment_method_not_card'
| 'unknown';
interface LocalizedCardPreapprovalFlowState {
actualCardCountry: string | null;
approvedPaymentMethodId: string | null;
clientGeoipCountryCode: string | null;
countryCode: string;
customerId: string;
currency: Currency;
euWithdrawalWaiverAccepted: boolean;
finalCheckoutUrl: string | null;
isBusiness: boolean;
preapprovalSessionId: string;
purchaseGeoipCountryCode: string | null;
priceId: string;
rejectionReason: LocalizedCardPreapprovalRejectedReason | null;
status: LocalizedCardPreapprovalStatus;
token: string;
userId: string;
}
export type ContinueLocalizedCardPreapprovalResult =
| {
status: 'expired';
}
| {
status: 'pending';
}
| {
status: 'ready';
url: string;
}
| {
status: 'rejected';
reason: LocalizedCardPreapprovalRejectedReason;
actual_country?: string | null;
};
export class StripeCheckoutService {
constructor(
private stripe: Stripe | null,
@@ -192,7 +150,15 @@ export class StripeCheckoutService {
});
const isRecurringSubscription = this.productRegistry.isRecurringSubscription(productInfo);
const checkoutMode: CheckoutSessionMode = isRecurringSubscription ? 'subscription' : 'payment';
this.assertPaymentMethodCompatibility({paymentMethod, productInfo, isGift, userId, priceId});
const effectivePaymentMethod =
this.resolveRequiredLocalPaymentMethod({productInfo, isGift, isRecurringSubscription}) ?? paymentMethod;
this.assertPaymentMethodCompatibility({
paymentMethod: effectivePaymentMethod,
productInfo,
isGift,
userId,
priceId,
});
const waiverContext = this.resolveEuWithdrawalWaiverContext({
countryCode,
clientGeoipCountryCode,
@@ -202,10 +168,10 @@ export class StripeCheckoutService {
const paymentMethodOptions = await this.buildPaymentMethodOptions({
productInfo,
checkoutMode,
paymentMethod,
paymentMethod: effectivePaymentMethod,
priceId,
});
const paymentMethodTypes = this.resolvePaymentMethodTypes(paymentMethod);
const paymentMethodTypes = this.resolvePaymentMethodTypes(effectivePaymentMethod);
const branding = await getBillingBranding();
const billing = getEffectiveBillingConfig();
const checkoutMetadata = {
@@ -220,7 +186,7 @@ export class StripeCheckoutService {
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION,
payment_method: paymentMethod,
payment_method: effectivePaymentMethod,
};
const checkoutParams: CheckoutSessionCreateParams = {
customer: customerId,
@@ -296,256 +262,6 @@ export class StripeCheckoutService {
});
}
async createLocalizedCardPreapprovalSession({
userId,
priceId,
countryCode,
clientGeoipCountryCode,
purchaseGeoipCountryCode,
euWithdrawalWaiverAccepted,
isBusiness = false,
}: Pick<
CreateCheckoutSessionParams,
| 'clientGeoipCountryCode'
| 'countryCode'
| 'euWithdrawalWaiverAccepted'
| 'isBusiness'
| 'priceId'
| 'purchaseGeoipCountryCode'
| 'userId'
>): Promise<string> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
const normalizedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
if (!normalizedCountryCode) {
Logger.error({priceId, userId}, 'Localized card preapproval requires a country code');
throw new StripeInvalidProductConfigurationError();
}
const {customerId, productInfo} = await this.prepareCheckoutContext({
userId,
priceId,
isGift: false,
countryCode: normalizedCountryCode,
});
if (!this.requiresLocalizedCardPreapproval(productInfo)) {
Logger.error(
{priceId, userId, currency: productInfo.currency, countryCode: normalizedCountryCode},
'Localized card preapproval requested for non-localized recurring price',
);
throw new StripeInvalidProductConfigurationError();
}
const waiverContext = this.resolveEuWithdrawalWaiverContext({
countryCode: normalizedCountryCode,
clientGeoipCountryCode,
purchaseGeoipCountryCode,
euWithdrawalWaiverAccepted,
});
const token = randomUUID();
const checkoutParams: CheckoutSessionCreateParams = {
customer: customerId,
client_reference_id: userId.toString(),
metadata: {
user_id: userId.toString(),
price_id: priceId,
product_type: productInfo.type,
country_code: normalizedCountryCode,
...(purchaseGeoipCountryCode ? {purchase_geoip_country_code: purchaseGeoipCountryCode.toUpperCase()} : {}),
...(clientGeoipCountryCode ? {purchase_client_country_code: clientGeoipCountryCode.toUpperCase()} : {}),
eu_withdrawal_waiver_required: waiverContext.required ? 'true' : 'false',
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
...(waiverContext.required ? {eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION} : {}),
setup_type: 'localized_card_preapproval',
localized_card_preapproval_currency: productInfo.currency,
localized_card_preapproval_token: token,
is_business: isBusiness ? 'true' : 'false',
},
mode: 'setup',
payment_method_types: ['card'],
success_url: `${Config.endpoints.webApp}/premium-callback?status=preapproval-success&token=${encodeURIComponent(token)}`,
cancel_url: `${Config.endpoints.webApp}/premium-callback?status=preapproval-cancel`,
tax_id_collection: {
enabled: getEffectiveBillingConfig().taxIdCollection,
},
billing_address_collection: isBusiness ? 'required' : 'auto',
customer_update: {
address: 'auto',
name: 'auto',
},
};
try {
const session = await this.stripe.checkout.sessions.create(checkoutParams);
try {
await getBillingRepository().checkoutSessions.upsertFromStripe(session, {knownUserId: userId});
} catch (mirrorErr) {
Logger.error(
{mirrorErr, sessionId: session.id},
'Mirror upsert failed after Stripe write; reconciler will heal',
);
}
if (!session.url) {
Logger.error({userId, sessionId: session.id}, 'Stripe localized card preapproval session missing url');
throw new StripeError('Stripe localized card preapproval session missing url');
}
await this.setLocalizedCardPreapprovalFlow(token, {
actualCardCountry: null,
approvedPaymentMethodId: null,
clientGeoipCountryCode: this.normalizeCountryCode(clientGeoipCountryCode),
countryCode: normalizedCountryCode,
customerId,
currency: productInfo.currency,
euWithdrawalWaiverAccepted: waiverContext.accepted,
finalCheckoutUrl: null,
isBusiness,
preapprovalSessionId: session.id,
purchaseGeoipCountryCode: this.normalizeCountryCode(purchaseGeoipCountryCode),
priceId,
rejectionReason: null,
status: 'pending',
token,
userId: userId.toString(),
});
Logger.debug(
{userId, sessionId: session.id, countryCode: normalizedCountryCode},
'Localized card preapproval session created',
);
return session.url;
} catch (error: unknown) {
Logger.error(
{error, userId, countryCode: normalizedCountryCode},
'Failed to create localized card preapproval session',
);
const message = error instanceof Error ? error.message : 'Failed to create localized card preapproval session';
throw new StripeError(message);
}
}
async continueLocalizedCardPreapproval(token: string): Promise<ContinueLocalizedCardPreapprovalResult> {
const normalizedToken = token.trim();
if (!normalizedToken) {
return {status: 'expired'};
}
const flowState = await this.getLocalizedCardPreapprovalFlow(normalizedToken);
if (!flowState) {
return {status: 'expired'};
}
if (flowState.finalCheckoutUrl) {
return {status: 'ready', url: flowState.finalCheckoutUrl};
}
if (flowState.status === 'pending') {
return {status: 'pending'};
}
if (flowState.status === 'rejected') {
return {
status: 'rejected',
reason: flowState.rejectionReason ?? 'unknown',
actual_country: flowState.actualCardCountry,
};
}
const lockKey = this.getLocalizedCardPreapprovalContinueLockKey(normalizedToken);
const lockToken = await this.cacheService.acquireLock(
lockKey,
StripeCheckoutService.LOCALIZED_CARD_PREAPPROVAL_CONTINUE_LOCK_TTL_SECONDS,
);
if (!lockToken) {
return {status: 'pending'};
}
try {
const freshFlowState = await this.getLocalizedCardPreapprovalFlow(normalizedToken);
if (!freshFlowState) {
return {status: 'expired'};
}
if (freshFlowState.finalCheckoutUrl) {
return {status: 'ready', url: freshFlowState.finalCheckoutUrl};
}
if (freshFlowState.status === 'pending') {
return {status: 'pending'};
}
if (freshFlowState.status === 'rejected') {
return {
status: 'rejected',
reason: freshFlowState.rejectionReason ?? 'unknown',
actual_country: freshFlowState.actualCardCountry,
};
}
if (freshFlowState.approvedPaymentMethodId && this.stripe) {
await this.setCustomerDefaultPaymentMethod(freshFlowState.customerId, freshFlowState.approvedPaymentMethodId);
}
const checkoutUrl = await this.createCheckoutSession({
userId: createUserID(BigInt(freshFlowState.userId)),
priceId: freshFlowState.priceId,
isGift: false,
countryCode: freshFlowState.countryCode,
clientGeoipCountryCode: freshFlowState.clientGeoipCountryCode,
purchaseGeoipCountryCode: freshFlowState.purchaseGeoipCountryCode,
euWithdrawalWaiverAccepted: freshFlowState.euWithdrawalWaiverAccepted,
isBusiness: freshFlowState.isBusiness,
});
const updatedFlowState: LocalizedCardPreapprovalFlowState = {
...freshFlowState,
finalCheckoutUrl: checkoutUrl,
status: 'checkout_created',
};
await this.setLocalizedCardPreapprovalFlow(normalizedToken, updatedFlowState);
return {status: 'ready', url: checkoutUrl};
} finally {
try {
await this.cacheService.releaseLock(lockKey, lockToken);
} catch (error) {
Logger.error({error, token: normalizedToken}, 'Failed to release localized card preapproval continuation lock');
}
}
}
async completeLocalizedCardPreapproval(session: Stripe.Checkout.Session): Promise<void> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
const token = session.metadata?.localized_card_preapproval_token?.trim();
if (!token) {
Logger.error({sessionId: session.id}, 'Localized card preapproval session missing token');
return;
}
const countryCode = session.metadata?.country_code?.trim().toUpperCase();
if (!countryCode) {
await this.rejectLocalizedCardPreapproval(session, token, 'unknown');
return;
}
const setupIntentId = extractId(session.setup_intent);
if (!setupIntentId) {
await this.rejectLocalizedCardPreapproval(session, token, 'missing_setup_intent');
return;
}
const setupIntent = await this.stripe.setupIntents.retrieve(setupIntentId, {
expand: ['payment_method'],
});
const paymentMethod = setupIntent.payment_method;
if (!paymentMethod || typeof paymentMethod === 'string') {
await this.rejectLocalizedCardPreapproval(session, token, 'missing_payment_method');
return;
}
if (paymentMethod.type !== 'card' || !paymentMethod.card) {
await this.rejectLocalizedCardPreapproval(session, token, 'payment_method_not_card');
return;
}
const cardCountry = paymentMethod.card.country?.trim().toUpperCase() ?? null;
if (cardCountry !== countryCode) {
await this.rejectLocalizedCardPreapproval(session, token, 'country_mismatch', cardCountry);
return;
}
const flowState = await this.buildLocalizedCardPreapprovalFlowStateFromSession(session, token);
const approvedFlowState: LocalizedCardPreapprovalFlowState = {
...flowState,
actualCardCountry: cardCountry,
approvedPaymentMethodId: paymentMethod.id,
rejectionReason: null,
status: 'approved',
};
await this.setLocalizedCardPreapprovalFlow(token, approvedFlowState);
Logger.info({sessionId: session.id, userId: flowState.userId, countryCode}, 'Localized card preapproval completed');
}
private async prepareCheckoutContext({
userId,
priceId,
@@ -746,132 +462,10 @@ export class StripeCheckoutService {
}
}
private requiresLocalizedCardPreapproval(productInfo: ProductInfo): boolean {
return this.productRegistry.isRecurringSubscription(productInfo) && isLocalizedCurrency(productInfo.currency);
}
private requiresCountryCodeForLocalizedCurrency(currency: Currency): boolean {
return isLocalizedCurrency(currency);
}
private async rejectLocalizedCardPreapproval(
session: Stripe.Checkout.Session,
token: string,
rejectionReason: LocalizedCardPreapprovalRejectedReason,
actualCardCountry: string | null = null,
): Promise<void> {
const flowState = await this.buildLocalizedCardPreapprovalFlowStateFromSession(session, token);
const rejectedFlowState: LocalizedCardPreapprovalFlowState = {
...flowState,
actualCardCountry,
approvedPaymentMethodId: null,
rejectionReason,
status: 'rejected',
};
await this.setLocalizedCardPreapprovalFlow(token, rejectedFlowState);
Logger.info(
{
sessionId: session.id,
userId: flowState.userId,
countryCode: flowState.countryCode,
actualCardCountry,
rejectionReason,
},
'Localized card preapproval rejected',
);
}
private async buildLocalizedCardPreapprovalFlowStateFromSession(
session: Stripe.Checkout.Session,
token: string,
): Promise<LocalizedCardPreapprovalFlowState> {
const existingFlowState = await this.getLocalizedCardPreapprovalFlow(token);
if (existingFlowState) {
return existingFlowState;
}
const userId = session.metadata?.user_id?.trim();
const priceId = session.metadata?.price_id?.trim();
const countryCode = session.metadata?.country_code?.trim().toUpperCase();
const currency = session.metadata?.localized_card_preapproval_currency?.trim().toUpperCase() as
| Currency
| undefined;
const customerId = extractId(session.customer);
if (!userId || !priceId || !countryCode || !currency || !customerId) {
throw new StripeError('Localized card preapproval session missing required metadata');
}
return {
actualCardCountry: null,
approvedPaymentMethodId: null,
clientGeoipCountryCode: this.normalizeCountryCode(session.metadata?.purchase_client_country_code),
countryCode,
customerId,
currency,
euWithdrawalWaiverAccepted: session.metadata?.eu_withdrawal_waiver_accepted === 'true',
finalCheckoutUrl: null,
isBusiness: session.metadata?.is_business === 'true',
preapprovalSessionId: session.id,
purchaseGeoipCountryCode: this.normalizeCountryCode(session.metadata?.purchase_geoip_country_code),
priceId,
rejectionReason: null,
status: 'pending',
token,
userId,
};
}
private async getLocalizedCardPreapprovalFlow(token: string): Promise<LocalizedCardPreapprovalFlowState | null> {
return (
(await this.cacheService.get<LocalizedCardPreapprovalFlowState>(
this.getLocalizedCardPreapprovalFlowKey(token),
)) ?? null
);
}
private async setLocalizedCardPreapprovalFlow(
token: string,
flowState: LocalizedCardPreapprovalFlowState,
): Promise<void> {
await this.cacheService.set(
this.getLocalizedCardPreapprovalFlowKey(token),
flowState,
StripeCheckoutService.LOCALIZED_CARD_PREAPPROVAL_TTL_SECONDS,
);
}
private getLocalizedCardPreapprovalFlowKey(token: string): string {
return `stripe:localized-card-preapproval:flow:${token}`;
}
private getLocalizedCardPreapprovalContinueLockKey(token: string): string {
return `stripe:localized-card-preapproval:continue:${token}`;
}
private async setCustomerDefaultPaymentMethod(customerId: string, paymentMethodId: string): Promise<void> {
if (!this.stripe) {
return;
}
try {
const updatedCustomer = await this.stripe.customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
try {
await getBillingRepository().customers.upsertFromStripe(updatedCustomer);
} catch (mirrorErr) {
Logger.error(
{mirrorErr, customerId: updatedCustomer.id},
'Mirror upsert failed after Stripe write; reconciler will heal',
);
}
} catch (error) {
Logger.warn(
{error, customerId, paymentMethodId},
'Failed to set localized card preapproval default payment method',
);
}
}
private async findBlockingSubscriptionForCustomer(customerId: string): Promise<Stripe.Subscription | null> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
@@ -1060,8 +654,6 @@ export class StripeCheckoutService {
}
private static readonly CUSTOMER_LOCK_TTL_SECONDS = seconds('30 seconds');
private static readonly LOCALIZED_CARD_PREAPPROVAL_CONTINUE_LOCK_TTL_SECONDS = seconds('30 seconds');
private static readonly LOCALIZED_CARD_PREAPPROVAL_TTL_SECONDS = seconds('1 day');
private resolveConfiguredPriceIds(countryCode?: string): ResolvedPriceIds {
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
@@ -1133,6 +725,24 @@ export class StripeCheckoutService {
return getCachedStripePriceSummary({stripe: this.stripe, cacheService: this.cacheService, priceId});
}
private resolveRequiredLocalPaymentMethod({
productInfo,
isGift,
isRecurringSubscription,
}: {
productInfo: ProductInfo;
isGift: boolean;
isRecurringSubscription: boolean;
}): CheckoutPaymentMethod | null {
if (isGift || !isRecurringSubscription) {
return null;
}
if (getEffectiveBillingConfig().catalogMode === 'operator') {
return null;
}
return LOCAL_PAYMENT_METHOD_BY_CURRENCY[productInfo.currency] ?? null;
}
private assertPaymentMethodCompatibility({
paymentMethod,
productInfo,
@@ -23,7 +23,6 @@ import type {StripeGiftService} from '@app/api/stripe/services/StripeGiftService
import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {isLocalizedCurrency} from '@app/api/utils/CurrencyUtils';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
@@ -46,12 +45,6 @@ interface DonationSubscriptionDetails {
status: string | null;
}
interface CheckoutChargeDetails {
chargeId: string | null;
paymentMethodType: string | null;
cardCountry: string | null;
}
type CheckoutPremiumApplyResult = 'granted' | 'refunded_duplicate_subscription';
type CheckoutSideEffectResult = 'continue' | 'stop';
@@ -198,10 +191,6 @@ export class StripeCheckoutWebhookHandler {
);
throw new StripeError('Checkout session missing amount or currency');
}
const cardEligible = await this.validateLocalizedCardEligibility(session, payment, productInfo, user);
if (!cardEligible) {
return;
}
const customerId = extractId(session.customer);
const subscriptionId = extractId(session.subscription);
const isRecurring = this.productRegistry.isRecurringSubscription(productInfo);
@@ -263,131 +252,6 @@ export class StripeCheckoutWebhookHandler {
);
}
private async validateLocalizedCardEligibility(
session: Stripe.Checkout.Session,
payment: Payment,
productInfo: ProductInfo,
user: User,
): Promise<boolean> {
const requestedCountryCode = session.metadata?.country_code?.trim().toUpperCase() ?? null;
if (!requestedCountryCode || !this.requiresLocalizedCardEligibility(productInfo)) {
return true;
}
const paymentIntentId = extractId(session.payment_intent);
if (!paymentIntentId) {
const inferredPaymentMethodType = this.getDeclaredCheckoutPaymentMethodType(session);
if (inferredPaymentMethodType && inferredPaymentMethodType !== 'card') {
Logger.debug(
{
sessionId: session.id,
requestedCountryCode,
currency: productInfo.currency,
inferredPaymentMethodType,
},
'Skipping localized card eligibility validation because checkout explicitly used a non-card payment method',
);
return true;
}
const fallbackChargeContext = await this.getLocalizedCheckoutChargeDetailsFromSubscription(session);
if (fallbackChargeContext?.chargeDetails) {
const fallbackChargeDetails = fallbackChargeContext.chargeDetails;
if (fallbackChargeDetails.paymentMethodType !== 'card') {
Logger.debug(
{
sessionId: session.id,
requestedCountryCode,
currency: productInfo.currency,
fallbackPaymentMethodType: fallbackChargeDetails.paymentMethodType,
},
'Skipping localized card eligibility validation because subscription fallback resolved to a non-card payment method',
);
return true;
}
const normalizedFallbackCardCountry = fallbackChargeDetails.cardCountry?.trim().toUpperCase() ?? null;
if (normalizedFallbackCardCountry === requestedCountryCode) {
Logger.debug(
{
sessionId: session.id,
requestedCountryCode,
currency: productInfo.currency,
cardCountry: normalizedFallbackCardCountry,
},
'Validated localized card eligibility from subscription fallback after checkout.session.completed omitted payment_intent',
);
return true;
}
if (fallbackChargeContext.paymentIntentId && fallbackChargeDetails.chargeId) {
Logger.warn(
{
sessionId: session.id,
userId: payment.userId,
paymentIntentId: fallbackChargeContext.paymentIntentId,
chargeId: fallbackChargeDetails.chargeId,
requestedCountryCode,
cardCountry: normalizedFallbackCardCountry,
currency: productInfo.currency,
},
'Rejecting localized checkout because subscription fallback resolved to a card issued outside the requested country',
);
await this.rejectLocalizedCardPayment({
session,
payment,
user,
chargeDetails: fallbackChargeDetails,
paymentIntentId: fallbackChargeContext.paymentIntentId,
requestedCountryCode,
cardCountry: normalizedFallbackCardCountry,
});
return false;
}
}
Logger.error(
{
sessionId: session.id,
requestedCountryCode,
currency: productInfo.currency,
inferredPaymentMethodType,
fallbackResolved: Boolean(fallbackChargeContext?.chargeDetails),
fallbackPaymentIntentId: fallbackChargeContext?.paymentIntentId ?? null,
fallbackPaymentMethodType: fallbackChargeContext?.chargeDetails?.paymentMethodType ?? null,
fallbackCardCountry: fallbackChargeContext?.chargeDetails?.cardCountry ?? null,
},
'Localized checkout missing payment intent for card eligibility validation',
);
throw new StripeError('Localized checkout missing payment intent');
}
const chargeDetails = await this.getCheckoutChargeDetails(paymentIntentId);
if (chargeDetails.paymentMethodType !== 'card') {
return true;
}
const normalizedCardCountry = chargeDetails.cardCountry?.trim().toUpperCase() ?? null;
if (normalizedCardCountry === requestedCountryCode) {
return true;
}
Logger.warn(
{
sessionId: session.id,
userId: payment.userId,
paymentIntentId,
chargeId: chargeDetails.chargeId,
requestedCountryCode,
cardCountry: normalizedCardCountry,
currency: productInfo.currency,
},
'Rejecting localized checkout because card issuing country did not match requested country',
);
await this.rejectLocalizedCardPayment({
session,
payment,
user,
chargeDetails,
paymentIntentId,
requestedCountryCode,
cardCountry: normalizedCardCountry,
});
return false;
}
private async applyCheckoutSideEffects(context: CheckoutFulfilmentContext): Promise<CheckoutSideEffectResult> {
const checkoutEffectsAppliedKey = this.getCheckoutEffectsAppliedKey(context.session.id);
if (await this.cacheService.get<boolean>(checkoutEffectsAppliedKey)) {
@@ -719,185 +583,6 @@ export class StripeCheckoutWebhookHandler {
return latestServerSeq > initialServerSeq;
}
private requiresLocalizedCardEligibility(productInfo: ProductInfo): boolean {
return isLocalizedCurrency(productInfo.currency);
}
private getDeclaredCheckoutPaymentMethodType(session: Stripe.Checkout.Session): string | null {
const metadataPaymentMethod = session.metadata?.payment_method?.trim().toLowerCase() ?? null;
if (metadataPaymentMethod) {
return metadataPaymentMethod;
}
const paymentMethodTypes = session.payment_method_types ?? [];
if (paymentMethodTypes.length === 1) {
return paymentMethodTypes[0]?.trim().toLowerCase() ?? null;
}
if (paymentMethodTypes.length > 1 && !paymentMethodTypes.some((type) => type.toLowerCase() === 'card')) {
return paymentMethodTypes[0]?.trim().toLowerCase() ?? null;
}
return null;
}
private async getLocalizedCheckoutChargeDetailsFromSubscription(session: Stripe.Checkout.Session): Promise<{
paymentIntentId: string | null;
chargeDetails: CheckoutChargeDetails | null;
} | null> {
if (!this.stripe) {
return null;
}
const subscriptionId = extractId(session.subscription);
if (!subscriptionId) {
return null;
}
type StripeSubscriptionWithFallbackPaymentState = Stripe.Subscription & {
default_payment_method?:
| {
id?: string;
type?: string | null;
card?: {
country?: string | null;
} | null;
}
| string
| null;
latest_invoice?: Stripe.Invoice | string | null;
};
try {
const subscription = (await this.stripe.subscriptions.retrieve(subscriptionId, {
expand: ['default_payment_method', 'latest_invoice.payments.data.payment'],
})) as StripeSubscriptionWithFallbackPaymentState;
const latestInvoice =
typeof subscription.latest_invoice === 'string' ? null : (subscription.latest_invoice ?? null);
const invoicePaymentIntentId = getFirstInvoicePaymentIntentId(latestInvoice);
if (invoicePaymentIntentId) {
return {
paymentIntentId: invoicePaymentIntentId,
chargeDetails: await this.getCheckoutChargeDetails(invoicePaymentIntentId),
};
}
const defaultPaymentMethod =
typeof subscription.default_payment_method === 'string' ? null : subscription.default_payment_method;
if (!defaultPaymentMethod) {
return null;
}
return {
paymentIntentId: null,
chargeDetails: {
chargeId: null,
paymentMethodType: defaultPaymentMethod.type ?? null,
cardCountry: defaultPaymentMethod.card?.country ?? null,
},
};
} catch (error) {
Logger.warn(
{
error,
sessionId: session.id,
subscriptionId,
},
'Failed to load subscription fallback payment details for localized card eligibility',
);
return null;
}
}
private async getCheckoutChargeDetails(paymentIntentId: string): Promise<CheckoutChargeDetails> {
if (!this.stripe) {
throw new StripeError('Stripe client not available for localized card eligibility checks');
}
try {
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ['latest_charge'],
});
const latestCharge = paymentIntent.latest_charge;
if (!latestCharge) {
throw new StripeError('Payment intent missing latest charge');
}
const charge = typeof latestCharge === 'string' ? await this.stripe.charges.retrieve(latestCharge) : latestCharge;
const paymentMethodDetails = charge.payment_method_details;
return {
chargeId: charge.id,
paymentMethodType: this.getChargePaymentMethodType(paymentMethodDetails),
cardCountry: paymentMethodDetails?.card?.country ?? null,
};
} catch (error) {
Logger.error({error, paymentIntentId}, 'Failed to load Stripe charge details for localized card eligibility');
throw error;
}
}
private getChargePaymentMethodType(paymentMethodDetails: Stripe.Charge.PaymentMethodDetails | null): string | null {
if (!paymentMethodDetails) {
return null;
}
if (paymentMethodDetails.type) {
return paymentMethodDetails.type;
}
if (paymentMethodDetails.card) {
return 'card';
}
if ('pix' in paymentMethodDetails && paymentMethodDetails.pix) {
return 'pix';
}
if ('upi' in paymentMethodDetails && paymentMethodDetails.upi) {
return 'upi';
}
return null;
}
private async rejectLocalizedCardPayment({
session,
payment,
user,
chargeDetails,
paymentIntentId,
requestedCountryCode,
cardCountry,
}: {
session: Stripe.Checkout.Session;
payment: Payment;
user: User;
chargeDetails: CheckoutChargeDetails;
paymentIntentId: string;
requestedCountryCode: string;
cardCountry: string | null;
}): Promise<void> {
const subscriptionId = extractId(session.subscription);
if (subscriptionId) {
await this.cancelStripeSubscriptionById(subscriptionId, session.id, user.id.toString());
}
if (chargeDetails.chargeId) {
await this.refundChargeForLocalizedCardMismatch({
chargeId: chargeDetails.chargeId,
checkoutSessionId: session.id,
requestedCountryCode,
cardCountry,
});
} else {
Logger.warn(
{
sessionId: session.id,
userId: user.id.toString(),
paymentIntentId,
requestedCountryCode,
cardCountry,
},
'Skipping localized card refund because Stripe did not surface a charge id',
);
}
await this.userRepository.updatePayment({
...payment.toRow(),
stripe_customer_id: extractId(session.customer),
payment_intent_id: paymentIntentId,
subscription_id: subscriptionId,
invoice_id: typeof session.invoice === 'string' ? session.invoice : null,
amount_cents: session.amount_total ?? payment.amountCents,
currency: session.currency ?? payment.currency,
status: 'failed',
completed_at: payment.completedAt ?? new Date(),
});
}
private async handleDonationCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const email = session.metadata?.donation_email?.trim().toLowerCase();
if (!email) {
@@ -1135,39 +820,6 @@ export class StripeCheckoutWebhookHandler {
}
}
private async refundChargeForLocalizedCardMismatch({
chargeId,
checkoutSessionId,
requestedCountryCode,
cardCountry,
}: {
chargeId: string;
checkoutSessionId: string;
requestedCountryCode: string;
cardCountry: string | null;
}): Promise<void> {
if (!this.stripe) {
throw new StripeError('Stripe client not available for localized card refund');
}
const refund = await this.stripe.refunds.create(
{
charge: chargeId,
metadata: {
checkout_session_id: checkoutSessionId,
rejection_reason: 'localized_card_country_mismatch',
expected_country: requestedCountryCode,
actual_country: cardCountry ?? 'unknown',
},
},
{idempotencyKey: `localized-card-country-refund:${checkoutSessionId}`},
);
try {
await getBillingRepository().refunds.upsertFromStripe(refund);
} catch (mirrorErr) {
Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal');
}
}
private isMissingOrCancelledSubscriptionError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
@@ -16,7 +16,6 @@ import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlem
import {getAcceptedWebhookSecrets} from '@app/api/stripe/BillingConfigCache';
import type {ProductRegistry} from '@app/api/stripe/ProductRegistry';
import type {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService';
import type {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
import {StripeCheckoutWebhookHandler} from '@app/api/stripe/services/StripeCheckoutWebhookHandler';
import {StripeDisputeWebhookHandler} from '@app/api/stripe/services/StripeDisputeWebhookHandler';
import {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler';
@@ -47,7 +46,6 @@ export class StripeWebhookService {
constructor(
private stripe: Stripe | null,
private checkoutService: StripeCheckoutService,
userRepository: IUserRepository,
userCacheService: UserCacheService,
sessionTerminator: ISessionTerminator,
@@ -176,10 +174,6 @@ export class StripeWebhookService {
await this.ageVerificationService.completeVerification(checkoutSession);
break;
}
if (checkoutSession.metadata?.setup_type === 'localized_card_preapproval') {
await this.checkoutService.completeLocalizedCardPreapproval(checkoutSession);
break;
}
await this.checkoutHandler.handleCheckoutSessionCompleted(checkoutSession);
break;
}
@@ -489,16 +489,6 @@ describe('operator billing catalog', () => {
expect(stripeHandlers.spies.createdCheckoutSessions[0]?.line_items?.[0]?.price).toBe('price_opchfyearly');
});
test('never offers the localized card preapproval flow', async () => {
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription/preapproval')
.body({price_id: 'price_opsekmonthly', country_code: 'SE'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects pix and upi for operator prices', async () => {
const token = await createPurchaser();
await createBuilder(harness, token)
@@ -144,8 +144,6 @@ type RouteMethod = 'GET' | 'POST' | 'DELETE';
const PURCHASE_ROUTES: ReadonlyArray<[RouteMethod, string]> = [
['POST', '/stripe/checkout/subscription'],
['POST', '/stripe/checkout/subscription/preapproval'],
['POST', '/stripe/checkout/subscription/preapproval/continue'],
['POST', '/stripe/checkout/gift'],
['GET', '/premium/price-ids'],
];
@@ -167,4 +167,37 @@ describe('StripeCheckoutCountryEnforcement', () => {
expect(priceIds.currency).toBe('BRL');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyBrl);
});
test('offers pix and nothing else for a localized BRL subscription', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdCheckoutSessions[0].payment_method_types).toEqual(['pix']);
});
test('forces pix even when the caller explicitly asks for card on a BRL subscription', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR', payment_method: 'card'})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions[0].payment_method_types).toEqual(['pix']);
});
test('leaves payment methods open for a base-currency subscription', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('US'));
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions[0].payment_method_types).toBeUndefined();
});
});
@@ -226,45 +226,6 @@ describe('StripeWebhookService - checkout.session.completed', () => {
.execute();
expect(user.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
});
test('allows localized BRL checkout when the card is issued in Brazil', async () => {
const account = await createTestAccount(harness);
const sessionId = 'cs_localized_brl_card_br';
const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository');
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
const paymentRepository = new PaymentRepository();
const userRepository = new UserRepository();
await paymentRepository.createPayment({
checkout_session_id: sessionId,
user_id: createUserID(BigInt(account.userId)),
price_id: MOCK_PRICES.monthlyBrl,
product_type: ProductType.MONTHLY_SUBSCRIPTION,
status: 'pending',
is_gift: false,
created_at: new Date(),
});
const eventData = createCheckoutCompletedEvent({
sessionId,
customerId: 'cus_localized_brl_card_br',
subscriptionId: 'sub_localized_brl_card_br',
paymentIntentId: 'pi_localized_brl_card_br',
amountTotal: 1288,
currency: 'brl',
metadata: {country_code: 'BR'},
});
const result = await sendWebhook(eventData);
expect(result.received).toBe(true);
const updatedPayment = await userRepository.getPaymentByCheckoutSession(sessionId);
expect(updatedPayment?.status).toBe('completed');
expect(stripeHandlers.spies.createdRefunds).toHaveLength(0);
expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0);
expect(stripeHandlers.spies.retrievedPaymentIntents).toContain('pi_localized_brl_card_br');
const user = await createBuilder<{
premium_type: number;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(user.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
});
test('allows localized BRL PIX subscription checkout when checkout.session.completed has no payment intent', async () => {
const account = await createTestAccount(harness);
const sessionId = 'cs_localized_brl_pix_subscription';
@@ -410,173 +371,6 @@ describe('StripeWebhookService - checkout.session.completed', () => {
.execute();
expect(user.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
});
test('rejects localized BRL checkout when the card is issued outside Brazil', async () => {
const account = await createTestAccount(harness);
const sessionId = 'cs_localized_brl_card_us';
const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository');
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
const paymentRepository = new PaymentRepository();
const userRepository = new UserRepository();
await paymentRepository.createPayment({
checkout_session_id: sessionId,
user_id: createUserID(BigInt(account.userId)),
price_id: MOCK_PRICES.monthlyBrl,
product_type: ProductType.MONTHLY_SUBSCRIPTION,
status: 'pending',
is_gift: false,
created_at: new Date(),
});
const eventData = createCheckoutCompletedEvent({
sessionId,
customerId: 'cus_localized_brl_card_us',
subscriptionId: 'sub_localized_brl_card_us',
paymentIntentId: 'pi_localized_brl_card_us',
amountTotal: 1288,
currency: 'brl',
metadata: {country_code: 'BR'},
});
const result = await sendWebhook(eventData);
expect(result.received).toBe(true);
const updatedPayment = await userRepository.getPaymentByCheckoutSession(sessionId);
expect(updatedPayment?.status).toBe('failed');
expect(updatedPayment?.subscriptionId).toBe('sub_localized_brl_card_us');
expect(stripeHandlers.spies.createdRefunds).toHaveLength(1);
expect(stripeHandlers.spies.cancelledSubscriptions).toContain('sub_localized_brl_card_us');
const user = await createBuilder<{
premium_type: number;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(user.premium_type).toBe(UserPremiumTypes.NONE);
});
test('continues localized card preapproval into paid checkout when the card matches the requested country', async () => {
const account = await createTestAccount(harness);
server.use(
http.get('https://api.stripe.com/v1/subscriptions', () => {
return HttpResponse.json({
object: 'list',
url: '/v1/subscriptions',
has_more: false,
data: [],
});
}),
);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({stripe_customer_id: 'cus_test_existing'})
.execute();
const preapprovalResponse = await createBuilder<{
url: string;
}>(harness, account.token)
.post('/stripe/checkout/subscription/preapproval')
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
.execute();
const preapprovalSession = stripeHandlers.spies.createdCheckoutSessions[0];
const successUrl = new URL(preapprovalSession?.success_url ?? 'https://example.com');
const token = successUrl.searchParams.get('token');
const preapprovalSessionId = preapprovalResponse.url.split('/').pop();
expect(token).toBeTruthy();
expect(preapprovalSessionId).toBeTruthy();
if (!token || !preapprovalSessionId) {
throw new Error('Expected localized card preapproval token and session id');
}
const webhookResult = await sendWebhook(
createCheckoutCompletedEvent({
sessionId: preapprovalSessionId,
customerId: 'cus_test_existing',
mode: 'setup',
setupIntentId: 'seti_localized_brl_card_br',
metadata: preapprovalSession?.metadata ?? {},
}),
);
expect(webhookResult.received).toBe(true);
const continueResponse = await createBuilder<{
status: string;
url?: string;
}>(harness, '')
.post('/stripe/checkout/subscription/preapproval/continue')
.body({token})
.execute();
expect(continueResponse.status).toBe('ready');
expect(continueResponse.url).toMatch(/^https:\/\/checkout\.stripe\.com/);
expect(stripeHandlers.spies.retrievedSetupIntents).toContain('seti_localized_brl_card_br');
expect(stripeHandlers.spies.updatedCustomers).toContainEqual({
id: 'cus_test_existing',
params: {
invoice_settings: {
default_payment_method: 'pm_localized_brl_card_br',
},
},
});
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(2);
expect(stripeHandlers.spies.createdCheckoutSessions[1]?.mode).toBe('subscription');
expect(stripeHandlers.spies.createdCheckoutSessions[1]?.customer).toBe('cus_test_existing');
expect(stripeHandlers.spies.createdCheckoutSessions[1]?.metadata?.country_code).toBe('BR');
});
test('keeps localized card preapproval rejected when the card country does not match', async () => {
const account = await createTestAccount(harness);
server.use(
http.get('https://api.stripe.com/v1/subscriptions', () => {
return HttpResponse.json({
object: 'list',
url: '/v1/subscriptions',
has_more: false,
data: [],
});
}),
);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({stripe_customer_id: 'cus_test_existing'})
.execute();
const preapprovalResponse = await createBuilder<{
url: string;
}>(harness, account.token)
.post('/stripe/checkout/subscription/preapproval')
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
.execute();
const preapprovalSession = stripeHandlers.spies.createdCheckoutSessions[0];
const successUrl = new URL(preapprovalSession?.success_url ?? 'https://example.com');
const token = successUrl.searchParams.get('token');
const preapprovalSessionId = preapprovalResponse.url.split('/').pop();
expect(token).toBeTruthy();
expect(preapprovalSessionId).toBeTruthy();
if (!token || !preapprovalSessionId) {
throw new Error('Expected localized card preapproval token and session id');
}
const webhookResult = await sendWebhook(
createCheckoutCompletedEvent({
sessionId: preapprovalSessionId,
customerId: 'cus_test_existing',
mode: 'setup',
setupIntentId: 'seti_localized_brl_card_us',
metadata: preapprovalSession?.metadata ?? {},
}),
);
expect(webhookResult.received).toBe(true);
const continueResponse = await createBuilder<{
status: string;
reason?: string;
actual_country?: string | null;
}>(harness, '')
.post('/stripe/checkout/subscription/preapproval/continue')
.body({token})
.execute();
expect(continueResponse.status).toBe('rejected');
expect(continueResponse.reason).toBe('country_mismatch');
expect(continueResponse.actual_country).toBe('US');
expect(stripeHandlers.spies.retrievedSetupIntents).toContain('seti_localized_brl_card_us');
expect(stripeHandlers.spies.updatedCustomers).toHaveLength(0);
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('updates user with Stripe customer ID on first purchase', async () => {
const account = await createTestAccount(harness);
const sessionId = 'cs_first_purchase_customer_123';
@@ -799,38 +593,6 @@ describe('StripeWebhookService - checkout.session.completed', () => {
expect(updatedPayment?.giftCode).not.toBeNull();
expect(stripeHandlers.spies.createdRefunds).toHaveLength(0);
});
test('rejects localized BRL gift checkout when the card is issued outside Brazil', async () => {
const account = await createTestAccount(harness);
const sessionId = 'cs_gift_brl_card_us';
const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository');
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
const paymentRepository = new PaymentRepository();
const userRepository = new UserRepository();
await paymentRepository.createPayment({
checkout_session_id: sessionId,
user_id: createUserID(BigInt(account.userId)),
price_id: MOCK_PRICES.gift1MonthBrl,
product_type: ProductType.GIFT_1_MONTH,
status: 'pending',
is_gift: true,
created_at: new Date(),
});
const eventData = createCheckoutCompletedEvent({
sessionId,
customerId: 'cus_gift_brl_card_us',
paymentIntentId: 'pi_localized_brl_card_us',
amountTotal: 1288,
currency: 'brl',
mode: 'payment',
metadata: {country_code: 'BR'},
});
const result = await sendWebhook(eventData);
expect(result.received).toBe(true);
const updatedPayment = await userRepository.getPaymentByCheckoutSession(sessionId);
expect(updatedPayment?.status).toBe('failed');
expect(updatedPayment?.giftCode).toBeNull();
expect(stripeHandlers.spies.createdRefunds).toHaveLength(1);
});
});
describe('donation checkout', () => {
test('handles donation without email gracefully', async () => {
@@ -71,7 +71,6 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
const refundService = new StripeRefundService(stripe, deps.userRepository, subscriptionService);
const webhookService = new StripeWebhookService(
stripe,
checkoutService,
deps.userRepository,
deps.userCacheService,
sessionTerminator,