mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
feat(api)!: always use localized pricing where it is offered (#2646)
This commit is contained in:
1 parent
905af5dd5a
commit
f708586c59
62 files changed
+3148
-11917
No files matched your search
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user