Skip to content

Commit d4593b1

Browse files
authored
Merge pull request #2307 from useautumn/dev
Release dev to main
2 parents e5dc579 + f4f3575 commit d4593b1

271 files changed

Lines changed: 14178 additions & 2757 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 7c63f31

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

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
findMainScheduledCustomerProductByGroup,
44
isCustomerProductOnStripeSubscription,
55
isCustomerProductPaid,
6+
isCustomerProductScheduled,
67
} from "@autumn/shared";
78
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
89
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
@@ -13,14 +14,7 @@ import {
1314
} from "../../common";
1415
import type { StripeSubscriptionDeletedContext } from "../setupStripeSubscriptionDeletedContext";
1516

16-
/**
17-
* Handles customer product state changes when a subscription is deleted.
18-
*
19-
* For each customer product on the deleted subscription:
20-
* 1. Expire the customer product and activate default if needed
21-
* 2. Delete any scheduled main customer product in the same group
22-
* 3. Cache expired products so invoice.created can access them
23-
*/
17+
/** Expires live products, then activates or removes their scheduled successors. */
2418
export const expireAndActivateCustomerProducts = async ({
2519
ctx,
2620
eventContext,
@@ -36,13 +30,10 @@ export const expireAndActivateCustomerProducts = async ({
3630
);
3731

3832
const expiredCustomerProducts: FullCusProduct[] = [];
39-
// Iterate over a snapshot: `expireAndActivateWithTracking` may insert a
40-
// default product, and `trackCustomerProductDeletion` below splices the
41-
// paid scheduled product out. Both mutate `customerProducts` in place,
42-
// which would otherwise invalidate the for-of cursor and cause elements to
43-
// be skipped or re-iterated (see the add-on skip bug in the renewal
44-
// handler).
45-
for (const customerProduct of [...customerProducts]) {
33+
const liveCustomerProducts = customerProducts.filter(
34+
(customerProduct) => !isCustomerProductScheduled(customerProduct),
35+
);
36+
for (const customerProduct of liveCustomerProducts) {
4637
// 1. If not on stripe subscription, skip
4738
const onStripeSubscription = isCustomerProductOnStripeSubscription({
4839
customerProduct,
@@ -84,10 +75,7 @@ export const expireAndActivateCustomerProducts = async ({
8475
}
8576
}
8677

87-
/**
88-
* Need to cache expired customer products to invoice.created can access them
89-
* invoice.created creates a final invoice for usage-based prices
90-
*/
78+
// invoice.created needs the expired snapshots for final usage billing.
9179
await customerProductActions.expiredCache.set({
9280
stripeSubscriptionId: stripeSubscription.id,
9381
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
@@ -15,15 +15,7 @@ import { computeOneOffPurchaseRebalance } from "./computeOneOffPurchaseRebalance
1515
import { finalizeAttachPlan } from "./finalizeAttachPlan";
1616
import { shouldBuildImmediateLineItems } from "./shouldBuildImmediateLineItems";
1717

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

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

7266
const {
7367
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
};

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ export const setupAttachBillingContext = async ({
149149

150150
const customerLicenseQuantities = setupCustomerLicenseQuantityContext({
151151
params,
152+
fullProduct: attachProduct,
153+
customerProduct: currentCustomerProduct,
152154
});
153155

154156
const invoiceMode = await setupInvoiceModeContext({ ctx, params });

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

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,7 @@ import type Stripe from "stripe";
99
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils";
1010
import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils";
1111

12-
/**
13-
* Computes the end of cycle timestamp for scheduled product attachments.
14-
* Used for:
15-
* - Downgrades (transition from existing product)
16-
* - Merged subscription scheduling (entity joins existing subscription at renewal)
17-
*
18-
* Returns undefined if planTiming is "immediate" or no billing cycle can be determined.
19-
*/
12+
/** Resolves a scheduled attachment's cycle boundary from Autumn or Stripe. */
2013
export const setupAttachEndOfCycleMs = ({
2114
planTiming,
2215
currentCustomerProduct,
@@ -45,17 +38,16 @@ export const setupAttachEndOfCycleMs = ({
4538
excludeOneOff: true,
4639
});
4740

48-
if (!largestInterval) {
49-
return undefined;
41+
if (largestInterval) {
42+
return getCycleEnd({
43+
anchor: billingCycleAnchorMs,
44+
interval: largestInterval.interval,
45+
intervalCount: largestInterval.intervalCount,
46+
now: currentEpochMs,
47+
floor:
48+
billingCycleAnchorMs === "now" ? undefined : billingCycleAnchorMs,
49+
});
5050
}
51-
52-
return getCycleEnd({
53-
anchor: billingCycleAnchorMs,
54-
interval: largestInterval.interval,
55-
intervalCount: largestInterval.intervalCount,
56-
now: currentEpochMs,
57-
floor: billingCycleAnchorMs === "now" ? undefined : billingCycleAnchorMs,
58-
});
5951
}
6052

6153
const latestPeriodEndSeconds = getLatestPeriodEnd({

0 commit comments

Comments
 (0)