feat(api): retire prices safely and add a self-serve switch (#2637)

This commit is contained in:
Hampus
2026-09-10 17:28:16 +02:00
committed by GitHub
parent d79cd99050
commit 4a93b677af
77 changed files with 9242 additions and 2542 deletions
+55
View File
@@ -81,6 +81,22 @@ function withUploadRelaySecret(master: MasterConfig, secretBase64: string): Mast
};
}
function withStripeLegacyPrices(
master: MasterConfig,
legacyPrices: Record<string, Array<string> | undefined> | undefined,
): MasterConfig {
return {
...master,
integrations: {
...master.integrations,
stripe: {
...master.integrations.stripe,
legacy_prices: legacyPrices,
},
},
};
}
describe('buildAPIConfigFromMaster upload relay secret', () => {
let master: MasterConfig;
beforeAll(async () => {
@@ -111,3 +127,42 @@ describe('buildAPIConfigFromMaster upload relay secret', () => {
);
});
});
describe('buildAPIConfigFromMaster stripe legacy prices', () => {
let master: MasterConfig;
beforeAll(async () => {
master = await loadConfig();
});
it('carries the retired stripe price map from master config onto the api config', () => {
const legacyPrices = {
monthly_brl: ['price_retired_monthly_brl'],
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up'],
};
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, legacyPrices)).stripe.legacyPrices).toEqual(
legacyPrices,
);
});
it('carries the retired price map even when no live prices are configured', () => {
const withoutPrices: MasterConfig = {
...master,
integrations: {
...master.integrations,
stripe: {
...master.integrations.stripe,
prices: undefined,
legacy_prices: {monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']},
},
},
};
const config = buildAPIConfigFromMaster(withoutPrices);
expect(config.stripe.prices).toBeUndefined();
expect(config.stripe.legacyPrices).toEqual({monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']});
});
it('leaves the retired price map undefined when master config does not set one', () => {
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
});
});
+1
View File
@@ -395,6 +395,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
gift1YearTry: master.integrations.stripe.prices.gift_1_year_try,
}
: undefined,
legacyPrices: master.integrations.stripe.legacy_prices,
},
bunny: {
purgeEnabled: master.integrations.bunny.purge_enabled,
+1
View File
@@ -248,6 +248,7 @@ export interface APIConfig {
gift1YearPln?: string;
gift1YearTry?: string;
};
legacyPrices?: Record<string, Array<string> | undefined>;
};
bunny: {
purgeEnabled: boolean;
+307
View File
@@ -13628,6 +13628,73 @@
]
}
},
"/premium/switch-to-list-price": {
"post": {
"operationId": "switch_subscription_to_list_price",
"summary": "Switch subscription to the current list price",
"tags": ["Premium"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SwitchToListPriceResponse"}}}
},
"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": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"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"}}}
}
},
"x-mint": {"metadata": {"title": "Switch subscription to the current list price"}},
"description": "Moves the authenticated user's grandfathered premium subscription down to the current list price for the same currency and billing cycle, effective at the end of the current billing period. The target price is resolved on the server and the switch is refused unless it lowers the amount charged.",
"security": [{"sessionToken": []}]
}
},
"/premium/visionary/rejoin": {
"post": {
"operationId": "rejoin_visionary_guild",
@@ -32194,6 +32261,7 @@
"pending_subscription_change": {
"anyOf": [{"$ref": "#/components/schemas/PendingSubscriptionChangeResponse"}, {"type": "null"}]
},
"list_price_switch": {"$ref": "#/components/schemas/ListPriceSwitchState"},
"subscription": {
"anyOf": [{"$ref": "#/components/schemas/PremiumBillingSubscriptionResponse"}, {"type": "null"}]
},
@@ -32209,6 +32277,7 @@
"stripe_customer_id",
"current_subscription_price",
"pending_subscription_change",
"list_price_switch",
"subscription",
"invoices",
"invoices_has_more",
@@ -32241,6 +32310,12 @@
"type": "string",
"description": "Stripe subscription schedule ID managing the pending change"
},
"change_kind": {
"enum": ["billing_cycle", "price"],
"type": "string",
"x-enumNames": ["billing_cycle", "price"],
"description": "Whether the pending change moves the billing cycle or only the price within the same cycle"
},
"current_billing_cycle": {
"anyOf": [
{"enum": ["monthly", "yearly"], "type": "string", "x-enumNames": ["monthly", "yearly"]},
@@ -32263,6 +32338,10 @@
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Stripe price ID that will be used after the change"
},
"target_amount_minor": {
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
"description": "Unit amount of the price that will be used after the change, in the currency minor unit"
},
"currency": {
"anyOf": [
{
@@ -32289,11 +32368,13 @@
},
"required": [
"schedule_id",
"change_kind",
"current_billing_cycle",
"target_billing_cycle",
"effective_at",
"current_price_id",
"target_price_id",
"target_amount_minor",
"currency",
"initial_amount_minor",
"recurring_amount_minor",
@@ -32303,6 +32384,104 @@
{"type": "null"}
]
},
"ListPriceSwitchState": {
"type": "object",
"properties": {
"available": {
"type": "boolean",
"description": "Whether the authenticated user can move their subscription down to the current list price right now"
},
"reason": {
"anyOf": [{"$ref": "#/components/schemas/ListPriceSwitchIneligibilityReason"}, {"type": "null"}],
"description": "Why the switch is unavailable, when available is false"
},
"pending": {
"type": "boolean",
"description": "Whether a switch to the current list price is already scheduled"
},
"current_price_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Stripe price ID the subscription is billed against today"
},
"current_amount_minor": {
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
"description": "Amount the subscription is billed today, in the currency minor unit"
},
"list_price_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Current list Stripe price ID for the same cycle and currency"
},
"list_amount_minor": {
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
"description": "Current list price for the same cycle and currency, in the currency minor unit"
},
"currency": {
"anyOf": [
{
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
},
{"type": "null"}
],
"description": "Currency of both the current and the list amount"
},
"billing_cycle": {
"anyOf": [
{"enum": ["monthly", "yearly"], "type": "string", "x-enumNames": ["monthly", "yearly"]},
{"type": "null"}
],
"description": "Recurring billing cycle the switch applies to"
},
"effective_at": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "ISO timestamp the switch takes effect, which is the end of the current billing period"
}
},
"required": [
"available",
"reason",
"pending",
"current_price_id",
"current_amount_minor",
"list_price_id",
"list_amount_minor",
"currency",
"billing_cycle",
"effective_at"
]
},
"ListPriceSwitchIneligibilityReason": {
"enum": [
"feature_unavailable",
"no_active_subscription",
"subscription_not_chargeable",
"unsupported_subscription",
"no_list_price",
"already_on_list_price",
"not_a_price_decrease",
"subscription_cancelling",
"cancellation_managed_by_schedule",
"conflicting_pending_change",
"missing_period_end",
"switch_in_progress"
],
"type": "string",
"x-enumNames": [
"feature_unavailable",
"no_active_subscription",
"subscription_not_chargeable",
"unsupported_subscription",
"no_list_price",
"already_on_list_price",
"not_a_price_decrease",
"subscription_cancelling",
"cancellation_managed_by_schedule",
"conflicting_pending_change",
"missing_period_end",
"switch_in_progress"
]
},
"PremiumBillingSubscriptionResponse": {
"type": "object",
"properties": {
@@ -32544,6 +32723,134 @@
"status"
]
},
"SwitchToListPriceResponse": {
"oneOf": [
{"$ref": "#/components/schemas/ScheduledSwitchToListPriceResponse"},
{"$ref": "#/components/schemas/AlreadyScheduledSwitchToListPriceResponse"},
{"$ref": "#/components/schemas/IneligibleSwitchToListPriceResponse"}
]
},
"ScheduledSwitchToListPriceResponse": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["scheduled"],
"description": "The switch was scheduled for the end of the current billing period"
},
"effective_at": {"type": "string", "description": "ISO timestamp the switch takes effect"},
"target_price_id": {
"type": "string",
"description": "Stripe price ID the subscription will be billed against after the switch"
},
"target_amount_minor": {
"type": "integer",
"format": "int53",
"description": "Amount billed after the switch, in the currency minor unit"
},
"current_amount_minor": {
"type": "integer",
"format": "int53",
"description": "Amount billed before the switch, in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"description": "Currency of both amounts"
}
},
"required": [
"status",
"effective_at",
"target_price_id",
"target_amount_minor",
"current_amount_minor",
"currency"
]
},
"AlreadyScheduledSwitchToListPriceResponse": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["already_scheduled"],
"description": "The switch was already scheduled by an earlier request"
},
"effective_at": {"type": "string", "description": "ISO timestamp the switch takes effect"},
"target_price_id": {
"type": "string",
"description": "Stripe price ID the subscription will be billed against after the switch"
},
"target_amount_minor": {
"type": "integer",
"format": "int53",
"description": "Amount billed after the switch, in the currency minor unit"
},
"current_amount_minor": {
"type": "integer",
"format": "int53",
"description": "Amount billed before the switch, in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"description": "Currency of both amounts"
}
},
"required": [
"status",
"effective_at",
"target_price_id",
"target_amount_minor",
"current_amount_minor",
"currency"
]
},
"IneligibleSwitchToListPriceResponse": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["ineligible"],
"description": "The subscription cannot be moved to the current list price"
},
"reason": {
"enum": [
"feature_unavailable",
"no_active_subscription",
"subscription_not_chargeable",
"unsupported_subscription",
"no_list_price",
"already_on_list_price",
"not_a_price_decrease",
"subscription_cancelling",
"cancellation_managed_by_schedule",
"conflicting_pending_change",
"missing_period_end",
"switch_in_progress"
],
"type": "string",
"x-enumNames": [
"feature_unavailable",
"no_active_subscription",
"subscription_not_chargeable",
"unsupported_subscription",
"no_list_price",
"already_on_list_price",
"not_a_price_decrease",
"subscription_cancelling",
"cancellation_managed_by_schedule",
"conflicting_pending_change",
"missing_period_end",
"switch_in_progress"
],
"description": "Why the switch was refused"
}
},
"required": ["status", "reason"]
},
"ReadStateAckResponse": {
"type": "object",
"properties": {
@@ -2,6 +2,7 @@
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {Config} from '../Config';
import {Logger} from '../Logger';
import type {Currency} from '../utils/CurrencyUtils';
export enum ProductType {
@@ -22,10 +23,66 @@ export interface ProductInfo {
billingCycle?: RecurringBillingCycle;
}
const LEGACY_SLOT_SHAPES: Record<string, Omit<ProductInfo, 'currency'> | undefined> = {
monthly: {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
billingCycle: 'monthly',
},
yearly: {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
billingCycle: 'yearly',
},
gift_1_month: {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
},
gift_1_year: {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
},
};
const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
usd: 'USD',
eur: 'EUR',
brl: 'BRL',
inr: 'INR',
pln: 'PLN',
try: 'TRY',
};
function parseLegacySlot(slot: string): ProductInfo | null {
const separatorIndex = slot.lastIndexOf('_');
if (separatorIndex <= 0) {
return null;
}
const shape = LEGACY_SLOT_SHAPES[slot.slice(0, separatorIndex)];
const currency = LEGACY_SLOT_CURRENCIES[slot.slice(separatorIndex + 1).toLowerCase()];
if (!shape || !currency) {
return null;
}
return {...shape, currency};
}
export class ProductRegistry {
private products = new Map<string, ProductInfo>();
constructor() {
this.registerConfiguredProducts();
this.registerLegacyProducts();
}
private registerConfiguredProducts(): void {
const prices = Config.stripe.prices;
if (!prices) return;
this.registerProduct(prices.monthlyUsd, {
@@ -210,6 +267,32 @@ export class ProductRegistry {
});
}
private registerLegacyProducts(): void {
const legacyPrices = Config.stripe.legacyPrices;
if (!legacyPrices) return;
if (typeof legacyPrices !== 'object' || Array.isArray(legacyPrices)) {
Logger.warn({}, 'Ignoring legacy Stripe price configuration that is not an object of slot names to price ids');
return;
}
for (const [slot, priceIds] of Object.entries(legacyPrices)) {
if (!Array.isArray(priceIds)) {
Logger.warn({slot}, 'Ignoring legacy Stripe price slot that is not a list of price IDs');
continue;
}
const info = parseLegacySlot(slot);
if (!info) {
Logger.warn({slot}, 'Ignoring legacy Stripe price slot with an unrecognised name or currency');
continue;
}
for (const priceId of priceIds) {
if (!priceId || this.products.has(priceId)) {
continue;
}
this.products.set(priceId, info);
}
}
}
private registerProduct(priceId: string | undefined, info: ProductInfo): void {
if (priceId) {
this.products.set(priceId, info);
@@ -18,6 +18,7 @@ import {
PriceIdsResponse,
SelfServeRefundEligibilityResponse,
SelfServeRefundResponse,
SwitchToListPriceResponse,
UrlResponse,
WebhookReceivedResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
@@ -494,6 +495,27 @@ export function StripeController(app: HonoApp) {
return ctx.body(null, 204);
},
);
app.post(
'/premium/switch-to-list-price',
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'switch_subscription_to_list_price',
summary: 'Switch subscription to the current list price',
description:
"Moves the authenticated user's grandfathered premium subscription down to the current list price for the same currency and billing cycle, effective at the end of the current billing period. The target price is resolved on the server and the switch is refused unless it lowers the amount charged.",
responseSchema: SwitchToListPriceResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Premium',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const result = await ctx.get('stripeService').switchSubscriptionToCurrentListPrice(userId);
return ctx.json(result);
},
);
app.post(
'/premium/cancel-pending-subscription-change',
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
@@ -7,6 +7,7 @@ import type {
PricingMode,
SelfServeRefundEligibilityResponse,
SelfServeRefundResponse,
SwitchToListPriceResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import Stripe from 'stripe';
@@ -201,6 +202,10 @@ export class StripeService {
return this.subscriptionService.changeBillingCycle(userId, billingCycle, effectiveAt);
}
async switchSubscriptionToCurrentListPrice(userId: UserID): Promise<SwitchToListPriceResponse> {
return this.subscriptionService.switchToCurrentListPrice(userId);
}
async cancelPendingSubscriptionChange(userId: UserID): Promise<void> {
return this.subscriptionService.cancelPendingSubscriptionChange(userId);
}
@@ -4,6 +4,8 @@ import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstant
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
CurrentSubscriptionPriceResponse,
ListPriceSwitchState,
PendingSubscriptionChangeKind,
PendingSubscriptionChangeResponse,
PremiumBillingInvoiceResponse,
PremiumBillingPaymentMethodResponse,
@@ -264,6 +266,9 @@ export class PremiumStateService {
this.resolvePricing(countryCode),
]);
const refundEligibilityState = await this.resolveRefundEligibility(user, invoices.allRows);
const listPriceSwitch = this.resolveListPriceSwitch(subscription, subscriptionPrice, pendingSubscriptionChange);
const pendingBillingCycleChange =
pendingSubscriptionChange?.change_kind === 'billing_cycle' ? pendingSubscriptionChange : null;
const activePaidPremium = checkHasActivePaidPremium(user);
const isEffectivePremium = user.isPremium();
const actualPremiumEndAt = user.effectivePremiumUntil;
@@ -298,7 +303,8 @@ export class PremiumStateService {
billing: {
stripe_customer_id: customerIds[0] ?? user.stripeCustomerId ?? null,
current_subscription_price: subscriptionPrice,
pending_subscription_change: pendingSubscriptionChange,
pending_subscription_change: pendingBillingCycleChange,
list_price_switch: listPriceSwitch,
subscription: subscription ? await this.mapSubscription(subscription) : null,
invoices: invoices.rows.map(mapInvoice),
invoices_has_more: invoices.hasMore,
@@ -576,8 +582,16 @@ export class PremiumStateService {
const targetItem = futurePhase.items[0] ?? null;
const targetPriceDetails = await this.resolveStripePriceDetails(targetItem?.price ?? null);
const metadataTargetBillingCycle = normalizeBillingCycle(schedule.metadata?.pending_billing_cycle);
const targetBillingCycle = targetPriceDetails.billingCycle ?? metadataTargetBillingCycle;
if (!targetBillingCycle || targetBillingCycle === currentBillingCycle) {
const targetBillingCycle = targetPriceDetails.billingCycle ?? metadataTargetBillingCycle ?? currentBillingCycle;
if (!targetBillingCycle) {
return null;
}
const changeKind: PendingSubscriptionChangeKind =
targetBillingCycle === currentBillingCycle ? 'price' : 'billing_cycle';
if (
changeKind === 'price' &&
(targetPriceDetails.priceId == null || targetPriceDetails.priceId === currentPriceDetails.priceId)
) {
return null;
}
const quantity = targetItem?.quantity ?? currentItem?.quantity ?? 1;
@@ -598,11 +612,13 @@ export class PremiumStateService {
const creditAmountMinor = firstInvoiceAdjustmentTotal < 0 ? -firstInvoiceAdjustmentTotal : null;
return {
schedule_id: schedule.id,
change_kind: changeKind,
current_billing_cycle: currentBillingCycle,
target_billing_cycle: targetBillingCycle,
effective_at: new Date(futurePhase.start_date * 1000).toISOString(),
current_price_id: currentPriceDetails.priceId,
target_price_id: targetPriceDetails.priceId,
target_amount_minor: targetPriceDetails.amountMinor,
currency: targetPriceDetails.currency,
initial_amount_minor: initialAmountMinor,
recurring_amount_minor: recurringAmountMinor,
@@ -610,6 +626,62 @@ export class PremiumStateService {
};
}
private resolveListPriceSwitch(
subscription: BillingSubscriptionRow | null,
subscriptionPrice: CurrentSubscriptionPriceResponse,
pendingChange: PendingSubscriptionChangeResponse,
): ListPriceSwitchState {
const base = {
pending: false,
current_price_id: subscriptionPrice?.price_id ?? null,
current_amount_minor: subscriptionPrice?.amount_minor ?? null,
list_price_id: subscriptionPrice?.list_price_id ?? null,
list_amount_minor: subscriptionPrice?.list_amount_minor ?? null,
currency: subscriptionPrice?.currency ?? null,
billing_cycle: subscriptionPrice?.billing_cycle ?? null,
effective_at: toIso(subscription?.current_period_end),
};
if (Config.instance.selfHosted || !Config.stripe.enabled || !Config.stripe.secretKey) {
return {...base, available: false, reason: 'feature_unavailable'};
}
if (!subscription) {
return {...base, available: false, reason: 'no_active_subscription'};
}
if (!subscriptionPrice) {
return {...base, available: false, reason: 'unsupported_subscription'};
}
if (!subscription.status || !ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
return {...base, available: false, reason: 'subscription_not_chargeable'};
}
if (subscription.cancel_at != null || subscription.cancel_at_period_end === true) {
return {...base, available: false, reason: 'subscription_cancelling'};
}
if (pendingChange) {
const targetsListPrice =
subscriptionPrice.list_price_id != null && pendingChange.target_price_id === subscriptionPrice.list_price_id;
return {
...base,
available: false,
reason: targetsListPrice ? null : 'conflicting_pending_change',
pending: targetsListPrice,
effective_at: pendingChange.effective_at,
};
}
if (subscriptionPrice.list_price_id == null || subscriptionPrice.list_amount_minor == null) {
return {...base, available: false, reason: 'no_list_price'};
}
if (subscriptionPrice.list_price_id === subscriptionPrice.price_id) {
return {...base, available: false, reason: 'already_on_list_price'};
}
if (subscriptionPrice.list_amount_minor >= subscriptionPrice.amount_minor) {
return {...base, available: false, reason: 'not_a_price_decrease'};
}
if (base.effective_at == null) {
return {...base, available: false, reason: 'missing_period_end'};
}
return {...base, available: true, reason: null};
}
private async resolveStripePriceDetails(
priceRef: Stripe.Price | Stripe.DeletedPrice | string | null | undefined,
): Promise<StripePriceDetails> {
@@ -5,11 +5,13 @@ import {PremiumFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import type {IEmailService} from '@pkgs/email/src/IEmailService';
import type Stripe from 'stripe';
import type {UserRow} from '../../database/types/UserTypes';
import type {IDonationRepository} from '../../donation/IDonationRepository';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {KVAccountDeletionQueueService} from '../../infrastructure/KVAccountDeletionQueueService';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import {Logger} from '../../Logger';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import type {GiftCode} from '../../models/GiftCode';
import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
@@ -19,6 +21,8 @@ import {extractId} from '../StripeUtils';
import type {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
import type {StripePaymentFraudService} from './StripePaymentFraudService';
export const REFUND_ALLOWANCE_CLAIM_PREFIX = 'refund-allowance';
export class StripeDisputeWebhookHandler {
constructor(
private userRepository: IUserRepository,
@@ -105,7 +109,7 @@ export class StripeDisputeWebhookHandler {
}
Logger.debug(
{userId: payment.userId},
'User unsuspended after chargeback withdrawal - 30 day purchase block applied',
'User unsuspended after chargeback withdrawal - 30 day self-serve refund cooldown applied',
);
}
}
@@ -167,25 +171,94 @@ export class StripeDisputeWebhookHandler {
);
user = foundUser;
}
if (!user.firstRefundAt) {
const updatedUser = await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
await this.dispatchUser(updatedUser);
const claimKeys = await this.resolveRefundAllowanceClaimKeys(charge);
if (claimKeys.length === 0) {
Logger.debug(
{userId: user.id, chargeId: charge.id, paymentIntentId},
'First refund recorded - 30 day purchase block applied',
);
} else {
const updatedUser = await this.userRepository.patchUpsert(
user.id,
{premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED},
user.toRow(),
);
await this.dispatchUser(updatedUser);
Logger.debug(
{userId: user.id, chargeId: charge.id, paymentIntentId},
'Second refund recorded - permanent purchase block applied',
'Refund was issued by Fluxer - not counted against the user refund allowance',
);
return;
}
const claimedKeys: Array<string> = [];
let alreadyCounted = false;
for (const claimKey of claimKeys) {
const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey);
if (claim === 'claimed') {
claimedKeys.push(claimKey);
} else {
alreadyCounted = true;
}
}
if (alreadyCounted) {
for (const claimKey of claimedKeys) {
await getBillingRepository().webhookEvents.markProcessed(claimKey);
}
Logger.debug(
{userId: user.id, chargeId: charge.id, paymentIntentId, claimKeys},
'Refund already counted against the user refund allowance',
);
return;
}
const isFirstRefund = !user.firstRefundAt;
const patch: Partial<UserRow> = isFirstRefund
? {first_refund_at: new Date()}
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
let updatedUser: User;
try {
updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow());
} catch (error) {
for (const claimKey of claimedKeys) {
await getBillingRepository().webhookEvents.releaseClaim(claimKey);
}
throw error;
}
for (const claimKey of claimedKeys) {
await getBillingRepository().webhookEvents.markProcessed(claimKey);
}
await this.dispatchUser(updatedUser);
Logger.debug(
{userId: user.id, chargeId: charge.id, paymentIntentId},
isFirstRefund
? 'First refund recorded - 30 day self-serve refund cooldown applied'
: 'Second refund recorded - permanent purchase block applied',
);
}
private async resolveRefundAllowanceClaimKeys(charge: Stripe.Charge): Promise<Array<string>> {
const chargeClaimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${charge.id}`;
const inlined = charge.refunds?.data ?? [];
const refunds = (
inlined.length > 0
? inlined.map((refund) => ({
id: refund.id,
createdAtMs: refund.created * 1000,
status: refund.status,
rejectionReason: refund.metadata?.rejection_reason ?? null,
}))
: (await getBillingRepository().refunds.listByCharge(charge.id)).map((row) => ({
id: row.provider_id,
createdAtMs: row.stripe_created_at?.getTime() ?? 0,
status: row.status,
rejectionReason: row.metadata?.get('rejection_reason') ?? null,
}))
).filter((refund) => refund.status !== 'failed' && refund.status !== 'canceled');
if (refunds.length === 0) {
Logger.warn(
{chargeId: charge.id},
'No refund records found for refunded charge; counting the charge once against the user refund allowance',
);
return [chargeClaimKey];
}
const customerRefunds = refunds
.filter((refund) => refund.rejectionReason === null)
.sort((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id));
const earliest = customerRefunds[0];
const latest = customerRefunds[customerRefunds.length - 1];
if (!earliest || !latest) {
return [];
}
const latestClaimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${latest.id}`;
return latest.id === earliest.id ? [chargeClaimKey, latestClaimKey] : [latestClaimKey];
}
private async handleGiftChargeback(giftCode: GiftCode, dispute: Stripe.Dispute): Promise<void> {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
import {FeatureNotAvailableSelfHostedError} from '@fluxer/errors/src/domains/core/FeatureNotAvailableSelfHostedError';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import {StripeNoPurchaseHistoryError} from '@fluxer/errors/src/domains/payment/StripeNoPurchaseHistoryError';
@@ -15,11 +16,13 @@ import type {
import type Stripe from 'stripe';
import {createUserID, type UserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import type {UserRow} from '../../database/types/UserTypes';
import {Logger} from '../../Logger';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
import {extractId} from '../StripeUtils';
import {REFUND_ALLOWANCE_CLAIM_PREFIX} from './StripeDisputeWebhookHandler';
import type {StripeSubscriptionService} from './StripeSubscriptionService';
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -260,16 +263,35 @@ export class StripeRefundService {
try {
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
} catch (error) {
Logger.warn(
Logger.error(
{error, userId: user.id.toString(), subscriptionId},
'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook',
'Self-serve refund confirmed but subscription cancellation failed; the subscription is still active and will keep billing until it is cancelled manually',
);
}
}
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
const claimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${refund.id}`;
const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey);
if (claim !== 'claimed') {
Logger.debug(
{userId: user.id.toString(), refundId: refund.id, claim},
'Self-serve refund already counted against the user refund allowance',
);
return;
}
const isFirstRefund = !user.firstRefundAt;
const patch: Partial<UserRow> = isFirstRefund
? {first_refund_at: new Date()}
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
try {
await this.userRepository.patchUpsert(user.id, patch, user.toRow());
} catch (error) {
await getBillingRepository().webhookEvents.releaseClaim(claimKey);
throw error;
}
await getBillingRepository().webhookEvents.markProcessed(claimKey);
Logger.info(
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null},
'Self-serve refund confirmed succeeded; cooldown and cancellation finalized',
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null, isFirstRefund},
'Self-serve refund confirmed succeeded; refund allowance and cancellation finalized',
);
}
@@ -10,7 +10,10 @@ import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment
import {StripeSubscriptionAlreadyCancelingError} from '@fluxer/errors/src/domains/payment/StripeSubscriptionAlreadyCancelingError';
import {StripeSubscriptionNotCancelingError} from '@fluxer/errors/src/domains/payment/StripeSubscriptionNotCancelingError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {CurrentSubscriptionPriceResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {
CurrentSubscriptionPriceResponse,
SwitchToListPriceResponse,
} 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,7 +28,7 @@ import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
import {mapUserToPrivateResponse} from '../../user/UserMappers';
import type {Currency} from '../../utils/CurrencyUtils';
import type {RecurringBillingCycle} from '../ProductRegistry';
import type {ProductInfo, RecurringBillingCycle} from '../ProductRegistry';
import {
getPrimarySubscriptionItem,
getSubscriptionEntitlementPeriodEndUnix,
@@ -36,12 +39,80 @@ import {extractId} from '../StripeUtils';
type BillingCycleChangeEffectiveAt = 'now' | 'period_end';
const CHARGEABLE_SUBSCRIPTION_STATUSES = new Set<Stripe.Subscription.Status>(['active', 'trialing']);
function mapStripeIds(values: Array<string | {id: string}> | null | undefined): Array<string> | null {
const ids = (values ?? []).map((value) => extractId(value)).filter((id): id is string => id !== null);
return ids.length > 0 ? ids : null;
}
function mapSchedulePhaseDiscounts(
discounts:
| Array<{
coupon: string | {id: string} | null;
discount: unknown;
promotion_code: string | {id: string} | null;
}>
| null
| undefined,
): Array<Stripe.SubscriptionScheduleUpdateParams.Phase.Discount> | null {
const mapped = (discounts ?? [])
.map((discount): Stripe.SubscriptionScheduleUpdateParams.Phase.Discount | null => {
const coupon = extractId(discount.coupon);
if (coupon) {
return {coupon};
}
const promotionCode = extractId(discount.promotion_code);
if (promotionCode) {
return {promotion_code: promotionCode};
}
return typeof discount.discount === 'string' ? {discount: discount.discount} : null;
})
.filter((discount): discount is Stripe.SubscriptionScheduleUpdateParams.Phase.Discount => discount !== null);
return mapped.length > 0 ? mapped : null;
}
function mapSchedulePhaseAddInvoiceItems(
addInvoiceItems: Array<Stripe.SubscriptionSchedule.Phase.AddInvoiceItem> | null | undefined,
): Array<Stripe.SubscriptionScheduleUpdateParams.Phase.AddInvoiceItem> | null {
const mapped = (addInvoiceItems ?? [])
.map((addInvoiceItem) => {
const price = extractId(addInvoiceItem.price);
if (!price) {
return null;
}
const mappedItem: Stripe.SubscriptionScheduleUpdateParams.Phase.AddInvoiceItem = {
price,
quantity: addInvoiceItem.quantity ?? 1,
period: addInvoiceItem.period,
};
const taxRates = mapStripeIds(addInvoiceItem.tax_rates);
if (taxRates) {
mappedItem.tax_rates = taxRates;
}
const discounts = mapSchedulePhaseDiscounts(addInvoiceItem.discounts);
if (discounts) {
mappedItem.discounts = discounts;
}
if (addInvoiceItem.metadata) {
mappedItem.metadata = addInvoiceItem.metadata;
}
return mappedItem;
})
.filter(
(addInvoiceItem): addInvoiceItem is Stripe.SubscriptionScheduleUpdateParams.Phase.AddInvoiceItem =>
addInvoiceItem !== null,
);
return mapped.length > 0 ? mapped : null;
}
export class StripeSubscriptionService {
constructor(
private stripe: Stripe | null,
private userRepository: IUserRepository,
private productRegistry: {
getRecurringSubscriptionPriceId: (billingCycle: RecurringBillingCycle, currency: string) => string | null;
getProduct: (priceId: string) => ProductInfo | null;
},
private cacheService: ICacheService,
private gatewayService: IGatewayService,
@@ -251,7 +322,7 @@ export class StripeSubscriptionService {
return;
}
await this.stripe.subscriptionSchedules.release(schedule.id, {
preserve_cancel_date: false,
preserve_cancel_date: Boolean(subscription.cancel_at || subscription.cancel_at_period_end),
});
const releasedSubscription = await this.stripe.subscriptions.retrieve(subscription.id, {
expand: ['items.data.price'],
@@ -268,7 +339,7 @@ export class StripeSubscriptionService {
);
}
const patch: Record<string, unknown> = {
premium_will_cancel: false,
premium_will_cancel: Boolean(releasedSubscription.cancel_at || releasedSubscription.cancel_at_period_end),
};
const computedPremiumUntil = getSubscriptionPremiumPeriodEnd(releasedSubscription);
if (computedPremiumUntil) {
@@ -433,6 +504,142 @@ export class StripeSubscriptionService {
}
}
async switchToCurrentListPrice(userId: UserID): Promise<SwitchToListPriceResponse> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
const user = await this.userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (!user.stripeSubscriptionId) {
throw new StripeNoActiveSubscriptionError();
}
const lockKey = `list_price_switch_lock:${user.id}`;
const lockToken = await this.cacheService.acquireLock(
lockKey,
StripeSubscriptionService.LIST_PRICE_SWITCH_LOCK_TTL_SECONDS,
);
if (!lockToken) {
Logger.debug(
{userId, subscriptionId: user.stripeSubscriptionId, lockKey},
'List price switch skipped because another switch is already in flight',
);
return {status: 'ineligible', reason: 'switch_in_progress'};
}
try {
const subscription = await this.stripe.subscriptions.retrieve(user.stripeSubscriptionId, {
expand: ['items.data.price', 'schedule'],
});
const item = getPrimarySubscriptionItem(subscription);
if (!item?.id || !item.price?.recurring || !item.price.currency || item.price.unit_amount == null) {
return {status: 'ineligible', reason: 'unsupported_subscription'};
}
if (!CHARGEABLE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
return {status: 'ineligible', reason: 'subscription_not_chargeable'};
}
if (subscription.cancel_at != null || subscription.cancel_at_period_end) {
return {status: 'ineligible', reason: 'subscription_cancelling'};
}
const billingCycle = this.getBillingCycleFromInterval(item.price.recurring.interval);
if (!billingCycle) {
return {status: 'ineligible', reason: 'unsupported_subscription'};
}
const currency = item.price.currency.toUpperCase() as Currency;
const targetPriceId = this.productRegistry.getRecurringSubscriptionPriceId(billingCycle, currency);
if (!targetPriceId) {
return {status: 'ineligible', reason: 'no_list_price'};
}
if (targetPriceId === item.price.id) {
return {status: 'ineligible', reason: 'already_on_list_price'};
}
const targetProduct = this.productRegistry.getProduct(targetPriceId);
if (!targetProduct || targetProduct.currency !== currency) {
return {status: 'ineligible', reason: 'no_list_price'};
}
const targetAmountMinor = await this.getListPriceAmountMinor(targetPriceId);
const currentAmountMinor = item.price.unit_amount;
if (targetAmountMinor == null) {
return {status: 'ineligible', reason: 'no_list_price'};
}
if (targetAmountMinor >= currentAmountMinor) {
return {status: 'ineligible', reason: 'not_a_price_decrease'};
}
const periodEnd = getSubscriptionEntitlementPeriodEndUnix(subscription, item);
if (!periodEnd || periodEnd <= Math.floor(Date.now() / 1000)) {
return {status: 'ineligible', reason: 'missing_period_end'};
}
const switched = {
effective_at: new Date(periodEnd * 1000).toISOString(),
target_price_id: targetPriceId,
target_amount_minor: targetAmountMinor,
current_amount_minor: currentAmountMinor,
currency,
};
const schedule = await this.loadSubscriptionSchedule(subscription.schedule);
const pendingSchedule =
schedule && (schedule.status === 'active' || schedule.status === 'not_started') ? schedule : null;
if (pendingSchedule) {
if (this.subscriptionScheduleHasFutureTargetPrice(pendingSchedule, targetPriceId)) {
Logger.debug(
{userId, subscriptionId: subscription.id, scheduleId: pendingSchedule.id, targetPriceId},
'List price switch already scheduled for period end',
);
return {status: 'already_scheduled', ...switched};
}
if (pendingSchedule.end_behavior === 'cancel') {
return {status: 'ineligible', reason: 'cancellation_managed_by_schedule'};
}
if (this.subscriptionScheduleHasPendingBillingCycleChange(pendingSchedule, subscription)) {
return {status: 'ineligible', reason: 'conflicting_pending_change'};
}
}
await this.scheduleBillingCycleChangeAtPeriodEnd({
user,
subscription,
item,
currentBillingCycle: billingCycle,
targetBillingCycle: billingCycle,
targetPriceId,
clearCancellation: false,
});
Logger.debug(
{
userId,
subscriptionId: subscription.id,
billingCycle,
currency,
currentPriceId: item.price.id,
targetPriceId,
currentAmountMinor,
targetAmountMinor,
periodEnd,
},
'Subscription switch to current list price scheduled for period end',
);
return {status: 'scheduled', ...switched};
} catch (error: unknown) {
Logger.error(
{error, userId, subscriptionId: user.stripeSubscriptionId},
'Failed to switch subscription to the current list price',
);
if (error instanceof StripeError || error instanceof StripeInvalidProductConfigurationError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Failed to switch subscription to the list price';
throw new StripeError(message);
} finally {
try {
const released = await this.cacheService.releaseLock(lockKey, lockToken);
if (!released) {
Logger.warn({userId, lockKey}, 'List price switch lock token no longer matched on release');
}
} catch (error) {
Logger.error({error, userId, lockKey}, 'Failed to release list price switch lock');
}
}
}
private async scheduleBillingCycleChangeAtPeriodEnd({
user,
subscription,
@@ -440,6 +647,7 @@ export class StripeSubscriptionService {
currentBillingCycle,
targetBillingCycle,
targetPriceId,
clearCancellation = true,
}: {
user: User;
subscription: Stripe.Subscription;
@@ -447,6 +655,7 @@ export class StripeSubscriptionService {
currentBillingCycle: RecurringBillingCycle;
targetBillingCycle: RecurringBillingCycle;
targetPriceId: string;
clearCancellation?: boolean;
}): Promise<void> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
@@ -456,7 +665,7 @@ export class StripeSubscriptionService {
throw new StripeError('Subscription is missing a future period end for scheduled billing cycle change');
}
let currentSubscription = subscription;
if (subscription.cancel_at || subscription.cancel_at_period_end) {
if (clearCancellation && (subscription.cancel_at || subscription.cancel_at_period_end)) {
currentSubscription = await this.stripe.subscriptions.update(
subscription.id,
this.getClearCancellationUpdateParams(subscription) ?? {proration_behavior: 'none'},
@@ -491,7 +700,6 @@ export class StripeSubscriptionService {
firstInvoiceCredit?.price_data?.unit_amount != null ? -firstInvoiceCredit.price_data.unit_amount : null;
const targetPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
start_date: periodEnd,
billing_cycle_anchor: 'phase_start',
items: [
{
price: targetPriceId,
@@ -500,6 +708,9 @@ export class StripeSubscriptionService {
],
proration_behavior: 'none',
};
if (currentBillingCycle !== targetBillingCycle) {
targetPhase.billing_cycle_anchor = 'phase_start';
}
if (firstInvoiceCredit) {
targetPhase.add_invoice_items = [firstInvoiceCredit];
targetPhase.metadata = {
@@ -508,7 +719,7 @@ export class StripeSubscriptionService {
};
}
await this.stripe.subscriptionSchedules.update(schedule.id, {
end_behavior: 'release',
end_behavior: clearCancellation ? 'release' : schedule.end_behavior,
proration_behavior: 'none',
metadata: {
user_id: user.id.toString(),
@@ -516,14 +727,33 @@ export class StripeSubscriptionService {
},
phases: [currentPhase, targetPhase],
});
let scheduledSubscription = currentSubscription;
if (!clearCancellation) {
scheduledSubscription = await this.stripe.subscriptions.retrieve(currentSubscription.id, {
expand: ['items.data.price'],
});
try {
await getBillingRepository().subscriptions.upsertFromStripe(scheduledSubscription, {
knownUserId: user.id,
snapshotCapturedAt: new Date(),
});
} catch (mirrorErr) {
Logger.error(
{mirrorErr, subId: scheduledSubscription.id},
'Mirror upsert failed after scheduling a period-end price change; reconciler will heal',
);
}
}
const patch: Record<string, unknown> = {
premium_will_cancel: false,
premium_will_cancel: clearCancellation
? false
: Boolean(scheduledSubscription.cancel_at || scheduledSubscription.cancel_at_period_end),
};
const computedPremiumUntil = getSubscriptionPremiumPeriodEnd(currentSubscription);
const computedPremiumUntil = getSubscriptionPremiumPeriodEnd(scheduledSubscription);
if (computedPremiumUntil) {
patch['premium_until'] = computedPremiumUntil;
}
const updatedCustomerId = extractId(currentSubscription.customer);
const updatedCustomerId = extractId(scheduledSubscription.customer);
if (updatedCustomerId && updatedCustomerId !== user.stripeCustomerId) {
patch['stripe_customer_id'] = updatedCustomerId;
}
@@ -532,7 +762,7 @@ export class StripeSubscriptionService {
Logger.debug(
{
userId: user.id,
subscriptionId: currentSubscription.id,
subscriptionId: scheduledSubscription.id,
scheduleId: schedule.id,
fromBillingCycle: currentBillingCycle,
toBillingCycle: targetBillingCycle,
@@ -742,12 +972,28 @@ export class StripeSubscriptionService {
if (!price) {
return null;
}
return {
const mappedItem: Stripe.SubscriptionScheduleUpdateParams.Phase.Item = {
price,
quantity: phaseItem.quantity ?? 1,
};
const itemTaxRates = mapStripeIds(phaseItem.tax_rates);
if (itemTaxRates) {
mappedItem.tax_rates = itemTaxRates;
}
const itemDiscounts = mapSchedulePhaseDiscounts(phaseItem.discounts);
if (itemDiscounts) {
mappedItem.discounts = itemDiscounts;
}
if (phaseItem.metadata) {
mappedItem.metadata = phaseItem.metadata;
}
if (phaseItem.billing_thresholds?.usage_gte != null) {
mappedItem.billing_thresholds = {usage_gte: phaseItem.billing_thresholds.usage_gte};
}
return mappedItem;
})
.filter((phaseItem): phaseItem is {price: string; quantity: number} => phaseItem !== null) ?? [];
.filter((phaseItem): phaseItem is Stripe.SubscriptionScheduleUpdateParams.Phase.Item => phaseItem !== null) ??
[];
const currentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
start_date: phase?.start_date ?? subscription.start_date ?? subscription.created,
end_date: periodEnd,
@@ -762,6 +1008,99 @@ export class StripeSubscriptionService {
],
proration_behavior: 'none',
};
if (phase) {
const phaseDiscounts = mapSchedulePhaseDiscounts(phase.discounts);
if (phaseDiscounts) {
currentPhase.discounts = phaseDiscounts;
}
const defaultTaxRates = mapStripeIds(phase.default_tax_rates);
if (defaultTaxRates) {
currentPhase.default_tax_rates = defaultTaxRates;
}
if (phase.metadata) {
currentPhase.metadata = phase.metadata;
}
if (phase.currency) {
currentPhase.currency = phase.currency;
}
if (phase.description != null) {
currentPhase.description = phase.description;
}
if (phase.collection_method) {
currentPhase.collection_method = phase.collection_method;
}
if (phase.application_fee_percent != null) {
currentPhase.application_fee_percent = phase.application_fee_percent;
}
const defaultPaymentMethod = extractId(phase.default_payment_method);
if (defaultPaymentMethod) {
currentPhase.default_payment_method = defaultPaymentMethod;
}
const onBehalfOf = extractId(phase.on_behalf_of);
if (onBehalfOf) {
currentPhase.on_behalf_of = onBehalfOf;
}
if (phase.automatic_tax) {
const automaticTax: Stripe.SubscriptionScheduleUpdateParams.Phase.AutomaticTax = {
enabled: phase.automatic_tax.enabled,
};
if (phase.automatic_tax.liability) {
automaticTax.liability = {type: phase.automatic_tax.liability.type};
const liabilityAccount = extractId(phase.automatic_tax.liability.account);
if (liabilityAccount) {
automaticTax.liability.account = liabilityAccount;
}
}
currentPhase.automatic_tax = automaticTax;
}
if (phase.invoice_settings) {
const invoiceSettings: Stripe.SubscriptionScheduleUpdateParams.Phase.InvoiceSettings = {};
const accountTaxIds = mapStripeIds(phase.invoice_settings.account_tax_ids);
if (accountTaxIds) {
invoiceSettings.account_tax_ids = accountTaxIds;
}
if (phase.invoice_settings.days_until_due != null) {
invoiceSettings.days_until_due = phase.invoice_settings.days_until_due;
}
if (phase.invoice_settings.issuer) {
invoiceSettings.issuer = {type: phase.invoice_settings.issuer.type};
const issuerAccount = extractId(phase.invoice_settings.issuer.account);
if (issuerAccount) {
invoiceSettings.issuer.account = issuerAccount;
}
}
if (Object.keys(invoiceSettings).length > 0) {
currentPhase.invoice_settings = invoiceSettings;
}
}
if (phase.billing_thresholds) {
const billingThresholds: Stripe.SubscriptionScheduleUpdateParams.Phase.BillingThresholds = {};
if (phase.billing_thresholds.amount_gte != null) {
billingThresholds.amount_gte = phase.billing_thresholds.amount_gte;
}
if (phase.billing_thresholds.reset_billing_cycle_anchor != null) {
billingThresholds.reset_billing_cycle_anchor = phase.billing_thresholds.reset_billing_cycle_anchor;
}
if (Object.keys(billingThresholds).length > 0) {
currentPhase.billing_thresholds = billingThresholds;
}
}
if (phase.transfer_data) {
const destination = extractId(phase.transfer_data.destination);
if (destination) {
currentPhase.transfer_data = {destination};
if (phase.transfer_data.amount_percent != null) {
currentPhase.transfer_data.amount_percent = phase.transfer_data.amount_percent;
}
}
}
if (phase.start_date > now) {
const addInvoiceItems = mapSchedulePhaseAddInvoiceItems(phase.add_invoice_items);
if (addInvoiceItems) {
currentPhase.add_invoice_items = addInvoiceItems;
}
}
}
const trialEnd = subscription.trial_end;
if (trialEnd != null && trialEnd > now) {
if (trialEnd >= periodEnd) {
@@ -770,6 +1109,10 @@ export class StripeSubscriptionService {
currentPhase.trial_end = trialEnd;
}
}
const phaseIsTrial = currentPhase.trial === true || currentPhase.trial_end != null;
if (phase?.billing_cycle_anchor && !(phaseIsTrial && phase.billing_cycle_anchor === 'phase_start')) {
currentPhase.billing_cycle_anchor = phase.billing_cycle_anchor;
}
return currentPhase;
}
@@ -1026,6 +1369,7 @@ export class StripeSubscriptionService {
private static readonly CURRENT_PRICE_CACHE_TTL_SECONDS = seconds('5 minutes');
private static readonly LIST_PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
private static readonly PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
private static readonly LIST_PRICE_SWITCH_LOCK_TTL_SECONDS = seconds('30 seconds');
private static readonly USER_TRIAL_LOCK_TTL_SECONDS = seconds('30 seconds');
private static readonly USER_TRIAL_LOCK_MAX_WAIT_MS = 15000;
private static readonly USER_TRIAL_LOCK_RETRY_DELAY_MS = 100;
@@ -65,6 +65,14 @@ export class StripeSubscriptionWebhookHandler {
Logger.error({invoiceId: invoice.id, billingReason}, 'No subscription ID found in subscription invoice');
throw new StripeError('Invoice missing subscription id');
}
const donor = await this.donationRepository.findDonorByStripeSubscriptionId(subscriptionId);
if (donor) {
Logger.debug(
{invoiceId: invoice.id, eventId, subscriptionId, donorEmail: donor.email},
'Skipping invoice payment for donation subscription',
);
return;
}
if (this.isSubscriptionUpdateInvoice(invoice)) {
Logger.debug(
{
@@ -309,6 +309,10 @@ export class StripeWebhookService {
await this.safeMirrorUpsert(event, () => this.billingRepository.paymentMethods.markDetached(pm.id, new Date()));
break;
}
case 'mandate.updated': {
await this.handleMandateUpdated(event.data.object as Stripe.Mandate);
break;
}
case 'payment_intent.created':
case 'payment_intent.processing':
case 'payment_intent.succeeded':
@@ -369,6 +373,18 @@ export class StripeWebhookService {
}
}
private async handleMandateUpdated(mandate: Stripe.Mandate): Promise<void> {
if (mandate.status !== 'inactive') {
return;
}
const paymentMethodId =
typeof mandate.payment_method === 'string' ? mandate.payment_method : (mandate.payment_method?.id ?? null);
Logger.warn(
{mandateId: mandate.id, paymentMethodId, status: mandate.status},
'Stripe mandate is no longer active; recurring payments on this payment method will fail',
);
}
private async resolveRefundCustomerId(refund: Stripe.Refund): Promise<string | null> {
const chargeId = typeof refund.charge === 'string' ? refund.charge : (refund.charge?.id ?? null);
if (chargeId !== null) {
@@ -0,0 +1,370 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, describe, expect, test} from 'vitest';
import {Config} from '../../Config';
import {ProductRegistry, ProductType} from '../ProductRegistry';
// Mirrors the live BRL/TRY configuration after the repricing: the BRL slots point at the new list
// prices, TRY is not configured at all (production has no TRY prices), and every retired price id
// lives only in the legacy map.
const MOCK_PRICES = {
monthlyUsd: 'price_monthly_usd',
monthlyBrl: 'price_1TMbqsFPC94Os7FdfElrIIaZ',
yearlyUsd: 'price_yearly_usd',
yearlyBrl: 'price_1TMbqtFPC94Os7Fd7VJqmDyt',
gift1MonthBrl: 'price_gift_1_month_brl',
gift1YearBrl: 'price_gift_1_year_brl',
};
const LEGACY_MONTHLY_BRL = 'price_legacy_monthly_brl';
const LEGACY_YEARLY_BRL = 'price_legacy_yearly_brl';
const LEGACY_GIFT_1_MONTH_BRL = 'price_legacy_gift_1_month_brl';
const LEGACY_GIFT_1_YEAR_BRL = 'price_legacy_gift_1_year_brl';
const LEGACY_MONTHLY_TRY = 'price_1TMYpdFPC94Os7FdZVRx98Up';
const MOCK_LEGACY_PRICES: Record<string, Array<string> | undefined> = {
monthly_brl: [LEGACY_MONTHLY_BRL],
yearly_brl: [LEGACY_YEARLY_BRL],
gift_1_month_brl: [LEGACY_GIFT_1_MONTH_BRL],
gift_1_year_brl: [LEGACY_GIFT_1_YEAR_BRL],
monthly_try: [LEGACY_MONTHLY_TRY],
};
describe('ProductRegistry - legacy prices', () => {
let originalPrices: typeof Config.stripe.prices | undefined;
let originalLegacyPrices: typeof Config.stripe.legacyPrices | undefined;
beforeAll(() => {
originalPrices = Config.stripe.prices;
originalLegacyPrices = Config.stripe.legacyPrices;
});
afterAll(() => {
Config.stripe.prices = originalPrices;
Config.stripe.legacyPrices = originalLegacyPrices;
});
function buildRegistry(
prices: typeof Config.stripe.prices,
legacyPrices: typeof Config.stripe.legacyPrices,
): ProductRegistry {
Config.stripe.prices = prices;
Config.stripe.legacyPrices = legacyPrices;
return new ProductRegistry();
}
describe('slot shapes', () => {
test('a legacy monthly slot resolves to a monthly recurring product in its currency', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
expect(registry.getProduct(LEGACY_MONTHLY_BRL)).toEqual({
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'BRL',
billingCycle: 'monthly',
});
});
test('a legacy yearly slot resolves to a yearly recurring product in its currency', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
expect(registry.getProduct(LEGACY_YEARLY_BRL)).toEqual({
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'BRL',
billingCycle: 'yearly',
});
});
test('a legacy gift_1_month slot resolves to a one-month gift with no billing cycle', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
const info = registry.getProduct(LEGACY_GIFT_1_MONTH_BRL);
expect(info).toEqual({
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'BRL',
});
expect(info?.billingCycle).toBeUndefined();
});
test('a legacy gift_1_year slot resolves to a one-year gift with no billing cycle', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
const info = registry.getProduct(LEGACY_GIFT_1_YEAR_BRL);
expect(info).toEqual({
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'BRL',
});
expect(info?.billingCycle).toBeUndefined();
});
test('the currency comes from the slot suffix, not from the price id', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
// Same shape of id, different slot: only the slot suffix decides the currency.
expect(registry.getProduct(LEGACY_MONTHLY_TRY)?.currency).toBe('TRY');
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
});
test('every supported currency suffix is recognised', () => {
const registry = buildRegistry(
{},
{
monthly_usd: ['legacy_usd'],
monthly_eur: ['legacy_eur'],
monthly_brl: ['legacy_brl'],
monthly_inr: ['legacy_inr'],
monthly_pln: ['legacy_pln'],
monthly_try: ['legacy_try'],
},
);
expect(registry.getProduct('legacy_usd')?.currency).toBe('USD');
expect(registry.getProduct('legacy_eur')?.currency).toBe('EUR');
expect(registry.getProduct('legacy_brl')?.currency).toBe('BRL');
expect(registry.getProduct('legacy_inr')?.currency).toBe('INR');
expect(registry.getProduct('legacy_pln')?.currency).toBe('PLN');
expect(registry.getProduct('legacy_try')?.currency).toBe('TRY');
});
test('an uppercased currency suffix is still recognised', () => {
const registry = buildRegistry({}, {monthly_BRL: ['legacy_upper_brl']});
expect(registry.getProduct('legacy_upper_brl')?.currency).toBe('BRL');
});
test('a slot may retire more than one price id', () => {
const registry = buildRegistry({}, {monthly_brl: ['legacy_one', 'legacy_two', 'legacy_three']});
expect(registry.getProduct('legacy_one')?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
expect(registry.getProduct('legacy_two')?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
expect(registry.getProduct('legacy_three')?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
});
test('legacy prices register even when no prices are configured at all', () => {
const registry = buildRegistry(undefined, MOCK_LEGACY_PRICES);
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)).toBeNull();
});
test('an unknown price id still resolves to null', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
expect(registry.getProduct('price_never_seen')).toBeNull();
});
});
describe('honoured but unpurchasable', () => {
test('a legacy recurring price resolves through getProduct but is never the checkout price', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
// Direction 1: the retired price is honoured on renewal.
expect(registry.getProduct(LEGACY_MONTHLY_BRL)).not.toBeNull();
expect(registry.getProduct(LEGACY_YEARLY_BRL)).not.toBeNull();
// Direction 2: checkout only ever offers the authored list price.
expect(registry.getRecurringSubscriptionPriceId('monthly', 'BRL')).toBe(MOCK_PRICES.monthlyBrl);
expect(registry.getRecurringSubscriptionPriceId('monthly', 'BRL')).not.toBe(LEGACY_MONTHLY_BRL);
expect(registry.getRecurringSubscriptionPriceId('yearly', 'BRL')).toBe(MOCK_PRICES.yearlyBrl);
expect(registry.getRecurringSubscriptionPriceId('yearly', 'BRL')).not.toBe(LEGACY_YEARLY_BRL);
});
test('a legacy gift price resolves through getProduct but is never the gift checkout price', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
expect(registry.getProduct(LEGACY_GIFT_1_MONTH_BRL)).not.toBeNull();
expect(registry.getProduct(LEGACY_GIFT_1_YEAR_BRL)).not.toBeNull();
expect(registry.getGiftPriceId('gift_1_month', 'BRL')).toBe(MOCK_PRICES.gift1MonthBrl);
expect(registry.getGiftPriceId('gift_1_month', 'BRL')).not.toBe(LEGACY_GIFT_1_MONTH_BRL);
expect(registry.getGiftPriceId('gift_1_year', 'BRL')).toBe(MOCK_PRICES.gift1YearBrl);
expect(registry.getGiftPriceId('gift_1_year', 'BRL')).not.toBe(LEGACY_GIFT_1_YEAR_BRL);
});
test('an archived price in a currency the instance does not configure renews but cannot be bought', () => {
// The real production case: one live subscription sits on an archived TRY price that
// production never configured. getProduct must resolve it; checkout must still refuse TRY.
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
expect(registry.getProduct(LEGACY_MONTHLY_TRY)).toEqual({
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'TRY',
billingCycle: 'monthly',
});
expect(registry.getRecurringSubscriptionPriceId('monthly', 'TRY')).toBeNull();
expect(registry.getRecurringSubscriptionPriceId('yearly', 'TRY')).toBeNull();
expect(registry.getGiftPriceId('gift_1_month', 'TRY')).toBeNull();
expect(registry.getGiftPriceId('gift_1_year', 'TRY')).toBeNull();
});
test('no legacy price id is ever returned by either price getter, for any cycle or currency', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
const legacyIds = new Set(Object.values(MOCK_LEGACY_PRICES).flatMap((ids) => ids ?? []));
const currencies = ['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY'];
const offered: Array<string> = [];
for (const currency of currencies) {
for (const cycle of ['monthly', 'yearly'] as const) {
const priceId = registry.getRecurringSubscriptionPriceId(cycle, currency);
if (priceId) offered.push(priceId);
}
for (const duration of ['gift_1_month', 'gift_1_year'] as const) {
const priceId = registry.getGiftPriceId(duration, currency);
if (priceId) offered.push(priceId);
}
}
expect(offered.length).toBeGreaterThan(0);
expect(offered.filter((priceId) => legacyIds.has(priceId))).toEqual([]);
// And every legacy id is still honoured by the registry.
for (const legacyId of legacyIds) {
expect(registry.getProduct(legacyId)).not.toBeNull();
}
});
test('a legacy recurring product is still classified as a recurring subscription', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
const recurring = registry.getProduct(LEGACY_MONTHLY_BRL);
const gift = registry.getProduct(LEGACY_GIFT_1_YEAR_BRL);
expect(recurring).not.toBeNull();
expect(gift).not.toBeNull();
expect(registry.isRecurringSubscription(recurring!)).toBe(true);
expect(registry.isRecurringSubscription(gift!)).toBe(false);
});
});
describe('precedence', () => {
test('an authored price wins over a legacy entry claiming the same id', () => {
// Deliberate collision: the live monthly BRL list price is also listed as a retired yearly
// USD price. The authored ProductInfo must survive.
const registry = buildRegistry(MOCK_PRICES, {
yearly_usd: [MOCK_PRICES.monthlyBrl],
});
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)).toEqual({
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'BRL',
billingCycle: 'monthly',
});
});
test('an authored gift price wins over a legacy entry claiming the same id', () => {
const registry = buildRegistry(MOCK_PRICES, {
monthly_try: [MOCK_PRICES.gift1YearBrl],
});
expect(registry.getProduct(MOCK_PRICES.gift1YearBrl)).toEqual({
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'BRL',
});
});
test('the first legacy slot to claim an id wins over a later one', () => {
const registry = buildRegistry(
{},
{
monthly_brl: ['legacy_shared'],
yearly_usd: ['legacy_shared'],
},
);
expect(registry.getProduct('legacy_shared')).toEqual({
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'BRL',
billingCycle: 'monthly',
});
});
});
describe('malformed configuration', () => {
test('a slot whose value is a bare string is ignored and the rest still registers', () => {
const registry = buildRegistry(MOCK_PRICES, {
monthly_brl: 'price_legacy_bare_string' as unknown as Array<string>,
yearly_brl: [LEGACY_YEARLY_BRL],
});
expect(registry.getProduct('price_legacy_bare_string')).toBeNull();
// Not accidentally registered character by character either.
expect(registry.getProduct('p')).toBeNull();
expect(registry.getProduct(LEGACY_YEARLY_BRL)?.type).toBe(ProductType.YEARLY_SUBSCRIPTION);
});
test('a slot with an unrecognised name is ignored and the rest still registers', () => {
const registry = buildRegistry(MOCK_PRICES, {
weekly_brl: ['legacy_weekly'],
visionary_usd: ['legacy_visionary'],
brl: ['legacy_no_separator'],
_brl: ['legacy_leading_separator'],
yearly_brl: [LEGACY_YEARLY_BRL],
});
expect(registry.getProduct('legacy_weekly')).toBeNull();
expect(registry.getProduct('legacy_visionary')).toBeNull();
expect(registry.getProduct('legacy_no_separator')).toBeNull();
expect(registry.getProduct('legacy_leading_separator')).toBeNull();
expect(registry.getProduct(LEGACY_YEARLY_BRL)?.type).toBe(ProductType.YEARLY_SUBSCRIPTION);
});
test('a slot with an unknown currency is ignored and the rest still registers', () => {
const registry = buildRegistry(MOCK_PRICES, {
monthly_jpy: ['legacy_jpy'],
gift_1_year_gbp: ['legacy_gbp'],
monthly_brl: [LEGACY_MONTHLY_BRL],
});
expect(registry.getProduct('legacy_jpy')).toBeNull();
expect(registry.getProduct('legacy_gbp')).toBeNull();
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
});
test('empty, blank and undefined entries inside a slot are skipped', () => {
const registry = buildRegistry(MOCK_PRICES, {
monthly_brl: ['', undefined as unknown as string, LEGACY_MONTHLY_BRL],
});
expect(registry.getProduct('')).toBeNull();
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
});
test('an undefined slot value is ignored', () => {
const registry = buildRegistry(MOCK_PRICES, {
monthly_try: undefined,
monthly_brl: [LEGACY_MONTHLY_BRL],
});
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
});
test('an array-valued legacy map is ignored without throwing', () => {
const registry = buildRegistry(MOCK_PRICES, [
'price_legacy_array',
] as unknown as typeof Config.stripe.legacyPrices);
expect(registry.getProduct('price_legacy_array')).toBeNull();
// The authored prices are untouched.
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
expect(registry.getRecurringSubscriptionPriceId('monthly', 'BRL')).toBe(MOCK_PRICES.monthlyBrl);
});
test('a string-valued legacy map is ignored without throwing', () => {
const registry = buildRegistry(MOCK_PRICES, 'nonsense' as unknown as typeof Config.stripe.legacyPrices);
expect(registry.getProduct('nonsense')).toBeNull();
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
});
test('a number-valued legacy map is ignored without throwing', () => {
const registry = buildRegistry(MOCK_PRICES, 42 as unknown as typeof Config.stripe.legacyPrices);
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
});
test('an absent or empty legacy map leaves the authored registry intact', () => {
expect(buildRegistry(MOCK_PRICES, undefined).getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
expect(buildRegistry(MOCK_PRICES, {}).getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
});
});
});
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
import type {
SelfServeRefundEligibilityResponse,
SelfServeRefundResponse,
@@ -9,10 +11,17 @@ import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {
createMockWebhookPayload,
createStripeApiHandlers,
type StripeWebhookEventData,
} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {createBuilder} from '../../test/TestRequestBuilder';
import {UserRepository} from '../../user/repositories/UserRepository';
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
const MOCK_CUSTOMER_ID = 'cus_self_serve_refund';
const MOCK_SUBSCRIPTION_ID = 'sub_self_serve_refund';
@@ -107,6 +116,7 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
}
function refundCreateHandler(opts?: {
refundId?: string;
status?: 'succeeded' | 'pending' | 'failed';
failureReason?: string;
onRequest?: (idempotencyKey: string | null) => void;
@@ -121,7 +131,7 @@ function refundCreateHandler(opts?: {
if (match) metadata[match[1]] = value as string;
}
return HttpResponse.json({
id: 're_test_self_serve',
id: opts?.refundId ?? 're_test_self_serve',
object: 'refund',
amount: Number.parseInt((params.amount as string) ?? '0', 10),
currency: 'usd',
@@ -156,7 +166,27 @@ describe('StripeRefundService self-serve refund', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
setupSyncStripeWebhookWorker();
});
function createWebhookSignature(payload: string, timestamp: number, secret: string): string {
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
return `t=${timestamp},v1=${signature}`;
}
async function sendWebhook(eventData: StripeWebhookEventData): Promise<{
received: boolean;
}> {
const {payload, timestamp} = createMockWebhookPayload(eventData);
const signature = createWebhookSignature(payload, timestamp, Config.stripe.webhookSecret!);
return createBuilder<{
received: boolean;
}>(harness, '')
.post('/stripe/webhook')
.header('stripe-signature', signature)
.header('content-type', 'application/json')
.body(payload)
.execute();
}
beforeEach(async () => {
await harness.reset();
});
@@ -345,6 +375,102 @@ describe('StripeRefundService self-serve refund', () => {
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
expect(updatedUser!.firstRefundAt).toBeNull();
});
test('counts one self-serve refund once even when charge.refunded arrives afterwards and is retried', async () => {
server.use(...createStripeApiHandlers().handlers);
const invoice = buildInvoice({
id: 'in_cross_path',
paidAtSecondsAgo: SECONDS_PER_DAY,
subscriptionId: null,
});
server.use(invoiceListHandler([invoice]), refundCreateHandler({refundId: 're_cross_path'}));
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
.post('/premium/refund-latest')
.execute();
expect(response.refund_id).toBe('re_cross_path');
expect(response.status).toBe('succeeded');
const userRepository = new UserRepository();
const afterSelfServe = await userRepository.findUnique(userId);
expect(afterSelfServe!.firstRefundAt).not.toBeNull();
expect(afterSelfServe!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
const chargeEvent = {
type: 'charge.refunded' as const,
data: {
object: {
id: invoice.chargeId,
payment_intent: invoice.paymentIntentId,
customer: MOCK_CUSTOMER_ID,
amount_refunded: 2500,
refunds: {
object: 'list',
has_more: false,
url: `/v1/charges/${invoice.chargeId}/refunds`,
data: [
{
id: 're_cross_path',
object: 'refund',
charge: invoice.chargeId,
payment_intent: invoice.paymentIntentId,
amount: 2500,
currency: 'usd',
status: 'succeeded',
created: Math.floor(Date.now() / 1000),
metadata: {
refund_kind: 'self_serve',
user_id: account.userId,
invoice_id: invoice.id,
},
},
],
},
},
},
};
await sendWebhook({...chargeEvent, id: 'evt_cross_path_1'});
await sendWebhook({...chargeEvent, id: 'evt_cross_path_2'});
const afterWebhook = await userRepository.findUnique(userId);
expect(afterWebhook!.firstRefundAt!.getTime()).toBe(afterSelfServe!.firstRefundAt!.getTime());
expect(afterWebhook!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
});
test('counts one self-serve refund once even when refund.updated confirms it after the endpoint already did', async () => {
server.use(...createStripeApiHandlers().handlers);
const invoice = buildInvoice({
id: 'in_double_confirm',
paidAtSecondsAgo: SECONDS_PER_DAY,
subscriptionId: null,
});
server.use(invoiceListHandler([invoice]), refundCreateHandler({refundId: 're_double_confirm'}));
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
const userRepository = new UserRepository();
const afterSelfServe = await userRepository.findUnique(userId);
expect(afterSelfServe!.firstRefundAt).not.toBeNull();
await sendWebhook({
id: 'evt_double_confirm_1',
type: 'refund.updated',
data: {
object: {
id: 're_double_confirm',
object: 'refund',
status: 'succeeded',
amount: 2500,
currency: 'usd',
metadata: {
refund_kind: 'self_serve',
user_id: account.userId,
invoice_id: invoice.id,
},
},
},
});
const afterWebhook = await userRepository.findUnique(userId);
expect(afterWebhook!.firstRefundAt!.getTime()).toBe(afterSelfServe!.firstRefundAt!.getTime());
expect(afterWebhook!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
});
test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => {
server.use(...createStripeApiHandlers().handlers);
server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]));
@@ -0,0 +1,589 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {
ListPriceSwitchIneligibilityReason,
PremiumStateResponse,
SwitchToListPriceResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import {HttpResponse, http} from 'msw';
import Stripe from 'stripe';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID, type UserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createPwnedPasswordsRangeHandler} from '../../test/msw/handlers/PwnedPasswordsHandlers';
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {createBuilder} from '../../test/TestRequestBuilder';
import {STRIPE_API_VERSION} from '../StripeApiVersion';
const MOCK_PRICES = {
monthlyUsd: 'price_list_monthly_usd',
yearlyUsd: 'price_list_yearly_usd',
monthlyBrl: 'price_list_monthly_brl',
yearlyBrl: 'price_list_yearly_brl',
};
const RETIRED_MONTHLY_BRL = 'price_retired_monthly_brl';
const RETIRED_CHEAP_MONTHLY_BRL = 'price_retired_cheap_monthly_brl';
const RETIRED_MONTHLY_TRY = 'price_retired_monthly_try';
const RETIRED_EQUAL_MONTHLY_BRL = 'price_retired_equal_monthly_brl';
const LIST_MONTHLY_BRL_MINOR = 1890;
const LIST_YEARLY_BRL_MINOR = 18900;
const RETIRED_MONTHLY_BRL_MINOR = 2499;
const RETIRED_CHEAP_MONTHLY_BRL_MINOR = 990;
const RETIRED_MONTHLY_TRY_MINOR = 2999;
const MOCK_PRICE_SEEDS: Record<
string,
{
unit_amount: number;
currency: string;
interval: 'month' | 'year';
}
> = {
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month'},
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year'},
[MOCK_PRICES.monthlyBrl]: {unit_amount: LIST_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
[MOCK_PRICES.yearlyBrl]: {unit_amount: LIST_YEARLY_BRL_MINOR, currency: 'brl', interval: 'year'},
[RETIRED_MONTHLY_BRL]: {unit_amount: RETIRED_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
[RETIRED_CHEAP_MONTHLY_BRL]: {unit_amount: RETIRED_CHEAP_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
[RETIRED_MONTHLY_TRY]: {unit_amount: RETIRED_MONTHLY_TRY_MINOR, currency: 'try', interval: 'month'},
[RETIRED_EQUAL_MONTHLY_BRL]: {unit_amount: LIST_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
};
interface SubscriberFixture {
subscriptionId: string;
priceId: string;
status?: 'active' | 'trialing' | 'past_due';
cancelAt?: number | null;
cancelAtPeriodEnd?: boolean;
periodStart?: number;
periodEnd?: number;
}
interface ResolvedSubscriber {
token: string;
userId: UserID;
subscriptionId: string;
priceId: string;
periodStart: number;
periodEnd: number;
}
describe('StripeSubscriptionListPriceSwitch', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
function applyStripeHandlers(fixture: Required<SubscriberFixture>): void {
const seed = MOCK_PRICE_SEEDS[fixture.priceId];
if (!seed) {
throw new Error(`Missing explicit price seed for ${fixture.priceId}`);
}
stripeHandlers = createStripeApiHandlers({
subscriptions: {
[fixture.subscriptionId]: {
customer: `cus_${fixture.subscriptionId}`,
price_id: fixture.priceId,
unit_amount: seed.unit_amount,
currency: seed.currency,
interval: seed.interval,
item_id: `si_${fixture.subscriptionId}`,
current_period_start: fixture.periodStart,
current_period_end: fixture.periodEnd,
status: fixture.status,
cancel_at: fixture.cancelAt,
cancel_at_period_end: fixture.cancelAtPeriodEnd,
},
},
prices: MOCK_PRICE_SEEDS,
});
server.use(...stripeHandlers.handlers, createPwnedPasswordsRangeHandler());
}
async function mirrorStripeState(userId: UserID, subscriptionId: string): Promise<void> {
const stripe = new Stripe(Config.stripe.secretKey ?? 'sk_test_fluxer', {
apiVersion: STRIPE_API_VERSION,
httpClient: Stripe.createFetchHttpClient(),
});
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {expand: ['items.data.price']});
await getBillingRepository().subscriptions.upsertFromStripe(subscription, {
knownUserId: userId,
snapshotCapturedAt: new Date(),
});
for (const priceId of Object.keys(MOCK_PRICE_SEEDS)) {
const price = await stripe.prices.retrieve(priceId);
await getBillingRepository().prices.upsertFromStripe(price);
}
}
async function createSubscriber(fixture: SubscriberFixture): Promise<ResolvedSubscriber> {
const periodStart = fixture.periodStart ?? Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
const periodEnd = fixture.periodEnd ?? periodStart + 30 * 24 * 60 * 60;
const resolved: Required<SubscriberFixture> = {
subscriptionId: fixture.subscriptionId,
priceId: fixture.priceId,
status: fixture.status ?? 'active',
cancelAt: fixture.cancelAt ?? null,
cancelAtPeriodEnd: fixture.cancelAtPeriodEnd ?? false,
periodStart,
periodEnd,
};
const account = await createTestAccount(harness);
applyStripeHandlers(resolved);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
stripe_subscription_id: resolved.subscriptionId,
premium_type: 1,
premium_billing_cycle: MOCK_PRICE_SEEDS[resolved.priceId]?.interval === 'year' ? 'yearly' : 'monthly',
premium_until: new Date(periodEnd * 1000).toISOString(),
premium_will_cancel: Boolean(resolved.cancelAt) || resolved.cancelAtPeriodEnd,
})
.execute();
const userId = createUserID(BigInt(account.userId));
await mirrorStripeState(userId, resolved.subscriptionId);
return {
token: account.token,
userId,
subscriptionId: resolved.subscriptionId,
priceId: resolved.priceId,
periodStart,
periodEnd,
};
}
function switchToListPrice(subscriber: ResolvedSubscriber): Promise<SwitchToListPriceResponse> {
return createBuilder<SwitchToListPriceResponse>(harness, subscriber.token)
.post('/premium/switch-to-list-price')
.expect(200)
.execute();
}
function getPremiumState(subscriber: ResolvedSubscriber): Promise<PremiumStateResponse> {
return createBuilder<PremiumStateResponse>(harness, subscriber.token).get('/premium/state').expect(200).execute();
}
function expectNoStripeWrites(): void {
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0);
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(0);
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(0);
expect(stripeHandlers.spies.releasedSubscriptionSchedules).toHaveLength(0);
}
async function expectRefusal(
subscriber: ResolvedSubscriber,
reason: ListPriceSwitchIneligibilityReason,
): Promise<void> {
const result = await switchToListPrice(subscriber);
expect(result).toEqual({status: 'ineligible', reason});
const state = await getPremiumState(subscriber);
expect(state.billing.list_price_switch.available).toBe(false);
expect(state.billing.list_price_switch.reason).toBe(reason);
}
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS});
server.use(...stripeHandlers.handlers);
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(async () => {
await harness.resetData();
Config.stripe.prices = MOCK_PRICES;
stripeHandlers.resetAll();
server.use(...stripeHandlers.handlers, createPwnedPasswordsRangeHandler());
});
describe('POST /premium/switch-to-list-price', () => {
test('schedules a grandfathered BRL monthly subscriber onto the current list price at period end', async () => {
const moneyMoves: Array<string> = [];
server.use(
http.post('https://api.stripe.com/v1/invoices', () => {
moneyMoves.push('invoices.create');
return HttpResponse.json({});
}),
http.post('https://api.stripe.com/v1/charges', () => {
moneyMoves.push('charges.create');
return HttpResponse.json({});
}),
http.post('https://api.stripe.com/v1/payment_intents', () => {
moneyMoves.push('payment_intents.create');
return HttpResponse.json({});
}),
);
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_grandfathered',
priceId: RETIRED_MONTHLY_BRL,
});
const result = await switchToListPrice(subscriber);
expect(result).toEqual({
status: 'scheduled',
effective_at: new Date(subscriber.periodEnd * 1000).toISOString(),
target_price_id: MOCK_PRICES.monthlyBrl,
target_amount_minor: LIST_MONTHLY_BRL_MINOR,
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
currency: 'BRL',
});
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(1);
expect(stripeHandlers.spies.createdSubscriptionSchedules[0]?.from_subscription).toBe('sub_brl_grandfathered');
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1);
const scheduleUpdate = stripeHandlers.spies.updatedSubscriptionSchedules[0];
expect(scheduleUpdate?.params.end_behavior).toBe('release');
expect(scheduleUpdate?.params.proration_behavior).toBe('none');
expect(scheduleUpdate?.params.phases).toHaveLength(2);
expect(scheduleUpdate?.params.phases?.[0]?.end_date).toBe(String(subscriber.periodEnd));
expect(scheduleUpdate?.params.phases?.[0]?.items?.[0]?.price).toBe(RETIRED_MONTHLY_BRL);
expect(scheduleUpdate?.params.phases?.[1]?.start_date).toBe(String(subscriber.periodEnd));
expect(scheduleUpdate?.params.phases?.[1]?.items?.[0]?.price).toBe(MOCK_PRICES.monthlyBrl);
expect(scheduleUpdate?.params.phases?.[1]?.proration_behavior).toBe('none');
expect(scheduleUpdate?.params.phases?.[1]?.billing_cycle_anchor).toBeUndefined();
expect(scheduleUpdate?.params.phases?.[1]?.add_invoice_items).toBeUndefined();
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
expect(moneyMoves).toEqual([]);
const me = await createBuilder<{
premium_will_cancel: boolean;
}>(harness, subscriber.token)
.get('/users/@me')
.execute();
expect(me.premium_will_cancel).toBe(false);
const state = await getPremiumState(subscriber);
expect(state.billing.pending_subscription_change).toBeNull();
expect(state.billing.list_price_switch).toEqual(
expect.objectContaining({
available: false,
reason: null,
pending: true,
effective_at: new Date(subscriber.periodEnd * 1000).toISOString(),
current_price_id: RETIRED_MONTHLY_BRL,
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
list_price_id: MOCK_PRICES.monthlyBrl,
list_amount_minor: LIST_MONTHLY_BRL_MINOR,
currency: 'BRL',
billing_cycle: 'monthly',
}),
);
});
test('preserves the phase-level settings Stripe would otherwise unset on the live subscription', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_phase_settings',
priceId: RETIRED_MONTHLY_BRL,
});
const scheduleId = 'sub_sched_phase_settings';
const richSchedule = {
id: scheduleId,
object: 'subscription_schedule',
status: 'active',
subscription: subscriber.subscriptionId,
end_behavior: 'release',
metadata: {},
current_phase: {start_date: subscriber.periodStart, end_date: subscriber.periodEnd},
phases: [
{
start_date: subscriber.periodStart,
end_date: subscriber.periodEnd,
currency: 'brl',
collection_method: 'send_invoice',
description: 'Grandfathered Plutonium',
metadata: {campaign: 'brl_2026'},
discounts: [{coupon: {id: 'co_grandfather_10'}, discount: null, promotion_code: null}],
default_tax_rates: [{id: 'txr_br_icms'}],
automatic_tax: {enabled: true, disabled_reason: null, liability: {type: 'self'}},
invoice_settings: {account_tax_ids: null, days_until_due: 14, issuer: null},
billing_cycle_anchor: 'automatic',
add_invoice_items: [],
items: [
{
price: RETIRED_MONTHLY_BRL,
quantity: 1,
metadata: {seat: 'primary'},
discounts: [{coupon: null, discount: 'di_existing', promotion_code: null}],
tax_rates: [{id: 'txr_br_iss'}],
billing_thresholds: null,
},
],
proration_behavior: 'none',
},
],
livemode: false,
created: Math.floor(Date.now() / 1000),
};
const scheduleUpdates: Array<Record<string, string>> = [];
server.use(
http.post('https://api.stripe.com/v1/subscription_schedules', () => HttpResponse.json(richSchedule)),
http.post(`https://api.stripe.com/v1/subscription_schedules/${scheduleId}`, async ({request}) => {
const formData = await request.formData();
const entries: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
entries[key] = String(value);
}
scheduleUpdates.push(entries);
return HttpResponse.json(richSchedule);
}),
);
const result = await switchToListPrice(subscriber);
expect(result.status).toBe('scheduled');
expect(scheduleUpdates).toHaveLength(1);
const update = scheduleUpdates[0]!;
expect(update['phases[0][items][0][price]']).toBe(RETIRED_MONTHLY_BRL);
expect(update['phases[0][discounts][0][coupon]']).toBe('co_grandfather_10');
expect(update['phases[0][default_tax_rates][0]']).toBe('txr_br_icms');
expect(update['phases[0][items][0][tax_rates][0]']).toBe('txr_br_iss');
expect(update['phases[0][items][0][discounts][0][discount]']).toBe('di_existing');
expect(update['phases[0][items][0][metadata][seat]']).toBe('primary');
expect(update['phases[0][metadata][campaign]']).toBe('brl_2026');
expect(update['phases[0][collection_method]']).toBe('send_invoice');
expect(update['phases[0][description]']).toBe('Grandfathered Plutonium');
expect(update['phases[0][currency]']).toBe('brl');
expect(update['phases[0][automatic_tax][enabled]']).toBe('true');
expect(update['phases[0][automatic_tax][liability][type]']).toBe('self');
expect(update['phases[0][invoice_settings][days_until_due]']).toBe('14');
expect(update['phases[0][billing_cycle_anchor]']).toBe('automatic');
expect(update['phases[0][end_date]']).toBe(String(subscriber.periodEnd));
expect(update['phases[1][items][0][price]']).toBe(MOCK_PRICES.monthlyBrl);
expect(update['phases[1][discounts][0][coupon]']).toBeUndefined();
});
test('refuses to move a subscriber up to a more expensive list price', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_below_list',
priceId: RETIRED_CHEAP_MONTHLY_BRL,
});
await expectRefusal(subscriber, 'not_a_price_decrease');
expectNoStripeWrites();
});
test('refuses a subscriber who is cancelling at period end', async () => {
const periodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
const periodEnd = periodStart + 30 * 24 * 60 * 60;
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_cancel_at_period_end',
priceId: RETIRED_MONTHLY_BRL,
cancelAtPeriodEnd: true,
periodStart,
periodEnd,
});
await expectRefusal(subscriber, 'subscription_cancelling');
expectNoStripeWrites();
const me = await createBuilder<{
premium_will_cancel: boolean;
}>(harness, subscriber.token)
.get('/users/@me')
.execute();
expect(me.premium_will_cancel).toBe(true);
});
test('refuses a subscriber who is cancelling on an explicit cancel_at date', async () => {
const periodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
const periodEnd = periodStart + 30 * 24 * 60 * 60;
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_cancel_at',
priceId: RETIRED_MONTHLY_BRL,
cancelAt: periodEnd,
cancelAtPeriodEnd: false,
periodStart,
periodEnd,
});
await expectRefusal(subscriber, 'subscription_cancelling');
expectNoStripeWrites();
const me = await createBuilder<{
premium_will_cancel: boolean;
}>(harness, subscriber.token)
.get('/users/@me')
.execute();
expect(me.premium_will_cancel).toBe(true);
});
test('is idempotent and reports an already scheduled switch on the second call', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_idempotent',
priceId: RETIRED_MONTHLY_BRL,
});
const first = await switchToListPrice(subscriber);
expect(first.status).toBe('scheduled');
const second = await switchToListPrice(subscriber);
expect(second).toEqual({
status: 'already_scheduled',
effective_at: new Date(subscriber.periodEnd * 1000).toISOString(),
target_price_id: MOCK_PRICES.monthlyBrl,
target_amount_minor: LIST_MONTHLY_BRL_MINOR,
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
currency: 'BRL',
});
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(1);
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1);
});
test('refuses a retired price that costs exactly the same as the list price', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_equal_price',
priceId: RETIRED_EQUAL_MONTHLY_BRL,
});
await expectRefusal(subscriber, 'not_a_price_decrease');
expectNoStripeWrites();
});
test('lets a scheduled switch be withdrawn again and restores eligibility', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_withdrawn',
priceId: RETIRED_MONTHLY_BRL,
});
expect((await switchToListPrice(subscriber)).status).toBe('scheduled');
await createBuilder(harness, subscriber.token)
.post('/premium/cancel-pending-subscription-change')
.expect(204)
.execute();
expect(stripeHandlers.spies.releasedSubscriptionSchedules).toHaveLength(1);
expect(stripeHandlers.spies.releasedSubscriptionSchedules[0]?.params.preserve_cancel_date).toBe('false');
const state = await getPremiumState(subscriber);
expect(state.billing.pending_subscription_change).toBeNull();
expect(state.billing.list_price_switch).toEqual(
expect.objectContaining({
available: true,
reason: null,
pending: false,
current_price_id: RETIRED_MONTHLY_BRL,
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
list_price_id: MOCK_PRICES.monthlyBrl,
list_amount_minor: LIST_MONTHLY_BRL_MINOR,
}),
);
});
test('refuses a subscriber who is already on the current list price', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_on_list_price',
priceId: MOCK_PRICES.monthlyBrl,
});
await expectRefusal(subscriber, 'already_on_list_price');
expectNoStripeWrites();
});
test('refuses a currency that has no configured list price', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_try_unconfigured',
priceId: RETIRED_MONTHLY_TRY,
});
await expectRefusal(subscriber, 'no_list_price');
expectNoStripeWrites();
});
test('refuses a subscription that is not chargeable', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_past_due',
priceId: RETIRED_MONTHLY_BRL,
status: 'past_due',
});
await expectRefusal(subscriber, 'subscription_not_chargeable');
expectNoStripeWrites();
});
test('refuses a subscriber whose cancellation is managed by a subscription schedule', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_schedule_cancel',
priceId: RETIRED_MONTHLY_BRL,
});
await createBuilder(harness, subscriber.token)
.post('/premium/change-subscription')
.body({billing_cycle: 'yearly', effective_at: 'period_end'})
.expect(204)
.execute();
await createBuilder(harness, subscriber.token).post('/premium/cancel-subscription').expect(204).execute();
stripeHandlers.spies.updatedSubscriptions.length = 0;
stripeHandlers.spies.createdSubscriptionSchedules.length = 0;
stripeHandlers.spies.updatedSubscriptionSchedules.length = 0;
stripeHandlers.spies.releasedSubscriptionSchedules.length = 0;
const result = await switchToListPrice(subscriber);
expect(result).toEqual({status: 'ineligible', reason: 'subscription_cancelling'});
expectNoStripeWrites();
const me = await createBuilder<{
premium_will_cancel: boolean;
}>(harness, subscriber.token)
.get('/users/@me')
.execute();
expect(me.premium_will_cancel).toBe(true);
});
test('refuses when a billing cycle change is already pending', async () => {
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_pending_cycle_change',
priceId: RETIRED_MONTHLY_BRL,
});
await createBuilder(harness, subscriber.token)
.post('/premium/change-subscription')
.body({billing_cycle: 'yearly', effective_at: 'period_end'})
.expect(204)
.execute();
stripeHandlers.spies.updatedSubscriptions.length = 0;
stripeHandlers.spies.createdSubscriptionSchedules.length = 0;
stripeHandlers.spies.updatedSubscriptionSchedules.length = 0;
await expectRefusal(subscriber, 'conflicting_pending_change');
expectNoStripeWrites();
});
test('rejects a user without an active subscription and mirrors that in premium state', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/premium/switch-to-list-price')
.expect(400, APIErrorCodes.STRIPE_NO_ACTIVE_SUBSCRIPTION)
.execute();
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state')
.expect(200)
.execute();
expect(state.billing.list_price_switch.available).toBe(false);
expect(state.billing.list_price_switch.reason).toBe('no_active_subscription');
expectNoStripeWrites();
});
});
describe('POST /premium/change-subscription (unchanged behaviour)', () => {
test('still clears a pending cancellation and re-anchors billing when the cycle actually changes', async () => {
const periodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
const periodEnd = periodStart + 30 * 24 * 60 * 60;
const subscriber = await createSubscriber({
subscriptionId: 'sub_brl_cycle_change',
priceId: RETIRED_MONTHLY_BRL,
cancelAt: periodEnd,
cancelAtPeriodEnd: false,
periodStart,
periodEnd,
});
await createBuilder(harness, subscriber.token)
.post('/premium/change-subscription')
.body({billing_cycle: 'yearly', effective_at: 'period_end'})
.expect(204)
.execute();
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(1);
const clearCancelUpdate = stripeHandlers.spies.updatedSubscriptions[0];
expect(clearCancelUpdate?.id).toBe('sub_brl_cycle_change');
expect(clearCancelUpdate?.params.cancel_at).toBe('');
expect(clearCancelUpdate?.params.proration_behavior).toBe('none');
expect(clearCancelUpdate?.params.items).toBeUndefined();
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1);
const scheduleUpdate = stripeHandlers.spies.updatedSubscriptionSchedules[0];
expect(scheduleUpdate?.params.end_behavior).toBe('release');
expect(scheduleUpdate?.params.phases?.[1]?.start_date).toBe(String(periodEnd));
expect(scheduleUpdate?.params.phases?.[1]?.billing_cycle_anchor).toBe('phase_start');
expect(scheduleUpdate?.params.phases?.[1]?.items?.[0]?.price).toBe(MOCK_PRICES.yearlyBrl);
const me = await createBuilder<{
premium_will_cancel: boolean;
}>(harness, subscriber.token)
.get('/users/@me')
.execute();
expect(me.premium_will_cancel).toBe(false);
});
});
});
@@ -4,12 +4,14 @@ import crypto from 'node:crypto';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {Logger} from '../../Logger';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {NoopLogger} from '../../test/mocks/NoopLogger';
import {
createInvoiceFinalizationFailedEvent,
createInvoicePaidEvent,
@@ -41,16 +43,24 @@ const MOCK_PRICES = {
gift1YearEur: 'price_gift_1_year_eur',
};
const LEGACY_MONTHLY_BRL_PRICE = 'price_legacy_monthly_brl';
const LEGACY_YEARLY_BRL_PRICE = 'price_legacy_yearly_brl';
const UNMAPPED_PRICE = 'price_retired_unmapped_brl';
const MANDATE_REVOKED_WARNING =
'Stripe mandate is no longer active; recurring payments on this payment method will fail';
describe('Stripe Webhook - Invoice Events', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalWebhookSecret: string | undefined;
let originalPrices: typeof Config.stripe.prices | undefined;
let originalLegacyPrices: typeof Config.stripe.legacyPrices | undefined;
beforeAll(async () => {
harness = await createApiTestHarness();
setupSyncStripeWebhookWorker();
originalWebhookSecret = Config.stripe.webhookSecret;
originalPrices = Config.stripe.prices;
originalLegacyPrices = Config.stripe.legacyPrices;
Config.stripe.webhookSecret = 'whsec_test_secret';
Config.stripe.prices = MOCK_PRICES;
stripeHandlers = createStripeApiHandlers();
@@ -60,9 +70,11 @@ describe('Stripe Webhook - Invoice Events', () => {
await harness.shutdown();
Config.stripe.webhookSecret = originalWebhookSecret;
Config.stripe.prices = originalPrices;
Config.stripe.legacyPrices = originalLegacyPrices;
});
beforeEach(async () => {
await harness.resetData();
Config.stripe.legacyPrices = undefined;
stripeHandlers.reset();
server.use(...stripeHandlers.handlers);
});
@@ -104,7 +116,7 @@ describe('Stripe Webhook - Invoice Events', () => {
subscriptionId: string;
priceId: string;
productType: string;
}): Promise<void> {
}): Promise<string> {
const {userId, subscriptionId, priceId, productType} = params;
const checkoutSessionId = `cs_test_${crypto.randomUUID()}`;
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
@@ -127,6 +139,7 @@ describe('Stripe Webhook - Invoice Events', () => {
currency: 'usd',
completed_at: new Date(),
});
return checkoutSessionId;
}
async function setSubscriptionUserState(params: {
accountUserId: string;
@@ -856,4 +869,365 @@ describe('Stripe Webhook - Invoice Events', () => {
expect(me.premium_until).not.toBeNull();
});
});
describe('renewals on retired prices', () => {
test('renews a subscription whose price is only known to the legacy price map', async () => {
Config.stripe.legacyPrices = {monthly_brl: [LEGACY_MONTHLY_BRL_PRICE]};
const account = await createTestAccount(harness);
const subscriptionId = 'sub_legacy_brl_renewal';
const customerId = 'cus_legacy_brl_renewal';
const invoiceId = 'in_legacy_brl_renewal';
const checkoutSessionId = await createPaymentRecord({
userId: account.userId,
subscriptionId,
priceId: LEGACY_MONTHLY_BRL_PRICE,
productType: ProductType.MONTHLY_SUBSCRIPTION,
});
const currentPeriodStart = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60;
server.use(
...createStripeApiHandlers({
subscriptions: {
[subscriptionId]: {
customer: customerId,
price_id: LEGACY_MONTHLY_BRL_PRICE,
currency: 'brl',
interval: 'month',
item_id: 'si_legacy_brl_renewal',
current_period_start: currentPeriodStart,
current_period_end: currentPeriodStart + 30 * 24 * 60 * 60,
},
},
}).handlers,
);
const result = await sendWebhook({
type: 'invoice.payment_succeeded',
data: {
object: {
id: invoiceId,
billing_reason: 'subscription_cycle',
customer: customerId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
});
expect(result.received).toBe(true);
const me = await createBuilder<{
premium_billing_cycle: string | null;
premium_type: number | null;
premium_until: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
expect(me.premium_billing_cycle).toBe('monthly');
expect(me.premium_until).not.toBeNull();
expect(new Date(me.premium_until!).toISOString()).toBe(
new Date((currentPeriodStart + 30 * 24 * 60 * 60) * 1000).toISOString(),
);
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
const payment = await new PaymentRepository().getPaymentByCheckoutSession(checkoutSessionId);
expect(payment?.invoiceId).toBe(invoiceId);
});
test('renews a subscription whose retired price sits in a legacy slot alongside other price ids', async () => {
Config.stripe.legacyPrices = {
monthly_brl: ['price_legacy_monthly_brl_older', LEGACY_MONTHLY_BRL_PRICE],
yearly_brl: [LEGACY_YEARLY_BRL_PRICE],
};
const account = await createTestAccount(harness);
const subscriptionId = 'sub_legacy_brl_yearly_renewal';
const customerId = 'cus_legacy_brl_yearly_renewal';
await createPaymentRecord({
userId: account.userId,
subscriptionId,
priceId: LEGACY_YEARLY_BRL_PRICE,
productType: ProductType.YEARLY_SUBSCRIPTION,
});
const currentPeriodStart = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60;
server.use(
...createStripeApiHandlers({
subscriptions: {
[subscriptionId]: {
customer: customerId,
price_id: LEGACY_YEARLY_BRL_PRICE,
currency: 'brl',
interval: 'year',
item_id: 'si_legacy_brl_yearly_renewal',
current_period_start: currentPeriodStart,
current_period_end: currentPeriodStart + 365 * 24 * 60 * 60,
},
},
}).handlers,
);
const result = await sendWebhook({
type: 'invoice.payment_succeeded',
data: {
object: {
id: 'in_legacy_brl_yearly_renewal',
billing_reason: 'subscription_cycle',
customer: customerId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
});
expect(result.received).toBe(true);
const me = await createBuilder<{
premium_billing_cycle: string | null;
premium_type: number | null;
premium_until: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
expect(me.premium_billing_cycle).toBe('yearly');
expect(new Date(me.premium_until!).toISOString()).toBe(
new Date((currentPeriodStart + 365 * 24 * 60 * 60) * 1000).toISOString(),
);
});
test('still rejects a renewal whose price is in neither the configured nor the legacy price map', async () => {
Config.stripe.legacyPrices = {monthly_brl: [LEGACY_MONTHLY_BRL_PRICE]};
const account = await createTestAccount(harness);
const subscriptionId = 'sub_unmapped_price_renewal';
const customerId = 'cus_unmapped_price_renewal';
await createPaymentRecord({
userId: account.userId,
subscriptionId,
priceId: UNMAPPED_PRICE,
productType: ProductType.MONTHLY_SUBSCRIPTION,
});
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
stripe_subscription_id: subscriptionId,
stripe_customer_id: customerId,
})
.execute();
const currentPeriodStart = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60;
server.use(
...createStripeApiHandlers({
subscriptions: {
[subscriptionId]: {
customer: customerId,
price_id: UNMAPPED_PRICE,
currency: 'brl',
interval: 'month',
item_id: 'si_unmapped_price_renewal',
current_period_start: currentPeriodStart,
current_period_end: currentPeriodStart + 30 * 24 * 60 * 60,
},
},
}).handlers,
);
await sendWebhookExpectStripeError({
type: 'invoice.payment_succeeded',
data: {
object: {
id: 'in_unmapped_price_renewal',
billing_reason: 'subscription_cycle',
customer: customerId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
});
const me = await createBuilder<{
premium_type: number | null;
premium_until: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_type).toBe(UserPremiumTypes.NONE);
expect(me.premium_until).toBeNull();
});
test('lets an authored price win over a legacy entry that claims the same price id', async () => {
Config.stripe.legacyPrices = {yearly_brl: [MOCK_PRICES.monthlyUsd]};
const account = await createTestAccount(harness);
const subscriptionId = 'sub_authored_wins_over_legacy';
await createPaymentRecord({
userId: account.userId,
subscriptionId,
priceId: MOCK_PRICES.monthlyUsd,
productType: ProductType.MONTHLY_SUBSCRIPTION,
});
const result = await sendWebhook({
type: 'invoice.payment_succeeded',
data: {
object: {
id: 'in_authored_wins_over_legacy',
billing_reason: 'subscription_cycle',
parent: {subscription_details: {subscription: subscriptionId}},
},
},
});
expect(result.received).toBe(true);
const me = await createBuilder<{
premium_billing_cycle: string | null;
premium_type: number | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
expect(me.premium_billing_cycle).toBe('monthly');
});
});
describe('donation subscription guard', () => {
test('does not extend premium for an invoice on a donation subscription', async () => {
const account = await createTestAccount(harness);
const subscriptionId = 'sub_donation_recurring';
await createPaymentRecord({
userId: account.userId,
subscriptionId,
priceId: MOCK_PRICES.monthlyUsd,
productType: ProductType.MONTHLY_SUBSCRIPTION,
});
const {DonationRepository} = await import('../../donation/DonationRepository');
await new DonationRepository().createDonor({
email: '[email protected]',
stripeCustomerId: 'cus_donation_recurring',
stripeSubscriptionId: subscriptionId,
subscriptionAmountCents: 1000,
subscriptionCurrency: 'usd',
subscriptionInterval: 'month',
subscriptionCurrentPeriodEnd: null,
});
const result = await sendWebhook({
type: 'invoice.payment_succeeded',
data: {
object: {
id: 'in_donation_recurring',
billing_reason: 'subscription_cycle',
customer: 'cus_donation_recurring',
parent: {subscription_details: {subscription: subscriptionId}},
},
},
});
expect(result.received).toBe(true);
const me = await createBuilder<{
premium_type: number | null;
premium_until: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_type).toBe(UserPremiumTypes.NONE);
expect(me.premium_until).toBeNull();
});
test('still renews when only the donor customer id matches and the subscription id does not', async () => {
const account = await createTestAccount(harness);
const sharedCustomerId = 'cus_donor_and_subscriber';
const premiumSubscriptionId = 'sub_premium_beside_donation';
await createPaymentRecord({
userId: account.userId,
subscriptionId: premiumSubscriptionId,
priceId: MOCK_PRICES.monthlyUsd,
productType: ProductType.MONTHLY_SUBSCRIPTION,
});
const {DonationRepository} = await import('../../donation/DonationRepository');
await new DonationRepository().createDonor({
email: '[email protected]',
stripeCustomerId: sharedCustomerId,
stripeSubscriptionId: 'sub_donation_beside_premium',
subscriptionAmountCents: 1000,
subscriptionCurrency: 'usd',
subscriptionInterval: 'month',
subscriptionCurrentPeriodEnd: null,
});
const result = await sendWebhook({
type: 'invoice.payment_succeeded',
data: {
object: {
id: 'in_premium_beside_donation',
billing_reason: 'subscription_cycle',
customer: sharedCustomerId,
parent: {subscription_details: {subscription: premiumSubscriptionId}},
},
},
});
expect(result.received).toBe(true);
const me = await createBuilder<{
premium_type: number | null;
premium_until: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
expect(me.premium_until).not.toBeNull();
});
});
describe('mandate.updated', () => {
function captureLoggerWarnings(): {
messages: Array<unknown>;
restore: () => void;
} {
const activeLogger = Logger.child({}) as unknown as NoopLogger;
expect(activeLogger).toBeInstanceOf(NoopLogger);
const messages: Array<unknown> = [];
const spy = vi.spyOn(activeLogger, 'warn').mockImplementation((...args: Array<unknown>) => {
messages.push(args[args.length - 1]);
});
return {messages, restore: () => spy.mockRestore()};
}
function revocationWarnings(messages: Array<unknown>): Array<unknown> {
return messages.filter((message) => message === MANDATE_REVOKED_WARNING);
}
test('logs the revocation warning and leaves premium untouched when a mandate goes inactive', async () => {
const account = await createTestAccount(harness);
const premiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: premiumUntil.toISOString(),
stripe_subscription_id: 'sub_pix_mandate',
stripe_customer_id: 'cus_pix_mandate',
})
.execute();
const warnings = captureLoggerWarnings();
try {
const result = await sendWebhook({
type: 'mandate.updated',
data: {
object: {
id: 'mandate_test_inactive',
object: 'mandate',
status: 'inactive',
payment_method: 'pm_pix_mandate',
type: 'multi_use',
},
},
});
expect(result.received).toBe(true);
expect(revocationWarnings(warnings.messages)).toHaveLength(1);
} finally {
warnings.restore();
}
const me = await createBuilder<{
premium_type: number | null;
premium_until: string | null;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
expect(me.premium_until).toBe(premiumUntil.toISOString());
});
test('does not log the revocation warning for a pending or still-active mandate', async () => {
const warnings = captureLoggerWarnings();
try {
for (const status of ['pending', 'active'] as const) {
const result = await sendWebhook({
type: 'mandate.updated',
data: {
object: {
id: `mandate_test_${status}`,
object: 'mandate',
status,
payment_method: {id: 'pm_pix_mandate', object: 'payment_method', type: 'pix'},
type: 'multi_use',
},
},
});
expect(result.received).toBe(true);
}
expect(revocationWarnings(warnings.messages)).toHaveLength(0);
} finally {
warnings.restore();
}
});
});
});
@@ -49,14 +49,14 @@ describe('Stripe Webhook Refund', () => {
.body(payload)
.execute();
}
function useRefundListHandler(chargeId: string): void {
function useRefundListHandler(chargeId: string, refunds: Array<Record<string, unknown>> = []): void {
server.use(
http.get(
({request}) => request.url === `https://api.stripe.com/v1/refunds?charge=${chargeId}&limit=100`,
() =>
HttpResponse.json({
object: 'list',
data: [],
data: refunds,
has_more: false,
url: '/v1/refunds',
}),
@@ -155,6 +155,366 @@ describe('Stripe Webhook Refund', () => {
expect(updatedPayment).not.toBeNull();
expect(updatedPayment!.status).toBe('refunded');
});
test('counts a refund once when the same charge.refunded event is redelivered', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
const {UserRepository} = await import('../../user/repositories/UserRepository');
const paymentRepository = new PaymentRepository();
const userRepository = new UserRepository();
const paymentIntentId = 'pi_test_refund_retry_123';
const checkoutSessionId = 'cs_test_refund_retry_123';
const chargeId = 'ch_test_refund_retry_123';
await paymentRepository.createPayment({
checkout_session_id: checkoutSessionId,
user_id: userId,
price_id: 'price_test_monthly',
product_type: 'monthly_subscription',
status: 'completed',
is_gift: false,
created_at: new Date(),
});
await paymentRepository.updatePayment({
checkout_session_id: checkoutSessionId,
payment_intent_id: paymentIntentId,
completed_at: new Date(),
});
useRefundListHandler(chargeId, [
{
id: 're_test_refund_retry_123',
object: 'refund',
charge: chargeId,
payment_intent: paymentIntentId,
amount: 2500,
currency: 'brl',
status: 'succeeded',
created: Math.floor(Date.now() / 1000),
metadata: {},
},
]);
const chargeEvent = {
type: 'charge.refunded' as const,
data: {
object: {
id: chargeId,
payment_intent: paymentIntentId,
amount_refunded: 2500,
},
},
};
await sendWebhook({...chargeEvent, id: 'evt_test_refund_retry_1'});
await sendWebhook({...chargeEvent, id: 'evt_test_refund_retry_2'});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser!.firstRefundAt).not.toBeNull();
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
});
test('counts a refund once when charge.refunded lands before the refund record exists', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
const {UserRepository} = await import('../../user/repositories/UserRepository');
const paymentRepository = new PaymentRepository();
const userRepository = new UserRepository();
const paymentIntentId = 'pi_test_refund_late_record';
const checkoutSessionId = 'cs_test_refund_late_record';
const chargeId = 'ch_test_refund_late_record';
const refundId = 're_test_refund_late_record';
await paymentRepository.createPayment({
checkout_session_id: checkoutSessionId,
user_id: userId,
price_id: 'price_test_monthly',
product_type: 'monthly_subscription',
status: 'completed',
is_gift: false,
created_at: new Date(),
});
await paymentRepository.updatePayment({
checkout_session_id: checkoutSessionId,
payment_intent_id: paymentIntentId,
completed_at: new Date(),
});
const refund = {
id: refundId,
object: 'refund',
charge: chargeId,
payment_intent: paymentIntentId,
amount: 2500,
currency: 'brl',
status: 'succeeded',
created: Math.floor(Date.now() / 1000),
metadata: {},
};
const chargeEvent = {
type: 'charge.refunded' as const,
data: {
object: {
id: chargeId,
payment_intent: paymentIntentId,
amount_refunded: 2500,
},
},
};
useRefundListHandler(chargeId);
await sendWebhook({...chargeEvent, id: 'evt_test_refund_late_record_1'});
const afterFallback = await userRepository.findUnique(userId);
expect(afterFallback!.firstRefundAt).not.toBeNull();
expect(afterFallback!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
await sendWebhook({
id: 'evt_test_refund_late_record_2',
type: 'refund.created',
data: {object: refund},
});
useRefundListHandler(chargeId, [refund]);
await sendWebhook({...chargeEvent, id: 'evt_test_refund_late_record_3'});
const afterRecord = await userRepository.findUnique(userId);
expect(afterRecord!.firstRefundAt!.getTime()).toBe(afterFallback!.firstRefundAt!.getTime());
expect(afterRecord!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
});
test('does not count a refund Fluxer issued itself against the refund allowance', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
const {UserRepository} = await import('../../user/repositories/UserRepository');
const paymentRepository = new PaymentRepository();
const userRepository = new UserRepository();
const paymentIntentId = 'pi_test_refund_system_123';
const checkoutSessionId = 'cs_test_refund_system_123';
const chargeId = 'ch_test_refund_system_123';
await paymentRepository.createPayment({
checkout_session_id: checkoutSessionId,
user_id: userId,
price_id: 'price_test_monthly',
product_type: 'monthly_subscription',
status: 'completed',
is_gift: false,
created_at: new Date(),
});
await paymentRepository.updatePayment({
checkout_session_id: checkoutSessionId,
payment_intent_id: paymentIntentId,
completed_at: new Date(),
});
useRefundListHandler(chargeId, [
{
id: 're_test_refund_system_123',
object: 'refund',
charge: chargeId,
payment_intent: paymentIntentId,
amount: 2500,
currency: 'brl',
status: 'succeeded',
created: Math.floor(Date.now() / 1000),
metadata: {rejection_reason: 'duplicate_active_subscription'},
},
]);
await sendWebhook({
type: 'charge.refunded',
data: {
object: {
id: chargeId,
payment_intent: paymentIntentId,
amount_refunded: 2500,
},
},
});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser!.firstRefundAt).toBeNull();
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
});
test('does not count a refund Fluxer issued for a localized card country mismatch', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
const {UserRepository} = await import('../../user/repositories/UserRepository');
const paymentRepository = new PaymentRepository();
const userRepository = new UserRepository();
const paymentIntentId = 'pi_test_refund_card_mismatch';
const checkoutSessionId = 'cs_test_refund_card_mismatch';
const chargeId = 'ch_test_refund_card_mismatch';
await paymentRepository.createPayment({
checkout_session_id: checkoutSessionId,
user_id: userId,
price_id: 'price_test_monthly',
product_type: 'monthly_subscription',
status: 'completed',
is_gift: false,
created_at: new Date(),
});
await paymentRepository.updatePayment({
checkout_session_id: checkoutSessionId,
payment_intent_id: paymentIntentId,
completed_at: new Date(),
});
useRefundListHandler(chargeId, [
{
id: 're_test_refund_card_mismatch',
object: 'refund',
charge: chargeId,
payment_intent: paymentIntentId,
amount: 2500,
currency: 'brl',
status: 'succeeded',
created: Math.floor(Date.now() / 1000),
metadata: {rejection_reason: 'localized_card_country_mismatch'},
},
]);
await sendWebhook({
type: 'charge.refunded',
data: {
object: {
id: chargeId,
payment_intent: paymentIntentId,
amount_refunded: 2500,
},
},
});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser!.firstRefundAt).toBeNull();
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
const updatedPayment = await userRepository.getPaymentByPaymentIntent(paymentIntentId);
expect(updatedPayment!.status).toBe('refunded');
});
test('counts only the customer refund when Fluxer issued a later system refund on the same charge', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
const {UserRepository} = await import('../../user/repositories/UserRepository');
const paymentRepository = new PaymentRepository();
const userRepository = new UserRepository();
const paymentIntentId = 'pi_test_refund_mixed';
const checkoutSessionId = 'cs_test_refund_mixed';
const chargeId = 'ch_test_refund_mixed';
await paymentRepository.createPayment({
checkout_session_id: checkoutSessionId,
user_id: userId,
price_id: 'price_test_monthly',
product_type: 'monthly_subscription',
status: 'completed',
is_gift: false,
created_at: new Date(),
});
await paymentRepository.updatePayment({
checkout_session_id: checkoutSessionId,
payment_intent_id: paymentIntentId,
completed_at: new Date(),
});
const nowSeconds = Math.floor(Date.now() / 1000);
const chargeEvent = {
type: 'charge.refunded' as const,
data: {
object: {
id: chargeId,
payment_intent: paymentIntentId,
amount_refunded: 2500,
refunds: {
object: 'list',
has_more: false,
url: `/v1/charges/${chargeId}/refunds`,
data: [
{
id: 're_test_refund_mixed_customer',
object: 'refund',
charge: chargeId,
payment_intent: paymentIntentId,
amount: 1500,
currency: 'brl',
status: 'succeeded',
created: nowSeconds - 600,
metadata: {},
},
{
id: 're_test_refund_mixed_system',
object: 'refund',
charge: chargeId,
payment_intent: paymentIntentId,
amount: 1000,
currency: 'brl',
status: 'succeeded',
created: nowSeconds,
metadata: {rejection_reason: 'duplicate_active_subscription'},
},
],
},
},
},
};
await sendWebhook({...chargeEvent, id: 'evt_test_refund_mixed_1'});
const afterFirst = await userRepository.findUnique(userId);
expect(afterFirst!.firstRefundAt).not.toBeNull();
expect(afterFirst!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
await sendWebhook({...chargeEvent, id: 'evt_test_refund_mixed_2'});
const afterRetry = await userRepository.findUnique(userId);
expect(afterRetry!.firstRefundAt!.getTime()).toBe(afterFirst!.firstRefundAt!.getTime());
expect(afterRetry!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
});
test('counts a second, distinct refund against the allowance after the first one was already counted', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
const {UserRepository} = await import('../../user/repositories/UserRepository');
const paymentRepository = new PaymentRepository();
const userRepository = new UserRepository();
const paymentIntentId = 'pi_test_refund_ladder';
const checkoutSessionId = 'cs_test_refund_ladder';
const chargeId = 'ch_test_refund_ladder';
await paymentRepository.createPayment({
checkout_session_id: checkoutSessionId,
user_id: userId,
price_id: 'price_test_monthly',
product_type: 'monthly_subscription',
status: 'completed',
is_gift: false,
created_at: new Date(),
});
await paymentRepository.updatePayment({
checkout_session_id: checkoutSessionId,
payment_intent_id: paymentIntentId,
completed_at: new Date(),
});
const nowSeconds = Math.floor(Date.now() / 1000);
function chargeEventWithRefunds(refunds: Array<Record<string, unknown>>) {
return {
type: 'charge.refunded' as const,
data: {
object: {
id: chargeId,
payment_intent: paymentIntentId,
amount_refunded: 2500,
refunds: {
object: 'list',
has_more: false,
url: `/v1/charges/${chargeId}/refunds`,
data: refunds,
},
},
},
};
}
const firstRefund = {
id: 're_test_refund_ladder_1',
object: 'refund',
charge: chargeId,
payment_intent: paymentIntentId,
amount: 1200,
currency: 'brl',
status: 'succeeded',
created: nowSeconds - 600,
metadata: {},
};
const secondRefund = {
...firstRefund,
id: 're_test_refund_ladder_2',
amount: 1300,
created: nowSeconds,
};
await sendWebhook({...chargeEventWithRefunds([firstRefund]), id: 'evt_test_refund_ladder_1'});
const afterFirst = await userRepository.findUnique(userId);
expect(afterFirst!.firstRefundAt).not.toBeNull();
expect(afterFirst!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
await sendWebhook({...chargeEventWithRefunds([firstRefund, secondRefund]), id: 'evt_test_refund_ladder_2'});
const afterSecond = await userRepository.findUnique(userId);
expect(afterSecond!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(PremiumFlags.PURCHASE_DISABLED);
});
test('falls back to customer ID when payment intent is not indexed (subscription mode)', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
@@ -64,6 +64,14 @@ interface StripeApiMockConfig {
paymentMethods?: Record<string, Partial<MockStripePaymentMethod>>;
setupIntents?: Record<string, Partial<MockStripeSetupIntent>>;
subscriptions?: Record<string, Partial<MockStripeSubscriptionState>>;
prices?: Record<string, MockStripePriceOverrides>;
}
interface MockStripePriceOverrides {
unit_amount?: number;
currency?: string;
interval?: 'month' | 'year';
product?: string;
}
interface SubscriptionScheduleParams {
@@ -302,6 +310,7 @@ interface MockStripeSubscriptionState {
customer: string;
trial_end: number | null;
price_id: string;
unit_amount: number;
currency: string;
interval: 'month' | 'year';
item_id: string;
@@ -808,6 +817,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
customer: 'cus_test_1',
trial_end: null,
price_id: 'price_test_1',
unit_amount: 2500,
currency: 'usd',
interval: 'month',
item_id: 'si_test_1',
@@ -856,7 +866,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
price: {
id: subState.price_id,
object: 'price',
unit_amount: 2500,
unit_amount: subState.unit_amount,
currency: subState.currency,
recurring: {
interval: subState.interval,
@@ -1703,28 +1713,31 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
http.get(`${STRIPE_API_BASE}/v1/prices/:id`, ({params}) => {
const {id} = params;
const normalizedPriceId = String(id).toLowerCase();
const overrides = config.prices?.[String(id)];
return HttpResponse.json({
id,
object: 'price',
active: true,
currency: normalizedPriceId.includes('eur')
? 'eur'
: normalizedPriceId.includes('brl')
? 'brl'
: normalizedPriceId.includes('inr')
? 'inr'
: normalizedPriceId.includes('pln')
? 'pln'
: normalizedPriceId.includes('try')
? 'try'
: 'usd',
unit_amount: normalizedPriceId.includes('year') ? 4999 : 499,
currency:
overrides?.currency ??
(normalizedPriceId.includes('eur')
? 'eur'
: normalizedPriceId.includes('brl')
? 'brl'
: normalizedPriceId.includes('inr')
? 'inr'
: normalizedPriceId.includes('pln')
? 'pln'
: normalizedPriceId.includes('try')
? 'try'
: 'usd'),
unit_amount: overrides?.unit_amount ?? (normalizedPriceId.includes('year') ? 4999 : 499),
type: 'recurring',
recurring: {
interval: normalizedPriceId.includes('year') ? 'year' : 'month',
interval: overrides?.interval ?? (normalizedPriceId.includes('year') ? 'year' : 'month'),
interval_count: 1,
},
product: 'prod_test_1',
product: overrides?.product ?? 'prod_test_1',
livemode: false,
created: Math.floor(Date.now() / 1000) - 365 * 24 * 60 * 60,
});