fix subscription item update (#11648)
closes https://twenty-v7.sentry.io/issues/6550388239/?environment=prod&project=4507072499810304&query=is%3Aunresolved%20issue.priority%3A%5Bhigh%2C%20medium%5D&referrer=issue-stream&stream_index=6
This commit is contained in:
@ -19,7 +19,7 @@ export const useGetWorkflowNodeExecutionUsage = () => {
|
|||||||
return {
|
return {
|
||||||
usageQuantity: 0,
|
usageQuantity: 0,
|
||||||
freeUsageQuantity: 0,
|
freeUsageQuantity: 0,
|
||||||
includedFreeQuantity: 0,
|
includedFreeQuantity: 10000,
|
||||||
paidUsageQuantity: 0,
|
paidUsageQuantity: 0,
|
||||||
unitPriceCents: 0,
|
unitPriceCents: 0,
|
||||||
totalCostCents: 0,
|
totalCostCents: 0,
|
||||||
|
|||||||
@ -106,7 +106,9 @@ export const SettingsBilling = () => {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SettingsPageContainer>
|
<SettingsPageContainer>
|
||||||
<SettingsBillingMonthlyCreditsSection />
|
{hasNotCanceledCurrentSubscription && (
|
||||||
|
<SettingsBillingMonthlyCreditsSection />
|
||||||
|
)}
|
||||||
<Section>
|
<Section>
|
||||||
<H2Title
|
<H2Title
|
||||||
title={t`Manage your subscription`}
|
title={t`Manage your subscription`}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export enum BillingExceptionCode {
|
|||||||
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
||||||
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
|
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
|
||||||
BILLING_METER_NOT_FOUND = 'BILLING_METER_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_ITEM_NOT_FOUND = 'BILLING_SUBSCRIPTION_ITEM_NOT_FOUND',
|
||||||
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_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',
|
BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND',
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
|
|||||||
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
|
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
|
||||||
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
|
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
|
||||||
case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
|
case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
|
||||||
|
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND:
|
||||||
return this.httpExceptionHandlerService.handleError(
|
return this.httpExceptionHandlerService.handleError(
|
||||||
exception,
|
exception,
|
||||||
response,
|
response,
|
||||||
|
|||||||
@ -94,6 +94,13 @@ export class BillingUsageService {
|
|||||||
{ workspaceId: workspace.id },
|
{ workspaceId: workspace.id },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isDefined(subscription)) {
|
||||||
|
throw new BillingException(
|
||||||
|
'Not-canceled subscription not found',
|
||||||
|
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const meteredSubscriptionItemDetails =
|
const meteredSubscriptionItemDetails =
|
||||||
await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
|
await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
|
||||||
subscription.id,
|
subscription.id,
|
||||||
|
|||||||
@ -77,6 +77,7 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
!isDefined(subscription) ||
|
||||||
![SubscriptionStatus.Active, SubscriptionStatus.Trialing].includes(
|
![SubscriptionStatus.Active, SubscriptionStatus.Trialing].includes(
|
||||||
subscription.status,
|
subscription.status,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
@ -6,7 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
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 { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||||
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.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 { 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 { 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 { 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 { 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 { 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';
|
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');
|
throw new Error('Billing subscription not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.billingSubscriptionItemRepository.upsert(
|
await this.updateBillingSubscriptionItems(
|
||||||
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
|
updatedBillingSubscription.id,
|
||||||
updatedBillingSubscription.id,
|
event,
|
||||||
data,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
conflictPaths: ['billingSubscriptionId', 'stripeProductId'],
|
|
||||||
skipUpdateIfNoValuesChanged: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -174,4 +169,33 @@ export class BillingWebhookSubscriptionService {
|
|||||||
|
|
||||||
return false;
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user