Etienne
2025-04-22 14:01:36 +02:00
committed by GitHub
parent 87083cb414
commit 5250d5c8d6
10 changed files with 768 additions and 12 deletions

View File

@ -19,7 +19,7 @@ export const useGetWorkflowNodeExecutionUsage = () => {
return {
usageQuantity: 0,
freeUsageQuantity: 0,
includedFreeQuantity: 0,
includedFreeQuantity: 10000,
paidUsageQuantity: 0,
unitPriceCents: 0,
totalCostCents: 0,

View File

@ -106,7 +106,9 @@ export const SettingsBilling = () => {
]}
>
<SettingsPageContainer>
<SettingsBillingMonthlyCreditsSection />
{hasNotCanceledCurrentSubscription && (
<SettingsBillingMonthlyCreditsSection />
)}
<Section>
<H2Title
title={t`Manage your subscription`}

View File

@ -14,6 +14,7 @@ export enum BillingExceptionCode {
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND',
BILLING_SUBSCRIPTION_NOT_FOUND = 'BILLING_SUBSCRIPTION_NOT_FOUND',
BILLING_SUBSCRIPTION_ITEM_NOT_FOUND = 'BILLING_SUBSCRIPTION_ITEM_NOT_FOUND',
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND',

View File

@ -45,6 +45,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception,
response,

View File

@ -94,6 +94,13 @@ export class BillingUsageService {
{ workspaceId: workspace.id },
);
if (!isDefined(subscription)) {
throw new BillingException(
'Not-canceled subscription not found',
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
);
}
const meteredSubscriptionItemDetails =
await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
subscription.id,

View File

@ -77,6 +77,7 @@ export class BillingService {
);
if (
!isDefined(subscription) ||
![SubscriptionStatus.Active, SubscriptionStatus.Trialing].includes(
subscription.status,
)

View File

@ -0,0 +1,658 @@
import Stripe from 'stripe';
export const mockStripeSubscriptionUpdatedEventWithoutUpdatedItem: Stripe.CustomerSubscriptionUpdatedEvent =
{
type: 'customer.subscription.updated',
data: {
object: {
id: 'sub_1RFLOkQjnlxerLN36QL6lXER',
object: 'subscription',
application: null,
application_fee_percent: null,
billing_cycle_anchor: 1745007630,
billing_cycle_anchor_config: null,
automatic_tax: {
enabled: false,
liability: null,
},
billing_thresholds: null,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
cancellation_details: {
comment: null,
feedback: null,
reason: null,
},
collection_method: 'charge_automatically',
created: 1745007630,
currency: 'usd',
current_period_end: 1747599630,
current_period_start: 1745007630,
customer: 'cus_S9eiHiQ8lNbIkL',
days_until_due: null,
default_payment_method: null,
default_source: null,
default_tax_rates: [],
description: null,
discount: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: 'self',
},
},
items: {
object: 'list',
data: [
{
id: 'si_S9einNoUMK9WAL',
object: 'subscription_item',
billing_thresholds: null,
created: 1745007631,
discounts: [],
metadata: {},
plan: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'plan',
active: true,
aggregate_usage: null,
amount: 1500,
amount_decimal: '1500',
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
interval: 'month',
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: 'licensed',
},
price: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'unspecified',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1500,
unit_amount_decimal: '1500',
},
quantity: 1,
subscription: 'sub_1RFLOkQjnlxerLN36QL6lXER',
tax_rates: [],
},
],
has_more: false,
url: '/v1/subscription_items?subscription=sub_1RFLOkQjnlxerLN36QL6lXER',
},
latest_invoice: 'in_1RFLOkQjnlxerLN3aexylENH',
livemode: false,
metadata: {
foo: 'bar',
},
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: {
payment_method_options: null,
payment_method_types: null,
save_default_payment_method: 'off',
},
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
schedule: null,
start_date: 1745007630,
status: 'active',
test_clock: null,
transfer_data: null,
trial_end: null,
trial_settings: {
end_behavior: {
missing_payment_method: 'create_invoice',
},
},
trial_start: null,
},
previous_attributes: {
metadata: {},
},
},
id: 'evt_1RFLOkQjnlxerLN36QL6lXER',
object: 'event',
api_version: '2024-04-10',
created: 1745007630,
livemode: false,
pending_webhooks: 0,
request: null,
};
export const mockStripeSubscriptionUpdatedEventWithUpdatedItemOnly: Stripe.CustomerSubscriptionUpdatedEvent =
{
type: 'customer.subscription.updated',
data: {
object: {
id: 'sub_1RFLOkQjnlxerLN36QL6lXER',
object: 'subscription',
application: null,
application_fee_percent: null,
billing_cycle_anchor: 1745007630,
billing_cycle_anchor_config: null,
automatic_tax: {
enabled: false,
liability: null,
},
billing_thresholds: null,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
cancellation_details: {
comment: null,
feedback: null,
reason: null,
},
collection_method: 'charge_automatically',
created: 1745007630,
currency: 'usd',
current_period_end: 1747599630,
current_period_start: 1745007630,
customer: 'cus_S9eiHiQ8lNbIkL',
days_until_due: null,
default_payment_method: null,
default_source: null,
default_tax_rates: [],
description: null,
discount: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: 'self',
},
},
items: {
object: 'list',
data: [
{
id: 'updated_item_id',
object: 'subscription_item',
billing_thresholds: null,
created: 1745007631,
discounts: [],
metadata: {},
plan: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'plan',
active: true,
aggregate_usage: null,
amount: 1500,
amount_decimal: '1500',
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
interval: 'month',
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: 'licensed',
},
price: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'unspecified',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1500,
unit_amount_decimal: '1500',
},
quantity: 1,
subscription: 'sub_1RFLOkQjnlxerLN36QL6lXER',
tax_rates: [],
},
],
has_more: false,
url: '/v1/subscription_items?subscription=sub_1RFLOkQjnlxerLN36QL6lXER',
},
latest_invoice: 'in_1RFLOkQjnlxerLN3aexylENH',
livemode: false,
metadata: {
foo: 'bar',
},
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: {
payment_method_options: null,
payment_method_types: null,
save_default_payment_method: 'off',
},
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
schedule: null,
start_date: 1745007630,
status: 'active',
test_clock: null,
transfer_data: null,
trial_end: null,
trial_settings: {
end_behavior: {
missing_payment_method: 'create_invoice',
},
},
trial_start: null,
},
previous_attributes: {
items: {
data: [
{
id: 'updated_item_id',
object: 'subscription_item',
billing_thresholds: null,
created: 1745007631,
discounts: [],
metadata: {},
plan: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'plan',
active: true,
aggregate_usage: null,
amount: 1500,
amount_decimal: '1500',
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
interval: 'month',
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: 'licensed',
},
price: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'unspecified',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1500,
unit_amount_decimal: '1500',
},
quantity: 1,
subscription: 'sub_1RFLOkQjnlxerLN36QL6lXER',
tax_rates: [],
},
],
object: 'list',
has_more: false,
url: '/v1/subscription_items?subscription=sub_1RFLOkQjnlxerLN36QL6lXER',
},
},
},
id: 'evt_1RFLOkQjnlxerLN36QL6lXER',
object: 'event',
api_version: '2024-04-10',
created: 1745007630,
livemode: false,
pending_webhooks: 0,
request: null,
};
export const mockStripeSubscriptionUpdatedEventWithDeletedItem: Stripe.CustomerSubscriptionUpdatedEvent =
{
type: 'customer.subscription.updated',
data: {
object: {
id: 'sub_1RFLOkQjnlxerLN36QL6lXER',
object: 'subscription',
application: null,
application_fee_percent: null,
billing_cycle_anchor: 1745007630,
billing_cycle_anchor_config: null,
automatic_tax: {
enabled: false,
liability: null,
},
billing_thresholds: null,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
cancellation_details: {
comment: null,
feedback: null,
reason: null,
},
collection_method: 'charge_automatically',
created: 1745007630,
currency: 'usd',
current_period_end: 1747599630,
current_period_start: 1745007630,
customer: 'cus_S9eiHiQ8lNbIkL',
days_until_due: null,
default_payment_method: null,
default_source: null,
default_tax_rates: [],
description: null,
discount: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: 'self',
},
},
items: {
object: 'list',
data: [
{
id: 'updated_item_id',
object: 'subscription_item',
billing_thresholds: null,
created: 1745007631,
discounts: [],
metadata: {},
plan: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'plan',
active: true,
aggregate_usage: null,
amount: 1500,
amount_decimal: '1500',
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
interval: 'month',
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: 'licensed',
},
price: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'unspecified',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1500,
unit_amount_decimal: '1500',
},
quantity: 1,
subscription: 'sub_1RFLOkQjnlxerLN36QL6lXER',
tax_rates: [],
},
],
has_more: false,
url: '/v1/subscription_items?subscription=sub_1RFLOkQjnlxerLN36QL6lXER',
},
latest_invoice: 'in_1RFLOkQjnlxerLN3aexylENH',
livemode: false,
metadata: {
foo: 'bar',
},
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: {
payment_method_options: null,
payment_method_types: null,
save_default_payment_method: 'off',
},
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
schedule: null,
start_date: 1745007630,
status: 'active',
test_clock: null,
transfer_data: null,
trial_end: null,
trial_settings: {
end_behavior: {
missing_payment_method: 'create_invoice',
},
},
trial_start: null,
},
previous_attributes: {
items: {
data: [
{
id: 'deleted_item_id',
object: 'subscription_item',
billing_thresholds: null,
created: 1745007631,
discounts: [],
metadata: {},
plan: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'plan',
active: true,
aggregate_usage: null,
amount: 1500,
amount_decimal: '1500',
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
interval: 'month',
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: 'licensed',
},
price: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'unspecified',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1500,
unit_amount_decimal: '1500',
},
quantity: 1,
subscription: 'sub_1RFLOkQjnlxerLN36QL6lXER',
tax_rates: [],
},
{
id: 'updated_item_id',
object: 'subscription_item',
billing_thresholds: null,
created: 1745007631,
discounts: [],
metadata: {},
plan: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'plan',
active: true,
aggregate_usage: null,
amount: 1500,
amount_decimal: '1500',
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
interval: 'month',
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: 'licensed',
},
price: {
id: 'price_1RFLOkQjnlxerLN3WzE0SNgt',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1745007630,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_S9eiEm1mzxUNjJ',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'unspecified',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1500,
unit_amount_decimal: '1500',
},
quantity: 1,
subscription: 'sub_1RFLOkQjnlxerLN36QL6lXER',
tax_rates: [],
},
],
object: 'list',
has_more: false,
url: '/v1/subscription_items?subscription=sub_1RFLOkQjnlxerLN36QL6lXER',
},
},
},
id: 'evt_1RFLOkQjnlxerLN36QL6lXER',
object: 'event',
api_version: '2024-04-10',
created: 1745007630,
livemode: false,
pending_webhooks: 0,
request: null,
};

View File

@ -6,7 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
@ -15,6 +15,7 @@ import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billin
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent } from 'src/engine/core-modules/billing/webhooks/utils/get-deleted-stripe-subscription-item-ids-from-stripe-subscription-event.util';
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util';
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
@ -100,15 +101,9 @@ export class BillingWebhookSubscriptionService {
throw new Error('Billing subscription not found');
}
await this.billingSubscriptionItemRepository.upsert(
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
updatedBillingSubscription.id,
data,
),
{
conflictPaths: ['billingSubscriptionId', 'stripeProductId'],
skipUpdateIfNoValuesChanged: true,
},
await this.updateBillingSubscriptionItems(
updatedBillingSubscription.id,
event,
);
if (
@ -174,4 +169,33 @@ export class BillingWebhookSubscriptionService {
return false;
}
async updateBillingSubscriptionItems(
subscriptionId: string,
event:
| Stripe.CustomerSubscriptionUpdatedEvent
| Stripe.CustomerSubscriptionCreatedEvent
| Stripe.CustomerSubscriptionDeletedEvent,
) {
const deletedSubscriptionItemIds =
getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent(event);
if (deletedSubscriptionItemIds.length > 0) {
await this.billingSubscriptionItemRepository.delete({
billingSubscriptionId: subscriptionId,
stripeSubscriptionItemId: In(deletedSubscriptionItemIds),
});
}
await this.billingSubscriptionItemRepository.upsert(
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
subscriptionId,
event.data,
),
{
conflictPaths: ['stripeSubscriptionItemId'],
skipUpdateIfNoValuesChanged: true,
},
);
}
}

View File

@ -0,0 +1,33 @@
import {
mockStripeSubscriptionUpdatedEventWithDeletedItem,
mockStripeSubscriptionUpdatedEventWithoutUpdatedItem,
mockStripeSubscriptionUpdatedEventWithUpdatedItemOnly,
} from 'src/engine/core-modules/billing/webhooks/__mocks__/stripe-subscription-updated-events';
import { getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent } from 'src/engine/core-modules/billing/webhooks/utils/get-deleted-stripe-subscription-item-ids-from-stripe-subscription-event.util';
describe('getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent', () => {
it('should return an empty array if subscription items are not updated', () => {
const result =
getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent(
mockStripeSubscriptionUpdatedEventWithoutUpdatedItem,
);
expect(result).toEqual([]);
});
it('should return an empty array if subscription items are updated but not deleted', () => {
const result =
getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent(
mockStripeSubscriptionUpdatedEventWithUpdatedItemOnly,
);
expect(result).toEqual([]);
});
it('should return subscription item ids if subscription items are deleted', () => {
const result =
getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent(
mockStripeSubscriptionUpdatedEventWithDeletedItem,
);
expect(result).toEqual(['deleted_item_id']);
});
});

View File

@ -0,0 +1,29 @@
/* @license Enterprise */
import Stripe from 'stripe';
import { isDefined } from 'twenty-shared/utils';
export const getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent = (
event:
| Stripe.CustomerSubscriptionUpdatedEvent
| Stripe.CustomerSubscriptionCreatedEvent
| Stripe.CustomerSubscriptionDeletedEvent,
): string[] => {
const hasUpdatedSubscriptionItems = isDefined(
event.data.previous_attributes?.items?.data,
);
if (!hasUpdatedSubscriptionItems) {
return [];
}
const subscriptionItemIds =
event.data.object.items.data.map((item) => item.id) ?? [];
const deletedSubscriptionItemIds =
event.data.previous_attributes?.items?.data
.filter((item) => !subscriptionItemIds.includes(item.id))
.map((item) => item.id) ?? [];
return deletedSubscriptionItemIds;
};