Files
twenty/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-subscription-created-data.util.ts
T
Félix MalfaitandGitHub 7021a51747 feat(billing): implement credit rollover from one billing period to another (#16802)
## Summary
This PR implements credit rollover functionality for billing, allowing
unused credits from one billing period to carry over to the next (capped
at the current period's subscription tier cap).

## Changes

### New Services
- **StripeCreditGrantService**: Interacts with Stripe's Billing Credits
API to create credit grants, retrieve customer credit balances, and void
grants
- **BillingCreditRolloverService**: Contains the rollover logic -
calculates unused credits and creates new grants for the next period
- **BillingWebhookCreditGrantService**: Handles
`billing.credit_grant.created` and `billing.credit_grant.updated`
webhooks to update billing alerts

### Modified Services
- **StripeBillingAlertService**: Updated to include credit balance when
calculating usage threshold alerts
- **BillingUsageService**: Returns rollover credits to the frontend for
display
- **BillingSubscriptionService**: Queries credit balance when creating
billing alerts
- **BillingWebhookInvoiceService**: Triggers rollover processing on
`invoice.finalized` webhook

### Frontend
- Updated `SettingsBillingCreditsSection` to display base credits,
rollover credits, and total available
- Updated GraphQL query to fetch new `rolloverCredits` and
`totalGrantedCredits` fields

### Stripe SDK Upgrade
- Upgraded from v17.3.1 to v19.3.1 to get proper types for
billing.credit_grant events
- Fixed breaking changes: invoice.subscription path, subscription period
fields location, removed properties

## How it works

1. When `invoice.finalized` webhook is received for
`subscription_cycle`, the system:
   - Calculates usage from the previous period
   - Determines unused credits (tier cap - usage)
   - Caps rollover at current tier cap
   - Creates a Stripe credit grant with expiration at end of new period

2. When credit grants are created/updated/voided:
   - Billing alerts are recreated with the updated credit balance

3. The UI displays:
   - Base credits (from subscription tier)
   - Rollover credits (from previous periods)
   - Total available credits

## Edge Cases Handled
- Credit grant voided: `billing.credit_grant.updated` webhook triggers
alert update
- Credit grant expired: Stripe's `creditBalanceSummary` API excludes
expired grants
- No unused credits: Rollover service skips grant creation
- Customer ID as object: Controller extracts `.id` from expanded
customer
2025-12-28 15:42:18 +01:00

133 lines
3.4 KiB
TypeScript

import type Stripe from 'stripe';
export const createMockStripeSubscriptionCreatedData = (
overrides = {},
): Stripe.CustomerSubscriptionCreatedEvent.Data => ({
object: {
object: 'subscription',
id: 'sub_default',
customer: 'cus_default1',
status: 'active',
items: {
data: [
{
plan: {
id: 'plan_default',
object: 'plan',
active: true,
amount_decimal: '0',
billing_scheme: 'per_unit',
interval_count: 1,
livemode: false,
nickname: null,
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
interval: 'month',
currency: 'usd',
amount: 0,
created: 1672531200,
product: 'prod_default',
usage_type: 'licensed',
metadata: {},
meter: null,
},
id: '',
object: 'subscription_item',
billing_thresholds: null,
created: 0,
current_period_end: 1672531200,
current_period_start: 1672531200,
discounts: [],
metadata: {},
price: {
id: 'price_default',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1672531200,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_default',
recurring: {
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: null,
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1000,
unit_amount_decimal: '1000',
},
subscription: '',
tax_rates: null,
},
],
object: 'list',
has_more: false,
url: '',
},
cancel_at_period_end: false,
currency: 'usd',
metadata: { workspaceId: '3b8e6458-5fc1-4e63-8563-008ccddaa6db' },
trial_end: null,
trial_start: null,
canceled_at: null,
...overrides,
application: null,
application_fee_percent: null,
automatic_tax: {
disabled_reason: null,
enabled: true,
liability: {
type: 'self',
},
},
billing_cycle_anchor: 0,
billing_cycle_anchor_config: null,
billing_mode: {
type: 'flexible',
flexible: {},
},
billing_thresholds: null,
cancel_at: null,
cancellation_details: null,
collection_method: 'charge_automatically',
created: 0,
days_until_due: null,
default_payment_method: null,
default_source: null,
description: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: 'self',
},
},
latest_invoice: null,
livemode: false,
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: null,
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
schedule: null,
start_date: 0,
test_clock: null,
transfer_data: null,
trial_settings: null,
},
});