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
44 changes: 44 additions & 0 deletions src/controllers/admin-invoice.controllers.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
const { filters, pagination, errors } = parseAdminInvoiceListQuery(
req.query as Record<string, unknown>,
);
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<void> => {
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' });
};
5 changes: 2 additions & 3 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
16 changes: 16 additions & 0 deletions src/routes/admin/invoice.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
47 changes: 47 additions & 0 deletions src/services/invoice.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/utils/invoice.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,60 @@ export const parseInvoiceListQuery = (query: Record<string, unknown>): 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<string, unknown>,
): 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 };
};
Loading
Loading