fix - handle invoice.paid webhook to recover unpaid subscriptions (#18770)
Fixes https://github.com/twentyhq/private-issues/issues/432 ## Problem When a user's invoice goes unpaid, Stripe moves their subscription to `unpaid` status, and Twenty suspends the workspace. But if the user pays that invoice while a new billing period has started, Stripe has already generated a new **draft** invoice for that period. Since the draft isn't finalized or paid, Stripe doesn't reactivate the subscription — the workspace stays suspended indefinitely. ## What was missing - No handler for the `invoice.paid` Stripe webhook event - No mechanism to finalize draft invoices that accumulated during the `unpaid` period - No way to reset the workspace deletion countdown (`suspendedAt`) when a user shows payment intent ## What was added ### 1. `StripeInvoiceService` — new Stripe SDK wrapper - `listDraftInvoices(stripeSubscriptionId)` — lists all draft invoices for a subscription - `finalizeInvoice(invoiceId)` — finalizes a draft with `auto_advance: true` so Stripe auto-charges it ### 2. `INVOICE_PAID` enum value Added to `BillingWebhookEvent` so the controller can route it. ### 3. `processInvoicePaid()` in `BillingWebhookInvoiceService` New private handler that: - Fetches all draft invoices for the subscription - Filters to only those whose `period_end` is in the past (already overdue) - Finalizes each one (with error handling per invoice to avoid blocking the webhook) - If the workspace is suspended, resets `suspendedAt` to now (buys time before deletion) ### 4. Controller routing `INVOICE_FINALIZED` and `INVOICE_PAID` are now grouped in the same switch case, both calling `processStripeEvent(data, eventType)`, which forks internally in the service. ## Expected recovery flow User pays overdue invoice → Stripe fires invoice.paid → Handler finalizes past-due draft invoices (auto_advance: true) → Stripe auto-charges them → Subscription becomes active → customer.subscription.updated fires → Existing logic unsuspends workspace → suspendedAt refreshed (resets deletion countdown while payments cascade)
This commit is contained in:
+2
-1
@@ -126,8 +126,9 @@ export class BillingWebhookController {
|
||||
);
|
||||
|
||||
case BillingWebhookEvent.INVOICE_FINALIZED:
|
||||
case BillingWebhookEvent.INVOICE_PAID:
|
||||
return await this.billingWebhookInvoiceService.processStripeEvent(
|
||||
event.data,
|
||||
event,
|
||||
);
|
||||
|
||||
case BillingWebhookEvent.CUSTOMER_CREATED:
|
||||
|
||||
+120
-2
@@ -1,33 +1,67 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { addMonths, addYears } from 'date-fns';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { getSubscriptionIdFromInvoice } from 'src/engine/core-modules/billing-webhook/utils/get-subscription-id-from-invoice.util';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
|
||||
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
|
||||
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
const SUBSCRIPTION_CYCLE_BILLING_REASON = 'subscription_cycle';
|
||||
|
||||
@Injectable()
|
||||
export class BillingWebhookInvoiceService {
|
||||
protected readonly logger = new Logger(BillingWebhookInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly billingCreditRolloverService: BillingCreditRolloverService,
|
||||
private readonly meteredCreditService: MeteredCreditService,
|
||||
private readonly stripeInvoiceService: StripeInvoiceService,
|
||||
) {}
|
||||
|
||||
async processStripeEvent(data: Stripe.InvoiceFinalizedEvent.Data) {
|
||||
async processStripeEvent(
|
||||
event: Stripe.InvoicePaidEvent | Stripe.InvoiceFinalizedEvent,
|
||||
) {
|
||||
if (event.type === BillingWebhookEvent.INVOICE_PAID) {
|
||||
return this.processInvoicePaid(
|
||||
event.data as Stripe.InvoicePaidEvent.Data,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === BillingWebhookEvent.INVOICE_FINALIZED) {
|
||||
return this.processInvoiceFinalized(
|
||||
event.data as Stripe.InvoiceFinalizedEvent.Data,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processInvoiceFinalized(
|
||||
data: Stripe.InvoiceFinalizedEvent.Data,
|
||||
) {
|
||||
const {
|
||||
billing_reason: billingReason,
|
||||
customer,
|
||||
@@ -112,6 +146,90 @@ export class BillingWebhookInvoiceService {
|
||||
});
|
||||
}
|
||||
|
||||
private async processInvoicePaid(data: Stripe.InvoicePaidEvent.Data) {
|
||||
const stripeSubscriptionId = getSubscriptionIdFromInvoice(data.object);
|
||||
const stripeCustomerId = data.object.customer as string | undefined;
|
||||
const paidInvoicePeriodEnd = data.object.period_end;
|
||||
|
||||
if (
|
||||
!isDefined(stripeSubscriptionId) ||
|
||||
!isDefined(stripeCustomerId) ||
|
||||
!isDefined(paidInvoicePeriodEnd)
|
||||
) {
|
||||
throw new BillingException(
|
||||
'Invalid invoice paid event data',
|
||||
BillingExceptionCode.BILLING_STRIPE_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
// Paying a past-due invoice won't reactivate the subscription if Stripe
|
||||
// already generated a draft for the next period. Finalize it so Stripe
|
||||
// can collect payment and resume the subscription.
|
||||
await this.finalizePastDueDraftInvoicesAfterPaidInvoice(
|
||||
stripeSubscriptionId,
|
||||
paidInvoicePeriodEnd,
|
||||
);
|
||||
|
||||
await this.delaySuspendedWorkspaceCleanup(stripeCustomerId);
|
||||
|
||||
return { stripeSubscriptionId };
|
||||
}
|
||||
|
||||
private async finalizePastDueDraftInvoicesAfterPaidInvoice(
|
||||
stripeSubscriptionId: string,
|
||||
paidInvoicePeriodEnd: number,
|
||||
): Promise<void> {
|
||||
const draftInvoices =
|
||||
await this.stripeInvoiceService.listDraftInvoices(stripeSubscriptionId);
|
||||
|
||||
const nowInSeconds = Date.now() / 1000;
|
||||
|
||||
const pastDueDraftInvoices = draftInvoices.filter(
|
||||
(invoice) =>
|
||||
isDefined(invoice.period_end) &&
|
||||
invoice.period_end > paidInvoicePeriodEnd &&
|
||||
invoice.period_end < nowInSeconds,
|
||||
);
|
||||
|
||||
for (const invoice of pastDueDraftInvoices) {
|
||||
try {
|
||||
await this.stripeInvoiceService.finalizeInvoice(invoice.id);
|
||||
} catch (error) {
|
||||
throw new BillingException(
|
||||
`Failed to finalize draft invoice ${invoice.id}: ${error.message}`,
|
||||
BillingExceptionCode.BILLING_STRIPE_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async delaySuspendedWorkspaceCleanup(
|
||||
stripeCustomerId: string,
|
||||
): Promise<void> {
|
||||
const billingCustomer = await this.billingCustomerRepository.findOne({
|
||||
where: { stripeCustomerId },
|
||||
});
|
||||
|
||||
if (!isDefined(billingCustomer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: {
|
||||
id: billingCustomer.workspaceId,
|
||||
activationStatus: WorkspaceActivationStatus.SUSPENDED,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(workspace)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.workspaceRepository.update(workspace.id, {
|
||||
suspendedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private calculateNextPeriodEnd(
|
||||
periodEnd: Date,
|
||||
interval: SubscriptionInterval,
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ export enum BillingWebhookEvent {
|
||||
PRICE_UPDATED = 'price.updated',
|
||||
ALERT_TRIGGERED = 'billing.alert.triggered',
|
||||
INVOICE_FINALIZED = 'invoice.finalized',
|
||||
INVOICE_PAID = 'invoice.paid',
|
||||
SUBSCRIPTION_SCHEDULE_UPDATED = 'subscription_schedule.updated',
|
||||
CREDIT_GRANT_CREATED = 'billing.credit_grant.created',
|
||||
CREDIT_GRANT_UPDATED = 'billing.credit_grant.updated',
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class StripeInvoiceService {
|
||||
protected readonly logger = new Logger(StripeInvoiceService.name);
|
||||
private readonly stripe: Stripe;
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly stripeSDKService: StripeSDKService,
|
||||
) {
|
||||
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
return;
|
||||
}
|
||||
this.stripe = this.stripeSDKService.getStripe(
|
||||
this.twentyConfigService.get('BILLING_STRIPE_API_KEY'),
|
||||
);
|
||||
}
|
||||
|
||||
async listDraftInvoices(
|
||||
stripeSubscriptionId: string,
|
||||
): Promise<Stripe.Invoice[]> {
|
||||
const invoices = await this.stripe.invoices.list({
|
||||
subscription: stripeSubscriptionId,
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
return invoices.data;
|
||||
}
|
||||
|
||||
async finalizeInvoice(invoiceId: string): Promise<Stripe.Invoice> {
|
||||
return this.stripe.invoices.finalizeInvoice(invoiceId, {
|
||||
auto_advance: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billi
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service';
|
||||
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
|
||||
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
|
||||
import { StripeSDKModule } from 'src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
|
||||
@@ -40,6 +41,7 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
|
||||
StripeBillingMeterEventService,
|
||||
StripeBillingAlertService,
|
||||
StripeCreditGrantService,
|
||||
StripeInvoiceService,
|
||||
],
|
||||
exports: [
|
||||
StripeWebhookService,
|
||||
@@ -55,6 +57,7 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
|
||||
StripeSubscriptionScheduleService,
|
||||
StripeBillingAlertService,
|
||||
StripeCreditGrantService,
|
||||
StripeInvoiceService,
|
||||
],
|
||||
})
|
||||
export class StripeModule {}
|
||||
|
||||
Reference in New Issue
Block a user