246 lines
7.3 KiB
TypeScript
246 lines
7.3 KiB
TypeScript
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();
|
|
});
|
|
});
|