import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { StripeProvider } from '../../src/providers/payments/stripe.provider'; const { mockPool, mockClient } = vi.hoisted(() => ({ mockPool: { query: vi.fn(), connect: vi.fn(), }, mockClient: { query: vi.fn(), release: vi.fn(), }, })); vi.mock('../../src/infra/database/database.manager', () => ({ DatabaseManager: { getInstance: () => ({ getPool: () => mockPool, }), }, })); import { PaymentCustomerService } from '../../src/services/payments/payment-customer.service'; describe('PaymentCustomerService', () => { beforeEach(() => { vi.clearAllMocks(); mockPool.query.mockReset(); mockPool.connect.mockReset(); mockClient.query.mockReset(); mockClient.release.mockReset(); mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 }); mockPool.connect.mockResolvedValue(mockClient); mockClient.query.mockResolvedValue({ rows: [], rowCount: 0 }); }); it('lists mirrored Stripe customers for one environment', async () => { mockPool.query.mockResolvedValueOnce({ rows: [ { environment: 'test', provider: 'stripe', providerCustomerId: 'cus_123', email: 'buyer@example.com', name: 'Buyer Example', phone: '+1 555-0100', deleted: false, metadata: { segment: 'pro' }, raw: { address: { country: 'us' }, invoice_settings: { default_payment_method: { card: { brand: 'visa', last4: '4242', }, }, }, }, providerCreatedAt: new Date('2026-05-01T00:00:00.000Z'), syncedAt: new Date('2026-05-02T00:00:00.000Z'), paymentsCount: 3, lastPaymentAt: new Date('2026-05-03T12:30:00.000Z'), totalSpend: 4200, totalSpendCurrency: 'usd', }, ], }); await expect( PaymentCustomerService.getInstance().listCustomers({ environment: 'test', limit: 25, }) ).resolves.toEqual({ customers: [ { environment: 'test', provider: 'stripe', providerCustomerId: 'cus_123', email: 'buyer@example.com', name: 'Buyer Example', phone: '+1 555-0100', deleted: false, metadata: { segment: 'pro' }, providerCreatedAt: '2026-05-01T00:00:00.000Z', syncedAt: '2026-05-02T00:00:00.000Z', paymentsCount: 3, lastPaymentAt: '2026-05-03T12:30:00.000Z', totalSpend: 4200, totalSpendCurrency: 'usd', paymentMethodBrand: 'visa', paymentMethodLast4: '4242', countryCode: 'US', }, ], }); expect(mockPool.query).toHaveBeenCalledWith( expect.stringMatching(/FROM payments\.customers/i), ['test', 25, 'stripe'] ); }); it('builds customer payment stats by combining Stripe-customer and email-only activity', async () => { mockPool.query.mockResolvedValueOnce({ rows: [] }); await PaymentCustomerService.getInstance().listCustomers({ environment: 'test', limit: 25, }); const [sql] = mockPool.query.mock.calls[0] ?? []; expect(sql).toMatch(/WITH unique_customer_emails AS/i); expect(sql).toMatch(/HAVING COUNT\(\*\) = 1/i); expect(sql).toMatch( /COALESCE\(payment_totals_by_customer\.payments_count, 0\)\s*\+\s*COALESCE\(payment_totals_by_email\.payments_count, 0\)/i ); expect(sql).toMatch(/GREATEST\(\s*payment_totals_by_customer\.last_payment_at/i); expect(sql).toMatch( /WHEN COALESCE\(payment_totals_by_customer\.payments_count, 0\) = 0\s+THEN payment_totals_by_email\.total_spend/i ); expect(sql).toMatch( /WHEN COALESCE\(payment_totals_by_email\.payments_count, 0\) = 0\s+THEN payment_totals_by_customer\.total_spend/i ); }); it('syncs Stripe customers and soft-deletes mirror rows missing from the latest provider import', async () => { const provider = { listCustomers: vi.fn().mockResolvedValue([ { id: 'cus_123', object: 'customer', email: 'buyer@example.com', name: 'Buyer Example', phone: '+1 555-0100', deleted: false, metadata: { segment: 'pro' }, created: 1777593600, }, { id: 'cus_456', object: 'customer', email: null, name: 'Second Customer', phone: null, deleted: false, metadata: {}, created: 1777680000, }, ]), }; await expect( PaymentCustomerService.getInstance().syncCustomersWithProvider( 'test', provider as unknown as StripeProvider ) ).resolves.toBe(2); expect(provider.listCustomers).toHaveBeenCalledTimes(1); expect(mockPool.connect).toHaveBeenCalledTimes(1); expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); const insertCalls = mockClient.query.mock.calls.filter(([sql]) => typeof sql === 'string' ? /INSERT INTO payments\.customers/i.test(sql) : false ); expect(insertCalls).toHaveLength(1); expect(insertCalls[0]).toEqual([ expect.stringMatching(/INSERT INTO payments\.customers/i), [ 'test', 'cus_123', 'buyer@example.com', 'Buyer Example', '+1 555-0100', false, { segment: 'pro' }, expect.objectContaining({ id: 'cus_123' }), new Date('2026-05-01T00:00:00.000Z'), expect.any(Date), 'test', 'cus_456', null, 'Second Customer', null, false, {}, expect.objectContaining({ id: 'cus_456' }), new Date('2026-05-02T00:00:00.000Z'), expect.any(Date), ], ]); expect(mockClient.query).toHaveBeenCalledWith( expect.stringMatching( /UPDATE payments\.customers[\s\S]*provider = 'stripe'[\s\S]*NOT \(provider_customer_id = ANY/i ), ['test', expect.any(Date), ['cus_123', 'cus_456']] ); expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); expect(mockClient.release).toHaveBeenCalledTimes(1); }); it('preserves existing detail fields when projecting deleted Stripe customers from webhooks', async () => { await expect( PaymentCustomerService.getInstance().upsertCustomerProjection('test', { id: 'cus_deleted', deleted: true, }) ).resolves.toBe(true); expect(mockPool.query).toHaveBeenCalledWith( expect.stringMatching(/INSERT INTO payments\.customers/i), [ 'test', 'cus_deleted', null, null, null, true, {}, expect.objectContaining({ id: 'cus_deleted', deleted: true }), null, expect.any(Date), true, ] ); const [sql] = mockPool.query.mock.calls[0] ?? []; expect(sql).toMatch( /raw = CASE WHEN \$11 THEN payments\.customers\.raw ELSE EXCLUDED\.raw END/i ); }); it('ignores webhook customer projections that are missing an id', async () => { await expect( PaymentCustomerService.getInstance().upsertCustomerProjection('test', { id: '', deleted: false, }) ).resolves.toBe(false); expect(mockPool.query).not.toHaveBeenCalled(); }); });