fix(vercel): 🐛 support invoice mode and logs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
136
server/src/external/vercel/misc/vercelAuth.ts
vendored
136
server/src/external/vercel/misc/vercelAuth.ts
vendored
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
112
server/src/external/vercel/misc/vercelLogContext.ts
vendored
Normal file
112
server/src/external/vercel/misc/vercelLogContext.ts
vendored
Normal 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 }),
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
16
server/src/external/vercel/misc/vercelSdkOptions.ts
vendored
Normal file
16
server/src/external/vercel/misc/vercelSdkOptions.ts
vendored
Normal 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`;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
216
server/src/external/vercel/misc/vercelStripeInvoiceMode.ts
vendored
Normal file
216
server/src/external/vercel/misc/vercelStripeInvoiceMode.ts
vendored
Normal 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;
|
||||
}
|
||||
};
|
||||
151
server/src/external/vercel/vercelTestApiRouter.ts
vendored
Normal file
151
server/src/external/vercel/vercelTestApiRouter.ts
vendored
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export type RequestContext = {
|
||||
eventId?: string;
|
||||
keepInternalFields?: boolean;
|
||||
useReplica?: boolean;
|
||||
mockVercelApi?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,7 @@ const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([
|
||||
"workflow",
|
||||
"trigger",
|
||||
"stripe_event",
|
||||
"vercel_event",
|
||||
"worker",
|
||||
"extras",
|
||||
"type",
|
||||
|
||||
44
server/src/utils/logging/logCaughtError.ts
Normal file
44
server/src/utils/logging/logCaughtError.ts
Normal 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 } : {}),
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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"],
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
106
server/tests/unit/logging/log-caught-error.test.ts
Normal file
106
server/tests/unit/logging/log-caught-error.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
86
server/tests/unit/logging/vercel-log-context.test.ts
Normal file
86
server/tests/unit/logging/vercel-log-context.test.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
40
server/tests/unit/vercelSdkOptions.test.ts
Normal file
40
server/tests/unit/vercelSdkOptions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user