diff --git a/supabase/functions/_backend/private/set_org_email.ts b/supabase/functions/_backend/private/set_org_email.ts index a0091fefd1..bc72dd0832 100644 --- a/supabase/functions/_backend/private/set_org_email.ts +++ b/supabase/functions/_backend/private/set_org_email.ts @@ -1,10 +1,11 @@ import type { MiddlewareKeyVariables } from '../utils/hono.ts' import { z } from 'zod' import { Hono } from 'hono/tiny' -import { safeParseSchema } from '../utils/schema_validation.ts' +import { syncBillingBentoTagsFromStoredStripeInfo } from '../triggers/stripe_event.ts' import { BRES, parseBody, quickError, simpleError, useCors } from '../utils/hono.ts' import { middlewareAuth } from '../utils/hono_middleware.ts' import { checkPermission } from '../utils/rbac.ts' +import { safeParseSchema } from '../utils/schema_validation.ts' import { updateCustomerEmail } from '../utils/stripe.ts' import { supabaseAdmin, supabaseWithAuth } from '../utils/supabase.ts' @@ -32,7 +33,7 @@ app.post('/', middlewareAuth(), async (c) => { const supabase = supabaseWithAuth(c, auth) const { data: organization, error: organizationError } = await supabase.from('orgs') - .select('customer_id, management_email') + .select('customer_id, management_email, created_by, name') .eq('id', safeBody.org_id) .maybeSingle() @@ -67,5 +68,13 @@ app.post('/', middlewareAuth(), async (c) => { throw simpleError('critical_error', 'Critical error', { updateOrgErr, orgId: safeBody.org_id }) } + await syncBillingBentoTagsFromStoredStripeInfo(c, { + id: safeBody.org_id, + name: organization.name, + management_email: safeBody.email, + created_by: organization.created_by, + customer_id: organization.customer_id, + }, organization.customer_id) + return c.json(BRES) }) diff --git a/supabase/functions/_backend/triggers/stripe_event.ts b/supabase/functions/_backend/triggers/stripe_event.ts index c31085032f..a1a7f90ea6 100644 --- a/supabase/functions/_backend/triggers/stripe_event.ts +++ b/supabase/functions/_backend/triggers/stripe_event.ts @@ -15,6 +15,7 @@ import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' import * as schema from '../utils/postgres_schema.ts' import { groupIdentifyPosthog } from '../utils/posthog.ts' import { ensureCustomerMetadata, getCreditCheckoutDetails, getStripe, syncStripeCustomerCountry } from '../utils/stripe.ts' +import { normalizeBillingEmail } from '../utils/stripe_event.ts' import { customerToSegmentOrg, supabaseAdmin } from '../utils/supabase.ts' import { sendEventToTracking } from '../utils/tracking.ts' import { purgeOnPremCacheForOrg, purgePlanCacheForOrg } from '../utils/cloudflare_cache_purge.ts' @@ -61,6 +62,7 @@ interface RevenueMovement { type PersistRevenueMovementResult = 'applied' | 'duplicate' | 'missing' | 'stale' type BentoSegmentUpdate = { segments: string[], deleteSegments: string[] } type BentoSubscriberTagUpdate = { email: string, segments: string[], deleteSegments: string[] } +const BENTO_CHARGE_SUCCEEDED_EVENT = 'org:charge_succeeded' const ZERO_REVENUE_MOVEMENT: RevenueMovement = { currentMrr: 0, @@ -120,17 +122,23 @@ function isSubscriptionUpdateStatus( return Boolean(status && SUBSCRIPTION_UPDATE_STATUSES.has(status)) } -function normalizeBillingEmail(email: string | null | undefined) { - const normalized = email?.trim().toLowerCase() - return normalized || null +function shouldReplaceOrgManagementEmail(currentEmail: string | null | undefined, stripeEmail: string | null): stripeEmail is string { + return Boolean(stripeEmail && stripeEmail !== normalizeBillingEmail(currentEmail)) } -function buildBillingBentoTagUpdates( - emails: Array, - segments: BentoSegmentUpdate, -): BentoSubscriberTagUpdate[] { +function didStripeCustomerEmailChange(event: Stripe.Event) { + if (event.type === 'customer.created') + return true + if (event.type !== 'customer.updated') + return false + + const previousAttributes = event.data.previous_attributes as Partial | undefined + return Boolean(previousAttributes && Object.hasOwn(previousAttributes, 'email')) +} + +function uniqueBillingEmails(emails: Array): string[] { const emailSet = new Set() - const updates: BentoSubscriberTagUpdate[] = [] + const unique: string[] = [] for (const email of emails) { const normalizedEmail = normalizeBillingEmail(email) @@ -138,14 +146,21 @@ function buildBillingBentoTagUpdates( continue emailSet.add(normalizedEmail) - updates.push({ - email: normalizedEmail, - segments: [...segments.segments], - deleteSegments: [...segments.deleteSegments], - }) + unique.push(normalizedEmail) } - return updates + return unique +} + +function buildBillingBentoTagUpdates( + emails: Array, + segments: BentoSegmentUpdate, +): BentoSubscriberTagUpdate[] { + return uniqueBillingEmails(emails).map(email => ({ + email, + segments: [...segments.segments], + deleteSegments: [...segments.deleteSegments], + })) } function getPaidAtUpdate( @@ -297,16 +312,20 @@ async function lookupOrgCreatorEmail( } } -async function getStripeCustomerBillingEmail(c: Context, customerId: string): Promise { +async function retrieveStripeCustomerBillingEmail(c: Context, customerId: string): Promise { if (!customerId || !isStripeConfigured(c)) return null - try { - const customer = await getStripe(c).customers.retrieve(customerId) - if ('deleted' in customer && customer.deleted) - return null + const customer = await getStripe(c).customers.retrieve(customerId) + if ('deleted' in customer && customer.deleted) + return null - return normalizeBillingEmail(customer.email) + return normalizeBillingEmail(customer.email) +} + +async function getStripeCustomerBillingEmail(c: Context, customerId: string): Promise { + try { + return await retrieveStripeCustomerBillingEmail(c, customerId) } catch (error) { cloudlogErr({ requestId: c.get('requestId'), message: 'getStripeCustomerBillingEmail error', customerId, error }) @@ -314,14 +333,26 @@ async function getStripeCustomerBillingEmail(c: Context, customerId: string): Pr } } +async function requireLiveStripeCustomerBillingEmail(c: Context, customerId: string): Promise { + try { + return await retrieveStripeCustomerBillingEmail(c, customerId) + } + catch (error) { + return quickError(500, 'stripe_customer_email_lookup_failed', 'Failed to read live Stripe customer email', { + customerId, + error, + }) + } +} + async function getBillingBentoEmails(c: Context, org: Org, customerId: string) { const emails: Array = [org.management_email] const pgClient = getPgClient(c, true) try { const drizzleClient = getDrizzleClient(pgClient) - const { emails: billingMemberEmails } = await getOrgAdminMemberEmailsForTags(c, org.id, drizzleClient, 'billing') - emails.push(...billingMemberEmails) + const { emails: memberEmails } = await getOrgAdminMemberEmailsForTags(c, org.id, drizzleClient, 'billing') + emails.push(...memberEmails) const creatorEmail = await lookupOrgCreatorEmail(c, drizzleClient, org) emails.push(creatorEmail) @@ -357,7 +388,8 @@ async function syncBillingBentoTags( if (!isBentoConfigured(c)) return - const updates = buildBillingBentoTagUpdates(await getBillingBentoEmails(c, org, customerId), segment) + const billingEmails = await getBillingBentoEmails(c, org, customerId) + const updates = buildBillingBentoTagUpdates(billingEmails, segment) if (updates.length === 0) return @@ -372,7 +404,40 @@ async function syncBillingBentoTags( }) } -async function syncBillingBentoTagsFromStoredStripeInfo(c: Context, org: Org, customerId: string) { +async function trackBillingBentoEvent( + c: Context, + org: Org, + customerId: string, + event: string, + data: Record = {}, +) { + if (!isBentoConfigured(c)) + return + + const emails = uniqueBillingEmails(await getBillingBentoEmails(c, org, customerId)) + if (emails.length === 0) { + cloudlog({ + requestId: c.get('requestId'), + message: 'trackBillingBentoEvent: no billing emails', + orgId: org.id, + customerId, + event, + }) + return + } + + await Promise.all(emails.map(email => trackBentoEvent(c, email, data, event))) + cloudlog({ + requestId: c.get('requestId'), + message: 'trackBillingBentoEvent', + orgId: org.id, + customerId, + event, + recipientCount: emails.length, + }) +} + +export async function syncBillingBentoTagsFromStoredStripeInfo(c: Context, org: Org, customerId: string) { if (!isBentoConfigured(c)) return @@ -1227,6 +1292,50 @@ async function getOrgForCustomerId(c: Context, customerId: string): Promise { + if (!didStripeCustomerEmailChange(event)) + return org + + // Live Stripe customer is the source of truth. Do not fall back to the webhook + // snapshot: a failed retrieve plus a stale customer.updated would roll the email back. + const stripeEmail = await requireLiveStripeCustomerBillingEmail(c, customerId) + if (!shouldReplaceOrgManagementEmail(org.management_email, stripeEmail)) + return org + + const { data: updatedOrg, error } = await supabaseAdmin(c) + .from('orgs') + .update({ management_email: stripeEmail }) + .eq('id', org.id) + .eq('customer_id', customerId) + .select('id') + .maybeSingle() + + if (error || !updatedOrg) { + return quickError(500, 'stripe_management_email_sync_failed', 'Failed to sync org management email from Stripe', { + orgId: org.id, + customerId, + previousEmail: org.management_email, + stripeEmail, + error, + }) + } + + cloudlog({ + requestId: c.get('requestId'), + message: 'Synced org management_email from Stripe customer email', + orgId: org.id, + customerId, + previousEmail: org.management_email, + stripeEmail, + }) + return { ...org, management_email: stripeEmail } +} + async function getOrg(c: Context, stripeData: StripeData) { const org = await getOrgForCustomerId(c, stripeData.data.customer_id) if (!org) @@ -1304,7 +1413,13 @@ app.post('/', middlewareStripeWebhook(), async (c) => { const org = await getOrgForCustomerId(c, stripeData.data.customer_id) if (org) { await ensureCustomerMetadata(c, stripeData.data.customer_id, org.id, org.created_by) - await syncBillingBentoTagsFromStoredStripeInfo(c, org, stripeData.data.customer_id) + const billingOrg = await syncOrgManagementEmailFromStripeCustomer( + c, + org, + stripeData.data.customer_id, + stripeEvent, + ) + await syncBillingBentoTagsFromStoredStripeInfo(c, billingOrg, stripeData.data.customer_id) } return c.json(BRES) } @@ -1338,6 +1453,12 @@ app.post('/', middlewareStripeWebhook(), async (c) => { else if (stripeEvent.type === 'invoice.upcoming') { return invoiceUpcoming(c, org, stripeEvent, stripeData) } + else if (stripeEvent.type === 'charge.succeeded') { + // Canonical dunning exit. Do not also emit this from subscription.updated: + // Stripe sends both, and a plan change is not proof of payment recovery. + await trackBillingBentoEvent(c, org, stripeData.data.customer_id, BENTO_CHARGE_SUCCEEDED_EVENT) + return c.json(BRES) + } if (isSubscriptionUpdateStatus(stripeData.data.status) && stripeData.data.price_id && stripeData.data.product_id) { const originalStatus = stripeData.data.status @@ -1352,7 +1473,7 @@ app.post('/', middlewareStripeWebhook(), async (c) => { cloudlog({ requestId: c.get('requestId'), message: 'Skipping failed payment email because org has active usage credits', orgId: org.id }) } else { - await trackBentoEvent(c, org.management_email, {}, 'org:failed_payment') + await trackBillingBentoEvent(c, org, stripeData.data.customer_id, 'org:failed_payment') } // Update the database with failed status await updateStripeInfo(c, stripeData) @@ -1430,7 +1551,9 @@ app.post('/', middlewareStripeWebhook(), async (c) => { }) export const stripeEventTestUtils = { + BENTO_CHARGE_SUCCEEDED_EVENT, buildBillingBentoTagUpdates, + uniqueBillingEmails, buildSubscriptionEventMetadata, classifyRevenueMovement, getEventDateId, @@ -1442,5 +1565,7 @@ export const stripeEventTestUtils = { getSubscriptionTrackingState, isStaleStripeEvent, isCustomerProfileEvent, + didStripeCustomerEmailChange, + shouldReplaceOrgManagementEmail, shouldTrackOrganizationUpgrade, } diff --git a/supabase/functions/_backend/utils/stripe_event.ts b/supabase/functions/_backend/utils/stripe_event.ts index 781b1e2da1..54857f1e87 100644 --- a/supabase/functions/_backend/utils/stripe_event.ts +++ b/supabase/functions/_backend/utils/stripe_event.ts @@ -118,6 +118,16 @@ function invoiceUpcoming(event: Stripe.InvoiceUpcomingEvent, data: StripeData['d return data } +function getStripeCustomerId(customer: Stripe.Charge['customer'] | Stripe.Checkout.Session['customer']): string { + if (!customer) + return '' + if (typeof customer === 'string') + return customer + if (typeof customer === 'object' && 'id' in customer && typeof customer.id === 'string') + return customer.id + return '' +} + export function extractDataEvent(c: Context, event: Stripe.Event): StripeData { let data: StripeData['data'] = { product_id: undefined as any, // Changed from '' to undefined to avoid FK constraint violations @@ -151,14 +161,19 @@ export function extractDataEvent(c: Context, event: Stripe.Event): StripeData { else if (event.type === 'charge.failed') { const charge = event.data.object data.status = 'failed' - data.customer_id = String(charge.customer) + data.customer_id = getStripeCustomerId(charge.customer) + } + else if (event.type === 'charge.succeeded') { + const charge = event.data.object + data.status = 'succeeded' + data.customer_id = getStripeCustomerId(charge.customer) } else if (event.type === 'invoice.upcoming') { data = invoiceUpcoming(event, data) } else if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') { const session = event.data.object as Stripe.Checkout.Session - data.customer_id = String(session.customer ?? '') + data.customer_id = getStripeCustomerId(session.customer) data.status = 'succeeded' } else if (event.type === 'customer.updated' || event.type === 'customer.created') { @@ -171,3 +186,21 @@ export function extractDataEvent(c: Context, event: Stripe.Event): StripeData { } return { data, isUpgrade, previousPriceId, previousProductId } } + +export function normalizeBillingEmail(email: string | null | undefined) { + const normalized = email?.trim().toLowerCase() + return normalized || null +} + +export function getStripeCustomerEmailFromEvent(event: Stripe.Event): string | null { + if (event.type !== 'customer.created' && event.type !== 'customer.updated') + return null + + const customer = event.data.object + if (customer?.object !== 'customer') + return null + if ('deleted' in customer && customer.deleted) + return null + + return normalizeBillingEmail(customer.email) +} diff --git a/tests/set-org-email-bento.unit.test.ts b/tests/set-org-email-bento.unit.test.ts new file mode 100644 index 0000000000..b9d78ff137 --- /dev/null +++ b/tests/set-org-email-bento.unit.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + checkPermissionMock, + supabaseWithAuthMock, + supabaseAdminMock, + updateCustomerEmailMock, + syncBillingBentoTagsFromStoredStripeInfoMock, +} = vi.hoisted(() => ({ + checkPermissionMock: vi.fn(async () => true), + supabaseWithAuthMock: vi.fn(), + supabaseAdminMock: vi.fn(), + updateCustomerEmailMock: vi.fn(async () => undefined), + syncBillingBentoTagsFromStoredStripeInfoMock: vi.fn(async () => undefined), +})) + +vi.mock('../supabase/functions/_backend/utils/hono_middleware.ts', () => ({ + middlewareAuth: () => async (c: { set: (key: string, value: unknown) => void }, next: () => Promise) => { + c.set('auth', { userId: 'user-123' }) + await next() + }, +})) + +vi.mock('../supabase/functions/_backend/utils/rbac.ts', () => ({ + checkPermission: checkPermissionMock, +})) + +vi.mock('../supabase/functions/_backend/utils/stripe.ts', () => ({ + updateCustomerEmail: updateCustomerEmailMock, +})) + +vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ + supabaseWithAuth: supabaseWithAuthMock, + supabaseAdmin: supabaseAdminMock, +})) + +vi.mock('../supabase/functions/_backend/triggers/stripe_event.ts', () => ({ + syncBillingBentoTagsFromStoredStripeInfo: syncBillingBentoTagsFromStoredStripeInfoMock, +})) + +const { app } = await import('../supabase/functions/_backend/private/set_org_email.ts') + +const ORG_ID = '11111111-1111-4111-8111-111111111111' +const CUSTOMER_ID = 'cus_billing_email' +const CREATOR_ID = '22222222-2222-4222-8222-222222222222' + +function mockOrgLookup(organization: Record | null) { + supabaseWithAuthMock.mockReturnValue({ + from: () => ({ + select: () => ({ + eq: () => ({ + maybeSingle: async () => ({ data: organization, error: null }), + }), + }), + }), + }) +} + +function mockOrgUpdate(updatedOrg: { id: string } | null) { + supabaseAdminMock.mockReturnValue({ + from: () => ({ + update: () => ({ + eq: () => ({ + select: () => ({ + maybeSingle: async () => ({ data: updatedOrg, error: null }), + }), + }), + }), + }), + }) +} + +describe('set_org_email Bento billing tag sync', () => { + beforeEach(() => { + checkPermissionMock.mockReset().mockResolvedValue(true) + updateCustomerEmailMock.mockReset().mockResolvedValue(undefined) + syncBillingBentoTagsFromStoredStripeInfoMock.mockReset().mockResolvedValue(undefined) + supabaseWithAuthMock.mockReset() + supabaseAdminMock.mockReset() + }) + + it('applies billing tags to the new management email after Stripe and DB succeed', async () => { + mockOrgLookup({ + created_by: CREATOR_ID, + customer_id: CUSTOMER_ID, + management_email: 'old-invoices@example.com', + name: 'Acme', + }) + mockOrgUpdate({ id: ORG_ID }) + + const response = await app.request('http://local/', { + body: JSON.stringify({ + email: 'invoices@example.com', + org_id: ORG_ID, + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + + expect(response.status).toBe(200) + expect(updateCustomerEmailMock).toHaveBeenCalledWith(expect.anything(), CUSTOMER_ID, 'invoices@example.com') + expect(syncBillingBentoTagsFromStoredStripeInfoMock).toHaveBeenCalledWith(expect.anything(), { + created_by: CREATOR_ID, + customer_id: CUSTOMER_ID, + id: ORG_ID, + management_email: 'invoices@example.com', + name: 'Acme', + }, CUSTOMER_ID) + }) + + it('does not tag Bento when the org write fails and Stripe is reverted', async () => { + mockOrgLookup({ + created_by: CREATOR_ID, + customer_id: CUSTOMER_ID, + management_email: 'old-invoices@example.com', + name: 'Acme', + }) + mockOrgUpdate(null) + + const response = await app.request('http://local/', { + body: JSON.stringify({ + email: 'invoices@example.com', + org_id: ORG_ID, + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + + expect(response.status).toBe(400) + expect(updateCustomerEmailMock).toHaveBeenNthCalledWith(2, expect.anything(), CUSTOMER_ID, 'old-invoices@example.com') + expect(syncBillingBentoTagsFromStoredStripeInfoMock).not.toHaveBeenCalled() + }) +}) diff --git a/tests/stripe-billing-bento-tags.unit.test.ts b/tests/stripe-billing-bento-tags.unit.test.ts index cac3367acb..1db002120f 100644 --- a/tests/stripe-billing-bento-tags.unit.test.ts +++ b/tests/stripe-billing-bento-tags.unit.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' import { stripeEventTestUtils } from '../supabase/functions/_backend/triggers/stripe_event.ts' +import { extractDataEvent } from '../supabase/functions/_backend/utils/stripe_event.ts' + +const mockContext = { + get: () => 'test-request-id', +} as any describe('stripe billing Bento tag updates', () => { it.concurrent('normalizes and deduplicates every billing-linked email', () => { @@ -22,4 +27,107 @@ describe('stripe billing Bento tag updates', () => { { email: 'creator@example.com', segments: segment.segments, deleteSegments: segment.deleteSegments }, ]) }) + + it.concurrent('keeps unique billing emails in first-seen order', () => { + expect(stripeEventTestUtils.uniqueBillingEmails([ + ' Owner@Example.com ', + 'owner@example.com', + 'billing@stripe.example', + null, + ' Billing@Stripe.Example ', + 'creator@example.com', + ])).toEqual([ + 'owner@example.com', + 'billing@stripe.example', + 'creator@example.com', + ]) + }) +}) + +describe('dunning Bento stop event', () => { + it.concurrent('uses a Capgo event so Bento can exit dunning for billing contacts', () => { + expect(stripeEventTestUtils.BENTO_CHARGE_SUCCEEDED_EVENT).toBe('org:charge_succeeded') + }) +}) + +describe('stripe charge events', () => { + it.concurrent('extracts the customer id from charge.succeeded', () => { + const stripeData = extractDataEvent(mockContext, { + data: { + object: { + customer: 'cus_charge_ok', + id: 'ch_ok', + object: 'charge', + }, + }, + type: 'charge.succeeded', + } as any) + + expect(stripeData.data.customer_id).toBe('cus_charge_ok') + expect(stripeData.data.status).toBe('succeeded') + }) + + it.concurrent('extracts the customer id from charge.failed', () => { + const stripeData = extractDataEvent(mockContext, { + data: { + object: { + customer: 'cus_charge_fail', + id: 'ch_fail', + object: 'charge', + }, + }, + type: 'charge.failed', + } as any) + + expect(stripeData.data.customer_id).toBe('cus_charge_fail') + expect(stripeData.data.status).toBe('failed') + }) + + it.concurrent('leaves customer_id empty when charge.succeeded has no customer', () => { + const stripeData = extractDataEvent(mockContext, { + data: { + object: { + customer: null, + id: 'ch_no_customer', + object: 'charge', + }, + }, + type: 'charge.succeeded', + } as any) + + expect(stripeData.data.customer_id).toBe('') + }) + + it.concurrent('leaves customer_id empty when charge.failed has no customer', () => { + const stripeData = extractDataEvent(mockContext, { + data: { + object: { + customer: null, + id: 'ch_fail_no_customer', + object: 'charge', + }, + }, + type: 'charge.failed', + } as any) + + expect(stripeData.data.customer_id).toBe('') + }) + + it.concurrent('extracts the customer id from an expanded charge customer', () => { + const stripeData = extractDataEvent(mockContext, { + data: { + object: { + customer: { + id: 'cus_expanded', + object: 'customer', + }, + id: 'ch_expanded', + object: 'charge', + }, + }, + type: 'charge.succeeded', + } as any) + + expect(stripeData.data.customer_id).toBe('cus_expanded') + }) }) diff --git a/tests/stripe-customer-email-sync.unit.test.ts b/tests/stripe-customer-email-sync.unit.test.ts new file mode 100644 index 0000000000..0d3e61c0ff --- /dev/null +++ b/tests/stripe-customer-email-sync.unit.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { stripeEventTestUtils } from '../supabase/functions/_backend/triggers/stripe_event.ts' +import { extractDataEvent, getStripeCustomerEmailFromEvent, normalizeBillingEmail } from '../supabase/functions/_backend/utils/stripe_event.ts' + +const mockContext = { + get: () => 'test-request-id', +} as any + +function makeCustomerEvent(type: 'customer.created' | 'customer.updated', customer: Record) { + return { + created: 1_711_925_200, + data: { + object: { + id: 'cus_billing_email', + object: 'customer', + ...customer, + }, + }, + type, + } as any +} + +describe('stripe customer billing email sync', () => { + it.concurrent('normalizes billing emails the same way extraction does', () => { + expect(normalizeBillingEmail(' Billing@Stripe.Example ')).toBe('billing@stripe.example') + expect(normalizeBillingEmail(' ')).toBeNull() + expect(normalizeBillingEmail(null)).toBeNull() + }) + + it.concurrent('extracts a normalized customer email from customer.updated', () => { + const event = makeCustomerEvent('customer.updated', { + email: ' Billing@Stripe.Example ', + }) + + expect(getStripeCustomerEmailFromEvent(event)).toBe('billing@stripe.example') + expect(extractDataEvent(mockContext, event).data.customer_id).toBe('cus_billing_email') + }) + + it.concurrent('extracts a normalized customer email from customer.created', () => { + expect(getStripeCustomerEmailFromEvent(makeCustomerEvent('customer.created', { + email: 'INVOICES@org.example', + }))).toBe('invoices@org.example') + }) + + it.concurrent('ignores missing, blank, or deleted Stripe customer emails', () => { + expect(getStripeCustomerEmailFromEvent(makeCustomerEvent('customer.updated', { + email: null, + }))).toBeNull() + expect(getStripeCustomerEmailFromEvent(makeCustomerEvent('customer.updated', { + email: ' ', + }))).toBeNull() + expect(getStripeCustomerEmailFromEvent({ + data: { + object: { + deleted: true, + id: 'cus_deleted', + object: 'customer', + }, + }, + type: 'customer.updated', + } as any)).toBeNull() + }) + + it.concurrent('does not read an email from non-customer profile events', () => { + expect(getStripeCustomerEmailFromEvent({ + data: { + object: { + customer: 'cus_billing_email', + email: 'ignored@example.com', + id: 'sub_123', + object: 'subscription', + }, + }, + type: 'customer.subscription.updated', + } as any)).toBeNull() + }) + + it.concurrent('replaces the org management email only when Stripe has a different address', () => { + expect(stripeEventTestUtils.shouldReplaceOrgManagementEmail( + 'old@example.com', + 'invoices@example.com', + )).toBe(true) + expect(stripeEventTestUtils.shouldReplaceOrgManagementEmail( + 'Invoices@Example.com ', + 'invoices@example.com', + )).toBe(false) + expect(stripeEventTestUtils.shouldReplaceOrgManagementEmail( + 'old@example.com', + null, + )).toBe(false) + expect(stripeEventTestUtils.shouldReplaceOrgManagementEmail( + null, + 'invoices@example.com', + )).toBe(true) + }) + + it.concurrent('only treats customer.updated as an email change when previous_attributes includes email', () => { + expect(stripeEventTestUtils.didStripeCustomerEmailChange(makeCustomerEvent('customer.updated', { + email: 'invoices@example.com', + }))).toBe(false) + + expect(stripeEventTestUtils.didStripeCustomerEmailChange({ + ...makeCustomerEvent('customer.updated', { email: 'invoices@example.com' }), + data: { + object: { + email: 'invoices@example.com', + id: 'cus_billing_email', + object: 'customer', + }, + previous_attributes: { + email: 'old@example.com', + }, + }, + })).toBe(true) + + expect(stripeEventTestUtils.didStripeCustomerEmailChange({ + ...makeCustomerEvent('customer.updated', { email: 'invoices@example.com' }), + data: { + object: { + email: 'invoices@example.com', + id: 'cus_billing_email', + object: 'customer', + }, + previous_attributes: { + name: 'New name', + }, + }, + })).toBe(false) + }) + + it.concurrent('treats customer.created as an email change so a mismatched first email can be repaired', () => { + expect(stripeEventTestUtils.didStripeCustomerEmailChange(makeCustomerEvent('customer.created', { + email: 'invoices@example.com', + }))).toBe(true) + }) +})