folder changes
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type UpdateSubscriptionV0Params,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const fetchTargetCusProductForUpdate = ({
|
||||
export const findTargetCustomerProduct = ({
|
||||
params,
|
||||
fullCustomer,
|
||||
}: {
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user