mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): drop localized card checks and require pix for brazil (#3203)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user