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:
Etienne
2026-03-20 15:19:53 +01:00
committed by GitHub
parent 91793ef930
commit 9a850e2241
5 changed files with 169 additions and 3 deletions
@@ -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:
@@ -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,
@@ -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',
@@ -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 {}