chore: fix customer email config

This commit is contained in:
Charlie Lamb
2026-01-27 16:07:17 +00:00
parent b7695abdd4
commit 46cf3e97b4
20 changed files with 184 additions and 27 deletions

View File

@@ -468,6 +468,19 @@ export class AutumnInt {
return data;
},
update: async (
customerId: string,
updates: {
name?: string;
email?: string;
send_email_receipts?: boolean;
metadata?: Record<string, unknown>;
},
) => {
const data = await this.patch(`/customers/${customerId}`, updates);
return data;
},
setBalance: async ({
customerId,
balances,

View File

@@ -22,7 +22,7 @@ export const sendEmailReceipt = async ({
return;
}
if (!fullCustomer.should_send_email_receipts) {
if (!fullCustomer.send_email_receipts) {
logger.debug(
"[invoice.paid] Customer has email receipts disabled, skipping",
);

View File

@@ -59,6 +59,7 @@ export const getApiCustomerBase = async ({
),
balances: apiBalances,
send_email_receipts: fullCus.send_email_receipts ?? false,
invoices:
fullCus.invoices && ctx.expand.includes(CusExpand.Invoices)

View File

@@ -48,6 +48,13 @@ export const updateCustomerDetails = async ({
updates.email = customerData.email;
}
}
// Update send_email_receipts if explicitly provided
if (customerData?.send_email_receipts !== undefined) {
const fullCus = customer as FullCustomer;
if (fullCus.send_email_receipts !== customerData.send_email_receipts) {
updates.send_email_receipts = customerData.send_email_receipts;
}
}
if (Object.keys(updates).length > 0) {
logger.info(`Updating customer details:`, {

View File

@@ -119,6 +119,10 @@ export const getCachedFullCustomer = async ({
fullCustomer.extra_customer_entitlements = [];
}
if (!fullCustomer.send_email_receipts) {
fullCustomer.send_email_receipts = false;
}
// Round balance fields to handle floating-point precision from JSON.NUMINCRBY
return roundFullCustomerBalances(fullCustomer);
};

View File

@@ -33,6 +33,7 @@ export const initCustomer = ({
type: "stripe",
}
: null,
send_email_receipts: customerData?.send_email_receipts ?? false,
};
};

View File

@@ -14,6 +14,7 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { notNullish } from "@/utils/genUtils.js";
import { CusService } from "../CusService.js";
import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js";
import { deleteCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
import { getOrSetCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js";
export const handleUpdateCustomerV2 = createRoute({
@@ -128,6 +129,13 @@ export const handleUpdateCustomerV2 = createRoute({
update: updateData,
});
// Invalidate cache after DB update
await deleteCachedFullCustomer({
customerId: customer_id,
ctx,
source: "handleUpdateCustomerV2",
});
// Skip cache to get fresh data after update
ctx.skipCache = true;
const fullCustomer = await getOrSetCachedFullCustomer({

View File

@@ -15,6 +15,7 @@ export const initCustomerV3 = async ({
withDefault = false,
defaultGroup = customerId,
skipWebhooks,
sendEmailReceipts,
}: {
ctx: TestContext;
customerId: string;
@@ -24,10 +25,10 @@ export const initCustomerV3 = async ({
withDefault?: boolean;
defaultGroup?: string;
skipWebhooks?: boolean;
sendEmailReceipts?: boolean;
}) => {
const name = customerId;
const email = `${customerId}@example.com`;
const fingerprint_ = "";
const { stripeCli } = ctx;
const autumn = new AutumnInt({
version: ApiVersion.V1_2,
@@ -62,6 +63,7 @@ export const initCustomerV3 = async ({
email,
fingerprint: customerData?.fingerprint,
stripe_id: stripeCus.id,
send_email_receipts: sendEmailReceipts,
internalOptions: {
disable_defaults: !withDefault,
// Only pass default_group when defaults are enabled

View File

@@ -3,7 +3,7 @@
*
* Tests for the sendEmailReceipt task in the invoice.paid webhook handler.
* Verifies that email receipts are sent (via PaymentIntent.receipt_email)
* based on the customer's should_send_email_receipts flag.
* based on the customer's send_email_receipts flag.
*/
import { expect, test } from "bun:test";
@@ -16,7 +16,7 @@ import { CusService } from "@/internal/customers/CusService";
import { timeout } from "@/utils/genUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Email receipt sent when should_send_email_receipts is true
// TEST 1: Email receipt sent when send_email_receipts is true
// ═══════════════════════════════════════════════════════════════════════════════
/**
@@ -28,7 +28,7 @@ import { timeout } from "@/utils/genUtils";
* Expected Result:
* - PaymentIntent from the renewal invoice should have receipt_email set
*/
test(`${chalk.yellowBright("invoice.paid: sends email receipt when should_send_email_receipts is true")}`, async () => {
test(`${chalk.yellowBright("invoice.paid: sends email receipt when send_email_receipts is true")}`, async () => {
const customerId = "inv-paid-email-receipt-enabled";
const testEmail = "test-receipt@example.com";
@@ -59,7 +59,7 @@ test(`${chalk.yellowBright("invoice.paid: sends email receipt when should_send_e
orgId: ctx.org.id,
env: ctx.env,
update: {
should_send_email_receipts: true,
send_email_receipts: true,
email: testEmail,
},
});
@@ -74,7 +74,7 @@ test(`${chalk.yellowBright("invoice.paid: sends email receipt when should_send_e
orgId: ctx.org.id,
env: ctx.env,
});
expect(updatedCustomer.should_send_email_receipts).toBe(true);
expect(updatedCustomer.send_email_receipts).toBe(true);
expect(updatedCustomer.email).toBe(testEmail);
// Step 3: Advance to next billing cycle - this triggers invoice.paid webhook
@@ -121,19 +121,19 @@ test(`${chalk.yellowBright("invoice.paid: sends email receipt when should_send_e
}, 120000); // 2 minute timeout for test clock operations
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Email receipt NOT sent when should_send_email_receipts is false
// TEST 2: Email receipt NOT sent when send_email_receipts is false
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Create customer and attach paid product
* - Customer has should_send_email_receipts: false (default)
* - Customer has send_email_receipts: false (default)
* - Advance to next billing cycle (triggers invoice.paid)
*
* Expected Result:
* - PaymentIntent from the renewal invoice should NOT have receipt_email set
*/
test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when should_send_email_receipts is false")}`, async () => {
test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when send_email_receipts is false")}`, async () => {
const customerId = "inv-paid-email-receipt-disabled";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
@@ -143,7 +143,7 @@ test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when shoul
});
// Step 1: Create customer and attach product
// Customer has should_send_email_receipts: false by default
// Customer has send_email_receipts: false by default
const { ctx, customer, testClockId } = await initScenario({
customerId,
setup: [
@@ -158,7 +158,7 @@ test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when shoul
expect(testClockId).toBeDefined();
// Step 2: Advance to next billing cycle - this triggers invoice.paid webhook
// Since should_send_email_receipts is false, receipt_email should NOT be set
// Since send_email_receipts is false, receipt_email should NOT be set
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
@@ -243,7 +243,7 @@ test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when custo
orgId: ctx.org.id,
env: ctx.env,
update: {
should_send_email_receipts: true,
send_email_receipts: true,
email: "", // Empty email
},
});
@@ -258,7 +258,7 @@ test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when custo
orgId: ctx.org.id,
env: ctx.env,
});
expect(updatedCustomer.should_send_email_receipts).toBe(true);
expect(updatedCustomer.send_email_receipts).toBe(true);
expect(updatedCustomer.email).toBe("");
// Step 3: Advance to next billing cycle - this triggers invoice.paid webhook

View File

@@ -106,3 +106,44 @@ test.concurrent(`${chalk.yellowBright("create: null ID no email (error)")}`, asy
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// SEND EMAIL RECEIPTS TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("create: with send_email_receipts true")}`, async () => {
const customerId = "create-send-email-receipts-true";
const { autumnV1 } = await initScenario({
customerId,
setup: [s.deleteCustomer({ customerId })],
actions: [],
});
const data = await autumnV1.customers.create({
id: customerId,
name: "Email Receipts Customer",
email: `${customerId}@example.com`,
send_email_receipts: true,
});
expect(data.id).toBe(customerId);
expect(data.send_email_receipts).toBe(true);
});
test.concurrent(`${chalk.yellowBright("create: send_email_receipts defaults to false")}`, async () => {
const customerId = "create-send-email-receipts-default";
const { autumnV1 } = await initScenario({
customerId,
setup: [s.deleteCustomer({ customerId })],
actions: [],
});
const data = await autumnV1.customers.create({
id: customerId,
name: "No Email Receipts Customer",
email: `${customerId}@example.com`,
});
expect(data.id).toBe(customerId);
expect(data.send_email_receipts).toBe(false);
});

View File

@@ -0,0 +1,70 @@
import { expect, test } from "bun:test";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// UPDATE CUSTOMER TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("update: send_email_receipts can be updated")}`, async () => {
const customerId = "update-send-email-receipts";
const { autumnV1 } = await initScenario({
customerId,
setup: [s.deleteCustomer({ customerId })],
actions: [],
});
// Create customer without send_email_receipts
const createData = await autumnV1.customers.create({
id: customerId,
name: "Update Email Receipts Customer",
email: `${customerId}@example.com`,
});
expect(createData.send_email_receipts).toBe(false);
// Update to enable send_email_receipts
const updateData = await autumnV1.customers.update(customerId, {
send_email_receipts: true,
});
expect(updateData.send_email_receipts).toBe(true);
// Verify by getting the customer
const getData = (await autumnV1.customers.get(customerId)) as {
send_email_receipts: boolean;
};
expect(getData.send_email_receipts).toBe(true);
});
test.concurrent(`${chalk.yellowBright("update: send_email_receipts can be disabled")}`, async () => {
const customerId = "update-send-email-receipts-disable";
const { autumnV1 } = await initScenario({
customerId,
setup: [s.deleteCustomer({ customerId })],
actions: [],
});
// Create customer with send_email_receipts enabled
const createData = await autumnV1.customers.create({
id: customerId,
name: "Disable Email Receipts Customer",
email: `${customerId}@example.com`,
send_email_receipts: true,
});
expect(createData.send_email_receipts).toBe(true);
// Update to disable send_email_receipts
const updateData = await autumnV1.customers.update(customerId, {
send_email_receipts: false,
});
expect(updateData.send_email_receipts).toBe(false);
// Verify by getting the customer
const getData = (await autumnV1.customers.get(customerId)) as {
send_email_receipts: boolean;
};
expect(getData.send_email_receipts).toBe(false);
});

View File

@@ -2,7 +2,7 @@
* Unit tests for sendEmailReceipt function.
*
* Tests the logic that sets receipt_email on PaymentIntent
* based on customer's should_send_email_receipts flag.
* based on customer's send_email_receipts flag.
*/
import { describe, expect, test } from "bun:test";
@@ -38,7 +38,7 @@ const createMockFullCustomer = (
customer_products: [],
entities: [],
extra_customer_entitlements: [],
should_send_email_receipts: true,
send_email_receipts: true,
...overrides,
});
@@ -81,13 +81,13 @@ describe(chalk.yellowBright("sendEmailReceipt"), () => {
expect(mockCli._calls.paymentIntents.update).toHaveLength(0);
});
test("returns when should_send_email_receipts is false", async () => {
test("returns when send_email_receipts is false", async () => {
const mockCli = stripeClients.createMockStripeClient();
const ctx = {
stripeCli: mockCli,
logger: createMockLogger(),
fullCustomer: createMockFullCustomer({
should_send_email_receipts: false,
send_email_receipts: false,
}),
} as unknown as StripeWebhookContext;
@@ -100,13 +100,13 @@ describe(chalk.yellowBright("sendEmailReceipt"), () => {
expect(mockCli._calls.paymentIntents.update).toHaveLength(0);
});
test("returns when should_send_email_receipts is undefined", async () => {
test("returns when send_email_receipts is undefined", async () => {
const mockCli = stripeClients.createMockStripeClient();
const ctx = {
stripeCli: mockCli,
logger: createMockLogger(),
fullCustomer: createMockFullCustomer({
should_send_email_receipts: undefined as unknown as boolean,
send_email_receipts: undefined as unknown as boolean,
}),
} as unknown as StripeWebhookContext;

View File

@@ -23,7 +23,7 @@ const create = ({
customer_products: customerProducts,
entities: [],
extra_customer_entitlements: [],
should_send_email_receipts: false,
send_email_receipts: false,
});
// ═══════════════════════════════════════════════════════════════════

View File

@@ -108,6 +108,7 @@ type ScenarioConfig = {
withDefault: boolean;
defaultGroup?: string;
skipWebhooks?: boolean;
sendEmailReceipts?: boolean;
products: ProductV2[];
productPrefix?: string;
entityConfig?: EntityConfig;
@@ -146,10 +147,12 @@ const generateEntities = (config: EntityConfig): GeneratedEntity[] => {
* @param withDefault - Attach the default product on creation (default: false)
* @param defaultGroup - The product group to use for default product selection
* @param skipWebhooks - Skip sending webhooks for this customer creation (default: undefined, uses server default)
* @param send_email_receipts - Whether to send email receipts to the customer
* @example s.customer({ paymentMethod: "success" })
* @example s.customer({ paymentMethod: "success", data: { name: "Test" } })
* @example s.customer({ withDefault: true, defaultGroup: "enterprise" })
* @example s.customer({ withDefault: true, skipWebhooks: false }) // Enable webhooks for testing
* @example s.customer({ paymentMethod: "success", send_email_receipts: true })
*/
const customer = ({
testClock = true,
@@ -158,6 +161,7 @@ const customer = ({
withDefault,
defaultGroup,
skipWebhooks,
send_email_receipts,
}: {
testClock?: boolean;
paymentMethod?: "success" | "fail" | "authenticate";
@@ -165,6 +169,7 @@ const customer = ({
withDefault?: boolean;
defaultGroup?: string;
skipWebhooks?: boolean;
send_email_receipts?: boolean;
}): ConfigFn => {
return (config) => ({
...config,
@@ -174,6 +179,7 @@ const customer = ({
withDefault: withDefault ?? config.withDefault,
defaultGroup: defaultGroup ?? config.defaultGroup,
skipWebhooks: skipWebhooks ?? config.skipWebhooks,
sendEmailReceipts: send_email_receipts ?? config.sendEmailReceipts,
});
};
@@ -717,6 +723,7 @@ export async function initScenario({
// Default group matches the product prefix (customerId) used in initProductsV0
defaultGroup: config.defaultGroup ?? customerId,
skipWebhooks: config.skipWebhooks,
sendEmailReceipts: config.sendEmailReceipts,
});
testClockId = result.testClockId;
customer = result.customer;

View File

@@ -45,7 +45,7 @@ export const ExtCustomerDataSchema = z
internal: true,
description: "External processors for the customer",
}),
should_send_email_receipts: z.boolean().optional().meta({
send_email_receipts: z.boolean().optional().meta({
description: "Whether to send email receipts to this customer",
}),
})

View File

@@ -34,6 +34,7 @@ export const BaseApiCustomerSchema = z
subscriptions: z.array(ApiSubscriptionSchema),
scheduled_subscriptions: z.array(ApiSubscriptionSchema),
balances: z.record(z.string(), ApiBalanceSchema),
send_email_receipts: z.boolean(),
})
.meta({
id: "BaseCustomer",

View File

@@ -100,6 +100,7 @@ export const V1_2_CustomerChange = defineVersionChange({
stripe_id: input.stripe_id,
env: input.env,
metadata: input.metadata,
send_email_receipts: input.send_email_receipts,
products: v3CusProducts,
features: v3_features,

View File

@@ -173,6 +173,9 @@ export const ApiCustomerV3Schema = z.object({
metadata: z.record(z.any(), z.any()).default({}).meta({
description: cusDescriptions.metadata,
}),
send_email_receipts: z.boolean().default(false).meta({
description: "Whether to send email receipts to the customer.",
}),
products: z.array(ApiCusProductV3Schema).meta({
description: cusDescriptions.products,
}),

View File

@@ -16,7 +16,7 @@ export const CustomerSchema = z.object({
processor: z.any(),
processors: ExternalProcessorsSchema.nullish(),
metadata: z.record(z.any(), z.any()).nullish().default({}),
should_send_email_receipts: z.boolean().default(false),
send_email_receipts: z.boolean().default(false),
});
export type Customer = z.infer<typeof CustomerSchema>;
@@ -69,7 +69,7 @@ export const CreateCustomerSchema = z.object({
metadata: z.record(z.any(), z.any()).default({}).nullish(),
stripe_id: z.string().nullish(),
processors: ExternalProcessorsSchema.nullish(),
should_send_email_receipts: z.boolean().default(false),
send_email_receipts: z.boolean().default(false),
});
export type CreateCustomer = z.infer<typeof CreateCustomerSchema>;

View File

@@ -34,9 +34,7 @@ export const customers = pgTable(
processors: jsonb()
.$type<ExternalProcessors>()
.default({} as ExternalProcessors),
should_send_email_receipts: boolean("should_send_email_receipts").default(
false,
),
send_email_receipts: boolean("send_email_receipts").default(false),
},
(table) => [
unique("cus_id_constraint").on(table.org_id, table.id, table.env),