refactor progress
This commit is contained in:
104
packages/api/src/donation/DonationController.tsx
Normal file
104
packages/api/src/donation/DonationController.tsx
Normal 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});
|
||||
},
|
||||
);
|
||||
}
|
||||
347
packages/api/src/donation/DonationRepository.tsx
Normal file
347
packages/api/src/donation/DonationRepository.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
50
packages/api/src/donation/DonationService.tsx
Normal file
50
packages/api/src/donation/DonationService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
72
packages/api/src/donation/DonationTables.tsx
Normal file
72
packages/api/src/donation/DonationTables.tsx
Normal 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_'],
|
||||
});
|
||||
71
packages/api/src/donation/IDonationRepository.tsx
Normal file
71
packages/api/src/donation/IDonationRepository.tsx
Normal 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>;
|
||||
}
|
||||
33
packages/api/src/donation/IDonationService.tsx
Normal file
33
packages/api/src/donation/IDonationService.tsx
Normal 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>;
|
||||
}
|
||||
119
packages/api/src/donation/models/Donor.tsx
Normal file
119
packages/api/src/donation/models/Donor.tsx
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
55
packages/api/src/donation/models/DonorMagicLinkToken.tsx
Normal file
55
packages/api/src/donation/models/DonorMagicLinkToken.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
173
packages/api/src/donation/services/DonationCheckoutService.tsx
Normal file
173
packages/api/src/donation/services/DonationCheckoutService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
311
packages/api/src/donation/tests/DonationCheckout.test.tsx
Normal file
311
packages/api/src/donation/tests/DonationCheckout.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
457
packages/api/src/donation/tests/DonationManage.test.tsx
Normal file
457
packages/api/src/donation/tests/DonationManage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
205
packages/api/src/donation/tests/DonationRequestLink.test.tsx
Normal file
205
packages/api/src/donation/tests/DonationRequestLink.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
107
packages/api/src/donation/tests/DonationTestUtils.tsx
Normal file
107
packages/api/src/donation/tests/DonationTestUtils.tsx
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user