feat(api)!: always use localized pricing where it is offered (#2646)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-10 21:22:32 +02:00
1 parent 905af5dd5a
commit f708586c59
62 files changed
+3148 -11917

No files matched your search

+14 -17
View File
@@ -13342,13 +13342,10 @@
"name": "country_code",
"in": "query",
"required": false,
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
},
{
"name": "pricing_mode",
"in": "query",
"required": false,
"schema": {"$ref": "#/components/schemas/PricingModeEnum"}
"schema": {
"type": "string",
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
}
}
]
}
@@ -13623,7 +13620,10 @@
"name": "country_code",
"in": "query",
"required": false,
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
"schema": {
"type": "string",
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
}
}
]
}
@@ -32622,13 +32622,9 @@
"localized": {
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
"description": "Localized checkout prices resolved from mirrored billing data"
},
"base": {
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
"description": "Standard USD/EUR checkout prices resolved from mirrored billing data"
}
},
"required": ["country_code", "localized", "base"]
"required": ["country_code", "localized"]
},
"PriceIdsResponse": {
"type": "object",
@@ -32687,7 +32683,6 @@
},
"required": ["disabled"]
},
"PricingModeEnum": {"enum": ["localized", "base"], "type": "string", "x-enumNames": ["localized", "base"]},
"SelfServeRefundResponse": {
"type": "object",
"properties": {
@@ -34016,7 +34011,10 @@
"type": "object",
"properties": {
"price_id": {"type": "string", "description": "The Stripe price ID for the subscription plan"},
"country_code": {"type": "string", "description": "Two-letter country code used for regional pricing"},
"country_code": {
"type": "string",
"description": "Two-letter country code used for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
},
"client_geoip_country_code": {
"type": "string",
"description": "Two-letter country code observed by the client GeoIP store before checkout"
@@ -34025,7 +34023,6 @@
"type": "boolean",
"description": "Whether the EU/EEA digital content withdrawal waiver was expressly accepted before checkout"
},
"pricing_mode": {"$ref": "#/components/schemas/PricingModeEnum"},
"payment_method": {"$ref": "#/components/schemas/CheckoutPaymentMethodEnum"},
"is_business": {
"type": "boolean",
@@ -36836,7 +36833,7 @@
"description": "Controls who sees the full profile: all guild members, only small-guild members, or only friends"
},
"synced_preferences": {
"anyOf": [{"type": "string", "maxLength": 349528}, {"type": "null"}],
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}],
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it."
}
}
@@ -10,6 +10,7 @@ import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../RateLimitConfig';
import type {HonoApp} from '../types/HonoEnv';
import {lookupGeoip} from '../utils/IpUtils';
import {Validator} from '../Validator';
export function PremiumController(app: HonoApp) {
@@ -32,7 +33,8 @@ export function PremiumController(app: HonoApp) {
async (ctx) => {
const userId = ctx.get('user').id;
const {country_code} = ctx.req.valid('query');
const state = await ctx.get('stripeService').getPremiumState(userId, country_code);
const geoip = await lookupGeoip(ctx.req.raw);
const state = await ctx.get('stripeService').getPremiumState(userId, geoip.countryCode ?? country_code);
return ctx.json(state);
},
);
+7 -22
View File
@@ -92,7 +92,6 @@ export function StripeController(app: HonoApp) {
country_code,
client_geoip_country_code,
eu_withdrawal_waiver_accepted,
pricing_mode,
payment_method,
is_business,
} = ctx.req.valid('json');
@@ -105,7 +104,6 @@ export function StripeController(app: HonoApp) {
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
pricingMode: pricing_mode,
paymentMethod: payment_method,
isBusiness: is_business,
});
@@ -129,14 +127,8 @@ export function StripeController(app: HonoApp) {
}),
Validator('json', CreateCheckoutSessionRequest),
async (ctx) => {
const {
price_id,
country_code,
client_geoip_country_code,
eu_withdrawal_waiver_accepted,
pricing_mode,
is_business,
} = ctx.req.valid('json');
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,
@@ -145,7 +137,6 @@ export function StripeController(app: HonoApp) {
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
pricingMode: pricing_mode,
isBusiness: is_business,
});
return ctx.json({url: checkoutUrl});
@@ -187,14 +178,8 @@ export function StripeController(app: HonoApp) {
}),
Validator('json', CreateCheckoutSessionRequest),
async (ctx) => {
const {
price_id,
country_code,
client_geoip_country_code,
eu_withdrawal_waiver_accepted,
pricing_mode,
is_business,
} = ctx.req.valid('json');
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').createCheckoutSession({
userId,
@@ -204,7 +189,6 @@ export function StripeController(app: HonoApp) {
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
pricingMode: pricing_mode,
isBusiness: is_business,
});
return ctx.json({url: checkoutUrl});
@@ -301,8 +285,9 @@ export function StripeController(app: HonoApp) {
tags: 'Premium',
}),
async (ctx) => {
const {country_code, pricing_mode} = ctx.req.valid('query');
const priceIds = await ctx.get('stripeService').getPriceIds(country_code, pricing_mode);
const {country_code} = ctx.req.valid('query');
const geoipCountryCode = await getPurchaseGeoipCountryCode(ctx.req.raw);
const priceIds = await ctx.get('stripeService').getPriceIds(geoipCountryCode ?? country_code);
return ctx.json(priceIds);
},
);
+2 -7
View File
@@ -4,7 +4,6 @@ import {PremiumPurchaseBlockedError} from '@fluxer/errors/src/domains/payment/Pr
import type {
CurrentSubscriptionPriceResponse,
PremiumStateResponse,
PricingMode,
SelfServeRefundEligibilityResponse,
SelfServeRefundResponse,
SwitchToListPriceResponse,
@@ -144,7 +143,6 @@ export class StripeService {
| 'euWithdrawalWaiverAccepted'
| 'isBusiness'
| 'priceId'
| 'pricingMode'
| 'purchaseGeoipCountryCode'
| 'userId'
>,
@@ -160,10 +158,7 @@ export class StripeService {
return this.checkoutService.createCustomerPortalSession(userId);
}
async getPriceIds(
countryCode?: string,
pricingMode: PricingMode = 'localized',
): Promise<{
async getPriceIds(countryCode?: string): Promise<{
monthly: string | null;
yearly: string | null;
gift_1_month: string | null;
@@ -175,7 +170,7 @@ export class StripeService {
gift_1_month_amount_minor: number | null;
gift_1_year_amount_minor: number | null;
}> {
return this.checkoutService.getPriceIds(countryCode, pricingMode);
return this.checkoutService.getPriceIds(countryCode);
}
async getCurrentSubscriptionPrice(userId: UserID): Promise<CurrentSubscriptionPriceResponse> {
@@ -13,7 +13,6 @@ import type {
PremiumPricingState,
PremiumStateResponse,
PriceIdsResponse,
PricingMode,
SelfServeRefundEligibilityResponse,
SelfServeRefundIneligibilityReason,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
@@ -33,13 +32,7 @@ import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
import {checkHasActivePaidPremium} from '../../user/UserHelpers';
import {mapUserToPrivateResponse} from '../../user/UserMappers';
import {
type Currency,
getBaseCurrencyPreferences,
getBaseGiftCurrencyPreferences,
getCurrencyPreferences,
getGiftCurrencyPreferences,
} from '../../utils/CurrencyUtils';
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../../utils/CurrencyUtils';
import type {RecurringBillingCycle} from '../ProductRegistry';
import {ProductRegistry} from '../ProductRegistry';
import {getPrimarySubscriptionItem} from '../StripeSubscriptionPeriod';
@@ -743,22 +736,15 @@ export class PremiumStateService {
private async resolvePricing(countryCode: string | null | undefined): Promise<PremiumPricingState> {
const normalizedCountryCode = normalizeCountryCode(countryCode);
const [localized, base] = await Promise.all([
this.resolvePriceIds(normalizedCountryCode, 'localized'),
this.resolvePriceIds(normalizedCountryCode, 'base'),
]);
const localized = await this.resolvePriceIds(normalizedCountryCode);
return {
country_code: normalizedCountryCode,
localized,
base,
};
}
private async resolvePriceIds(
countryCode: string | null,
pricingMode: PricingMode,
): Promise<PriceIdsResponse | null> {
const resolved = this.resolveConfiguredPriceIds(countryCode, pricingMode);
private async resolvePriceIds(countryCode: string | null): Promise<PriceIdsResponse | null> {
const resolved = this.resolveConfiguredPriceIds(countryCode);
if (!resolved) return null;
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
resolved.monthly ? this.billingRepository.prices.findById(resolved.monthly) : null,
@@ -775,11 +761,9 @@ export class PremiumStateService {
};
}
private resolveConfiguredPriceIds(countryCode: string | null, pricingMode: PricingMode): ResolvedPriceIds | null {
const recurringCurrencyPreferences =
pricingMode === 'base' ? getBaseCurrencyPreferences(countryCode) : getCurrencyPreferences(countryCode);
const giftCurrencyPreferences =
pricingMode === 'base' ? getBaseGiftCurrencyPreferences(countryCode) : getGiftCurrencyPreferences(countryCode);
private resolveConfiguredPriceIds(countryCode: string | null): ResolvedPriceIds | null {
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
if (!recurringPrices || !giftPrices) return null;
@@ -13,7 +13,6 @@ import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment
import {UnclaimedAccountCannotMakePurchasesError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotMakePurchasesError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {CheckoutPaymentMethod} from '@fluxer/schema/src/domains/premium/GiftCodeSchemas';
import type {PricingMode} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {seconds} from 'itty-time';
import type Stripe from 'stripe';
@@ -25,13 +24,7 @@ import {Logger} from '../../Logger';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
import {
type Currency,
getBaseCurrencyPreferences,
getBaseGiftCurrencyPreferences,
getCurrencyPreferences,
getGiftCurrencyPreferences,
} from '../../utils/CurrencyUtils';
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../../utils/CurrencyUtils';
import type {ProductInfo, ProductRegistry} from '../ProductRegistry';
import {
canProvisionPremiumFromSubscriptionStatus,
@@ -86,7 +79,6 @@ export interface CreateCheckoutSessionParams {
clientGeoipCountryCode?: string | null;
purchaseGeoipCountryCode?: string | null;
euWithdrawalWaiverAccepted?: boolean;
pricingMode?: PricingMode;
paymentMethod?: CheckoutPaymentMethod;
isBusiness?: boolean;
}
@@ -182,7 +174,6 @@ export class StripeCheckoutService {
clientGeoipCountryCode,
purchaseGeoipCountryCode,
euWithdrawalWaiverAccepted,
pricingMode = 'localized',
paymentMethod = 'card',
isBusiness = false,
}: CreateCheckoutSessionParams): Promise<string> {
@@ -191,7 +182,7 @@ export class StripeCheckoutService {
priceId,
isGift,
countryCode,
pricingMode,
purchaseGeoipCountryCode,
});
const isRecurringSubscription = this.productRegistry.isRecurringSubscription(productInfo);
const checkoutMode: CheckoutSessionMode = isRecurringSubscription ? 'subscription' : 'payment';
@@ -221,7 +212,6 @@ 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,
pricing_mode: pricingMode,
payment_method: paymentMethod,
};
const checkoutParams: CheckoutSessionCreateParams = {
@@ -301,7 +291,6 @@ export class StripeCheckoutService {
clientGeoipCountryCode,
purchaseGeoipCountryCode,
euWithdrawalWaiverAccepted,
pricingMode = 'localized',
isBusiness = false,
}: Pick<
CreateCheckoutSessionParams,
@@ -310,31 +299,22 @@ export class StripeCheckoutService {
| 'euWithdrawalWaiverAccepted'
| 'isBusiness'
| 'priceId'
| 'pricingMode'
| 'purchaseGeoipCountryCode'
| 'userId'
>): Promise<string> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
const normalizedCountryCode = countryCode?.trim().toUpperCase();
const normalizedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
if (!normalizedCountryCode) {
Logger.error({priceId, userId}, 'Localized card preapproval requires a country code');
throw new StripeInvalidProductConfigurationError();
}
if (pricingMode !== 'localized') {
Logger.error(
{priceId, userId, pricingMode},
'Localized card preapproval requested for non-localized pricing mode',
);
throw new StripeInvalidProductConfigurationError();
}
const {customerId, productInfo} = await this.prepareCheckoutContext({
userId,
priceId,
isGift: false,
countryCode: normalizedCountryCode,
pricingMode,
});
if (!this.requiresLocalizedCardPreapproval(productInfo)) {
Logger.error(
@@ -364,7 +344,6 @@ export class StripeCheckoutService {
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} : {}),
pricing_mode: pricingMode,
setup_type: 'localized_card_preapproval',
localized_card_preapproval_currency: productInfo.currency,
localized_card_preapproval_token: token,
@@ -560,7 +539,7 @@ export class StripeCheckoutService {
priceId,
isGift = false,
countryCode,
pricingMode = 'localized',
purchaseGeoipCountryCode,
}: CreateCheckoutSessionParams): Promise<{
customerId: string;
productInfo: ProductInfo;
@@ -581,12 +560,13 @@ export class StripeCheckoutService {
);
throw new StripeInvalidProductConfigurationError();
}
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !countryCode) {
const enforcedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !enforcedCountryCode) {
Logger.error({priceId, userId, currency: productInfo.currency}, 'Localized price requested without country code');
throw new StripeInvalidProductConfigurationError();
}
if (countryCode) {
this.assertPriceMatchesCountryCatalog({countryCode, priceId, isGift, pricingMode, userId});
if (enforcedCountryCode) {
this.assertPriceMatchesCountryCatalog({countryCode: enforcedCountryCode, priceId, isGift, userId});
}
const user = await this.userRepository.findUnique(userId);
if (!user) {
@@ -703,20 +683,25 @@ export class StripeCheckoutService {
return normalized && /^[A-Z]{2}$/.test(normalized) ? normalized : null;
}
private resolveEnforcedPricingCountryCode({
countryCode,
purchaseGeoipCountryCode,
}: Pick<CreateCheckoutSessionParams, 'countryCode' | 'purchaseGeoipCountryCode'>): string | null {
return this.normalizeCountryCode(purchaseGeoipCountryCode) ?? this.normalizeCountryCode(countryCode);
}
private assertPriceMatchesCountryCatalog({
countryCode,
priceId,
isGift,
pricingMode = 'localized',
userId,
}: {
countryCode: string;
priceId: string;
isGift: boolean;
pricingMode?: PricingMode;
userId: UserID;
}): void {
const localizedPrices = this.resolveConfiguredPriceIds(countryCode, pricingMode);
const localizedPrices = this.resolveConfiguredPriceIds(countryCode);
const allowedPriceIds = new Set(
(isGift
? [localizedPrices.gift_1_month, localizedPrices.gift_1_year]
@@ -731,7 +716,6 @@ export class StripeCheckoutService {
userId,
currency: isGift ? localizedPrices.gift_currency : localizedPrices.currency,
isGift,
pricingMode,
},
'Checkout price mismatch for country',
);
@@ -1022,8 +1006,8 @@ export class StripeCheckoutService {
}
}
async getPriceIds(countryCode?: string, pricingMode: PricingMode = 'localized'): Promise<PriceIdsResponse> {
const resolvedPrices = this.resolveConfiguredPriceIds(countryCode, pricingMode);
async getPriceIds(countryCode?: string): Promise<PriceIdsResponse> {
const resolvedPrices = this.resolveConfiguredPriceIds(countryCode);
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
this.getStripePriceSummary(resolvedPrices.monthly),
this.getStripePriceSummary(resolvedPrices.yearly),
@@ -1057,11 +1041,9 @@ export class StripeCheckoutService {
private static readonly PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
private static readonly PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
private resolveConfiguredPriceIds(countryCode?: string, pricingMode: PricingMode = 'localized'): ResolvedPriceIds {
const recurringCurrencyPreferences =
pricingMode === 'base' ? getBaseCurrencyPreferences(countryCode) : getCurrencyPreferences(countryCode);
const giftCurrencyPreferences =
pricingMode === 'base' ? getBaseGiftCurrencyPreferences(countryCode) : getGiftCurrencyPreferences(countryCode);
private resolveConfiguredPriceIds(countryCode?: string): ResolvedPriceIds {
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
return {
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import Stripe from 'stripe';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {Config} from '../../Config';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {STRIPE_API_VERSION} from '../StripeApiVersion';
const MOCK_PRICES = {
monthlyUsd: 'price_state_pricing_monthly_usd',
yearlyUsd: 'price_state_pricing_yearly_usd',
monthlyBrl: 'price_state_pricing_monthly_brl',
yearlyBrl: 'price_state_pricing_yearly_brl',
gift1MonthUsd: 'price_state_pricing_gift_1_month_usd',
gift1YearUsd: 'price_state_pricing_gift_1_year_usd',
gift1MonthBrl: 'price_state_pricing_gift_1_month_brl',
gift1YearBrl: 'price_state_pricing_gift_1_year_brl',
};
const MOCK_PRICE_SEEDS = {
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.monthlyBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
[MOCK_PRICES.yearlyBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
[MOCK_PRICES.gift1MonthUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.gift1YearUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.gift1MonthBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
[MOCK_PRICES.gift1YearBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
};
describe('PremiumStatePricing', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
async function mirrorPrices(): Promise<void> {
const stripe = new Stripe(Config.stripe.secretKey ?? 'sk_test_fluxer', {
apiVersion: STRIPE_API_VERSION,
httpClient: Stripe.createFetchHttpClient(),
});
for (const priceId of Object.keys(MOCK_PRICE_SEEDS)) {
const price = await stripe.prices.retrieve(priceId);
await getBillingRepository().prices.upsertFromStripe(price);
}
}
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(async () => {
await harness.resetData();
Config.stripe.prices = MOCK_PRICES;
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, subscriptionsListEmpty: true});
server.use(...stripeHandlers.handlers);
await mirrorPrices();
});
test('resolves the localized BRL catalog for a Brazilian request', async () => {
const account = await createTestAccount(harness);
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state?country_code=BR')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.pricing.country_code).toBe('BR');
expect(state.pricing.localized?.currency).toBe('BRL');
expect(state.pricing.localized?.monthly_amount_minor).toBe(1890);
expect(state.pricing.localized?.yearly_amount_minor).toBe(18900);
expect(state.pricing.localized?.monthly).toBe(MOCK_PRICES.monthlyBrl);
expect(state.pricing.localized?.yearly).toBe(MOCK_PRICES.yearlyBrl);
});
test('resolves the USD catalog when the request declares no country', async () => {
const account = await createTestAccount(harness);
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.pricing.country_code).toBeNull();
expect(state.pricing.localized?.currency).toBe('USD');
expect(state.pricing.localized?.monthly_amount_minor).toBe(499);
});
});
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {Config} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
const {lookupGeoipMock} = vi.hoisted(() => ({
lookupGeoipMock: vi.fn(),
}));
vi.mock('../../utils/IpUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/IpUtils')>()),
lookupGeoip: lookupGeoipMock,
}));
const MOCK_PRICES = {
monthlyUsd: 'price_enforce_monthly_usd',
yearlyUsd: 'price_enforce_yearly_usd',
monthlyBrl: 'price_enforce_monthly_brl',
yearlyBrl: 'price_enforce_yearly_brl',
gift1MonthUsd: 'price_enforce_gift_1_month_usd',
gift1YearUsd: 'price_enforce_gift_1_year_usd',
gift1MonthBrl: 'price_enforce_gift_1_month_brl',
gift1YearBrl: 'price_enforce_gift_1_year_brl',
};
const MOCK_PRICE_SEEDS = {
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.monthlyBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
[MOCK_PRICES.yearlyBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
};
function geoipCountry(countryCode: string | null): GeoipResult {
return {
countryCode,
normalizedIp: '203.0.113.10',
city: null,
region: null,
countryName: null,
};
}
describe('StripeCheckoutCountryEnforcement', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
async function createPurchaser(): Promise<string> {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
return account.token;
}
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(() => {
Config.stripe.prices = MOCK_PRICES;
lookupGeoipMock.mockReset();
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, subscriptionsListEmpty: true});
server.use(...stripeHandlers.handlers);
});
test('rejects a base-currency subscription price when the request geolocates to a localized market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a base-currency subscription price when country_code is omitted entirely', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a localized gift price when the request geolocates outside that market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('US'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthBrl, country_code: 'BR'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('accepts the localized price for a request that geolocates to that market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
const response = 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(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('falls back to the declared country when the request cannot be geolocated', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
const token = await createPurchaser();
const response = 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(response.url).toContain('checkout.stripe.com');
});
test('resolves price ids from the request geolocation rather than the query parameter', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const priceIds = await createBuilder<{currency: string; monthly: string | null}>(harness, '')
.get('/premium/price-ids?country_code=US')
.expect(HTTP_STATUS.OK)
.execute();
expect(priceIds.currency).toBe('BRL');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyBrl);
});
});
-15
View File
@@ -31,21 +31,6 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
return ['USD', 'EUR'];
}
export function getBaseCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
if (!countryCode) {
return ['USD', 'EUR'];
}
const upperCode = countryCode.toUpperCase();
if (isEuEeaCountryCode(upperCode)) {
return ['EUR', 'USD'];
}
return ['USD', 'EUR'];
}
export function getGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
return getCurrencyPreferences(countryCode);
}
export function getBaseGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
return getBaseCurrencyPreferences(countryCode);
}