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
@@ -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 {}