fix: customer updated webhook syncs back to autumn

This commit is contained in:
johnyeo
2026-05-29 15:54:25 +01:00
parent 5f54383181
commit 1228fe2ff9
8 changed files with 472 additions and 2 deletions

2
ai

Submodule ai updated: b1efb8d303...0d561b1747

View File

@@ -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)

View File

@@ -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;

View File

@@ -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}`,
);
}

View File

@@ -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;
}

View File

@@ -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<ReturnType<typeof initScenario>>["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<ReturnType<typeof initScenario>>["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);
},
);

View File

@@ -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(() => {});
}
},
);

View File

@@ -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<void> => {
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<ApiCustomerV3> => {
const customer = await autumn.customers.get<ApiCustomerV3>(customerId);
if (name !== undefined) expect(customer.name).toBe(name);
if (email !== undefined) expect(customer.email).toBe(email);
return customer;
};