diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts index 9ef1fc788f..e151d2f3e1 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts @@ -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: diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts index 0e5cbc8835..9e453f0cce 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts @@ -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, + @InjectRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: Repository, + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, 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 { + 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 { + 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, diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts index 9fb2c09d22..eaeecaee9a 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-invoice.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-invoice.service.ts new file mode 100644 index 0000000000..15503c0109 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-invoice.service.ts @@ -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 { + const invoices = await this.stripe.invoices.list({ + subscription: stripeSubscriptionId, + status: 'draft', + }); + + return invoices.data; + } + + async finalizeInvoice(invoiceId: string): Promise { + return this.stripe.invoices.finalizeInvoice(invoiceId, { + auto_advance: true, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts index ecbdc0f3b0..098874e640 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts @@ -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 {}