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

@@ -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',