refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,104 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {DonationRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/DonationRateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {
DonationCheckoutRequest,
DonationCheckoutResponse,
DonationManageQuery,
DonationRequestLinkRequest,
} from '@fluxer/schema/src/domains/donation/DonationSchemas';
export function DonationController(app: HonoApp) {
app.post(
'/donations/request-link',
RateLimitMiddleware(DonationRateLimitConfigs.DONATION_REQUEST_LINK),
OpenAPI({
operationId: 'request_donation_magic_link',
summary: 'Request donation management link',
description: 'Sends a magic link email to the provided address for managing recurring donations.',
responseSchema: null,
statusCode: 204,
security: [],
tags: 'Donations',
}),
Validator('json', DonationRequestLinkRequest),
async (ctx) => {
const {email} = ctx.req.valid('json');
await ctx.get('donationService').requestMagicLink(email);
return ctx.body(null, 204);
},
);
app.get(
'/donations/manage',
RateLimitMiddleware(DonationRateLimitConfigs.DONATION_MANAGE),
OpenAPI({
operationId: 'manage_donation',
summary: 'Manage donation subscription',
description: 'Validates the magic link token and redirects to Stripe billing portal.',
responseSchema: null,
statusCode: 302,
security: [],
tags: 'Donations',
}),
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`);
}
const portalUrl = await ctx.get('donationService').createDonorPortalSession(stripeCustomerId);
return ctx.redirect(portalUrl);
},
);
app.post(
'/donations/checkout',
RateLimitMiddleware(DonationRateLimitConfigs.DONATION_CHECKOUT),
OpenAPI({
operationId: 'create_donation_checkout',
summary: 'Create donation checkout session',
description: 'Creates a Stripe checkout session for a recurring donation.',
responseSchema: DonationCheckoutResponse,
statusCode: 200,
security: [],
tags: 'Donations',
}),
Validator('json', DonationCheckoutRequest),
async (ctx) => {
const body = ctx.req.valid('json');
const url = await ctx.get('donationService').createDonationCheckout({
email: body.email,
amountCents: body.amount_cents,
currency: body.currency,
interval: body.interval,
});
return ctx.json({url});
},
);
}

View File

@@ -0,0 +1,347 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {PatchObject} from '@fluxer/api/src/database/Cassandra';
import {
BatchBuilder,
Db,
deleteOneOrMany,
executeVersionedUpdate,
fetchMany,
fetchOne,
} from '@fluxer/api/src/database/Cassandra';
import type {
DonorByStripeCustomerIdRow,
DonorByStripeSubscriptionIdRow,
DonorMagicLinkTokenByEmailRow,
DonorMagicLinkTokenRow,
DonorRow,
} from '@fluxer/api/src/database/types/DonationTypes';
import {
DonorMagicLinkTokens,
DonorMagicLinkTokensByEmail,
Donors,
DonorsByStripeCustomerId,
DonorsByStripeSubscriptionId,
} from '@fluxer/api/src/donation/DonationTables';
import {IDonationRepository} from '@fluxer/api/src/donation/IDonationRepository';
import {Donor} from '@fluxer/api/src/donation/models/Donor';
import {DonorMagicLinkToken} from '@fluxer/api/src/donation/models/DonorMagicLinkToken';
const FETCH_DONOR_BY_EMAIL_QUERY = Donors.selectCql({
where: Donors.where.eq('email'),
limit: 1,
});
const FETCH_DONOR_BY_STRIPE_CUSTOMER_ID_QUERY = DonorsByStripeCustomerId.selectCql({
columns: ['email'],
where: DonorsByStripeCustomerId.where.eq('stripe_customer_id'),
limit: 1,
});
const FETCH_DONOR_BY_STRIPE_SUBSCRIPTION_ID_QUERY = DonorsByStripeSubscriptionId.selectCql({
columns: ['email'],
where: DonorsByStripeSubscriptionId.where.eq('stripe_subscription_id'),
limit: 1,
});
const FETCH_MAGIC_LINK_TOKEN_QUERY = DonorMagicLinkTokens.selectCql({
where: DonorMagicLinkTokens.where.eq('token_'),
limit: 1,
});
const FETCH_MAGIC_LINK_TOKENS_BY_EMAIL_QUERY = DonorMagicLinkTokensByEmail.selectCql({
columns: ['token_'],
where: DonorMagicLinkTokensByEmail.where.eq('donor_email'),
});
export class DonationRepository extends IDonationRepository {
async findDonorByEmail(email: string): Promise<Donor | null> {
const row = await fetchOne<DonorRow>(FETCH_DONOR_BY_EMAIL_QUERY, {email});
return row ? new Donor(row) : null;
}
async findDonorByStripeCustomerId(customerId: string): Promise<Donor | null> {
const mapping = await fetchOne<DonorByStripeCustomerIdRow>(FETCH_DONOR_BY_STRIPE_CUSTOMER_ID_QUERY, {
stripe_customer_id: customerId,
});
if (!mapping) return null;
return this.findDonorByEmail(mapping.email);
}
async findDonorByStripeSubscriptionId(subscriptionId: string): Promise<Donor | null> {
const mapping = await fetchOne<DonorByStripeSubscriptionIdRow>(FETCH_DONOR_BY_STRIPE_SUBSCRIPTION_ID_QUERY, {
stripe_subscription_id: subscriptionId,
});
if (!mapping) return null;
return this.findDonorByEmail(mapping.email);
}
async upsertDonor(donor: Donor): Promise<void> {
const row = donor.toRow();
const batch = new BatchBuilder();
batch.addPrepared(Donors.upsertAll(row));
if (row.stripe_customer_id) {
batch.addPrepared(
DonorsByStripeCustomerId.upsertAll({
stripe_customer_id: row.stripe_customer_id,
email: row.email,
}),
);
}
if (row.stripe_subscription_id) {
batch.addPrepared(
DonorsByStripeSubscriptionId.upsertAll({
stripe_subscription_id: row.stripe_subscription_id,
email: row.email,
}),
);
}
await batch.execute();
}
async createDonor(data: {
email: string;
stripeCustomerId: string | null;
businessName?: string | null;
taxId?: string | null;
taxIdType?: string | null;
stripeSubscriptionId: string | null;
subscriptionAmountCents: number | null;
subscriptionCurrency: string | null;
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
}): Promise<Donor> {
const now = new Date();
const donorRow: DonorRow = {
email: data.email,
stripe_customer_id: data.stripeCustomerId,
business_name: data.businessName ?? null,
tax_id: data.taxId ?? null,
tax_id_type: data.taxIdType ?? null,
stripe_subscription_id: data.stripeSubscriptionId,
subscription_amount_cents: data.subscriptionAmountCents,
subscription_currency: data.subscriptionCurrency,
subscription_interval: data.subscriptionInterval,
subscription_current_period_end: data.subscriptionCurrentPeriodEnd,
subscription_cancel_at: data.subscriptionCancelAt ?? null,
created_at: now,
updated_at: now,
version: 1,
};
const batch = new BatchBuilder();
batch.addPrepared(Donors.upsertAll(donorRow));
if (donorRow.stripe_customer_id) {
batch.addPrepared(
DonorsByStripeCustomerId.upsertAll({
stripe_customer_id: donorRow.stripe_customer_id,
email: donorRow.email,
}),
);
}
if (donorRow.stripe_subscription_id) {
batch.addPrepared(
DonorsByStripeSubscriptionId.upsertAll({
stripe_subscription_id: donorRow.stripe_subscription_id,
email: donorRow.email,
}),
);
}
await batch.execute();
return new Donor(donorRow);
}
async updateDonorSubscription(
email: string,
data: {
stripeCustomerId: string | null;
businessName?: string | null;
taxId?: string | null;
taxIdType?: string | null;
stripeSubscriptionId: string | null;
subscriptionAmountCents: number | null;
subscriptionCurrency: string | null;
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
},
): Promise<{applied: boolean; donor: Donor | null}> {
const result = await executeVersionedUpdate(
() => fetchOne<DonorRow>(FETCH_DONOR_BY_EMAIL_QUERY, {email}),
() => {
const patch: PatchObject = {
stripe_customer_id: Db.set(data.stripeCustomerId),
stripe_subscription_id: Db.set(data.stripeSubscriptionId),
subscription_amount_cents: Db.set(data.subscriptionAmountCents),
subscription_currency: Db.set(data.subscriptionCurrency),
subscription_interval: Db.set(data.subscriptionInterval),
subscription_current_period_end: Db.set(data.subscriptionCurrentPeriodEnd),
subscription_cancel_at: Db.set(data.subscriptionCancelAt ?? null),
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 (result.applied) {
const batch = new BatchBuilder();
if (data.stripeCustomerId) {
batch.addPrepared(
DonorsByStripeCustomerId.upsertAll({
stripe_customer_id: data.stripeCustomerId,
email,
}),
);
}
if (data.stripeSubscriptionId) {
batch.addPrepared(
DonorsByStripeSubscriptionId.upsertAll({
stripe_subscription_id: data.stripeSubscriptionId,
email,
}),
);
}
await batch.execute();
const updatedDonor = await this.findDonorByEmail(email);
return {applied: true, donor: updatedDonor};
}
return {applied: false, donor: null};
}
async cancelDonorSubscription(email: string): Promise<{applied: boolean}> {
const current = await this.findDonorByEmail(email);
if (!current) {
return {applied: false};
}
const oldSubscriptionId = current.stripeSubscriptionId;
const result = await executeVersionedUpdate(
() => fetchOne<DonorRow>(FETCH_DONOR_BY_EMAIL_QUERY, {email}),
() => ({
pk: {email},
patch: {
stripe_subscription_id: Db.clear(),
subscription_amount_cents: Db.clear(),
subscription_currency: Db.clear(),
subscription_interval: Db.clear(),
subscription_current_period_end: Db.clear(),
subscription_cancel_at: Db.clear(),
updated_at: Db.set(new Date()),
},
}),
Donors,
);
if (result.applied && oldSubscriptionId) {
await deleteOneOrMany(
DonorsByStripeSubscriptionId.deleteByPk({
stripe_subscription_id: oldSubscriptionId,
email,
}),
);
}
return {applied: result.applied};
}
async createMagicLinkToken(token: DonorMagicLinkToken): Promise<void> {
const row = token.toRow();
const batch = new BatchBuilder();
batch.addPrepared(DonorMagicLinkTokens.upsertAll(row));
batch.addPrepared(
DonorMagicLinkTokensByEmail.upsertAll({
donor_email: row.donor_email,
token_: row.token_,
}),
);
await batch.execute();
}
async findMagicLinkToken(token: string): Promise<DonorMagicLinkToken | null> {
const row = await fetchOne<DonorMagicLinkTokenRow>(FETCH_MAGIC_LINK_TOKEN_QUERY, {token_: token});
return row ? new DonorMagicLinkToken(row) : null;
}
async markMagicLinkTokenUsed(token: string, usedAt: Date): Promise<void> {
await fetchOne(
DonorMagicLinkTokens.patchByPk(
{token_: token},
{
used_at: Db.set(usedAt),
},
),
);
}
async invalidateTokensForEmail(email: string): Promise<void> {
const tokenRefs = await fetchMany<DonorMagicLinkTokenByEmailRow>(FETCH_MAGIC_LINK_TOKENS_BY_EMAIL_QUERY, {
donor_email: email,
});
if (tokenRefs.length === 0) return;
const batch = new BatchBuilder();
for (const ref of tokenRefs) {
batch.addPrepared(DonorMagicLinkTokens.deleteByPk({token_: ref.token_}));
batch.addPrepared(
DonorMagicLinkTokensByEmail.deleteByPk({
donor_email: ref.donor_email,
token_: ref.token_,
}),
);
}
await batch.execute();
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IDonationService} from '@fluxer/api/src/donation/IDonationService';
import type {DonationCheckoutService} from '@fluxer/api/src/donation/services/DonationCheckoutService';
import type {DonationMagicLinkService} from '@fluxer/api/src/donation/services/DonationMagicLinkService';
export class DonationService implements IDonationService {
constructor(
private magicLinkService: DonationMagicLinkService,
private checkoutService: DonationCheckoutService,
) {}
async requestMagicLink(email: string): Promise<void> {
return this.magicLinkService.sendMagicLink(email);
}
async validateMagicLinkToken(token: string): Promise<{email: string; stripeCustomerId: string | null}> {
return this.magicLinkService.validateToken(token);
}
async createDonationCheckout(params: {
email: string;
amountCents: number;
currency: 'usd' | 'eur';
interval: 'month' | 'year' | null;
}): Promise<string> {
return this.checkoutService.createCheckout(params);
}
async createDonorPortalSession(stripeCustomerId: string): Promise<string> {
return this.checkoutService.createPortalSession(stripeCustomerId);
}
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {defineTable} from '@fluxer/api/src/database/Cassandra';
import {
DONOR_BY_STRIPE_CUSTOMER_ID_COLUMNS,
DONOR_BY_STRIPE_SUBSCRIPTION_ID_COLUMNS,
DONOR_COLUMNS,
DONOR_MAGIC_LINK_TOKEN_BY_EMAIL_COLUMNS,
DONOR_MAGIC_LINK_TOKEN_COLUMNS,
type DonorByStripeCustomerIdRow,
type DonorByStripeSubscriptionIdRow,
type DonorMagicLinkTokenByEmailRow,
type DonorMagicLinkTokenRow,
type DonorRow,
} from '@fluxer/api/src/database/types/DonationTypes';
export const Donors = defineTable<DonorRow, 'email'>({
name: 'donors',
columns: DONOR_COLUMNS,
primaryKey: ['email'],
});
export const DonorsByStripeCustomerId = defineTable<
DonorByStripeCustomerIdRow,
'stripe_customer_id' | 'email',
'stripe_customer_id'
>({
name: 'donors_by_stripe_customer_id',
columns: DONOR_BY_STRIPE_CUSTOMER_ID_COLUMNS,
primaryKey: ['stripe_customer_id', 'email'],
partitionKey: ['stripe_customer_id'],
});
export const DonorsByStripeSubscriptionId = defineTable<
DonorByStripeSubscriptionIdRow,
'stripe_subscription_id' | 'email',
'stripe_subscription_id'
>({
name: 'donors_by_stripe_subscription_id',
columns: DONOR_BY_STRIPE_SUBSCRIPTION_ID_COLUMNS,
primaryKey: ['stripe_subscription_id', 'email'],
partitionKey: ['stripe_subscription_id'],
});
export const DonorMagicLinkTokens = defineTable<DonorMagicLinkTokenRow, 'token_'>({
name: 'donor_magic_link_tokens',
columns: DONOR_MAGIC_LINK_TOKEN_COLUMNS,
primaryKey: ['token_'],
});
export const DonorMagicLinkTokensByEmail = defineTable<DonorMagicLinkTokenByEmailRow, 'donor_email' | 'token_'>({
name: 'donor_magic_link_tokens_by_email',
columns: DONOR_MAGIC_LINK_TOKEN_BY_EMAIL_COLUMNS,
primaryKey: ['donor_email', 'token_'],
});

View File

@@ -0,0 +1,71 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Donor} from '@fluxer/api/src/donation/models/Donor';
import type {DonorMagicLinkToken} from '@fluxer/api/src/donation/models/DonorMagicLinkToken';
export abstract class IDonationRepository {
abstract findDonorByEmail(email: string): Promise<Donor | null>;
abstract findDonorByStripeCustomerId(customerId: string): Promise<Donor | null>;
abstract findDonorByStripeSubscriptionId(subscriptionId: string): Promise<Donor | null>;
abstract upsertDonor(donor: Donor): Promise<void>;
abstract createDonor(data: {
email: string;
stripeCustomerId: string | null;
businessName?: string | null;
taxId?: string | null;
taxIdType?: string | null;
stripeSubscriptionId: string | null;
subscriptionAmountCents: number | null;
subscriptionCurrency: string | null;
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
}): Promise<Donor>;
abstract updateDonorSubscription(
email: string,
data: {
stripeCustomerId: string | null;
businessName?: string | null;
taxId?: string | null;
taxIdType?: string | null;
stripeSubscriptionId: string | null;
subscriptionAmountCents: number | null;
subscriptionCurrency: string | null;
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
},
): Promise<{applied: boolean; donor: Donor | null}>;
abstract cancelDonorSubscription(email: string): Promise<{applied: boolean}>;
abstract createMagicLinkToken(token: DonorMagicLinkToken): Promise<void>;
abstract findMagicLinkToken(token: string): Promise<DonorMagicLinkToken | null>;
abstract markMagicLinkTokenUsed(token: string, usedAt: Date): Promise<void>;
abstract invalidateTokensForEmail(email: string): Promise<void>;
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface IDonationService {
requestMagicLink(email: string): Promise<void>;
validateMagicLinkToken(token: string): Promise<{email: string; stripeCustomerId: string | null}>;
createDonationCheckout(params: {
email: string;
amountCents: number;
currency: 'usd' | 'eur';
interval: 'month' | 'year' | null;
}): Promise<string>;
createDonorPortalSession(stripeCustomerId: string): Promise<string>;
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {nextVersion} from '@fluxer/api/src/database/Cassandra';
import type {DonorRow} from '@fluxer/api/src/database/types/DonationTypes';
export class Donor {
readonly email: string;
readonly stripeCustomerId: string | null;
readonly businessName: string | null;
readonly taxId: string | null;
readonly taxIdType: string | null;
readonly stripeSubscriptionId: string | null;
readonly subscriptionAmountCents: number | null;
readonly subscriptionCurrency: string | null;
readonly subscriptionInterval: string | null;
readonly subscriptionCurrentPeriodEnd: Date | null;
readonly subscriptionCancelAt: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly version: number;
constructor(row: DonorRow) {
this.email = row.email;
this.stripeCustomerId = row.stripe_customer_id ?? null;
this.businessName = row.business_name ?? null;
this.taxId = row.tax_id ?? null;
this.taxIdType = row.tax_id_type ?? null;
this.stripeSubscriptionId = row.stripe_subscription_id ?? null;
this.subscriptionAmountCents = row.subscription_amount_cents ?? null;
this.subscriptionCurrency = row.subscription_currency ?? null;
this.subscriptionInterval = row.subscription_interval ?? null;
this.subscriptionCurrentPeriodEnd = row.subscription_current_period_end ?? null;
this.subscriptionCancelAt = row.subscription_cancel_at ?? null;
this.createdAt = row.created_at;
this.updatedAt = row.updated_at;
this.version = row.version;
}
toRow(): DonorRow {
return {
email: this.email,
stripe_customer_id: this.stripeCustomerId,
business_name: this.businessName,
tax_id: this.taxId,
tax_id_type: this.taxIdType,
stripe_subscription_id: this.stripeSubscriptionId,
subscription_amount_cents: this.subscriptionAmountCents,
subscription_currency: this.subscriptionCurrency,
subscription_interval: this.subscriptionInterval,
subscription_current_period_end: this.subscriptionCurrentPeriodEnd,
subscription_cancel_at: this.subscriptionCancelAt,
created_at: this.createdAt,
updated_at: this.updatedAt,
version: this.version,
};
}
hasActiveSubscription(): boolean {
if (!this.stripeSubscriptionId || !this.subscriptionCurrentPeriodEnd) {
return false;
}
// If subscription is marked for cancellation, it's not "active" for our purposes
if (this.subscriptionCancelAt !== null) {
return false;
}
return this.subscriptionCurrentPeriodEnd > new Date();
}
isBusiness(): boolean {
return this.taxId !== null;
}
withUpdatedSubscription(data: {
stripeCustomerId: string | null;
businessName?: string | null;
taxId?: string | null;
taxIdType?: string | null;
stripeSubscriptionId: string | null;
subscriptionAmountCents: number | null;
subscriptionCurrency: string | null;
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
}): DonorRow {
return {
email: this.email,
stripe_customer_id: data.stripeCustomerId,
business_name: data.businessName ?? this.businessName,
tax_id: data.taxId ?? this.taxId,
tax_id_type: data.taxIdType ?? this.taxIdType,
stripe_subscription_id: data.stripeSubscriptionId,
subscription_amount_cents: data.subscriptionAmountCents,
subscription_currency: data.subscriptionCurrency,
subscription_interval: data.subscriptionInterval,
subscription_current_period_end: data.subscriptionCurrentPeriodEnd,
subscription_cancel_at: data.subscriptionCancelAt ?? this.subscriptionCancelAt,
created_at: this.createdAt,
updated_at: new Date(),
version: nextVersion(this.version),
};
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {DonorMagicLinkTokenRow} from '@fluxer/api/src/database/types/DonationTypes';
export class DonorMagicLinkToken {
readonly token: string;
readonly donorEmail: string;
readonly expiresAt: Date;
readonly usedAt: Date | null;
constructor(row: DonorMagicLinkTokenRow) {
this.token = row.token_;
this.donorEmail = row.donor_email;
this.expiresAt = row.expires_at;
this.usedAt = row.used_at ?? null;
}
toRow(): DonorMagicLinkTokenRow {
return {
token_: this.token,
donor_email: this.donorEmail,
expires_at: this.expiresAt,
used_at: this.usedAt,
};
}
isExpired(): boolean {
return new Date() > this.expiresAt;
}
isUsed(): boolean {
return this.usedAt !== null;
}
isValid(): boolean {
return !this.isExpired() && !this.isUsed();
}
}

View File

@@ -0,0 +1,173 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import type {IDonationRepository} from '@fluxer/api/src/donation/IDonationRepository';
import {Logger} from '@fluxer/api/src/Logger';
import {DonationAmountInvalidError} from '@fluxer/errors/src/domains/donation/DonationAmountInvalidError';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment/StripePaymentNotAvailableError';
import type Stripe from 'stripe';
const MIN_DONATION_CENTS = 500;
const MAX_DONATION_CENTS = 100000;
export class DonationCheckoutService {
constructor(
private stripe: Stripe | null,
private donationRepository: IDonationRepository,
) {}
async createCheckout(params: {
email: string;
amountCents: number;
currency: 'usd' | 'eur';
interval: 'month' | 'year' | null;
}): Promise<string> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
if (params.amountCents < MIN_DONATION_CENTS || params.amountCents > MAX_DONATION_CENTS) {
throw new DonationAmountInvalidError();
}
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`;
}
try {
const mode: Stripe.Checkout.SessionCreateParams.Mode = isRecurring ? 'subscription' : 'payment';
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = isRecurring
? {
price_data: {
currency: params.currency,
product_data: {
name: 'Fluxer Recurring Donation',
description: `${params.interval === 'month' ? 'Monthly' : 'Yearly'} donation to support Fluxer`,
},
unit_amount: params.amountCents,
recurring: {
interval: params.interval as 'month' | 'year',
},
},
quantity: 1,
}
: {
price_data: {
currency: params.currency,
product_data: {
name: 'Fluxer Donation',
description: 'One-time donation to support Fluxer',
},
unit_amount: params.amountCents,
},
quantity: 1,
};
const sessionParams: Stripe.Checkout.SessionCreateParams = {
line_items: [lineItem],
mode,
metadata: {
is_donation: 'true',
donation_email: params.email,
donation_type: isRecurring ? 'recurring' : 'one_time',
},
success_url: `${Config.endpoints.marketing}/donate/success`,
cancel_url: `${Config.endpoints.marketing}/donate`,
allow_promotion_codes: true,
automatic_tax: {
enabled: false,
},
tax_id_collection: {
enabled: true,
},
...(mode === 'payment'
? {
invoice_creation: {
enabled: true,
},
}
: {}),
};
if (existingDonor?.stripeCustomerId) {
sessionParams.customer = existingDonor.stripeCustomerId;
sessionParams.customer_update = {
address: 'auto',
name: 'auto',
};
} else {
sessionParams.customer_email = params.email;
}
const session = await this.stripe.checkout.sessions.create(sessionParams);
if (!session.url) {
throw new StripeError('Failed to create checkout session');
}
Logger.debug(
{
email: params.email,
amountCents: params.amountCents,
interval: params.interval,
mode,
sessionId: session.id,
},
'Donation checkout session created',
);
return session.url;
} catch (error: unknown) {
if (error instanceof StripeError || error instanceof DonationAmountInvalidError) {
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);
}
}
async createPortalSession(stripeCustomerId: string): Promise<string> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
try {
const session = await this.stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${Config.endpoints.marketing}/donate`,
});
Logger.debug({stripeCustomerId}, 'Donation portal session created');
return session.url;
} catch (error: unknown) {
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);
}
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomBytes} from 'node:crypto';
import {Config} from '@fluxer/api/src/Config';
import type {IDonationRepository} from '@fluxer/api/src/donation/IDonationRepository';
import {DonorMagicLinkToken} from '@fluxer/api/src/donation/models/DonorMagicLinkToken';
import {Logger} from '@fluxer/api/src/Logger';
import {DONATION_MAGIC_LINK_EXPIRY_MS} from '@fluxer/constants/src/DonationConstants';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
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';
export class DonationMagicLinkService {
constructor(
private donationRepository: IDonationRepository,
private emailService: IEmailService,
) {}
async sendMagicLink(email: string): Promise<void> {
const donor = await this.donationRepository.findDonorByEmail(email);
if (!donor) {
Logger.info({email}, 'Donation magic link requested for unknown donor');
return;
}
await this.donationRepository.invalidateTokensForEmail(email);
const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + DONATION_MAGIC_LINK_EXPIRY_MS);
const tokenModel = new DonorMagicLinkToken({
token_: token,
donor_email: email,
expires_at: expiresAt,
used_at: null,
});
await this.donationRepository.createMagicLinkToken(tokenModel);
const manageUrl = `${Config.endpoints.apiPublic}/donations/manage?token=${token}`;
await this.emailService.sendDonationMagicLink(email, token, manageUrl, expiresAt, null);
Logger.debug({email}, 'Donation magic link sent');
}
async validateToken(token: string): Promise<{email: string; stripeCustomerId: string | null}> {
const tokenModel = await this.donationRepository.findMagicLinkToken(token);
if (!tokenModel) {
throw new DonationMagicLinkInvalidError();
}
if (tokenModel.isExpired()) {
throw new DonationMagicLinkExpiredError();
}
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 {
email: tokenModel.donorEmail,
stripeCustomerId: donor?.stripeCustomerId ?? null,
};
}
}

View File

@@ -0,0 +1,311 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createDonationCheckoutBuilder,
createValidCheckoutBody,
DONATION_AMOUNTS,
DONATION_CURRENCIES,
DONATION_INTERVALS,
TEST_DONOR_EMAIL,
} from '@fluxer/api/src/donation/tests/DonationTestUtils';
import {createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createStripeApiHandlers} from '@fluxer/api/src/test/msw/handlers/StripeApiHandlers';
import {server} from '@fluxer/api/src/test/msw/server';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
describe('POST /donations/checkout', () => {
let harness: Awaited<ReturnType<typeof createApiTestHarness>>;
let stripeHandlers: ReturnType<typeof createStripeApiHandlers>;
beforeAll(async () => {
harness = await createApiTestHarness();
stripeHandlers = createStripeApiHandlers();
});
afterAll(async () => {
await harness.shutdown();
});
beforeEach(async () => {
await harness.resetData();
stripeHandlers.reset();
server.use(...stripeHandlers.handlers);
});
test('creates checkout session with valid params', async () => {
const response = await createDonationCheckoutBuilder(harness).body(createValidCheckoutBody()).expect(200).execute();
expect(response.url).toContain('https://');
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('passes customer email to stripe', async () => {
await createDonationCheckoutBuilder(harness)
.body(createValidCheckoutBody({email: TEST_DONOR_EMAIL}))
.expect(200)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
const session = stripeHandlers.spies.createdCheckoutSessions[0];
expect(session?.customer_email).toBe(TEST_DONOR_EMAIL);
});
test('sets subscription mode', async () => {
await createDonationCheckoutBuilder(harness).body(createValidCheckoutBody()).expect(200).execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
const session = stripeHandlers.spies.createdCheckoutSessions[0];
expect(session?.mode).toBe('subscription');
});
test('rejects amount below minimum', async () => {
await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
amount_cents: DONATION_AMOUNTS.BELOW_MINIMUM,
}),
)
.expect(400, APIErrorCodes.INVALID_FORM_BODY)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects amount above maximum', async () => {
await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
amount_cents: DONATION_AMOUNTS.ABOVE_MAXIMUM,
}),
)
.expect(400, APIErrorCodes.INVALID_FORM_BODY)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('accepts minimum amount', async () => {
const response = await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
amount_cents: DONATION_AMOUNTS.MINIMUM,
}),
)
.expect(200)
.execute();
expect(response.url).toContain('https://');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts maximum amount', async () => {
const response = await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
amount_cents: DONATION_AMOUNTS.MAXIMUM,
}),
)
.expect(200)
.execute();
expect(response.url).toContain('https://');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts EUR currency', async () => {
const response = await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
currency: DONATION_CURRENCIES.EUR,
}),
)
.expect(200)
.execute();
expect(response.url).toBeDefined();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts USD currency', async () => {
const response = await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
currency: DONATION_CURRENCIES.USD,
}),
)
.expect(200)
.execute();
expect(response.url).toBeDefined();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts monthly interval', async () => {
const response = await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
interval: DONATION_INTERVALS.MONTH,
}),
)
.expect(200)
.execute();
expect(response.url).toBeDefined();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts yearly interval', async () => {
const response = await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
interval: DONATION_INTERVALS.YEAR,
}),
)
.expect(200)
.execute();
expect(response.url).toBeDefined();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('rejects invalid email', async () => {
await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
email: 'not-an-email',
}),
)
.expect(400)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects empty email', async () => {
await createDonationCheckoutBuilder(harness)
.body(
createValidCheckoutBody({
email: '',
}),
)
.expect(400)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects missing email field', async () => {
const body = createValidCheckoutBody();
const {email: _, ...bodyWithoutEmail} = body;
await createDonationCheckoutBuilder(harness).body(bodyWithoutEmail).expect(400).execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects missing amount_cents field', async () => {
const body = createValidCheckoutBody();
const {amount_cents: _, ...bodyWithoutAmount} = body;
await createDonationCheckoutBuilder(harness).body(bodyWithoutAmount).expect(400).execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects missing currency field', async () => {
const body = createValidCheckoutBody();
const {currency: _, ...bodyWithoutCurrency} = body;
await createDonationCheckoutBuilder(harness).body(bodyWithoutCurrency).expect(400).execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects missing interval field', async () => {
const body = createValidCheckoutBody();
const {interval: _, ...bodyWithoutInterval} = body;
await createDonationCheckoutBuilder(harness).body(bodyWithoutInterval).expect(400).execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects invalid currency', async () => {
await createDonationCheckoutBuilder(harness)
.body({
...createValidCheckoutBody(),
currency: 'gbp',
})
.expect(400)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects invalid interval', async () => {
await createDonationCheckoutBuilder(harness)
.body({
...createValidCheckoutBody(),
interval: 'week',
})
.expect(400)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects non-integer amount', async () => {
await createDonationCheckoutBuilder(harness)
.body({
...createValidCheckoutBody(),
amount_cents: 25.5,
})
.expect(400)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects negative amount', async () => {
await createDonationCheckoutBuilder(harness)
.body({
...createValidCheckoutBody(),
amount_cents: -500,
})
.expect(400)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects zero amount', async () => {
await createDonationCheckoutBuilder(harness)
.body({
...createValidCheckoutBody(),
amount_cents: 0,
})
.expect(400)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
});

View File

@@ -0,0 +1,457 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {DonationRepository} from '@fluxer/api/src/donation/DonationRepository';
import {DonorMagicLinkToken} from '@fluxer/api/src/donation/models/DonorMagicLinkToken';
import {
createDonationManageBuilder,
TEST_DONOR_EMAIL,
TEST_INVALID_TOKEN,
TEST_MAGIC_LINK_TOKEN,
} from '@fluxer/api/src/donation/tests/DonationTestUtils';
import {createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createPwnedPasswordsRangeHandler} from '@fluxer/api/src/test/msw/handlers/PwnedPasswordsHandlers';
import {createStripeApiHandlers} from '@fluxer/api/src/test/msw/handlers/StripeApiHandlers';
import {server} from '@fluxer/api/src/test/msw/server';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
describe('GET /donations/manage', () => {
let harness: Awaited<ReturnType<typeof createApiTestHarness>>;
let stripeHandlers: ReturnType<typeof createStripeApiHandlers>;
let donationRepository: DonationRepository;
beforeAll(async () => {
harness = await createApiTestHarness();
donationRepository = new DonationRepository();
stripeHandlers = createStripeApiHandlers();
});
afterAll(async () => {
await harness.shutdown();
});
beforeEach(async () => {
await harness.reset();
stripeHandlers.reset();
server.use(...stripeHandlers.handlers);
});
async function createDonorWithCustomerId(email: string, customerId: string): Promise<void> {
await donationRepository.createDonor({
email,
stripeCustomerId: customerId,
businessName: null,
taxId: null,
taxIdType: null,
stripeSubscriptionId: 'sub_test_1',
subscriptionAmountCents: 2500,
subscriptionCurrency: 'usd',
subscriptionInterval: 'month',
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,
});
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);
const tokenModel = new DonorMagicLinkToken({
token_: token,
donor_email: email,
expires_at: expiresAt,
used_at: usedAt,
});
await donationRepository.createMagicLinkToken(tokenModel);
}
describe('valid token with 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(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);
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);
});
test('includes return_url in portal session', async () => {
const customerId = 'cus_test_return_url';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
const portalSession = stripeHandlers.spies.createdPortalSessions[0];
expect(portalSession?.return_url).toBeDefined();
});
});
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,
});
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(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('redirects to donation page 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(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('marks token as used even when redirecting to donate page', 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);
});
});
describe('expired token handling', () => {
test('rejects expired token', async () => {
const customerId = 'cus_test_expired';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
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(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);
});
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();
});
});
describe('used token handling', () => {
test('rejects already-used token', async () => {
const customerId = 'cus_test_used';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
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(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 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(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(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token with invalid format', async () => {
await createDonationManageBuilder(harness, TEST_INVALID_TOKEN).expect(400).execute();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects empty token', async () => {
await createDonationManageBuilder(harness, '').expect(400).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();
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();
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(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(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(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
});
describe('missing token parameter', () => {
test('rejects request without token query parameter', async () => {
const response = await harness.requestJson({
path: '/donations/manage',
method: 'GET',
});
expect(response.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, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
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);
});
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();
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);
});
test('handles token expiring in exactly 0 milliseconds', async () => {
const expiresAt = new Date(Date.now());
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();
});
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();
});
test('handles multiple valid tokens for different donors', async () => {
const token1 = 'a'.repeat(64);
const token2 = 'b'.repeat(64);
const email1 = 'donor1@test.com';
const email2 = 'donor2@test.com';
const customerId1 = 'cus_test_1';
const customerId2 = 'cus_test_2';
await createDonorWithCustomerId(email1, customerId1);
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(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId1);
const {response: response2} = await createDonationManageBuilder(harness, token2).executeRaw();
expect(response2.status).toBe(302);
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();
});
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();
});
});
});

View File

@@ -0,0 +1,205 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {DonationRepository} from '@fluxer/api/src/donation/DonationRepository';
import {
clearDonationTestEmails,
createDonationRequestLinkBuilder,
createUniqueEmail,
listDonationTestEmails,
TEST_DONOR_EMAIL,
} from '@fluxer/api/src/donation/tests/DonationTestUtils';
import {createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
describe('POST /donations/request-link', () => {
let harness: Awaited<ReturnType<typeof createApiTestHarness>>;
let donationRepository: DonationRepository;
beforeAll(async () => {
harness = await createApiTestHarness();
donationRepository = new DonationRepository();
});
afterAll(async () => {
await harness.shutdown();
});
beforeEach(async () => {
await harness.reset();
await clearDonationTestEmails(harness);
});
async function createDonor(email: string): Promise<void> {
await donationRepository.createDonor({
email,
stripeCustomerId: 'cus_test_123',
businessName: null,
taxId: null,
taxIdType: null,
stripeSubscriptionId: 'sub_test_123',
subscriptionAmountCents: 2500,
subscriptionCurrency: 'usd',
subscriptionInterval: 'month',
subscriptionCurrentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
}
describe('email sending behaviour', () => {
test('sends magic link email when donor exists', async () => {
await createDonor(TEST_DONOR_EMAIL);
await createDonationRequestLinkBuilder(harness).body({email: TEST_DONOR_EMAIL}).expect(204).execute();
const emails = await listDonationTestEmails(harness, {recipient: TEST_DONOR_EMAIL});
expect(emails).toHaveLength(1);
expect(emails[0]?.type).toBe('donation_magic_link');
});
test('does not send email when donor does not exist', async () => {
const unknownEmail = createUniqueEmail('unknown');
await createDonationRequestLinkBuilder(harness).body({email: unknownEmail}).expect(204).execute();
const emails = await listDonationTestEmails(harness, {recipient: unknownEmail});
expect(emails).toHaveLength(0);
});
test('returns 204 when donor does not exist', async () => {
await createDonationRequestLinkBuilder(harness)
.body({email: createUniqueEmail('nonexistent')})
.expect(204)
.execute();
});
test('magic link URL points to the API endpoint', async () => {
await createDonor(TEST_DONOR_EMAIL);
await createDonationRequestLinkBuilder(harness).body({email: TEST_DONOR_EMAIL}).expect(204).execute();
const emails = await listDonationTestEmails(harness, {recipient: TEST_DONOR_EMAIL});
expect(emails).toHaveLength(1);
const manageUrl = emails[0]?.metadata.manage_url;
expect(manageUrl).toBeDefined();
expect(manageUrl).toContain(`${Config.endpoints.apiPublic}/donations/manage?token=`);
});
test('magic link token is 64-character hex string', async () => {
await createDonor(TEST_DONOR_EMAIL);
await createDonationRequestLinkBuilder(harness).body({email: TEST_DONOR_EMAIL}).expect(204).execute();
const emails = await listDonationTestEmails(harness, {recipient: TEST_DONOR_EMAIL});
const token = emails[0]?.metadata.token;
expect(token).toBeDefined();
expect(token).toHaveLength(64);
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
});
describe('validation', () => {
test('rejects invalid email format', async () => {
await createDonationRequestLinkBuilder(harness).body({email: 'not-an-email'}).expect(400).execute();
});
test('rejects email without @ symbol', async () => {
await createDonationRequestLinkBuilder(harness).body({email: 'invalidemail.com'}).expect(400).execute();
});
test('rejects email without domain', async () => {
await createDonationRequestLinkBuilder(harness).body({email: 'invalid@'}).expect(400).execute();
});
test('rejects email without local part', async () => {
await createDonationRequestLinkBuilder(harness).body({email: '@example.com'}).expect(400).execute();
});
test('rejects empty email', async () => {
await createDonationRequestLinkBuilder(harness).body({email: ''}).expect(400).execute();
});
test('rejects missing email field', async () => {
await createDonationRequestLinkBuilder(harness).body({}).expect(400).execute();
});
test('rejects null email', async () => {
await createDonationRequestLinkBuilder(harness).body({email: null}).expect(400).execute();
});
test('rejects email with spaces', async () => {
await createDonationRequestLinkBuilder(harness)
.body({email: 'email with spaces@example.com'})
.expect(400)
.execute();
});
test('rejects email exceeding maximum length', async () => {
const longLocalPart = 'a'.repeat(250);
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: ' test@example.com '}).expect(400).execute();
});
});
describe('idempotency', () => {
test('multiple requests for same existing donor succeed', async () => {
await createDonor(TEST_DONOR_EMAIL);
await createDonationRequestLinkBuilder(harness).body({email: TEST_DONOR_EMAIL}).expect(204).execute();
await createDonationRequestLinkBuilder(harness).body({email: TEST_DONOR_EMAIL}).expect(204).execute();
await createDonationRequestLinkBuilder(harness).body({email: TEST_DONOR_EMAIL}).expect(204).execute();
});
test('generates new token on each request, invalidating previous ones', async () => {
await createDonor(TEST_DONOR_EMAIL);
await createDonationRequestLinkBuilder(harness).body({email: TEST_DONOR_EMAIL}).expect(204).execute();
await createDonationRequestLinkBuilder(harness).body({email: TEST_DONOR_EMAIL}).expect(204).execute();
const emails = await listDonationTestEmails(harness, {recipient: TEST_DONOR_EMAIL});
expect(emails).toHaveLength(2);
const token1 = emails[0]?.metadata.token;
const token2 = emails[1]?.metadata.token;
expect(token1).not.toBe(token2);
});
});
describe('email format acceptance', () => {
test('accepts various valid email formats for existing donors', async () => {
const validEmails = [
'simple@example.com',
'very.common@example.com',
'disposable.style.email.with+symbol@example.com',
'other.email-with-hyphen@example.com',
'user.name@example.co.uk',
'x@example.com',
'example-indeed@strange-example.com',
];
for (const email of validEmails) {
await createDonor(email);
await createDonationRequestLinkBuilder(harness).body({email}).expect(204).execute();
}
});
});
});

View File

@@ -0,0 +1,107 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth, type TestRequestBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
export interface DonationTestEmailRecord {
to: string;
type: string;
timestamp: string;
metadata: Record<string, string>;
}
export async function listDonationTestEmails(
harness: ApiTestHarness,
params?: {recipient?: string},
): Promise<Array<DonationTestEmailRecord>> {
const query = params?.recipient ? `?recipient=${encodeURIComponent(params.recipient)}` : '';
const response = await createBuilderWithoutAuth<{emails: Array<DonationTestEmailRecord>}>(harness)
.get(`/test/emails${query}`)
.execute();
return response.emails;
}
export async function clearDonationTestEmails(harness: ApiTestHarness): Promise<void> {
await createBuilderWithoutAuth(harness).delete('/test/emails').expect(204).execute();
}
export const TEST_DONOR_EMAIL = 'donor@test.com';
export const TEST_DONOR_EMAIL_ALT = 'donor-alt@test.com';
export const TEST_MAGIC_LINK_TOKEN = 'a'.repeat(64);
export const TEST_INVALID_TOKEN = 'invalid-token-too-short';
export const DONATION_AMOUNTS = {
MINIMUM: 500,
BELOW_MINIMUM: 100,
STANDARD: 2500,
ABOVE_MAXIMUM: 200000,
MAXIMUM: 100000,
} as const;
export const DONATION_CURRENCIES = {
USD: 'usd',
EUR: 'eur',
} as const;
export const DONATION_INTERVALS = {
MONTH: 'month',
YEAR: 'year',
} as const;
export interface DonationCheckoutRequestBody {
email: string;
amount_cents: number;
currency: 'usd' | 'eur';
interval: 'month' | 'year';
}
export interface DonationCheckoutResponse {
url: string;
}
export interface DonationRequestLinkBody {
email: string;
}
export function createDonationRequestLinkBuilder(harness: ApiTestHarness): TestRequestBuilder<void> {
return createBuilderWithoutAuth<void>(harness).post('/donations/request-link');
}
export function createDonationCheckoutBuilder(harness: ApiTestHarness): TestRequestBuilder<DonationCheckoutResponse> {
return createBuilderWithoutAuth<DonationCheckoutResponse>(harness).post('/donations/checkout');
}
export function createDonationManageBuilder(harness: ApiTestHarness, token: string): TestRequestBuilder<void> {
return createBuilderWithoutAuth<void>(harness).get(`/donations/manage?token=${encodeURIComponent(token)}`);
}
export function createValidCheckoutBody(overrides?: Partial<DonationCheckoutRequestBody>): DonationCheckoutRequestBody {
return {
email: TEST_DONOR_EMAIL,
amount_cents: DONATION_AMOUNTS.STANDARD,
currency: DONATION_CURRENCIES.USD,
interval: DONATION_INTERVALS.MONTH,
...overrides,
};
}
export function createUniqueEmail(prefix = 'donation'): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`;
}