From e6c8b6a036395278bc1478350939d23f91de3255 Mon Sep 17 00:00:00 2001 From: ryzen-xp Date: Wed, 29 Jul 2026 08:19:12 +0530 Subject: [PATCH 1/2] feat : Indexer Handler InvoicePaidEvent --- src/controllers/pay.controllers.ts | 6 +- src/indexer/handlers/index.ts | 6 +- src/indexer/handlers/invoicePaid.ts | 13 ++++ src/indexer/run.ts | 1 + src/indexer/types.ts | 75 +++++++++++++++++++ src/services/invoice.services.ts | 77 ++++++++++++++++++++ tests/unit/invoice-paid.handler.test.ts | 44 ++++++++++++ tests/unit/invoice.services.test.ts | 96 ++++++++++++++++++++++++- 8 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 src/indexer/handlers/invoicePaid.ts create mode 100644 tests/unit/invoice-paid.handler.test.ts diff --git a/src/controllers/pay.controllers.ts b/src/controllers/pay.controllers.ts index 0b88f74..9557fbd 100644 --- a/src/controllers/pay.controllers.ts +++ b/src/controllers/pay.controllers.ts @@ -65,8 +65,12 @@ export const confirmPaymentController = async (req: Request, res: Response): Pro return; } + // Kept for backwards compatibility only. Client input is never used to + // mutate invoice payment state; InvoicePaid indexer events are authoritative. await confirmPayment(slug as string, payerAddress, txHash); - res.status(202).json({ message: 'Payment confirmation received' }); + res.status(202).json({ + message: 'Confirmation recorded; invoice payment state is updated from on-chain events.', + }); } catch (error) { if (error instanceof AppError) { if (error.statusCode === 410 && error.message === 'expired') { diff --git a/src/indexer/handlers/index.ts b/src/indexer/handlers/index.ts index 4d39919..fe67499 100644 --- a/src/indexer/handlers/index.ts +++ b/src/indexer/handlers/index.ts @@ -1,2 +1,4 @@ -// Handlers will be registered here as separate issues are implemented. -export {}; +import { registerEventHandler } from '../registry.js'; +import { handleInvoicePaid, INVOICE_PAID_TOPIC } from './invoicePaid.js'; + +registerEventHandler(INVOICE_PAID_TOPIC, handleInvoicePaid); diff --git a/src/indexer/handlers/invoicePaid.ts b/src/indexer/handlers/invoicePaid.ts new file mode 100644 index 0000000..2d0faa7 --- /dev/null +++ b/src/indexer/handlers/invoicePaid.ts @@ -0,0 +1,13 @@ +import { applyInvoicePayment } from '../../services/invoice.services.js'; +import { decodeInvoicePaidEventData, type DecodedEvent } from '../types.js'; + +// The `#[contractevent] InvoicePaidEvent` macro emits this first topic symbol. +export const INVOICE_PAID_TOPIC = 'InvoicePaid'; + +/** + * Keeps contract-specific payload normalization at the indexer edge; all + * persistence is delegated to applyInvoicePayment. + */ +export const handleInvoicePaid = async (event: DecodedEvent): Promise => { + await applyInvoicePayment(decodeInvoicePaidEventData(event.data), event.txHash); +}; diff --git a/src/indexer/run.ts b/src/indexer/run.ts index e7fad92..3757833 100644 --- a/src/indexer/run.ts +++ b/src/indexer/run.ts @@ -1,3 +1,4 @@ +import './handlers/index.js'; import { startPolling, stopPolling } from './poller.js'; process.on('SIGINT', () => { diff --git a/src/indexer/types.ts b/src/indexer/types.ts index 4b0e4d4..ba07372 100644 --- a/src/indexer/types.ts +++ b/src/indexer/types.ts @@ -5,3 +5,78 @@ export interface DecodedEvent { txHash: string; data: any; } + +/** + * Normalized payload for the Shade contract's `InvoicePaidEvent`. + * + * Soroban's `scValToNative` preserves the Rust event-map field names + * (`invoice_id`, `merchant_id`, and so on), so the handler converts that + * payload to this application-facing shape before calling the invoice service. + */ +export interface InvoicePaidEventData { + invoiceId: number; + merchantId: number; + payer: string; + amount: bigint; + fee: bigint; + merchantAmount: bigint; + token: string; + timestamp: number; +} + +type EventRecord = Record | Map; + +const isEventRecord = (value: unknown): value is EventRecord => + value instanceof Map || (typeof value === 'object' && value !== null); + +const readField = (data: EventRecord, camelCase: string, snakeCase: string): unknown => { + if (data instanceof Map) { + return data.get(camelCase) ?? data.get(snakeCase); + } + return data[camelCase] ?? data[snakeCase]; +}; + +const toBigInt = (value: unknown, field: string): bigint => { + try { + return typeof value === 'bigint' ? value : BigInt(value as string | number | boolean); + } catch { + throw new Error(`InvoicePaid event field "${field}" must be an integer`); + } +}; + +const toSafeNumber = (value: unknown, field: string): number => { + const parsed = toBigInt(value, field); + if (parsed < 0n || parsed > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error( + `InvoicePaid event field "${field}" is outside JavaScript's safe integer range`, + ); + } + return Number(parsed); +}; + +const toString = (value: unknown, field: string): string => { + if (value === null || value === undefined) { + throw new Error(`InvoicePaid event field "${field}" is required`); + } + return String(value); +}; + +export const decodeInvoicePaidEventData = (data: unknown): InvoicePaidEventData => { + if (!isEventRecord(data)) { + throw new Error('InvoicePaid event data must be a decoded map'); + } + + return { + invoiceId: toSafeNumber(readField(data, 'invoiceId', 'invoice_id'), 'invoice_id'), + merchantId: toSafeNumber(readField(data, 'merchantId', 'merchant_id'), 'merchant_id'), + payer: toString(readField(data, 'payer', 'payer'), 'payer'), + amount: toBigInt(readField(data, 'amount', 'amount'), 'amount'), + fee: toBigInt(readField(data, 'fee', 'fee'), 'fee'), + merchantAmount: toBigInt( + readField(data, 'merchantAmount', 'merchant_amount'), + 'merchant_amount', + ), + token: toString(readField(data, 'token', 'token'), 'token'), + timestamp: toSafeNumber(readField(data, 'timestamp', 'timestamp'), 'timestamp'), + }; +}; diff --git a/src/services/invoice.services.ts b/src/services/invoice.services.ts index 28e3b54..dad40b5 100644 --- a/src/services/invoice.services.ts +++ b/src/services/invoice.services.ts @@ -8,6 +8,7 @@ import { InvoicePagination, parseAmount, } from '../utils/invoice.validation.js'; +import type { InvoicePaidEventData } from '../indexer/types.js'; const SLUG_MAX_RETRIES = 5; @@ -18,9 +19,14 @@ const InvoiceStatus = { DRAFT: 'DRAFT', PENDING: 'PENDING', PAID: 'PAID', + PARTIALLY_PAID: 'PARTIALLY_PAID', CANCELLED: 'CANCELLED', } as const satisfies Record; +const TransactionType = { + INVOICE_PAYMENT: 'INVOICE_PAYMENT', +} as const; + /** * Public-facing view of an invoice. `amount` is serialized to a string because * `BigInt` is not JSON-serializable. @@ -172,3 +178,74 @@ export const voidInvoice = async (merchantId: string, id: string) => { return sanitizeInvoice(updated); }; + +/** + * Applies a confirmed on-chain invoice payment to the backend projection. + * + * The indexer's IndexerEvent table is the only replay guard. Do not add an + * event-level guard here: this service deliberately owns state mutation only. + * Deposit-account payment detection is intentionally out of scope; it will be + * handled by a dedicated indexer handler in a follow-up issue. + */ +export const applyInvoicePayment = async (event: InvoicePaidEventData, txHash: string) => { + const invoice = await prisma.invoice.findUnique({ + where: { invoiceId: event.invoiceId }, + }); + + if (!invoice) { + console.warn( + `InvoicePaid event for invoice ${event.invoiceId} (${txHash}) skipped: invoice is not in the database.`, + ); + return null; + } + + const merchant = await prisma.merchant.findUnique({ + where: { merchantId: event.merchantId }, + }); + + if (!merchant) { + console.warn( + `InvoicePaid event for invoice ${event.invoiceId} (${txHash}) skipped: merchant ${event.merchantId} is not in the database.`, + ); + return null; + } + + if (invoice.merchantId !== merchant.id) { + console.warn( + `InvoicePaid event for invoice ${event.invoiceId} (${txHash}) skipped: invoice and event merchants do not match.`, + ); + return null; + } + + const amountPaid = invoice.amountPaid + event.amount; + const status: PrismaInvoiceStatus = + amountPaid >= invoice.amount ? InvoiceStatus.PAID : InvoiceStatus.PARTIALLY_PAID; + const paidAt = new Date(event.timestamp * 1000); + const description = `Invoice #${event.invoiceId} payment${txHash ? ` (${txHash})` : ''}`; + + return prisma.$transaction(async (tx: any) => { + const updatedInvoice = await tx.invoice.update({ + where: { id: invoice.id }, + data: { + status, + payer: event.payer, + amountPaid, + datePaid: status === InvoiceStatus.PAID ? paidAt : null, + }, + }); + + const transaction = await tx.transaction.create({ + data: { + transactionType: TransactionType.INVOICE_PAYMENT, + refId: event.invoiceId, + amount: event.amount, + token: event.token, + description, + merchantId: merchant.id, + date: paidAt, + }, + }); + + return { invoice: updatedInvoice, transaction }; + }); +}; diff --git a/tests/unit/invoice-paid.handler.test.ts b/tests/unit/invoice-paid.handler.test.ts new file mode 100644 index 0000000..f6d052f --- /dev/null +++ b/tests/unit/invoice-paid.handler.test.ts @@ -0,0 +1,44 @@ +const { decodeInvoicePaidEventData } = await import('../../src/indexer/types.js'); +const { dispatch } = await import('../../src/indexer/registry.js'); +const { INVOICE_PAID_TOPIC } = await import('../../src/indexer/handlers/invoicePaid.js'); +await import('../../src/indexer/handlers/index.js'); + +describe('InvoicePaid indexer handler', () => { + test('normalizes the contract event map emitted by scValToNative', () => { + expect( + decodeInvoicePaidEventData({ + invoice_id: 42n, + merchant_id: 9n, + merchant_account: 'C_MERCHANT_ACCOUNT', + payer: 'G_PAYER', + amount: 5000n, + fee: 50n, + merchant_amount: 4950n, + token: 'C_TOKEN', + timestamp: 1_700_000_000n, + }), + ).toEqual({ + invoiceId: 42, + merchantId: 9, + payer: 'G_PAYER', + amount: 5000n, + fee: 50n, + merchantAmount: 4950n, + token: 'C_TOKEN', + timestamp: 1_700_000_000, + }); + }); + + test('registers the contract event symbol used by InvoicePaidEvent', async () => { + expect(INVOICE_PAID_TOPIC).toBe('InvoicePaid'); + await expect( + dispatch({ + id: 'invoice-paid-invalid-payload', + topic: INVOICE_PAID_TOPIC, + ledger: 1, + txHash: 'tx-hash', + data: null, + }), + ).rejects.toThrow('InvoicePaid event data must be a decoded map'); + }); +}); diff --git a/tests/unit/invoice.services.test.ts b/tests/unit/invoice.services.test.ts index e9f79f2..f09f3f2 100644 --- a/tests/unit/invoice.services.test.ts +++ b/tests/unit/invoice.services.test.ts @@ -1,7 +1,8 @@ +import { jest } from '@jest/globals'; import { mockReset } from 'jest-mock-extended'; const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; -const { createInvoice, listInvoices, getInvoice, voidInvoice } = await import( +const { createInvoice, listInvoices, getInvoice, voidInvoice, applyInvoicePayment } = await import( '../../src/services/invoice.services.js' ); @@ -13,6 +14,7 @@ const baseInvoice = { paymentSlug: 'slug-1', description: 'Website design', amount: 5000n, + amountPaid: 0n, token: 'USDC', merchantId: MERCHANT_ID, status: 'PENDING', @@ -29,6 +31,7 @@ const baseInvoice = { describe('invoice services', () => { beforeEach(() => { mockReset(prismaMock); + prismaMock.$transaction.mockImplementation(async (callback: any) => callback(prismaMock)); }); describe('createInvoice', () => { @@ -158,4 +161,95 @@ describe('invoice services', () => { }); }); }); + + describe('applyInvoicePayment', () => { + const paymentEvent = { + invoiceId: 101, + merchantId: 7, + payer: 'GPAyerAddress', + amount: 2000n, + fee: 20n, + merchantAmount: 1980n, + token: 'USDC', + timestamp: 1_700_000_000, + }; + + const merchant = { id: MERCHANT_ID, merchantId: paymentEvent.merchantId }; + + test('marks a partially paid invoice, records the amount, and creates its transaction', async () => { + prismaMock.invoice.findUnique.mockResolvedValue({ + ...baseInvoice, + invoiceId: paymentEvent.invoiceId, + } as any); + prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); + prismaMock.invoice.update.mockImplementation(async (args: any) => ({ + ...baseInvoice, + ...args.data, + })); + prismaMock.transaction.create.mockResolvedValue({ id: 'transaction-1' } as any); + + await applyInvoicePayment(paymentEvent, 'tx-partial'); + + expect(prismaMock.invoice.findUnique).toHaveBeenCalledWith({ + where: { invoiceId: paymentEvent.invoiceId }, + }); + expect(prismaMock.merchant.findUnique).toHaveBeenCalledWith({ + where: { merchantId: paymentEvent.merchantId }, + }); + expect(prismaMock.invoice.update).toHaveBeenCalledWith({ + where: { id: baseInvoice.id }, + data: { + status: 'PARTIALLY_PAID', + payer: paymentEvent.payer, + amountPaid: 2000n, + datePaid: null, + }, + }); + expect(prismaMock.transaction.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + transactionType: 'INVOICE_PAYMENT', + refId: paymentEvent.invoiceId, + amount: paymentEvent.amount, + token: paymentEvent.token, + merchantId: MERCHANT_ID, + date: new Date(paymentEvent.timestamp * 1000), + }), + }); + }); + + test('marks the invoice PAID and sets datePaid when the payment completes it', async () => { + prismaMock.invoice.findUnique.mockResolvedValue({ + ...baseInvoice, + invoiceId: paymentEvent.invoiceId, + amountPaid: 3500n, + } as any); + prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); + prismaMock.invoice.update.mockResolvedValue({ ...baseInvoice, status: 'PAID' } as any); + prismaMock.transaction.create.mockResolvedValue({ id: 'transaction-1' } as any); + + await applyInvoicePayment(paymentEvent, 'tx-complete'); + + expect(prismaMock.invoice.update).toHaveBeenCalledWith({ + where: { id: baseInvoice.id }, + data: { + status: 'PAID', + payer: paymentEvent.payer, + amountPaid: 5500n, + datePaid: new Date(paymentEvent.timestamp * 1000), + }, + }); + }); + + test('logs and skips an on-chain payment whose invoice is not in the database', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + prismaMock.invoice.findUnique.mockResolvedValue(null); + + await expect(applyInvoicePayment(paymentEvent, 'tx-missing')).resolves.toBeNull(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('invoice is not in the database')); + expect(prismaMock.merchant.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.$transaction).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + }); }); From 2842dd4ca4ac500672fbb07fab4bfb5f7f9446ad Mon Sep 17 00:00:00 2001 From: ryzen-xp Date: Wed, 29 Jul 2026 15:14:57 +0530 Subject: [PATCH 2/2] fix(invoice): calculate payment totals within transaction --- src/services/invoice.services.ts | 12 ++++++++---- tests/unit/invoice.services.test.ts | 14 +++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/services/invoice.services.ts b/src/services/invoice.services.ts index dad40b5..60ecf45 100644 --- a/src/services/invoice.services.ts +++ b/src/services/invoice.services.ts @@ -217,15 +217,19 @@ export const applyInvoicePayment = async (event: InvoicePaidEventData, txHash: s return null; } - const amountPaid = invoice.amountPaid + event.amount; - const status: PrismaInvoiceStatus = - amountPaid >= invoice.amount ? InvoiceStatus.PAID : InvoiceStatus.PARTIALLY_PAID; const paidAt = new Date(event.timestamp * 1000); const description = `Invoice #${event.invoiceId} payment${txHash ? ` (${txHash})` : ''}`; return prisma.$transaction(async (tx: any) => { + const transactionInvoice = await tx.invoice.findUniqueOrThrow({ + where: { invoiceId: event.invoiceId }, + }); + const amountPaid = transactionInvoice.amountPaid + event.amount; + const status: PrismaInvoiceStatus = + amountPaid >= transactionInvoice.amount ? InvoiceStatus.PAID : InvoiceStatus.PARTIALLY_PAID; + const updatedInvoice = await tx.invoice.update({ - where: { id: invoice.id }, + where: { id: transactionInvoice.id }, data: { status, payer: event.payer, diff --git a/tests/unit/invoice.services.test.ts b/tests/unit/invoice.services.test.ts index f09f3f2..8efad03 100644 --- a/tests/unit/invoice.services.test.ts +++ b/tests/unit/invoice.services.test.ts @@ -181,6 +181,10 @@ describe('invoice services', () => { ...baseInvoice, invoiceId: paymentEvent.invoiceId, } as any); + prismaMock.invoice.findUniqueOrThrow.mockResolvedValue({ + ...baseInvoice, + invoiceId: paymentEvent.invoiceId, + } as any); prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); prismaMock.invoice.update.mockImplementation(async (args: any) => ({ ...baseInvoice, @@ -221,6 +225,11 @@ describe('invoice services', () => { prismaMock.invoice.findUnique.mockResolvedValue({ ...baseInvoice, invoiceId: paymentEvent.invoiceId, + } as any); + prismaMock.invoice.findUniqueOrThrow.mockResolvedValue({ + ...baseInvoice, + id: 'transaction-invoice-id', + invoiceId: paymentEvent.invoiceId, amountPaid: 3500n, } as any); prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); @@ -229,8 +238,11 @@ describe('invoice services', () => { await applyInvoicePayment(paymentEvent, 'tx-complete'); + expect(prismaMock.invoice.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { invoiceId: paymentEvent.invoiceId }, + }); expect(prismaMock.invoice.update).toHaveBeenCalledWith({ - where: { id: baseInvoice.id }, + where: { id: 'transaction-invoice-id' }, data: { status: 'PAID', payer: paymentEvent.payer,