feat: add setup payment v2
This commit is contained in:
6
server/src/external/autumn/autumnCli.ts
vendored
6
server/src/external/autumn/autumnCli.ts
vendored
@@ -30,7 +30,7 @@ import {
|
||||
type ProductItem,
|
||||
type RewardRedemption,
|
||||
type SetUsageParams,
|
||||
type SetupPaymentParamsV0,
|
||||
type SetupPaymentParamsV1,
|
||||
type TrackParams,
|
||||
type UpdateBalanceParamsV0,
|
||||
type UpdateSubscriptionV0Params,
|
||||
@@ -908,8 +908,8 @@ export class AutumnInt {
|
||||
return data;
|
||||
},
|
||||
|
||||
setupPayment: async (params: SetupPaymentParamsV0) => {
|
||||
const data = await this.post(`/setup_payment`, params);
|
||||
setupPayment: async (params: SetupPaymentParamsV1) => {
|
||||
const data = await this.post(`/billing.setup_payment`, params);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
30
server/src/external/stripe/stripeCusUtils.ts
vendored
30
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { handleCheckoutSessionMetadataV2 } from "@/external/stripe/webhookHandle
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
|
||||
import { setupCheckoutSessionCompletedContext } from "./setupCheckoutSessionCompletedContext.js";
|
||||
import { handleLegacyCheckoutSessionMetadata } from "./tasks/handleLegacyCheckoutSessionMetadata.ts/handleCheckoutSessionCompletedLegacy.js";
|
||||
import { handleSetupPaymentMetadata } from "./tasks/handleSetupPaymentMetadata.js";
|
||||
import { handleStandaloneSetupCheckout } from "./tasks/handleStandaloneSetupCheckout.js";
|
||||
import { updateCustomerFromCheckout } from "./tasks/updateCustomerFromCheckout.js";
|
||||
|
||||
@@ -24,6 +25,12 @@ export const handleStripeCheckoutSessionCompleted = async ({
|
||||
checkoutContext,
|
||||
});
|
||||
|
||||
// Setup payment with metadata (plan attachment after setup)
|
||||
await handleSetupPaymentMetadata({
|
||||
ctx,
|
||||
checkoutContext,
|
||||
});
|
||||
|
||||
// Legacy flow
|
||||
await handleLegacyCheckoutSessionMetadata({
|
||||
ctx,
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext.js";
|
||||
import type { CheckoutSessionCompletedContext } from "../setupCheckoutSessionCompletedContext.js";
|
||||
@@ -42,11 +42,9 @@ export const handleStandaloneSetupCheckout = async ({
|
||||
}
|
||||
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
const paymentMethod = await getCusPaymentMethod({
|
||||
const paymentMethod = await updateDefaultPaymentMethod({
|
||||
stripeCli,
|
||||
stripeId: stripeCustomerId,
|
||||
errorIfNone: false,
|
||||
stripeCustomerId,
|
||||
});
|
||||
|
||||
if (!paymentMethod) {
|
||||
@@ -57,13 +55,6 @@ export const handleStandaloneSetupCheckout = async ({
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
|
||||
import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
|
||||
import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js";
|
||||
import { handlePreviewUpdateSubscription } from "./v2/handlers/handlePreviewUpdateSubscription.js";
|
||||
import { handleSetupPaymentV2 } from "./v2/handlers/handleSetupPaymentV2.js";
|
||||
import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js";
|
||||
|
||||
export const billingRouter = new Hono<HonoEnv>();
|
||||
@@ -29,6 +30,7 @@ billingRpcRouter.post(
|
||||
);
|
||||
billingRpcRouter.post("/billing.attach", ...handleAttachV2);
|
||||
billingRpcRouter.post("/billing.preview_attach", ...handlePreviewAttach);
|
||||
billingRpcRouter.post("/billing.setup_payment", ...handleSetupPaymentV2);
|
||||
billingRpcRouter.post(
|
||||
"/billing.open_customer_portal",
|
||||
...handleOpenCustomerPortalV2,
|
||||
|
||||
@@ -4,10 +4,12 @@ import { legacyAttach } from "@/internal/billing/v2/actions/legacy/legacyAttach"
|
||||
import { renew } from "@/internal/billing/v2/actions/legacy/renew";
|
||||
import { updateQuantity } from "@/internal/billing/v2/actions/legacy/updateQuantity";
|
||||
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";
|
||||
|
||||
export const billingActions = {
|
||||
attach: attach,
|
||||
setupPayment: setupPayment,
|
||||
updateSubscription: updateSubscription,
|
||||
migrate: migrate,
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,7 @@ 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 {
|
||||
insertMetadataFromBillingPlan,
|
||||
updateMetadataWithCheckoutSession,
|
||||
@@ -60,28 +61,15 @@ export const executeStripeCheckoutSessionAction = async ({
|
||||
metadata: { autumn_metadata_id: metadata.id },
|
||||
};
|
||||
|
||||
// 3. Create checkout session with fallback for payment method types
|
||||
let stripeCheckoutSession: Stripe.Checkout.Session;
|
||||
try {
|
||||
stripeCheckoutSession =
|
||||
await stripeCli.checkout.sessions.create(fullParams);
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 3. Create checkout session with card-type fallback
|
||||
const stripeCheckoutSession = await createStripeSessionWithCardFallback({
|
||||
stripeCli,
|
||||
params: fullParams,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Created checkout session for customer ${fullCustomer.id ?? fullCustomer.internal_id}`,
|
||||
);
|
||||
|
||||
// 4. Update metadata with checkout session ID
|
||||
await updateMetadataWithCheckoutSession({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SetupPaymentParamsV1 } from "@api/billing/setupPayment/setupPaymentParamsV1";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
@@ -93,3 +94,10 @@ export type DeferredAutumnBillingPlanData = {
|
||||
billingContext: BillingContext;
|
||||
resumeAfter?: StripeBillingStage;
|
||||
};
|
||||
|
||||
export type DeferredSetupPaymentData = {
|
||||
requestId: string;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
params: SetupPaymentParamsV1;
|
||||
};
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
type AutumnBillingPlan,
|
||||
AutumnBillingPlanSchema,
|
||||
type DeferredAutumnBillingPlanData,
|
||||
type DeferredSetupPaymentData,
|
||||
} from "./autumnBillingPlan";
|
||||
|
||||
export type {
|
||||
AutumnBillingPlan,
|
||||
DeferredAutumnBillingPlanData,
|
||||
DeferredSetupPaymentData,
|
||||
StripeBillingPlan,
|
||||
StripeCheckoutSessionAction,
|
||||
StripeInvoiceAction,
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum MetadataType {
|
||||
|
||||
DeferredInvoice = "deferred_invoice",
|
||||
CheckoutSessionV2 = "checkout_session_v2",
|
||||
SetupPaymentV2 = "setup_payment_v2",
|
||||
}
|
||||
|
||||
export const metadata = pgTable("metadata", {
|
||||
|
||||
Reference in New Issue
Block a user