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
113 changes: 107 additions & 6 deletions src/controllers/admin-merchant.controllers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,119 @@
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<void> => {
const { filters, pagination, sortBy, sortDir, errors } = parseAdminMerchantListQuery(
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 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<void> => {
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<void> => {
const { filters, pagination, errors } = parseInvoiceListQuery(
req.query as Record<string, unknown>,
);
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<void> => {
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<void> => {
const admin = req.admin;
if (!admin) {
res.status(401).json({ error: 'Unauthorized' });
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({
Expand All @@ -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');
}
};
20 changes: 18 additions & 2 deletions src/routes/admin/merchant.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
156 changes: 156 additions & 0 deletions src/services/merchant.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 } });
Expand All @@ -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<string, number> = {};
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<string, number>) =>
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.
*
Expand Down
Loading
Loading