Background
Read and moderation endpoints over merchants for the admin dashboard. No new schema is required — every field needed already exists on Merchant, Invoice, and MerchantAnalytics/Subscription. Blocking a merchant follows the same off-chain-first, on-chain-reconciliation-deferred pattern already established in (invoice amendment) — the contract's own set_merchant_status(admin, merchant_id, status) requires the on-chain admin's signature, which this backend cannot produce; reconciling that is separate future work, not silently skipped but explicitly deferred.
Proposed Steps
GET /admin/merchants — paginated (reuse the limit/offset/DEFAULT_LIMIT/MAX_LIMIT pattern from invoice.validation.ts), filterable by active (bool), verified (bool), category (exact match), search (case-insensitive contains against businessName, email, and address), sortable by sortBy in [createdAt, merchantId, businessName] / sortDir in [asc, desc], default createdAt desc.
GET /admin/merchants/:id — full merchant detail. Admins see the whole row (no allow-list trimming needed beyond what sanitizeMerchant already does, Merchant no longer stores the OTP fields, so there's nothing secret left to withhold from an admin).
GET /admin/merchants/:id/invoices — delegates directly to the existing listInvoices(merchantId, filters, pagination) from invoice.services.ts, reusing its exact filter shape (status, token, startDate, endDate) instead of building a parallel one.
GET /admin/merchants/:id/analytics — new getMerchantAdminAnalytics(merchantId):
- Per-token volume/fees/transaction count from
MerchantAnalytics
- Invoice counts grouped by
status (prisma.invoice.groupBy)
- Subscription counts grouped by
status (Subscription.merchantId is a direct field — no join needed)
POST /admin/merchants/:id/block (requireSuperAdmin) — sets Merchant.active = false, records an merchant.blocked audit log entry with metadata: { reason? }. Off-chain only; leave a code comment noting set_merchant_status on-chain reconciliation is deferred.
- Unblocking is explicitly not built here — flagging it rather than silently including it, since only "block" was requested. It's an obvious near-term follow-up.
Acceptance Criteria
Background
Read and moderation endpoints over merchants for the admin dashboard. No new schema is required — every field needed already exists on
Merchant,Invoice, andMerchantAnalytics/Subscription. Blocking a merchant follows the same off-chain-first, on-chain-reconciliation-deferred pattern already established in (invoice amendment) — the contract's ownset_merchant_status(admin, merchant_id, status)requires the on-chain admin's signature, which this backend cannot produce; reconciling that is separate future work, not silently skipped but explicitly deferred.Proposed Steps
GET /admin/merchants— paginated (reuse thelimit/offset/DEFAULT_LIMIT/MAX_LIMITpattern frominvoice.validation.ts), filterable byactive(bool),verified(bool),category(exact match),search(case-insensitivecontainsagainstbusinessName,email, andaddress), sortable bysortByin[createdAt, merchantId, businessName]/sortDirin[asc, desc], defaultcreatedAt desc.GET /admin/merchants/:id— full merchant detail. Admins see the whole row (no allow-list trimming needed beyond whatsanitizeMerchantalready does,Merchantno longer stores the OTP fields, so there's nothing secret left to withhold from an admin).GET /admin/merchants/:id/invoices— delegates directly to the existinglistInvoices(merchantId, filters, pagination)frominvoice.services.ts, reusing its exact filter shape (status,token,startDate,endDate) instead of building a parallel one.GET /admin/merchants/:id/analytics— newgetMerchantAdminAnalytics(merchantId):MerchantAnalyticsstatus(prisma.invoice.groupBy)status(Subscription.merchantIdis a direct field — no join needed)POST /admin/merchants/:id/block(requireSuperAdmin) — setsMerchant.active = false, records anmerchant.blockedaudit log entry withmetadata: { reason? }. Off-chain only; leave a code comment notingset_merchant_statuson-chain reconciliation is deferred.Acceptance Criteria
GET /admin/merchantssupports all listed filters, sort options, and paginationGET /admin/merchants/:idreturns404for an unknown idGET /admin/merchants/:id/invoicesreturns the same shape as the merchant-facinglistInvoicesresponse, just admin-scoped instead of self-scopedGET /admin/merchants/:id/analyticsreturns per-token totals and status-grouped invoice/subscription countsPOST /admin/merchants/:id/blockrequiresrequireSuperAdmin— a non-superadmin admin gets403active: falseand produces exactly oneAdminLogentry