Skip to content

Commit 755a97e

Browse files
committed
fix(billing): 🐛 merge latest dev into pooled balances
2 parents 442f46a + f60544e commit 755a97e

290 files changed

Lines changed: 16164 additions & 2924 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ai

Submodule ai updated from bca809a to bce748e

server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
22
import { setupStripeSubscriptionCreatedContext } from "./setupStripeSubscriptionCreatedContext.js";
3-
import { autoSyncFromSubscription } from "./tasks/autoSyncFromSubscription.js";
3+
import { autoSyncFromSubscriptionWithLock } from "./tasks/autoSyncFromSubscription.js";
44
import { linkScheduledCustomerProductsToSubscription } from "./tasks/linkScheduledCustomerProductsToSubscription.js";
55

66
export const handleStripeSubscriptionCreated = async ({
@@ -17,5 +17,5 @@ export const handleStripeSubscriptionCreated = async ({
1717
subscription: subscriptionCreatedContext.subscription,
1818
});
1919

20-
await autoSyncFromSubscription({ ctx, subscriptionCreatedContext });
20+
await autoSyncFromSubscriptionWithLock({ ctx, subscriptionCreatedContext });
2121
};

server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,35 @@ import { billingActions } from "@/internal/billing/v2/actions";
33
import { canAutoSync } from "@/internal/billing/v2/actions/sync/canAutoSync/index.js";
44
import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams.js";
55
import { isAutumnCheckoutSubscription } from "@/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.js";
6+
import { withStripeSyncCustomerLock } from "@/internal/billing/v2/actions/sync/utils/withStripeSyncCustomerLock.js";
7+
import { CusService } from "@/internal/customers/CusService.js";
68
import { shouldSkipSubscriptionSync } from "../../common/subscriptionSync/shouldSkipSubscriptionSync.js";
79
import type { StripeSubscriptionCreatedContext } from "../setupStripeSubscriptionCreatedContext.js";
810

911
/**
1012
* Custom base prices can auto-sync; custom feature prices and unresolved items
1113
* still abort via canAutoSync.
1214
*/
13-
export const autoSyncFromSubscription = async ({
14-
ctx,
15-
subscriptionCreatedContext,
16-
}: {
15+
type AutoSyncFromSubscriptionParams = {
1716
ctx: StripeWebhookContext;
1817
subscriptionCreatedContext: StripeSubscriptionCreatedContext;
19-
}) => {
18+
};
19+
20+
const autoSyncFromSubscription = async ({
21+
ctx,
22+
subscriptionCreatedContext,
23+
}: AutoSyncFromSubscriptionParams) => {
2024
const { logger, stripeCli } = ctx;
2125
const { subscription, fullCustomer } = subscriptionCreatedContext;
2226
const customerId = fullCustomer.id ?? fullCustomer.internal_id;
27+
const currentCustomer = await CusService.getFull({
28+
ctx,
29+
idOrInternalId: customerId,
30+
});
2331

2432
const skip = shouldSkipSubscriptionSync({
2533
subscription,
26-
fullCustomer,
34+
fullCustomer: currentCustomer,
2735
requireRecent: false,
2836
});
2937
if (skip.skip) {
@@ -44,7 +52,7 @@ export const autoSyncFromSubscription = async ({
4452
ctx,
4553
customerId,
4654
subscription,
47-
customerProducts: fullCustomer.customer_products,
55+
customerProducts: currentCustomer.customer_products,
4856
});
4957

5058
const eligibility = canAutoSync({ match });
@@ -57,3 +65,17 @@ export const autoSyncFromSubscription = async ({
5765

5866
await billingActions.syncV2({ ctx, params });
5967
};
68+
69+
export const autoSyncFromSubscriptionWithLock = async (
70+
params: AutoSyncFromSubscriptionParams,
71+
) => {
72+
const { ctx, subscriptionCreatedContext } = params;
73+
const customerId =
74+
subscriptionCreatedContext.fullCustomer.id ??
75+
subscriptionCreatedContext.fullCustomer.internal_id;
76+
await withStripeSyncCustomerLock({
77+
ctx,
78+
customerId,
79+
run: () => autoSyncFromSubscription(params),
80+
});
81+
};

server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
findMainScheduledCustomerProductByGroup,
66
isCustomerProductOnStripeSubscription,
77
isCustomerProductPaid,
8+
isCustomerProductScheduled,
89
} from "@autumn/shared";
910
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
1011
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js";
@@ -22,14 +23,7 @@ import {
2223
} from "../../common";
2324
import type { StripeSubscriptionDeletedContext } from "../setupStripeSubscriptionDeletedContext";
2425

25-
/**
26-
* Handles customer product state changes when a subscription is deleted.
27-
*
28-
* For each customer product on the deleted subscription:
29-
* 1. Expire the customer product and activate default if needed
30-
* 2. Delete any scheduled main customer product in the same group
31-
* 3. Cache expired products so invoice.created can access them
32-
*/
26+
/** Expires live products, then activates or removes their scheduled successors. */
3327
export const expireAndActivateCustomerProducts = async ({
3428
ctx,
3529
eventContext,
@@ -54,7 +48,10 @@ export const expireAndActivateCustomerProducts = async ({
5448
// Prepare from a stable snapshot. Completion and tracking mutate both the
5549
// event-context product list and FullCustomer, but only after the merged
5650
// lifecycle plan has committed successfully.
57-
for (const customerProduct of [...customerProducts]) {
51+
const liveCustomerProducts = customerProducts.filter(
52+
(customerProduct) => !isCustomerProductScheduled(customerProduct),
53+
);
54+
for (const customerProduct of liveCustomerProducts) {
5855
const onStripeSubscription = isCustomerProductOnStripeSubscription({
5956
customerProduct,
6057
stripeSubscriptionId: stripeSubscription.id,
@@ -160,10 +157,7 @@ export const expireAndActivateCustomerProducts = async ({
160157
}
161158
}
162159

163-
/**
164-
* Need to cache expired customer products to invoice.created can access them
165-
* invoice.created creates a final invoice for usage-based prices
166-
*/
160+
// invoice.created needs the expired snapshots for final usage billing.
167161
await customerProductActions.expiredCache.set({
168162
stripeSubscriptionId: stripeSubscription.id,
169163
customerProducts: expiredCustomerProducts,

server/src/internal/billing/v2/actions/attach/attach.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import {
77
import { ms } from "@shared/utils/common/unixUtils";
88
import type { AutumnContext } from "@/honoUtils/HonoEnv";
99
import { computeAttachPlan } from "@/internal/billing/v2/actions/attach/compute/computeAttachPlan";
10+
import { handleAttachComputeErrors } from "@/internal/billing/v2/actions/attach/errors/handleAttachComputeErrors";
1011
import { handleAttachV2Errors } from "@/internal/billing/v2/actions/attach/errors/handleAttachV2Errors";
11-
import { handleCurrencyMismatchErrors } from "@/internal/billing/v2/actions/attach/errors/handleCurrencyMismatchErrors";
1212
import { logAttachContext } from "@/internal/billing/v2/actions/attach/logs/logAttachContext";
1313
import { setupAttachBillingContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachBillingContext";
1414
import { checkCheckoutSessionLock } from "@/internal/billing/v2/actions/locks/checkoutSessionLock/checkCheckoutSessionLock";
@@ -60,11 +60,6 @@ export async function attach({
6060

6161
logAttachContext({ ctx, billingContext });
6262

63-
// Currency guard runs here (not in handleAttachV2Errors) because
64-
// evaluateStripeBillingPlan below creates Stripe prices, so the block must
65-
// fire before it. Covers preview too.
66-
handleCurrencyMismatchErrors({ ctx, billingContext, params });
67-
6863
// 2. Compute
6964
const autumnBillingPlan = computeAttachPlan({
7065
ctx,
@@ -73,6 +68,12 @@ export async function attach({
7368
});
7469

7570
logAutumnBillingPlan({ ctx, plan: autumnBillingPlan, billingContext });
71+
await handleAttachComputeErrors({
72+
ctx,
73+
billingContext,
74+
autumnBillingPlan,
75+
params,
76+
});
7677

7778
// 3. Evaluate Stripe billing plan (handles checkout mode internally)
7879
const stripeBillingPlan = await evaluateStripeBillingPlan({

server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,7 @@ import { computeOneOffPurchaseRebalance } from "./computeOneOffPurchaseRebalance
1616
import { finalizeAttachPlan } from "./finalizeAttachPlan";
1717
import { shouldBuildImmediateLineItems } from "./shouldBuildImmediateLineItems";
1818

19-
/**
20-
* Computes the billing plan for attaching a product.
21-
*
22-
* Scenarios:
23-
* - Add-on/One-time (no currentCustomerProduct): Just insert new product
24-
* - First main product (no currentCustomerProduct): Just insert new product
25-
* - Upgrade (currentCustomerProduct exists, planTiming=immediate): Expire current, insert new active
26-
* - Downgrade (currentCustomerProduct exists, planTiming=end_of_cycle): Cancel current at end of cycle, insert new scheduled
27-
*/
19+
/** Computes new attachments and immediate or scheduled product transitions. */
2820
export const computeAttachPlan = ({
2921
ctx,
3022
attachBillingContext,
@@ -60,15 +52,17 @@ export const computeAttachPlan = ({
6052

6153
// Customer licenses follow the incoming definitions on immediate swaps;
6254
// scheduled swaps transition at activation instead.
55+
const computedCustomerLicenseTransitions = currentCustomerProduct
56+
? computeCustomerLicenseTransitions({
57+
outgoingCustomerProducts: [currentCustomerProduct],
58+
incomingCustomerProducts: [newCustomerProduct],
59+
customerLicenseBillingContext:
60+
attachBillingContext.customerLicenseBillingContext,
61+
carryCustomerLicenseState: planTiming === "immediate",
62+
})
63+
: [];
6364
const customerLicenseTransitions =
64-
planTiming === "immediate" && currentCustomerProduct
65-
? computeCustomerLicenseTransitions({
66-
outgoingCustomerProducts: [currentCustomerProduct],
67-
incomingCustomerProducts: [newCustomerProduct],
68-
customerLicenseBillingContext:
69-
attachBillingContext.customerLicenseBillingContext,
70-
})
71-
: [];
65+
planTiming === "immediate" ? computedCustomerLicenseTransitions : [];
7266

7367
const {
7468
entitlements: carriedOverEntitlements,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type {
2+
AttachBillingContext,
3+
AttachParamsV1,
4+
AutumnBillingPlan,
5+
} from "@autumn/shared";
6+
import type { AutumnContext } from "@/honoUtils/HonoEnv";
7+
import { validateCustomerEntitlementBatchTransitions } from "@/internal/billing/v2/actions/batchTransition/errors/validateCustomerEntitlementBatchTransitions";
8+
import { handleLicenseTransitionErrors } from "@/internal/billing/v2/common/errors/handleLicenseTransitionErrors";
9+
import { handleCurrencyMismatchErrors } from "./handleCurrencyMismatchErrors";
10+
import { handleLicenseErrors } from "./handleLicenseErrors/handleLicenseErrors";
11+
12+
export const handleAttachComputeErrors = async ({
13+
ctx,
14+
billingContext,
15+
autumnBillingPlan,
16+
params,
17+
}: {
18+
ctx: AutumnContext;
19+
billingContext: AttachBillingContext;
20+
autumnBillingPlan: AutumnBillingPlan;
21+
params: AttachParamsV1;
22+
}) => {
23+
handleCurrencyMismatchErrors({ ctx, billingContext, params });
24+
handleLicenseTransitionErrors({ autumnBillingPlan });
25+
handleLicenseErrors({ billingContext });
26+
await validateCustomerEntitlementBatchTransitions({
27+
ctx,
28+
transitions: autumnBillingPlan.customerLicenseTransitions,
29+
});
30+
};

server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ import { handleProrationBehaviorErrors } from "@/internal/billing/v2/common/erro
1919
import { handleCustomLineItemsErrors } from "@/internal/billing/v2/common/errors/handleCustomLineItemsErrors";
2020
import { handleEntityLicenseAssignmentErrors } from "@/internal/billing/v2/common/errors/handleEntityLicenseAssignmentErrors";
2121
import { handleExternalPSPErrors } from "@/internal/billing/v2/common/errors/handleExternalPSPErrors";
22+
import { handleLicenseAttachTargetErrors } from "@/internal/billing/v2/common/errors/handleLicenseAttachTargetErrors";
2223
import { handleSubscriptionIdErrors } from "@/internal/billing/v2/common/errors/handleSubscriptionIdErrors";
2324
import { handleStripeBillingPlanErrors } from "@/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors";
2425
import { handleCustomPaymentMethodErrorsV2 } from "@/internal/customers/attach/attachUtils/handleAttachErrors";
25-
import { handleLicenseErrors } from "./handleLicenseErrors/handleLicenseErrors";
2626
import { handleRevertTrialErrors } from "./handleRevertTrialErrors";
2727

2828
/** Validates attach v2 request before executing the billing plan. */
@@ -60,6 +60,12 @@ export const handleAttachV2Errors = async ({
6060
fullCustomer: billingContext.fullCustomer,
6161
});
6262

63+
// 1.4. License linkage preconditions on the attach target
64+
handleLicenseAttachTargetErrors({
65+
fullCustomer: billingContext.fullCustomer,
66+
attachProduct: billingContext.attachProduct,
67+
});
68+
6369
// 2. Current customer product errors (same product)
6470
handleCurrentCustomerProductErrors({ billingContext });
6571

@@ -112,6 +118,4 @@ export const handleAttachV2Errors = async ({
112118
handleRevertTrialErrors({ billingContext });
113119

114120
handleStripeBillingPlanErrors({ ctx, billingContext, billingPlan });
115-
116-
handleLicenseErrors({ billingContext, autumnBillingPlan });
117121
};

server/src/internal/billing/v2/actions/attach/errors/handleLicenseErrors/handleDroppedLicenseErrors.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,47 @@
11
import {
22
type AttachBillingContext,
3-
type AutumnBillingPlan,
43
customerLicenseToUsage,
54
ErrCode,
65
type FullCustomerLicense,
76
RecaseError,
87
} from "@autumn/shared";
8+
import { matchCustomerLicensePlanSuccessors } from "@/internal/billing/v2/compute/customerLicenseTransitions/matchCustomerLicenseSuccessors.js";
99

1010
const licensePlanIdOf = (customerLicense: FullCustomerLicense) =>
1111
customerLicense.planLicense?.product.id ??
1212
customerLicense.license_internal_product_id;
1313

14+
/** Blocks an immediate switch that would strand active assignments: a used
15+
* outgoing pool must have a successor (same license plan, or a 1:1 group
16+
* match) on the incoming plan. */
1417
export const handleDroppedLicenseErrors = ({
1518
billingContext,
16-
autumnBillingPlan,
1719
}: {
1820
billingContext: AttachBillingContext;
19-
autumnBillingPlan: AutumnBillingPlan;
2021
}) => {
2122
const { currentCustomerProduct, planTiming } = billingContext;
2223
if (planTiming !== "immediate" || !currentCustomerProduct) return;
2324

24-
const incomingLicensePlanIds = new Set(
25-
(autumnBillingPlan.insertCustomerProducts ?? []).flatMap(
26-
(customerProduct) =>
27-
(customerProduct.customer_licenses ?? []).map(licensePlanIdOf),
28-
),
29-
);
25+
const { unmatched } = matchCustomerLicensePlanSuccessors({
26+
outgoingCustomerLicenses: currentCustomerProduct.customer_licenses ?? [],
27+
incomingPlanLicenses: billingContext.attachProduct.licenses ?? [],
28+
});
3029

31-
for (const outgoingPool of currentCustomerProduct.customer_licenses ?? []) {
32-
const used = customerLicenseToUsage({ customerLicense: outgoingPool });
30+
for (const { outgoingCustomerLicense, reason, group } of unmatched) {
31+
const used = customerLicenseToUsage({
32+
customerLicense: outgoingCustomerLicense,
33+
});
3334
if (used === 0) continue;
34-
if (incomingLicensePlanIds.has(licensePlanIdOf(outgoingPool))) continue;
35+
36+
const licensePlanId = licensePlanIdOf(outgoingCustomerLicense);
37+
const conflict =
38+
reason === "ambiguous"
39+
? `the licenses in group "${group}" are not a 1:1 match on the incoming plan`
40+
: "the incoming plan drops the license";
3541
throw new RecaseError({
3642
message:
3743
`License changes conflict with active license assignments: ` +
38-
`${used} assigned for ${licensePlanIdOf(outgoingPool)}, but the incoming plan drops the license. Release licenses first.`,
44+
`${used} assigned for ${licensePlanId}, but ${conflict}. Release licenses first.`,
3945
code: ErrCode.InvalidRequest,
4046
statusCode: 400,
4147
});
Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
import type { AttachBillingContext, AutumnBillingPlan } from "@autumn/shared";
2-
import { handleLicenseTransitionErrors } from "@/internal/billing/v2/common/errors/handleLicenseTransitionErrors";
1+
import type { AttachBillingContext } from "@autumn/shared";
32
import { handleDroppedLicenseErrors } from "./handleDroppedLicenseErrors";
43

54
export const handleLicenseErrors = ({
65
billingContext,
7-
autumnBillingPlan,
86
}: {
97
billingContext: AttachBillingContext;
10-
autumnBillingPlan: AutumnBillingPlan;
118
}) => {
12-
handleLicenseTransitionErrors({ autumnBillingPlan });
13-
handleDroppedLicenseErrors({ billingContext, autumnBillingPlan });
9+
handleDroppedLicenseErrors({ billingContext });
1410
};

0 commit comments

Comments
 (0)