mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-15 15:08:54 +09:00
448 lines
17 KiB
TypeScript
448 lines
17 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
|
import {StripeWebhookNotAvailableError} from '@fluxer/errors/src/domains/payment/StripeWebhookNotAvailableError';
|
|
import {StripeWebhookSignatureInvalidError} from '@fluxer/errors/src/domains/payment/StripeWebhookSignatureInvalidError';
|
|
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
|
import type {IEmailService} from '@pkgs/email/src/IEmailService';
|
|
import type Stripe from 'stripe';
|
|
import type {AdminRepository} from '../../admin/AdminRepository';
|
|
import {AdminAuditService} from '../../admin/services/AdminAuditService';
|
|
import type {ISessionTerminator} from '../../auth/ISessionTerminator';
|
|
import type {BillingRepository} from '../../billing/repositories/BillingRepository';
|
|
import {Config} from '../../Config';
|
|
import type {IDonationRepository} from '../../donation/IDonationRepository';
|
|
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
|
import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
|
|
import type {KVAccountDeletionQueueService} from '../../infrastructure/KVAccountDeletionQueueService';
|
|
import type {PremiumStateReconciliationQueueService} from '../../infrastructure/PremiumStateReconciliationQueueService';
|
|
import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
|
import {Logger} from '../../Logger';
|
|
import type {IUserRepository} from '../../user/IUserRepository';
|
|
import type {ProductRegistry} from '../ProductRegistry';
|
|
import type {AgeVerificationService} from './AgeVerificationService';
|
|
import type {StripeCheckoutService} from './StripeCheckoutService';
|
|
import {StripeCheckoutWebhookHandler} from './StripeCheckoutWebhookHandler';
|
|
import {StripeDisputeWebhookHandler} from './StripeDisputeWebhookHandler';
|
|
import {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
|
|
import type {StripeGiftService} from './StripeGiftService';
|
|
import {StripePaymentFraudService} from './StripePaymentFraudService';
|
|
import type {StripePremiumService} from './StripePremiumService';
|
|
import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler';
|
|
import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler';
|
|
|
|
interface HandleWebhookParams {
|
|
body: string;
|
|
signature: string;
|
|
}
|
|
|
|
export class StripeWebhookService {
|
|
private checkoutHandler: StripeCheckoutWebhookHandler;
|
|
private subscriptionHandler: StripeSubscriptionWebhookHandler;
|
|
private disputeHandler: StripeDisputeWebhookHandler;
|
|
private paymentFraudService: StripePaymentFraudService;
|
|
|
|
constructor(
|
|
private stripe: Stripe | null,
|
|
private checkoutService: StripeCheckoutService,
|
|
userRepository: IUserRepository,
|
|
userCacheService: UserCacheService,
|
|
sessionTerminator: ISessionTerminator,
|
|
emailService: IEmailService,
|
|
gatewayService: IGatewayService,
|
|
productRegistry: ProductRegistry,
|
|
cacheService: ICacheService,
|
|
giftService: StripeGiftService,
|
|
premiumService: StripePremiumService,
|
|
donationRepository: IDonationRepository,
|
|
kvDeletionQueue: KVAccountDeletionQueueService,
|
|
premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService,
|
|
private ageVerificationService: AgeVerificationService | null,
|
|
adminRepository: AdminRepository,
|
|
snowflakeService: ISnowflakeService,
|
|
private billingRepository: BillingRepository,
|
|
) {
|
|
this.checkoutHandler = new StripeCheckoutWebhookHandler(
|
|
stripe,
|
|
userRepository,
|
|
emailService,
|
|
gatewayService,
|
|
productRegistry,
|
|
cacheService,
|
|
giftService,
|
|
premiumService,
|
|
donationRepository,
|
|
);
|
|
const reconciler = new StripeSubscriptionReconciler(stripe, userRepository, productRegistry);
|
|
const giftReversalHandler = new StripeGiftReversalHandler(
|
|
userRepository,
|
|
gatewayService,
|
|
premiumService,
|
|
premiumStateReconciliationQueueService,
|
|
);
|
|
const auditService = new AdminAuditService(adminRepository, snowflakeService);
|
|
this.paymentFraudService = new StripePaymentFraudService({
|
|
stripe,
|
|
userRepository,
|
|
userCacheService,
|
|
sessionTerminator,
|
|
emailService,
|
|
gatewayService,
|
|
donationRepository,
|
|
cacheService,
|
|
auditService,
|
|
kvDeletionQueue,
|
|
});
|
|
this.subscriptionHandler = new StripeSubscriptionWebhookHandler(
|
|
userRepository,
|
|
gatewayService,
|
|
premiumService,
|
|
donationRepository,
|
|
premiumStateReconciliationQueueService,
|
|
reconciler,
|
|
billingRepository,
|
|
);
|
|
this.disputeHandler = new StripeDisputeWebhookHandler(
|
|
userRepository,
|
|
userCacheService,
|
|
emailService,
|
|
gatewayService,
|
|
donationRepository,
|
|
kvDeletionQueue,
|
|
giftReversalHandler,
|
|
this.paymentFraudService,
|
|
);
|
|
}
|
|
|
|
async handleWebhook({body, signature}: HandleWebhookParams): Promise<void> {
|
|
if (!this.stripe || !Config.stripe.webhookSecret) {
|
|
throw new StripeWebhookNotAvailableError();
|
|
}
|
|
let event: Stripe.Event;
|
|
try {
|
|
const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60;
|
|
event = this.stripe.webhooks.constructEvent(body, signature, Config.stripe.webhookSecret, SEVEN_DAYS_SECONDS);
|
|
} catch (error: unknown) {
|
|
Logger.error({error}, 'Invalid webhook signature');
|
|
throw new StripeWebhookSignatureInvalidError();
|
|
}
|
|
Logger.debug({eventType: event.type, eventId: event.id}, 'Processing Stripe webhook');
|
|
const claim = await this.billingRepository.webhookEvents.tryClaim(event.id);
|
|
if (claim === 'already_processed') {
|
|
Logger.debug({eventId: event.id, eventType: event.type}, 'Stripe webhook already processed (durable claim)');
|
|
return;
|
|
}
|
|
if (claim === 'in_flight') {
|
|
Logger.warn({eventId: event.id, eventType: event.type}, 'Stripe webhook in-flight; will retry via worker queue');
|
|
throw new StripeError('Stripe webhook event in-flight, retry');
|
|
}
|
|
try {
|
|
await this.dispatchWebhookEvent(event);
|
|
await this.billingRepository.webhookEvents.markProcessed(event.id);
|
|
} catch (err) {
|
|
await this.billingRepository.webhookEvents.releaseClaim(event.id);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private async dispatchWebhookEvent(event: Stripe.Event): Promise<void> {
|
|
switch (event.type) {
|
|
case 'checkout.session.completed': {
|
|
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
|
await this.safeMirrorUpsert(event, () =>
|
|
this.billingRepository.checkoutSessions.upsertFromStripe(checkoutSession, {eventCreated: event.created}),
|
|
);
|
|
if (checkoutSession.metadata?.verification_type === 'uk_age_verification' && this.ageVerificationService) {
|
|
await this.ageVerificationService.completeVerification(checkoutSession);
|
|
break;
|
|
}
|
|
if (checkoutSession.metadata?.setup_type === 'localized_card_preapproval') {
|
|
await this.checkoutService.completeLocalizedCardPreapproval(checkoutSession);
|
|
break;
|
|
}
|
|
await this.checkoutHandler.handleCheckoutSessionCompleted(checkoutSession);
|
|
break;
|
|
}
|
|
case 'checkout.session.async_payment_succeeded': {
|
|
const cs = event.data.object as Stripe.Checkout.Session;
|
|
await this.safeMirrorUpsert(event, () =>
|
|
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
|
|
);
|
|
await this.checkoutHandler.handleAsyncPaymentSucceeded(cs);
|
|
break;
|
|
}
|
|
case 'checkout.session.async_payment_failed': {
|
|
const cs = event.data.object as Stripe.Checkout.Session;
|
|
await this.safeMirrorUpsert(event, () =>
|
|
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
|
|
);
|
|
await this.checkoutHandler.handleAsyncPaymentFailed(cs);
|
|
break;
|
|
}
|
|
case 'invoice.paid':
|
|
case 'invoice.payment_succeeded': {
|
|
const inv = event.data.object as Stripe.Invoice;
|
|
await this.mirrorInvoice(event, inv);
|
|
await this.subscriptionHandler.handleInvoicePaymentSucceeded(event.id, inv);
|
|
break;
|
|
}
|
|
case 'invoice.payment_failed': {
|
|
const inv = event.data.object as Stripe.Invoice;
|
|
await this.mirrorInvoice(event, inv);
|
|
await this.subscriptionHandler.handleInvoicePaymentFailed(inv);
|
|
break;
|
|
}
|
|
case 'invoice.payment_action_required': {
|
|
const inv = event.data.object as Stripe.Invoice;
|
|
await this.mirrorInvoice(event, inv);
|
|
await this.subscriptionHandler.handleInvoicePaymentActionRequired(inv);
|
|
break;
|
|
}
|
|
case 'invoice.finalization_failed': {
|
|
const inv = event.data.object as Stripe.Invoice;
|
|
await this.mirrorInvoice(event, inv);
|
|
await this.subscriptionHandler.handleInvoiceFinalizationFailed(inv);
|
|
break;
|
|
}
|
|
case 'invoice.updated': {
|
|
const inv = event.data.object as Stripe.Invoice;
|
|
await this.mirrorInvoice(event, inv);
|
|
await this.subscriptionHandler.handleInvoiceUpdated(inv);
|
|
break;
|
|
}
|
|
case 'customer.subscription.created':
|
|
case 'customer.subscription.updated':
|
|
case 'customer.subscription.pending_update_applied':
|
|
case 'customer.subscription.pending_update_expired': {
|
|
const sub = event.data.object as Stripe.Subscription;
|
|
const subKnownUserId = await this.resolveKnownUserIdForSubscription(sub);
|
|
const snapshotCapturedAt = new Date(event.created * 1000);
|
|
await this.safeMirrorUpsert(event, () =>
|
|
this.billingRepository.subscriptions.upsertFromStripe(sub, {
|
|
knownUserId: subKnownUserId ?? undefined,
|
|
snapshotCapturedAt,
|
|
}),
|
|
);
|
|
await this.subscriptionHandler.handleSubscriptionUpdated(sub, snapshotCapturedAt);
|
|
break;
|
|
}
|
|
case 'customer.subscription.deleted': {
|
|
const sub = event.data.object as Stripe.Subscription;
|
|
const subKnownUserId = await this.resolveKnownUserIdForSubscription(sub);
|
|
await this.safeMirrorUpsert(event, () =>
|
|
this.billingRepository.subscriptions.upsertFromStripe(sub, {
|
|
knownUserId: subKnownUserId ?? undefined,
|
|
snapshotCapturedAt: new Date(event.created * 1000),
|
|
}),
|
|
);
|
|
await this.subscriptionHandler.handleSubscriptionDeleted(sub);
|
|
break;
|
|
}
|
|
case 'charge.dispute.created': {
|
|
const d = event.data.object as Stripe.Dispute;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.disputes.upsertFromStripe(d));
|
|
await this.disputeHandler.handleChargebackCreated(d);
|
|
break;
|
|
}
|
|
case 'charge.dispute.closed': {
|
|
const d = event.data.object as Stripe.Dispute;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.disputes.upsertFromStripe(d));
|
|
await this.disputeHandler.handleChargebackClosed(d);
|
|
break;
|
|
}
|
|
case 'radar.early_fraud_warning.created':
|
|
case 'radar.early_fraud_warning.updated':
|
|
await this.paymentFraudService.handleEarlyFraudWarning(event.data.object as Stripe.Radar.EarlyFraudWarning);
|
|
break;
|
|
case 'charge.refunded': {
|
|
const c = event.data.object as Stripe.Charge;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.charges.upsertFromStripe(c));
|
|
const refunds = await this.listChargeRefunds(c);
|
|
for (const r of refunds) {
|
|
await this.safeMirrorUpsert(event, () =>
|
|
this.billingRepository.refunds.upsertFromStripe(r, {
|
|
customerId: typeof c.customer === 'string' ? c.customer : (c.customer?.id ?? undefined),
|
|
livemode: event.livemode,
|
|
}),
|
|
);
|
|
}
|
|
await this.disputeHandler.handleRefund(c);
|
|
break;
|
|
}
|
|
case 'customer.created':
|
|
case 'customer.updated': {
|
|
const cust = event.data.object as Stripe.Customer;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.customers.upsertFromStripe(cust));
|
|
break;
|
|
}
|
|
case 'customer.deleted': {
|
|
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()));
|
|
break;
|
|
}
|
|
case 'product.created':
|
|
case 'product.updated':
|
|
case 'product.deleted': {
|
|
const p = event.data.object as Stripe.Product;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.products.upsertFromStripe(p));
|
|
break;
|
|
}
|
|
case 'price.created':
|
|
case 'price.updated':
|
|
case 'price.deleted': {
|
|
const p = event.data.object as Stripe.Price;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.prices.upsertFromStripe(p));
|
|
break;
|
|
}
|
|
case 'payment_method.attached':
|
|
case 'payment_method.updated':
|
|
case 'payment_method.automatically_updated': {
|
|
const pm = event.data.object as Stripe.PaymentMethod;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.paymentMethods.upsertFromStripe(pm));
|
|
break;
|
|
}
|
|
case 'payment_method.detached': {
|
|
const pm = event.data.object as Stripe.PaymentMethod;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.paymentMethods.markDetached(pm.id, new Date()));
|
|
break;
|
|
}
|
|
case 'payment_intent.created':
|
|
case 'payment_intent.processing':
|
|
case 'payment_intent.succeeded':
|
|
case 'payment_intent.payment_failed':
|
|
case 'payment_intent.canceled':
|
|
case 'payment_intent.requires_action': {
|
|
const pi = event.data.object as Stripe.PaymentIntent;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.paymentIntents.upsertFromStripe(pi));
|
|
break;
|
|
}
|
|
case 'charge.succeeded':
|
|
case 'charge.updated':
|
|
case 'charge.failed':
|
|
case 'charge.captured': {
|
|
const c = event.data.object as Stripe.Charge;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.charges.upsertFromStripe(c));
|
|
break;
|
|
}
|
|
case 'refund.created':
|
|
case 'refund.updated':
|
|
case 'refund.failed': {
|
|
const r = event.data.object as Stripe.Refund;
|
|
const customerId = await this.resolveRefundCustomerId(r);
|
|
await this.safeMirrorUpsert(event, () =>
|
|
this.billingRepository.refunds.upsertFromStripe(r, {
|
|
customerId: customerId ?? undefined,
|
|
livemode: event.livemode,
|
|
}),
|
|
);
|
|
break;
|
|
}
|
|
case 'invoice.created':
|
|
case 'invoice.finalized':
|
|
case 'invoice.voided':
|
|
case 'invoice.marked_uncollectible': {
|
|
const inv = event.data.object as Stripe.Invoice;
|
|
await this.mirrorInvoice(event, inv);
|
|
break;
|
|
}
|
|
case 'checkout.session.expired': {
|
|
const cs = event.data.object as Stripe.Checkout.Session;
|
|
await this.safeMirrorUpsert(event, () =>
|
|
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
|
|
);
|
|
break;
|
|
}
|
|
case 'charge.dispute.updated':
|
|
case 'charge.dispute.funds_reinstated':
|
|
case 'charge.dispute.funds_withdrawn': {
|
|
const d = event.data.object as Stripe.Dispute;
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.disputes.upsertFromStripe(d));
|
|
break;
|
|
}
|
|
default: {
|
|
Logger.debug({eventType: event.type, eventId: event.id}, 'Stripe webhook event type not handled');
|
|
}
|
|
}
|
|
}
|
|
|
|
private async resolveRefundCustomerId(refund: Stripe.Refund): Promise<string | null> {
|
|
const chargeId = typeof refund.charge === 'string' ? refund.charge : (refund.charge?.id ?? null);
|
|
if (chargeId !== null) {
|
|
const chargeRow = await this.billingRepository.charges.findById(chargeId);
|
|
if (chargeRow?.customer_id != null) {
|
|
return chargeRow.customer_id;
|
|
}
|
|
}
|
|
const paymentIntentId =
|
|
typeof refund.payment_intent === 'string' ? refund.payment_intent : (refund.payment_intent?.id ?? null);
|
|
if (paymentIntentId !== null) {
|
|
const paymentIntentRow = await this.billingRepository.paymentIntents.findById(paymentIntentId);
|
|
if (paymentIntentRow?.customer_id != null) {
|
|
return paymentIntentRow.customer_id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async listChargeRefunds(charge: Stripe.Charge): Promise<Array<Stripe.Refund>> {
|
|
const inlined = charge.refunds?.data ?? [];
|
|
if (inlined.length > 0 || charge.id == null || this.stripe == null) {
|
|
return inlined;
|
|
}
|
|
try {
|
|
const listed = await this.stripe.refunds.list({charge: charge.id, limit: 100});
|
|
return listed.data;
|
|
} catch (listErr) {
|
|
Logger.warn({listErr, chargeId: charge.id}, 'Failed to list refunds for charge; skipping refund mirror');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private async mirrorInvoice(event: Stripe.Event, inv: Stripe.Invoice): Promise<void> {
|
|
let hydrated = inv;
|
|
if (inv.payments === undefined && inv.id != null && this.stripe != null) {
|
|
try {
|
|
hydrated = await this.stripe.invoices.retrieve(inv.id, {expand: ['payments.data.payment']});
|
|
} catch (hydrateErr) {
|
|
Logger.warn(
|
|
{hydrateErr, eventId: event.id, invoiceId: inv.id},
|
|
'Failed to hydrate invoice payments; mirroring invoice without payment rows',
|
|
);
|
|
}
|
|
}
|
|
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(hydrated));
|
|
}
|
|
|
|
private async safeMirrorUpsert(event: Stripe.Event, fn: () => Promise<unknown>): Promise<void> {
|
|
try {
|
|
await fn();
|
|
} catch (mirrorErr) {
|
|
Logger.error(
|
|
{mirrorErr, eventId: event.id, eventType: event.type},
|
|
'Mirror upsert failed; continuing with downstream handler',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async resolveKnownUserIdForSubscription(sub: Stripe.Subscription): Promise<bigint | null> {
|
|
const metadataUserId = sub.metadata?.user_id ?? sub.metadata?.userId;
|
|
if (typeof metadataUserId === 'string' && metadataUserId.length > 0) {
|
|
try {
|
|
return BigInt(metadataUserId);
|
|
} catch {}
|
|
}
|
|
const customerId = typeof sub.customer === 'string' ? sub.customer : (sub.customer?.id ?? null);
|
|
if (!customerId) {
|
|
return null;
|
|
}
|
|
try {
|
|
const customerRow = await this.billingRepository.customers.findById(customerId);
|
|
return customerRow?.user_id ?? null;
|
|
} catch (err) {
|
|
Logger.warn({err, subscriptionId: sub.id, customerId}, 'Customer reverse-lookup failed for subscription mirror');
|
|
return null;
|
|
}
|
|
}
|
|
}
|