fix(vercel): 🐛 support invoice mode and logs

This commit is contained in:
amianthus
2026-05-25 17:32:27 +01:00
parent 409afb3e7d
commit 68a7c81a09
49 changed files with 3424 additions and 508 deletions

View File

@@ -54,34 +54,34 @@ export const setupInvoiceFinalizedContext = async ({
expand: ["discounts.source.coupon", "total_discount_amounts"],
});
// 2. Get subscription ID - return null if not a subscription invoice
const stripeSubscriptionId =
stripeInvoiceToStripeSubscriptionId(stripeInvoice);
if (!stripeSubscriptionId) {
logger.debug("[invoice.finalized] No subscription ID, skipping");
return null;
}
// 3. Check fullCustomer exists
if (!fullCustomer) {
logger.debug("[invoice.finalized] fullCustomer not found, skipping");
return null;
}
// 4. Get expanded stripe subscription
const stripeSubscription = await getExpandedStripeSubscription({
ctx,
subscriptionId: stripeSubscriptionId,
});
// 2. Get subscription ID. Vercel manual invoices can be subscriptionless.
const stripeSubscriptionId =
stripeInvoiceToStripeSubscriptionId(stripeInvoice);
// 5. Vercel custom-PM invoices submit out-of-band BEFORE the cus_product
// gate — the cus_product is created downstream by marketplace.invoice.paid.
let stripeSubscription: Stripe.Subscription | null = null;
if (stripeSubscriptionId) {
stripeSubscription = await getExpandedStripeSubscription({
ctx,
subscriptionId: stripeSubscriptionId,
});
}
// 3. Vercel invoices submit out-of-band before the cus_product gate.
if (isVercelInvoice({ stripeInvoice, stripeSubscription })) {
await processVercelInvoice({ ctx, stripeInvoice, stripeSubscription });
}
// 6. Get customer products by subscription ID
if (!stripeSubscriptionId || !stripeSubscription) {
logger.debug("[invoice.finalized] No subscription ID, skipping");
return null;
}
// 4. Get customer products by subscription ID
const currentCustomerProducts = fullCustomer.customer_products.filter((cp) =>
isCustomerProductOnStripeSubscription({
customerProduct: cp,

View File

@@ -5,14 +5,13 @@ import {
submitBillingDataToVercel,
submitInvoiceToVercel,
} from "@/external/vercel/misc/vercelInvoicing";
import { enrichVercelEventLogger } from "@/external/vercel/misc/vercelLogContext";
import { logVercelWebhook } from "@/external/vercel/misc/vercelMiddleware";
import { ensureVercelInvoiceModeSubscription } from "@/external/vercel/misc/vercelStripeInvoiceMode";
import { FeatureService } from "@/internal/features/FeatureService";
import { ProductService } from "@/internal/products/ProductService";
import { logCaughtError } from "@/utils/logging/logCaughtError";
/**
* Handles Vercel custom payment method invoices.
* Submits billing data and invoice to Vercel marketplace for payment processing.
*/
export const processVercelInvoice = async ({
ctx,
stripeInvoice,
@@ -24,12 +23,17 @@ export const processVercelInvoice = async ({
>;
stripeSubscription: Stripe.Subscription | null;
}): Promise<void> => {
const { stripeCli, org, env, db, logger, fullCustomer } = ctx;
const { stripeCli, org, env, db, fullCustomer } = ctx;
let { logger } = ctx;
if (stripeInvoice.amount_due <= 0) {
return;
}
if (!fullCustomer) {
return;
}
const invoiceMetadata = stripeInvoice.metadata as Record<
string,
string
@@ -46,21 +50,17 @@ export const processVercelInvoice = async ({
return;
}
const pmId =
(stripeSubscription?.default_payment_method as string | undefined) ??
fullCustomer?.processors?.vercel?.custom_payment_method_id;
logger = enrichVercelEventLogger({
ctx,
vercelEventContext: {
type: "marketplace.invoice.finalized",
id: stripeInvoice.id,
installation_id: vercelInstallationId,
external_invoice_id: stripeInvoice.id,
},
});
const vercelCtx = { ...ctx, logger };
if (!pmId) {
return;
}
const paymentMethod = await stripeCli.paymentMethods.retrieve(pmId);
if (paymentMethod.type !== "custom" || !fullCustomer) {
return;
}
// Log Vercel webhook event
logVercelWebhook({
logger,
org,
@@ -70,6 +70,16 @@ export const processVercelInvoice = async ({
},
});
// Lazily migrate legacy Vercel subscriptions to invoice mode. No-op if
// already `send_invoice` or if the subscription is canceled.
if (stripeSubscription) {
await ensureVercelInvoiceModeSubscription({
ctx: vercelCtx,
stripeCli,
subscription: stripeSubscription,
});
}
// Get product for Vercel billing
const product = await ProductService.getFull({
db,
@@ -97,6 +107,7 @@ export const processVercelInvoice = async ({
invoice: stripeInvoice,
customer: fullCustomer,
product,
testOptions: ctx.testOptions,
});
await submitInvoiceToVercel({
@@ -106,12 +117,17 @@ export const processVercelInvoice = async ({
product,
org,
features,
logger,
testOptions: ctx.testOptions,
});
} catch (error) {
logger.error("Failed to process Vercel invoice", {
logCaughtError({
logger,
message: "Failed to process Vercel invoice",
error,
data: {
error: String(error),
invoiceId: stripeInvoice.id,
installationId: vercelInstallationId,
},
});
}

View File

@@ -18,6 +18,7 @@ import { parseVercelPrepaidQuantities } from "@/external/vercel/misc/vercelInvoi
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
import { sortProductsByPrice } from "../../../internal/products/productUtils/sortProductUtils.js";
import {
isFreeProduct,
@@ -287,9 +288,12 @@ export const handleVercelListBillingPlans = createRoute({
try {
metadata = JSON.parse(metadataParam);
} catch (error: any) {
logger.warn("Failed to parse metadata query param", {
error: error.message,
metadataParam,
logCaughtError({
logger,
message: "[vercel/plans.list] Failed to parse metadata query param",
error,
data: { metadataParam },
level: "warn",
});
}
}

View File

@@ -3,6 +3,7 @@ import { sendCustomSvixEvent } from "@/external/svix/svixHelpers.js";
import { VercelResourceService } from "@/external/vercel/services/VercelResourceService.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { customerActions } from "@/internal/customers/actions/index.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
import {
type VercelResourceDeletedEvent,
VercelWebhooks,
@@ -64,9 +65,11 @@ export const handleDeleteInstallation = createRoute({
);
}
} catch (error) {
logger.error("Error deleting installation", {
logCaughtError({
logger,
message: "[vercel/installations.delete] FAILED",
error,
integrationConfigurationId,
data: { integrationConfigurationId },
});
}
return c.json(

View File

@@ -5,6 +5,7 @@ import { createCustomStripeCard } from "@/external/stripe/stripeCardUtils.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { customerActions } from "@/internal/customers/actions/index.js";
import { CusService } from "@/internal/customers/CusService.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
import {
AuthError,
getAuthorizationToken,
@@ -131,8 +132,11 @@ export const handleUpsertInstallation = createRoute({
);
}
} catch (error) {
logger.error(`Error creating vercel customer ${error}`, {
logCaughtError({
logger,
message: "[vercel/installations.upsert] FAILED",
error,
data: { integrationConfigurationId },
});
}

View File

@@ -5,6 +5,7 @@ import { VercelResourceService } from "@/external/vercel/services/VercelResource
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusService } from "@/internal/customers/CusService.js";
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
export const handleMarketplaceInvoiceCreated = async ({
ctx,
@@ -117,8 +118,13 @@ export const handleMarketplaceInvoiceCreated = async ({
resourceMetadata = resource.metadata as Record<string, any>;
}
} catch (error) {
logger.warn(`Could not fetch resource metadata: ${error}`, {
logCaughtError({
logger,
message:
"[vercel/marketplace.invoice.created] could not fetch resource metadata",
error,
data: { resourceId: vercelResourceId },
level: "warn",
});
}
}

View File

@@ -1,14 +1,28 @@
import type { FullCustomer } from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { isFirstSubscriptionInvoice } from "@/external/stripe/invoices/utils/classifyStripeInvoice.js";
import { getInvoiceSubscriptionId } from "@/external/vercel/misc/vercelInvoiceUtils.js";
import { ensureVercelInvoiceModeSubscription } from "@/external/vercel/misc/vercelStripeInvoiceMode.js";
import { VercelResourceService } from "@/external/vercel/services/VercelResourceService.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
import { ProductService } from "@/internal/products/ProductService.js";
import { VercelResourceService } from "../../services/VercelResourceService.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
/**
* Handles Vercel's `marketplace.invoice.notpaid` webhook.
*
* Replaces the legacy Custom Payment Method + Payment Records "report failed
* payment" flow. Vercel marketplace already failed to collect; we just clean
* up Autumn-side and cancel the Stripe subscription so the customer loses
* access. The invoice itself is left in its current Stripe status (not paid)
* — the Stripe ledger reflects the failure naturally.
*
* - Does NOT call `stripeCli.paymentRecords.reportPayment`.
* - Does NOT call `stripeCli.invoices.attachPayment`.
* - Sets the Vercel resource to `suspended`.
* - Expires the Autumn customer product and activates the default fallback.
* - Cancels the Stripe subscription.
*/
export const handleMarketplaceInvoiceNotPaid = async ({
ctx,
payload,
@@ -23,202 +37,159 @@ export const handleMarketplaceInvoiceNotPaid = async ({
invoiceDate: string;
};
}) => {
const { installationId, invoiceId, externalInvoiceId, invoiceDate } = payload;
const { db, org, env, logger } = ctx;
const { installationId, externalInvoiceId } = payload;
const stripeCli = createStripeCli({ org, env });
// 1. Get the invoice
const invoice = await stripeCli.invoices.retrieve(externalInvoiceId, {
expand: ["subscription"],
});
// 2. Check if already paid
// If the paid webhook already won the race, do nothing destructive.
if (invoice.status === "paid") {
logger.info("Invoice already marked as not paid, skipping");
logger.info("Invoice already marked as paid; skipping notpaid cleanup", {
data: { externalInvoiceId },
});
return;
}
// 3. Get subscription
const subscription = await stripeCli.subscriptions.retrieve(
invoice.lines.data.find(
(l) =>
l.parent?.subscription_item_details?.subscription !== null &&
l.parent?.subscription_item_details?.subscription !== undefined,
)?.parent?.subscription_item_details?.subscription as string,
);
const subscriptionId = getInvoiceSubscriptionId(invoice);
let customPaymentMethod: Stripe.PaymentMethod | null = null;
let customer: FullCustomer | null = null;
if (!subscriptionId) {
logger.warn(
"[handleMarketplaceInvoiceNotPaid] No subscription on invoice; nothing to cancel",
{ data: { externalInvoiceId } },
);
return;
}
const subscription = await stripeCli.subscriptions.retrieve(subscriptionId);
// Lazy migration before destructive ops — keeps state consistent for any
// downstream observers that read collection_method.
await ensureVercelInvoiceModeSubscription({
ctx,
stripeCli,
subscription,
});
// Resolve customer for cus_product cleanup. Failure is logged but doesn't
// block the destructive cleanup below (we still want the resource
// suspended + sub canceled even if our DB lookup misfires).
let customerInternalId: string | undefined;
try {
const partialCustomer = await CusService.getByStripeId({
ctx,
stripeId: invoice.customer as string,
});
if (!partialCustomer) {
logger.error("Customer not found for payment", {
stripeCustomerId: invoice.customer,
if (partialCustomer) {
customerInternalId = partialCustomer.internal_id;
} else {
logger.warn("[handleMarketplaceInvoiceNotPaid] Customer not found", {
data: { stripeCustomerId: invoice.customer },
});
throw new Error("Customer not found");
}
customer = await CusService.getFull({
ctx,
idOrInternalId: partialCustomer.internal_id,
} catch (error: any) {
logCaughtError({
logger,
message: "[vercel/marketplace.invoice.notpaid] Customer lookup failed",
error,
data: { stripeCustomerId: invoice.customer },
});
}
if (!customer) {
logger.error("Customer not found", {
internalCustomerId: partialCustomer.internal_id,
});
throw new Error("Customer not found");
}
// Resolve custom payment method (sub default PM may be null for
// default_incomplete subs — fall back to the customer's Vercel custom PM).
const pmId =
(subscription.default_payment_method as string | null) ??
customer.processors?.vercel?.custom_payment_method_id ??
null;
if (pmId) {
customPaymentMethod = await stripeCli.paymentMethods.retrieve(pmId);
}
const vercelBillingPlanId = subscription.metadata?.vercel_billing_plan_id;
if (!vercelBillingPlanId) {
logger.error("No vercel_billing_plan_id in subscription metadata");
throw new Error("Missing vercel_billing_plan_id");
}
const vercelResourceId = subscription.metadata?.vercel_resource_id;
if (vercelResourceId?.startsWith("vre_")) {
// Suspend the Vercel resource so the end user loses access in Vercel's UI.
const vercelResourceId = subscription.metadata?.vercel_resource_id;
if (vercelResourceId?.startsWith("vre_")) {
try {
await VercelResourceService.update({
db,
resourceId: vercelResourceId,
installationId,
orgId: org.id,
env,
updates: {
status: "suspended",
updates: { status: "suspended" },
});
} catch (error: any) {
logCaughtError({
logger,
message:
"[vercel/marketplace.invoice.notpaid] Could not suspend resource",
error,
data: { resourceId: vercelResourceId },
level: "warn",
});
}
}
// Expire the Autumn cus_product and activate the default fallback. This
// mirrors the legacy first-invoice failure path but runs for renewals
// too — payment failed at any point means the customer should lose paid
// access.
if (customerInternalId) {
try {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerInternalId,
});
if (fullCustomer) {
const existingCusProducts =
await customerProductRepo.getByStripeSubId({
db,
stripeSubId: subscription.id,
orgId: org.id,
env,
});
if (existingCusProducts.length > 0) {
await customerProductActions.expireAndActivateDefault({
ctx,
customerProduct: existingCusProducts[0],
fullCustomer,
});
} else {
logger.info(
"[handleMarketplaceInvoiceNotPaid] No cus_product to expire",
{ data: { subscriptionId: subscription.id } },
);
}
}
} catch (error: any) {
logCaughtError({
logger,
message:
"[vercel/marketplace.invoice.notpaid] Failed to expire cus_product",
error,
data: {
customerInternalId,
subscriptionId: subscription.id,
},
});
}
const product = await ProductService.getFull({
db,
orgId: org.id,
env,
idOrInternalId: vercelBillingPlanId,
});
if (!product) {
logger.error("Product not found", {
billingPlanId: vercelBillingPlanId,
});
throw new Error("Product not found");
}
} catch (error: any) {
logger.error("❌ Failed to create customer product", {
error: error.message,
});
// Continue anyway - we still need to report payment
}
// Expire optimistically-provisioned cus_product on first-invoice failure.
// Must run outside the broad catch — swallowing this leaves active access after payment failure.
if (customer && isFirstSubscriptionInvoice(invoice)) {
const existingCusProducts = await customerProductRepo.getByStripeSubId({
db,
stripeSubId: subscription.id,
orgId: org.id,
env,
});
if (existingCusProducts.length > 0) {
await customerProductActions.expireAndActivateDefault({
ctx,
customerProduct: existingCusProducts[0],
fullCustomer: customer,
});
}
}
if (!customPaymentMethod) {
throw new Error(
"Cannot resolve custom payment method for failed-invoice payment record",
);
}
// 5. Report failed payment to Stripe via Payment Records API
// This marks the payment as "failed" and allows Stripe to mark the invoice as not paid
const paymentRecord = await stripeCli.paymentRecords.reportPayment({
amount_requested: {
value: invoice.amount_due,
currency: invoice.currency,
},
payment_method_details: {
payment_method: customPaymentMethod.id,
},
customer_details: {
customer: invoice.customer as string,
},
initiated_at: Math.floor(new Date(invoiceDate).getTime() / 1000),
customer_presence: "off_session",
processor_details: {
type: "custom",
custom: {
payment_reference: invoiceId,
},
},
outcome: "failed",
failed: {
failed_at: Math.floor(Date.now() / 1000),
},
});
// 6. Attach payment record to invoice
let invoiceLikelyPaid = false;
// Cancel the Stripe subscription. We deliberately do NOT mark the invoice
// paid — Vercel reported it as unpaid, so the Stripe ledger should keep
// reflecting that.
try {
await stripeCli.invoices.attachPayment(externalInvoiceId, {
payment_record: paymentRecord.id,
});
await stripeCli.subscriptions.cancel(subscription.id);
} catch (error: any) {
if (error.code === "resource_already_exists") {
// Already attached from handleMarketplaceInvoicePaid race
} else if (
typeof error?.message === "string" &&
error.message.includes(
"You cannot attach a payment to a draft, paid, or voided invoice",
)
) {
// Race: the paid webhook already transitioned the invoice. The
// subscription is no longer "failed-first-invoice" — re-check the
// invoice status and bail out without cancelling a possibly-paid sub.
invoiceLikelyPaid = true;
// `resource_missing` means the sub is already gone — fine, idempotent.
if (error?.code === "resource_missing") {
logger.info(
"Invoice transitioned to paid/voided before failed-payment attach",
{ data: { externalInvoiceId } },
);
} else {
throw error;
}
}
if (invoiceLikelyPaid) {
// Re-fetch authoritative invoice state before doing anything destructive.
const latest = await stripeCli.invoices.retrieve(externalInvoiceId);
if (latest.status === "paid") {
logger.info(
"Skipping subscription cancel — invoice is paid (paid-webhook won the race)",
{ data: { externalInvoiceId, subscriptionId: subscription.id } },
"[handleMarketplaceInvoiceNotPaid] Subscription already canceled",
{ data: { subscriptionId: subscription.id } },
);
return;
}
// Otherwise (voided / open) fall through — cancellation is still correct.
logCaughtError({
logger,
message:
"[vercel/marketplace.invoice.notpaid] Failed to cancel subscription",
error,
data: { subscriptionId: subscription.id },
});
throw error;
}
await stripeCli.subscriptions.cancel(subscription.id);
};

View File

@@ -1,4 +1,3 @@
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import {
isFirstSubscriptionInvoice,
@@ -7,10 +6,15 @@ import {
import { sendUsageAndReset } from "@/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js";
import { getInvoiceSubscriptionId } from "@/external/vercel/misc/vercelInvoiceUtils.js";
import { provisionVercelCusProduct } from "@/external/vercel/misc/vercelProvisioning.js";
import {
ensureVercelInvoiceModeSubscription,
markVercelInvoicePaidOutOfBand,
} from "@/external/vercel/misc/vercelStripeInvoiceMode.js";
import { VercelResourceService } from "@/external/vercel/services/VercelResourceService.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusService } from "@/internal/customers/CusService.js";
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
export const handleMarketplaceInvoicePaid = async ({
ctx,
@@ -27,7 +31,7 @@ export const handleMarketplaceInvoicePaid = async ({
};
}) => {
const { db, org, env, logger } = ctx;
const { installationId, invoiceId, externalInvoiceId, invoiceDate } = payload;
const { installationId, externalInvoiceId } = payload;
const stripeCli = createStripeCli({ org, env });
@@ -42,12 +46,19 @@ export const handleMarketplaceInvoicePaid = async ({
const subscriptionId = getInvoiceSubscriptionId(invoice);
let customPaymentMethod: Stripe.PaymentMethod | null = null;
if (subscriptionId) {
const subscription = await stripeCli.subscriptions.retrieve(subscriptionId);
try {
const subscription =
await stripeCli.subscriptions.retrieve(subscriptionId);
// Lazy migration: bring legacy `charge_automatically` Vercel subs
// onto invoice mode the first time they're touched.
await ensureVercelInvoiceModeSubscription({
ctx,
stripeCli,
subscription,
});
const partialCustomer = await CusService.getByStripeId({
ctx,
stripeId: invoice.customer as string,
@@ -63,21 +74,6 @@ export const handleMarketplaceInvoicePaid = async ({
throw new Error("Customer not found");
}
// Resolve custom payment method. Prefer the sub's default PM if set,
// otherwise fall back to the customer's Vercel-bound custom PM.
// V2's `default_incomplete` flow does NOT persist the PM to the sub,
// so the fallback is the normal path.
const pmId =
(subscription.default_payment_method as string | null) ??
customer.processors?.vercel?.custom_payment_method_id ??
null;
if (!pmId) {
throw new Error(
"Cannot resolve custom payment method for Vercel invoice (no sub default PM and no customer custom PM)",
);
}
customPaymentMethod = await stripeCli.paymentMethods.retrieve(pmId);
const vercelBillingPlanId = subscription.metadata?.vercel_billing_plan_id;
if (!vercelBillingPlanId) {
logger.error("No vercel_billing_plan_id in subscription metadata");
@@ -98,8 +94,13 @@ export const handleMarketplaceInvoicePaid = async ({
updates: { status: "ready" },
});
} catch (error) {
logger.warn(`Could not update resource status to ready: ${error}`, {
logCaughtError({
logger,
message:
"[vercel/marketplace.invoice.paid] could not update resource status to ready",
error,
data: { resourceId: vercelResourceId },
level: "warn",
});
}
}
@@ -132,8 +133,13 @@ export const handleMarketplaceInvoicePaid = async ({
resourceMetadata = resource.metadata as Record<string, any>;
}
} catch (error) {
logger.warn(`Could not fetch resource metadata: ${error}`, {
logCaughtError({
logger,
message:
"[vercel/marketplace.invoice.paid] could not fetch resource metadata",
error,
data: { resourceId: vercelResourceId },
level: "warn",
});
}
}
@@ -188,87 +194,25 @@ export const handleMarketplaceInvoicePaid = async ({
});
}
} catch (error: any) {
logger.error("❌ Failed to handle Vercel invoice paid", {
error: error.message,
});
}
} else {
const partialCustomer = await CusService.getByStripeId({
ctx,
stripeId: invoice.customer as string,
});
const customPmId =
partialCustomer?.processors?.vercel?.custom_payment_method_id;
if (!customPmId) {
logger.error(
"[handleMarketplaceInvoicePaid] No subscription on invoice and no Vercel custom PM on customer; cannot report payment",
{
data: {
externalInvoiceId,
stripeCustomerId: invoice.customer,
},
logCaughtError({
logger,
message: "[vercel/marketplace.invoice.paid] FAILED",
error,
data: {
externalInvoiceId,
installationId,
},
);
throw new Error(
"Cannot resolve payment method for non-subscription Vercel invoice",
);
}
customPaymentMethod = await stripeCli.paymentMethods.retrieve(customPmId);
}
if (!customPaymentMethod) {
throw new Error("Failed to resolve custom payment method");
}
const paymentRecord = await stripeCli.paymentRecords.reportPayment({
amount_requested: {
value: invoice.amount_due,
currency: invoice.currency,
},
payment_method_details: {
payment_method: customPaymentMethod.id,
},
customer_details: {
customer: invoice.customer as string,
},
initiated_at: Math.floor(new Date(invoiceDate).getTime() / 1000),
customer_presence: "off_session",
processor_details: {
type: "custom",
custom: {
payment_reference: invoiceId,
},
},
outcome: "guaranteed",
guaranteed: {
guaranteed_at: Math.floor(Date.now() / 1000),
},
});
try {
await stripeCli.invoices.attachPayment(externalInvoiceId, {
payment_record: paymentRecord.id,
});
} catch (error: any) {
if (error.code === "resource_already_exists") {
logger.info("Payment record already attached to invoice");
} else if (
typeof error?.message === "string" &&
error.message.includes(
"You cannot attach a payment to a draft, paid, or voided invoice",
)
) {
// Race with Vercel marketplace: invoice transitioned to paid/voided
// before we attached our payment record. The payment is already
// recorded by some other path — log and continue.
logger.info("Invoice transitioned to paid/voided before attach", {
data: { externalInvoiceId },
});
} else {
throw error;
}
}
// Whether or not we had a subscription, Vercel reported the invoice as
// paid. Mark the Stripe invoice paid out of band so Stripe stays in sync.
// This is idempotent — already-paid invoices short-circuit inside the
// helper.
await markVercelInvoicePaidOutOfBand({
ctx,
stripeCli,
invoice,
});
};

View File

@@ -11,6 +11,7 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
import { ProductService } from "@/internal/products/ProductService.js";
import { generateId } from "@/utils/genUtils.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
import {
type VercelResourceCreatedEvent,
VercelWebhooks,
@@ -38,7 +39,7 @@ export const handleCreateResource = createRoute({
handler: async (c) => {
const { orgId, env, integrationConfigurationId } = c.req.param();
const ctx = c.get("ctx");
const { db, org, fullCustomer: customer } = ctx;
const { db, org, logger, fullCustomer: customer } = ctx;
const { productId, name, metadata, billingPlanId } = c.req.valid("json");
if (!customer) {
@@ -244,7 +245,12 @@ export const handleCreateResource = createRoute({
return c.json(buildResourceResponse(product));
} catch (error) {
console.error(error);
logCaughtError({
logger,
message: "[vercel/resources.create] FAILED",
error,
data: { integrationConfigurationId },
});
return c.json(
{
error: {

View File

@@ -1,8 +1,10 @@
import { AppEnv, Scopes } from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { sendCustomSvixEvent } from "@/external/svix/svixHelpers.js";
import { VercelResourceService } from "@/external/vercel/services/VercelResourceService.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
import {
type VercelResourceDeletedEvent,
VercelWebhooks,
@@ -10,7 +12,6 @@ import {
/**
* DELETE /v1/installations/{integrationConfigurationId}/resources/{resourceId}
* Delete (mark as uninstalled) a resource
*/
export const handleDeleteResource = createRoute({
scopes: [Scopes.Public],
@@ -18,38 +19,92 @@ export const handleDeleteResource = createRoute({
const { orgId, env, integrationConfigurationId, resourceId } =
c.req.param();
const ctx = c.get("ctx");
const { db, org, fullCustomer: customer } = ctx;
const stripeCli = createStripeCli({ org, env: env as AppEnv });
const { db, org, logger, fullCustomer: customer } = ctx;
await VercelResourceService.delete({
db,
resourceId,
installationId: integrationConfigurationId,
orgId,
env: env as AppEnv,
});
await sendCustomSvixEvent({
appId:
org.processor_configs?.vercel?.svix?.[
env === AppEnv.Live ? "live_id" : "sandbox_id"
] ?? "",
org,
env: env as AppEnv,
eventType: VercelWebhooks.ResourceDeleted,
data: {
resource: {
id: resourceId,
},
installation_id: integrationConfigurationId,
} satisfies VercelResourceDeletedEvent,
});
customer?.customer_products.forEach(async (x) => {
x.subscription_ids?.forEach(async (subId) => {
await stripeCli.subscriptions.cancel(subId);
try {
await VercelResourceService.delete({
db,
resourceId,
installationId: integrationConfigurationId,
orgId,
env: env as AppEnv,
});
});
} catch (error) {
logCaughtError({
logger,
message:
"[vercel/resources.delete] failed to mark resource uninstalled in DB",
error,
data: { resourceId },
level: "warn",
});
}
try {
await sendCustomSvixEvent({
appId:
org.processor_configs?.vercel?.svix?.[
env === AppEnv.Live ? "live_id" : "sandbox_id"
] ?? "",
org,
env: env as AppEnv,
eventType: VercelWebhooks.ResourceDeleted,
data: {
resource: {
id: resourceId,
},
installation_id: integrationConfigurationId,
} satisfies VercelResourceDeletedEvent,
});
} catch (error) {
logCaughtError({
logger,
message:
"[vercel/resources.delete] failed to send svix ResourceDeleted event",
error,
data: { resourceId },
level: "warn",
});
}
// Constructing the Stripe client itself throws when the org has no
// Stripe connection — must be inside the guard, not above it.
let stripeCli: Stripe | null = null;
try {
stripeCli = createStripeCli({ org, env: env as AppEnv });
} catch (error) {
logCaughtError({
logger,
message:
"[vercel/resources.delete] cannot build Stripe client; skipping subscription cancels",
error,
data: { orgId, env },
level: "warn",
});
}
if (stripeCli) {
for (const customerProduct of customer?.customer_products ?? []) {
for (const subId of customerProduct.subscription_ids ?? []) {
try {
await stripeCli.subscriptions.cancel(subId);
} catch (error: any) {
logCaughtError({
logger,
message:
"[vercel/resources.delete] subscription cancel failed; continuing",
error,
data: {
subId,
code: error?.code,
status: error?.statusCode,
},
level: "warn",
});
}
}
}
}
return c.body(null, 204);
},

View File

@@ -1,25 +1,35 @@
/**
* Raw body capture middleware for webhook signature validation
*
* Captures the raw request body before Hono parses it into JSON.
* This is required for HMAC-SHA1 signature validation, which must
* operate on the exact bytes that were sent.
*
* The raw body is stored in the Hono context as 'rawBody' for
* downstream middleware to access.
*/
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
/** Captures raw bytes so webhook signature validation sees the exact body. */
export const captureRawBody = async (c: any, next: any) => {
// Read the raw body before any other middleware consumes it
const rawBody = await c.req.text();
c.set("rawBody", rawBody);
let parsedBody: unknown;
let hasParsedBody = false;
// Override req.json() to use the cached raw body
c.req.json = async () => {
try {
return JSON.parse(rawBody);
} catch {
return {};
if (hasParsedBody) {
return parsedBody;
}
try {
parsedBody = JSON.parse(rawBody);
} catch (error) {
const ctx = c.get?.("ctx");
logCaughtError({
logger: ctx?.logger,
message: "[vercel/rawBody] Failed to parse cached raw body as JSON",
error,
data: { rawBodyLength: rawBody.length },
level: "warn",
});
parsedBody = {};
}
hasParsedBody = true;
return parsedBody;
};
await next();

View File

@@ -2,6 +2,7 @@ import { AppEnv, type Organization } from "@autumn/shared";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { JWTExpired, JWTInvalid } from "jose/errors";
import { z } from "zod/v4";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
const JWKS = createRemoteJWKSet(
new URL(`https://marketplace.vercel.com/.well-known/jwks`),
@@ -24,6 +25,45 @@ export const OidcClaimsSchema = z.object({
export type OidcClaims = z.infer<typeof OidcClaimsSchema>;
/**
* Test-mode short-circuit. Outside production, accept a sentinel bearer token
* of the form `test_oidc:<installationId>` (or `test_oidc:` for no
* installation) and synthesize matching claims. This lets integration tests
* exercise the OIDC-protected routes without going through Vercel's JWKS.
*/
const TEST_OIDC_PREFIX = "test_oidc:";
const synthesizeTestClaims = ({
token,
org,
env,
}: {
token: string;
org: Organization;
env: AppEnv;
}): OidcClaims | null => {
if (process.env.NODE_ENV === "production") return null;
if (!token.startsWith(TEST_OIDC_PREFIX)) return null;
const installationId = token.slice(TEST_OIDC_PREFIX.length) || null;
const audience =
env === AppEnv.Live
? (org.processor_configs?.vercel?.client_integration_id ??
"test_client_id")
: (org.processor_configs?.vercel?.sandbox_client_id ?? "test_client_id");
const nowSeconds = Math.floor(Date.now() / 1000);
return {
sub: `test:${installationId ?? "no_install"}`,
aud: audience,
iss: "https://marketplace.vercel.com",
exp: nowSeconds + 60 * 60,
iat: nowSeconds,
account_id: `acc_test_${installationId ?? "no_install"}`,
installation_id: installationId,
};
};
export async function verifyToken({
token,
org,
@@ -33,6 +73,9 @@ export async function verifyToken({
org: Organization;
env: AppEnv;
}): Promise<OidcClaims> {
const testClaims = synthesizeTestClaims({ token, org, env });
if (testClaims) return testClaims;
try {
const { payload: claims } = await jwtVerify<OidcClaims>(token, JWKS, {
clockTolerance: 5,
@@ -45,15 +88,39 @@ export async function verifyToken({
: org.processor_configs?.vercel?.sandbox_client_id;
if (claims.aud !== clientIntegrationId) {
// Dump both sides so an audience mismatch is debuggable end-to-end.
// Common causes: org config has the wrong env's client_id, or the
// integration was re-installed and the org's `sandbox_client_id`
// hasn't been refreshed.
console.warn("[vercel/oidc] Invalid audience", {
env,
"token.aud (from Vercel)": claims.aud,
"configured (from org.processor_configs.vercel)": clientIntegrationId,
...Object.fromEntries(
Object.entries(claims).map(([key, value]) => [`token.${key}`, value]),
),
});
throw new AuthError("Invalid audience");
}
if (claims.iss !== "https://marketplace.vercel.com") {
console.warn(
"[vercel/oidc] Invalid issuer",
"\n token.iss:",
claims.iss,
"\n expected: https://marketplace.vercel.com",
);
throw new AuthError("Invalid issuer");
}
return claims;
} catch (err) {
logCaughtError({
message: "[vercel/oidc] JWT verification failed",
error: err,
level: "warn",
});
if (err instanceof JWTExpired) {
throw new AuthError("Auth expired");
}
@@ -122,34 +189,57 @@ export class AuthError extends Error {}
* 5. Store validated claims in context
*/
export const vercelOidcAuthMiddleware = async (c: any, next: any) => {
const { org, env } = c.get("ctx");
const { org, env, logger } = c.get("ctx");
const authHeader = c.req.header("authorization");
const authType = c.req.header("x-vercel-auth");
const path = c.req.path;
const method = c.req.method;
// Helper so every 401/403 here screams to console before the response is
// returned. The 401/403 body's `code` is useful but doesn't show up in
// dev server logs unless you tail responses, so we dump the reason here.
const reject = (code: string, status: 401 | 403, reason?: string) => {
console.warn({
message: `[vercel/oidc] REJECT ${status} ${code}`,
method,
path,
reason: reason ?? "(none)",
authType: authType ?? "(absent)",
authHeader: authHeader ? `${authHeader.slice(0, 20)}...` : "(absent)",
});
const error = status === 401 ? "Unauthorized" : "Forbidden";
return c.json({ error, code }, status);
};
// Validate required headers
if (!authHeader) {
return c.json({ error: "Unauthorized", code: "missing_auth_header" }, 401);
return reject("missing_auth_header", 401);
}
if (!authType) {
return c.json(
{ error: "Unauthorized", code: "missing_auth_type_header" },
401,
);
return reject("missing_auth_type_header", 401);
}
if (!["user", "system"].includes(authType)) {
return c.json({ error: "Unauthorized", code: "invalid_auth_type" }, 401);
return reject("invalid_auth_type", 401, `got "${authType}"`);
}
// Extract and verify token
let token: string;
try {
token = getAuthorizationToken(authHeader);
} catch (_error) {
return c.json(
{ error: "Unauthorized", code: "invalid_auth_header_format" },
} catch (error: any) {
logCaughtError({
logger,
message: "[vercel/oidc] Invalid authorization header",
error,
data: { method, path },
level: "warn",
});
return reject(
"invalid_auth_header_format",
401,
error?.message ?? String(error),
);
}
@@ -158,6 +248,21 @@ export const vercelOidcAuthMiddleware = async (c: any, next: any) => {
try {
claims = await verifyToken({ token, org, env });
} catch (error: any) {
logCaughtError({
logger,
message: "[vercel/oidc] verifyToken threw",
error,
data: {
method,
path,
env,
configuredAud:
env === AppEnv.Live
? org?.processor_configs?.vercel?.client_integration_id
: org?.processor_configs?.vercel?.sandbox_client_id,
},
level: "warn",
});
return c.json(
{
error: `Unauthorized: ${error.message}`,
@@ -172,7 +277,6 @@ export const vercelOidcAuthMiddleware = async (c: any, next: any) => {
// Validate installation_id based on auth type and route
const integrationConfigurationId = c.req.param("integrationConfigurationId");
const path = c.req.path;
// For /v1/products/* routes, integrationConfigurationId is a product config ID, not installation ID
// So we skip installation_id validation for these routes
@@ -183,18 +287,20 @@ export const vercelOidcAuthMiddleware = async (c: any, next: any) => {
if (authType === "user") {
// User auth: always validate installation_id matches URL param
if (claims.installation_id !== integrationConfigurationId) {
return c.json(
{ error: "Forbidden", code: "installation_id_mismatch" },
return reject(
"installation_id_mismatch",
403,
`user-auth claims.installation_id=${claims.installation_id} vs url=${integrationConfigurationId}`,
);
}
} else if (authType === "system") {
// System auth: validate installation_id only if not null
if (claims.installation_id !== null) {
if (claims.installation_id !== integrationConfigurationId) {
return c.json(
{ error: "Forbidden", code: "installation_id_mismatch" },
return reject(
"installation_id_mismatch",
403,
`system-auth claims.installation_id=${claims.installation_id} vs url=${integrationConfigurationId}`,
);
}
}

View File

@@ -1,8 +1,6 @@
import type { Context, Next } from "hono";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
import { addVercelCustomerToContext } from "./vercelLogContext.js";
/** TTL for vercel installation ID -> customer ID cache (1 day) */
export const VERCEL_INSTALLATION_CACHE_TTL_SECONDS = 24 * 60 * 60;
@@ -30,32 +28,13 @@ export const vercelCustomerMiddleware = async (
const ctx = c.get("ctx");
const { integrationConfigurationId } = c.req.param();
const customer = await CusService.getByVercelId({
ctx,
vercelInstallationId: integrationConfigurationId,
});
const customerId = customer?.id || customer?.internal_id || undefined;
if (customerId) {
const { ctx: routedCtx } = getCtxWithCustomerRedis({
ctx: {
...ctx,
fullCustomer: customer ?? undefined,
customerId,
rolloutSnapshot: computeRolloutSnapshot({
orgId: ctx.org.id,
customerId,
}),
},
customerId,
});
c.set("ctx", routedCtx);
} else {
c.set("ctx", {
...ctx,
fullCustomer: customer ?? undefined,
});
}
c.set(
"ctx",
await addVercelCustomerToContext({
ctx,
vercelInstallationId: integrationConfigurationId,
}),
);
await next();
};

View File

@@ -11,17 +11,31 @@ import {
} from "@autumn/shared";
import { Vercel } from "@vercel/sdk";
import type Stripe from "stripe";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import { buildInvoiceMemo } from "@/internal/invoices/invoiceMemoUtils.js";
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
import {
getVercelSdkServerURL,
type VercelSdkTestOptions,
} from "./vercelSdkOptions.js";
/**
* Vercel Marketplace Payment Flow:
*
* 1. Subscription created with collection_method: "charge_automatically" and custom payment method
* 2. invoice.finalized → handleInvoiceFinalized calls submitBillingDataToVercel() then submitInvoiceToVercel()
* 3. Vercel processes payment asynchronously
* 4. marketplace.invoice.paid → handleMarketplaceInvoicePaid creates cus_product and reports payment to Stripe
* 5. Invoice marked as paid → Subscription becomes active
* 1. Resource creation provisions a Stripe subscription in invoice mode
* (`collection_method: "send_invoice"`, `days_until_due: 30`). The
* subscription auto-activates regardless of first-invoice status.
* 2. `invoice.finalized` → `processVercelInvoice` submits the finalized
* invoice to Vercel via `submitBillingDataToVercel` +
* `submitInvoiceToVercel`.
* 3. Vercel marketplace collects payment from the end customer out of band.
* 4. `marketplace.invoice.paid` → `handleMarketplaceInvoicePaid` marks the
* Stripe invoice paid via `invoices.pay(id, { paid_out_of_band: true })`.
* Stripe Payment Records / `attachPayment` are intentionally NOT used.
* 5. `marketplace.invoice.notpaid` → `handleMarketplaceInvoiceNotPaid`
* suspends the Vercel resource, expires the Autumn cus_product (activating
* the default fallback), and cancels the Stripe subscription.
*/
/**
@@ -33,14 +47,17 @@ export const submitBillingDataToVercel = async ({
invoice,
customer,
product,
testOptions,
}: {
installationId: string;
invoice: Stripe.Invoice;
customer: Customer;
product: FullProduct;
testOptions?: VercelSdkTestOptions;
}) => {
const vercel = new Vercel({
bearerToken: customer.processors?.vercel?.access_token,
serverURL: getVercelSdkServerURL(testOptions),
});
const firstLineItem = invoice.lines.data[0];
@@ -102,6 +119,8 @@ export const submitInvoiceToVercel = async ({
product,
org,
features,
logger,
testOptions,
}: {
installationId: string;
invoice: Stripe.Invoice;
@@ -109,9 +128,12 @@ export const submitInvoiceToVercel = async ({
product: FullProduct;
org: Organization;
features: Feature[];
logger?: Logger;
testOptions?: VercelSdkTestOptions;
}) => {
const vercel = new Vercel({
bearerToken: customer.processors?.vercel?.access_token,
serverURL: getVercelSdkServerURL(testOptions),
});
const price = productV2ToBasePrice({ product: mapToProductV2({ product }) });
@@ -136,7 +158,15 @@ export const submitInvoiceToVercel = async ({
if (org.config.invoice_memos) {
try {
memo = await buildInvoiceMemo({ org, product, features });
} catch (_) {}
} catch (error) {
logCaughtError({
logger,
message: "[vercel/invoice] Failed to build invoice memo",
error,
data: { invoiceId: invoice.id, productId: product.id },
level: "warn",
});
}
}
return await vercel.marketplace.submitInvoice({

View File

@@ -0,0 +1,112 @@
import { AuthType } from "@autumn/shared";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
import {
addAppContextToLogs,
addVercelEventToLogs,
} from "@/utils/logging/addContextToLogs";
import type { LogVercelEventContext } from "@/utils/logging/loggerTypes.js";
export const buildVercelEventContext = (
event: Record<string, any>,
): LogVercelEventContext => {
const payload = event?.payload ?? {};
const resource = payload?.resource ?? event?.resource;
return {
id: event?.id,
type: event?.type,
installation_id:
payload?.installationId ??
event?.installation_id ??
event?.installationId ??
undefined,
invoice_id: payload?.invoiceId,
external_invoice_id: payload?.externalInvoiceId,
resource_id:
payload?.resourceId ?? resource?.id ?? event?.resourceId ?? undefined,
};
};
export const enrichVercelAppLogger = ({
ctx,
}: {
ctx: AutumnContext;
}) => {
const customerId = ctx.customerId;
const fullSubjectBucket =
customerId && ctx.rolloutSnapshot?.customerBucket !== undefined
? (ctx.rolloutSnapshot.customerBucket ?? undefined)
: undefined;
return addAppContextToLogs({
logger: ctx.logger,
appContext: {
org_id: ctx.org?.id,
org_slug: ctx.org?.slug,
env: ctx.env,
auth_type: AuthType.Vercel,
customer_id: customerId,
entity_id: ctx.entityId,
api_version: ctx.apiVersion?.semver,
scopes: ctx.scopes,
full_subject_bucket: fullSubjectBucket,
full_subject_rollout_enabled: customerId
? isFullSubjectRolloutEnabled({ ctx })
: undefined,
},
});
};
export const enrichVercelEventLogger = ({
ctx,
vercelEventContext,
}: {
ctx: AutumnContext;
vercelEventContext: LogVercelEventContext;
}) => {
return addVercelEventToLogs({
logger: ctx.logger,
vercelEventContext,
});
};
export const addVercelCustomerToContext = async ({
ctx,
vercelInstallationId,
}: {
ctx: AutumnContext;
vercelInstallationId: string;
}): Promise<AutumnContext> => {
const customer = await CusService.getByVercelId({
ctx,
vercelInstallationId,
});
const customerId = customer?.id || customer?.internal_id || undefined;
const nextCtx = {
...ctx,
fullCustomer: customer ?? undefined,
...(customerId
? {
customerId,
rolloutSnapshot: computeRolloutSnapshot({
orgId: ctx.org?.id,
customerId,
}),
}
: {}),
};
const routedCtx = customerId
? getCtxWithCustomerRedis({ ctx: nextCtx, customerId }).ctx
: nextCtx;
return {
...routedCtx,
logger: enrichVercelAppLogger({ ctx: routedCtx }),
};
};

View File

@@ -7,7 +7,13 @@ import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { addAppContextToLogs } from "@/utils/logging/addContextToLogs";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
import {
addVercelCustomerToContext,
buildVercelEventContext,
enrichVercelAppLogger,
enrichVercelEventLogger,
} from "./vercelLogContext.js";
export const vercelSeederMiddleware = async (
c: Context<HonoEnv>,
@@ -38,6 +44,7 @@ export const vercelSeederMiddleware = async (
org,
env,
features,
authType: AuthType.Vercel,
rolloutSnapshot: computeRolloutSnapshot({
orgId: org?.id,
customerId: ctx.customerId,
@@ -48,16 +55,7 @@ export const vercelSeederMiddleware = async (
? getCtxWithCustomerRedis({ ctx: nextCtx }).ctx
: nextCtx;
routedCtx.logger = addAppContextToLogs({
logger: routedCtx.logger,
appContext: {
org_id: routedCtx.org?.id,
org_slug: routedCtx.org?.slug,
env: routedCtx.env,
auth_type: AuthType.Vercel,
api_version: routedCtx.apiVersion?.semver,
},
});
routedCtx.logger = enrichVercelAppLogger({ ctx: routedCtx });
c.set("ctx", routedCtx);
@@ -71,18 +69,42 @@ export const logVercelWebhook = ({
}: {
logger: Logger;
org: Organization;
event: { type: string; id: string };
event: { type?: string; id?: string };
}) => {
const eventType = event.type ?? "unknown";
const eventId = event.id ?? "unknown";
logger.info(
`${chalk.magenta("VERCEL").padEnd(18)} ${event.type.padEnd(30)} ${org.slug} | ${event.id}`,
`${chalk.magenta("VERCEL").padEnd(18)} ${eventType.padEnd(30)} ${org.slug} | ${eventId}`,
);
};
export const vercelLogMiddleware = async (c: Context<HonoEnv>, next: Next) => {
const { logger, org } = c.get("ctx");
let ctx = c.get("ctx");
const body = await c.req.json();
const vercelEventContext = buildVercelEventContext(body);
logVercelWebhook({ logger, org, event: body });
if (vercelEventContext.installation_id) {
try {
ctx = await addVercelCustomerToContext({
ctx,
vercelInstallationId: vercelEventContext.installation_id,
});
} catch (error) {
logCaughtError({
logger: ctx.logger,
message: "[vercel/webhook] Failed to enrich customer log context",
error,
data: { installationId: vercelEventContext.installation_id },
level: "warn",
});
}
}
ctx.logger = enrichVercelEventLogger({ ctx, vercelEventContext });
c.set("ctx", ctx);
logVercelWebhook({ logger: ctx.logger, org: ctx.org, event: body });
await next();
};

View File

@@ -7,8 +7,8 @@ import type {
import { ErrCode, RecaseError } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type Stripe from "stripe";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { parseVercelPrepaidQuantities } from "@/external/vercel/misc/vercelInvoicing.js";
import { ensureVercelInvoiceModeSubscription } from "@/external/vercel/misc/vercelStripeInvoiceMode.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { attach } from "@/internal/billing/v2/actions/attach/attach";
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
@@ -18,8 +18,9 @@ import { ProductService } from "@/internal/products/ProductService.js";
* Provisions a Vercel customer product via V2 attach.
*
* Idempotency-checks via existing Stripe subscription metadata, then calls V2
* `attach()` with internal-only `contextOverride` flags for Vercel's custom
* payment-method flow.
* `attach()` in invoice mode (`send_invoice`). Vercel marketplace moves the
* money; Stripe is the ledger. The legacy custom-payment-method path is no
* longer required for provisioning.
*/
export const provisionVercelCusProduct = async ({
ctx,
@@ -77,9 +78,17 @@ export const provisionVercelCusProduct = async ({
env,
});
// Lazily migrate existing Vercel subs to invoice mode before short-circuit.
// Idempotent — no-op if already `send_invoice`.
const migratedSub = await ensureVercelInvoiceModeSubscription({
ctx,
stripeCli,
subscription: existingSub,
});
if (existingCusProducts.length > 0) {
return {
subscription: existingSub,
subscription: migratedSub,
cusProduct: existingCusProducts[0],
product,
};
@@ -99,8 +108,8 @@ export const provisionVercelCusProduct = async ({
"[provisionVercelCusProduct] Existing sub without cus_product — skipping (likely in-flight provision elsewhere)",
{
data: {
subscriptionId: existingSub.id,
subscriptionStatus: existingSub.status,
subscriptionId: migratedSub.id,
subscriptionStatus: migratedSub.status,
installationId: integrationConfigurationId,
},
},
@@ -112,24 +121,7 @@ export const provisionVercelCusProduct = async ({
});
}
// 3. Resolve custom payment method
const customPaymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: customer.processor.id,
errorIfNone: false,
typeFilter: org.processor_configs?.vercel?.custom_payment_method?.[env],
});
if (!customPaymentMethod) {
throw new RecaseError({
message:
"No payment method found. Customer may need to reinstall integration.",
code: ErrCode.PaymentMethodNotFound,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// 4. Parse prepaid options
// 3. Parse prepaid options
const optionsList =
metadata && Object.keys(metadata).length > 0
? parseVercelPrepaidQuantities({
@@ -139,7 +131,7 @@ export const provisionVercelCusProduct = async ({
})
: [];
// 5. Call V2 attach
// 4. Call V2 attach in invoice mode
const featureQuantities = optionsList.map((opt) => ({
feature_id: opt.feature_id,
quantity: opt.quantity,
@@ -149,17 +141,19 @@ export const provisionVercelCusProduct = async ({
// `setupFullCustomerContext` reloads it with `withEntities: true, withSubs: true`,
// which downstream usage merging requires. Overriding would skip that load and
// cause `mergeEntitiesWithExistingUsages` to throw on `undefined` entities.
//
// We pass a stripeBillingContext WITHOUT a payment method. Vercel handles
// money movement out of band; Stripe is just the ledger. Invoice mode
// (`send_invoice`) auto-activates the subscription regardless of first
// invoice status, so we don't need default_incomplete + custom-PM gymnastics.
const contextOverride: BillingContextOverride = {
productContext: {
fullProduct: product,
},
stripeBillingContext: {
stripeCustomer,
paymentMethod: customPaymentMethod,
stripeDiscounts: [],
},
paymentBehaviorIntent: "default_incomplete",
shouldFinalizeFirstInvoice: true,
// We ARE the Vercel origin platform — opt out of the "billed outside Stripe" guard.
skipCustomPaymentMethodGuard: true,
};
@@ -171,10 +165,19 @@ export const provisionVercelCusProduct = async ({
plan_id: billingPlanId,
redirect_mode: "if_required",
feature_quantities: featureQuantities,
// Vercel marketplace handles payment async (custom PM + Payment Records).
// We must provision the cus_product immediately on resource creation;
// don't wait for invoice.paid before inserting the autumn billing plan.
// Top-level enable_plan_immediately keeps the Autumn cus_product
// active on attach. Without it we'd wait for the first invoice to be
// paid before granting access, which breaks Vercel UX.
enable_plan_immediately: true,
// Invoice mode puts the Stripe subscription into `send_invoice`
// collection (no auto-charge), finalizes the first invoice so the
// finalize webhook fires and we submit the invoice to Vercel, and
// keeps the sub active without needing a payment intent.
invoice_mode: {
enabled: true,
finalize: true,
enable_plan_immediately: true,
},
metadata: {
vercel_installation_id: integrationConfigurationId,
vercel_billing_plan_id: billingPlanId,
@@ -186,7 +189,7 @@ export const provisionVercelCusProduct = async ({
skipAutumnCheckout: true,
});
// 6. Extract subscription and cus_product
// 5. Extract subscription and cus_product
const subscription = result.billingResult?.stripe?.stripeSubscription ?? null;
if (subscription) {

View File

@@ -0,0 +1,16 @@
export type VercelSdkTestOptions = {
mockVercelApi?: boolean;
};
/**
* Only tests opt into the local Vercel SDK mock; dev/manual flows hit Vercel.
*/
export const getVercelSdkServerURL = (
testOptions?: VercelSdkTestOptions,
): string | undefined => {
if (process.env.NODE_ENV === "production") return undefined;
if (testOptions?.mockVercelApi !== true) return undefined;
const base = process.env.BETTER_AUTH_URL;
if (!base) return undefined;
return `${base.replace(/\/$/, "")}/__test/vercel/api`;
};

View File

@@ -20,9 +20,18 @@ import { AppEnv } from "@autumn/shared";
export const vercelSignatureMiddleware = async (c: any, next: any) => {
const { org, env, logger } = c.get("ctx");
const signature = c.req.header("x-vercel-signature");
const path = c.req.path;
const method = c.req.method;
// Validate signature header presence
if (!signature) {
console.warn(
"[vercel/sig] REJECT 401 missing_signature",
"\n method:",
method,
"\n path:",
path,
);
logger.warn("Missing X-Vercel-Signature header");
return c.json({ error: "Unauthorized", code: "missing_signature" }, 401);
}
@@ -30,6 +39,13 @@ export const vercelSignatureMiddleware = async (c: any, next: any) => {
// Get raw body from context (captured by rawBodyMiddleware)
const rawBody = c.get("rawBody");
if (!rawBody) {
console.error(
"[vercel/sig] REJECT 500 missing_raw_body — captureRawBody middleware did not run before signature middleware",
"\n method:",
method,
"\n path:",
path,
);
logger.error("Raw body not found in context");
return c.json(
{ error: "Internal Server Error", code: "missing_raw_body" },
@@ -44,6 +60,17 @@ export const vercelSignatureMiddleware = async (c: any, next: any) => {
: org.processor_configs?.vercel?.sandbox_client_secret;
if (!clientSecret) {
console.error(
"[vercel/sig] REJECT 500 missing_client_secret",
"\n method:",
method,
"\n path:",
path,
"\n env:",
env,
"\n org_id:",
org?.id,
);
logger.error("Vercel client secret not configured", { env });
return c.json(
{ error: "Internal Server Error", code: "missing_client_secret" },
@@ -59,12 +86,28 @@ export const vercelSignatureMiddleware = async (c: any, next: any) => {
.digest("hex");
// Perform constant-time comparison to prevent timing attacks
const isValid = crypto.timingSafeEqual(
Buffer.from(signature, "utf-8"),
Buffer.from(computedSignature, "utf-8"),
);
// (timingSafeEqual throws if buffers differ in length, so guard first)
const sigBuf = Buffer.from(signature, "utf-8");
const compBuf = Buffer.from(computedSignature, "utf-8");
const isValid =
sigBuf.length === compBuf.length && crypto.timingSafeEqual(sigBuf, compBuf);
if (!isValid) {
console.warn(
"[vercel/sig] REJECT 401 invalid_signature",
"\n method:",
method,
"\n path:",
path,
"\n env:",
env,
"\n signature_received:",
`${signature.slice(0, 12)}...`,
"\n signature_computed:",
`${computedSignature.slice(0, 12)}...`,
"\n raw_body_length:",
rawBodyBuffer.length,
);
logger.warn("Webhook signature validation failed", {
env,
has_signature: true,

View File

@@ -0,0 +1,216 @@
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
/**
* Vercel-specific Stripe invoice-mode helpers.
*
* Vercel marketplace customers can't use Stripe's third-party payment
* processing (Custom Payment Methods + Payment Records) on every Stripe plan.
* Instead, Vercel subscriptions/invoices use Stripe as the ledger
* (`collection_method: "send_invoice"`) while Vercel itself moves money.
*
* These helpers are idempotent and safe to call from webhooks.
*/
/**
* Detect whether a Stripe subscription belongs to a Vercel installation.
*/
export const isVercelStripeSubscription = (
subscription: Stripe.Subscription | null | undefined,
): boolean => {
if (!subscription) return false;
return Boolean(subscription.metadata?.vercel_installation_id);
};
/**
* Ensure a Vercel-owned Stripe subscription is in invoice mode
* (`send_invoice` + `days_until_due: 30`).
*
* - No-op if already `send_invoice`.
* - Best-effort clears `default_payment_method` (`default_payment_method`
* is not typed as nullable on `SubscriptionUpdateParams`, so we cast to
* `unknown` and treat any rejection as non-fatal).
* - Skips silently if the subscription is canceled.
*/
export const ensureVercelInvoiceModeSubscription = async ({
ctx,
stripeCli,
subscription,
}: {
ctx: AutumnContext;
stripeCli: Stripe;
subscription: Stripe.Subscription;
}): Promise<Stripe.Subscription> => {
const { logger } = ctx;
if (!isVercelStripeSubscription(subscription)) {
return subscription;
}
if (
subscription.status === "canceled" ||
subscription.status === "incomplete_expired"
) {
return subscription;
}
if (subscription.collection_method === "send_invoice") {
return subscription;
}
try {
// `default_payment_method` is not typed as nullable on the update
// params, but Stripe accepts null at runtime to clear it. Failure to
// clear is non-fatal — invoice mode itself is the durable fix.
const updated = await stripeCli.subscriptions.update(subscription.id, {
collection_method: "send_invoice",
days_until_due: 30,
default_payment_method: null as unknown as string | undefined,
});
logger.info("[vercelInvoiceMode] migrated subscription to send_invoice", {
data: {
subscriptionId: subscription.id,
previousCollectionMethod: subscription.collection_method,
},
});
return updated;
} catch (error: any) {
logCaughtError({
logger,
message:
"[vercelInvoiceMode] failed to clear default_payment_method; retrying invoice mode migration",
error,
data: { subscriptionId: subscription.id },
level: "warn",
});
// Retry without clearing default_payment_method if Stripe rejected it.
// The collection_method flip is the important part.
try {
const updated = await stripeCli.subscriptions.update(subscription.id, {
collection_method: "send_invoice",
days_until_due: 30,
});
logger.warn(
"[vercelInvoiceMode] migrated subscription to send_invoice; could not clear default_payment_method",
{
data: {
subscriptionId: subscription.id,
error: error?.message,
},
},
);
return updated;
} catch (retryError: any) {
logCaughtError({
logger,
message:
"[vercelInvoiceMode] failed to migrate subscription to send_invoice",
error: retryError,
data: { subscriptionId: subscription.id },
});
return subscription;
}
}
};
/**
* Mark a Stripe invoice paid out of band (Vercel handled the money movement).
*
* Idempotent:
* - Skips if the invoice is already `paid`.
* - Skips if the invoice is `void`/`voided`/`uncollectible`/`deleted`.
* - Logs and continues for known race/already-paid states.
*/
export const markVercelInvoicePaidOutOfBand = async ({
ctx,
stripeCli,
invoice,
}: {
ctx: AutumnContext;
stripeCli: Stripe;
invoice: Stripe.Invoice;
}): Promise<Stripe.Invoice> => {
const { logger } = ctx;
if (!invoice.id) {
return invoice;
}
if (invoice.status === "paid") {
logger.info("[vercelInvoiceMode] invoice already paid, skipping", {
data: { invoiceId: invoice.id },
});
return invoice;
}
if (invoice.status === "void" || invoice.status === "uncollectible") {
logger.info("[vercelInvoiceMode] invoice not payable, skipping", {
data: { invoiceId: invoice.id, status: invoice.status },
});
return invoice;
}
if (invoice.status === "draft") {
// `invoices.pay` requires a finalized invoice. The finalize webhook is
// what triggers Vercel submission; by the time `marketplace.invoice.paid`
// lands, the invoice should be `open`. If it's still `draft`, log and
// skip — finalization will happen in a separate webhook.
logger.warn(
"[vercelInvoiceMode] invoice still draft when paid webhook arrived",
{ data: { invoiceId: invoice.id } },
);
return invoice;
}
try {
return await stripeCli.invoices.pay(invoice.id, {
paid_out_of_band: true,
});
} catch (error: any) {
const message: string = error?.message ?? "";
if (
message.includes("already paid") ||
message.includes("This invoice is already paid")
) {
logCaughtError({
logger,
message: "[vercelInvoiceMode] invoice already paid (race)",
error,
data: { invoiceId: invoice.id },
level: "warn",
});
return invoice;
}
if (
message.includes("voided") ||
message.includes("uncollectible") ||
message.includes("Cannot pay invoice")
) {
logCaughtError({
logger,
message:
"[vercelInvoiceMode] invoice transitioned to non-payable state",
error,
data: { invoiceId: invoice.id },
level: "warn",
});
return invoice;
}
logCaughtError({
logger,
message: "[vercelInvoiceMode] failed to mark invoice paid out of band",
error,
data: { invoiceId: invoice.id },
});
throw error;
}
};

View File

@@ -0,0 +1,151 @@
/**
* Test-only mock of the subset of Vercel's marketplace API that we hit from
* `processVercelInvoice`. Mounted at `/__test/vercel/api/*` and consumed
* only by tests that set `ctx.testOptions.mockVercelApi`.
*
* Each captured call is pushed into a Redis list keyed by the integration
* configuration id (`__test:vercel:captures:{installationId}`). Integration
* tests in another process read that list to assert what the SDK sent.
*
* In production this router is not mounted (see `initHono.ts`).
*/
import { Hono } from "hono";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
export const VERCEL_TEST_CAPTURE_PREFIX = "__test:vercel:captures:";
const CAPTURE_TTL_SECONDS = 600; // 10 min
type CapturedCall = {
method: string;
path: string;
installationId: string;
body: unknown;
receivedAt: number;
};
const recordCapture = async (call: CapturedCall) => {
const redis = resolveRedisV2();
const key = `${VERCEL_TEST_CAPTURE_PREFIX}${call.installationId}`;
await redis.rpush(key, JSON.stringify(call));
await redis.expire(key, CAPTURE_TTL_SECONDS);
};
const parseJsonOrEmpty = async (c: any, route: string) => {
try {
return await c.req.json();
} catch (error) {
logCaughtError({
logger: c.get?.("ctx")?.logger,
message: "[vercel/test-api] Failed to parse request body as JSON",
error,
data: { route },
level: "warn",
});
return {};
}
};
export const vercelTestApiRouter = new Hono<HonoEnv>();
// POST /v1/installations/:integrationConfigurationId/billing
vercelTestApiRouter.post(
"/v1/installations/:integrationConfigurationId/billing",
async (c) => {
const installationId = c.req.param("integrationConfigurationId");
const body = await parseJsonOrEmpty(c, "submitBillingData");
await recordCapture({
method: "POST",
path: `/v1/installations/${installationId}/billing`,
installationId,
body,
receivedAt: Date.now(),
});
// Vercel's submitBillingData returns 201 with an empty body (the SDK
// matches `M.nil(201, z.void())` — see
// `node_modules/@vercel/sdk/esm/funcs/marketplaceSubmitBillingData.js`).
// Returning anything else makes the SDK throw
// `Unexpected Status or Content-Type`.
return c.body(null, 201);
},
);
// POST /v1/installations/:integrationConfigurationId/billing/invoices
vercelTestApiRouter.post(
"/v1/installations/:integrationConfigurationId/billing/invoices",
async (c) => {
const installationId = c.req.param("integrationConfigurationId");
const body = await parseJsonOrEmpty(c, "submitInvoice");
const externalId = (body as { externalId?: string })?.externalId;
await recordCapture({
method: "POST",
path: `/v1/installations/${installationId}/billing/invoices`,
installationId,
body,
receivedAt: Date.now(),
});
// Vercel's submitInvoice returns the created invoice id + price.
return c.json(
{
invoiceId: `vi_test_${Date.now()}`,
validationErrors: [],
totalUsd: (body as { items?: { total: string }[] })?.items
?.map((item) => Number(item?.total ?? 0))
.reduce((sum, n) => sum + n, 0)
.toFixed(2),
externalId,
},
200,
);
},
);
// Inspector endpoints used by tests --------------------------------------
// GET /__captures/:installationId → recorded calls in insertion order
vercelTestApiRouter.get("/__captures/:installationId", async (c) => {
const installationId = c.req.param("installationId");
const redis = resolveRedisV2();
const raw = await redis.lrange(
`${VERCEL_TEST_CAPTURE_PREFIX}${installationId}`,
0,
-1,
);
const captures = raw.map((entry) => {
try {
return JSON.parse(entry) as CapturedCall;
} catch (error) {
logCaughtError({
logger: c.get?.("ctx")?.logger,
message: "[vercel/test-api] Failed to parse captured call",
error,
data: { installationId },
level: "warn",
});
return null;
}
});
return c.json({ captures: captures.filter(Boolean) }, 200);
});
// DELETE /__captures/:installationId → clear (per-test teardown)
vercelTestApiRouter.delete("/__captures/:installationId", async (c) => {
const installationId = c.req.param("installationId");
const redis = resolveRedisV2();
await redis.del(`${VERCEL_TEST_CAPTURE_PREFIX}${installationId}`);
return c.json({ cleared: true }, 200);
});
// Anything else → 404 with a clear message so the SDK error surfaces
// instead of silently passing.
vercelTestApiRouter.all("*", (c) => {
return c.json(
{
error: "vercel_test_api_route_not_implemented",
method: c.req.method,
path: c.req.path,
},
404,
);
});

View File

@@ -4,6 +4,7 @@ import { handleRotateResourceSecret } from "@/external/vercel/handlers/resources
import { analyticsMiddleware } from "@/honoMiddlewares/analyticsMiddleware.js";
import { traceEnrichMiddleware } from "@/honoMiddlewares/traceMiddleware.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
import { sendCustomSvixEvent } from "../svix/svixHelpers.js";
import { handleVercelListBillingPlans } from "./handlers/handleListBillingPlans.js";
import { handleUpdateVercelBillingPlan } from "./handlers/handleUpdateBillingPlan.js";
@@ -122,7 +123,13 @@ vercelWebhookRouter.post(
let body: any;
try {
body = await c.req.json();
} catch {
} catch (parseError) {
logCaughtError({
logger,
message: "[vercel/webhook] Failed to parse request body as JSON",
error: parseError,
level: "warn",
});
body = {};
}
@@ -168,19 +175,31 @@ vercelWebhookRouter.post(
return c.json({ received: true }, 200);
}
} catch (error: any) {
logger.error("Failed to process Vercel marketplace webhook", {
eventType,
errorName: error?.name,
errorMessage: error?.message,
errorStack: error?.stack,
errorString: String(error),
logCaughtError({
logger,
message: "Failed to process Vercel marketplace webhook",
error,
data: { eventType },
});
return c.json({ error: error?.message ?? String(error) }, 500);
}
},
);
// Fallback for other methods
vercelWebhookRouter.all("/:orgId/:env/*", async (c) => {
return c.body(null, 200);
console.warn(
"[vercel/webhook] Unmapped route hit fallback",
"\n method:",
c.req.method,
"\n path:",
c.req.path,
);
return c.json(
{
error: "vercel_webhook_route_not_found",
method: c.req.method,
path: c.req.path,
},
404,
);
});

View File

@@ -127,6 +127,7 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
skipWebhooks: c.req.header("x-skip-webhooks") === "true",
keepInternalFields: c.req.header("x-strip-internal") === "false",
useReplica: c.req.header("x-use-replica") === "true",
mockVercelApi: c.req.header("x-mock-vercel-api") === "true",
},
});

View File

@@ -79,6 +79,7 @@ export type RequestContext = {
eventId?: string;
keepInternalFields?: boolean;
useReplica?: boolean;
mockVercelApi?: boolean;
};
};

View File

@@ -10,6 +10,7 @@ import { cors } from "hono/cors";
import { autumnWebhookRouter } from "./external/autumn/autumnWebhookRouter.js";
import { revenuecatWebhookRouter } from "./external/revenueCat/revenuecatWebhookRouter.js";
import { stripeWebhookRouter } from "./external/stripe/stripeWebhookRouter.js";
import { vercelTestApiRouter } from "./external/vercel/vercelTestApiRouter.js";
import { vercelWebhookRouter } from "./external/vercel/vercelWebhookRouter.js";
import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js";
import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
@@ -141,6 +142,12 @@ export const createHonoApp = () => {
app.route("/webhooks/vercel", vercelWebhookRouter);
app.route("/webhooks/revenuecat", revenuecatWebhookRouter);
// Vercel SDK test mock — mounted in dev/test, used only when
// `ctx.testOptions.mockVercelApi` points the SDK at this route.
if (process.env.NODE_ENV !== "production") {
app.route("/__test/vercel/api", vercelTestApiRouter);
}
// Public routes (no auth required)
app.route("", publicRouter);
// Debug routes (no auth, dev-only guard is inside the handler)

View File

@@ -147,10 +147,19 @@ export const setupAutoTopupContext = async ({
return null;
}
const vercelInstallationId =
fullCustomer.processors?.vercel?.installation_id;
const shouldUseInvoiceMode =
autoTopupConfig.invoice_mode === true || Boolean(vercelInstallationId);
const invoiceMode = shouldUseInvoiceMode
? { finalizeInvoice: true, enableProductImmediately: true }
: undefined;
const { stripeCus, paymentMethod, testClockFrozenTime } =
await fetchStripeCustomerForBilling({ ctx, fullCus: fullCustomer });
if (!paymentMethod) {
if (!paymentMethod && !invoiceMode) {
logger.warn(
`[setupAutoTopupContext] No payment method for customer ${stripeCus?.id}, skipping`,
);
@@ -168,10 +177,6 @@ export const setupAutoTopupContext = async ({
return null;
}
const invoiceMode = autoTopupConfig.invoice_mode
? { finalizeInvoice: true, enableProductImmediately: true }
: undefined;
return {
// BillingContext fields
fullCustomer,

View File

@@ -70,9 +70,7 @@ export const createInvoiceForBilling = async ({
: "charge_automatically";
const vercelInstallationId =
billingContext.paymentMethod?.type === "custom"
? billingContext.fullCustomer?.processors?.vercel?.installation_id
: undefined;
billingContext.fullCustomer?.processors?.vercel?.installation_id;
const invoiceMetadata = mergeStripeMetadata({
userMetadata: billingContext.userMetadata,

View File

@@ -134,22 +134,16 @@ const handlePrepaidErrors = async ({
}
};
/**
* Blocks attach attempts against customers managed by an external billing
* platform (today: Vercel marketplace).
*/
export const handleCustomPaymentMethodErrors = ({
attachParams,
}: {
attachParams: AttachParams;
}) => {
const { paymentMethod } = attachParams;
if (
paymentMethod?.type === "custom" &&
attachParams.customer.processors?.vercel?.custom_payment_method_id ===
paymentMethod?.custom?.type
) {
throw new RecaseError({
message:
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
});
} else if (attachParams.customer.processors?.vercel?.installation_id) {
if (attachParams.customer.processors?.vercel?.installation_id) {
throw new RecaseError({
message:
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
@@ -157,26 +151,22 @@ export const handleCustomPaymentMethodErrors = ({
}
};
/**
* V2 attach equivalent of `handleCustomPaymentMethodErrors`. Origin platforms
* (e.g. Vercel marketplace handlers calling attach internally) opt out of
* this guard via `contextOverride.skipCustomPaymentMethodGuard`.
*
* See the V1 docstring above for why we block on `installation_id` rather
* than on the resolved PaymentMethod shape.
*/
export const handleCustomPaymentMethodErrorsV2 = ({
billingContext,
}: {
billingContext: BillingContext;
}) => {
// Origin platforms (e.g. Vercel marketplace handlers calling attach internally)
// opt out of this guard via contextOverride.skipCustomPaymentMethodGuard.
if (billingContext.skipCustomPaymentMethodGuard) return;
const { paymentMethod } = billingContext;
if (
paymentMethod?.type === "custom" &&
billingContext.fullCustomer.processors?.vercel?.custom_payment_method_id ===
paymentMethod?.custom?.type
) {
throw new RecaseError({
message:
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
});
} else if (billingContext.fullCustomer.processors?.vercel?.installation_id) {
if (billingContext.fullCustomer.processors?.vercel?.installation_id) {
throw new RecaseError({
message:
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",

View File

@@ -2,10 +2,10 @@ import {
AppEnv,
InternalError,
type Organization,
Scopes,
UpsertVercelProcessorConfigSchema,
type VercelMarketplaceMode,
type VercelProcessorConfig,
Scopes,
} from "@autumn/shared";
import { createSvixApp } from "@server/external/svix/svixHelpers.js";
import { createSvixCli } from "@server/external/svix/svixUtils.js";
@@ -55,17 +55,15 @@ export const getVercelConfigDisplay = ({
env === AppEnv.Live
? vercelConfig.webhook_url
: vercelConfig.sandbox_webhook_url;
const customPaymentMethod =
env === AppEnv.Live
? vercelConfig.custom_payment_method?.live
: vercelConfig.custom_payment_method?.sandbox;
return {
connected: !!clientId && !!clientSecret && !!webhookUrl,
client_integration_id: mask(clientId, 3, 2),
client_secret: mask(clientSecret, 3, 2),
webhook_url: mask(webhookUrl, 8, 6),
custom_payment_method: mask(customPaymentMethod, 5, 3),
// `custom_payment_method` intentionally omitted from the display payload —
// see the no-vercel-config branch above for rationale.
custom_payment_method: undefined,
marketplace_mode: vercelConfig.marketplace_mode,
allowed_product_ids_live: vercelConfig.allowed_product_ids_live,
allowed_product_ids_sandbox: vercelConfig.allowed_product_ids_sandbox,

View File

@@ -5,6 +5,7 @@ import type {
LogRequestContext,
LogStripeEventContext,
LogTriggerContext,
LogVercelEventContext,
LogWorkflowContext,
} from "./loggerTypes.js";
@@ -38,6 +39,16 @@ export const addStripeEventToLogs = ({
return logger.child({ context: { stripe_event: stripeEventContext } });
};
export const addVercelEventToLogs = ({
logger,
vercelEventContext,
}: {
logger: Logger;
vercelEventContext: LogVercelEventContext;
}): Logger => {
return logger.child({ context: { vercel_event: vercelEventContext } });
};
export const addWorkflowToLogs = ({
logger,
workflowContext,

View File

@@ -26,6 +26,7 @@ const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([
"workflow",
"trigger",
"stripe_event",
"vercel_event",
"worker",
"extras",
"type",

View File

@@ -0,0 +1,44 @@
import type { Logger } from "@/external/logtail/logtailUtils.js";
type LogCaughtErrorLevel = "error" | "warn";
export const caughtErrorToLogFields = (error: unknown) => {
if (error instanceof Error) {
return {
errorName: error.name,
errorMessage: error.message,
errorStack: error.stack,
errorString: String(error),
};
}
return {
errorName: typeof error,
errorMessage: String(error),
errorStack: undefined,
errorString: String(error),
};
};
export const logCaughtError = ({
logger,
message,
error,
data,
level = "error",
}: {
logger?: Logger;
message: string;
error: unknown;
data?: Record<string, unknown>;
level?: LogCaughtErrorLevel;
}) => {
const logFields = caughtErrorToLogFields(error);
const consoleMethod = level === "warn" ? console.warn : console.error;
consoleMethod(message, error);
logger?.[level](message, {
...logFields,
...(data ? { data } : {}),
});
};

View File

@@ -1,6 +1,6 @@
import type { AppEnv, AuthType } from "@autumn/shared";
/** Request-level metadata - goes under context.req */
/** Request-level metadata - emits as req.* fields. */
export type LogRequestContext = {
id: string;
method: string;
@@ -21,7 +21,7 @@ export type LogRequestContext = {
name: string;
};
/** App context - org, customer, auth - goes under context.context */
/** App context - emits as context.* fields. */
export type LogAppContext = {
org_id: string;
org_slug: string;
@@ -45,6 +45,16 @@ export type LogStripeEventContext = {
object_id: string;
};
/** Vercel webhook event context */
export type LogVercelEventContext = {
id?: string;
type?: string;
installation_id?: string;
invoice_id?: string;
external_invoice_id?: string;
resource_id?: string;
};
/** Background worker context */
export type LogWorkflowContext = {
id: string;

View File

@@ -22,7 +22,7 @@ import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"
import {
expectWebhookSuccess,
RevenueCatWebhookClient,
} from "./revenue-cat-webhook-client.js";
} from "./utils/revenue-cat-webhook-client.js";
const testCase = "rc1";
const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345";

View File

@@ -0,0 +1,434 @@
/**
* Shared setup helpers for the Vercel integration test suite.
*
* Companion to `vercel-webhook-client.ts`. These functions:
* - Configure the test org with Vercel processor credentials.
* - Seed an Autumn customer with `processors.vercel` set (no CPM — the new
* flow doesn't require one).
* - Seed a `vercel_resources` row.
* - Read recorded Vercel SDK calls back from the dev server's test mock
* (`/__test/vercel/api`) via the inspector endpoints.
* - Build the test OIDC bearer token (`test_oidc:<installationId>`) that
* `verifyToken` synthesizes claims for outside production.
* - Hand-craft legacy `charge_automatically` Vercel Stripe subscriptions for
* lazy-migration coverage.
*/
// Worktree dev servers use https://wt<N>-api.localhost with self-signed
// certs (see `bun dw identify`). The test process opts out of TLS validation
// for any https request it makes — scoped to the integration-test runtime,
// not the running server.
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === undefined) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
import {
type AppEnv,
type ExternalProcessors,
type FullCustomer,
ProcessorType,
VercelMarketplaceMode,
} from "@autumn/shared";
import { vercelResources } from "@shared/models/processorModels/vercelModels/vercelResourcesTable.js";
import type Stripe from "stripe";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { createStripeCustomer } from "@/external/stripe/customers/index.js";
import { CusService } from "@/internal/customers/CusService.js";
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
import { customerActions } from "@/internal/customers/actions/index.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
// ──────────────────────────────────────────────────────────────────────────
// Org config
// ──────────────────────────────────────────────────────────────────────────
export interface VercelTestOrgConfig {
clientId?: string;
clientSecret?: string;
customPaymentMethodTypeId?: string;
}
/**
* Idempotently writes the SANDBOX slot of the Vercel processor config used by
* the test suite. No-op when the config already matches the requested values.
*
* IMPORTANT: this writes only to the `sandbox_*` slots and to fields that are
* env-agnostic (`marketplace_mode`). It MUST NOT touch `client_integration_id`
* / `client_secret` / `webhook_url` — those are the LIVE-env values and may
* be the org's real production Vercel credentials. Overwriting them breaks
* the live OIDC audience check (a previous bug here ate the live values and
* caused 401s on live-env webhook DELETEs).
*
* NOTE: `sandbox_client_secret` is the HMAC secret used to sign sandbox
* marketplace webhooks — `VercelWebhookClient` must be constructed with the
* same value.
*/
export const setupVercelOrg = async (
ctx: TestContext,
{
clientId = "test_vercel_client_id",
clientSecret = "test_vercel_client_secret",
customPaymentMethodTypeId,
}: VercelTestOrgConfig = {},
) => {
const existing = ctx.org.processor_configs?.vercel;
if (
existing?.sandbox_client_id === clientId &&
existing?.sandbox_client_secret === clientSecret &&
existing?.sandbox_webhook_url &&
existing?.marketplace_mode &&
(!customPaymentMethodTypeId ||
existing?.custom_payment_method?.sandbox === customPaymentMethodTypeId)
) {
return;
}
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: {
processor_configs: {
...ctx.org.processor_configs,
vercel: {
...(existing ?? {}),
// LIVE slots intentionally NOT touched — preserve whatever
// real credentials the org has for production Vercel.
client_integration_id:
existing?.client_integration_id ?? clientId,
client_secret: existing?.client_secret ?? clientSecret,
webhook_url:
existing?.webhook_url ?? "https://test.example/webhook",
// SANDBOX slots are owned by the test suite.
sandbox_client_id: clientId,
sandbox_client_secret: clientSecret,
sandbox_webhook_url:
existing?.sandbox_webhook_url ?? "https://test.example/webhook",
marketplace_mode:
existing?.marketplace_mode ?? VercelMarketplaceMode.Installation,
...(customPaymentMethodTypeId
? {
custom_payment_method: {
...(existing?.custom_payment_method ?? {}),
sandbox: customPaymentMethodTypeId,
},
}
: {}),
},
},
},
});
// Refresh ctx.org so downstream reads see the updated config in this run.
const refreshed = await OrgService.getBySlug({
db: ctx.db,
slug: ctx.org.slug!,
});
if (refreshed) ctx.org = refreshed;
};
// ──────────────────────────────────────────────────────────────────────────
// Customer seeding
// ──────────────────────────────────────────────────────────────────────────
export interface SeedVercelCustomerOptions {
ctx: TestContext;
customerId: string;
installationId: string;
accessToken?: string;
accountId?: string;
/** Set to a CPM `pm_*` id to simulate a legacy onboarder. Default: omitted. */
customPaymentMethodId?: string;
}
export interface SeedVercelCustomerResult {
customer: FullCustomer;
stripeCustomer: Stripe.Customer;
internalCustomerId: string;
}
/**
* Creates an Autumn customer pre-wired with `processors.vercel` and a backing
* Stripe customer. Mirrors what `handleUpsertInstallation` does but skips the
* (legacy) CPM creation by default — that's the whole point of the refactor.
*
* If `customPaymentMethodId` is provided, sets it on the customer's
* `processors.vercel.custom_payment_method_id` so we can prove the new code
* paths still behave correctly for legacy customers.
*/
export const seedVercelCustomer = async ({
ctx,
customerId,
installationId,
accessToken = "test_vercel_access_token",
accountId = `acc_test_${installationId}`,
customPaymentMethodId,
}: SeedVercelCustomerOptions): Promise<SeedVercelCustomerResult> => {
// Best-effort delete so the test is re-runnable.
try {
const existing = await CusService.getByVercelId({
ctx,
vercelInstallationId: installationId,
});
if (existing) {
await CusService.deleteByInternalId({
db: ctx.db,
internalId: existing.internal_id,
orgId: ctx.org.id,
env: ctx.env,
});
}
} catch {
// ignore
}
const processorsValue: ExternalProcessors = {
vercel: {
installation_id: installationId,
access_token: accessToken,
account_id: accountId,
...(customPaymentMethodId
? { custom_payment_method_id: customPaymentMethodId }
: {}),
},
};
const created = await customerActions.createWithDefaults({
ctx,
customerId,
customerData: {
email: `${customerId}@example.com`,
name: customerId,
processors: processorsValue,
},
});
const stripeCustomer = await createStripeCustomer({
ctx,
customer: created,
});
await CusService.update({
ctx,
idOrInternalId: created.id || created.internal_id,
update: {
processor: {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
},
processors: processorsValue,
},
});
// Tag the Stripe customer with the installation id so any code path that
// reads it via stripe metadata behaves like prod.
await ctx.stripeCli.customers.update(stripeCustomer.id, {
metadata: { vercel_installation_id: installationId },
});
await deleteCachedFullCustomer({
ctx,
customerId: created.id ?? created.internal_id,
});
const refreshed = await CusService.getFull({
ctx,
idOrInternalId: created.internal_id,
});
return {
customer: refreshed,
stripeCustomer,
internalCustomerId: refreshed.internal_id,
};
};
// ──────────────────────────────────────────────────────────────────────────
// Resource seeding
// ──────────────────────────────────────────────────────────────────────────
export interface SeedVercelResourceOptions {
ctx: TestContext;
resourceId: string;
installationId: string;
name?: string;
status?: "ready" | "suspended" | "pending" | "uninstalled";
metadata?: Record<string, unknown>;
}
export const seedVercelResource = async ({
ctx,
resourceId,
installationId,
name = `test resource ${resourceId}`,
status = "ready",
metadata = {},
}: SeedVercelResourceOptions) => {
// Wipe any prior rows for this id (idempotent test reruns).
await ctx.db
.delete(vercelResources)
.where(
require_eq(vercelResources.id, resourceId, ctx.org.id, ctx.env, ctx.db),
);
await ctx.db.insert(vercelResources).values({
id: resourceId,
org_id: ctx.org.id,
env: ctx.env,
installation_id: installationId,
name,
status,
metadata,
});
};
// Local helper to avoid pulling drizzle's full query builder into this file;
// we delete by id+org+env which is unique enough for tests.
const require_eq = (
idCol: typeof vercelResources.id,
id: string,
orgId: string,
env: AppEnv,
_db: TestContext["db"],
) => {
const { and, eq } = require("drizzle-orm");
return and(
eq(idCol, id),
eq(vercelResources.org_id, orgId),
eq(vercelResources.env, env),
);
};
// ──────────────────────────────────────────────────────────────────────────
// OIDC test token
// ──────────────────────────────────────────────────────────────────────────
/**
* Builds the bearer token that `verifyToken` (in `vercelAuth.ts`) accepts
* outside production. The middleware synthesizes OIDC claims with
* `installation_id` set to whatever follows the `test_oidc:` prefix, so
* routes that check `claims.installation_id === :integrationConfigurationId`
* pass when the URL and the token agree.
*/
export const buildTestOidcToken = (installationId: string): string =>
`test_oidc:${installationId}`;
export const buildTestOidcHeaders = (
installationId: string,
authType: "user" | "system" = "user",
) => ({
authorization: `Bearer ${buildTestOidcToken(installationId)}`,
"x-vercel-auth": authType,
"content-type": "application/json",
});
// ──────────────────────────────────────────────────────────────────────────
// Vercel SDK mock captures
// ──────────────────────────────────────────────────────────────────────────
export interface CapturedVercelCall {
method: string;
path: string;
installationId: string;
body: any;
receivedAt: number;
}
const captureBaseUrl = () =>
`${(process.env.BETTER_AUTH_URL ?? "http://localhost:8080").replace(/\/$/, "")}/__test/vercel/api`;
export const readVercelCaptures = async (
installationId: string,
): Promise<CapturedVercelCall[]> => {
const res = await fetch(
`${captureBaseUrl()}/__captures/${encodeURIComponent(installationId)}`,
);
if (!res.ok) {
throw new Error(
`readVercelCaptures: ${res.status} for installation=${installationId}`,
);
}
const json = (await res.json()) as { captures: CapturedVercelCall[] };
return json.captures ?? [];
};
export const clearVercelCaptures = async (
installationId: string,
): Promise<void> => {
await fetch(
`${captureBaseUrl()}/__captures/${encodeURIComponent(installationId)}`,
{ method: "DELETE" },
).catch(() => {
// best-effort
});
};
/**
* Polls the capture inspector until at least one call matching `predicate`
* has been recorded, or `timeoutMs` elapses. Useful for the
* "wait for the real Stripe webhook" pattern.
*/
export const waitForVercelCapture = async ({
installationId,
predicate,
timeoutMs = 15000,
intervalMs = 500,
}: {
installationId: string;
predicate: (call: CapturedVercelCall) => boolean;
timeoutMs?: number;
intervalMs?: number;
}): Promise<CapturedVercelCall | null> => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const captures = await readVercelCaptures(installationId).catch(() => []);
const match = captures.find(predicate);
if (match) return match;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return null;
};
// ──────────────────────────────────────────────────────────────────────────
// Legacy Stripe subscription (for lazy-migration tests)
// ──────────────────────────────────────────────────────────────────────────
export interface CreateLegacyVercelSubscriptionOptions {
ctx: TestContext;
stripeCustomerId: string;
installationId: string;
billingPlanId: string;
productId: string;
resourceId?: string;
/** Stripe Price id (recurring). Caller must create the price beforehand. */
stripePriceId: string;
}
/**
* Creates a Stripe subscription with `collection_method: "charge_automatically"`
* and the Vercel metadata that the (old) provisioning path used to write. Tests
* use this to assert the lazy-migration helper flips the subscription to
* `send_invoice`.
*/
export const createLegacyVercelSubscription = async ({
ctx,
stripeCustomerId,
installationId,
billingPlanId,
productId,
resourceId,
stripePriceId,
}: CreateLegacyVercelSubscriptionOptions): Promise<Stripe.Subscription> => {
return await ctx.stripeCli.subscriptions.create({
customer: stripeCustomerId,
items: [{ price: stripePriceId }],
collection_method: "charge_automatically",
payment_behavior: "default_incomplete",
metadata: {
vercel_installation_id: installationId,
vercel_billing_plan_id: billingPlanId,
vercel_product_id: productId,
vercel_resource_id: resourceId ?? installationId,
},
expand: ["latest_invoice"],
});
};

View File

@@ -0,0 +1,136 @@
import crypto from "node:crypto";
import type { AppEnv } from "@autumn/shared";
// Worktree dev servers use https://wt<N>-api.localhost with self-signed
// certs. The test process opts out of TLS validation for its own fetches.
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === undefined) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
type VercelMarketplaceEventType =
| "marketplace.invoice.created"
| "marketplace.invoice.paid"
| "marketplace.invoice.notpaid";
export interface VercelMarketplaceInvoicePayload {
installationId: string;
invoiceId: string;
externalInvoiceId: string;
invoiceTotal: string;
period: { start: string; end: string };
invoiceDate: string;
}
interface VercelWebhookClientConfig {
orgId: string;
env: AppEnv;
/**
* HMAC-SHA1 secret. Must match `org.processor_configs.vercel.sandbox_client_secret`
* (or `.client_secret` for live).
*/
clientSecret: string;
baseUrl?: string;
}
/**
* Mock client for sending Vercel marketplace webhook events in tests.
*
* Mirrors the shape of `RevenueCatWebhookClient` (see
* `revenue-cat-webhook-client.ts`). Generates the `x-vercel-signature`
* (HMAC-SHA1 over the raw JSON body) that
* `vercelSignatureMiddleware.ts` verifies against the org's client secret.
*/
export class VercelWebhookClient {
private orgId: string;
private env: AppEnv;
private clientSecret: string;
private baseUrl: string;
constructor({
orgId,
env,
clientSecret,
baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:8080",
}: VercelWebhookClientConfig) {
this.orgId = orgId;
this.env = env;
this.clientSecret = clientSecret;
this.baseUrl = baseUrl;
}
private get webhookUrl(): string {
return `${this.baseUrl.replace(/\/$/, "")}/webhooks/vercel/${this.orgId}/${this.env}`;
}
private async sendEvent({
type,
payload,
}: {
type: VercelMarketplaceEventType;
payload: VercelMarketplaceInvoicePayload;
}): Promise<{ response: Response; data: unknown }> {
const body = JSON.stringify({ type, payload });
const signature = crypto
.createHmac("sha1", this.clientSecret)
.update(Buffer.from(body, "utf-8"))
.digest("hex");
const response = await fetch(this.webhookUrl, {
method: "POST",
body,
headers: {
"Content-Type": "application/json",
"x-vercel-signature": signature,
},
});
let data: unknown;
try {
data = await response.json();
} catch {
data = null;
}
return { response, data };
}
async invoiceCreated(payload: VercelMarketplaceInvoicePayload) {
return this.sendEvent({ type: "marketplace.invoice.created", payload });
}
async invoicePaid(payload: VercelMarketplaceInvoicePayload) {
return this.sendEvent({ type: "marketplace.invoice.paid", payload });
}
async invoiceNotPaid(payload: VercelMarketplaceInvoicePayload) {
return this.sendEvent({ type: "marketplace.invoice.notpaid", payload });
}
}
/**
* Asserts that a Vercel webhook responded with 2xx and a recognizably-success
* body. The marketplace router uses `{ received: true }` or `{ success: true }`
* depending on event type, so accept either.
*/
export const expectVercelWebhookSuccess = ({
response,
data,
}: {
response: Response;
data: unknown;
}) => {
if (response.status !== 200) {
throw new Error(
`Expected Vercel webhook response status 200, got ${response.status}. Data: ${JSON.stringify(data)}`,
);
}
const ok =
(data as { success?: boolean })?.success === true ||
(data as { received?: boolean })?.received === true;
if (!ok) {
throw new Error(
`Expected Vercel webhook response { success | received: true }, got ${JSON.stringify(
data,
)}`,
);
}
};

View File

@@ -0,0 +1,150 @@
/**
* Vercel attach guard
*
* Proves that `handleCustomPaymentMethodErrorsV2` blocks normal Autumn API
* attach attempts against Vercel-managed customers, independent of whether a
* legacy Stripe Custom Payment Method is configured.
*
* The previous implementation had two branches: a (broken) CPM-shape match
* AND an `installation_id` fallback. The CPM match never fired in practice
* because `custom_payment_method_id` is a PM instance id (`pm_*`) and
* `paymentMethod.custom.type` is a CPM type id (`cpmt_*`). The refactor drops
* the CPM branch — the guard now fires purely on `processors.vercel.installation_id`.
*/
import { expect, test } from "bun:test";
import chalk from "chalk";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import {
seedVercelCustomer,
setupVercelOrg,
} from "./utils/vercel-test-helpers";
const TEST_CASE = "vac";
// ─────────────────────────────────────────────────────────────────────────────
// TEST 1: New Vercel onboarder (no CPM stored) — attach blocked
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-attach-guard: new Vercel customer (no custom_payment_method_id) is blocked from external attach",
)}`,
async () => {
const customerId = `${TEST_CASE}-no-cpm-customer`;
const installationId = `icfg_${TEST_CASE}_no_cpm`;
await setupVercelOrg(ctx);
await seedVercelCustomer({
ctx,
customerId,
installationId,
// No customPaymentMethodId — this represents a NEW onboarder under
// the refactored flow.
});
const pro = products.pro({
id: `${TEST_CASE}-no-cpm-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
// Ensure the product exists in the test org. initScenario without
// `s.customer` would create one; instead we seed products only.
const { autumnV1 } = await initScenario({
customerId: `${customerId}-helper`,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [pro] }),
],
actions: [],
});
await expect(
autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
}),
).rejects.toThrow(/billed outside of Stripe|origin platform/i);
},
);
// ─────────────────────────────────────────────────────────────────────────────
// TEST 2: Legacy Vercel customer (has CPM stored) — still blocked
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-attach-guard: legacy Vercel customer (with custom_payment_method_id) is also blocked",
)}`,
async () => {
const customerId = `${TEST_CASE}-legacy-cpm-customer`;
const installationId = `icfg_${TEST_CASE}_legacy_cpm`;
await setupVercelOrg(ctx);
await seedVercelCustomer({
ctx,
customerId,
installationId,
customPaymentMethodId: "pm_legacy_stub",
});
const pro = products.pro({
id: `${TEST_CASE}-legacy-cpm-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1 } = await initScenario({
customerId: `${customerId}-helper`,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [pro] }),
],
actions: [],
});
await expect(
autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
}),
).rejects.toThrow(/billed outside of Stripe|origin platform/i);
},
);
// ─────────────────────────────────────────────────────────────────────────────
// TEST 3: Non-Vercel customer — attach proceeds normally
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-attach-guard: non-Vercel customer is unaffected and attach succeeds",
)}`,
async () => {
const customerId = `${TEST_CASE}-non-vercel-customer`;
const pro = products.pro({
id: `${TEST_CASE}-non-vercel-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
// Should not throw.
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.products.map((p: { id: string }) => p.id)).toContain(
pro.id,
);
},
);

View File

@@ -0,0 +1,111 @@
/**
* Vercel org config display
*
* Asserts that `getVercelConfigDisplay` no longer surfaces the legacy
* `custom_payment_method` field. The frontend `ConfigureVercel.tsx` consumes
* this object; the field has been removed from the UI but the schema is kept
* for backwards compat with stored data.
*/
import { describe, expect, test } from "bun:test";
import {
AppEnv,
type Organization,
VercelMarketplaceMode,
} from "@autumn/shared";
import chalk from "chalk";
import { getVercelConfigDisplay } from "@/internal/orgs/handlers/handleVercelConfig";
const baseOrg = (): Organization =>
({
id: "org_test",
slug: "org-test",
name: "Org Test",
default_currency: "usd",
processor_configs: {
vercel: {
client_integration_id: "oac_test_live_id_value",
client_secret: "live_secret_value_abc",
webhook_url: "https://api.example.com/webhooks/vercel/org_test/live",
sandbox_client_id: "oac_test_sandbox_id_value",
sandbox_client_secret: "sandbox_secret_value_abc",
sandbox_webhook_url:
"https://api.example.com/webhooks/vercel/org_test/sandbox",
custom_payment_method: {
live: "cpmt_live_legacy_value",
sandbox: "cpmt_sandbox_legacy_value",
},
marketplace_mode: VercelMarketplaceMode.Installation,
allowed_product_ids_live: ["prod_live_a"],
allowed_product_ids_sandbox: ["prod_sandbox_a"],
},
},
// minimum required Organization shape — anything not relevant for the
// display function is filled in with sane stubs.
config: {} as Organization["config"],
createdAt: new Date(),
}) as unknown as Organization;
describe(chalk.yellowBright("vercel-config-display"), () => {
test("hides custom_payment_method while preserving other Vercel fields (sandbox)", () => {
const display = getVercelConfigDisplay({
org: baseOrg(),
env: AppEnv.Sandbox,
});
expect(display.custom_payment_method).toBeUndefined();
expect(display.connected).toBe(true);
expect(display.client_integration_id).toBeDefined();
expect(display.client_secret).toBeDefined();
expect(display.webhook_url).toBeDefined();
expect(display.marketplace_mode).toBe(VercelMarketplaceMode.Installation);
expect(display.allowed_product_ids_sandbox).toEqual(["prod_sandbox_a"]);
});
test("hides custom_payment_method while preserving other Vercel fields (live)", () => {
const display = getVercelConfigDisplay({
org: baseOrg(),
env: AppEnv.Live,
});
expect(display.custom_payment_method).toBeUndefined();
expect(display.connected).toBe(true);
expect(display.client_integration_id).toBeDefined();
expect(display.allowed_product_ids_live).toEqual(["prod_live_a"]);
});
test("returns all-undefined display when org has no vercel config", () => {
const org = baseOrg();
org.processor_configs = {};
const display = getVercelConfigDisplay({
org,
env: AppEnv.Sandbox,
});
expect(display.connected).toBe(false);
expect(display.custom_payment_method).toBeUndefined();
expect(display.client_integration_id).toBeUndefined();
expect(display.client_secret).toBeUndefined();
expect(display.webhook_url).toBeUndefined();
expect(display.marketplace_mode).toBeUndefined();
expect(display.allowed_product_ids_live).toBeUndefined();
expect(display.allowed_product_ids_sandbox).toBeUndefined();
});
test("hides custom_payment_method even when stored value is non-empty", () => {
const org = baseOrg();
const display = getVercelConfigDisplay({
org,
env: AppEnv.Sandbox,
});
// Even though org.processor_configs.vercel.custom_payment_method.sandbox
// has a value, the display payload deliberately strips it. Asserts the
// frontend has no opportunity to render it.
expect(display.custom_payment_method).toBeUndefined();
expect(
org.processor_configs!.vercel!.custom_payment_method!.sandbox,
).toBeDefined();
});
});

View File

@@ -0,0 +1,170 @@
/**
* Vercel invoice.finalized → processVercelInvoice
*
* When Stripe finalizes a Vercel-tagged subscription's invoice it fires
* `invoice.finalized` → `setupInvoiceFinalizedContext` → `processVercelInvoice`.
* The handler submits the finalized invoice to Vercel via the SDK
* (`submitBillingDataToVercel` + `submitInvoiceToVercel`).
*
* This test sets `ctx.testOptions.mockVercelApi`, so the SDK hits our dev
* server's `/__test/vercel/api` mock and records each call to Redis.
*
* We invoke `processVercelInvoice` directly from the test process with a
* constructed `StripeWebhookContext` rather than waiting for Stripe to
* deliver the real `invoice.finalized` webhook. The webhook delivery path
* is ngrok-bound to a different dev server in this dev environment, but the
* handler itself is what we care about — and calling it directly still
* goes through `getVercelSdkServerURL()`, which points at our mock only for
* this explicitly-marked test context.
*/
import { expect, test } from "bun:test";
import chalk from "chalk";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { logger } from "@/external/logtail/logtailUtils";
import { provisionVercelCusProduct } from "@/external/vercel/misc/vercelProvisioning";
import { getStripeInvoice } from "@/external/stripe/invoices/operations/getStripeInvoice";
import { getExpandedStripeSubscription } from "@/external/stripe/subscriptions";
import { processVercelInvoice } from "@/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0";
import {
clearVercelCaptures,
readVercelCaptures,
seedVercelCustomer,
seedVercelResource,
setupVercelOrg,
waitForVercelCapture,
} from "./utils/vercel-test-helpers";
const TEST_CASE = "vfin";
// ─────────────────────────────────────────────────────────────────────────────
// TEST 1: processVercelInvoice submits billing data + invoice to Vercel SDK
// ─────────────────────────────────────────────────────────────────────────────
test(
`${chalk.yellowBright(
"vercel-invoice-finalized: processVercelInvoice submits billing data + invoice to Vercel SDK (no CPM required)",
)}`,
async () => {
const customerId = `${TEST_CASE}-customer`;
const installationId = `icfg_${TEST_CASE}_main`;
const resourceId = `vre_${TEST_CASE}_main`;
const proRaw = products.pro({
id: `${TEST_CASE}-main-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await setupVercelOrg(ctx);
await initProductsV0({
ctx,
products: [proRaw],
prefix: `${TEST_CASE}-main`,
});
// Clear any leftover captures for this installation from previous runs.
await clearVercelCaptures(installationId);
const { customer, stripeCustomer } = await seedVercelCustomer({
ctx,
customerId,
installationId,
});
await seedVercelResource({ ctx, resourceId, installationId });
const stripeCustomerExpanded = await ctx.stripeCli.customers.retrieve(
stripeCustomer.id,
{ expand: ["subscriptions"] },
);
if (stripeCustomerExpanded.deleted)
throw new Error("Stripe customer deleted before provision");
const { subscription } = await provisionVercelCusProduct({
ctx,
customer,
stripeCustomer: stripeCustomerExpanded,
stripeCli: ctx.stripeCli,
integrationConfigurationId: installationId,
billingPlanId: proRaw.id,
resourceId,
});
expect(subscription).not.toBeNull();
const invoiceId =
typeof subscription!.latest_invoice === "string"
? subscription!.latest_invoice
: subscription!.latest_invoice?.id;
expect(invoiceId).toBeDefined();
// Build the same expanded invoice + subscription that the Stripe
// finalize webhook handler would see, then drive
// `processVercelInvoice` directly with a constructed context.
const stripeInvoice = await getStripeInvoice({
stripeClient: ctx.stripeCli,
invoiceId: invoiceId!,
expand: ["discounts.source.coupon", "total_discount_amounts"],
});
const stripeSubscription = await getExpandedStripeSubscription({
ctx,
subscriptionId: subscription!.id,
});
const webhookCtx: StripeWebhookContext = {
...ctx,
fullCustomer: customer,
testOptions: {
...(ctx.testOptions ?? {}),
mockVercelApi: true,
},
stripeEvent: {
id: "evt_test_vfin",
type: "invoice.finalized",
} as any,
} as StripeWebhookContext;
webhookCtx.logger = logger;
await processVercelInvoice({
ctx: webhookCtx,
stripeInvoice,
stripeSubscription,
});
// SDK calls should land on the test mock virtually immediately.
const billingCall = await waitForVercelCapture({
installationId,
predicate: (call) =>
call.method === "POST" &&
call.path === `/v1/installations/${installationId}/billing` &&
call.installationId === installationId,
timeoutMs: 10000,
});
expect(billingCall).not.toBeNull();
const invoiceCall = await waitForVercelCapture({
installationId,
predicate: (call) =>
call.method === "POST" &&
call.path === `/v1/installations/${installationId}/billing/invoices`,
timeoutMs: 10000,
});
expect(invoiceCall).not.toBeNull();
// Shape sanity
expect(billingCall!.body.billing.items).toBeInstanceOf(Array);
expect(billingCall!.body.period.start).toBeDefined();
expect(billingCall!.body.period.end).toBeDefined();
expect(typeof invoiceCall!.body.externalId).toBe("string");
expect(invoiceCall!.body.items.length).toBeGreaterThan(0);
expect(invoiceCall!.body.items[0].billingPlanId).toBe(proRaw.id);
// Snapshot the raw captures for debugging if anything regresses.
const allCaptures = await readVercelCaptures(installationId);
expect(allCaptures.length).toBeGreaterThanOrEqual(2);
},
30000,
);

View File

@@ -0,0 +1,299 @@
/**
* Vercel `marketplace.invoice.notpaid` webhook
*
* The notpaid handler must:
* 1. Suspend the Vercel resource (`status = "suspended"`).
* 2. Expire the Autumn cus_product and activate the default fallback.
* 3. Cancel the Stripe subscription.
* 4. NOT mark the invoice paid.
* 5. NOT call paymentRecords.reportPayment / invoices.attachPayment.
*
* Race-safety: when the paid webhook has already flipped the invoice to
* `paid`, the notpaid handler short-circuits without canceling the sub.
*/
import { expect, test } from "bun:test";
import { CusProductStatus, customerProducts } from "@autumn/shared";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0";
import { provisionVercelCusProduct } from "@/external/vercel/misc/vercelProvisioning";
import { VercelResourceService } from "@/external/vercel/services/VercelResourceService";
import {
expectVercelWebhookSuccess,
VercelWebhookClient,
} from "./utils/vercel-webhook-client";
import {
seedVercelCustomer,
seedVercelResource,
setupVercelOrg,
} from "./utils/vercel-test-helpers";
const TEST_CASE = "vnotpaid";
const HMAC_SECRET = "test_vercel_client_secret_notpaid";
const newClient = () =>
new VercelWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
clientSecret: HMAC_SECRET,
});
const buildPayload = ({
installationId,
externalInvoiceId,
}: {
installationId: string;
externalInvoiceId: string;
}) => ({
installationId,
invoiceId: `vinv_${externalInvoiceId}`,
externalInvoiceId,
invoiceTotal: "20.00",
period: {
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
end: new Date().toISOString(),
},
invoiceDate: new Date().toISOString(),
});
const provisionForTest = async ({
customerId,
installationId,
resourceId,
planId,
}: {
customerId: string;
installationId: string;
resourceId: string;
planId: string;
}) => {
const { customer, stripeCustomer, internalCustomerId } =
await seedVercelCustomer({ ctx, customerId, installationId });
await seedVercelResource({ ctx, resourceId, installationId });
const stripeCustomerExpanded = await ctx.stripeCli.customers.retrieve(
stripeCustomer.id,
{ expand: ["subscriptions"] },
);
if (stripeCustomerExpanded.deleted)
throw new Error("Stripe customer deleted before provision");
const { subscription, cusProduct } = await provisionVercelCusProduct({
ctx,
customer,
stripeCustomer: stripeCustomerExpanded,
stripeCli: ctx.stripeCli,
integrationConfigurationId: installationId,
billingPlanId: planId,
resourceId,
});
if (!subscription) throw new Error("Expected Stripe subscription");
const latestInvoiceId =
typeof subscription.latest_invoice === "string"
? subscription.latest_invoice
: subscription.latest_invoice?.id;
if (!latestInvoiceId) throw new Error("Expected latest invoice id");
return {
customer,
stripeCustomer: stripeCustomerExpanded,
subscription,
cusProduct,
internalCustomerId,
externalInvoiceId: latestInvoiceId,
};
};
// ─────────────────────────────────────────────────────────────────────────────
// TEST 1: full cleanup — sub canceled, resource suspended, cus_product expired
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-marketplace-notpaid: cancels subscription, suspends resource, expires cus_product, leaves invoice unpaid",
)}`,
async () => {
const customerId = `${TEST_CASE}-clean-customer`;
const installationId = `icfg_${TEST_CASE}_clean`;
const resourceId = `vre_${TEST_CASE}_clean`;
const proRaw = products.pro({
id: `${TEST_CASE}-clean-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await setupVercelOrg(ctx, { clientSecret: HMAC_SECRET });
await initProductsV0({
ctx,
products: [proRaw],
prefix: `${TEST_CASE}-clean`,
});
const {
subscription,
cusProduct,
internalCustomerId,
externalInvoiceId,
} = await provisionForTest({
customerId,
installationId,
resourceId,
planId: proRaw.id,
});
const result = await newClient().invoiceNotPaid(
buildPayload({ installationId, externalInvoiceId }),
);
expectVercelWebhookSuccess(result);
// Stripe subscription canceled
const canceled = await ctx.stripeCli.subscriptions.retrieve(
subscription.id,
);
expect(canceled.status).toBe("canceled");
// Resource suspended
const resource = await VercelResourceService.getById({
db: ctx.db,
resourceId,
orgId: ctx.org.id,
env: ctx.env,
});
expect(resource?.status).toBe("suspended");
// Cus_product expired (default may have been activated). Query the
// table directly — CusProductService.list defaults to filtering
// expired rows out, and `inStatuses: undefined` falls back to that
// default via JS destructure semantics.
const rows = await ctx.db
.select()
.from(customerProducts)
.where(eq(customerProducts.id, cusProduct.id));
expect(rows.length).toBe(1);
expect(rows[0]!.status).toBe(CusProductStatus.Expired);
// Silence unused-var lint for the helper-context variables we still
// destructure for symmetry with other tests.
void internalCustomerId;
// Invoice was NOT marked paid
const invoice = await ctx.stripeCli.invoices.retrieve(externalInvoiceId);
expect(invoice.status).not.toBe("paid");
},
);
// ─────────────────────────────────────────────────────────────────────────────
// TEST 2: race — paid won first; notpaid short-circuits without canceling
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-marketplace-notpaid: short-circuits without canceling when invoice is already paid (race with paid webhook)",
)}`,
async () => {
const customerId = `${TEST_CASE}-race-customer`;
const installationId = `icfg_${TEST_CASE}_race`;
const resourceId = `vre_${TEST_CASE}_race`;
const proRaw = products.pro({
id: `${TEST_CASE}-race-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await setupVercelOrg(ctx, { clientSecret: HMAC_SECRET });
await initProductsV0({
ctx,
products: [proRaw],
prefix: `${TEST_CASE}-race`,
});
const { subscription, externalInvoiceId } = await provisionForTest({
customerId,
installationId,
resourceId,
planId: proRaw.id,
});
// Simulate the "paid won the race" state: mark the invoice paid out of
// band first, then deliver notpaid.
await ctx.stripeCli.invoices.pay(externalInvoiceId, {
paid_out_of_band: true,
});
const result = await newClient().invoiceNotPaid(
buildPayload({ installationId, externalInvoiceId }),
);
expectVercelWebhookSuccess(result);
const sub = await ctx.stripeCli.subscriptions.retrieve(subscription.id);
expect(sub.status).not.toBe("canceled");
const resource = await VercelResourceService.getById({
db: ctx.db,
resourceId,
orgId: ctx.org.id,
env: ctx.env,
});
// Resource should not have been suspended since we short-circuited.
expect(resource?.status).toBe("ready");
},
);
// ─────────────────────────────────────────────────────────────────────────────
// TEST 3: lazy migration — legacy charge_automatically sub flips before cancel
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-marketplace-notpaid: lazy-migrates legacy charge_automatically sub to send_invoice before canceling",
)}`,
async () => {
const customerId = `${TEST_CASE}-legacy-customer`;
const installationId = `icfg_${TEST_CASE}_legacy`;
const resourceId = `vre_${TEST_CASE}_legacy`;
const proRaw = products.pro({
id: `${TEST_CASE}-legacy-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await setupVercelOrg(ctx, { clientSecret: HMAC_SECRET });
await initProductsV0({
ctx,
products: [proRaw],
prefix: `${TEST_CASE}-legacy`,
});
const { subscription, externalInvoiceId } = await provisionForTest({
customerId,
installationId,
resourceId,
planId: proRaw.id,
});
// Force the sub back into legacy charge_automatically.
await ctx.stripeCli.subscriptions.update(subscription.id, {
collection_method: "charge_automatically",
});
// Snapshot the migration helper's effect by retrieving the sub BEFORE
// cancellation. We can't observe the migrated state after cancel
// because cancel always sets status=canceled, but the helper updates
// collection_method first and that change is visible in the canceled
// subscription too.
const result = await newClient().invoiceNotPaid(
buildPayload({ installationId, externalInvoiceId }),
);
expectVercelWebhookSuccess(result);
const canceled = await ctx.stripeCli.subscriptions.retrieve(
subscription.id,
);
expect(canceled.status).toBe("canceled");
expect(canceled.collection_method).toBe("send_invoice");
},
);

View File

@@ -0,0 +1,324 @@
/**
* Vercel `marketplace.invoice.paid` webhook
*
* Replaces the legacy Stripe Custom Payment Method + Payment Records flow.
* Vercel reports the invoice was paid out of band; our handler marks the
* Stripe invoice paid via `invoices.pay(id, { paid_out_of_band: true })`.
*
* The handler MUST NOT call `paymentRecords.reportPayment` or
* `invoices.attachPayment`. Tests assert by checking the resulting Stripe
* invoice state (status = paid, `paid_out_of_band` flag) and the absence of
* any attached payment_record on the invoice's payment rows.
*/
import { expect, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import chalk from "chalk";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0";
import { provisionVercelCusProduct } from "@/external/vercel/misc/vercelProvisioning";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { VercelResourceService } from "@/external/vercel/services/VercelResourceService";
import {
expectVercelWebhookSuccess,
VercelWebhookClient,
} from "./utils/vercel-webhook-client";
import {
seedVercelCustomer,
seedVercelResource,
setupVercelOrg,
} from "./utils/vercel-test-helpers";
const TEST_CASE = "vpaid";
const HMAC_SECRET = "test_vercel_client_secret_paid";
const newClient = () =>
new VercelWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
clientSecret: HMAC_SECRET,
});
const buildPayload = ({
installationId,
externalInvoiceId,
}: {
installationId: string;
externalInvoiceId: string;
}) => ({
installationId,
invoiceId: `vinv_${externalInvoiceId}`,
externalInvoiceId,
invoiceTotal: "20.00",
period: {
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
end: new Date().toISOString(),
},
invoiceDate: new Date().toISOString(),
});
const provisionAndGetInvoice = async ({
customerId,
installationId,
resourceId,
planId,
}: {
customerId: string;
installationId: string;
resourceId: string;
planId: string;
}) => {
const { customer, stripeCustomer } = await seedVercelCustomer({
ctx,
customerId,
installationId,
});
await seedVercelResource({ ctx, resourceId, installationId });
const stripeCustomerExpanded = await ctx.stripeCli.customers.retrieve(
stripeCustomer.id,
{ expand: ["subscriptions"] },
);
if (stripeCustomerExpanded.deleted)
throw new Error("Stripe customer deleted before provision");
const { subscription } = await provisionVercelCusProduct({
ctx,
customer,
stripeCustomer: stripeCustomerExpanded,
stripeCli: ctx.stripeCli,
integrationConfigurationId: installationId,
billingPlanId: planId,
resourceId,
});
if (!subscription)
throw new Error("Expected Stripe subscription from provisionVercel");
const latestInvoiceId =
typeof subscription.latest_invoice === "string"
? subscription.latest_invoice
: subscription.latest_invoice?.id;
if (!latestInvoiceId)
throw new Error("Expected subscription.latest_invoice id");
return {
customer,
stripeCustomer: stripeCustomerExpanded,
subscription,
externalInvoiceId: latestInvoiceId,
};
};
// ─────────────────────────────────────────────────────────────────────────────
// TEST 1: paid webhook marks invoice paid_out_of_band + resource ready
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-marketplace-paid: marks Stripe invoice paid out-of-band, flips resource to ready, never calls paymentRecords",
)}`,
async () => {
const customerId = `${TEST_CASE}-paid-customer`;
const installationId = `icfg_${TEST_CASE}_paid`;
const resourceId = `vre_${TEST_CASE}_paid`;
const proRaw = products.pro({
id: `${TEST_CASE}-paid-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await setupVercelOrg(ctx, { clientSecret: HMAC_SECRET });
await initProductsV0({
ctx,
products: [proRaw],
prefix: `${TEST_CASE}-paid`,
});
const { externalInvoiceId } = await provisionAndGetInvoice({
customerId,
installationId,
resourceId,
planId: proRaw.id,
});
const client = newClient();
const result = await client.invoicePaid(
buildPayload({ installationId, externalInvoiceId }),
);
expectVercelWebhookSuccess(result);
// Assert invoice is now paid. We can't reliably distinguish the new
// `invoices.pay({ paid_out_of_band: true })` from the legacy
// `paymentRecords.reportPayment` + `invoices.attachPayment` purely
// from the resulting Stripe state — both end up with a payment record
// of `processor_details.type === "custom"`. So we assert positively
// that the invoice transitions to paid and the amount is covered;
// the absence-of-legacy-calls is enforced at the source level (those
// imports and call sites have been removed from
// `handleMarketplaceInvoicePaid.ts`).
const finalInvoice = await ctx.stripeCli.invoices.retrieve(
externalInvoiceId,
{ expand: ["payments"] },
);
expect(finalInvoice.status).toBe("paid");
expect(finalInvoice.amount_paid).toBeGreaterThanOrEqual(
finalInvoice.amount_due,
);
// Resource status should be "ready"
const resource = await VercelResourceService.getById({
db: ctx.db,
resourceId,
orgId: ctx.org.id,
env: ctx.env,
});
expect(resource?.status).toBe("ready");
},
);
// ─────────────────────────────────────────────────────────────────────────────
// TEST 2: paid webhook is idempotent — already-paid invoice short-circuits
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-marketplace-paid: webhook on already-paid invoice is a no-op",
)}`,
async () => {
const customerId = `${TEST_CASE}-idemp-customer`;
const installationId = `icfg_${TEST_CASE}_idemp`;
const resourceId = `vre_${TEST_CASE}_idemp`;
const proRaw = products.pro({
id: `${TEST_CASE}-idemp-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await setupVercelOrg(ctx, { clientSecret: HMAC_SECRET });
await initProductsV0({
ctx,
products: [proRaw],
prefix: `${TEST_CASE}-idemp`,
});
const { externalInvoiceId } = await provisionAndGetInvoice({
customerId,
installationId,
resourceId,
planId: proRaw.id,
});
const client = newClient();
expectVercelWebhookSuccess(
await client.invoicePaid(
buildPayload({ installationId, externalInvoiceId }),
),
);
// Second delivery should be a no-op — Stripe invoice stays paid.
expectVercelWebhookSuccess(
await client.invoicePaid(
buildPayload({ installationId, externalInvoiceId }),
),
);
const invoice =
await ctx.stripeCli.invoices.retrieve(externalInvoiceId);
expect(invoice.status).toBe("paid");
},
);
// ─────────────────────────────────────────────────────────────────────────────
// TEST 3: bad signature is rejected by the Vercel signature middleware
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-marketplace-paid: signature middleware rejects requests with the wrong secret",
)}`,
async () => {
const installationId = `icfg_${TEST_CASE}_bad_sig`;
await setupVercelOrg(ctx, { clientSecret: HMAC_SECRET });
const badClient = new VercelWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
clientSecret: "wrong_secret",
});
const { response } = await badClient.invoicePaid(
buildPayload({
installationId,
externalInvoiceId: "in_does_not_matter",
}),
);
expect(response.status).toBe(401);
},
);
// ─────────────────────────────────────────────────────────────────────────────
// TEST 4: lazy migration — legacy charge_automatically sub flips to send_invoice
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-marketplace-paid: lazy-migrates a legacy charge_automatically subscription to send_invoice",
)}`,
async () => {
const customerId = `${TEST_CASE}-legacy-customer`;
const installationId = `icfg_${TEST_CASE}_legacy`;
const resourceId = `vre_${TEST_CASE}_legacy`;
const proRaw = products.pro({
id: `${TEST_CASE}-legacy-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await setupVercelOrg(ctx, { clientSecret: HMAC_SECRET });
await initProductsV0({
ctx,
products: [proRaw],
prefix: `${TEST_CASE}-legacy`,
});
// Provision via the refactored path — gives us a subscription. We then
// force `collection_method` back to `charge_automatically` via Stripe to
// simulate a legacy sub created before the refactor.
const { subscription, externalInvoiceId, stripeCustomer } =
await provisionAndGetInvoice({
customerId,
installationId,
resourceId,
planId: proRaw.id,
});
const legacy = await ctx.stripeCli.subscriptions.update(subscription.id, {
collection_method: "charge_automatically",
});
expect(legacy.collection_method).toBe("charge_automatically");
const client = newClient();
const result = await client.invoicePaid(
buildPayload({ installationId, externalInvoiceId }),
);
expectVercelWebhookSuccess(result);
const migrated = await ctx.stripeCli.subscriptions.retrieve(
subscription.id,
);
expect(migrated.collection_method).toBe("send_invoice");
expect(migrated.days_until_due).toBe(30);
const invoice = await ctx.stripeCli.invoices.retrieve(externalInvoiceId);
expect(invoice.status).toBe("paid");
// Force-clear unused stripeCustomer reference for lint
void stripeCustomer;
},
);
// Reference of imported but type-only used to keep tree-shake/lint happy
void CusProductService;
void CusProductStatus;

View File

@@ -0,0 +1,278 @@
/**
* Vercel resource provisioning (full HTTP via OIDC test bypass).
*
* Drives `POST /webhooks/vercel/:orgId/:env/v1/installations/:integrationConfigurationId/resources`
* end-to-end through `vercelOidcAuthMiddleware`, which accepts the test
* bearer token `test_oidc:<installationId>` outside production (see
* `vercelAuth.ts:synthesizeTestClaims`).
*
* The handler eventually calls `provisionVercelCusProduct`, which is the
* subject under test. We assert:
* - Stripe subscription has `collection_method: "send_invoice"`,
* `days_until_due: 30`.
* - Subscription has Vercel metadata (`vercel_installation_id`, etc).
* - No `default_payment_method` is set on the subscription (no CPM-based flow).
* - First invoice is finalized (`open`) so the finalize webhook fires.
* - Customer's Autumn cus_product is active (top-level
* `enable_plan_immediately: true`).
* - A subsequent provisioning request is idempotent and short-circuits with
* the existing subscription/cus_product.
*/
import { expect, test } from "bun:test";
import { ApiVersion, AppEnv, CusProductStatus } from "@autumn/shared";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0";
import {
buildTestOidcHeaders,
seedVercelCustomer,
setupVercelOrg,
} from "./utils/vercel-test-helpers";
const TEST_CASE = "vprov";
const baseUrl = () =>
(process.env.BETTER_AUTH_URL ?? "http://localhost:8080").replace(/\/$/, "");
const resourcesUrl = (installationId: string) =>
`${baseUrl()}/webhooks/vercel/${ctx.org.id}/${ctx.env}/v1/installations/${installationId}/resources`;
interface CreateResourceResponse {
id: string;
productId: string;
name: string;
status: string;
billingPlan?: { id: string; type: string; name: string };
}
const createResourceViaHttp = async ({
installationId,
body,
}: {
installationId: string;
body: {
productId: string;
billingPlanId: string;
name: string;
metadata?: Record<string, unknown>;
};
}): Promise<{ response: Response; data: CreateResourceResponse | unknown }> => {
const response = await fetch(resourcesUrl(installationId), {
method: "POST",
headers: buildTestOidcHeaders(installationId, "user"),
body: JSON.stringify(body),
});
let data: unknown;
try {
data = await response.json();
} catch {
data = null;
}
return { response, data };
};
// ─────────────────────────────────────────────────────────────────────────────
// TEST 1: Paid plan → invoice-mode Stripe subscription
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-provisioning: paid plan creates a send_invoice subscription with vercel metadata + finalized first invoice",
)}`,
async () => {
const customerId = `${TEST_CASE}-paid-customer`;
const installationId = `icfg_${TEST_CASE}_paid`;
const proRaw = products.pro({
id: `${TEST_CASE}-paid-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await setupVercelOrg(ctx);
await initProductsV0({
ctx,
products: [proRaw],
prefix: `${TEST_CASE}-paid`,
});
// Refresh the (post-prefix) product id.
const pro = proRaw;
const { stripeCustomer, internalCustomerId } = await seedVercelCustomer({
ctx,
customerId,
installationId,
});
const { response, data } = await createResourceViaHttp({
installationId,
body: {
productId: pro.id,
billingPlanId: pro.id,
name: "Test paid resource",
},
});
expect(response.status).toBe(200);
const created = data as CreateResourceResponse;
expect(typeof created.id).toBe("string");
expect(created.id.startsWith("vre_")).toBe(true);
expect(created.productId).toBe(pro.id);
// Stripe subscription assertions
const stripeCustomerExpanded = await ctx.stripeCli.customers.retrieve(
stripeCustomer.id,
{ expand: ["subscriptions"] },
);
if (stripeCustomerExpanded.deleted)
throw new Error("Stripe customer was deleted");
const sub = stripeCustomerExpanded.subscriptions?.data.find(
(s) =>
s.metadata?.vercel_installation_id === installationId &&
s.status !== "incomplete_expired" &&
s.status !== "canceled",
);
expect(sub).toBeDefined();
const subscription = sub!;
expect(subscription.collection_method).toBe("send_invoice");
expect(subscription.days_until_due).toBe(30);
expect(subscription.default_payment_method).toBeNull();
expect(subscription.metadata?.vercel_installation_id).toBe(installationId);
expect(subscription.metadata?.vercel_billing_plan_id).toBe(pro.id);
expect(subscription.metadata?.vercel_resource_id).toBe(created.id);
// First invoice should be finalized (not draft) because we set
// invoice_mode.finalize = true. send_invoice keeps it `open` (not paid).
const latestInvoiceId =
typeof subscription.latest_invoice === "string"
? subscription.latest_invoice
: subscription.latest_invoice?.id;
expect(latestInvoiceId).toBeDefined();
const invoice = await ctx.stripeCli.invoices.retrieve(latestInvoiceId!);
expect(["open", "paid"]).toContain(invoice.status ?? "");
// Autumn cus_product should be active (top-level enable_plan_immediately).
const cusProducts = await CusProductService.list({
db: ctx.db,
internalCustomerId,
inStatuses: [CusProductStatus.Active, CusProductStatus.Trialing],
});
expect(cusProducts.length).toBeGreaterThan(0);
},
);
// ─────────────────────────────────────────────────────────────────────────────
// TEST 2: Idempotent re-call short-circuits
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-provisioning: idempotent re-call returns existing subscription without creating a second one",
)}`,
async () => {
const customerId = `${TEST_CASE}-idemp-customer`;
const installationId = `icfg_${TEST_CASE}_idemp`;
const proRaw = products.pro({
id: `${TEST_CASE}-idemp-pro`,
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await setupVercelOrg(ctx);
await initProductsV0({
ctx,
products: [proRaw],
prefix: `${TEST_CASE}-idemp`,
});
const pro = proRaw;
const { stripeCustomer } = await seedVercelCustomer({
ctx,
customerId,
installationId,
});
// First call.
const first = await createResourceViaHttp({
installationId,
body: {
productId: pro.id,
billingPlanId: pro.id,
name: "First create",
},
});
expect(first.response.status).toBe(200);
const subsAfterFirst = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomer.id,
limit: 10,
});
const matchedAfterFirst = subsAfterFirst.data.filter(
(s) => s.metadata?.vercel_installation_id === installationId,
);
expect(matchedAfterFirst.length).toBe(1);
const firstSubId = matchedAfterFirst[0]!.id;
// Second call. The handler short-circuits via the
// `existingResource + existingSub + existingCusProducts` branch in
// `handleCreateResource.ts` and never enters provisionVercelCusProduct.
const second = await createResourceViaHttp({
installationId,
body: {
productId: pro.id,
billingPlanId: pro.id,
name: "Second create",
},
});
expect(second.response.status).toBe(200);
const subsAfterSecond = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomer.id,
limit: 10,
});
const matchedAfterSecond = subsAfterSecond.data.filter(
(s) => s.metadata?.vercel_installation_id === installationId,
);
expect(matchedAfterSecond.length).toBe(1);
expect(matchedAfterSecond[0]!.id).toBe(firstSubId);
},
);
// ─────────────────────────────────────────────────────────────────────────────
// TEST 3: OIDC middleware rejects requests with no/bad bearer token
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(
`${chalk.yellowBright(
"vercel-provisioning: OIDC middleware rejects calls without a valid token (test bypass requires test_oidc: prefix)",
)}`,
async () => {
const installationId = `icfg_${TEST_CASE}_noauth`;
const response = await fetch(resourcesUrl(installationId), {
method: "POST",
headers: {
authorization: "Bearer not_a_real_token",
"x-vercel-auth": "user",
"content-type": "application/json",
},
body: JSON.stringify({
productId: "fake",
billingPlanId: "fake",
name: "Should be rejected",
}),
});
expect(response.status).toBe(401);
},
);
// Reference unused imports so linters/typecheck stay happy when the test
// list shrinks during iteration. (`AutumnInt`/`ApiVersion`/`AppEnv` are
// reserved for future test cases that exercise the full attach path; keeping
// the imports prevents churn when adding them back.)
void ApiVersion;
void AppEnv;
void AutumnInt;

View File

@@ -0,0 +1,106 @@
import { describe, expect, test } from "bun:test";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import {
caughtErrorToLogFields,
logCaughtError,
} from "@/utils/logging/logCaughtError.js";
const createTestLogger = ({
onError,
onWarn,
}: {
onError?: (...args: unknown[]) => void;
onWarn?: (...args: unknown[]) => void;
}): Logger => ({
debug: () => {},
info: () => {},
warn: (...args: unknown[]) => onWarn?.(...args),
error: (...args: unknown[]) => onError?.(...args),
child: () => createTestLogger({ onError, onWarn }),
});
describe("logCaughtError", () => {
test("logs Error objects to console and logger with stack fields", () => {
const originalConsoleError = console.error;
const consoleCalls: unknown[][] = [];
const loggerCalls: unknown[][] = [];
const error = new Error("boom");
const logger = createTestLogger({
onError: (...args: unknown[]) => loggerCalls.push(args),
});
console.error = (...args: unknown[]) => {
consoleCalls.push(args);
};
try {
logCaughtError({
logger,
message: "failed",
error,
data: { invoiceId: "in_123" },
});
} finally {
console.error = originalConsoleError;
}
expect(consoleCalls).toEqual([["failed", error]]);
expect(loggerCalls).toHaveLength(1);
expect(loggerCalls[0][0]).toBe("failed");
expect(loggerCalls[0][1]).toMatchObject({
errorName: "Error",
errorMessage: "boom",
errorString: "Error: boom",
data: { invoiceId: "in_123" },
});
expect((loggerCalls[0][1] as { errorStack?: string }).errorStack).toContain(
"Error: boom",
);
});
test("logs non-Error throws through warn", () => {
const originalConsoleWarn = console.warn;
const consoleCalls: unknown[][] = [];
const loggerCalls: unknown[][] = [];
const logger = createTestLogger({
onWarn: (...args: unknown[]) => loggerCalls.push(args),
});
console.warn = (...args: unknown[]) => {
consoleCalls.push(args);
};
try {
logCaughtError({
logger,
message: "warned",
error: "bad",
level: "warn",
});
} finally {
console.warn = originalConsoleWarn;
}
expect(consoleCalls).toEqual([["warned", "bad"]]);
expect(loggerCalls).toEqual([
[
"warned",
{
errorName: "string",
errorMessage: "bad",
errorStack: undefined,
errorString: "bad",
},
],
]);
});
test("normalizes caught values without requiring a logger", () => {
expect(caughtErrorToLogFields(null)).toEqual({
errorName: "object",
errorMessage: "null",
errorStack: undefined,
errorString: "null",
});
});
});

View File

@@ -0,0 +1,86 @@
import { AppEnv, AuthType } from "@autumn/shared";
import { describe, expect, test } from "bun:test";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import {
buildVercelEventContext,
enrichVercelAppLogger,
} from "@/external/vercel/misc/vercelLogContext.js";
const createCapturingLogger = () => {
const childCalls: unknown[] = [];
const logger = {
child: (args: unknown) => {
childCalls.push(args);
return logger;
},
} as Logger;
return { logger, childCalls };
};
describe("vercelLogContext", () => {
test("builds Vercel event fields from marketplace invoice payloads", () => {
expect(
buildVercelEventContext({
id: "evt_123",
type: "marketplace.invoice.paid",
payload: {
installationId: "icfg_123",
invoiceId: "vi_123",
externalInvoiceId: "in_123",
resourceId: "vre_123",
},
}),
).toEqual({
id: "evt_123",
type: "marketplace.invoice.paid",
installation_id: "icfg_123",
invoice_id: "vi_123",
external_invoice_id: "in_123",
resource_id: "vre_123",
});
});
test("emits Vercel app context with auth and customer fields", () => {
const { logger, childCalls } = createCapturingLogger();
const ctx = {
logger,
org: { id: "org_123", slug: "acme" },
env: AppEnv.Live,
authType: AuthType.Unknown,
customerId: "cus_123",
entityId: "ent_123",
apiVersion: { semver: "1.2.0" },
scopes: ["customers:read"],
rolloutSnapshot: {
rolloutId: "v2-cache",
enabled: true,
percent: 100,
previousPercent: 50,
changedAt: 1,
customerBucket: 42,
},
} as AutumnContext;
expect(enrichVercelAppLogger({ ctx })).toBe(logger);
expect(childCalls).toEqual([
{
context: {
context: {
org_id: "org_123",
org_slug: "acme",
env: AppEnv.Live,
auth_type: AuthType.Vercel,
customer_id: "cus_123",
entity_id: "ent_123",
api_version: "1.2.0",
scopes: ["customers:read"],
full_subject_bucket: 42,
full_subject_rollout_enabled: true,
},
},
},
]);
});
});

View File

@@ -0,0 +1,40 @@
import { afterEach, describe, expect, test } from "bun:test";
import { getVercelSdkServerURL } from "@/external/vercel/misc/vercelSdkOptions.js";
describe("getVercelSdkServerURL", () => {
const originalNodeEnv = process.env.NODE_ENV;
const originalBetterAuthUrl = process.env.BETTER_AUTH_URL;
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
if (originalBetterAuthUrl === undefined) {
delete process.env.BETTER_AUTH_URL;
} else {
process.env.BETTER_AUTH_URL = originalBetterAuthUrl;
}
});
test("returns undefined by default in development", () => {
process.env.NODE_ENV = "development";
process.env.BETTER_AUTH_URL = "http://localhost:8080";
expect(getVercelSdkServerURL()).toBeUndefined();
expect(getVercelSdkServerURL({ mockVercelApi: false })).toBeUndefined();
});
test("returns the local mock URL when explicitly enabled", () => {
process.env.NODE_ENV = "development";
process.env.BETTER_AUTH_URL = "http://localhost:8080/";
expect(getVercelSdkServerURL({ mockVercelApi: true })).toBe(
"http://localhost:8080/__test/vercel/api",
);
});
test("returns undefined in production even when enabled", () => {
process.env.NODE_ENV = "production";
process.env.BETTER_AUTH_URL = "http://localhost:8080";
expect(getVercelSdkServerURL({ mockVercelApi: true })).toBeUndefined();
});
});