feat(donations): add Nordic currencies, raise amount ceilings (#2775)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 20:27:24 +02:00
1 parent 6339c3b8ad
commit 8ee2279b4b
102 files changed
+1333 -401

No files matched your search

+5
View File
@@ -147,6 +147,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
}
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
if (donationProxyKey.length > 0 && donationProxyKey.length < 32) {
throw new Error('FLUXER_API_DONATION_PROXY_KEY must be at least 32 characters');
}
if (!s3Config) {
throw new Error('S3 configuration is required for the API');
}
@@ -275,6 +279,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
internal: {
gateway: resolveGatewayInternalUrl(master),
gatewayRpcAuthToken: master.services.gateway.rpc_auth_token ?? '',
donationProxyKey,
},
hosts: {
invite: extractHostname(master.endpoints.invite),
+1
View File
@@ -134,6 +134,7 @@ export interface APIConfig {
internal: {
gateway: string;
gatewayRpcAuthToken: string;
donationProxyKey: string;
};
hosts: {
invite: string;
@@ -14,6 +14,7 @@ export interface DonorRow {
subscription_interval: Nullish<string>;
subscription_current_period_end: Nullish<Date>;
subscription_cancel_at: Nullish<Date>;
subscription_status: Nullish<string>;
created_at: Date;
updated_at: Date;
version: number;
@@ -53,6 +54,7 @@ export const DONOR_COLUMNS = [
'subscription_interval',
'subscription_current_period_end',
'subscription_cancel_at',
'subscription_status',
'created_at',
'updated_at',
'version',
@@ -6,6 +6,11 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {DonationMagicLinkExpiredError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkExpiredError';
import {DonationMagicLinkInvalidError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkInvalidError';
import {DonationMagicLinkUsedError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkUsedError';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment/StripePaymentNotAvailableError';
import {
DonationCheckoutRequest,
DonationCheckoutResponse,
@@ -13,6 +18,26 @@ import {
DonationRequestLinkRequest,
} from '@fluxer/schema/src/domains/donation/DonationSchemas';
function donationManageAlertUrl(alert: string): string {
return `${Config.endpoints.marketing}/donate/manage?alert=${alert}`;
}
function getMagicLinkAlert(error: unknown): string | null {
if (error instanceof DonationMagicLinkExpiredError) {
return 'link_expired';
}
if (error instanceof DonationMagicLinkUsedError) {
return 'link_used';
}
if (error instanceof DonationMagicLinkInvalidError) {
return 'link_invalid';
}
if (error instanceof StripeError || error instanceof StripePaymentNotAvailableError) {
return 'portal_error';
}
return null;
}
export function DonationController(app: HonoApp) {
app.post(
'/donations/request-link',
@@ -38,8 +63,9 @@ export function DonationController(app: HonoApp) {
RateLimitMiddleware(DonationRateLimitConfigs.DONATION_MANAGE),
OpenAPI({
operationId: 'manage_donation',
summary: 'Manage donation subscription',
description: 'Validates the magic link token and redirects to Stripe billing portal.',
summary: 'Open donation management link',
description:
'Checks the magic link token without consuming it and redirects to the donation management confirmation page.',
responseSchema: null,
statusCode: 302,
security: [],
@@ -48,12 +74,50 @@ export function DonationController(app: HonoApp) {
Validator('query', DonationManageQuery),
async (ctx) => {
const {token} = ctx.req.valid('query');
const {stripeCustomerId} = await ctx.get('donationService').validateMagicLinkToken(token);
if (!stripeCustomerId) {
return ctx.redirect(`${Config.endpoints.marketing}/donate`);
try {
const {stripeCustomerId} = await ctx.get('donationService').validateMagicLinkToken(token);
if (!stripeCustomerId) {
return ctx.redirect(donationManageAlertUrl('no_customer'), 302);
}
} catch (error: unknown) {
const alert = getMagicLinkAlert(error);
if (!alert) {
throw error;
}
return ctx.redirect(donationManageAlertUrl(alert), 302);
}
const encodedToken = encodeURIComponent(token);
return ctx.redirect(`${Config.endpoints.marketing}/donate/manage/confirm?token=${encodedToken}`, 302);
},
);
app.post(
'/donations/manage',
RateLimitMiddleware(DonationRateLimitConfigs.DONATION_MANAGE),
OpenAPI({
operationId: 'redeem_donation_magic_link',
summary: 'Redeem donation management link',
description: 'Consumes the magic link token and redirects to the Stripe billing portal.',
responseSchema: null,
statusCode: 302,
security: [],
tags: 'Donations',
}),
Validator('query', DonationManageQuery),
async (ctx) => {
const {token} = ctx.req.valid('query');
try {
const portalUrl = await ctx.get('donationService').redeemMagicLinkToken(token);
if (!portalUrl) {
return ctx.redirect(donationManageAlertUrl('no_customer'), 302);
}
return ctx.redirect(portalUrl, 302);
} catch (error: unknown) {
const alert = getMagicLinkAlert(error);
if (!alert) {
throw error;
}
return ctx.redirect(donationManageAlertUrl(alert), 302);
}
const portalUrl = await ctx.get('donationService').createDonorPortalSession(stripeCustomerId);
return ctx.redirect(portalUrl);
},
);
app.post(
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne} from '@app/api/database/CassandraQueryExecution';
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db, type PatchObject} from '@app/api/database/CassandraTypes';
import {executeVersionedUpdate} from '@app/api/database/CassandraVersionedUpdate';
import type {
@@ -101,6 +101,7 @@ export class DonationRepository extends IDonationRepository {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
}): Promise<Donor> {
const now = new Date();
const donorRow: DonorRow = {
@@ -115,6 +116,7 @@ export class DonationRepository extends IDonationRepository {
subscription_interval: data.subscriptionInterval,
subscription_current_period_end: data.subscriptionCurrentPeriodEnd,
subscription_cancel_at: data.subscriptionCancelAt ?? null,
subscription_status: data.subscriptionStatus ?? null,
created_at: now,
updated_at: now,
version: 1,
@@ -154,6 +156,7 @@ export class DonationRepository extends IDonationRepository {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
},
): Promise<Donor | null> {
await executeVersionedUpdate(
@@ -167,6 +170,7 @@ export class DonationRepository extends IDonationRepository {
subscription_interval: Db.set(data.subscriptionInterval),
subscription_current_period_end: Db.set(data.subscriptionCurrentPeriodEnd),
subscription_cancel_at: Db.set(data.subscriptionCancelAt ?? null),
subscription_status: Db.set(data.subscriptionStatus ?? null),
updated_at: Db.set(new Date()),
};
if (data.businessName !== undefined) {
@@ -206,6 +210,84 @@ export class DonationRepository extends IDonationRepository {
return this.findDonorByEmail(email);
}
async updateDonorCustomerDetails(
email: string,
data: {
stripeCustomerId: string | null;
businessName?: string | null;
taxId?: string | null;
taxIdType?: string | null;
},
): Promise<Donor | null> {
await executeVersionedUpdate(
() => fetchOne<DonorRow>(FETCH_DONOR_BY_EMAIL_QUERY, {email}),
() => {
const patch: PatchObject = {
stripe_customer_id: Db.set(data.stripeCustomerId),
updated_at: Db.set(new Date()),
};
if (data.businessName !== undefined) {
patch.business_name = Db.set(data.businessName);
}
if (data.taxId !== undefined) {
patch.tax_id = Db.set(data.taxId);
}
if (data.taxIdType !== undefined) {
patch.tax_id_type = Db.set(data.taxIdType);
}
return {
pk: {email},
patch,
};
},
Donors,
);
if (data.stripeCustomerId) {
await upsertOne(
DonorsByStripeCustomerId.upsertAll({
stripe_customer_id: data.stripeCustomerId,
email,
}),
);
}
return this.findDonorByEmail(email);
}
async linkDonorStripeCustomer(email: string, stripeCustomerId: string): Promise<void> {
await upsertOne(
DonorsByStripeCustomerId.upsertAll({
stripe_customer_id: stripeCustomerId,
email,
}),
);
}
async clearDonorStripeCustomer(stripeCustomerId: string): Promise<void> {
const donor = await this.findDonorByStripeCustomerId(stripeCustomerId);
if (!donor) {
return;
}
if (donor.stripeCustomerId === stripeCustomerId) {
await executeVersionedUpdate(
() => fetchOne<DonorRow>(FETCH_DONOR_BY_EMAIL_QUERY, {email: donor.email}),
() => ({
pk: {email: donor.email},
patch: {
stripe_customer_id: Db.clear(),
updated_at: Db.set(new Date()),
},
}),
Donors,
);
}
await deleteOneOrMany(
DonorsByStripeCustomerId.deleteByPk({
stripe_customer_id: stripeCustomerId,
email: donor.email,
}),
);
}
async cancelDonorSubscription(email: string): Promise<void> {
const current = await this.findDonorByEmail(email);
if (!current) {
@@ -223,6 +305,7 @@ export class DonationRepository extends IDonationRepository {
subscription_interval: Db.clear(),
subscription_current_period_end: Db.clear(),
subscription_cancel_at: Db.clear(),
subscription_status: Db.clear(),
updated_at: Db.set(new Date()),
},
}),
@@ -238,6 +321,15 @@ export class DonationRepository extends IDonationRepository {
}
}
async deleteDonorSubscriptionMapping(stripeSubscriptionId: string, email: string): Promise<void> {
await deleteOneOrMany(
DonorsByStripeSubscriptionId.deleteByPk({
stripe_subscription_id: stripeSubscriptionId,
email,
}),
);
}
async createMagicLinkToken(token: DonorMagicLinkToken): Promise<void> {
const row = token.toRow();
const batch = new BatchBuilder();
+13 -4
View File
@@ -22,6 +22,19 @@ export class DonationService implements IDonationService {
return this.magicLinkService.validateToken(token);
}
async redeemMagicLinkToken(token: string): Promise<string | null> {
const {stripeCustomerId} = await this.magicLinkService.validateToken(token);
if (!stripeCustomerId) {
return null;
}
const portalUrl = await this.checkoutService.createPortalSession(stripeCustomerId);
if (!portalUrl) {
return null;
}
await this.magicLinkService.consumeToken(token);
return portalUrl;
}
async createDonationCheckout(params: {
email: string;
amountCents: number;
@@ -32,8 +45,4 @@ export class DonationService implements IDonationService {
}): Promise<string> {
return this.checkoutService.createCheckout(params);
}
async createDonorPortalSession(stripeCustomerId: string): Promise<string> {
return this.checkoutService.createPortalSession(stripeCustomerId);
}
}
@@ -24,6 +24,7 @@ export abstract class IDonationRepository {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
}): Promise<Donor>;
abstract updateDonorSubscription(
@@ -39,11 +40,28 @@ export abstract class IDonationRepository {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
},
): Promise<Donor | null>;
abstract updateDonorCustomerDetails(
email: string,
data: {
stripeCustomerId: string | null;
businessName?: string | null;
taxId?: string | null;
taxIdType?: string | null;
},
): Promise<Donor | null>;
abstract linkDonorStripeCustomer(email: string, stripeCustomerId: string): Promise<void>;
abstract clearDonorStripeCustomer(stripeCustomerId: string): Promise<void>;
abstract cancelDonorSubscription(email: string): Promise<void>;
abstract deleteDonorSubscriptionMapping(stripeSubscriptionId: string, email: string): Promise<void>;
abstract createMagicLinkToken(token: DonorMagicLinkToken): Promise<void>;
abstract findMagicLinkToken(token: string): Promise<DonorMagicLinkToken | null>;
@@ -8,6 +8,7 @@ export interface IDonationService {
email: string;
stripeCustomerId: string | null;
}>;
redeemMagicLinkToken(token: string): Promise<string | null>;
createDonationCheckout(params: {
email: string;
amountCents: number;
@@ -16,5 +17,4 @@ export interface IDonationService {
isBusiness?: boolean;
locale?: string | null;
}): Promise<string>;
createDonorPortalSession(stripeCustomerId: string): Promise<string>;
}
@@ -3,6 +3,8 @@
import {nextVersion} from '@app/api/database/CassandraTypes';
import type {DonorRow} from '@app/api/database/types/DonationTypes';
const ACTIVE_SUBSCRIPTION_STATUSES: ReadonlySet<string> = new Set(['active', 'trialing']);
export class Donor {
readonly email: string;
readonly stripeCustomerId: string | null;
@@ -15,6 +17,7 @@ export class Donor {
readonly subscriptionInterval: string | null;
readonly subscriptionCurrentPeriodEnd: Date | null;
readonly subscriptionCancelAt: Date | null;
readonly subscriptionStatus: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly version: number;
@@ -31,6 +34,7 @@ export class Donor {
this.subscriptionInterval = row.subscription_interval ?? null;
this.subscriptionCurrentPeriodEnd = row.subscription_current_period_end ?? null;
this.subscriptionCancelAt = row.subscription_cancel_at ?? null;
this.subscriptionStatus = row.subscription_status ?? null;
this.createdAt = row.created_at;
this.updatedAt = row.updated_at;
this.version = row.version;
@@ -49,6 +53,7 @@ export class Donor {
subscription_interval: this.subscriptionInterval,
subscription_current_period_end: this.subscriptionCurrentPeriodEnd,
subscription_cancel_at: this.subscriptionCancelAt,
subscription_status: this.subscriptionStatus,
created_at: this.createdAt,
updated_at: this.updatedAt,
version: this.version,
@@ -62,6 +67,9 @@ export class Donor {
if (this.subscriptionCancelAt !== null) {
return false;
}
if (this.subscriptionStatus !== null && !ACTIVE_SUBSCRIPTION_STATUSES.has(this.subscriptionStatus)) {
return false;
}
return this.subscriptionCurrentPeriodEnd > new Date();
}
@@ -80,6 +88,7 @@ export class Donor {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
}): DonorRow {
return {
email: this.email,
@@ -93,6 +102,7 @@ export class Donor {
subscription_interval: data.subscriptionInterval,
subscription_current_period_end: data.subscriptionCurrentPeriodEnd,
subscription_cancel_at: data.subscriptionCancelAt ?? this.subscriptionCancelAt,
subscription_status: data.subscriptionStatus ?? this.subscriptionStatus,
created_at: this.createdAt,
updated_at: new Date(),
version: nextVersion(this.version),
@@ -18,9 +18,62 @@ import type Stripe from 'stripe';
type CheckoutSessionCreateParams = Stripe.Checkout.SessionCreateParams;
type CheckoutSessionMode = CheckoutSessionCreateParams['mode'];
type CheckoutSessionLineItem = NonNullable<CheckoutSessionCreateParams['line_items']>[number];
type CheckoutSessionLocale = NonNullable<CheckoutSessionCreateParams['locale']>;
const PRODUCT_NAME = 'Fluxer';
const STRIPE_CHECKOUT_LOCALES: Record<string, CheckoutSessionLocale> = {
bg: 'bg',
cs: 'cs',
da: 'da',
de: 'de',
el: 'el',
'en-GB': 'en-GB',
'en-US': 'en',
'es-419': 'es-419',
'es-ES': 'es',
fi: 'fi',
fr: 'fr',
hr: 'hr',
hu: 'hu',
id: 'id',
it: 'it',
ja: 'ja',
ko: 'ko',
lt: 'lt',
nl: 'nl',
no: 'nb',
pl: 'pl',
'pt-BR': 'pt-BR',
ro: 'ro',
ru: 'ru',
'sv-SE': 'sv',
th: 'th',
tr: 'tr',
vi: 'vi',
'zh-CN': 'zh',
'zh-TW': 'zh-TW',
};
function getStripeCheckoutLocale(locale: string | null): CheckoutSessionLocale {
if (!locale) {
return 'auto';
}
const checkoutLocale = STRIPE_CHECKOUT_LOCALES[locale];
return checkoutLocale ?? 'auto';
}
function isMissingCustomerError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'resource_missing' &&
'param' in error &&
error.param === 'customer'
);
}
function getDonationProductData(interval: 'month' | 'year' | null, locale: string | null) {
if (interval === 'month') {
return {
@@ -68,8 +121,7 @@ export class DonationCheckoutService {
const isRecurring = params.interval !== null;
const existingDonor = await this.donationRepository.findDonorByEmail(params.email);
if (isRecurring && existingDonor?.hasActiveSubscription()) {
const encodedEmail = encodeURIComponent(params.email);
return `${Config.endpoints.marketing}/donate/manage?email=${encodedEmail}&alert=active_subscription`;
return `${Config.endpoints.marketing}/donate/manage?email=${encodeURIComponent(params.email)}&alert=active_subscription`;
}
const locale = params.locale ?? null;
const donationProductData = getDonationProductData(params.interval, locale);
@@ -96,18 +148,24 @@ export class DonationCheckoutService {
quantity: 1,
};
const isBusiness = params.isBusiness === true;
const donationMetadata = {
is_donation: 'true',
donation_email: params.email,
};
const reusableCustomerId = isRecurring ? (existingDonor?.stripeCustomerId ?? null) : null;
const sessionParams: CheckoutSessionCreateParams = {
line_items: [lineItem],
mode,
locale: getStripeCheckoutLocale(locale),
...(reusableCustomerId ? {customer: reusableCustomerId} : {customer_email: params.email}),
metadata: {
is_donation: 'true',
donation_email: params.email,
...donationMetadata,
donation_type: isRecurring ? 'recurring' : 'one_time',
is_business: isBusiness ? 'true' : 'false',
...(locale ? {donation_locale: locale} : {}),
},
success_url: `${Config.endpoints.marketing}/donate/success`,
cancel_url: `${Config.endpoints.marketing}/donate`,
cancel_url: `${Config.endpoints.marketing}/donate?type=${isBusiness ? 'business' : 'individual'}&currency=${params.currency}`,
automatic_tax: {
enabled: false,
},
@@ -117,22 +175,32 @@ export class DonationCheckoutService {
...(isBusiness ? {billing_address_collection: 'required' as const} : {}),
...(mode === 'payment'
? {
customer_creation: 'always' as const,
invoice_creation: {
enabled: true,
},
payment_intent_data: {
metadata: donationMetadata,
},
}
: {}),
: {
subscription_data: {
metadata: donationMetadata,
},
}),
};
if (existingDonor?.stripeCustomerId) {
sessionParams.customer = existingDonor.stripeCustomerId;
sessionParams.customer_update = {
address: 'auto',
name: 'auto',
};
} else {
let session: Stripe.Checkout.Session;
try {
session = await this.stripe.checkout.sessions.create(sessionParams);
} catch (createError: unknown) {
if (!reusableCustomerId || !isMissingCustomerError(createError)) {
throw createError;
}
await this.clearStaleCustomerId(reusableCustomerId);
delete sessionParams.customer;
sessionParams.customer_email = params.email;
session = await this.stripe.checkout.sessions.create(sessionParams);
}
const session = await this.stripe.checkout.sessions.create(sessionParams);
try {
await getBillingRepository().checkoutSessions.upsertFromStripe(session);
} catch (mirrorErr) {
@@ -160,12 +228,11 @@ export class DonationCheckoutService {
throw error;
}
Logger.error({error, email: params.email}, 'Failed to create donation checkout session');
const message = error instanceof Error ? error.message : 'Failed to create checkout session';
throw new StripeError(message);
throw new StripeError('Failed to create checkout session');
}
}
async createPortalSession(stripeCustomerId: string): Promise<string> {
async createPortalSession(stripeCustomerId: string): Promise<string | null> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
@@ -177,9 +244,17 @@ export class DonationCheckoutService {
Logger.debug({stripeCustomerId}, 'Donation portal session created');
return session.url;
} catch (error: unknown) {
if (isMissingCustomerError(error)) {
await this.clearStaleCustomerId(stripeCustomerId);
return null;
}
Logger.error({error, stripeCustomerId}, 'Failed to create donor portal session');
const message = error instanceof Error ? error.message : 'Failed to create portal session';
throw new StripeError(message);
throw new StripeError('Failed to create portal session');
}
}
private async clearStaleCustomerId(stripeCustomerId: string): Promise<void> {
await this.donationRepository.clearDonorStripeCustomer(stripeCustomerId);
Logger.warn({stripeCustomerId}, 'Cleared deleted Stripe customer from donor');
}
}
@@ -60,7 +60,6 @@ export class DonationMagicLinkService {
if (tokenModel.isUsed()) {
throw new DonationMagicLinkUsedError();
}
await this.donationRepository.markMagicLinkTokenUsed(token, new Date());
const donor = await this.donationRepository.findDonorByEmail(tokenModel.donorEmail);
Logger.debug({email: tokenModel.donorEmail}, 'Donation magic link validated');
return {
@@ -68,4 +67,8 @@ export class DonationMagicLinkService {
stripeCustomerId: donor?.stripeCustomerId ?? null,
};
}
async consumeToken(token: string): Promise<void> {
await this.donationRepository.markMagicLinkTokenUsed(token, new Date());
}
}
@@ -106,6 +106,9 @@ describe('POST /donations/checkout', () => {
DONATION_CURRENCY_VALUES.INR,
DONATION_CURRENCY_VALUES.PLN,
DONATION_CURRENCY_VALUES.TRY,
DONATION_CURRENCY_VALUES.SEK,
DONATION_CURRENCY_VALUES.DKK,
DONATION_CURRENCY_VALUES.NOK,
])('accepts %s currency', async (currency) => {
const amount_cents = getDonationAmountConstraints(currency).minimumAmountMinor;
const response = await createDonationCheckoutBuilder(harness)
@@ -13,10 +13,15 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {createPwnedPasswordsRangeHandler} from '@app/api/test/msw/handlers/PwnedPasswordsHandlers';
import {createStripeApiHandlers, type StripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers';
import {server} from '@app/api/test/msw/server';
import {createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
describe('GET /donations/manage', () => {
type DonationManageAlert = 'link_expired' | 'link_used' | 'link_invalid' | 'no_customer' | 'portal_error';
const STRIPE_PORTAL_URL_PATTERN = /^https:\/\/billing\.stripe\.com\/p\/session\//;
describe('GET and POST /donations/manage', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let donationRepository: DonationRepository;
@@ -33,6 +38,32 @@ describe('GET /donations/manage', () => {
stripeHandlers.reset();
server.use(...stripeHandlers.handlers);
});
function createDonationRedeemBuilder(token: string): TestRequestBuilder<void> {
return createBuilderWithoutAuth<void>(harness).post(`/donations/manage?token=${encodeURIComponent(token)}`);
}
async function openManageLink(token: string): Promise<string | null> {
const {response} = await createDonationManageBuilder(harness, token).expect(302).executeWithResponse();
return response.headers.get('location');
}
async function redeemManageLink(token: string): Promise<string | null> {
const {response} = await createDonationRedeemBuilder(token).expect(302).executeWithResponse();
return response.headers.get('location');
}
function confirmUrl(token: string): string {
return `${Config.endpoints.marketing}/donate/manage/confirm?token=${encodeURIComponent(token)}`;
}
function alertUrl(alert: DonationManageAlert): string {
return `${Config.endpoints.marketing}/donate/manage?alert=${alert}`;
}
async function isTokenUsed(token: string): Promise<boolean | undefined> {
const tokenModel = await donationRepository.findMagicLinkToken(token);
return tokenModel?.isUsed();
}
function useFailingPortalHandlers(): void {
server.resetHandlers();
const failingHandlers = createStripeApiHandlers({portalShouldFail: true});
server.use(createPwnedPasswordsRangeHandler(), ...failingHandlers.handlers);
}
async function createDonorWithCustomerId(email: string, customerId: string): Promise<void> {
await donationRepository.createDonor({
email,
@@ -47,29 +78,21 @@ describe('GET /donations/manage', () => {
subscriptionCurrentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
}
async function createValidMagicLinkToken(email: string, token: string): Promise<void> {
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const tokenModel = new DonorMagicLinkToken({
token_: token,
donor_email: email,
expires_at: expiresAt,
used_at: null,
async function createDonorWithoutSubscription(email: string, customerId: string | null): Promise<void> {
await donationRepository.createDonor({
email,
stripeCustomerId: customerId,
businessName: null,
taxId: null,
taxIdType: null,
stripeSubscriptionId: null,
subscriptionAmountCents: null,
subscriptionCurrency: null,
subscriptionInterval: null,
subscriptionCurrentPeriodEnd: null,
});
await donationRepository.createMagicLinkToken(tokenModel);
}
async function createExpiredMagicLinkToken(email: string, token: string): Promise<void> {
const expiresAt = new Date(Date.now() - 1000);
const tokenModel = new DonorMagicLinkToken({
token_: token,
donor_email: email,
expires_at: expiresAt,
used_at: null,
});
await donationRepository.createMagicLinkToken(tokenModel);
}
async function createUsedMagicLinkToken(email: string, token: string): Promise<void> {
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const usedAt = new Date(Date.now() - 5000);
async function createMagicLinkToken(email: string, token: string, expiresAt: Date, usedAt: Date | null) {
const tokenModel = new DonorMagicLinkToken({
token_: token,
donor_email: email,
@@ -78,242 +101,279 @@ describe('GET /donations/manage', () => {
});
await donationRepository.createMagicLinkToken(tokenModel);
}
describe('valid token with customer ID', () => {
async function createValidMagicLinkToken(email: string, token: string): Promise<void> {
await createMagicLinkToken(email, token, new Date(Date.now() + 15 * 60 * 1000), null);
}
async function createExpiredMagicLinkToken(email: string, token: string): Promise<void> {
await createMagicLinkToken(email, token, new Date(Date.now() - 1000), null);
}
async function createUsedMagicLinkToken(email: string, token: string): Promise<void> {
await createMagicLinkToken(email, token, new Date(Date.now() + 15 * 60 * 1000), new Date(Date.now() - 5000));
}
describe('GET with a valid token and customer ID', () => {
test('redirects to the confirmation page', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_valid_123');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
});
test('does not open a Stripe billing portal session', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_valid_123');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
});
describe('link scanner prefetch', () => {
test('GET does not mark token as used', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_prefetch');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
});
test('repeated GETs keep redirecting to the confirmation page', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_prefetch');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('POST still redeems a token after repeated GETs', async () => {
const customerId = 'cus_test_prefetch';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(true);
});
});
describe('POST with a valid token and customer ID', () => {
test('redirects to Stripe billing portal', async () => {
const customerId = 'cus_test_valid_123';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(302);
expect(response.headers.get('location')).toContain('https://billing.stripe.com');
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
});
test('marks token as used after validation', async () => {
const customerId = 'cus_test_valid_456';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
test('marks token as used after the portal session is created', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_valid_456');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN);
expect(token?.isUsed()).toBe(true);
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(true);
});
test('includes return_url in portal session', async () => {
const customerId = 'cus_test_return_url';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_return_url');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
const portalSession = stripeHandlers.spies.createdPortalSessions[0];
expect(portalSession?.return_url).toBeDefined();
expect(portalSession?.return_url).toBe(`${Config.endpoints.marketing}/donate`);
});
});
describe('valid token without customer ID', () => {
test('redirects to donation page when donor has no customer ID', async () => {
await donationRepository.createDonor({
email: TEST_DONOR_EMAIL,
stripeCustomerId: null,
businessName: null,
taxId: null,
taxIdType: null,
stripeSubscriptionId: null,
subscriptionAmountCents: null,
subscriptionCurrency: null,
subscriptionInterval: null,
subscriptionCurrentPeriodEnd: null,
});
test('redirects to no_customer alert when donor has no customer ID', async () => {
await createDonorWithoutSubscription(TEST_DONOR_EMAIL, null);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(302);
const location = response.headers.get('location');
expect(location).toContain(Config.endpoints.marketing);
expect(location).toContain('/donate');
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('redirects to donation page when donor does not exist', async () => {
test('redirects to no_customer alert when donor does not exist', async () => {
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(302);
const location = response.headers.get('location');
expect(location).toContain(Config.endpoints.marketing);
expect(location).toContain('/donate');
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('marks token as used even when redirecting to donate page', async () => {
test('does not mark token as used when there is no customer', async () => {
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN);
expect(token?.isUsed()).toBe(true);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
});
});
describe('expired token handling', () => {
test('rejects expired token', async () => {
const customerId = 'cus_test_expired';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_expired');
await createExpiredMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('does not mark expired token as used', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
await createExpiredMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN);
expect(token?.isUsed()).toBe(false);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
});
test('rejects token expired by exactly 1 millisecond', async () => {
const expiresAt = new Date(Date.now() - 1);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
const tokenModel = new DonorMagicLinkToken({
token_: TEST_MAGIC_LINK_TOKEN,
donor_email: TEST_DONOR_EMAIL,
expires_at: expiresAt,
used_at: null,
});
await donationRepository.createMagicLinkToken(tokenModel);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
await createMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN, new Date(Date.now() - 1), null);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
});
describe('used token handling', () => {
test('rejects already-used token', async () => {
const customerId = 'cus_test_used';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_used');
await createUsedMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_USED)
.execute();
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('prevents reuse of token after successful redemption', async () => {
const customerId = 'cus_test_reuse';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_reuse');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_USED)
.execute();
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
});
});
describe('invalid token handling', () => {
test('rejects non-existent token', async () => {
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID)
.execute();
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token with invalid format', async () => {
await createDonationManageBuilder(harness, TEST_INVALID_TOKEN).expect(400).execute();
await createDonationManageBuilder(harness, TEST_INVALID_TOKEN)
.expect(400, APIErrorCodes.INVALID_FORM_BODY)
.execute();
await createDonationRedeemBuilder(TEST_INVALID_TOKEN).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects empty token', async () => {
await createDonationManageBuilder(harness, '').expect(400).execute();
await createDonationManageBuilder(harness, '').expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
await createDonationRedeemBuilder('').expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token that is too short', async () => {
const shortToken = 'a'.repeat(63);
await createDonationManageBuilder(harness, shortToken).expect(400).execute();
await createDonationManageBuilder(harness, shortToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
await createDonationRedeemBuilder(shortToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token that is too long', async () => {
const longToken = 'a'.repeat(65);
await createDonationManageBuilder(harness, longToken).expect(400).execute();
await createDonationManageBuilder(harness, longToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
await createDonationRedeemBuilder(longToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token with non-hex characters', async () => {
const invalidToken = 'g'.repeat(64);
await createDonationManageBuilder(harness, invalidToken).expect(400).execute();
expect(await openManageLink(invalidToken)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(invalidToken)).toBe(alertUrl('link_invalid'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token with special characters', async () => {
const specialToken = `${'a'.repeat(62)}@!`;
await createDonationManageBuilder(harness, specialToken).expect(400).execute();
expect(await openManageLink(specialToken)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(specialToken)).toBe(alertUrl('link_invalid'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token with whitespace', async () => {
const whitespaceToken = `${'a'.repeat(32)} ${'a'.repeat(31)}`;
await createDonationManageBuilder(harness, whitespaceToken).expect(400).execute();
expect(await openManageLink(whitespaceToken)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(whitespaceToken)).toBe(alertUrl('link_invalid'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
});
describe('missing token parameter', () => {
test('rejects request without token query parameter', async () => {
const response = await harness.requestJson({
const getResponse = await harness.requestJson({
path: '/donations/manage',
method: 'GET',
});
expect(response.status).toBe(400);
expect(getResponse.status).toBe(400);
const postResponse = await harness.requestJson({
path: '/donations/manage',
method: 'POST',
});
expect(postResponse.status).toBe(400);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
});
describe('Stripe portal session creation', () => {
test('handles Stripe API failure gracefully', async () => {
const customerId = 'cus_test_stripe_fail';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_stripe_fail');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
useFailingPortalHandlers();
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('portal_error'));
});
test('does not mark token as used when the portal session fails', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_stripe_fail');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
useFailingPortalHandlers();
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
});
test('redeems the same token once Stripe recovers', async () => {
const customerId = 'cus_test_stripe_retry';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
useFailingPortalHandlers();
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('portal_error'));
server.resetHandlers();
const failingHandlers = createStripeApiHandlers({portalShouldFail: true});
server.use(createPwnedPasswordsRangeHandler(), ...failingHandlers.handlers);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(400);
server.use(...stripeHandlers.handlers);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(true);
});
test('creates portal session with correct customer ID', async () => {
const customerId = 'cus_test_specific_123';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
const session = stripeHandlers.spies.createdPortalSessions[0];
expect(session?.customer).toBe(customerId);
});
});
describe('edge cases', () => {
test('handles token for donor with cancelled subscription', async () => {
await donationRepository.createDonor({
email: TEST_DONOR_EMAIL,
stripeCustomerId: 'cus_test_cancelled',
businessName: null,
taxId: null,
taxIdType: null,
stripeSubscriptionId: null,
subscriptionAmountCents: null,
subscriptionCurrency: null,
subscriptionInterval: null,
subscriptionCurrentPeriodEnd: null,
});
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(302);
expect(response.headers.get('location')).toContain('https://billing.stripe.com');
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
afterEach(() => {
vi.useRealTimers();
});
test('handles token expiring in exactly 0 milliseconds', async () => {
const expiresAt = new Date(Date.now() - 1);
test('handles token for donor with cancelled subscription', async () => {
const customerId = 'cus_test_cancelled';
await createDonorWithoutSubscription(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
});
test('handles token at its exact expiry instant', async () => {
const expiresAt = new Date();
vi.useFakeTimers({toFake: ['Date']});
vi.setSystemTime(expiresAt);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
const tokenModel = new DonorMagicLinkToken({
token_: TEST_MAGIC_LINK_TOKEN,
donor_email: TEST_DONOR_EMAIL,
expires_at: expiresAt,
used_at: null,
});
await donationRepository.createMagicLinkToken(tokenModel);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
await createMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN, expiresAt, null);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
vi.setSystemTime(expiresAt.getTime() + 1);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('handles case-sensitive token comparison', async () => {
const lowerToken = 'a'.repeat(64);
const upperToken = 'A'.repeat(64);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, lowerToken);
await createDonationManageBuilder(harness, upperToken)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID)
.execute();
expect(await openManageLink(upperToken)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(upperToken)).toBe(alertUrl('link_invalid'));
expect(await isTokenUsed(lowerToken)).toBe(false);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('handles multiple valid tokens for different donors', async () => {
const token1 = 'a'.repeat(64);
@@ -326,35 +386,37 @@ describe('GET /donations/manage', () => {
await createDonorWithCustomerId(email2, customerId2);
await createValidMagicLinkToken(email1, token1);
await createValidMagicLinkToken(email2, token2);
const {response: response1} = await createDonationManageBuilder(harness, token1).executeRaw();
expect(response1.status).toBe(302);
expect(await openManageLink(token1)).toBe(confirmUrl(token1));
expect(await openManageLink(token2)).toBe(confirmUrl(token2));
expect(await redeemManageLink(token1)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId1);
const {response: response2} = await createDonationManageBuilder(harness, token2).executeRaw();
expect(response2.status).toBe(302);
expect(await isTokenUsed(token1)).toBe(true);
expect(await isTokenUsed(token2)).toBe(false);
expect(await redeemManageLink(token2)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions[1]?.customer).toBe(customerId2);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(2);
});
});
describe('token validation order', () => {
test('checks token existence before expiration', async () => {
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID)
.execute();
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
});
test('checks expiration before usage status', async () => {
const expiresAt = new Date(Date.now() - 1000);
const usedAt = new Date(Date.now() - 5000);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
const tokenModel = new DonorMagicLinkToken({
token_: TEST_MAGIC_LINK_TOKEN,
donor_email: TEST_DONOR_EMAIL,
expires_at: expiresAt,
used_at: usedAt,
});
await donationRepository.createMagicLinkToken(tokenModel);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
await createMagicLinkToken(
TEST_DONOR_EMAIL,
TEST_MAGIC_LINK_TOKEN,
new Date(Date.now() - 1000),
new Date(Date.now() - 5000),
);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
});
test('checks usage status before customer lookup', async () => {
await createUsedMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
});
});
});
@@ -112,8 +112,12 @@ describe('POST /donations/request-link', () => {
const longEmail = `${longLocalPart}@example.com`;
await createDonationRequestLinkBuilder(harness).body({email: longEmail}).expect(400).execute();
});
test('rejects email with leading/trailing whitespace', async () => {
await createDonationRequestLinkBuilder(harness).body({email: ' [email protected] '}).expect(400).execute();
test('normalizes surrounding whitespace and letter case to the stored donor', async () => {
await createDonor(TEST_DONOR_EMAIL);
await createDonationRequestLinkBuilder(harness)
.body({email: ` ${TEST_DONOR_EMAIL.toUpperCase()} `})
.expect(204)
.execute();
});
});
describe('idempotency', () => {
@@ -52,6 +52,9 @@ export const DONATION_CURRENCY_VALUES = {
INR: DONATION_CURRENCIES[3],
PLN: DONATION_CURRENCIES[4],
TRY: DONATION_CURRENCIES[5],
SEK: DONATION_CURRENCIES[6],
DKK: DONATION_CURRENCIES[7],
NOK: DONATION_CURRENCIES[8],
} as const;
export const DONATION_INTERVALS = {
MONTH: 'month',
@@ -1,13 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {createHash, timingSafeEqual} from 'node:crypto';
import * as AuthSession from '@app/api/auth/AuthSession';
import {Config} from '@app/api/Config';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {getRequestClientIp} from '@app/api/utils/RequestClientIp';
import {readRequestJsonBody} from '@app/api/utils/RequestJsonBody';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import {getSameIpDecisionKey, parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import type {BucketConfig, RateLimitResult, RateLimitScope} from '@pkgs/rate_limit/src/IRateLimitService';
import type {Context, MiddlewareHandler} from 'hono';
import {createMiddleware} from 'hono/factory';
@@ -18,10 +19,17 @@ export interface RouteRateLimitConfig {
bucket: string;
config: BucketConfig;
scope?: RateLimitScope;
trustDonorIpHeader?: boolean;
emailBucket?: {
bucket: string;
config: BucketConfig;
};
}
const TEST_ENABLE_RATE_LIMITS_HEADER = 'x-fluxer-test-enable-rate-limits';
const TEST_GLOBAL_RATE_LIMIT_OVERRIDE_HEADER = 'x-fluxer-test-global-rate-limit';
const INTERNAL_KEY_HEADER = 'x-fluxer-internal-key';
const DONOR_IP_HEADER = 'x-fluxer-donor-ip';
function shouldEnforceRateLimits(ctx: Context<HonoEnv>): boolean {
if (!Config.dev.testModeEnabled) {
@@ -49,7 +57,27 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
return accountType === 'bot' || accountType === 'webhook';
}
function getClientIdentifier(ctx: Context<HonoEnv>): string {
function isTrustedInternalCaller(ctx: Context<HonoEnv>): boolean {
const expectedKey = Config.internal.donationProxyKey;
if (!expectedKey) return false;
const providedKey = ctx.req.header(INTERNAL_KEY_HEADER);
if (!providedKey) return false;
const expectedBuffer = Buffer.from(expectedKey);
const providedBuffer = Buffer.from(providedKey);
if (expectedBuffer.length !== providedBuffer.length) return false;
return timingSafeEqual(expectedBuffer, providedBuffer);
}
function getForwardedDonorIdentifier(ctx: Context<HonoEnv>): string | null {
if (!isTrustedInternalCaller(ctx)) return null;
const headerValue = ctx.req.header(DONOR_IP_HEADER)?.split(',', 1)[0].trim();
if (!headerValue) return null;
const donorIp = parseIpAddress(headerValue);
if (!donorIp) return null;
return `ip:${getSameIpDecisionKey(donorIp.normalized) ?? donorIp.normalized}`;
}
function getClientIdentifier(ctx: Context<HonoEnv>, routeConfig: RouteRateLimitConfig): string {
const user = ctx.get('user');
if (user?.id) {
const tokenType = ctx.get('authTokenType') ?? 'session';
@@ -58,11 +86,25 @@ function getClientIdentifier(ctx: Context<HonoEnv>): string {
}
return `user:${user.id}:${tokenType}`;
}
if (routeConfig.trustDonorIpHeader) {
const donorIdentifier = getForwardedDonorIdentifier(ctx);
if (donorIdentifier) return donorIdentifier;
}
const ip = getRequestClientIp(ctx);
if (!ip) return 'internal';
return `ip:${getSameIpDecisionKey(ip) ?? ip}`;
}
async function getRequestEmailIdentifier(ctx: Context<HonoEnv>): Promise<string | null> {
const body = await readRequestJsonBody(ctx.req);
if (!body.parsed || typeof body.value !== 'object' || body.value === null) return null;
const email = Reflect.get(body.value, 'email');
if (typeof email !== 'string') return null;
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) return null;
return `email:${createHash('sha256').update(normalizedEmail).digest('hex').slice(0, 32)}`;
}
function getGlobalRateLimit(ctx: Context<HonoEnv>): number {
if (Config.dev.testModeEnabled) {
const override = ctx.req.header(TEST_GLOBAL_RATE_LIMIT_OVERRIDE_HEADER);
@@ -131,6 +173,7 @@ async function revokeAuthenticatedSessionOnGlobalRateLimit(ctx: Context<HonoEnv>
export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): MiddlewareHandler<HonoEnv> {
const routeBucketHash = getBucketHash(routeConfig.bucket);
const emailBucketHash = routeConfig.emailBucket ? getBucketHash(routeConfig.emailBucket.bucket) : undefined;
return createMiddleware<HonoEnv>(async (ctx, next) => {
if (!shouldEnforceRateLimits(ctx)) {
await next();
@@ -148,7 +191,7 @@ export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): Middlewa
}
const accountType = getAccountType(ctx);
const showHeaders = shouldShowHeadersOnSuccess(accountType);
const clientId = getClientIdentifier(ctx);
const clientId = getClientIdentifier(ctx, routeConfig);
if (!routeConfig.config.exemptFromGlobal) {
const globalLimit = getGlobalRateLimit(ctx);
const globalResult = await rateLimitService.checkGlobalLimit(clientId, globalLimit);
@@ -182,6 +225,27 @@ export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): Middlewa
scope: routeConfig.scope ?? 'user',
});
}
const emailBucketConfig = routeConfig.emailBucket;
if (emailBucketConfig) {
const emailIdentifier = await getRequestEmailIdentifier(ctx);
if (emailIdentifier) {
const emailBucketResult = await rateLimitService.checkBucketLimit(
resolveBucket(emailBucketConfig.bucket, emailIdentifier, ctx),
{...emailBucketConfig.config, algorithm: 'leaky_bucket'},
);
if (!emailBucketResult.allowed) {
throw new RateLimitError({
retryAfter: getRetryAfterSeconds(emailBucketResult),
retryAfterDecimal: emailBucketResult.retryAfterDecimal,
limit: emailBucketResult.limit,
resetTime: emailBucketResult.resetTime,
resetAfterDecimal: emailBucketResult.resetAfterDecimal,
bucketHash: emailBucketHash,
scope: 'shared',
});
}
}
}
if (showHeaders) {
setRateLimitHeaders(ctx, bucketResult, routeBucketHash);
}
+56 -3
View File
@@ -5310,7 +5310,7 @@
"/donations/manage": {
"get": {
"operationId": "manage_donation",
"summary": "Manage donation subscription",
"summary": "Open donation management link",
"tags": ["Donations"],
"responses": {
"302": {"description": "Success"},
@@ -5345,7 +5345,60 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Validates the magic link token and redirects to Stripe billing portal.",
"description": "Checks the magic link token without consuming it and redirects to the donation management confirmation page.",
"parameters": [
{
"name": "token",
"in": "query",
"required": true,
"schema": {
"type": "string",
"minLength": 64,
"maxLength": 64,
"description": "Magic link token for donor authentication"
},
"description": "Magic link token for donor authentication"
}
]
},
"post": {
"operationId": "redeem_donation_magic_link",
"summary": "Redeem donation management link",
"tags": ["Donations"],
"responses": {
"302": {"description": "Success"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Consumes the magic link token and redirects to the Stripe billing portal.",
"parameters": [
{
"name": "token",
@@ -32004,7 +32057,7 @@
"required": ["id", "name"],
"additionalProperties": false
},
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try"]},
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
"GuildExpressionSourceGuildResponse": {
"type": "object",
"properties": {
@@ -7,13 +7,24 @@ export const DonationRateLimitConfigs = {
DONATION_REQUEST_LINK: {
bucket: 'donation:request_link',
config: {limit: 3, windowMs: ms('1 hour')},
trustDonorIpHeader: true,
emailBucket: {
bucket: 'donation:request_link:email',
config: {limit: 10, windowMs: ms('1 hour')},
},
} as RouteRateLimitConfig,
DONATION_MANAGE: {
bucket: 'donation:manage',
config: {limit: 10, windowMs: ms('1 minute')},
trustDonorIpHeader: true,
} as RouteRateLimitConfig,
DONATION_CHECKOUT: {
bucket: 'donation:checkout',
config: {limit: 5, windowMs: ms('1 minute')},
trustDonorIpHeader: true,
emailBucket: {
bucket: 'donation:checkout:email',
config: {limit: 10, windowMs: ms('1 hour')},
},
} as RouteRateLimitConfig,
} as const;
@@ -31,9 +31,9 @@ import {seconds} from 'itty-time';
import type Stripe from 'stripe';
interface DonationCustomerDetails {
businessName: string | null;
taxId: string | null;
taxIdType: string | null;
businessName: string | undefined;
taxId: string | undefined;
taxIdType: string | undefined;
}
interface DonationSubscriptionDetails {
@@ -42,6 +42,7 @@ interface DonationSubscriptionDetails {
interval: string | null;
currentPeriodEnd: Date | null;
cancelAt: Date | null;
status: string | null;
}
interface CheckoutChargeDetails {
@@ -112,6 +113,8 @@ export class StripeCheckoutWebhookHandler {
async handleAsyncPaymentSucceeded(session: Stripe.Checkout.Session): Promise<void> {
if (session.metadata?.is_donation === 'true') {
Logger.info({sessionId: session.id}, 'Processing async payment succeeded for donation checkout session');
await this.handleDonationCheckoutCompleted(session);
return;
}
Logger.info({sessionId: session.id}, 'Processing async payment succeeded for checkout session');
@@ -120,6 +123,14 @@ export class StripeCheckoutWebhookHandler {
async handleAsyncPaymentFailed(session: Stripe.Checkout.Session): Promise<void> {
if (session.metadata?.is_donation === 'true') {
Logger.warn(
{
sessionId: session.id,
email: session.metadata?.donation_email,
paymentStatus: session.payment_status,
},
'Async payment failed for donation checkout session; no confirmation sent',
);
return;
}
const payment = await this.userRepository.getPaymentByCheckoutSession(session.id);
@@ -887,7 +898,7 @@ export class StripeCheckoutWebhookHandler {
}
private async handleDonationCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const email = session.metadata?.donation_email;
const email = session.metadata?.donation_email?.trim().toLowerCase();
if (!email) {
Logger.error({sessionId: session.id}, 'Donation checkout missing email in metadata');
throw new StripeError('Donation checkout missing email');
@@ -904,24 +915,40 @@ export class StripeCheckoutWebhookHandler {
Logger.error({sessionId: session.id}, 'Donation checkout missing subscription id');
throw new StripeError('Donation checkout missing subscription id');
}
const customerDetails = await this.loadDonationCustomerDetails(customerId);
const customerDetails = await this.loadDonationCustomerDetails(
customerId,
session.metadata?.is_business === 'true',
);
const subscriptionDetails = isRecurring
? await this.loadDonationSubscriptionDetails(subscriptionId)
: {amountCents: null, currency: null, interval: null, currentPeriodEnd: null, cancelAt: null};
: {amountCents: null, currency: null, interval: null, currentPeriodEnd: null, cancelAt: null, status: null};
const existingDonor = await this.donationRepository.findDonorByEmail(email);
if (existingDonor) {
const subscriptionUpdate = {
stripeSubscriptionId: subscriptionId,
subscriptionAmountCents: subscriptionDetails.amountCents,
subscriptionCurrency: subscriptionDetails.currency,
subscriptionInterval: subscriptionDetails.interval,
subscriptionCurrentPeriodEnd: subscriptionDetails.currentPeriodEnd,
subscriptionCancelAt: subscriptionDetails.cancelAt,
subscriptionStatus: subscriptionDetails.status,
};
if (existingDonor && isRecurring) {
await this.donationRepository.updateDonorSubscription(email, {
stripeCustomerId: customerId,
businessName: customerDetails.businessName,
taxId: customerDetails.taxId,
taxIdType: customerDetails.taxIdType,
stripeSubscriptionId: subscriptionId,
subscriptionAmountCents: subscriptionDetails.amountCents,
subscriptionCurrency: subscriptionDetails.currency,
subscriptionInterval: subscriptionDetails.interval,
subscriptionCurrentPeriodEnd: subscriptionDetails.currentPeriodEnd,
subscriptionCancelAt: subscriptionDetails.cancelAt,
...subscriptionUpdate,
});
} else if (existingDonor) {
const subscriptionCustomerId = existingDonor.stripeSubscriptionId ? existingDonor.stripeCustomerId : null;
await this.donationRepository.updateDonorCustomerDetails(email, {
stripeCustomerId: subscriptionCustomerId ?? customerId,
businessName: customerDetails.businessName,
taxId: customerDetails.taxId,
taxIdType: customerDetails.taxIdType,
});
await this.donationRepository.linkDonorStripeCustomer(email, customerId);
} else {
await this.donationRepository.createDonor({
email,
@@ -929,14 +956,16 @@ export class StripeCheckoutWebhookHandler {
businessName: customerDetails.businessName,
taxId: customerDetails.taxId,
taxIdType: customerDetails.taxIdType,
stripeSubscriptionId: subscriptionId,
subscriptionAmountCents: subscriptionDetails.amountCents,
subscriptionCurrency: subscriptionDetails.currency,
subscriptionInterval: subscriptionDetails.interval,
subscriptionCurrentPeriodEnd: subscriptionDetails.currentPeriodEnd,
subscriptionCancelAt: subscriptionDetails.cancelAt,
...subscriptionUpdate,
});
}
if (session.payment_status === 'unpaid') {
Logger.info(
{sessionId: session.id, email, isRecurring, paymentStatus: session.payment_status},
'Donation checkout completed with unpaid status; deferring confirmation until settlement',
);
return;
}
const encodedEmail = encodeURIComponent(email);
const manageUrl = `${Config.endpoints.marketing}/donate/manage?email=${encodedEmail}`;
if (isRecurring) {
@@ -947,14 +976,15 @@ export class StripeCheckoutWebhookHandler {
Logger.error({sessionId: session.id, subscriptionId}, 'Donation subscription details incomplete');
throw new StripeError('Donation subscription details incomplete');
}
await this.emailService.sendDonationConfirmation(
await this.sendDonationConfirmationOnce({
sessionId: session.id,
email,
recurringAmountCents,
recurringCurrency,
recurringInterval,
amountCents: recurringAmountCents,
currency: recurringCurrency,
interval: recurringInterval,
manageUrl,
donationLocale,
);
locale: donationLocale,
});
} else {
const oneTimeAmountCents = session.amount_total;
const oneTimeCurrency = session.currency;
@@ -965,14 +995,15 @@ export class StripeCheckoutWebhookHandler {
);
throw new StripeError('Donation checkout missing amount or currency');
}
await this.emailService.sendDonationConfirmation(
await this.sendDonationConfirmationOnce({
sessionId: session.id,
email,
oneTimeAmountCents,
oneTimeCurrency,
'once',
amountCents: oneTimeAmountCents,
currency: oneTimeCurrency,
interval: 'once',
manageUrl,
donationLocale,
);
locale: donationLocale,
});
}
Logger.info(
{
@@ -991,6 +1022,47 @@ export class StripeCheckoutWebhookHandler {
);
}
private async sendDonationConfirmationOnce({
sessionId,
email,
amountCents,
currency,
interval,
manageUrl,
locale,
}: {
sessionId: string;
email: string;
amountCents: number;
currency: string;
interval: string;
manageUrl: string;
locale: string | null;
}): Promise<void> {
const confirmationSentKey = this.getDonationConfirmationSentKey(sessionId);
if (await this.cacheService.get<boolean>(confirmationSentKey)) {
Logger.debug({sessionId}, 'Donation confirmation already sent for checkout session');
return;
}
const sent = await this.emailService.sendDonationConfirmation(
email,
amountCents,
currency,
interval,
manageUrl,
locale,
);
if (!sent) {
Logger.error({sessionId, email, interval}, 'Failed to send donation confirmation email');
throw new StripeError('Failed to send donation confirmation email');
}
await this.cacheService.set(
confirmationSentKey,
true,
StripeCheckoutWebhookHandler.CHECKOUT_EFFECTS_APPLIED_TTL_SECONDS,
);
}
private async cancelStripeSubscriptionImmediately(user: User): Promise<void> {
if (!this.stripe) {
throw new StripeError('Stripe client not available for immediate cancellation');
@@ -1185,19 +1257,23 @@ export class StripeCheckoutWebhookHandler {
return `stripe:checkout:gift:finalised:${checkoutSessionId}`;
}
private async loadDonationCustomerDetails(customerId: string): Promise<DonationCustomerDetails> {
private getDonationConfirmationSentKey(checkoutSessionId: string): string {
return `stripe:checkout:donation:confirmation:sent:${checkoutSessionId}`;
}
private async loadDonationCustomerDetails(customerId: string, isBusiness: boolean): Promise<DonationCustomerDetails> {
if (!this.stripe) {
throw new StripeError('Stripe client not available for donation customer lookup');
}
try {
const customer = await this.stripe.customers.retrieve(customerId);
const customer = await this.stripe.customers.retrieve(customerId, {expand: ['tax_ids']});
if (customer && !customer.deleted) {
const businessName = customer.name ?? null;
const primaryTaxId = customer.tax_ids?.data?.[0] ?? null;
const isBusinessDonor = isBusiness || primaryTaxId !== null;
return {
businessName,
taxId: primaryTaxId?.value ?? null,
taxIdType: primaryTaxId?.type ?? null,
businessName: isBusinessDonor ? (customer.name ?? undefined) : undefined,
taxId: primaryTaxId?.value ?? undefined,
taxIdType: primaryTaxId?.type ?? undefined,
};
}
} catch (error) {
@@ -1237,6 +1313,7 @@ export class StripeCheckoutWebhookHandler {
interval: item.price.recurring.interval,
currentPeriodEnd,
cancelAt,
status: subscription.status,
};
} catch (error) {
Logger.error({error, subscriptionId}, 'Failed to retrieve subscription details');
@@ -2,6 +2,7 @@
import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IDonationRepository} from '@app/api/donation/IDonationRepository';
import type {Donor} from '@app/api/donation/models/Donor';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
@@ -49,6 +50,14 @@ export class StripeDisputeWebhookHandler {
}
const payment = await this.userRepository.getPaymentByPaymentIntent(paymentIntentId);
if (!payment) {
const donor = await this.findDonorForDispute(dispute);
if (donor) {
Logger.info(
{paymentIntentId, disputeId: dispute.id, email: donor.email},
'Chargeback for donation customer - no premium action required',
);
return;
}
Logger.error({paymentIntentId}, 'No payment found for chargeback');
throw new StripeError('No payment found for chargeback');
}
@@ -73,6 +82,14 @@ export class StripeDisputeWebhookHandler {
}
const payment = await this.userRepository.getPaymentByPaymentIntent(paymentIntentId);
if (!payment) {
const donor = await this.findDonorForDispute(dispute);
if (donor) {
Logger.info(
{paymentIntentId, disputeId: dispute.id, email: donor.email},
'Chargeback withdrawal for donation customer - no premium action required',
);
return;
}
throw new StripeError('No payment found for chargeback withdrawal');
}
const user = await this.userRepository.findUnique(payment.userId);
@@ -110,6 +127,30 @@ export class StripeDisputeWebhookHandler {
}
}
private async resolveDisputeCustomerId(dispute: Stripe.Dispute): Promise<string | null> {
const expandedCharge = typeof dispute.charge === 'object' ? dispute.charge : null;
if (expandedCharge) {
const expandedCustomerId = extractId(expandedCharge.customer);
if (expandedCustomerId) {
return expandedCustomerId;
}
}
const chargeId = extractId(dispute.charge);
if (!chargeId) {
return null;
}
const mirroredCharge = await getBillingRepository().charges.findById(chargeId);
return mirroredCharge?.customer_id ?? null;
}
private async findDonorForDispute(dispute: Stripe.Dispute): Promise<Donor | null> {
const customerId = await this.resolveDisputeCustomerId(dispute);
if (!customerId) {
return null;
}
return await this.donationRepository.findDonorByStripeCustomerId(customerId);
}
async handleRefund(charge: Stripe.Charge): Promise<void> {
const paymentIntentId = extractId(charge.payment_intent);
if (!paymentIntentId) {
@@ -2,10 +2,9 @@
import type {UserID} from '@app/api/BrandedTypes';
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
import {nextVersion} from '@app/api/database/CassandraTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IDonationRepository} from '@app/api/donation/IDonationRepository';
import {Donor} from '@app/api/donation/models/Donor';
import type {Donor} from '@app/api/donation/models/Donor';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
import {Logger} from '@app/api/Logger';
@@ -36,6 +35,19 @@ import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import type Stripe from 'stripe';
const TERMINAL_SUBSCRIPTION_STATUSES: ReadonlySet<Stripe.Subscription.Status> = new Set([
'canceled',
'incomplete_expired',
]);
function isDonationSubscription(subscription: Stripe.Subscription): boolean {
return subscription.metadata?.is_donation === 'true';
}
function isDonationInvoice(invoice: Stripe.Invoice): boolean {
return invoice.parent?.subscription_details?.metadata?.is_donation === 'true';
}
export class StripeSubscriptionWebhookHandler {
private readonly paymentRepository = new PaymentRepository();
@@ -73,6 +85,13 @@ export class StripeSubscriptionWebhookHandler {
);
return;
}
if (isDonationInvoice(invoice)) {
Logger.debug(
{invoiceId: invoice.id, eventId, subscriptionId},
'Skipping invoice payment for donation subscription that has no donor mapping',
);
return;
}
if (this.isSubscriptionUpdateInvoice(invoice)) {
Logger.debug(
{
@@ -301,6 +320,17 @@ export class StripeSubscriptionWebhookHandler {
});
}
private async isDonationSubscriptionEvent(subscription: Stripe.Subscription): Promise<boolean> {
if (isDonationSubscription(subscription)) {
return true;
}
const customerId = extractId(subscription.customer);
if (!customerId) {
return false;
}
return (await this.donationRepository.findDonorByStripeCustomerId(customerId)) !== null;
}
private async handleInvoiceCollectionIssue(
invoice: Stripe.Invoice,
context: {
@@ -309,6 +339,17 @@ export class StripeSubscriptionWebhookHandler {
},
): Promise<void> {
const subscriptionId = this.reconciler.getSubscriptionIdFromInvoice(invoice);
if (isDonationInvoice(invoice)) {
Logger.debug({invoiceId: invoice.id, subscriptionId}, 'Skipping collection issue for donation invoice');
return;
}
if (subscriptionId && (await this.donationRepository.findDonorByStripeSubscriptionId(subscriptionId))) {
Logger.debug(
{invoiceId: invoice.id, subscriptionId},
'Skipping collection issue for donation subscription invoice',
);
return;
}
const targetUser = await this.reconciler.resolveUserForInvoiceReconciliation(invoice, {
subscriptionId,
reason: context.reason,
@@ -371,6 +412,13 @@ export class StripeSubscriptionWebhookHandler {
await this.handleDonationSubscriptionUpdated(subscription, donor);
return;
}
if (await this.isDonationSubscriptionEvent(subscription)) {
Logger.debug(
{subscriptionId: subscription.id, status: subscription.status},
'Skipping donation subscription update that has no donor mapping yet',
);
return;
}
let canonicalSubscription = subscription;
const subscriptionInfo = await this.userRepository.getSubscriptionInfo(subscription.id);
Logger.debug(
@@ -548,19 +596,27 @@ export class StripeSubscriptionWebhookHandler {
async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
const donor = await this.donationRepository.findDonorByStripeSubscriptionId(subscription.id);
if (donor) {
const updatedDonor = new Donor({
...donor.toRow(),
stripe_subscription_id: null,
subscription_amount_cents: null,
subscription_currency: null,
subscription_interval: null,
subscription_current_period_end: null,
subscription_cancel_at: null,
updated_at: new Date(),
version: nextVersion(donor.version),
});
await this.donationRepository.upsertDonor(updatedDonor);
Logger.info({email: donor.email, subscriptionId: subscription.id}, 'Donation subscription deleted');
if (donor.stripeSubscriptionId === subscription.id) {
await this.donationRepository.cancelDonorSubscription(donor.email);
Logger.info({email: donor.email, subscriptionId: subscription.id}, 'Donation subscription deleted');
return;
}
await this.donationRepository.deleteDonorSubscriptionMapping(subscription.id, donor.email);
Logger.info(
{
email: donor.email,
subscriptionId: subscription.id,
currentSubscriptionId: donor.stripeSubscriptionId,
},
'Removed stale donation subscription mapping without touching the current donation',
);
return;
}
if (await this.isDonationSubscriptionEvent(subscription)) {
Logger.debug(
{subscriptionId: subscription.id},
'Skipping donation subscription deletion that has no donor mapping',
);
return;
}
const info = await this.userRepository.getSubscriptionInfo(subscription.id);
@@ -621,6 +677,30 @@ export class StripeSubscriptionWebhookHandler {
}
private async handleDonationSubscriptionUpdated(subscription: Stripe.Subscription, donor: Donor): Promise<void> {
const isTerminal = TERMINAL_SUBSCRIPTION_STATUSES.has(subscription.status);
if (donor.stripeSubscriptionId !== subscription.id) {
if (isTerminal) {
await this.donationRepository.deleteDonorSubscriptionMapping(subscription.id, donor.email);
}
Logger.info(
{
email: donor.email,
subscriptionId: subscription.id,
currentSubscriptionId: donor.stripeSubscriptionId,
status: subscription.status,
},
'Ignoring donation subscription update for a subscription the donor no longer tracks',
);
return;
}
if (isTerminal) {
await this.donationRepository.cancelDonorSubscription(donor.email);
Logger.info(
{email: donor.email, subscriptionId: subscription.id, status: subscription.status},
'Donation subscription reached a terminal status',
);
return;
}
const item = getPrimarySubscriptionItem(subscription);
if (!item?.price?.recurring || item.price.unit_amount == null || !item.price.currency) {
Logger.error({subscriptionId: subscription.id}, 'Donation subscription update missing pricing details');
@@ -636,13 +716,14 @@ export class StripeSubscriptionWebhookHandler {
const interval = item.price.recurring.interval;
const cancelAt = subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null;
await this.donationRepository.updateDonorSubscription(donor.email, {
stripeCustomerId: donor.stripeCustomerId,
stripeCustomerId: extractId(subscription.customer) ?? donor.stripeCustomerId,
stripeSubscriptionId: subscription.id,
subscriptionAmountCents: amountCents,
subscriptionCurrency: currency,
subscriptionInterval: interval,
subscriptionCurrentPeriodEnd: currentPeriodEnd,
subscriptionCancelAt: cancelAt,
subscriptionStatus: subscription.status,
});
Logger.debug(
{
@@ -55,7 +55,7 @@ export class StripeWebhookService {
cacheService: ICacheService,
giftService: StripeGiftService,
premiumService: StripePremiumService,
donationRepository: IDonationRepository,
private donationRepository: IDonationRepository,
kvDeletionQueue: KVAccountDeletionQueueService,
premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService,
private ageVerificationService: AgeVerificationService | null,
@@ -281,6 +281,7 @@ export class StripeWebhookService {
const cust = event.data.object as Stripe.Customer | Stripe.DeletedCustomer;
await this.safeMirrorUpsert(event, () => this.billingRepository.customers.upsertFromStripe(cust));
await this.safeMirrorUpsert(event, () => this.billingRepository.customers.markDeleted(cust.id, new Date()));
await this.donationRepository.clearDonorStripeCustomer(cust.id);
break;
}
case 'product.created':