diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts index 352f9091a..e205845af 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts @@ -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, diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts index 366ef2a4e..34e7d3a6d 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts @@ -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 => { - 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, }, }); } diff --git a/server/src/external/vercel/handlers/handleListBillingPlans.ts b/server/src/external/vercel/handlers/handleListBillingPlans.ts index 0b94322ad..23c5b4254 100644 --- a/server/src/external/vercel/handlers/handleListBillingPlans.ts +++ b/server/src/external/vercel/handlers/handleListBillingPlans.ts @@ -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", }); } } diff --git a/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts b/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts index 2e82da5a7..8a2393719 100644 --- a/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts @@ -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( diff --git a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts index 3dbf90819..add6d2b5d 100644 --- a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts @@ -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 }, }); } diff --git a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceCreated.ts b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceCreated.ts index 43f2d2a14..1132d3fec 100644 --- a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceCreated.ts +++ b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceCreated.ts @@ -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; } } 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", }); } } diff --git a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts index 093acc80b..c180161be 100644 --- a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts +++ b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts @@ -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); }; diff --git a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoicePaid.ts b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoicePaid.ts index c6e105f13..92ae59670 100644 --- a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoicePaid.ts +++ b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoicePaid.ts @@ -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; } } 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, + }); }; diff --git a/server/src/external/vercel/handlers/resources/handleCreateResource.ts b/server/src/external/vercel/handlers/resources/handleCreateResource.ts index 05946628f..4c5f26ad9 100644 --- a/server/src/external/vercel/handlers/resources/handleCreateResource.ts +++ b/server/src/external/vercel/handlers/resources/handleCreateResource.ts @@ -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: { diff --git a/server/src/external/vercel/handlers/resources/handleDeleteResource.ts b/server/src/external/vercel/handlers/resources/handleDeleteResource.ts index 51bb568ba..6e4b85dd5 100644 --- a/server/src/external/vercel/handlers/resources/handleDeleteResource.ts +++ b/server/src/external/vercel/handlers/resources/handleDeleteResource.ts @@ -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); }, diff --git a/server/src/external/vercel/misc/rawBodyMiddleware.ts b/server/src/external/vercel/misc/rawBodyMiddleware.ts index 0570458de..2476607b7 100644 --- a/server/src/external/vercel/misc/rawBodyMiddleware.ts +++ b/server/src/external/vercel/misc/rawBodyMiddleware.ts @@ -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(); diff --git a/server/src/external/vercel/misc/vercelAuth.ts b/server/src/external/vercel/misc/vercelAuth.ts index 2bc205032..37a693b7b 100644 --- a/server/src/external/vercel/misc/vercelAuth.ts +++ b/server/src/external/vercel/misc/vercelAuth.ts @@ -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; +/** + * Test-mode short-circuit. Outside production, accept a sentinel bearer token + * of the form `test_oidc:` (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 { + const testClaims = synthesizeTestClaims({ token, org, env }); + if (testClaims) return testClaims; + try { const { payload: claims } = await jwtVerify(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}`, ); } } diff --git a/server/src/external/vercel/misc/vercelCustomerMiddleware.ts b/server/src/external/vercel/misc/vercelCustomerMiddleware.ts index ea4352a2c..3bea524b9 100644 --- a/server/src/external/vercel/misc/vercelCustomerMiddleware.ts +++ b/server/src/external/vercel/misc/vercelCustomerMiddleware.ts @@ -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(); }; diff --git a/server/src/external/vercel/misc/vercelInvoicing.ts b/server/src/external/vercel/misc/vercelInvoicing.ts index 6184418a5..d5ba9b99e 100644 --- a/server/src/external/vercel/misc/vercelInvoicing.ts +++ b/server/src/external/vercel/misc/vercelInvoicing.ts @@ -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({ diff --git a/server/src/external/vercel/misc/vercelLogContext.ts b/server/src/external/vercel/misc/vercelLogContext.ts new file mode 100644 index 000000000..b5860f0e7 --- /dev/null +++ b/server/src/external/vercel/misc/vercelLogContext.ts @@ -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, +): 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 => { + 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 }), + }; +}; diff --git a/server/src/external/vercel/misc/vercelMiddleware.ts b/server/src/external/vercel/misc/vercelMiddleware.ts index 84c71a43a..d67e1c3f5 100644 --- a/server/src/external/vercel/misc/vercelMiddleware.ts +++ b/server/src/external/vercel/misc/vercelMiddleware.ts @@ -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, @@ -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, 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(); }; diff --git a/server/src/external/vercel/misc/vercelProvisioning.ts b/server/src/external/vercel/misc/vercelProvisioning.ts index a95ebba00..38fbc06b7 100644 --- a/server/src/external/vercel/misc/vercelProvisioning.ts +++ b/server/src/external/vercel/misc/vercelProvisioning.ts @@ -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) { diff --git a/server/src/external/vercel/misc/vercelSdkOptions.ts b/server/src/external/vercel/misc/vercelSdkOptions.ts new file mode 100644 index 000000000..8f7ecf918 --- /dev/null +++ b/server/src/external/vercel/misc/vercelSdkOptions.ts @@ -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`; +}; diff --git a/server/src/external/vercel/misc/vercelSignatureMiddleware.ts b/server/src/external/vercel/misc/vercelSignatureMiddleware.ts index 805f8d0aa..76b1f45ba 100644 --- a/server/src/external/vercel/misc/vercelSignatureMiddleware.ts +++ b/server/src/external/vercel/misc/vercelSignatureMiddleware.ts @@ -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, diff --git a/server/src/external/vercel/misc/vercelStripeInvoiceMode.ts b/server/src/external/vercel/misc/vercelStripeInvoiceMode.ts new file mode 100644 index 000000000..e18231cf7 --- /dev/null +++ b/server/src/external/vercel/misc/vercelStripeInvoiceMode.ts @@ -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 => { + 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 => { + 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; + } +}; diff --git a/server/src/external/vercel/vercelTestApiRouter.ts b/server/src/external/vercel/vercelTestApiRouter.ts new file mode 100644 index 000000000..36e45af39 --- /dev/null +++ b/server/src/external/vercel/vercelTestApiRouter.ts @@ -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(); + +// 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, + ); +}); diff --git a/server/src/external/vercel/vercelWebhookRouter.ts b/server/src/external/vercel/vercelWebhookRouter.ts index b681264a6..544cee6b5 100644 --- a/server/src/external/vercel/vercelWebhookRouter.ts +++ b/server/src/external/vercel/vercelWebhookRouter.ts @@ -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, + ); }); diff --git a/server/src/honoMiddlewares/baseMiddleware.ts b/server/src/honoMiddlewares/baseMiddleware.ts index fe88ee6c6..1e4df9e9e 100644 --- a/server/src/honoMiddlewares/baseMiddleware.ts +++ b/server/src/honoMiddlewares/baseMiddleware.ts @@ -127,6 +127,7 @@ export const baseMiddleware = async (c: Context, 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", }, }); diff --git a/server/src/honoUtils/HonoEnv.ts b/server/src/honoUtils/HonoEnv.ts index 55644eef3..1019bed6b 100644 --- a/server/src/honoUtils/HonoEnv.ts +++ b/server/src/honoUtils/HonoEnv.ts @@ -79,6 +79,7 @@ export type RequestContext = { eventId?: string; keepInternalFields?: boolean; useReplica?: boolean; + mockVercelApi?: boolean; }; }; diff --git a/server/src/initHono.ts b/server/src/initHono.ts index b445f1591..55666fb50 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -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) diff --git a/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts b/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts index ba47be7c1..89eaf81d6 100644 --- a/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts +++ b/server/src/internal/balances/autoTopUp/setup/setupAutoTopupContext.ts @@ -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, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts index b2f00e4f9..793b25ef5 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts @@ -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, diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts index e56cd7d11..b5f4ced7c 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -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.", diff --git a/server/src/internal/orgs/handlers/handleVercelConfig.ts b/server/src/internal/orgs/handlers/handleVercelConfig.ts index 09fd45261..0be9041c4 100644 --- a/server/src/internal/orgs/handlers/handleVercelConfig.ts +++ b/server/src/internal/orgs/handlers/handleVercelConfig.ts @@ -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, diff --git a/server/src/utils/logging/addContextToLogs.ts b/server/src/utils/logging/addContextToLogs.ts index 26a3e2d63..b9cfa28a9 100644 --- a/server/src/utils/logging/addContextToLogs.ts +++ b/server/src/utils/logging/addContextToLogs.ts @@ -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, diff --git a/server/src/utils/logging/initLogger.ts b/server/src/utils/logging/initLogger.ts index fbd5cffc9..14ca34d64 100644 --- a/server/src/utils/logging/initLogger.ts +++ b/server/src/utils/logging/initLogger.ts @@ -26,6 +26,7 @@ const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([ "workflow", "trigger", "stripe_event", + "vercel_event", "worker", "extras", "type", diff --git a/server/src/utils/logging/logCaughtError.ts b/server/src/utils/logging/logCaughtError.ts new file mode 100644 index 000000000..7f1265f56 --- /dev/null +++ b/server/src/utils/logging/logCaughtError.ts @@ -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; + level?: LogCaughtErrorLevel; +}) => { + const logFields = caughtErrorToLogFields(error); + const consoleMethod = level === "warn" ? console.warn : console.error; + + consoleMethod(message, error); + logger?.[level](message, { + ...logFields, + ...(data ? { data } : {}), + }); +}; diff --git a/server/src/utils/logging/loggerTypes.ts b/server/src/utils/logging/loggerTypes.ts index 5f852785a..335968eb5 100644 --- a/server/src/utils/logging/loggerTypes.ts +++ b/server/src/utils/logging/loggerTypes.ts @@ -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; diff --git a/server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat-cross-processor-oneoff.test.ts similarity index 100% rename from server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts rename to server/tests/integration/external-psps/revenuecat/revenuecat-cross-processor-oneoff.test.ts diff --git a/server/tests/integration/external-psps/revenuecat-webhooks.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts similarity index 100% rename from server/tests/integration/external-psps/revenuecat-webhooks.test.ts rename to server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts diff --git a/server/tests/integration/external-psps/revenuecat.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts similarity index 100% rename from server/tests/integration/external-psps/revenuecat.test.ts rename to server/tests/integration/external-psps/revenuecat/revenuecat.test.ts diff --git a/server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecatWebhooks.test.ts similarity index 99% rename from server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts rename to server/tests/integration/external-psps/revenuecat/revenuecatWebhooks.test.ts index c6eeb9f68..fe377573f 100644 --- a/server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts +++ b/server/tests/integration/external-psps/revenuecat/revenuecatWebhooks.test.ts @@ -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"; diff --git a/server/tests/integration/external-psps/utils/revenue-cat-webhook-client.ts b/server/tests/integration/external-psps/revenuecat/utils/revenue-cat-webhook-client.ts similarity index 100% rename from server/tests/integration/external-psps/utils/revenue-cat-webhook-client.ts rename to server/tests/integration/external-psps/revenuecat/utils/revenue-cat-webhook-client.ts diff --git a/server/tests/integration/external-psps/vercel/utils/vercel-test-helpers.ts b/server/tests/integration/external-psps/vercel/utils/vercel-test-helpers.ts new file mode 100644 index 000000000..d30ca5513 --- /dev/null +++ b/server/tests/integration/external-psps/vercel/utils/vercel-test-helpers.ts @@ -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:`) 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-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 => { + // 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; +} + +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 => { + 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 => { + 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 => { + 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 => { + 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"], + }); +}; diff --git a/server/tests/integration/external-psps/vercel/utils/vercel-webhook-client.ts b/server/tests/integration/external-psps/vercel/utils/vercel-webhook-client.ts new file mode 100644 index 000000000..2b94139cb --- /dev/null +++ b/server/tests/integration/external-psps/vercel/utils/vercel-webhook-client.ts @@ -0,0 +1,136 @@ +import crypto from "node:crypto"; +import type { AppEnv } from "@autumn/shared"; + +// Worktree dev servers use https://wt-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, + )}`, + ); + } +}; diff --git a/server/tests/integration/external-psps/vercel/vercel-attach-guard.test.ts b/server/tests/integration/external-psps/vercel/vercel-attach-guard.test.ts new file mode 100644 index 000000000..beeedaf63 --- /dev/null +++ b/server/tests/integration/external-psps/vercel/vercel-attach-guard.test.ts @@ -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, + ); + }, +); diff --git a/server/tests/integration/external-psps/vercel/vercel-config-display.test.ts b/server/tests/integration/external-psps/vercel/vercel-config-display.test.ts new file mode 100644 index 000000000..e26c4c8e1 --- /dev/null +++ b/server/tests/integration/external-psps/vercel/vercel-config-display.test.ts @@ -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(); + }); +}); diff --git a/server/tests/integration/external-psps/vercel/vercel-invoice-finalized.test.ts b/server/tests/integration/external-psps/vercel/vercel-invoice-finalized.test.ts new file mode 100644 index 000000000..d8acb7b21 --- /dev/null +++ b/server/tests/integration/external-psps/vercel/vercel-invoice-finalized.test.ts @@ -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, +); diff --git a/server/tests/integration/external-psps/vercel/vercel-marketplace-notpaid.test.ts b/server/tests/integration/external-psps/vercel/vercel-marketplace-notpaid.test.ts new file mode 100644 index 000000000..97447d50a --- /dev/null +++ b/server/tests/integration/external-psps/vercel/vercel-marketplace-notpaid.test.ts @@ -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"); + }, +); diff --git a/server/tests/integration/external-psps/vercel/vercel-marketplace-paid.test.ts b/server/tests/integration/external-psps/vercel/vercel-marketplace-paid.test.ts new file mode 100644 index 000000000..902a02e3d --- /dev/null +++ b/server/tests/integration/external-psps/vercel/vercel-marketplace-paid.test.ts @@ -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; diff --git a/server/tests/integration/external-psps/vercel/vercel-provisioning.test.ts b/server/tests/integration/external-psps/vercel/vercel-provisioning.test.ts new file mode 100644 index 000000000..e00e2f8ec --- /dev/null +++ b/server/tests/integration/external-psps/vercel/vercel-provisioning.test.ts @@ -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:` 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; + }; +}): 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; diff --git a/server/tests/unit/logging/log-caught-error.test.ts b/server/tests/unit/logging/log-caught-error.test.ts new file mode 100644 index 000000000..2c92fccfb --- /dev/null +++ b/server/tests/unit/logging/log-caught-error.test.ts @@ -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", + }); + }); +}); diff --git a/server/tests/unit/logging/vercel-log-context.test.ts b/server/tests/unit/logging/vercel-log-context.test.ts new file mode 100644 index 000000000..acaa01792 --- /dev/null +++ b/server/tests/unit/logging/vercel-log-context.test.ts @@ -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, + }, + }, + }, + ]); + }); +}); diff --git a/server/tests/unit/vercelSdkOptions.test.ts b/server/tests/unit/vercelSdkOptions.test.ts new file mode 100644 index 000000000..e83ad6eec --- /dev/null +++ b/server/tests/unit/vercelSdkOptions.test.ts @@ -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(); + }); +});