Skip to content
Closed
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
63 changes: 63 additions & 0 deletions src/controllers/admin-merchant.controllers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Request, Response } from 'express';
import {
blockMerchant,
getAdminMerchant,
listAdminMerchantInvoices,
listAdminMerchants,
} from '../services/merchant.services.js';
import { getMerchantAdminAnalytics } from '../services/analytics.services.js';
import { parseAdminMerchantListQuery } from '../utils/merchant.validation.js';
import { parseInvoiceListQuery } from '../utils/invoice.validation.js';
getMerchantAdminAnalytics,
getMerchantForAdmin,
listMerchantsForAdmin,
Expand All @@ -14,6 +21,7 @@ import {
import { parseInvoiceListQuery } from '../utils/invoice.validation.js';
import { AppError } from '../utils/errors.js';

export const listAdminMerchantsController = async (req: Request, res: Response): Promise<void> => {
const handleError = (error: unknown, req: Request, res: Response, action: string): void => {
if (error instanceof AppError) {
res.status(error.statusCode).json({ error: error.message });
Expand All @@ -38,6 +46,23 @@ export const listMerchantsController = async (req: Request, res: Response): Prom
}

try {
const result = await listAdminMerchants(filters, pagination, sortBy, sortDir);
res.status(200).json(result);
} catch (error) {
handleError(error, req, res);
}
};

export const getAdminMerchantController = async (req: Request, res: Response): Promise<void> => {
try {
const merchant = await getAdminMerchant(req.params.id as string);
res.status(200).json(merchant);
} catch (error) {
handleError(error, req, res);
}
};

export const getAdminMerchantInvoicesController = async (
const result = await listMerchantsForAdmin(filters, pagination, sortBy, sortDir);
res.status(200).json(result);
} catch (error) {
Expand Down Expand Up @@ -72,6 +97,14 @@ export const listMerchantInvoicesController = async (
}

try {
const result = await listAdminMerchantInvoices(req.params.id as string, filters, pagination);
res.status(200).json(result);
} catch (error) {
handleError(error, req, res);
}
};

export const getAdminMerchantAnalyticsController = async (
// 404s an unknown merchant rather than returning an empty page for an id
// that never existed.
await getMerchantForAdmin(req.params.id as string);
Expand All @@ -90,6 +123,10 @@ export const getMerchantAnalyticsController = async (
const result = await getMerchantAdminAnalytics(req.params.id as string);
res.status(200).json(result);
} catch (error) {
handleError(error, req, res);
}
};

handleError(error, req, res, 'load the merchant analytics');
}
};
Expand All @@ -108,6 +145,11 @@ export const blockMerchantController = async (req: Request, res: Response): Prom
return;
}

const body = (req.body ?? {}) as Record<string, unknown>;
const reason =
typeof body.reason === 'string' && body.reason.trim().length > 0
? body.reason.trim()
: undefined;
const { input, errors } = validateBlockMerchant(req.body);
if (Object.keys(errors).length > 0) {
res.status(400).json({ error: 'Validation failed', errors });
Expand All @@ -116,17 +158,38 @@ export const blockMerchantController = async (req: Request, res: Response): Prom

try {
const merchant = await blockMerchant(req.params.id as string);

// Off-chain only: the merchant is deactivated in this projection now. The
// contract's set_merchant_status requires the on-chain admin's signature,
// which this backend cannot produce, so on-chain reconciliation is deferred.
await recordAuditLog({
Comment thread
Tijesunimi004 marked this conversation as resolved.
action: 'merchant.blocked',
actorType: ActorType.ADMIN,
actorId: admin.id,
actorLabel: admin.address,
targetType: 'Merchant',
targetId: merchant.id,
metadata: reason ? { reason } : undefined,
...(input.reason !== undefined ? { metadata: { reason: input.reason } } : {}),
});

res.status(200).json(merchant);
} 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;
handleError(error, req, res, 'block the merchant');
}

console.error('Failed to handle admin merchant request', {
path: req.path,
method: req.method,
error: error instanceof Error ? error.message : 'Unknown error',
});
res.status(500).json({ error: 'Internal Server Error' });
};
20 changes: 20 additions & 0 deletions src/routes/admin/merchant.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import { Router } from 'express';
import {
blockMerchantController,
getAdminMerchantAnalyticsController,
getAdminMerchantController,
getAdminMerchantInvoicesController,
listAdminMerchantsController,
} from '../../controllers/admin-merchant.controllers.js';
import { authenticateAdmin, requireSuperAdmin } from '../../middlewares/admin.middleware.js';

const router = Router();

router.use(authenticateAdmin);

// Read-only dashboard data: any authenticated admin.
router.get('/', listAdminMerchantsController);
router.get('/:id', getAdminMerchantController);
router.get('/:id/invoices', getAdminMerchantInvoicesController);
router.get('/:id/analytics', getAdminMerchantAnalyticsController);

// Moderation: superadmin only. Off-chain block; on-chain set_merchant_status
// reconciliation is deferred (see blockMerchantController). Unblocking is
// intentionally not implemented here.
getMerchantAnalyticsController,
getMerchantController,
listMerchantInvoicesController,
Expand Down
57 changes: 57 additions & 0 deletions src/services/analytics.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,63 @@ export const getAnalyticsSummary = async () => {
};
};

/**
* Per-merchant analytics for the admin dashboard: current per-token totals from
* MerchantAnalytics, plus live status-grouped invoice and subscription counts.
* Mirrors the shape of getAnalyticsSummary, scoped to one merchant.
*
* `Subscription.merchantId` is a scalar column, so the subscription count is a
* direct `where` filter with no join.
*/
export const getMerchantAdminAnalytics = async (merchantId: string) => {
const merchant = await prisma.merchant.findUnique({
where: { id: merchantId },
select: { id: true },
});

if (!merchant) {
throw new AppError(404, 'Merchant not found');
}

const [tokenRows, invoicesByStatus, subscriptionsByStatus] = await Promise.all([
prisma.merchantAnalytics.findMany({
where: { merchantId },
orderBy: { totalVolume: 'desc' },
}),
prisma.invoice.groupBy({
by: ['status'],
where: { merchantId },
_count: { _all: true },
}),
prisma.subscription.groupBy({
by: ['status'],
where: { merchantId },
_count: { _all: true },
}),
]);

const invoiceCounts = countByKey(invoicesByStatus, group => group.status);
const subscriptionCounts = countByKey(subscriptionsByStatus, group => group.status);

return {
merchantId,
tokens: tokenRows.map(row => ({
token: row.token,
totalVolume: toStringAmount(row.totalVolume),
totalFees: toStringAmount(row.totalFees),
transactionCount: toStringAmount(row.transactionCount),
})),
invoices: {
total: sumCounts(invoiceCounts),
byStatus: invoiceCounts,
},
subscriptions: {
total: sumCounts(subscriptionCounts),
byStatus: subscriptionCounts,
},
};
};

const parseDateParam = (value: unknown, field: string): Date | null => {
if (value === undefined || value === null || value === '') return null;
if (typeof value !== 'string') {
Expand Down
100 changes: 100 additions & 0 deletions src/services/merchant.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ 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 {
import type {
AdminMerchantListFilters,
AdminMerchantListPagination,
MerchantListSortBy,
MerchantListSortDir,
} from '../utils/merchant.validation.js';
import { InvoiceListFilters, InvoicePagination } from '../utils/invoice.validation.js';
import { generateOtp, hashOtp } from './otp.services.js';
import { sendOtp } from './email.service.js';
import { listInvoices } from './invoice.services.js';
import { Keypair } from '@stellar/stellar-sdk';

const OTP_EXPIRY_MS = 10 * 60 * 1000;
Expand Down Expand Up @@ -191,6 +194,13 @@ export const generateMerchantSigningKey = async (id: string) => {
};

/**
* Deactivates a merchant (admin moderation action). Sets Merchant.active = false
* only; this does not currently gate login, invoice creation, or any other flow.
Comment thread
Tijesunimi004 marked this conversation as resolved.
*
* 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; reconciling the on-chain merchant status is deferred to separate work
* and is deliberately not attempted here.
* Deactivates a merchant (admin action). Sets Merchant.active = false only;
* this does not currently gate login, invoice creation, or any other flow.
*
Expand Down Expand Up @@ -391,3 +401,93 @@ export const updateMyProfile = async (id: string, data: UpdateMerchantInput) =>

return sanitizeMerchant(updated);
};

// ── Read side (admin dashboard) ───────────────────────────────────────────────

/**
* Lists merchants for the admin dashboard. `search` is a case-insensitive
* substring match against businessName, email and address; `active`, `verified`
* and `category` are exact matches. The literal `id` tiebreaker keeps ordering
* stable across pages, matching listSubscriptions and listAuditLogs.
*/
export const listAdminMerchants = 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 !== undefined) 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 orderBy: Prisma.MerchantOrderByWithRelationInput[] = [
{ [sortBy]: sortDir },
{ id: 'desc' },
];

const [merchants, total] = await Promise.all([
prisma.merchant.findMany({
where,
take: pagination.limit,
skip: pagination.offset,
orderBy,
}),
prisma.merchant.count({ where }),
]);

return {
data: merchants.map(sanitizeMerchant),
pagination: {
limit: pagination.limit,
offset: pagination.offset,
total,
},
};
};

/**
* Full merchant detail for an admin. `sanitizeMerchant` already drops the OTP
* columns; nothing else on the row is withheld from an admin.
*/
export const getAdminMerchant = async (id: string) => {
const merchant = await prisma.merchant.findUnique({ where: { id } });

if (!merchant) {
throw new AppError(404, 'Merchant not found');
}

return sanitizeMerchant(merchant);
};

/**
* Admin-scoped view of a single merchant's invoices. Delegates to the
* merchant-facing listInvoices with the same filter and pagination shape, after
* confirming the merchant exists so an unknown id is a 404 rather than an empty
* page.
*/
export const listAdminMerchantInvoices = async (
id: string,
filters: InvoiceListFilters,
pagination: InvoicePagination,
) => {
const merchant = await prisma.merchant.findUnique({
where: { id },
select: { id: true },
});

if (!merchant) {
throw new AppError(404, 'Merchant not found');
}

return listInvoices(id, filters, pagination);
};
7 changes: 7 additions & 0 deletions src/utils/merchant.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;

/**
* Parses a query parameter that is meant to be a boolean. Accepts only the
* strings "true" and "false" (case-insensitive); anything else records an error
* so a typo does not silently fall through to an unfiltered result.
* 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.
Expand All @@ -42,6 +45,9 @@ const parseBoolean = (
field: string,
errors: ValidationErrors,
): boolean | undefined => {
const normalized = String(value).trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
const raw = String(value).toLowerCase();
if (raw === 'true') return true;
if (raw === 'false') return false;
Expand All @@ -53,6 +59,7 @@ const parseBoolean = (
/**
* 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.
* DEFAULT_LIMIT. Mirrors parseAdminSubscriptionListQuery in
* subscription.validation.ts.
*/
Expand Down
Loading
Loading