diff --git a/src/__tests__/payment-detector-transactions.spec.ts b/src/__tests__/payment-detector-transactions.spec.ts index 98f0fab..18c5aa5 100644 --- a/src/__tests__/payment-detector-transactions.spec.ts +++ b/src/__tests__/payment-detector-transactions.spec.ts @@ -7,30 +7,82 @@ import { db } from '../db/index'; jest.mock('../db/index', () => ({ db: { - query: { - payments: { findFirst: jest.fn() }, - }, transaction: jest.fn(), - execute: jest.fn(), - update: jest.fn(), - insert: jest.fn(), }, client: {}, })); const mockDb = db as jest.Mocked; -const createMockTx = (sessionRow: any = null) => ({ - execute: jest.fn().mockResolvedValue(sessionRow ? [sessionRow] : []), - update: jest.fn().mockReturnValue({ - set: jest.fn().mockReturnValue({ - where: jest.fn().mockResolvedValue(undefined), - }), - }), - insert: jest.fn().mockReturnValue({ - values: jest.fn().mockResolvedValue(undefined), - }), -}); +const baseSession = { + id: 'session-1', + merchant_id: 'merchant-1', + amount: '10.0000000', + asset_code: 'USDC', + memo: 'memo-1', + status: 'pending', +}; + +/** Mocks the phase-1 transaction: locking + validating + claiming the session. */ +function createClaimTx( + sessionRow: Record | null, + opts: { existingPayment?: any; claimReturning?: any[] } = {}, +) { + const returningMock = jest + .fn() + .mockResolvedValue(opts.claimReturning ?? (sessionRow ? [{ id: sessionRow.id }] : [])); + const setMock = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ returning: returningMock }), + }); + const updateMock = jest.fn().mockReturnValue({ set: setMock }); + + const tx = { + execute: jest.fn().mockResolvedValue(sessionRow ? [sessionRow] : []), + query: { + payments: { + findFirst: jest.fn().mockResolvedValue(opts.existingPayment ?? null), + }, + }, + update: updateMock, + }; + + return { tx, updateMock, setMock, returningMock }; +} + +/** Mocks the phase-2 transaction: inserting the payment and finalizing 'paid'. */ +function createFinalizeTx() { + const onConflictDoNothingMock = jest.fn().mockResolvedValue(undefined); + const valuesMock = jest.fn().mockReturnValue({ onConflictDoNothing: onConflictDoNothingMock }); + const insertMock = jest.fn().mockReturnValue({ values: valuesMock }); + + const updateWhereMock = jest.fn().mockResolvedValue(undefined); + const setMock = jest.fn().mockReturnValue({ where: updateWhereMock }); + const updateMock = jest.fn().mockReturnValue({ set: setMock }); + + const tx = { insert: insertMock, update: updateMock }; + + return { + tx, + insertMock, + valuesMock, + onConflictDoNothingMock, + updateMock, + setMock, + updateWhereMock, + }; +} + +function buildOp(overrides: Record = {}) { + return { + type: 'payment', + transaction_memo: 'memo-1', + transaction_hash: 'tx-456', + amount: '10.0000000', + asset_code: 'USDC', + from: 'GSENDER', + ...overrides, + }; +} describe('PaymentDetectorService - Transaction Wrapping', () => { let service: PaymentDetectorService; @@ -67,228 +119,246 @@ describe('PaymentDetectorService - Transaction Wrapping', () => { service = new PaymentDetectorService(mockStellar, mockWebhooks, mockMetrics, mockCursorService); }); - describe('idempotency check', () => { - it('skips duplicate payment when tx_hash already exists', async () => { - (mockDb.query.payments.findFirst as jest.Mock).mockResolvedValue({ - id: 'existing-payment', - txHash: 'tx-123', - }); + describe('two-phase claim + finalize', () => { + it('claims the session (pending -> processing) and finalizes it (insert + paid) in two separate transactions', async () => { + const claim = createClaimTx({ ...baseSession }); + const finalize = createFinalizeTx(); + (mockDb.transaction as jest.Mock) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claim.tx)) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(finalize.tx)); + + await (service as any).processPayment(buildOp()); + + expect(mockDb.transaction).toHaveBeenCalledTimes(2); + expect(claim.tx.execute).toHaveBeenCalledTimes(1); + expect(claim.updateMock).toHaveBeenCalledTimes(1); + expect(finalize.insertMock).toHaveBeenCalledTimes(1); + expect(finalize.onConflictDoNothingMock).toHaveBeenCalledTimes(1); + expect(finalize.updateMock).toHaveBeenCalledTimes(1); + expect(mockMetrics.paymentsConfirmed.inc).toHaveBeenCalledTimes(1); + expect(mockWebhooks.dispatchWebhook).toHaveBeenCalledTimes(1); + }); - const op = { - type: 'payment', - transaction_memo: 'memo-1', - transaction_hash: 'tx-123', - amount: '10.0000000', - asset_code: 'USDC', - from: 'GSENDER', - }; + it('uses SELECT ... FOR UPDATE to lock the session row before claiming it', async () => { + const claim = createClaimTx({ ...baseSession }); + const finalize = createFinalizeTx(); + (mockDb.transaction as jest.Mock) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claim.tx)) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(finalize.tx)); - await (service as any).processPayment(op); + await (service as any).processPayment(buildOp()); - expect(mockDb.query.payments.findFirst).toHaveBeenCalled(); - expect(mockDb.transaction).not.toHaveBeenCalled(); - expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); + expect(claim.tx.execute).toHaveBeenCalledTimes(1); + const sqlArg = claim.tx.execute.mock.calls[0][0]; + expect(sqlArg).toBeDefined(); + expect(typeof sqlArg).toBe('object'); }); - it('proceeds with transaction when tx_hash is new', async () => { - (mockDb.query.payments.findFirst as jest.Mock).mockResolvedValue(null); - const mockTx = createMockTx({ - id: 'session-1', - merchant_id: 'merchant-1', - amount: '10.0000000', - asset_code: 'USDC', - memo: 'memo-1', - }); - (mockDb.transaction as jest.Mock).mockImplementation(async (fn: (tx: any) => Promise) => - fn(mockTx), + it('returns early without any update/insert if no session matches the memo', async () => { + const claim = createClaimTx(null); + (mockDb.transaction as jest.Mock).mockImplementationOnce( + async (fn: (tx: any) => Promise) => fn(claim.tx), ); - const op = { - type: 'payment', - transaction_memo: 'memo-1', - transaction_hash: 'tx-456', - amount: '10.0000000', - asset_code: 'USDC', - from: 'GSENDER', - }; + await (service as any).processPayment(buildOp()); + + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(claim.updateMock).not.toHaveBeenCalled(); + expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); + }); + }); + + describe('processing status transitions', () => { + it('transitions pending -> processing during the claim phase', async () => { + const claim = createClaimTx({ ...baseSession }); + const finalize = createFinalizeTx(); + (mockDb.transaction as jest.Mock) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claim.tx)) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(finalize.tx)); - await (service as any).processPayment(op); + await (service as any).processPayment(buildOp()); - expect(mockDb.query.payments.findFirst).toHaveBeenCalled(); - expect(mockDb.transaction).toHaveBeenCalled(); - expect(mockTx.execute).toHaveBeenCalled(); + expect(claim.setMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'processing' })); + }); + + it('transitions processing -> paid only after the payment insert in the finalize phase', async () => { + const claim = createClaimTx({ ...baseSession }); + const finalize = createFinalizeTx(); + (mockDb.transaction as jest.Mock) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claim.tx)) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(finalize.tx)); + + await (service as any).processPayment(buildOp()); + + expect(finalize.setMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'paid' })); }); }); - describe('transaction atomicity', () => { - it('wraps session update, payment insert, and webhook dispatch in transaction', async () => { - (mockDb.query.payments.findFirst as jest.Mock).mockResolvedValue(null); - const mockTx = createMockTx({ - id: 'session-1', - merchant_id: 'merchant-1', - amount: '10.0000000', - asset_code: 'USDC', - memo: 'memo-1', - }); - (mockDb.transaction as jest.Mock).mockImplementation(async (fn: (tx: any) => Promise) => - fn(mockTx), + describe('idempotency key', () => { + it('skips the insert when a payment already exists for this (tx_hash, session_id) pair', async () => { + const claim = createClaimTx( + { ...baseSession }, + { existingPayment: { id: 'existing-payment' } }, + ); + (mockDb.transaction as jest.Mock).mockImplementationOnce( + async (fn: (tx: any) => Promise) => fn(claim.tx), ); - const op = { - type: 'payment', - transaction_memo: 'memo-1', - transaction_hash: 'tx-789', - amount: '10.0000000', - asset_code: 'USDC', - from: 'GSENDER', - }; - - await (service as any).processPayment(op); + await (service as any).processPayment(buildOp()); + expect(claim.tx.query.payments.findFirst).toHaveBeenCalled(); expect(mockDb.transaction).toHaveBeenCalledTimes(1); - expect(mockTx.execute).toHaveBeenCalledTimes(1); - expect(mockTx.update).toHaveBeenCalledTimes(1); - expect(mockTx.insert).toHaveBeenCalledTimes(1); - expect(mockWebhooks.dispatchWebhook).toHaveBeenCalledTimes(1); + expect(claim.updateMock).not.toHaveBeenCalled(); + expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); }); - it('uses SELECT FOR UPDATE to lock session row', async () => { - (mockDb.query.payments.findFirst as jest.Mock).mockResolvedValue(null); - const mockTx = createMockTx({ - id: 'session-1', - merchant_id: 'merchant-1', - amount: '10.0000000', - asset_code: 'USDC', - memo: 'memo-1', - }); - (mockDb.transaction as jest.Mock).mockImplementation(async (fn: (tx: any) => Promise) => - fn(mockTx), - ); + it('proceeds normally when no payment exists yet for this idempotency key', async () => { + const claim = createClaimTx({ ...baseSession }, { existingPayment: null }); + const finalize = createFinalizeTx(); + (mockDb.transaction as jest.Mock) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claim.tx)) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(finalize.tx)); - const op = { - type: 'payment', - transaction_memo: 'memo-1', - transaction_hash: 'tx-999', - amount: '10.0000000', - asset_code: 'USDC', - from: 'GSENDER', - }; + await (service as any).processPayment(buildOp()); - await (service as any).processPayment(op); + expect(mockDb.transaction).toHaveBeenCalledTimes(2); + expect(finalize.insertMock).toHaveBeenCalledTimes(1); + }); + }); - expect(mockTx.execute).toHaveBeenCalledTimes(1); - const sqlArg = mockTx.execute.mock.calls[0][0]; - expect(sqlArg).toBeDefined(); - expect(typeof sqlArg).toBe('object'); + describe('duplicate payment handling', () => { + it('logs a warning and returns gracefully for an already-paid session, without touching it', async () => { + const claim = createClaimTx({ ...baseSession, status: 'paid' }); + (mockDb.transaction as jest.Mock).mockImplementationOnce( + async (fn: (tx: any) => Promise) => fn(claim.tx), + ); + + await expect((service as any).processPayment(buildOp())).resolves.not.toThrow(); + + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(claim.updateMock).not.toHaveBeenCalled(); + expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); + expect(mockMetrics.paymentsConfirmed.inc).not.toHaveBeenCalled(); }); - it('returns early if session not found in locked query', async () => { - (mockDb.query.payments.findFirst as jest.Mock).mockResolvedValue(null); - const mockTx = createMockTx(null); - (mockDb.transaction as jest.Mock).mockImplementation(async (fn: (tx: any) => Promise) => - fn(mockTx), + it('skips a session that is already being processed by another payment', async () => { + const claim = createClaimTx({ ...baseSession, status: 'processing' }); + (mockDb.transaction as jest.Mock).mockImplementationOnce( + async (fn: (tx: any) => Promise) => fn(claim.tx), ); - const op = { - type: 'payment', - transaction_memo: 'nonexistent-memo', - transaction_hash: 'tx-111', - amount: '10.0000000', - asset_code: 'USDC', - from: 'GSENDER', - }; + await (service as any).processPayment(buildOp()); + + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(claim.updateMock).not.toHaveBeenCalled(); + expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); + }); + + it('skips if the claim update affects zero rows (lost the optimistic-lock race)', async () => { + const claim = createClaimTx({ ...baseSession }, { claimReturning: [] }); + (mockDb.transaction as jest.Mock).mockImplementationOnce( + async (fn: (tx: any) => Promise) => fn(claim.tx), + ); - await (service as any).processPayment(op); + await (service as any).processPayment(buildOp()); - expect(mockTx.update).not.toHaveBeenCalled(); - expect(mockTx.insert).not.toHaveBeenCalled(); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); }); }); describe('amount and asset validation', () => { it('rejects payment with amount mismatch', async () => { - (mockDb.query.payments.findFirst as jest.Mock).mockResolvedValue(null); - const mockTx = createMockTx({ - id: 'session-1', - merchant_id: 'merchant-1', - amount: '10.0000000', - asset_code: 'USDC', - memo: 'memo-1', - }); - (mockDb.transaction as jest.Mock).mockImplementation(async (fn: (tx: any) => Promise) => - fn(mockTx), + const claim = createClaimTx({ ...baseSession }); + (mockDb.transaction as jest.Mock).mockImplementationOnce( + async (fn: (tx: any) => Promise) => fn(claim.tx), ); - const op = { - type: 'payment', - transaction_memo: 'memo-1', - transaction_hash: 'tx-222', - amount: '5.0000000', - asset_code: 'USDC', - from: 'GSENDER', - }; + await (service as any).processPayment(buildOp({ amount: '5.0000000' })); - await (service as any).processPayment(op); - - expect(mockTx.update).not.toHaveBeenCalled(); - expect(mockTx.insert).not.toHaveBeenCalled(); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(claim.updateMock).not.toHaveBeenCalled(); }); it('rejects payment with asset mismatch', async () => { - (mockDb.query.payments.findFirst as jest.Mock).mockResolvedValue(null); - const mockTx = createMockTx({ - id: 'session-1', - merchant_id: 'merchant-1', - amount: '10.0000000', - asset_code: 'USDC', - memo: 'memo-1', - }); - (mockDb.transaction as jest.Mock).mockImplementation(async (fn: (tx: any) => Promise) => - fn(mockTx), + const claim = createClaimTx({ ...baseSession }); + (mockDb.transaction as jest.Mock).mockImplementationOnce( + async (fn: (tx: any) => Promise) => fn(claim.tx), ); - const op = { - type: 'payment', - transaction_memo: 'memo-1', - transaction_hash: 'tx-333', - amount: '10.0000000', - asset_code: 'BTC', - from: 'GSENDER', - }; - - await (service as any).processPayment(op); + await (service as any).processPayment(buildOp({ asset_code: 'BTC' })); - expect(mockTx.update).not.toHaveBeenCalled(); - expect(mockTx.insert).not.toHaveBeenCalled(); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(claim.updateMock).not.toHaveBeenCalled(); }); it('accepts payment with XLM when session expects XLM', async () => { - (mockDb.query.payments.findFirst as jest.Mock).mockResolvedValue(null); - const mockTx = createMockTx({ - id: 'session-1', - merchant_id: 'merchant-1', - amount: '10.0000000', - asset_code: 'XLM', - memo: 'memo-1', + const claim = createClaimTx({ ...baseSession, asset_code: 'XLM' }); + const finalize = createFinalizeTx(); + (mockDb.transaction as jest.Mock) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claim.tx)) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(finalize.tx)); + + await (service as any).processPayment(buildOp({ asset_code: 'XLM' })); + + expect(claim.updateMock).toHaveBeenCalled(); + expect(finalize.insertMock).toHaveBeenCalled(); + }); + }); + + describe('transaction rollback on failure', () => { + it('does not finalize, increment metrics, or dispatch a webhook when the finalize transaction fails', async () => { + const claim = createClaimTx({ ...baseSession }); + (mockDb.transaction as jest.Mock) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claim.tx)) + .mockImplementationOnce(async () => { + throw new Error('insert failed — transaction rolled back'); + }); + + await expect((service as any).processPayment(buildOp())).resolves.not.toThrow(); + + expect(mockDb.transaction).toHaveBeenCalledTimes(2); + expect(mockMetrics.paymentsConfirmed.inc).not.toHaveBeenCalled(); + expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); + }); + + it('does not call db.transaction a second time when the claim phase itself fails', async () => { + (mockDb.transaction as jest.Mock).mockImplementationOnce(async () => { + throw new Error('connection lost during claim'); }); - (mockDb.transaction as jest.Mock).mockImplementation(async (fn: (tx: any) => Promise) => - fn(mockTx), + + await expect((service as any).processPayment(buildOp())).rejects.toThrow( + 'connection lost during claim', ); - const op = { - type: 'payment', - transaction_memo: 'memo-1', - transaction_hash: 'tx-444', - amount: '10.0000000', - asset_code: 'XLM', - from: 'GSENDER', - }; + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); + }); + }); - await (service as any).processPayment(op); + describe('concurrent payment submissions (integration)', () => { + it('only one of two simultaneous payments for the same session wins the claim', async () => { + const claimWinner = createClaimTx({ ...baseSession }); + const claimLoser = createClaimTx({ ...baseSession, status: 'processing' }); + const finalizeWinner = createFinalizeTx(); - expect(mockTx.update).toHaveBeenCalled(); - expect(mockTx.insert).toHaveBeenCalled(); + (mockDb.transaction as jest.Mock) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claimWinner.tx)) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claimLoser.tx)) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(finalizeWinner.tx)); + + const opA = buildOp({ transaction_hash: 'tx-A' }); + const opB = buildOp({ transaction_hash: 'tx-B' }); + + await Promise.all([ + (service as any).processPayment(opA), + (service as any).processPayment(opB), + ]); + + expect(mockDb.transaction).toHaveBeenCalledTimes(3); + expect(finalizeWinner.insertMock).toHaveBeenCalledTimes(1); + expect(mockMetrics.paymentsConfirmed.inc).toHaveBeenCalledTimes(1); + expect(mockWebhooks.dispatchWebhook).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/__tests__/payment-recovery-cron.spec.ts b/src/__tests__/payment-recovery-cron.spec.ts new file mode 100644 index 0000000..ab75723 --- /dev/null +++ b/src/__tests__/payment-recovery-cron.spec.ts @@ -0,0 +1,90 @@ +import { Test } from '@nestjs/testing'; +import { ScheduleModule } from '@nestjs/schedule'; +import { PaymentRecoveryService } from '../payments/payment-recovery.service'; +import { WebhookService } from '../webhook/webhook.service'; +import { StellarService } from '../stellar/stellar.service'; +import { db } from '../db/index'; + +jest.mock('../db/index', () => ({ + db: { + query: { + payments: { findFirst: jest.fn() }, + }, + execute: jest.fn(), + update: jest.fn(), + transaction: jest.fn(), + }, +})); + +const mockDb = db as jest.Mocked; + +/** + * Verifies the @Cron(EVERY_5_MINUTES) wiring itself — that recoverStaleSessions + * actually runs automatically on the documented schedule once registered with + * Nest's SchedulerRegistry — as opposed to the other payment-recovery specs, + * which call the recovery methods directly and never exercise the schedule. + * + * Spying on a @Cron-decorated method does not work with @nestjs/schedule: + * registering before app.init() strips the metadata schedule discovery relies + * on (no job gets registered at all), and registering after init has no effect + * because the CronJob already captured the real method. So instead of spying, + * this lets the real handler run on the fake-timer schedule and observes it + * through its (mocked) db.execute call. + */ +describe('PaymentRecoveryService cron schedule', () => { + beforeEach(() => { + jest.clearAllMocks(); + (mockDb.execute as jest.Mock).mockResolvedValue([]); + // Anchor 1s past a 5-minute boundary so EVERY_5_MINUTES has a deterministic + // ~5-minute gap to the next fire, instead of depending on which second the + // real wall clock happens to be at when the test runs. + jest.useFakeTimers({ now: new Date('2026-01-01T00:00:01.000Z') }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + async function bootstrap() { + const moduleRef = await Test.createTestingModule({ + imports: [ScheduleModule.forRoot()], + providers: [ + PaymentRecoveryService, + { provide: WebhookService, useValue: { dispatchWebhook: jest.fn() } }, + { + provide: StellarService, + useValue: { + getPaymentsForAccount: jest.fn().mockResolvedValue([]), + verifyTransaction: jest.fn(), + }, + }, + ], + }).compile(); + + const app = moduleRef.createNestApplication(); + await app.init(); + return app; + } + + it('does not run before 5 minutes have elapsed', async () => { + const app = await bootstrap(); + + await jest.advanceTimersByTimeAsync(4 * 60 * 1000); + expect(mockDb.execute).not.toHaveBeenCalled(); + + await app.close(); + }); + + it('runs the recovery scan automatically every 5 minutes', async () => { + const app = await bootstrap(); + + await jest.advanceTimersByTimeAsync(5 * 60 * 1000 + 1000); + expect(mockDb.execute).toHaveBeenCalled(); + + const callsAfterFirstRun = (mockDb.execute as jest.Mock).mock.calls.length; + await jest.advanceTimersByTimeAsync(5 * 60 * 1000); + expect((mockDb.execute as jest.Mock).mock.calls.length).toBeGreaterThan(callsAfterFirstRun); + + await app.close(); + }); +}); diff --git a/src/__tests__/payment-recovery.spec.ts b/src/__tests__/payment-recovery.spec.ts index 12192b7..0491c02 100644 --- a/src/__tests__/payment-recovery.spec.ts +++ b/src/__tests__/payment-recovery.spec.ts @@ -1,5 +1,6 @@ import { PaymentRecoveryService } from '../payments/payment-recovery.service'; import { WebhookService } from '../webhook/webhook.service'; +import { StellarService } from '../stellar/stellar.service'; import { db } from '../db/index'; jest.mock('../db/index', () => ({ @@ -9,6 +10,7 @@ jest.mock('../db/index', () => ({ }, execute: jest.fn(), update: jest.fn(), + transaction: jest.fn(), }, })); @@ -20,9 +22,23 @@ const createMockUpdateChain = () => { return { set, where }; }; +const createMockTx = () => ({ + insert: jest.fn().mockReturnValue({ + values: jest.fn().mockReturnValue({ + onConflictDoNothing: jest.fn().mockResolvedValue(undefined), + }), + }), + update: jest.fn().mockReturnValue({ + set: jest.fn().mockReturnValue({ + where: jest.fn().mockResolvedValue(undefined), + }), + }), +}); + describe('PaymentRecoveryService', () => { let service: PaymentRecoveryService; let mockWebhooks: jest.Mocked; + let mockStellar: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); @@ -31,12 +47,137 @@ describe('PaymentRecoveryService', () => { dispatchWebhook: jest.fn(), } as any; - service = new PaymentRecoveryService(mockWebhooks); + mockStellar = { + getPaymentsForAccount: jest.fn().mockResolvedValue([]), + verifyTransaction: jest.fn().mockResolvedValue(false), + } as any; + + service = new PaymentRecoveryService(mockWebhooks, mockStellar); + }); + + describe('recoverStuckProcessing', () => { + const stuckRow = { + id: 'session-stuck', + merchant_id: 'merchant-1', + memo: 'memo-stuck', + amount: '10.0000000', + asset_code: 'USDC', + asset_issuer: null, + receiving_account: 'GPLATFORM', + }; + + it('does nothing when no sessions are stuck in processing', async () => { + (mockDb.execute as jest.Mock).mockResolvedValueOnce([]); + + await (service as any).recoverStuckProcessing(); + + expect(mockStellar.getPaymentsForAccount).not.toHaveBeenCalled(); + expect(mockDb.update).not.toHaveBeenCalled(); + expect(mockDb.transaction).not.toHaveBeenCalled(); + }); + + it('completes the payment when Horizon confirms a matching transaction', async () => { + (mockDb.execute as jest.Mock).mockResolvedValueOnce([stuckRow]); + (mockStellar.getPaymentsForAccount as jest.Mock).mockResolvedValue([ + { + type: 'payment', + transaction_memo: 'memo-stuck', + transaction_hash: 'tx-recovered', + amount: '10.0000000', + asset_code: 'USDC', + from: 'GSENDER', + }, + ]); + (mockStellar.verifyTransaction as jest.Mock).mockResolvedValue(true); + + const mockTx = createMockTx(); + (mockDb.transaction as jest.Mock).mockImplementation(async (fn: (tx: any) => Promise) => + fn(mockTx), + ); + + await (service as any).recoverStuckProcessing(); + + expect(mockStellar.verifyTransaction).toHaveBeenCalledWith('tx-recovered'); + expect(mockTx.insert).toHaveBeenCalledTimes(1); + expect(mockTx.update).toHaveBeenCalledTimes(1); + expect(mockWebhooks.dispatchWebhook).toHaveBeenCalledWith( + 'merchant-1', + 'payment.confirmed', + expect.objectContaining({ sessionId: 'session-stuck', txHash: 'tx-recovered' }), + ); + }); + + it('reverts to pending when no matching payment is found on Horizon', async () => { + (mockDb.execute as jest.Mock).mockResolvedValueOnce([stuckRow]); + (mockStellar.getPaymentsForAccount as jest.Mock).mockResolvedValue([]); + + const chain = createMockUpdateChain(); + (mockDb.update as jest.Mock).mockReturnValue({ set: chain.set }); + + await (service as any).recoverStuckProcessing(); + + expect(mockDb.transaction).not.toHaveBeenCalled(); + expect(chain.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'pending' })); + }); + + it('reverts to pending when the matching transaction did not actually confirm', async () => { + (mockDb.execute as jest.Mock).mockResolvedValueOnce([stuckRow]); + (mockStellar.getPaymentsForAccount as jest.Mock).mockResolvedValue([ + { + type: 'payment', + transaction_memo: 'memo-stuck', + transaction_hash: 'tx-failed', + amount: '10.0000000', + asset_code: 'USDC', + from: 'GSENDER', + }, + ]); + (mockStellar.verifyTransaction as jest.Mock).mockResolvedValue(false); + + const chain = createMockUpdateChain(); + (mockDb.update as jest.Mock).mockReturnValue({ set: chain.set }); + + await (service as any).recoverStuckProcessing(); + + expect(mockDb.transaction).not.toHaveBeenCalled(); + expect(chain.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'pending' })); + }); + + it('reverts to pending without calling Horizon when the session has no memo', async () => { + (mockDb.execute as jest.Mock).mockResolvedValueOnce([{ ...stuckRow, memo: null }]); + + const chain = createMockUpdateChain(); + (mockDb.update as jest.Mock).mockReturnValue({ set: chain.set }); + + await (service as any).recoverStuckProcessing(); + + expect(mockStellar.getPaymentsForAccount).not.toHaveBeenCalled(); + expect(chain.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'pending' })); + }); + + it('continues reconciling remaining sessions if one fails', async () => { + (mockDb.execute as jest.Mock).mockResolvedValueOnce([ + { ...stuckRow, id: 'session-a' }, + { ...stuckRow, id: 'session-b' }, + ]); + (mockStellar.getPaymentsForAccount as jest.Mock) + .mockRejectedValueOnce(new Error('Horizon down')) + .mockResolvedValueOnce([]); + + const chain = createMockUpdateChain(); + (mockDb.update as jest.Mock).mockReturnValue({ set: chain.set }); + + await (service as any).recoverStuckProcessing(); + + expect(chain.set).toHaveBeenCalledTimes(1); + expect(chain.where).toHaveBeenCalled(); + }); }); describe('recoverPendingWithPayments', () => { it('marks pending sessions with existing payments as paid', async () => { (mockDb.execute as jest.Mock) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { id: 'session-1', @@ -65,6 +206,7 @@ describe('PaymentRecoveryService', () => { it('handles multiple pending sessions', async () => { (mockDb.execute as jest.Mock) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { id: 'session-1', @@ -97,6 +239,7 @@ describe('PaymentRecoveryService', () => { it('continues if one session recovery fails', async () => { (mockDb.execute as jest.Mock) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { id: 'session-1', @@ -138,6 +281,7 @@ describe('PaymentRecoveryService', () => { describe('recoverStuckPending', () => { it('marks stale pending sessions without payments as expired', async () => { (mockDb.execute as jest.Mock) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ id: 'stale-session', memo: 'memo-1' }]) .mockResolvedValueOnce([]); @@ -153,6 +297,7 @@ describe('PaymentRecoveryService', () => { it('marks stale pending sessions with payments as paid', async () => { (mockDb.execute as jest.Mock) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ id: 'stale-session', memo: 'memo-1' }]) .mockResolvedValueOnce([]); @@ -167,9 +312,24 @@ describe('PaymentRecoveryService', () => { }); }); + describe('orchestration', () => { + it('runs all four recovery scenarios in order', async () => { + (mockDb.execute as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.recoverStaleSessions(); + + expect(mockDb.execute).toHaveBeenCalledTimes(4); + }); + }); + describe('empty results', () => { it('does nothing when no sessions need recovery', async () => { (mockDb.execute as jest.Mock) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([]); diff --git a/src/db/migrations/0000_baseline.sql b/src/db/migrations/0000_baseline.sql new file mode 100644 index 0000000..59d9091 --- /dev/null +++ b/src/db/migrations/0000_baseline.sql @@ -0,0 +1,113 @@ +CREATE TYPE "public"."api_key_environment" AS ENUM('testnet', 'mainnet');--> statement-breakpoint +CREATE TYPE "public"."merchant_role" AS ENUM('admin', 'merchant', 'viewer');--> statement-breakpoint +CREATE TYPE "public"."session_status" AS ENUM('pending', 'paid', 'expired', 'cancelled');--> statement-breakpoint +CREATE TYPE "public"."webhook_delivery_status" AS ENUM('pending', 'delivered', 'failed', 'dead');--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "merchant_id" uuid NOT NULL, + "key_prefix" text NOT NULL, + "key_hash" text NOT NULL, + "environment" "api_key_environment" DEFAULT 'testnet' NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "merchant_id" uuid, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_id" uuid, + "ip_address" text, + "user_agent" text, + "details" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "checkout_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "merchant_id" uuid NOT NULL, + "amount" numeric(36, 7) NOT NULL, + "asset_code" text NOT NULL, + "asset_issuer" text, + "receiving_account" text NOT NULL, + "memo" text, + "status" "session_status" DEFAULT 'pending' NOT NULL, + "success_url" text, + "cancel_url" text, + "metadata" jsonb, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "merchants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "wallet_address" text NOT NULL, + "business_name" text NOT NULL, + "email" text NOT NULL, + "role" "merchant_role" DEFAULT 'merchant' NOT NULL, + "webhook_url" text, + "webhook_secret" text, + "logo_url" text, + "cors_origins" jsonb DEFAULT '[]'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "merchants_wallet_address_unique" UNIQUE("wallet_address"), + CONSTRAINT "merchants_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "payments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "merchant_id" uuid NOT NULL, + "tx_hash" text NOT NULL, + "amount" numeric(36, 7) NOT NULL, + "asset_code" text NOT NULL, + "asset_issuer" text, + "sender_address" text NOT NULL, + "confirmed_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "payments_tx_hash_unique" UNIQUE("tx_hash") +); +--> statement-breakpoint +CREATE TABLE "webhook_dead_letters" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "merchant_id" uuid NOT NULL, + "delivery_id" uuid NOT NULL, + "session_id" uuid, + "event" text NOT NULL, + "payload" jsonb NOT NULL, + "attempts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "reason" text NOT NULL, + "retried_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "webhook_dead_letters_delivery_id_unique" UNIQUE("delivery_id") +); +--> statement-breakpoint +CREATE TABLE "webhook_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "merchant_id" uuid NOT NULL, + "session_id" uuid, + "event" text NOT NULL, + "payload" jsonb NOT NULL, + "delivery_id" uuid NOT NULL, + "sequence" integer DEFAULT 0 NOT NULL, + "priority" integer DEFAULT 3 NOT NULL, + "status" "webhook_delivery_status" DEFAULT 'pending' NOT NULL, + "response_status" integer, + "attempt_log" jsonb DEFAULT '[]'::jsonb NOT NULL, + "delivered_at" timestamp with time zone, + "attempts" integer DEFAULT 0 NOT NULL, + "next_retry_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "webhook_deliveries_delivery_id_unique" UNIQUE("delivery_id") +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_merchant_id_merchants_id_fk" FOREIGN KEY ("merchant_id") REFERENCES "public"."merchants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_merchant_id_merchants_id_fk" FOREIGN KEY ("merchant_id") REFERENCES "public"."merchants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "checkout_sessions" ADD CONSTRAINT "checkout_sessions_merchant_id_merchants_id_fk" FOREIGN KEY ("merchant_id") REFERENCES "public"."merchants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payments" ADD CONSTRAINT "payments_session_id_checkout_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."checkout_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payments" ADD CONSTRAINT "payments_merchant_id_merchants_id_fk" FOREIGN KEY ("merchant_id") REFERENCES "public"."merchants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_dead_letters" ADD CONSTRAINT "webhook_dead_letters_merchant_id_merchants_id_fk" FOREIGN KEY ("merchant_id") REFERENCES "public"."merchants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_dead_letters" ADD CONSTRAINT "webhook_dead_letters_session_id_checkout_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."checkout_sessions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_merchant_id_merchants_id_fk" FOREIGN KEY ("merchant_id") REFERENCES "public"."merchants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_session_id_checkout_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."checkout_sessions"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/src/db/migrations/0001_processing-status-and-payment-idempotency-key.sql b/src/db/migrations/0001_processing-status-and-payment-idempotency-key.sql new file mode 100644 index 0000000..24cb70f --- /dev/null +++ b/src/db/migrations/0001_processing-status-and-payment-idempotency-key.sql @@ -0,0 +1,3 @@ +ALTER TYPE "public"."session_status" ADD VALUE 'processing' BEFORE 'paid';--> statement-breakpoint +ALTER TABLE "payments" DROP CONSTRAINT "payments_tx_hash_unique";--> statement-breakpoint +ALTER TABLE "payments" ADD CONSTRAINT "payment_idempotency_key" UNIQUE("tx_hash","session_id"); \ No newline at end of file diff --git a/src/db/migrations/meta/0000_snapshot.json b/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..6cd0589 --- /dev/null +++ b/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,783 @@ +{ + "id": "6b47ab12-2894-4fc8-9f9a-ccf726924f90", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "api_key_environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'testnet'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_merchant_id_merchants_id_fk": { + "name": "api_keys_merchant_id_merchants_id_fk", + "tableFrom": "api_keys", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_merchant_id_merchants_id_fk": { + "name": "audit_logs_merchant_id_merchants_id_fk", + "tableFrom": "audit_logs", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkout_sessions": { + "name": "checkout_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(36, 7)", + "primaryKey": false, + "notNull": true + }, + "asset_code": { + "name": "asset_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_issuer": { + "name": "asset_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receiving_account": { + "name": "receiving_account", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "checkout_sessions_merchant_id_merchants_id_fk": { + "name": "checkout_sessions_merchant_id_merchants_id_fk", + "tableFrom": "checkout_sessions", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.merchants": { + "name": "merchants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "merchant_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'merchant'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cors_origins": { + "name": "cors_origins", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "merchants_wallet_address_unique": { + "name": "merchants_wallet_address_unique", + "nullsNotDistinct": false, + "columns": [ + "wallet_address" + ] + }, + "merchants_email_unique": { + "name": "merchants_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(36, 7)", + "primaryKey": false, + "notNull": true + }, + "asset_code": { + "name": "asset_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_issuer": { + "name": "asset_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_address": { + "name": "sender_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_session_id_checkout_sessions_id_fk": { + "name": "payments_session_id_checkout_sessions_id_fk", + "tableFrom": "payments", + "tableTo": "checkout_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payments_merchant_id_merchants_id_fk": { + "name": "payments_merchant_id_merchants_id_fk", + "tableFrom": "payments", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_tx_hash_unique": { + "name": "payments_tx_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "tx_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_dead_letters": { + "name": "webhook_dead_letters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retried_at": { + "name": "retried_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_dead_letters_merchant_id_merchants_id_fk": { + "name": "webhook_dead_letters_merchant_id_merchants_id_fk", + "tableFrom": "webhook_dead_letters", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_dead_letters_session_id_checkout_sessions_id_fk": { + "name": "webhook_dead_letters_session_id_checkout_sessions_id_fk", + "tableFrom": "webhook_dead_letters", + "tableTo": "checkout_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_dead_letters_delivery_id_unique": { + "name": "webhook_dead_letters_delivery_id_unique", + "nullsNotDistinct": false, + "columns": [ + "delivery_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "status": { + "name": "status", + "type": "webhook_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_log": { + "name": "attempt_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_merchant_id_merchants_id_fk": { + "name": "webhook_deliveries_merchant_id_merchants_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_session_id_checkout_sessions_id_fk": { + "name": "webhook_deliveries_session_id_checkout_sessions_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "checkout_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_deliveries_delivery_id_unique": { + "name": "webhook_deliveries_delivery_id_unique", + "nullsNotDistinct": false, + "columns": [ + "delivery_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.api_key_environment": { + "name": "api_key_environment", + "schema": "public", + "values": [ + "testnet", + "mainnet" + ] + }, + "public.merchant_role": { + "name": "merchant_role", + "schema": "public", + "values": [ + "admin", + "merchant", + "viewer" + ] + }, + "public.session_status": { + "name": "session_status", + "schema": "public", + "values": [ + "pending", + "paid", + "expired", + "cancelled" + ] + }, + "public.webhook_delivery_status": { + "name": "webhook_delivery_status", + "schema": "public", + "values": [ + "pending", + "delivered", + "failed", + "dead" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..161fb31 --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,785 @@ +{ + "id": "fe035b6d-b362-42d9-8b12-cf67148ec338", + "prevId": "6b47ab12-2894-4fc8-9f9a-ccf726924f90", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "api_key_environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'testnet'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_merchant_id_merchants_id_fk": { + "name": "api_keys_merchant_id_merchants_id_fk", + "tableFrom": "api_keys", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_merchant_id_merchants_id_fk": { + "name": "audit_logs_merchant_id_merchants_id_fk", + "tableFrom": "audit_logs", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkout_sessions": { + "name": "checkout_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(36, 7)", + "primaryKey": false, + "notNull": true + }, + "asset_code": { + "name": "asset_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_issuer": { + "name": "asset_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receiving_account": { + "name": "receiving_account", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "checkout_sessions_merchant_id_merchants_id_fk": { + "name": "checkout_sessions_merchant_id_merchants_id_fk", + "tableFrom": "checkout_sessions", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.merchants": { + "name": "merchants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "merchant_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'merchant'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cors_origins": { + "name": "cors_origins", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "merchants_wallet_address_unique": { + "name": "merchants_wallet_address_unique", + "nullsNotDistinct": false, + "columns": [ + "wallet_address" + ] + }, + "merchants_email_unique": { + "name": "merchants_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(36, 7)", + "primaryKey": false, + "notNull": true + }, + "asset_code": { + "name": "asset_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_issuer": { + "name": "asset_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_address": { + "name": "sender_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_session_id_checkout_sessions_id_fk": { + "name": "payments_session_id_checkout_sessions_id_fk", + "tableFrom": "payments", + "tableTo": "checkout_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payments_merchant_id_merchants_id_fk": { + "name": "payments_merchant_id_merchants_id_fk", + "tableFrom": "payments", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payment_idempotency_key": { + "name": "payment_idempotency_key", + "nullsNotDistinct": false, + "columns": [ + "tx_hash", + "session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_dead_letters": { + "name": "webhook_dead_letters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retried_at": { + "name": "retried_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_dead_letters_merchant_id_merchants_id_fk": { + "name": "webhook_dead_letters_merchant_id_merchants_id_fk", + "tableFrom": "webhook_dead_letters", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_dead_letters_session_id_checkout_sessions_id_fk": { + "name": "webhook_dead_letters_session_id_checkout_sessions_id_fk", + "tableFrom": "webhook_dead_letters", + "tableTo": "checkout_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_dead_letters_delivery_id_unique": { + "name": "webhook_dead_letters_delivery_id_unique", + "nullsNotDistinct": false, + "columns": [ + "delivery_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "status": { + "name": "status", + "type": "webhook_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_log": { + "name": "attempt_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_merchant_id_merchants_id_fk": { + "name": "webhook_deliveries_merchant_id_merchants_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "merchants", + "columnsFrom": [ + "merchant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_session_id_checkout_sessions_id_fk": { + "name": "webhook_deliveries_session_id_checkout_sessions_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "checkout_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_deliveries_delivery_id_unique": { + "name": "webhook_deliveries_delivery_id_unique", + "nullsNotDistinct": false, + "columns": [ + "delivery_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.api_key_environment": { + "name": "api_key_environment", + "schema": "public", + "values": [ + "testnet", + "mainnet" + ] + }, + "public.merchant_role": { + "name": "merchant_role", + "schema": "public", + "values": [ + "admin", + "merchant", + "viewer" + ] + }, + "public.session_status": { + "name": "session_status", + "schema": "public", + "values": [ + "pending", + "processing", + "paid", + "expired", + "cancelled" + ] + }, + "public.webhook_delivery_status": { + "name": "webhook_delivery_status", + "schema": "public", + "values": [ + "pending", + "delivered", + "failed", + "dead" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..a201fa7 --- /dev/null +++ b/src/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1781974337143, + "tag": "0000_baseline", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1781974361686, + "tag": "0001_processing-status-and-payment-idempotency-key", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 3815fc9..720728b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -7,6 +7,7 @@ import { pgTable, text, timestamp, + unique, uuid, } from 'drizzle-orm/pg-core'; @@ -16,6 +17,7 @@ export const merchantRoleEnum = pgEnum('merchant_role', ['admin', 'merchant', 'v export const sessionStatusEnum = pgEnum('session_status', [ 'pending', + 'processing', 'paid', 'expired', 'cancelled', @@ -71,22 +73,31 @@ export const checkoutSessions = pgTable('checkout_sessions', { createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }); -export const payments = pgTable('payments', { - id: uuid('id').defaultRandom().primaryKey(), - sessionId: uuid('session_id') - .notNull() - .references(() => checkoutSessions.id, { onDelete: 'cascade' }), - merchantId: uuid('merchant_id') - .notNull() - .references(() => merchants.id), - txHash: text('tx_hash').notNull().unique(), - amount: numeric('amount', { precision: 36, scale: 7 }).notNull(), - assetCode: text('asset_code').notNull(), - assetIssuer: text('asset_issuer'), - senderAddress: text('sender_address').notNull(), - confirmedAt: timestamp('confirmed_at', { withTimezone: true }).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); +export const payments = pgTable( + 'payments', + { + id: uuid('id').defaultRandom().primaryKey(), + sessionId: uuid('session_id') + .notNull() + .references(() => checkoutSessions.id, { onDelete: 'cascade' }), + merchantId: uuid('merchant_id') + .notNull() + .references(() => merchants.id), + txHash: text('tx_hash').notNull(), + amount: numeric('amount', { precision: 36, scale: 7 }).notNull(), + assetCode: text('asset_code').notNull(), + assetIssuer: text('asset_issuer'), + senderAddress: text('sender_address').notNull(), + confirmedAt: timestamp('confirmed_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + // Idempotency key: a tx hash can only ever confirm one session (Stellar memos + // are transaction-level), so the pair must be unique to safely support + // ON CONFLICT DO NOTHING upserts in processPayment and the recovery job. + unique('payment_idempotency_key').on(table.txHash, table.sessionId), + ], +); export interface WebhookDeliveryAttempt { attempt: number; diff --git a/src/payments/payment-detector.service.ts b/src/payments/payment-detector.service.ts index de5bf43..31db718 100644 --- a/src/payments/payment-detector.service.ts +++ b/src/payments/payment-detector.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/commo import { randomUUID } from 'crypto'; import { db } from '../db/index'; import { checkoutSessions, payments } from '../db/schema'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { sql } from 'drizzle-orm'; import { StellarService } from '../stellar/stellar.service'; import { WebhookService } from '../webhook/webhook.service'; @@ -15,6 +15,7 @@ interface LockedSessionRow { amount: string; asset_code: string; memo: string | null; + status: string; } const DEFAULT_INTERVAL_MS = 3_000; @@ -144,20 +145,87 @@ export class PaymentDetectorService implements OnModuleInit, OnModuleDestroy { const txHash = op.transaction_hash; - const existingPayment = await db.query.payments.findFirst({ - where: eq(payments.txHash, txHash), - }); - if (existingPayment) { - this.logger.warn(`Duplicate payment ${txHash} — skipping`); + // Phase 1: lock the session and claim it (pending -> processing) in its own + // transaction. Committing this transition independently of the payment insert + // is what makes a crash between the two phases observable: the session is left + // in 'processing' rather than silently reverting to 'pending', so the recovery + // job can find and reconcile it instead of two pollers racing on the same memo. + const session = await this.claimSession(memo, op, txHash); + if (!session) return; + + // Phase 2: insert the payment record and finalize the session as paid. + try { + await db.transaction(async (tx) => { + await tx + .insert(payments) + .values({ + sessionId: session.id, + merchantId: session.merchant_id, + txHash, + amount: op.amount, + assetCode: op.asset_code ?? 'XLM', + assetIssuer: op.asset_issuer ?? null, + senderAddress: op.from, + confirmedAt: new Date(), + } as any) + .onConflictDoNothing({ target: [payments.txHash, payments.sessionId] }); + + await tx + .update(checkoutSessions) + .set({ status: 'paid' } as any) + .where(eq(checkoutSessions.id, session.id)); + }); + } catch (err) { + this.logger.error( + `Failed to finalize payment for session ${session.id} — tx ${txHash}; session left in 'processing' for recovery`, + err as Error, + ); return; } - await db.transaction(async (tx) => { + this.metrics.paymentsConfirmed.inc(); + this.logger.log(`Payment confirmed for session ${session.id} — tx ${txHash}`); + + await this.webhooks.dispatchWebhook(session.merchant_id, 'payment.confirmed', { + sessionId: session.id, + txHash, + amount: op.amount, + asset: op.asset_code ?? 'XLM', + sender: op.from, + }); + } + + /** + * Locks the session row matching `memo`, validates it, and atomically + * transitions it from 'pending' to 'processing'. Returns the claimed session + * row, or null (after logging why) if the payment should not proceed: + * the session doesn't exist, is already paid/non-pending, fails amount/asset + * validation, was already recorded under this idempotency key, or lost the + * claim race to a concurrent processPayment/recovery run. + */ + private async claimSession( + memo: string, + op: any, + txHash: string, + ): Promise { + return db.transaction(async (tx) => { const lockedSessions = (await tx.execute( - sql`SELECT * FROM checkout_sessions WHERE memo = ${memo} AND status = 'pending' FOR UPDATE`, + sql`SELECT * FROM checkout_sessions WHERE memo = ${memo} FOR UPDATE`, )) as unknown as LockedSessionRow[]; const session = lockedSessions[0]; - if (!session) return; + if (!session) return null; + + if (session.status === 'paid') { + this.logger.warn(`Duplicate payment for already-paid session ${session.id} — tx ${txHash}`); + return null; + } + + if (session.status !== 'pending') { + this.logger.warn( + `Session ${session.id} is not pending (status=${session.status}) — skipping tx ${txHash}`, + ); + return null; + } const opAmount = parseFloat(op.amount); const sessionAmount = parseFloat(session.amount); @@ -165,42 +233,38 @@ export class PaymentDetectorService implements OnModuleInit, OnModuleDestroy { this.logger.warn( `Amount mismatch for memo ${memo}: expected ${sessionAmount}, got ${opAmount}`, ); - return; + return null; } if (op.asset_code !== session.asset_code && session.asset_code !== 'XLM') { this.logger.warn( `Asset mismatch for memo ${memo}: expected ${session.asset_code}, got ${op.asset_code}`, ); - return; + return null; } - await tx - .update(checkoutSessions) - .set({ status: 'paid' } as any) - .where(eq(checkoutSessions.id, session.id)); - - await tx.insert(payments).values({ - sessionId: session.id, - merchantId: session.merchant_id, - txHash, - amount: op.amount, - assetCode: op.asset_code ?? 'XLM', - assetIssuer: op.asset_issuer ?? null, - senderAddress: op.from, - confirmedAt: new Date(), - } as any); - - this.metrics.paymentsConfirmed.inc(); - this.logger.log(`Payment confirmed for session ${session.id} — tx ${txHash}`); - - await this.webhooks.dispatchWebhook(session.merchant_id, 'payment.confirmed', { - sessionId: session.id, - txHash, - amount: op.amount, - asset: op.asset_code ?? 'XLM', - sender: op.from, + const existingPayment = await tx.query.payments.findFirst({ + where: and(eq(payments.txHash, txHash), eq(payments.sessionId, session.id)), }); + if (existingPayment) { + this.logger.warn( + `Payment ${txHash} already recorded for session ${session.id} — skipping insert (idempotent)`, + ); + return null; + } + + const claimed = await tx + .update(checkoutSessions) + .set({ status: 'processing' } as any) + .where(and(eq(checkoutSessions.id, session.id), eq(checkoutSessions.status, 'pending'))) + .returning({ id: checkoutSessions.id }); + + if (claimed.length === 0) { + this.logger.warn(`Lost claim race for session ${session.id} — tx ${txHash}`); + return null; + } + + return session; }); } diff --git a/src/payments/payment-recovery.service.ts b/src/payments/payment-recovery.service.ts index d7cf9fa..be32d85 100644 --- a/src/payments/payment-recovery.service.ts +++ b/src/payments/payment-recovery.service.ts @@ -2,8 +2,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { db } from '../db/index'; import { checkoutSessions, payments } from '../db/schema'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { sql } from 'drizzle-orm'; +import { StellarService } from '../stellar/stellar.service'; import { WebhookService } from '../webhook/webhook.service'; const STALE_MINUTES = 5; @@ -13,6 +14,16 @@ interface StaleSessionRow { memo: string | null; } +interface StuckProcessingRow { + id: string; + merchant_id: string; + memo: string | null; + amount: string; + asset_code: string; + asset_issuer: string | null; + receiving_account: string; +} + interface PaidWithoutPaymentRow { id: string; merchant_id: string; @@ -33,17 +44,134 @@ interface PendingWithPaymentRow { export class PaymentRecoveryService { private readonly logger = new Logger(PaymentRecoveryService.name); - constructor(private readonly webhooks: WebhookService) {} + constructor( + private readonly webhooks: WebhookService, + private readonly stellar: StellarService, + ) {} @Cron(CronExpression.EVERY_5_MINUTES) async recoverStaleSessions(): Promise { this.logger.debug('Running payment recovery job'); + await this.recoverStuckProcessing(); await this.recoverPendingWithPayments(); await this.recoverStuckPending(); await this.recoverPaidWithoutPayments(); } + /** + * Sessions stuck in 'processing' mean processPayment's claim phase committed + * but the process crashed (or errored) before the payment insert + paid + * transition could commit. Re-checks Horizon by memo to decide whether to + * complete the payment or release the session back to 'pending'. + */ + private async recoverStuckProcessing(): Promise { + const staleThreshold = new Date(Date.now() - STALE_MINUTES * 60 * 1000); + + const stuck = (await db.execute(sql` + SELECT id, merchant_id, memo, amount, asset_code, asset_issuer, receiving_account + FROM checkout_sessions + WHERE status = 'processing' AND created_at < ${staleThreshold} + `)) as unknown as StuckProcessingRow[]; + + if (!stuck.length) return; + + this.logger.warn( + `Found ${stuck.length} session(s) stuck in processing for >${STALE_MINUTES} minutes`, + ); + + for (const row of stuck) { + try { + await this.reconcileStuckSession(row); + } catch (err) { + this.logger.error(`Failed to reconcile stuck session ${row.id}`, err as Error); + } + } + } + + private async reconcileStuckSession(row: StuckProcessingRow): Promise { + const match = row.memo ? await this.findConfirmingPayment(row) : null; + + if (!match) { + this.logger.warn( + `Stuck session ${row.id} — no confirming payment found on Horizon, reverting to pending`, + ); + await this.revertToPending(row.id); + return; + } + + const confirmed = await this.stellar.verifyTransaction(match.transaction_hash); + if (!confirmed) { + this.logger.warn( + `Stuck session ${row.id} — transaction ${match.transaction_hash} not successful on Horizon, reverting to pending`, + ); + await this.revertToPending(row.id); + return; + } + + await db.transaction(async (tx) => { + await tx + .insert(payments) + .values({ + sessionId: row.id, + merchantId: row.merchant_id, + txHash: match.transaction_hash, + amount: match.amount, + assetCode: match.asset_code ?? 'XLM', + assetIssuer: match.asset_issuer ?? null, + senderAddress: match.from, + confirmedAt: new Date(), + } as any) + .onConflictDoNothing({ target: [payments.txHash, payments.sessionId] }); + + await tx + .update(checkoutSessions) + .set({ status: 'paid' } as any) + .where(eq(checkoutSessions.id, row.id)); + }); + + this.logger.log( + `Recovered stuck session ${row.id} — confirmed via Horizon tx ${match.transaction_hash}, marked as paid`, + ); + + await this.webhooks.dispatchWebhook(row.merchant_id, 'payment.confirmed', { + sessionId: row.id, + txHash: match.transaction_hash, + amount: match.amount, + asset: match.asset_code ?? 'XLM', + sender: match.from, + }); + } + + /** Searches the receiving account's recent Horizon payments for one matching this session. */ + private async findConfirmingPayment(row: StuckProcessingRow): Promise { + const records = await this.stellar.getPaymentsForAccount( + row.receiving_account, + undefined, + 'desc', + ); + + const sessionAmount = parseFloat(row.amount); + return ( + records.find( + (r: any) => + r.type === 'payment' && + r.transaction_memo === row.memo && + Math.abs(parseFloat(r.amount) - sessionAmount) < 0.0000001 && + (r.asset_code === row.asset_code || row.asset_code === 'XLM'), + ) ?? null + ); + } + + /** Releases a stuck 'processing' session back to 'pending' so it can be re-detected. */ + private async revertToPending(sessionId: string): Promise { + await db + .update(checkoutSessions) + .set({ status: 'pending' } as any) + .where(and(eq(checkoutSessions.id, sessionId), eq(checkoutSessions.status, 'processing'))); + this.logger.log(`Reverted session ${sessionId} to pending for re-detection`); + } + private async recoverPendingWithPayments(): Promise { const orphaned = (await db.execute(sql` SELECT cs.id, cs.merchant_id, p.tx_hash, p.amount, p.asset_code, p.sender_address, p.confirmed_at diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts index bfd61e0..c278cfb 100644 --- a/src/stellar/stellar.service.ts +++ b/src/stellar/stellar.service.ts @@ -40,17 +40,27 @@ export class StellarService { return data._embedded?.records ?? []; } - async getPaymentsForAccount(accountAddress: string, cursor?: string) { - const { records } = await this.getPaymentsPage(accountAddress, cursor); + async getPaymentsForAccount( + accountAddress: string, + cursor?: string, + order: 'asc' | 'desc' = 'asc', + ) { + const { records } = await this.getPaymentsPage(accountAddress, cursor, order); return records; } /** * Fetch a page of payments and return Horizon rate-limit metadata alongside the records. * Throws an AxiosError on non-2xx responses so callers can inspect the HTTP status. + * `order: 'desc'` is used by the recovery job to look for a recent confirmation + * without needing a stored cursor. */ - async getPaymentsPage(accountAddress: string, cursor?: string): Promise { - const params: any = { order: 'asc', limit: 50 }; + async getPaymentsPage( + accountAddress: string, + cursor?: string, + order: 'asc' | 'desc' = 'asc', + ): Promise { + const params: any = { order, limit: 50 }; if (cursor && cursor !== 'now') params.cursor = cursor; const response = await axios.get(`${this.horizonUrl}/accounts/${accountAddress}/payments`, {