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