fix: customer updated webhook syncs back to autumn
This commit is contained in:
2
ai
2
ai
Submodule ai updated: b1efb8d303...0d561b1747
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
54
server/src/external/stripe/webhookHandlers/handleStripeCustomerUpdated.ts
vendored
Normal file
54
server/src/external/stripe/webhookHandlers/handleStripeCustomerUpdated.ts
vendored
Normal 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}`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user