Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/controllers/pay.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
6 changes: 4 additions & 2 deletions src/indexer/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -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);
13 changes: 13 additions & 0 deletions src/indexer/handlers/invoicePaid.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
await applyInvoicePayment(decodeInvoicePaidEventData(event.data), event.txHash);
};
1 change: 1 addition & 0 deletions src/indexer/run.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './handlers/index.js';
import { startPolling, stopPolling } from './poller.js';

process.on('SIGINT', () => {
Expand Down
75 changes: 75 additions & 0 deletions src/indexer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | Map<unknown, unknown>;

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'),
};
};
81 changes: 81 additions & 0 deletions src/services/invoice.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
InvoicePagination,
parseAmount,
} from '../utils/invoice.validation.js';
import type { InvoicePaidEventData } from '../indexer/types.js';

const SLUG_MAX_RETRIES = 5;
const INVOICE_DESCRIPTION_MAX_LENGTH = 100;
Expand All @@ -19,9 +20,14 @@ const InvoiceStatus = {
DRAFT: 'DRAFT',
PENDING: 'PENDING',
PAID: 'PAID',
PARTIALLY_PAID: 'PARTIALLY_PAID',
CANCELLED: 'CANCELLED',
} as const satisfies Record<string, PrismaInvoiceStatus>;

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.
Expand Down Expand Up @@ -231,3 +237,78 @@ 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 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: transactionInvoice.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 };
});
};
Comment thread
ryzen-xp marked this conversation as resolved.
44 changes: 44 additions & 0 deletions tests/unit/invoice-paid.handler.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
108 changes: 107 additions & 1 deletion tests/unit/invoice.services.test.ts
Original file line number Diff line number Diff line change
@@ -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, amendInvoice } = await import(
const { createInvoice, listInvoices, getInvoice, voidInvoice, amendInvoice, applyInvoicePayment } = await import(
'../../src/services/invoice.services.js'
);

Expand All @@ -13,6 +14,7 @@ const baseInvoice = {
paymentSlug: 'slug-1',
description: 'Website design',
amount: 5000n,
amountPaid: 0n,
token: 'USDC',
merchantId: MERCHANT_ID,
status: 'PENDING',
Expand All @@ -29,6 +31,7 @@ const baseInvoice = {
describe('invoice services', () => {
beforeEach(() => {
mockReset(prismaMock);
prismaMock.$transaction.mockImplementation(async (callback: any) => callback(prismaMock));
});

describe('createInvoice', () => {
Expand Down Expand Up @@ -208,4 +211,107 @@ 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.invoice.findUniqueOrThrow.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,
} 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);
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.findUniqueOrThrow).toHaveBeenCalledWith({
where: { invoiceId: paymentEvent.invoiceId },
});
expect(prismaMock.invoice.update).toHaveBeenCalledWith({
where: { id: 'transaction-invoice-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();
});
});
});
Loading