mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(donations): add Nordic currencies, raise amount ceilings (#2775)
This commit is contained in:
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();
|
||||
|
||||
@@ -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'}¤cy=${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',
|
||||
|
||||
Reference in new issue
Block a user