diff --git a/src/controllers/admin-invoice.controllers.ts b/src/controllers/admin-invoice.controllers.ts new file mode 100644 index 0000000..627d073 --- /dev/null +++ b/src/controllers/admin-invoice.controllers.ts @@ -0,0 +1,44 @@ +import { Request, Response } from 'express'; +import { getAdminInvoice, listAdminInvoices } from '../services/invoice.services.js'; +import { parseAdminInvoiceListQuery } from '../utils/invoice.validation.js'; +import { AppError } from '../utils/errors.js'; + +export const listAdminInvoicesController = async (req: Request, res: Response): Promise => { + const { filters, pagination, errors } = parseAdminInvoiceListQuery( + req.query as Record, + ); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const result = await listAdminInvoices(filters, pagination); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res); + } +}; + +export const getAdminInvoiceController = async (req: Request, res: Response): Promise => { + try { + const invoice = await getAdminInvoice(req.params.id as string); + res.status(200).json(invoice); + } catch (error) { + handleError(error, req, res); + } +}; + +const handleError = (error: unknown, req: Request, res: Response): void => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error('Failed to load admin invoices', { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Internal Server Error' }); +}; diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 1776288..d08c575 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -4,6 +4,7 @@ import analyticsRoutes from './analytics.routes.js'; import merchantRoutes from './merchant.routes.js'; import logsRoutes from './logs.routes.js'; import subscriptionsRoutes from './subscriptions.routes.js'; +import invoiceRoutes from './invoice.routes.js'; import { authenticateAdmin } from '../../middlewares/admin.middleware.js'; const router = Router(); @@ -19,8 +20,6 @@ router.use('/analytics', analyticsRoutes); router.use('/merchants', authenticateAdmin, merchantRoutes); router.use('/logs', authenticateAdmin, logsRoutes); router.use('/subscriptions', authenticateAdmin, subscriptionsRoutes); - -// Sibling routers added by later issues (invoice.routes.ts, ...) are mounted -// here behind authenticateAdmin. +router.use('/invoices', authenticateAdmin, invoiceRoutes); export default router; diff --git a/src/routes/admin/invoice.routes.ts b/src/routes/admin/invoice.routes.ts new file mode 100644 index 0000000..bee4aea --- /dev/null +++ b/src/routes/admin/invoice.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { + getAdminInvoiceController, + listAdminInvoicesController, +} from '../../controllers/admin-invoice.controllers.js'; +import { authenticateAdmin } from '../../middlewares/admin.middleware.js'; + +const router = Router(); + +// Read-only dashboard data: any authenticated admin, no superadmin requirement. +router.use(authenticateAdmin); + +router.get('/', listAdminInvoicesController); +router.get('/:id', getAdminInvoiceController); + +export default router; diff --git a/src/services/invoice.services.ts b/src/services/invoice.services.ts index b796c42..1bb24c2 100644 --- a/src/services/invoice.services.ts +++ b/src/services/invoice.services.ts @@ -3,6 +3,7 @@ import prisma from '../config/prisma.js'; import { AppError } from '../utils/errors.js'; import { generatePaymentSlug } from '../utils/slug.js'; import { + AdminInvoiceListFilters, CreateInvoiceInput, InvoiceListFilters, InvoicePagination, @@ -170,6 +171,52 @@ export const getInvoice = async (merchantId: string, id: string) => { return sanitizeInvoice(invoice); }; +export const listAdminInvoices = async ( + filters: AdminInvoiceListFilters, + pagination: InvoicePagination, +) => { + const where: Prisma.InvoiceWhereInput = {}; + + if (filters.status) { + where.status = filters.status; + } + + if (filters.merchantAddress) { + where.merchant = { address: filters.merchantAddress }; + } + + const [invoices, total] = await Promise.all([ + prisma.invoice.findMany({ + where, + take: pagination.limit, + skip: pagination.offset, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }), + prisma.invoice.count({ where }), + ]); + + return { + data: invoices.map(sanitizeInvoice), + pagination: { + limit: pagination.limit, + offset: pagination.offset, + total, + }, + }; +}; + +export const getAdminInvoice = async (id: string) => { + const invoice = await prisma.invoice.findUnique({ + where: { id }, + }); + + if (!invoice) { + throw new AppError(404, 'Invoice not found'); + } + + return sanitizeInvoice(invoice); +}; + /** * Fetches the raw invoice + merchant records, scoped to the owning merchant, * for the PDF/email flows that need fields beyond the sanitized public view diff --git a/src/utils/invoice.validation.ts b/src/utils/invoice.validation.ts index 2e0bfce..254e903 100644 --- a/src/utils/invoice.validation.ts +++ b/src/utils/invoice.validation.ts @@ -171,3 +171,60 @@ export const parseInvoiceListQuery = (query: Record): ParsedLis return { filters, pagination: { limit, offset }, errors }; }; + +export interface AdminInvoiceListFilters { + status?: InvoiceStatus; + merchantAddress?: string; +} + +export interface ParsedAdminInvoiceListQuery { + filters: AdminInvoiceListFilters; + pagination: InvoicePagination; + errors: ValidationErrors; +} + +/** + * Parses admin invoice list query parameters into typed filters and pagination, + * clamping the page size to [1, MAX_LIMIT] and defaulting to DEFAULT_LIMIT. + */ +export const parseAdminInvoiceListQuery = ( + query: Record, +): ParsedAdminInvoiceListQuery => { + const errors: ValidationErrors = {}; + const filters: AdminInvoiceListFilters = {}; + + if (query.status !== undefined) { + const status = String(query.status).toUpperCase(); + if ((INVOICE_STATUSES as readonly string[]).includes(status)) { + filters.status = status as InvoiceStatus; + } else { + errors.status = `status must be one of ${INVOICE_STATUSES.join(', ')}`; + } + } + + if (isNonEmptyString(query.merchantAddress)) { + filters.merchantAddress = query.merchantAddress.trim(); + } + + let limit = DEFAULT_LIMIT; + if (query.limit !== undefined) { + const parsed = Number(query.limit); + if (!Number.isFinite(parsed) || parsed < 1) { + errors.limit = 'limit must be a positive number'; + } else { + limit = Math.min(Math.floor(parsed), MAX_LIMIT); + } + } + + let offset = 0; + if (query.offset !== undefined) { + const parsed = Number(query.offset); + if (!Number.isFinite(parsed) || parsed < 0) { + errors.offset = 'offset must be a non-negative number'; + } else { + offset = Math.floor(parsed); + } + } + + return { filters, pagination: { limit, offset }, errors }; +}; diff --git a/tests/integration/admin.invoices.routes.test.ts b/tests/integration/admin.invoices.routes.test.ts new file mode 100644 index 0000000..69a821d --- /dev/null +++ b/tests/integration/admin.invoices.routes.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, test } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { default: app } = await import('../../src/app.js'); + +const admin = { + id: 'admin-uuid', + address: 'GADMINADDRESS', + active: true, + isSuperAdmin: false, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const adminToken = jwt.sign( + { sub: admin.id, address: admin.address, type: 'admin' }, + environment.jwtSecret, + { expiresIn: '15m' }, +); + +const mockDate = new Date('2026-06-24T10:00:00.000Z'); + +const baseInvoice = { + id: 'invoice-uuid-1', + invoiceId: 101, + paymentSlug: 'slug-101', + description: 'Pro Subscription Invoice', + amount: BigInt(50_000_000), + amountPaid: BigInt(0), + amountRefunded: BigInt(0), + token: 'CDSTOKEN...', + merchantId: 'merchant-uuid-1', + payer: 'GPAYER123', + email: 'payer@example.com', + status: 'PENDING', + pricingMode: 'FIXED_CRYPTO', + fiatCurrency: null, + fiatAmount: null, + fiatDecimals: null, + expiresAt: null, + datePaid: null, + createdAt: mockDate, + updatedAt: mockDate, +}; + +describe('Admin Invoice Routes', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + describe('GET /api/v1/admin/invoices', () => { + test('returns 401 when unauthenticated', async () => { + const response = await request(app).get('/api/v1/admin/invoices'); + + expect(response.status).toBe(401); + expect(prismaMock.invoice.findMany).not.toHaveBeenCalled(); + }); + + test('lists invoices newest-first by default with default pagination', async () => { + prismaMock.invoice.findMany.mockResolvedValue([baseInvoice]); + prismaMock.invoice.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/invoices') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0]).toEqual({ + id: baseInvoice.id, + paymentSlug: baseInvoice.paymentSlug, + description: baseInvoice.description, + amount: '50000000', + token: baseInvoice.token, + status: baseInvoice.status, + merchantId: baseInvoice.merchantId, + email: baseInvoice.email, + expiresAt: null, + datePaid: null, + createdAt: mockDate.toISOString(), + updatedAt: mockDate.toISOString(), + }); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('filters correctly by status', async () => { + prismaMock.invoice.findMany.mockResolvedValue([baseInvoice]); + prismaMock.invoice.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/invoices?status=PAID') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: { status: 'PAID' }, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('filters correctly by merchantAddress', async () => { + prismaMock.invoice.findMany.mockResolvedValue([baseInvoice]); + prismaMock.invoice.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/invoices?merchantAddress=GMERCHANT123') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: { merchant: { address: 'GMERCHANT123' } }, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('filters correctly by both status and merchantAddress combined', async () => { + prismaMock.invoice.findMany.mockResolvedValue([baseInvoice]); + prismaMock.invoice.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/invoices?status=pending&merchantAddress=GMERCHANT123&limit=10&offset=5') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: { + status: 'PENDING', + merchant: { address: 'GMERCHANT123' }, + }, + take: 10, + skip: 5, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + expect(response.body.pagination).toEqual({ limit: 10, offset: 5, total: 1 }); + }); + + test('returns 400 when validation fails for query params', async () => { + const response = await request(app) + .get('/api/v1/admin/invoices?status=INVALID&limit=-1&offset=-5') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Validation failed'); + expect(response.body.errors.status).toBeDefined(); + expect(response.body.errors.limit).toBeDefined(); + expect(response.body.errors.offset).toBeDefined(); + expect(prismaMock.invoice.findMany).not.toHaveBeenCalled(); + }); + + test('handles database errors with 500 status', async () => { + prismaMock.invoice.findMany.mockRejectedValue(new Error('DB failure')); + + const response = await request(app) + .get('/api/v1/admin/invoices') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Internal Server Error'); + }); + }); + + describe('GET /api/v1/admin/invoices/:id', () => { + test('returns 401 when unauthenticated', async () => { + const response = await request(app).get('/api/v1/admin/invoices/invoice-uuid-1'); + + expect(response.status).toBe(401); + expect(prismaMock.invoice.findUnique).not.toHaveBeenCalled(); + }); + + test('returns full invoice detail regardless of merchant ownership', async () => { + prismaMock.invoice.findUnique.mockResolvedValue(baseInvoice); + + const response = await request(app) + .get('/api/v1/admin/invoices/invoice-uuid-1') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + id: baseInvoice.id, + paymentSlug: baseInvoice.paymentSlug, + description: baseInvoice.description, + amount: '50000000', + token: baseInvoice.token, + status: baseInvoice.status, + merchantId: baseInvoice.merchantId, + email: baseInvoice.email, + expiresAt: null, + datePaid: null, + createdAt: mockDate.toISOString(), + updatedAt: mockDate.toISOString(), + }); + expect(prismaMock.invoice.findUnique).toHaveBeenCalledWith({ + where: { id: 'invoice-uuid-1' }, + }); + }); + + test('returns 404 when invoice does not exist', async () => { + prismaMock.invoice.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/invoices/missing-id') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Invoice not found'); + }); + + test('handles database errors with 500 status', async () => { + prismaMock.invoice.findUnique.mockRejectedValue(new Error('DB connection failure')); + + const response = await request(app) + .get('/api/v1/admin/invoices/invoice-uuid-1') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Internal Server Error'); + }); + }); +}); diff --git a/tests/unit/admin-invoice.validation.test.ts b/tests/unit/admin-invoice.validation.test.ts new file mode 100644 index 0000000..86665b2 --- /dev/null +++ b/tests/unit/admin-invoice.validation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from '@jest/globals'; +import { parseAdminInvoiceListQuery } from '../../src/utils/invoice.validation.js'; + +describe('parseAdminInvoiceListQuery', () => { + test('returns default pagination and empty filters for empty query', () => { + const result = parseAdminInvoiceListQuery({}); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({}); + expect(result.pagination).toEqual({ limit: 20, offset: 0 }); + }); + + test('parses valid status and merchantAddress', () => { + const result = parseAdminInvoiceListQuery({ + status: 'paid', + merchantAddress: ' 0x123abc ', + limit: '50', + offset: '10', + }); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({ + status: 'PAID', + merchantAddress: '0x123abc', + }); + expect(result.pagination).toEqual({ limit: 50, offset: 10 }); + }); + + test('clamps limit to MAX_LIMIT (100)', () => { + const result = parseAdminInvoiceListQuery({ limit: '150' }); + + expect(result.errors).toEqual({}); + expect(result.pagination.limit).toBe(100); + }); + + test('returns error for invalid status', () => { + const result = parseAdminInvoiceListQuery({ status: 'UNKNOWN_STATUS' }); + + expect(result.errors.status).toContain('status must be one of'); + }); + + test('returns error for invalid limit', () => { + const result = parseAdminInvoiceListQuery({ limit: '0' }); + + expect(result.errors.limit).toBe('limit must be a positive number'); + }); + + test('returns error for negative offset', () => { + const result = parseAdminInvoiceListQuery({ offset: '-1' }); + + expect(result.errors.offset).toBe('offset must be a non-negative number'); + }); +}); diff --git a/tests/unit/invoice.services.test.ts b/tests/unit/invoice.services.test.ts index be4fe83..b526743 100644 --- a/tests/unit/invoice.services.test.ts +++ b/tests/unit/invoice.services.test.ts @@ -2,9 +2,16 @@ 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, applyInvoicePayment } = await import( - '../../src/services/invoice.services.js' -); +const { + createInvoice, + listInvoices, + listAdminInvoices, + getInvoice, + getAdminInvoice, + voidInvoice, + amendInvoice, + applyInvoicePayment, +} = await import('../../src/services/invoice.services.js'); const MERCHANT_ID = 'merchant-1'; @@ -124,6 +131,53 @@ describe('invoice services', () => { }); }); + describe('listAdminInvoices', () => { + test('lists invoices across all merchants with status and merchantAddress filters', async () => { + prismaMock.invoice.findMany.mockResolvedValue([baseInvoice] as any); + prismaMock.invoice.count.mockResolvedValue(1 as any); + + const result = await listAdminInvoices( + { status: 'PENDING' as any, merchantAddress: '0x123' }, + { limit: 10, offset: 5 }, + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].amount).toBe('5000'); + expect(result.pagination).toEqual({ limit: 10, offset: 5, total: 1 }); + + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: { + status: 'PENDING', + merchant: { address: '0x123' }, + }, + take: 10, + skip: 5, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + }); + + describe('getAdminInvoice', () => { + test('returns invoice regardless of merchantId', async () => { + prismaMock.invoice.findUnique.mockResolvedValue(baseInvoice as any); + + const result = await getAdminInvoice('invoice-1'); + + expect(result.id).toBe('invoice-1'); + expect(prismaMock.invoice.findUnique).toHaveBeenCalledWith({ + where: { id: 'invoice-1' }, + }); + }); + + test('throws 404 when invoice does not exist', async () => { + prismaMock.invoice.findUnique.mockResolvedValue(null); + + await expect(getAdminInvoice('missing')).rejects.toMatchObject({ + statusCode: 404, + }); + }); + }); + describe('amendInvoice', () => { test('updates only the provided fields on a PENDING invoice', async () => { prismaMock.invoice.findFirst.mockResolvedValue(baseInvoice as any);