From 1228fe2ff955fa31db7a80b391df90bc5b719a79 Mon Sep 17 00:00:00 2001 From: johnyeo Date: Fri, 29 May 2026 15:54:25 +0100 Subject: [PATCH] fix: customer updated webhook syncs back to autumn --- ai | 2 +- .../external/stripe/common/stripeConstants.ts | 2 +- .../stripe/handleStripeWebhookEvent.ts | 5 + .../handleStripeCustomerUpdated.ts | 54 +++++ .../stripeToAutumnCustomerMiddleware.ts | 1 + ...checkout-reward-trial-free-product.test.ts | 137 +++++++++++ .../customer-updated-email.test.ts | 215 ++++++++++++++++++ .../customerUpdatedTestUtils.ts | 58 +++++ 8 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeCustomerUpdated.ts create mode 100644 server/tests/integration/billing/stripe-webhooks/checkout-session-completed/checkout-reward-trial-free-product.test.ts create mode 100644 server/tests/integration/billing/stripe-webhooks/customer-updated/customer-updated-email.test.ts create mode 100644 server/tests/integration/billing/stripe-webhooks/customer-updated/customerUpdatedTestUtils.ts diff --git a/ai b/ai index b1efb8d30..0d561b174 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit b1efb8d303caf7fdc76d820b00ad87d305f6867f +Subproject commit 0d561b1747e8f47190a01d7a9bff7d8fcb42c9dd diff --git a/server/src/external/stripe/common/stripeConstants.ts b/server/src/external/stripe/common/stripeConstants.ts index a3764df97..c2ced7dc6 100644 --- a/server/src/external/stripe/common/stripeConstants.ts +++ b/server/src/external/stripe/common/stripeConstants.ts @@ -6,6 +6,7 @@ type StripeEventType = Stripe.WebhookEndpointCreateParams.EnabledEvent; export const MAIN_STRIPE_EVENT_TYPES: StripeEventType[] = [ "checkout.session.completed", "checkout.session.expired", + "customer.updated", "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", @@ -23,7 +24,6 @@ export const MAIN_STRIPE_EVENT_TYPES: StripeEventType[] = [ export const SYNC_STRIPE_EVENT_TYPES: StripeEventType[] = [ // customers "customer.created", - "customer.updated", "customer.deleted", // subscriptions (extras beyond main) diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index c5e99ea8e..9b847a297 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -8,6 +8,7 @@ import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js"; import { handleWebhookErrorSkip } from "@/utils/routerUtils/webhookErrorSkip.js"; import { getSentryTags } from "../sentry/sentryUtils.js"; import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; +import { handleStripeCustomerUpdated } from "./webhookHandlers/handleStripeCustomerUpdated.js"; import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js"; import { handleStripeCheckoutSessionCompleted } from "./webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.js"; import { handleStripeCheckoutSessionExpired } from "./webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.js"; @@ -34,6 +35,10 @@ export const handleStripeWebhookEvent = async ( try { switch (event.type) { + case "customer.updated": + await handleStripeCustomerUpdated({ ctx, event }); + break; + case "customer.subscription.created": await handleStripeSubscriptionCreated({ ctx }); break; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCustomerUpdated.ts b/server/src/external/stripe/webhookHandlers/handleStripeCustomerUpdated.ts new file mode 100644 index 000000000..2c3326eda --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeCustomerUpdated.ts @@ -0,0 +1,54 @@ +import { notNullish } from "@autumn/shared"; +import type Stripe from "stripe"; +import { CusService } from "@/internal/customers/CusService.js"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; +import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js"; + +const SYNCED_FIELDS = ["name", "email"] as const; + +/** + * Syncs a Stripe customer's name + email to the linked Autumn customer on + * `customer.updated`. Each field is synced independently; an unchanged or + * cleared (empty/null) field is left as-is so a partial update never clobbers + * Autumn's stored value. + */ +export async function handleStripeCustomerUpdated({ + ctx, + event, +}: { + ctx: StripeWebhookContext; + event: Stripe.CustomerUpdatedEvent; +}) { + const { logger, fullCustomer } = ctx; + if (!fullCustomer) return; + + const stripeCustomer = event.data.object; + const update: { name?: string; email?: string } = {}; + + for (const field of SYNCED_FIELDS) { + const newValue = stripeCustomer[field]; + if (!notNullish(newValue) || newValue === "") continue; + if (fullCustomer[field] === newValue) continue; + update[field] = newValue; + } + + if (!update.name && !update.email) return; + + const idOrInternalId = fullCustomer.id || fullCustomer.internal_id; + + await CusService.update({ + ctx, + idOrInternalId, + update, + }); + + await deleteCachedFullCustomer({ + ctx, + customerId: idOrInternalId, + source: "customer.updated: detail sync", + }); + + logger.info( + `[customer.updated] synced ${Object.keys(update).join(", ")} for customer ${fullCustomer.id}`, + ); +} diff --git a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts index 5776ee70e..27e48f3ea 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts @@ -26,6 +26,7 @@ const getAutumnCustomerId = async ({ ctx }: { ctx: StripeWebhookContext }) => { case "subscription_schedule.canceled": return stripeEvent.data.object.customer; + case "customer.updated": case "customer.discount.deleted": return stripeEvent.data.object.id; } diff --git a/server/tests/integration/billing/stripe-webhooks/checkout-session-completed/checkout-reward-trial-free-product.test.ts b/server/tests/integration/billing/stripe-webhooks/checkout-session-completed/checkout-reward-trial-free-product.test.ts new file mode 100644 index 000000000..817895a2d --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/checkout-session-completed/checkout-reward-trial-free-product.test.ts @@ -0,0 +1,137 @@ +/** + * Regression coverage for the micro-org sandbox config: when=checkout, + * exclude_trial=false, received_by=all, reward = FREE PRODUCT (credits bonus add-on). + * + * The existing checkout-reward-trial.test.ts only covers a DISCOUNT reward + + * received_by=referrer; this pins the free-product + received_by=all variant. + * + * Behavior: after the redeemer completes Stripe checkout for a trial product, the + * referral reward must be granted even while the redeemer is trialing (because + * exclude_trial=false) — redemption.triggered && redemption.applied, and BOTH the + * redeemer and the referrer must hold the bonus product. (Verified green on main: + * the reported failure occurred under exclude_trial=true, which correctly defers.) + */ + +import { expect, test } from "bun:test"; +import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { referralPrograms } from "@tests/utils/fixtures/referralPrograms"; +import { rewards } from "@tests/utils/fixtures/rewards"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +const waitForRedemptionApplied = async ({ + autumnV1, + redemptionId, +}: { + autumnV1: Awaited>["autumnV1"]; + redemptionId: string; +}) => { + for (let i = 0; i < 25; i++) { + const redemption = await autumnV1.redemptions.get(redemptionId); + if (redemption.triggered && redemption.applied) return redemption; + await timeout(1000); + } + + return autumnV1.redemptions.get(redemptionId); +}; + +const customerHasProduct = async ({ + autumnV1, + customerId, + productId, +}: { + autumnV1: Awaited>["autumnV1"]; + customerId: string; + productId: string; +}) => { + const customer = await autumnV1.customers.get(customerId); + return (customer.products ?? []).some((p: { id: string }) => p.id === productId); +}; + +test.concurrent( + `${chalk.yellowBright("checkout-reward-trial-free-product: free-product reward (received_by=all) applies on trial checkout when exclude_trial=false")}`, + async () => { + const referrerId = "checkout-reward-trial-fp-referrer"; + const redeemerId = "checkout-reward-trial-fp-redeemer"; + + const proTrial = products.proWithTrial({ + id: "pro-trial-fp-reward", + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 7, + cardRequired: true, + }); + + // Free add-on (no price) granting credits — mirrors `1k_credits_referral_bonus`. + const bonus = products.base({ + id: "credits-bonus-reward", + isAddOn: true, + items: [items.lifetimeMessages({ includedUsage: 1000 })], + }); + + const reward = rewards.freeProduct({ + id: "free-product-reward", + freeProductId: bonus.id, + }); + const program = { + ...referralPrograms.onCheckoutBoth({ + rewardId: reward.id, + productIds: [proTrial.id], + maxRedemptions: 100, + }), + exclude_trial: false, + }; + + const { autumnV1, redemption } = await initScenario({ + customerId: referrerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.otherCustomers([{ id: redeemerId }]), + s.products({ list: [proTrial, bonus] }), + s.referralProgram({ reward, program }), + ], + actions: [ + s.attach({ productId: "pro-trial-fp-reward" }), + s.referral.createAndRedeem({ customerId: redeemerId }), + ], + }); + + // Redeemer checks out the trial product (no PM → Stripe checkout URL). + const result = await autumnV1.billing.attach({ + customer_id: redeemerId, + product_id: proTrial.id, + }); + + expect(result.payment_url).toBeDefined(); + expect(result.payment_url).toContain("checkout.stripe.com"); + + await completeStripeCheckoutFormV2({ url: result.payment_url! }); + + const updatedRedemption = await waitForRedemptionApplied({ + autumnV1, + redemptionId: redemption!.id, + }); + + // Primary symptom the customer reported: reward_applied stayed false. + expect(updatedRedemption.triggered).toBe(true); + expect(updatedRedemption.applied).toBe(true); + + // received_by=all → both parties must actually receive the bonus product. + // `bonus.id` is mutated to the prefixed id by initScenario. + const redeemerGotBonus = await customerHasProduct({ + autumnV1, + customerId: redeemerId, + productId: bonus.id, + }); + const referrerGotBonus = await customerHasProduct({ + autumnV1, + customerId: referrerId, + productId: bonus.id, + }); + + expect(redeemerGotBonus).toBe(true); + expect(referrerGotBonus).toBe(true); + }, +); diff --git a/server/tests/integration/billing/stripe-webhooks/customer-updated/customer-updated-email.test.ts b/server/tests/integration/billing/stripe-webhooks/customer-updated/customer-updated-email.test.ts new file mode 100644 index 000000000..28952e058 --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/customer-updated/customer-updated-email.test.ts @@ -0,0 +1,215 @@ +/** + * TDD test for syncing a Stripe customer's name + email to Autumn on `customer.updated`. + * + * Contract under test: + * New behaviors (applied independently to `name` and `email`): + * - customer.updated with a changed, non-empty value -> Autumn customer field + * updated to match Stripe. + * - a field that did not change is left untouched (changing email never rewrites + * name, and vice versa). + * - customer.updated where nothing relevant changed (e.g. metadata-only) -> no-op. + * - customer.updated where a value is cleared (empty/null) in Stripe -> existing + * Autumn value preserved (no clobber). + * - customer.updated for a Stripe customer with no linked Autumn customer + * -> no-op, no crash. + * Side effects: + * - customers.name / customers.email columns updated; FullCustomer cache + * invalidated so the API reflects the change. + * Config: + * - customer.updated handled by handleStripeCustomerUpdated (in + * MAIN_STRIPE_EVENT_TYPES / "core"). + * + * Pre-impl (name) red: the name-sync assertions fail because the handler only syncs + * email. The guard assertions hold pre-impl and protect against over-reaching. + */ + +import { expect, test } from "bun:test"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; +import { + expectCustomerDetails, + getStripeCustomerId, + updateStripeCustomerAndWait, +} from "./customerUpdatedTestUtils.js"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: changed email syncs; the unchanged name is left alone +// ═══════════════════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("customer.updated: changed email syncs (name untouched)")}`, + async () => { + const customerId = "cus-updated-email-sync"; + + const { autumnV1, customer, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + const newEmail = `${customerId}-updated@example.com`; + await updateStripeCustomerAndWait({ + ctx, + stripeCustomerId: getStripeCustomerId(customer), + update: { email: newEmail }, + }); + + await expectCustomerDetails({ + autumn: autumnV1, + customerId, + email: newEmail, + name: customerId, // initCustomerV3 default name; must be untouched + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: changed name syncs; the unchanged email is left alone +// ═══════════════════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("customer.updated: changed name syncs (email untouched)")}`, + async () => { + const customerId = "cus-updated-name-sync"; + + const { autumnV1, customer, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + const newName = "Renamed Customer"; + await updateStripeCustomerAndWait({ + ctx, + stripeCustomerId: getStripeCustomerId(customer), + update: { name: newName }, + }); + + await expectCustomerDetails({ + autumn: autumnV1, + customerId, + name: newName, + email: `${customerId}@example.com`, // unchanged + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: name + email both change -> both sync in one event +// ═══════════════════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("customer.updated: name and email both sync")}`, + async () => { + const customerId = "cus-updated-both-sync"; + + const { autumnV1, customer, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + const newName = "Both Changed"; + const newEmail = `${customerId}-both@example.com`; + await updateStripeCustomerAndWait({ + ctx, + stripeCustomerId: getStripeCustomerId(customer), + update: { name: newName, email: newEmail }, + }); + + await expectCustomerDetails({ + autumn: autumnV1, + customerId, + name: newName, + email: newEmail, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: no-op — a metadata-only change touches neither name nor email +// ═══════════════════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("customer.updated: metadata-only change is a no-op")}`, + async () => { + const customerId = "cus-updated-metadata-only"; + + const { autumnV1, customer, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await updateStripeCustomerAndWait({ + ctx, + stripeCustomerId: getStripeCustomerId(customer), + update: { metadata: { changed: "true" } }, + }); + + await expectCustomerDetails({ + autumn: autumnV1, + customerId, + name: customerId, + email: `${customerId}@example.com`, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: guard — cleared name/email in Stripe do NOT clobber Autumn values +// ═══════════════════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("customer.updated: cleared name/email do not clobber Autumn")}`, + async () => { + const customerId = "cus-updated-cleared"; + + const { autumnV1, customer, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + // Empty strings clear both fields on the Stripe customer (object -> null). + await updateStripeCustomerAndWait({ + ctx, + stripeCustomerId: getStripeCustomerId(customer), + update: { name: "", email: "" }, + }); + + await expectCustomerDetails({ + autumn: autumnV1, + customerId, + name: customerId, + email: `${customerId}@example.com`, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 6: robustness — customer.updated for an unlinked Stripe customer is a no-op +// ═══════════════════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("customer.updated: unlinked Stripe customer is a safe no-op")}`, + async () => { + const { ctx } = await initScenario({ setup: [], actions: [] }); + + const orphan = await ctx.stripeCli.customers.create({ + name: "Orphan Before", + email: "orphan-before@example.com", + }); + + try { + await updateStripeCustomerAndWait({ + ctx, + stripeCustomerId: orphan.id, + update: { name: "Orphan After", email: "orphan-after@example.com" }, + }); + + const linked = await CusService.getByStripeId({ + ctx, + stripeId: orphan.id, + }); + expect(linked).toBeNull(); + } finally { + await ctx.stripeCli.customers.del(orphan.id).catch(() => {}); + } + }, +); diff --git a/server/tests/integration/billing/stripe-webhooks/customer-updated/customerUpdatedTestUtils.ts b/server/tests/integration/billing/stripe-webhooks/customer-updated/customerUpdatedTestUtils.ts new file mode 100644 index 000000000..21af0529d --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/customer-updated/customerUpdatedTestUtils.ts @@ -0,0 +1,58 @@ +import { expect } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import type Stripe from "stripe"; +import type { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; + +/** Default time to wait for a `customer.updated` webhook to round-trip and process. */ +export const CUSTOMER_UPDATED_WAIT_MS = 8000; + +/** Stripe customer id for a customer created via initScenario. */ +export const getStripeCustomerId = (customer: { + processor?: { id?: string } | null; +} | null): string => { + const stripeId = customer?.processor?.id; + if (!stripeId) { + throw new Error("Customer has no linked Stripe id (processor.id)"); + } + return stripeId; +}; + +/** Update a Stripe customer, then wait for the `customer.updated` webhook to process. */ +export const updateStripeCustomerAndWait = async ({ + ctx, + stripeCustomerId, + update, + waitMs = CUSTOMER_UPDATED_WAIT_MS, +}: { + ctx: TestContext; + stripeCustomerId: string; + update: Stripe.CustomerUpdateParams; + waitMs?: number; +}): Promise => { + await ctx.stripeCli.customers.update(stripeCustomerId, update); + await timeout(waitMs); +}; + +/** + * Assert the Autumn customer's `name` and/or `email`. Only the fields you pass are + * checked, so a caller can assert one field changed while the other stayed put. + * Returns the fetched customer. + */ +export const expectCustomerDetails = async ({ + autumn, + customerId, + name, + email, +}: { + autumn: AutumnInt; + customerId: string; + name?: string | null; + email?: string | null; +}): Promise => { + const customer = await autumn.customers.get(customerId); + if (name !== undefined) expect(customer.name).toBe(name); + if (email !== undefined) expect(customer.email).toBe(email); + return customer; +};