folder changes

This commit is contained in:
John Yeo
2026-01-07 14:27:54 +00:00
parent 2ed5268877
commit 4fb29d910a
15 changed files with 106 additions and 177 deletions

View File

@@ -4,7 +4,7 @@ import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleAttach } from "./attach/handleAttach.js";
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js";
import { handleUpdateSubscription } from "./v2/updateSubscription/handleUpdateSubscription.js";
export const billingRouter = new Hono<HonoEnv>();
@@ -12,7 +12,6 @@ export const billingRouter = new Hono<HonoEnv>();
billingRouter.post("/setup_payment", ...handleSetupPayment);
billingRouter.post("/checkout", ...handleCheckoutV2);
billingRouter.post("/attach", ...handleAttach);
billingRouter.post("/attach_v2", ...handleAttachV2);
billingRouter.post("/subscriptions/update", ...handleUpdateSubscription);
billingRouter.post(

View File

@@ -1,69 +0,0 @@
import {
cusProductsToPrices,
cusProductToPrices,
type FullCusProduct,
isFreeProduct,
} from "@autumn/shared";
import { notNullish } from "../../../../../utils/genUtils";
import type { AttachContext } from "../../typesOld";
export const computeShouldCreateStripeCheckout = ({
attachContext,
newCusProducts,
}: {
attachContext: AttachContext;
newCusProducts: FullCusProduct[];
}) => {
const { body } = attachContext;
// 1. If force_checkout, create checkout
if (body.force_checkout)
return {
shouldCreate: true,
reason: "force_checkout",
};
// 2. If invoice is true, don't create checkout
if (body.invoice)
return {
shouldCreate: false,
reason: "invoice",
};
// 3. If has payment method, don't create checkout
const hasPaymentMethod = notNullish(attachContext.paymentMethod);
if (hasPaymentMethod)
return {
shouldCreate: false,
reason: "has_payment_method",
};
// 4. If new cus products are free, don't create checkout
const newPrices = cusProductsToPrices({ cusProducts: newCusProducts });
const newIsFree = isFreeProduct({ prices: newPrices });
if (newIsFree)
return {
shouldCreate: false,
reason: "new products are free",
};
// 5. If there's an ongoing cus product and it's not free, don't create checkout (?)
const ongoingCusProduct = attachContext.ongoingCusProductAction?.cusProduct;
const ongoingPrices = ongoingCusProduct
? cusProductToPrices({ cusProduct: ongoingCusProduct })
: [];
const ongoingIsFree = ongoingPrices
? isFreeProduct({ prices: ongoingPrices })
: false;
if (ongoingCusProduct && !ongoingIsFree)
return {
shouldCreate: false,
reason: "ongoing cus product is not free",
};
return {
shouldCreate: true,
reason: "passed all checks",
};
};

View File

@@ -1,62 +0,0 @@
import {
AffectedResource,
ApiVersion,
AttachBodyV0Schema,
AttachBodyV1Schema,
} from "@autumn/shared";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
export const handleAttachV2 = createRoute({
versionedBody: {
latest: AttachBodyV1Schema,
[ApiVersion.V2_0]: AttachBodyV0Schema,
},
resource: AffectedResource.Attach,
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
return c.json({ success: true }, 200);
// // Step 1: Fetch autumn state
// const attachContext = await fetchAttachContext({
// ctx,
// body,
// });
// // Step 2: Compute attach plan (no external calls)
// const attachPlan = await computeAttachPlan({
// ctx,
// attachContext,
// });
// // Step 3: Execute attach actions
// const attachResponse = await executeAttachActions({
// ctx,
// attachContext,
// attachPlan,
// });
// ctx.logger.info(`attach completed!`);
// return c.json({ success: true, attachResponse }, 200);
},
});
// Phase 1: FETCH (all external state upfront)
// ├── Stripe: customer, subscription, schedule, payment method
// └── Autumn DB: customer, products, existing cus products
// Phase 2: COMPUTE (pure Autumn logic, zero external calls)
// ├── Resolve actions
// ├── Init new cus products (using sub anchor from Phase 1)
// ├── Build line items
// └── Determine Stripe operations needed
// Phase 3: EXECUTE STRIPE (all Stripe writes together)
// ├── Create/pay invoice
// ├── Update/create subscription
// └── Update/create schedule
// Phase 4: EXECUTE AUTUMN (all DB writes together)
// ├── Insert new cus products
// ├── Update ongoing cus product status
// └── Delete scheduled cus product if needed

View File

@@ -0,0 +1,51 @@
import type { FullCusProduct, FullCustomer } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchStripeCustomerForBilling } from "./fetchStripeCustomerForBilling";
import { fetchStripeSubscriptionForBilling } from "./fetchStripeSubscriptionForBilling";
import { fetchStripeSubscriptionScheduleForBilling } from "./fetchStripeSubscriptionScheduleForBilling";
export const setupStripeBillingContext = async ({
ctx,
fullCustomer,
targetCustomerProduct,
}: {
ctx: AutumnContext;
fullCustomer: FullCustomer;
targetCustomerProduct: FullCusProduct;
}) => {
const stripeSubscription = await fetchStripeSubscriptionForBilling({
ctx,
fullCus: fullCustomer,
products: [],
targetCusProductId: targetCustomerProduct.id,
});
const stripeSubscriptionSchedule =
await fetchStripeSubscriptionScheduleForBilling({
ctx,
fullCus: fullCustomer,
subscriptionScheduleId:
typeof stripeSubscription?.schedule === "string"
? stripeSubscription.schedule
: undefined,
products: [],
targetCusProductId: targetCustomerProduct.id,
});
const {
stripeCus: stripeCustomer,
paymentMethod,
testClockFrozenTime,
} = await fetchStripeCustomerForBilling({
ctx,
fullCus: fullCustomer,
});
return {
stripeSubscription,
stripeSubscriptionSchedule,
stripeCustomer,
paymentMethod,
testClockFrozenTime,
};
};

View File

@@ -9,6 +9,7 @@ import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutum
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { computeCustomPlanFreeTrial } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFreeTrial";
import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct";
import { parseFeatureQuantitiesParams } from "@/internal/billing/v2/utils/parseFeatureQuantitiesParams";
import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct";
export const computeCustomPlan = async ({
@@ -49,6 +50,13 @@ export const computeCustomPlan = async ({
updateSubscriptionContext.billingCycleAnchorMs = freeTrialPlan.trialEndsAt;
}
updateSubscriptionContext.featureQuantities = parseFeatureQuantitiesParams({
ctx,
featureQuantitiesParams: params,
fullProduct: customFullProduct,
currentCustomerProduct: customerProduct,
}); // re-parse feature quantities for new custom product
// 3. Compute the new customer product
const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({
ctx,

View File

@@ -36,6 +36,8 @@ export const computeCustomPlanNewCustomerProduct = ({
cusProduct: customerProduct,
});
console.log("New customer product feature quantities", featureQuantities);
// Compute the new full customer product
const newFullCustomerProduct = initFullCustomerProduct({
ctx,

View File

@@ -3,7 +3,7 @@ import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/strip
import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { computeUpdateSubscriptionPlan } from "./compute/computeUpdateSubscriptionPlan";
import { fetchUpdateSubscriptionBillingContext } from "./fetch/fetchUpdateSubscriptionBillingContext";
import { setupUpdateSubscriptionBillingContext } from "./setup/setupUpdateSubscriptionBillingContext";
export const handlePreviewUpdateSubscription = createRoute({
body: UpdateSubscriptionV0ParamsSchema,
@@ -12,7 +12,7 @@ export const handlePreviewUpdateSubscription = createRoute({
const body = c.req.valid("json");
const updateSubscriptionBillingContext =
await fetchUpdateSubscriptionBillingContext({
await setupUpdateSubscriptionBillingContext({
ctx,
params: body,
});

View File

@@ -3,7 +3,7 @@ import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/updateSubsc
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { executeBillingPlan } from "../execute/executeBillingPlan";
import { evaluateStripeBillingPlan } from "../providers/stripe/actionBuilders/evaluateStripeBillingPlan";
import { fetchUpdateSubscriptionBillingContext } from "../updateSubscription/fetch/fetchUpdateSubscriptionBillingContext";
import { setupUpdateSubscriptionBillingContext } from "./setup/setupUpdateSubscriptionBillingContext";
export const handleUpdateSubscription = createRoute({
body: UpdateSubscriptionV0ParamsSchema,
@@ -11,7 +11,7 @@ export const handleUpdateSubscription = createRoute({
const ctx = c.get("ctx");
const body = c.req.valid("json");
const billingContext = await fetchUpdateSubscriptionBillingContext({
const billingContext = await setupUpdateSubscriptionBillingContext({
ctx,
params: body,
});

View File

@@ -4,7 +4,7 @@ import {
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
export const fetchTargetCusProductForUpdate = ({
export const findTargetCustomerProduct = ({
params,
fullCustomer,
}: {

View File

@@ -5,13 +5,11 @@ import {
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling";
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling";
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
import { CusService } from "../../../../customers/CusService";
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams";
import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate";
import { findTargetCustomerProduct } from "./findTargetCustomerProduct";
/**
* Fetch the context for updating a subscription
@@ -19,7 +17,7 @@ import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate
* @param body - The body of the request
* @returns The update subscription context
*/
export const fetchUpdateSubscriptionBillingContext = async ({
export const setupUpdateSubscriptionBillingContext = async ({
ctx,
params,
}: {
@@ -39,7 +37,7 @@ export const fetchUpdateSubscriptionBillingContext = async ({
entityId: params.entity_id ?? undefined,
});
const targetCustomerProduct = fetchTargetCusProductForUpdate({
const targetCustomerProduct = findTargetCustomerProduct({
params,
fullCustomer,
});
@@ -54,32 +52,16 @@ export const fetchUpdateSubscriptionBillingContext = async ({
cusProduct: targetCustomerProduct,
});
const stripeSubscription = await fetchStripeSubscriptionForBilling({
ctx,
fullCus: fullCustomer,
products: [],
targetCusProductId: targetCustomerProduct.id,
});
const stripeSubscriptionSchedule =
await fetchStripeSubscriptionScheduleForBilling({
ctx,
fullCus: fullCustomer,
subscriptionScheduleId:
typeof stripeSubscription?.schedule === "string"
? stripeSubscription.schedule
: undefined,
products: [],
targetCusProductId: targetCustomerProduct.id,
});
const {
stripeCus: stripeCustomer,
stripeSubscription,
stripeSubscriptionSchedule,
stripeCustomer,
paymentMethod,
testClockFrozenTime,
} = await fetchStripeCustomerForBilling({
} = await setupStripeBillingContext({
ctx,
fullCus: fullCustomer,
fullCustomer,
targetCustomerProduct,
});
const featureQuantities = parseFeatureQuantitiesParams({
@@ -90,7 +72,6 @@ export const fetchUpdateSubscriptionBillingContext = async ({
});
const currentEpochMs = testClockFrozenTime ?? Date.now();
const billingCycleAnchorMs = secondsToMs(
stripeSubscription?.billing_cycle_anchor,
);

View File

@@ -552,7 +552,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
expect(result).toHaveLength(0);
});
test("skips price when feature not found (no error thrown)", () => {
test("throws error when feature not found for price", () => {
const price = createMockPrepaidPrice({
id: "price_credits",
featureId: "credits",
@@ -569,15 +569,14 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
// Empty features array - feature won't be found
const ctx = createMockCtx({ features: [] });
// Should not throw, just skip the price
const result = parseFeatureQuantitiesParams({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(0);
expect(() =>
parseFeatureQuantitiesParams({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
}),
).toThrow("Feature not found for price");
});
test("neither current nor new has quantity → feature not included", () => {

View File

@@ -1146,6 +1146,26 @@ function SheetContent({
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{/* Current Customer Product Options */}
{cusProduct.options && cusProduct.options.length > 0 ? (
<div className="border-b border-border">
<div className="px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium">Current Options</h3>
</div>
<div className="px-4 py-3 space-y-1">
{cusProduct.options.map((option) => (
<div
key={option.feature_id}
className="flex justify-between text-sm"
>
<span className="text-t-secondary">{option.feature_id}</span>
<span className="font-mono">{option.quantity}</span>
</div>
))}
</div>
</div>
) : null}
{/* Request Body Display */}
<div className="border-b border-border">
<div className="px-4 py-2 border-b border-border">