feat: add setup payment v2

This commit is contained in:
Charlie Lamb
2026-02-23 13:31:33 +00:00
parent cf831d53d6
commit 37ef0a9322
16 changed files with 384 additions and 38 deletions

View File

@@ -30,7 +30,7 @@ import {
type ProductItem, type ProductItem,
type RewardRedemption, type RewardRedemption,
type SetUsageParams, type SetUsageParams,
type SetupPaymentParamsV0, type SetupPaymentParamsV1,
type TrackParams, type TrackParams,
type UpdateBalanceParamsV0, type UpdateBalanceParamsV0,
type UpdateSubscriptionV0Params, type UpdateSubscriptionV0Params,
@@ -908,8 +908,8 @@ export class AutumnInt {
return data; return data;
}, },
setupPayment: async (params: SetupPaymentParamsV0) => { setupPayment: async (params: SetupPaymentParamsV1) => {
const data = await this.post(`/setup_payment`, params); const data = await this.post(`/billing.setup_payment`, params);
return data; return data;
}, },
}; };

View File

@@ -280,3 +280,33 @@ const deleteAllStripeCustomers = async ({
); );
} }
}; };
/**
* Retrieves the customer's payment method and sets it as their default for invoices.
* Returns the payment method if found and set, or null if none available.
*/
export const updateDefaultPaymentMethod = async ({
stripeCli,
stripeCustomerId,
}: {
stripeCli: Stripe;
stripeCustomerId: string;
}) => {
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: stripeCustomerId,
errorIfNone: false,
});
if (!paymentMethod) {
return null;
}
await stripeCli.customers.update(stripeCustomerId, {
invoice_settings: {
default_payment_method: paymentMethod.id,
},
});
return paymentMethod;
};

View File

@@ -3,6 +3,7 @@ import { handleCheckoutSessionMetadataV2 } from "@/external/stripe/webhookHandle
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js"; import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
import { setupCheckoutSessionCompletedContext } from "./setupCheckoutSessionCompletedContext.js"; import { setupCheckoutSessionCompletedContext } from "./setupCheckoutSessionCompletedContext.js";
import { handleLegacyCheckoutSessionMetadata } from "./tasks/handleLegacyCheckoutSessionMetadata.ts/handleCheckoutSessionCompletedLegacy.js"; import { handleLegacyCheckoutSessionMetadata } from "./tasks/handleLegacyCheckoutSessionMetadata.ts/handleCheckoutSessionCompletedLegacy.js";
import { handleSetupPaymentMetadata } from "./tasks/handleSetupPaymentMetadata.js";
import { handleStandaloneSetupCheckout } from "./tasks/handleStandaloneSetupCheckout.js"; import { handleStandaloneSetupCheckout } from "./tasks/handleStandaloneSetupCheckout.js";
import { updateCustomerFromCheckout } from "./tasks/updateCustomerFromCheckout.js"; import { updateCustomerFromCheckout } from "./tasks/updateCustomerFromCheckout.js";
@@ -24,6 +25,12 @@ export const handleStripeCheckoutSessionCompleted = async ({
checkoutContext, checkoutContext,
}); });
// Setup payment with metadata (plan attachment after setup)
await handleSetupPaymentMetadata({
ctx,
checkoutContext,
});
// Legacy flow // Legacy flow
await handleLegacyCheckoutSessionMetadata({ await handleLegacyCheckoutSessionMetadata({
ctx, ctx,

View File

@@ -0,0 +1,80 @@
import { type DeferredSetupPaymentData, MetadataType } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
import { updateDefaultPaymentMethod } from "@/external/stripe/stripeCusUtils";
import { billingActions } from "@/internal/billing/v2/actions";
import { setupPaymentToAttachParams } from "@/internal/billing/v2/actions/setupPayment/setupPaymentUtils";
import { MetadataService } from "@/internal/metadata/MetadataService";
import type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext";
import type { CheckoutSessionCompletedContext } from "../setupCheckoutSessionCompletedContext";
/**
* Handles setup checkout sessions with metadata (plan attachment after setup).
* Updates the customer's payment method, then attaches the plan if specified.
*/
export const handleSetupPaymentMetadata = async ({
ctx,
checkoutContext,
}: {
ctx: StripeWebhookContext;
checkoutContext: CheckoutSessionCompletedContext;
}): Promise<void> => {
const { org, env, logger } = ctx;
const { stripeCheckoutSession, metadata } = checkoutContext;
if (metadata?.type !== MetadataType.SetupPaymentV2) {
return;
}
logger.info(
`[checkout.completed] Handling setup payment metadata: ${metadata.id}`,
);
const deferredData = metadata.data as DeferredSetupPaymentData;
const stripeCustomerId = stripeCheckoutSession.customer as string;
if (!stripeCustomerId) {
logger.warn("Setup payment metadata: no Stripe customer ID, skipping");
await MetadataService.delete({ db: ctx.db, id: metadata.id });
return;
}
// 1. Update customer's default payment method
const stripeCli = createStripeCli({ org, env });
const paymentMethod = await updateDefaultPaymentMethod({
stripeCli,
stripeCustomerId,
});
if (paymentMethod) {
logger.info(
`Setup payment metadata: set default payment method for ${stripeCustomerId}`,
);
} else {
logger.warn("Setup payment metadata: no payment method found after setup");
}
// 2. Attach plan if plan_id was specified
if (deferredData.params.plan_id) {
logger.info(
`Setup payment metadata: attaching plan ${deferredData.params.plan_id}`,
);
const attachParams = setupPaymentToAttachParams({
params: deferredData.params,
});
await billingActions.attach({
ctx,
params: attachParams,
preview: false,
skipAutumnCheckout: true,
});
logger.info(
`Setup payment metadata: plan ${deferredData.params.plan_id} attached successfully`,
);
}
// 3. Cleanup metadata
await MetadataService.delete({ db: ctx.db, id: metadata.id });
};

View File

@@ -1,5 +1,5 @@
import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { updateDefaultPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js"; import { CusService } from "@/internal/customers/CusService.js";
import type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext.js"; import type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext.js";
import type { CheckoutSessionCompletedContext } from "../setupCheckoutSessionCompletedContext.js"; import type { CheckoutSessionCompletedContext } from "../setupCheckoutSessionCompletedContext.js";
@@ -42,11 +42,9 @@ export const handleStandaloneSetupCheckout = async ({
} }
const stripeCli = createStripeCli({ org, env }); const stripeCli = createStripeCli({ org, env });
const paymentMethod = await updateDefaultPaymentMethod({
const paymentMethod = await getCusPaymentMethod({
stripeCli, stripeCli,
stripeId: stripeCustomerId, stripeCustomerId,
errorIfNone: false,
}); });
if (!paymentMethod) { if (!paymentMethod) {
@@ -57,13 +55,6 @@ export const handleStandaloneSetupCheckout = async ({
} }
logger.info( logger.info(
`Standalone setup checkout: updating default payment method for customer ${customer.id}`, `Standalone setup checkout: updated default payment method for customer ${customer.id}`,
); );
// Set as customer's default payment method
await stripeCli.customers.update(stripeCustomerId, {
invoice_settings: {
default_payment_method: paymentMethod.id,
},
});
}; };

View File

@@ -9,6 +9,7 @@ import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
import { handleSetupPayment } from "./handlers/handleSetupPayment.js"; import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js"; import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js";
import { handlePreviewUpdateSubscription } from "./v2/handlers/handlePreviewUpdateSubscription.js"; import { handlePreviewUpdateSubscription } from "./v2/handlers/handlePreviewUpdateSubscription.js";
import { handleSetupPaymentV2 } from "./v2/handlers/handleSetupPaymentV2.js";
import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js"; import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js";
export const billingRouter = new Hono<HonoEnv>(); export const billingRouter = new Hono<HonoEnv>();
@@ -29,6 +30,7 @@ billingRpcRouter.post(
); );
billingRpcRouter.post("/billing.attach", ...handleAttachV2); billingRpcRouter.post("/billing.attach", ...handleAttachV2);
billingRpcRouter.post("/billing.preview_attach", ...handlePreviewAttach); billingRpcRouter.post("/billing.preview_attach", ...handlePreviewAttach);
billingRpcRouter.post("/billing.setup_payment", ...handleSetupPaymentV2);
billingRpcRouter.post( billingRpcRouter.post(
"/billing.open_customer_portal", "/billing.open_customer_portal",
...handleOpenCustomerPortalV2, ...handleOpenCustomerPortalV2,

View File

@@ -4,10 +4,12 @@ import { legacyAttach } from "@/internal/billing/v2/actions/legacy/legacyAttach"
import { renew } from "@/internal/billing/v2/actions/legacy/renew"; import { renew } from "@/internal/billing/v2/actions/legacy/renew";
import { updateQuantity } from "@/internal/billing/v2/actions/legacy/updateQuantity"; import { updateQuantity } from "@/internal/billing/v2/actions/legacy/updateQuantity";
import { migrate } from "@/internal/billing/v2/actions/migrate/migrate"; import { migrate } from "@/internal/billing/v2/actions/migrate/migrate";
import { setupPayment } from "@/internal/billing/v2/actions/setupPayment/setupPayment";
import { updateSubscription } from "@/internal/billing/v2/actions/updateSubscription/updateSubscription"; import { updateSubscription } from "@/internal/billing/v2/actions/updateSubscription/updateSubscription";
export const billingActions = { export const billingActions = {
attach: attach, attach: attach,
setupPayment: setupPayment,
updateSubscription: updateSubscription, updateSubscription: updateSubscription,
migrate: migrate, migrate: migrate,

View File

@@ -0,0 +1,96 @@
import {
type Customer,
type DeferredSetupPaymentData,
MetadataType,
type SetupPaymentParamsV1,
} from "@autumn/shared";
import { addDays } from "date-fns";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { createStripeSessionWithCardFallback } from "@/internal/billing/v2/providers/stripe/utils/checkoutSessions/createStripeSessionWithCardFallback";
import { MetadataService } from "@/internal/metadata/MetadataService";
import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils";
import { generateId } from "@/utils/genUtils";
/**
* Inserts deferred metadata so the webhook can attach the plan after setup completes.
*/
const insertSetupPaymentMetadata = async ({
ctx,
params,
}: {
ctx: AutumnContext;
params: SetupPaymentParamsV1;
}) => {
const payload: DeferredSetupPaymentData = {
requestId: ctx.id,
orgId: ctx.org.id,
env: ctx.env,
params,
};
return MetadataService.insert({
db: ctx.db,
data: {
id: generateId("meta"),
type: MetadataType.SetupPaymentV2,
data: payload,
created_at: Date.now(),
expires_at: addDays(Date.now(), 10).getTime(),
},
});
};
/**
* Creates a Stripe checkout session in setup mode.
* If plan_id is specified, stores metadata so the webhook can attach the plan after setup.
*/
export const createSetupCheckoutSession = async ({
ctx,
customer,
params,
}: {
ctx: AutumnContext;
customer: Customer;
params: SetupPaymentParamsV1;
}) => {
const { org, env, logger } = ctx;
const stripeCli = createStripeCli({ org, env });
// 1. Insert metadata (if plan_id specified)
const metadata = params.plan_id
? await insertSetupPaymentMetadata({ ctx, params })
: null;
// 2. Build session params
const fullParams: Stripe.Checkout.SessionCreateParams = {
customer: customer.processor?.id ?? undefined,
mode: "setup",
success_url: params.success_url || toSuccessUrl({ org, env }),
currency: org.default_currency || "usd",
...params.checkout_session_params,
...(metadata ? { metadata: { autumn_metadata_id: metadata.id } } : {}),
};
// 3. Create session with card-type fallback
const session = await createStripeSessionWithCardFallback({
stripeCli,
params: fullParams,
});
logger.info(
`Created setup checkout session for ${customer.id ?? customer.internal_id}`,
);
// 4. Link metadata to checkout session
if (metadata) {
await MetadataService.update({
db: ctx.db,
id: metadata.id,
updates: { stripe_checkout_session_id: session.id },
});
}
return { url: session.url };
};

View File

@@ -0,0 +1,69 @@
import type { SetupPaymentParamsV1 } from "@autumn/shared";
import { getOrCreateStripeCustomer } from "@/external/stripe/customers/operations/getOrCreateStripeCustomer";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { billingActions } from "@/internal/billing/v2/actions";
import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer";
import { createSetupCheckoutSession } from "./createSetupCheckoutSession";
import { setupPaymentToAttachParams } from "./setupPaymentUtils";
export interface SetupPaymentResult {
customer_id: string;
entity_id?: string;
url: string;
}
/**
* Creates a Stripe checkout session in setup mode.
* If plan_id is specified, validates the plan via preview and attaches it after setup completes.
*/
export const setupPayment = async ({
ctx,
params,
}: {
ctx: AutumnContext;
params: SetupPaymentParamsV1;
}): Promise<SetupPaymentResult> => {
const { logger } = ctx;
// 1. Get or create customer (+ Stripe customer)
const fullCustomer = await getOrCreateCustomer({
ctx,
customerId: params.customer_id,
customerData: params.customer_data,
entityId: params.entity_id,
entityData: params.entity_data,
});
await getOrCreateStripeCustomer({
ctx,
customer: fullCustomer,
});
// 2. If plan_id specified, run attach in preview mode to validate
if (params.plan_id) {
logger.info(`Setup payment: validating plan ${params.plan_id} via preview`);
const attachParams = setupPaymentToAttachParams({ params });
await billingActions.attach({
ctx,
params: attachParams,
preview: true,
});
logger.info(`Setup payment: plan ${params.plan_id} validated successfully`);
}
// 3. Create Stripe setup checkout session
const { url } = await createSetupCheckoutSession({
ctx,
customer: fullCustomer,
params,
});
return {
customer_id: fullCustomer.id ?? fullCustomer.internal_id,
entity_id: params.entity_id,
url: url ?? "",
};
};

View File

@@ -0,0 +1,14 @@
import type { AttachParamsV1, SetupPaymentParamsV1 } from "@autumn/shared";
/**
* Converts setup payment params to attach params for the preview/attach call.
*/
export const setupPaymentToAttachParams = ({
params,
}: {
params: SetupPaymentParamsV1;
}): AttachParamsV1 => ({
...params,
plan_id: params.plan_id as string,
redirect_mode: "if_required",
});

View File

@@ -0,0 +1,27 @@
import {
AffectedResource,
ApiVersion,
SetupPaymentParamsV0Schema,
SetupPaymentParamsV1Schema,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { billingActions } from "@/internal/billing/v2/actions";
export const handleSetupPaymentV2 = createRoute({
versionedBody: {
latest: SetupPaymentParamsV1Schema,
[ApiVersion.V1_Beta]: SetupPaymentParamsV0Schema,
},
resource: AffectedResource.Customer,
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
const result = await billingActions.setupPayment({
ctx,
params: body,
});
return c.json(result, 200);
},
});

View File

@@ -8,6 +8,7 @@ import { addDays } from "date-fns";
import type Stripe from "stripe"; import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli"; import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { createStripeSessionWithCardFallback } from "@/internal/billing/v2/providers/stripe/utils/checkoutSessions/createStripeSessionWithCardFallback";
import { import {
insertMetadataFromBillingPlan, insertMetadataFromBillingPlan,
updateMetadataWithCheckoutSession, updateMetadataWithCheckoutSession,
@@ -60,28 +61,15 @@ export const executeStripeCheckoutSessionAction = async ({
metadata: { autumn_metadata_id: metadata.id }, metadata: { autumn_metadata_id: metadata.id },
}; };
// 3. Create checkout session with fallback for payment method types // 3. Create checkout session with card-type fallback
let stripeCheckoutSession: Stripe.Checkout.Session; const stripeCheckoutSession = await createStripeSessionWithCardFallback({
try { stripeCli,
stripeCheckoutSession = params: fullParams,
await stripeCli.checkout.sessions.create(fullParams); });
logger.info(
`✅ Created checkout session for customer ${fullCustomer.id ?? fullCustomer.internal_id}`, logger.info(
); `Created checkout session for customer ${fullCustomer.id ?? fullCustomer.internal_id}`,
} catch (error) { );
const msg = error instanceof Error ? error.message : undefined;
if (msg?.includes("No valid payment method types")) {
stripeCheckoutSession = await stripeCli.checkout.sessions.create({
...fullParams,
payment_method_types: ["card"],
});
logger.info(
"✅ Created fallback checkout session with card payment method",
);
} else {
throw error;
}
}
// 4. Update metadata with checkout session ID // 4. Update metadata with checkout session ID
await updateMetadataWithCheckoutSession({ await updateMetadataWithCheckoutSession({

View File

@@ -0,0 +1,29 @@
import type Stripe from "stripe";
import type { createStripeCli } from "@/external/connect/createStripeCli";
/**
* Creates a Stripe checkout session, retrying with explicit `payment_method_types: ["card"]`
* if Stripe rejects automatic payment method determination.
*/
export const createStripeSessionWithCardFallback = async ({
stripeCli,
params,
}: {
stripeCli: ReturnType<typeof createStripeCli>;
params: Stripe.Checkout.SessionCreateParams;
}) => {
try {
return await stripeCli.checkout.sessions.create(params);
} catch (error) {
const msg = error instanceof Error ? error.message : undefined;
if (msg?.includes("payment method") || msg?.includes("No valid payment")) {
return stripeCli.checkout.sessions.create({
...params,
payment_method_types: ["card"],
});
}
throw error;
}
};

View File

@@ -1,3 +1,4 @@
import type { SetupPaymentParamsV1 } from "@api/billing/setupPayment/setupPaymentParamsV1";
import { import {
type AppEnv, type AppEnv,
CusProductStatus, CusProductStatus,
@@ -93,3 +94,10 @@ export type DeferredAutumnBillingPlanData = {
billingContext: BillingContext; billingContext: BillingContext;
resumeAfter?: StripeBillingStage; resumeAfter?: StripeBillingStage;
}; };
export type DeferredSetupPaymentData = {
requestId: string;
orgId: string;
env: AppEnv;
params: SetupPaymentParamsV1;
};

View File

@@ -13,11 +13,13 @@ import {
type AutumnBillingPlan, type AutumnBillingPlan,
AutumnBillingPlanSchema, AutumnBillingPlanSchema,
type DeferredAutumnBillingPlanData, type DeferredAutumnBillingPlanData,
type DeferredSetupPaymentData,
} from "./autumnBillingPlan"; } from "./autumnBillingPlan";
export type { export type {
AutumnBillingPlan, AutumnBillingPlan,
DeferredAutumnBillingPlanData, DeferredAutumnBillingPlanData,
DeferredSetupPaymentData,
StripeBillingPlan, StripeBillingPlan,
StripeCheckoutSessionAction, StripeCheckoutSessionAction,
StripeInvoiceAction, StripeInvoiceAction,

View File

@@ -9,6 +9,7 @@ export enum MetadataType {
DeferredInvoice = "deferred_invoice", DeferredInvoice = "deferred_invoice",
CheckoutSessionV2 = "checkout_session_v2", CheckoutSessionV2 = "checkout_session_v2",
SetupPaymentV2 = "setup_payment_v2",
} }
export const metadata = pgTable("metadata", { export const metadata = pgTable("metadata", {