From e8be5364c08f5dd7867066420e74bb69ed4ba67a Mon Sep 17 00:00:00 2001 From: Damola09 Date: Fri, 28 Aug 2026 22:38:08 +0100 Subject: [PATCH] feat(admin): add read and moderation endpoints over merchants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the admin dashboard's merchant surface. No schema change is required — every field served here already exists on Merchant, Invoice, MerchantAnalytics and Subscription. GET /admin/merchants lists merchants with limit/offset pagination reusing the DEFAULT_LIMIT/MAX_LIMIT convention from invoice.validation.ts, filters on active, verified, category and a case-insensitive search across businessName, email and address, and sorts by createdAt, merchantId or businessName in either direction, defaulting to createdAt desc. Booleans are parsed strictly: a query string carries no real boolean, so only the literals "true" and "false" are accepted rather than coercing anything truthy and silently filtering on the wrong value. GET /admin/merchants/:id serves the merchant detail through sanitizeMerchant, which already withholds the OTP columns an admin has no reason to see. GET /admin/merchants/:id/invoices delegates to the existing listInvoices(merchantId, filters, pagination) and parses its query with parseInvoiceListQuery, so the admin-scoped response shape and accepted filters cannot drift from the merchant-facing route. The merchant is resolved first so an unknown id is a 404 rather than an empty page. GET /admin/merchants/:id/analytics adds getMerchantAdminAnalytics: per-token volume, fees and transaction counts from MerchantAnalytics, plus live status-grouped invoice and subscription counts. Subscription.merchantId is a direct scalar, so the subscription grouping needs no join through SubscriptionPlan. BigInt counters are serialized as strings, matching how analytics.services.ts already reports them. POST /admin/merchants/:id/block replaces the previous PATCH route and is now gated by requireSuperAdmin, so a non-superadmin admin gets a 403. It sets Merchant.active = false and records exactly one merchant.blocked AdminLog entry, carrying an optional { reason } in the metadata. This is off-chain only: the contract's set_merchant_status(admin, merchant_id, status) requires the on-chain admin's signature, which this backend cannot produce, so reconciling the on-chain status is deferred to separate work rather than silently skipped — the same off-chain-first pattern used for invoice amendment. Unblocking is deliberately not implemented. Only blocking was in scope; a test asserts no unblock route answers, so its absence is explicit rather than an oversight. --- src/controllers/admin-merchant.controllers.ts | 113 +++++- src/routes/admin/merchant.routes.ts | 20 +- src/services/merchant.services.ts | 156 ++++++++ src/utils/merchant.validation.ts | 150 ++++++++ .../integration/admin.merchant.routes.test.ts | 346 +++++++++++++++++- tests/unit/merchant.validation.test.ts | 100 +++++ 6 files changed, 863 insertions(+), 22 deletions(-) create mode 100644 src/utils/merchant.validation.ts create mode 100644 tests/unit/merchant.validation.test.ts diff --git a/src/controllers/admin-merchant.controllers.ts b/src/controllers/admin-merchant.controllers.ts index 58caf4d..430b658 100644 --- a/src/controllers/admin-merchant.controllers.ts +++ b/src/controllers/admin-merchant.controllers.ts @@ -1,8 +1,106 @@ import { Request, Response } from 'express'; -import { blockMerchant } from '../services/merchant.services.js'; +import { + blockMerchant, + getMerchantAdminAnalytics, + getMerchantForAdmin, + listMerchantsForAdmin, +} from '../services/merchant.services.js'; +import { listInvoices } from '../services/invoice.services.js'; import { recordAuditLog, ActorType } from '../services/audit-log.services.js'; +import { + parseAdminMerchantListQuery, + validateBlockMerchant, +} from '../utils/merchant.validation.js'; +import { parseInvoiceListQuery } from '../utils/invoice.validation.js'; import { AppError } from '../utils/errors.js'; +const handleError = (error: unknown, req: Request, res: Response, action: string): void => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error(`Failed to ${action}`, { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Internal Server Error' }); +}; + +export const listMerchantsController = async (req: Request, res: Response): Promise => { + const { filters, pagination, sortBy, sortDir, errors } = parseAdminMerchantListQuery( + req.query as Record, + ); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const result = await listMerchantsForAdmin(filters, pagination, sortBy, sortDir); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'list merchants'); + } +}; + +export const getMerchantController = async (req: Request, res: Response): Promise => { + try { + const merchant = await getMerchantForAdmin(req.params.id as string); + res.status(200).json(merchant); + } catch (error) { + handleError(error, req, res, 'load the merchant'); + } +}; + +/** + * Admin-scoped view of one merchant's invoices. Delegates to the same + * listInvoices the merchant-facing route uses, so the response shape and the + * accepted filters cannot drift between the two. + */ +export const listMerchantInvoicesController = async ( + req: Request, + res: Response, +): Promise => { + const { filters, pagination, errors } = parseInvoiceListQuery( + req.query as Record, + ); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + // 404s an unknown merchant rather than returning an empty page for an id + // that never existed. + await getMerchantForAdmin(req.params.id as string); + const result = await listInvoices(req.params.id as string, filters, pagination); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'list the merchant invoices'); + } +}; + +export const getMerchantAnalyticsController = async ( + req: Request, + res: Response, +): Promise => { + try { + const result = await getMerchantAdminAnalytics(req.params.id as string); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'load the merchant analytics'); + } +}; + +/** + * Blocks a merchant off-chain. The on-chain `set_merchant_status` call is + * deliberately not made here — it requires the on-chain admin's signature, + * which this backend does not hold; that reconciliation is deferred. + * + * Unblocking is out of scope for this endpoint and is not implemented. + */ export const blockMerchantController = async (req: Request, res: Response): Promise => { const admin = req.admin; if (!admin) { @@ -10,6 +108,12 @@ export const blockMerchantController = async (req: Request, res: Response): Prom return; } + const { input, errors } = validateBlockMerchant(req.body); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + try { const merchant = await blockMerchant(req.params.id as string); await recordAuditLog({ @@ -19,13 +123,10 @@ export const blockMerchantController = async (req: Request, res: Response): Prom actorLabel: admin.address, targetType: 'Merchant', targetId: merchant.id, + ...(input.reason !== undefined ? { metadata: { reason: input.reason } } : {}), }); res.status(200).json(merchant); } catch (error) { - if (error instanceof AppError) { - res.status(error.statusCode).json({ error: error.message }); - return; - } - res.status(500).json({ error: 'Internal Server Error' }); + handleError(error, req, res, 'block the merchant'); } }; diff --git a/src/routes/admin/merchant.routes.ts b/src/routes/admin/merchant.routes.ts index 7466868..3cff0f8 100644 --- a/src/routes/admin/merchant.routes.ts +++ b/src/routes/admin/merchant.routes.ts @@ -1,8 +1,24 @@ import { Router } from 'express'; -import { blockMerchantController } from '../../controllers/admin-merchant.controllers.js'; +import { + blockMerchantController, + getMerchantAnalyticsController, + getMerchantController, + listMerchantInvoicesController, + listMerchantsController, +} from '../../controllers/admin-merchant.controllers.js'; +import { requireSuperAdmin } from '../../middlewares/admin.middleware.js'; const router = Router(); -router.patch('/:id/block', blockMerchantController); +// Read-only dashboard data: any authenticated admin, no superadmin requirement. +// authenticateAdmin is applied where this router is mounted (admin/index.ts). +router.get('/', listMerchantsController); +router.get('/:id', getMerchantController); +router.get('/:id/invoices', listMerchantInvoicesController); +router.get('/:id/analytics', getMerchantAnalyticsController); + +// Moderation: superadmin only. Unblocking is deliberately not exposed here — +// only blocking was in scope; see blockMerchant in merchant.services.ts. +router.post('/:id/block', requireSuperAdmin, blockMerchantController); export default router; diff --git a/src/services/merchant.services.ts b/src/services/merchant.services.ts index 042a098..40b5752 100644 --- a/src/services/merchant.services.ts +++ b/src/services/merchant.services.ts @@ -2,6 +2,12 @@ import { Merchant, Prisma } from '@prisma/client'; import prisma from '../config/prisma.js'; import { AppError } from '../utils/errors.js'; import { RegisterMerchantInput, UpdateMerchantInput } from '../utils/validation.js'; +import type { + AdminMerchantListFilters, + AdminMerchantListPagination, + MerchantListSortBy, + MerchantListSortDir, +} from '../utils/merchant.validation.js'; import { generateOtp, hashOtp } from './otp.services.js'; import { sendOtp } from './email.service.js'; import { Keypair } from '@stellar/stellar-sdk'; @@ -187,6 +193,14 @@ export const generateMerchantSigningKey = async (id: string) => { /** * Deactivates a merchant (admin action). Sets Merchant.active = false only; * this does not currently gate login, invoice creation, or any other flow. + * + * Off-chain only. The contract's own `set_merchant_status(admin, merchant_id, + * status)` requires the on-chain admin's signature, which this backend cannot + * produce, so the on-chain merchant status is deliberately left untouched here. + * Reconciling the two is deferred to separate future work, following the same + * off-chain-first pattern already established for invoice amendment. + * + * Unblocking is intentionally not implemented — only blocking was in scope. */ export const blockMerchant = async (id: string) => { const merchant = await prisma.merchant.findUnique({ where: { id } }); @@ -203,6 +217,148 @@ export const blockMerchant = async (id: string) => { return sanitizeMerchant(updated); }; +// ── Admin read side ───────────────────────────────────────────────────────── + +/** + * Paginated merchant list for the admin dashboard. + * + * `search` is a case-insensitive contains across businessName, email and + * address; the boolean and category filters are exact. Rows go through + * sanitizeMerchant like every other merchant response, which already withholds + * the OTP columns an admin has no reason to see. + */ +export const listMerchantsForAdmin = async ( + filters: AdminMerchantListFilters, + pagination: AdminMerchantListPagination, + sortBy: MerchantListSortBy, + sortDir: MerchantListSortDir, +) => { + const where: Prisma.MerchantWhereInput = {}; + + if (filters.active !== undefined) { + where.active = filters.active; + } + + if (filters.verified !== undefined) { + where.verified = filters.verified; + } + + if (filters.category) { + where.category = filters.category; + } + + if (filters.search) { + where.OR = [ + { businessName: { contains: filters.search, mode: 'insensitive' } }, + { email: { contains: filters.search, mode: 'insensitive' } }, + { address: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + + const [merchants, total] = await Promise.all([ + prisma.merchant.findMany({ + where, + take: pagination.limit, + skip: pagination.offset, + orderBy: { [sortBy]: sortDir }, + }), + prisma.merchant.count({ where }), + ]); + + return { + data: merchants.map(sanitizeMerchant), + pagination: { + limit: pagination.limit, + offset: pagination.offset, + total, + }, + }; +}; + +/** + * Full merchant detail for an admin, keyed by Merchant.id (uuid). + */ +export const getMerchantForAdmin = async (id: string) => { + const merchant = await prisma.merchant.findUnique({ where: { id } }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + return sanitizeMerchant(merchant); +}; + +/** + * Per-merchant analytics for the admin dashboard: the merchant's own per-token + * counters plus live status-grouped invoice and subscription counts. + * + * BigInt counters are serialized as strings, matching how analytics.services.ts + * already reports them, since JSON has no BigInt. + */ +export const getMerchantAdminAnalytics = async (id: string) => { + const merchant = await prisma.merchant.findUnique({ where: { id } }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + const [tokenRows, invoicesByStatus, subscriptionsByStatus] = await Promise.all([ + prisma.merchantAnalytics.findMany({ + where: { merchantId: id }, + orderBy: { totalVolume: 'desc' }, + }), + prisma.invoice.groupBy({ + by: ['status'], + where: { merchantId: id }, + _count: { _all: true }, + }), + // Subscription.merchantId is a direct scalar (see the composite FK comment + // on the model), so this needs no join through SubscriptionPlan. + prisma.subscription.groupBy({ + by: ['status'], + where: { merchantId: id }, + _count: { _all: true }, + }), + ]); + + const countByStatus = (groups: { status: string; _count: { _all: number } }[]) => { + const counts: Record = {}; + for (const group of groups) { + counts[group.status] = group._count._all; + } + return counts; + }; + + const invoiceCounts = countByStatus( + invoicesByStatus as { status: string; _count: { _all: number } }[], + ); + const subscriptionCounts = countByStatus( + subscriptionsByStatus as { status: string; _count: { _all: number } }[], + ); + + const sumCounts = (counts: Record) => + Object.values(counts).reduce((total, count) => total + count, 0); + + return { + merchantId: merchant.id, + tokens: tokenRows.map(row => ({ + token: row.token, + totalVolume: row.totalVolume.toString(), + totalFees: row.totalFees.toString(), + transactionCount: row.transactionCount.toString(), + lastUpdated: row.lastUpdated.toISOString(), + })), + invoices: { + total: sumCounts(invoiceCounts), + byStatus: invoiceCounts, + }, + subscriptions: { + total: sumCounts(subscriptionCounts), + byStatus: subscriptionCounts, + }, + }; +}; + /** * Partially updates the authenticated merchant's editable profile fields. * diff --git a/src/utils/merchant.validation.ts b/src/utils/merchant.validation.ts new file mode 100644 index 0000000..1fc0841 --- /dev/null +++ b/src/utils/merchant.validation.ts @@ -0,0 +1,150 @@ +export const DEFAULT_LIMIT = 20; +export const MAX_LIMIT = 100; + +const MERCHANT_SORT_FIELDS = ['createdAt', 'merchantId', 'businessName'] as const; +const SORT_DIRECTIONS = ['asc', 'desc'] as const; + +export interface AdminMerchantListFilters { + active?: boolean; + verified?: boolean; + category?: string; + search?: string; +} + +export interface AdminMerchantListPagination { + limit: number; + offset: number; +} + +export type MerchantListSortBy = (typeof MERCHANT_SORT_FIELDS)[number]; +export type MerchantListSortDir = (typeof SORT_DIRECTIONS)[number]; + +export type ValidationErrors = Record; + +export interface ParsedAdminMerchantListQuery { + filters: AdminMerchantListFilters; + pagination: AdminMerchantListPagination; + sortBy: MerchantListSortBy; + sortDir: MerchantListSortDir; + errors: ValidationErrors; +} + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +/** + * Parses a boolean query parameter. Query strings never carry a real boolean, + * so only the literals "true"/"false" are accepted; anything else is a 400 + * rather than a silent coercion that would filter on the wrong value. + */ +const parseBoolean = ( + value: unknown, + field: string, + errors: ValidationErrors, +): boolean | undefined => { + const raw = String(value).toLowerCase(); + if (raw === 'true') return true; + if (raw === 'false') return false; + + errors[field] = `${field} must be either true or false`; + return undefined; +}; + +/** + * Parses admin merchant list query parameters into typed filters, sort and + * pagination, clamping the page size to [1, MAX_LIMIT] and defaulting to + * DEFAULT_LIMIT. Mirrors parseAdminSubscriptionListQuery in + * subscription.validation.ts. + */ +export const parseAdminMerchantListQuery = ( + query: Record, +): ParsedAdminMerchantListQuery => { + const errors: ValidationErrors = {}; + const filters: AdminMerchantListFilters = {}; + + if (query.active !== undefined) { + const active = parseBoolean(query.active, 'active', errors); + if (active !== undefined) filters.active = active; + } + + if (query.verified !== undefined) { + const verified = parseBoolean(query.verified, 'verified', errors); + if (verified !== undefined) filters.verified = verified; + } + + if (isNonEmptyString(query.category)) { + filters.category = query.category.trim(); + } + + if (isNonEmptyString(query.search)) { + filters.search = query.search.trim(); + } + + let sortBy: MerchantListSortBy = 'createdAt'; + if (query.sortBy !== undefined) { + const value = String(query.sortBy); + if ((MERCHANT_SORT_FIELDS as readonly string[]).includes(value)) { + sortBy = value as MerchantListSortBy; + } else { + errors.sortBy = `sortBy must be one of ${MERCHANT_SORT_FIELDS.join(', ')}`; + } + } + + let sortDir: MerchantListSortDir = 'desc'; + if (query.sortDir !== undefined) { + const value = String(query.sortDir).toLowerCase(); + if ((SORT_DIRECTIONS as readonly string[]).includes(value)) { + sortDir = value as MerchantListSortDir; + } else { + errors.sortDir = `sortDir must be one of ${SORT_DIRECTIONS.join(', ')}`; + } + } + + 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 }, sortBy, sortDir, errors }; +}; + +export interface BlockMerchantInput { + reason?: string; +} + +/** + * Validates the optional `reason` carried on a block request. The reason is + * recorded in the audit log's metadata; it is never persisted on Merchant. + */ +export const validateBlockMerchant = ( + body: unknown, +): { input: BlockMerchantInput; errors: ValidationErrors } => { + const errors: ValidationErrors = {}; + const payload = (body ?? {}) as Record; + const input: BlockMerchantInput = {}; + + if (payload.reason !== undefined && payload.reason !== null) { + if (typeof payload.reason !== 'string' || payload.reason.trim().length === 0) { + errors.reason = 'reason must be a non-empty string'; + } else { + input.reason = payload.reason.trim(); + } + } + + return { input, errors }; +}; diff --git a/tests/integration/admin.merchant.routes.test.ts b/tests/integration/admin.merchant.routes.test.ts index 78d7a55..bb0f591 100644 --- a/tests/integration/admin.merchant.routes.test.ts +++ b/tests/integration/admin.merchant.routes.test.ts @@ -16,6 +16,8 @@ const admin = { updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; +const superAdmin = { ...admin, id: 'superadmin-uuid', isSuperAdmin: true }; + const merchant = { id: 'merchant-1', merchantId: 1, @@ -40,32 +42,310 @@ const merchant = { 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 signToken = (subject: string) => + jwt.sign({ sub: subject, address: admin.address, type: 'admin' }, environment.jwtSecret, { + expiresIn: '15m', + }); + +const adminToken = signToken(admin.id); +const superAdminToken = signToken(superAdmin.id); -describe('PATCH /api/v1/admin/merchants/:id/block', () => { +describe('GET /api/v1/admin/merchants', () => { beforeEach(() => { mockReset(prismaMock); prismaMock.admin.findUnique.mockResolvedValue(admin); }); test('returns 401 when unauthenticated', async () => { - const response = await request(app).patch('/api/v1/admin/merchants/merchant-1/block'); + const response = await request(app).get('/api/v1/admin/merchants'); + + expect(response.status).toBe(401); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); + + test('defaults to createdAt desc with the default page size', async () => { + prismaMock.merchant.findMany.mockResolvedValue([merchant]); + prismaMock.merchant.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].id).toBe('merchant-1'); + // sanitizeMerchant keeps the OTP columns out of an admin response too. + expect(response.body.data[0]).not.toHaveProperty('emailOtp'); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('applies the active, verified, category and search filters', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ active: 'true', verified: 'false', category: 'software', search: 'eng' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ + where: { + active: true, + verified: false, + category: 'software', + OR: [ + { businessName: { contains: 'eng', mode: 'insensitive' } }, + { email: { contains: 'eng', mode: 'insensitive' } }, + { address: { contains: 'eng', mode: 'insensitive' } }, + ], + }, + take: 20, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('honours sortBy, sortDir and pagination, clamping limit to MAX_LIMIT', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'businessName', sortDir: 'asc', limit: '500', offset: '40' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ + where: {}, + take: 100, + skip: 40, + orderBy: { businessName: 'asc' }, + }); + }); + + test('sorts by merchantId when asked', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'merchantId', sortDir: 'asc' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { merchantId: 'asc' } }), + ); + }); + + test('returns 400 for an unsupported sortBy', async () => { + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'email' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('sortBy'); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); + + test('returns 400 for a non-boolean active filter', async () => { + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ active: 'yes' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('active'); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('GET /api/v1/admin/merchants/:id', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns the merchant detail', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.id).toBe('merchant-1'); + expect(response.body.businessName).toBe('Engines'); + expect(response.body).not.toHaveProperty('emailOtp'); + }); + + test('returns 404 for an unknown id', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/merchants/missing') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + }); +}); + +describe('GET /api/v1/admin/merchants/:id/invoices', () => { + const invoice = { + id: 'invoice-1', + invoiceId: 1, + merchantId: 'merchant-1', + description: 'work', + amount: 1000n, + amountPaid: 0n, + amountRefunded: 0n, + token: 'USDC', + status: 'PENDING', + payerEmail: null, + expiresAt: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), + }; + + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('scopes listInvoices to the merchant and passes its filters through', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.invoice.findMany.mockResolvedValue([invoice]); + prismaMock.invoice.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1/invoices') + .query({ status: 'pending', token: 'USDC' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(response.body.data).toHaveLength(1); + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: { merchantId: 'merchant-1', status: 'PENDING', token: 'USDC' }, + take: 20, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('returns 404 for an unknown merchant', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/merchants/missing/invoices') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + expect(prismaMock.invoice.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('GET /api/v1/admin/merchants/:id/analytics', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns per-token totals and status-grouped invoice/subscription counts', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.merchantAnalytics.findMany.mockResolvedValue([ + { + id: 'analytics-1', + merchantId: 'merchant-1', + token: 'USDC', + totalVolume: 5000n, + totalFees: 50n, + transactionCount: 3n, + lastUpdated: new Date('2026-06-27T12:00:00.000Z'), + }, + ]); + prismaMock.invoice.groupBy.mockResolvedValue([ + { status: 'PAID', _count: { _all: 2 } }, + { status: 'PENDING', _count: { _all: 1 } }, + ]); + prismaMock.subscription.groupBy.mockResolvedValue([{ status: 'ACTIVE', _count: { _all: 4 } }]); + + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1/analytics') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.tokens).toEqual([ + { + token: 'USDC', + totalVolume: '5000', + totalFees: '50', + transactionCount: '3', + lastUpdated: '2026-06-27T12:00:00.000Z', + }, + ]); + expect(response.body.invoices).toEqual({ total: 3, byStatus: { PAID: 2, PENDING: 1 } }); + expect(response.body.subscriptions).toEqual({ total: 4, byStatus: { ACTIVE: 4 } }); + // Subscription.merchantId is a direct scalar, so no join through the plan. + expect(prismaMock.subscription.groupBy).toHaveBeenCalledWith({ + by: ['status'], + where: { merchantId: 'merchant-1' }, + _count: { _all: true }, + }); + }); + + test('returns 404 for an unknown merchant', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/merchants/missing/analytics') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + expect(prismaMock.merchantAnalytics.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/v1/admin/merchants/:id/block', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(superAdmin); + }); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app).post('/api/v1/admin/merchants/merchant-1/block'); expect(response.status).toBe(401); expect(prismaMock.merchant.update).not.toHaveBeenCalled(); }); - test('sets active to false, returns the merchant, and logs the action', async () => { + test('returns 403 for an authenticated admin that is not a superadmin', async () => { + prismaMock.admin.findUnique.mockResolvedValue(admin); + + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(403); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); + }); + + test('sets active to false, returns the merchant, and logs the action once', async () => { prismaMock.merchant.findUnique.mockResolvedValue(merchant); prismaMock.merchant.update.mockResolvedValue({ ...merchant, active: false }); const response = await request(app) - .patch('/api/v1/admin/merchants/merchant-1/block') - .set('Authorization', `Bearer ${adminToken}`); + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`); expect(response.status).toBe(200); expect(response.body.active).toBe(false); @@ -73,27 +353,65 @@ describe('PATCH /api/v1/admin/merchants/:id/block', () => { where: { id: 'merchant-1' }, data: { active: false }, }); + expect(prismaMock.adminLog.create).toHaveBeenCalledTimes(1); expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'merchant.blocked', actorType: 'ADMIN', - actorId: admin.id, - actorLabel: admin.address, + actorId: superAdmin.id, + actorLabel: superAdmin.address, targetType: 'Merchant', targetId: 'merchant-1', }), }); }); + test('records an optional reason in the audit log metadata', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.merchant.update.mockResolvedValue({ ...merchant, active: false }); + + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ reason: 'chargeback fraud' }); + + expect(response.status).toBe(200); + expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ metadata: { reason: 'chargeback fraud' } }), + }); + }); + + test('returns 400 for a non-string reason', async () => { + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ reason: 42 }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('reason'); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); + test('returns 404 when the merchant does not exist', async () => { prismaMock.merchant.findUnique.mockResolvedValue(null); const response = await request(app) - .patch('/api/v1/admin/merchants/missing/block') - .set('Authorization', `Bearer ${adminToken}`); + .post('/api/v1/admin/merchants/missing/block') + .set('Authorization', `Bearer ${superAdminToken}`); expect(response.status).toBe(404); expect(prismaMock.merchant.update).not.toHaveBeenCalled(); expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); }); + + // Unblocking is deliberately out of scope for this issue; no unblock route + // exists, so the router must not answer one. + test('exposes no unblock route', async () => { + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/unblock') + .set('Authorization', `Bearer ${superAdminToken}`); + + expect(response.status).toBe(404); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/merchant.validation.test.ts b/tests/unit/merchant.validation.test.ts new file mode 100644 index 0000000..af2774d --- /dev/null +++ b/tests/unit/merchant.validation.test.ts @@ -0,0 +1,100 @@ +const { parseAdminMerchantListQuery, validateBlockMerchant, DEFAULT_LIMIT, MAX_LIMIT } = + await import('../../src/utils/merchant.validation.js'); + +describe('parseAdminMerchantListQuery', () => { + test('defaults to createdAt desc with DEFAULT_LIMIT and no filters', () => { + const result = parseAdminMerchantListQuery({}); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({}); + expect(result.pagination).toEqual({ limit: DEFAULT_LIMIT, offset: 0 }); + expect(result.sortBy).toBe('createdAt'); + expect(result.sortDir).toBe('desc'); + }); + + test('parses the boolean, category and search filters', () => { + const result = parseAdminMerchantListQuery({ + active: 'false', + verified: 'true', + category: ' software ', + search: ' eng ', + }); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({ + active: false, + verified: true, + category: 'software', + search: 'eng', + }); + }); + + test('rejects a boolean filter that is not true or false', () => { + const result = parseAdminMerchantListQuery({ active: '1', verified: 'nope' }); + + expect(result.errors.active).toBeDefined(); + expect(result.errors.verified).toBeDefined(); + expect(result.filters.active).toBeUndefined(); + expect(result.filters.verified).toBeUndefined(); + }); + + test('ignores a blank category or search rather than filtering on an empty string', () => { + const result = parseAdminMerchantListQuery({ category: ' ', search: '' }); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({}); + }); + + test.each(['createdAt', 'merchantId', 'businessName'])('accepts sortBy=%s', field => { + const result = parseAdminMerchantListQuery({ sortBy: field }); + + expect(result.errors).toEqual({}); + expect(result.sortBy).toBe(field); + }); + + test('rejects an unsupported sortBy and sortDir', () => { + const result = parseAdminMerchantListQuery({ sortBy: 'email', sortDir: 'sideways' }); + + expect(result.errors.sortBy).toBeDefined(); + expect(result.errors.sortDir).toBeDefined(); + }); + + test('accepts sortDir case-insensitively', () => { + const result = parseAdminMerchantListQuery({ sortDir: 'ASC' }); + + expect(result.errors).toEqual({}); + expect(result.sortDir).toBe('asc'); + }); + + test('clamps limit to MAX_LIMIT and floors fractional pagination', () => { + const result = parseAdminMerchantListQuery({ limit: '1000', offset: '10.9' }); + + expect(result.errors).toEqual({}); + expect(result.pagination).toEqual({ limit: MAX_LIMIT, offset: 10 }); + }); + + test('rejects a non-positive limit and a negative offset', () => { + const result = parseAdminMerchantListQuery({ limit: '0', offset: '-1' }); + + expect(result.errors.limit).toBeDefined(); + expect(result.errors.offset).toBeDefined(); + }); +}); + +describe('validateBlockMerchant', () => { + test('accepts a missing body', () => { + expect(validateBlockMerchant(undefined)).toEqual({ input: {}, errors: {} }); + }); + + test('trims a supplied reason', () => { + const result = validateBlockMerchant({ reason: ' fraud ' }); + + expect(result.errors).toEqual({}); + expect(result.input.reason).toBe('fraud'); + }); + + test('rejects a non-string or blank reason', () => { + expect(validateBlockMerchant({ reason: 42 }).errors.reason).toBeDefined(); + expect(validateBlockMerchant({ reason: ' ' }).errors.reason).toBeDefined(); + }); +});