diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 2bbb6971e..e6b72a038 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -468,6 +468,19 @@ export class AutumnInt { return data; }, + update: async ( + customerId: string, + updates: { + name?: string; + email?: string; + send_email_receipts?: boolean; + metadata?: Record; + }, + ) => { + const data = await this.patch(`/customers/${customerId}`, updates); + return data; + }, + setBalance: async ({ customerId, balances, diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt.ts index 652b047ec..b296973ba 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt.ts @@ -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", ); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index 68400b4d4..403158a1a 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -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) diff --git a/server/src/internal/customers/cusUtils/cusUtils.ts b/server/src/internal/customers/cusUtils/cusUtils.ts index df649c07c..c42a00964 100644 --- a/server/src/internal/customers/cusUtils/cusUtils.ts +++ b/server/src/internal/customers/cusUtils/cusUtils.ts @@ -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:`, { diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts index 2a1fc1256..4d6d0dc85 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts @@ -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); }; diff --git a/server/src/internal/customers/cusUtils/initCustomer.ts b/server/src/internal/customers/cusUtils/initCustomer.ts index da8e1aa04..8ae78a6e9 100644 --- a/server/src/internal/customers/cusUtils/initCustomer.ts +++ b/server/src/internal/customers/cusUtils/initCustomer.ts @@ -33,6 +33,7 @@ export const initCustomer = ({ type: "stripe", } : null, + send_email_receipts: customerData?.send_email_receipts ?? false, }; }; diff --git a/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts b/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts index 021a67698..502756b28 100644 --- a/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts +++ b/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts @@ -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({ diff --git a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts index 85224394e..f2679f6a1 100644 --- a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts +++ b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts @@ -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 diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts index e1b0a4d65..bd7c64b08 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts @@ -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 diff --git a/server/tests/integration/crud/customers/create-customer.test.ts b/server/tests/integration/crud/customers/create-customer.test.ts index 44af654b2..69fcfcff8 100644 --- a/server/tests/integration/crud/customers/create-customer.test.ts +++ b/server/tests/integration/crud/customers/create-customer.test.ts @@ -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); +}); diff --git a/server/tests/integration/crud/customers/update-customer.test.ts b/server/tests/integration/crud/customers/update-customer.test.ts new file mode 100644 index 000000000..3d194835c --- /dev/null +++ b/server/tests/integration/crud/customers/update-customer.test.ts @@ -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); +}); diff --git a/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts b/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts index fc8329f67..69c9ac44b 100644 --- a/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts +++ b/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts @@ -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; diff --git a/server/tests/utils/fixtures/db/customers.ts b/server/tests/utils/fixtures/db/customers.ts index 930eb1644..34ce7bfc9 100644 --- a/server/tests/utils/fixtures/db/customers.ts +++ b/server/tests/utils/fixtures/db/customers.ts @@ -23,7 +23,7 @@ const create = ({ customer_products: customerProducts, entities: [], extra_customer_entitlements: [], - should_send_email_receipts: false, + send_email_receipts: false, }); // ═══════════════════════════════════════════════════════════════════ diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index d19580a87..cb10ab157 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -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; diff --git a/shared/api/common/customerData.ts b/shared/api/common/customerData.ts index d2d26520e..662a8dfda 100644 --- a/shared/api/common/customerData.ts +++ b/shared/api/common/customerData.ts @@ -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", }), }) diff --git a/shared/api/customers/apiCustomer.ts b/shared/api/customers/apiCustomer.ts index acfa1e5e8..4a9acae96 100644 --- a/shared/api/customers/apiCustomer.ts +++ b/shared/api/customers/apiCustomer.ts @@ -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", diff --git a/shared/api/customers/changes/V1.2_CustomerChange.ts b/shared/api/customers/changes/V1.2_CustomerChange.ts index 897cf7248..b4710d6ce 100644 --- a/shared/api/customers/changes/V1.2_CustomerChange.ts +++ b/shared/api/customers/changes/V1.2_CustomerChange.ts @@ -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, diff --git a/shared/api/customers/previousVersions/apiCustomerV3.ts b/shared/api/customers/previousVersions/apiCustomerV3.ts index 74206cefe..fcd20e5f3 100644 --- a/shared/api/customers/previousVersions/apiCustomerV3.ts +++ b/shared/api/customers/previousVersions/apiCustomerV3.ts @@ -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, }), diff --git a/shared/models/cusModels/cusModels.ts b/shared/models/cusModels/cusModels.ts index e9a14d388..836834ee9 100644 --- a/shared/models/cusModels/cusModels.ts +++ b/shared/models/cusModels/cusModels.ts @@ -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; @@ -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; diff --git a/shared/models/cusModels/cusTable.ts b/shared/models/cusModels/cusTable.ts index bd96b58e6..104bb498f 100644 --- a/shared/models/cusModels/cusTable.ts +++ b/shared/models/cusModels/cusTable.ts @@ -34,9 +34,7 @@ export const customers = pgTable( processors: jsonb() .$type() .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),