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
71 changes: 71 additions & 0 deletions src/controllers/admin-subscription.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Request, Response } from 'express';
import {
getSubscription,
listSubscriptionPayments,
listSubscriptions,
} from '../services/subscription.services.js';
import {
parseAdminSubscriptionListQuery,
parseAdminSubscriptionPaymentsQuery,
} from '../utils/subscription.validation.js';
import { AppError } from '../utils/errors.js';

export const listSubscriptionsController = async (req: Request, res: Response): Promise<void> => {
const { filters, pagination, sortBy, sortDir, errors } = parseAdminSubscriptionListQuery(
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 listSubscriptions(filters, pagination, sortBy, sortDir);
res.status(200).json(result);
} catch (error) {
handleError(error, req, res);
}
};

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

export const listSubscriptionPaymentsController = async (
req: Request,
res: Response,
): Promise<void> => {
const { filters, pagination, errors } = parseAdminSubscriptionPaymentsQuery(
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 listSubscriptionPayments(filters, pagination);
res.status(200).json(result);
} 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 subscriptions', {
path: req.path,
method: req.method,
error: error instanceof Error ? error.message : 'Unknown error',
});
res.status(500).json({ error: 'Internal Server Error' });
};
2 changes: 2 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import authRoutes from './auth.routes.js';
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 { authenticateAdmin } from '../../middlewares/admin.middleware.js';

const router = Router();
Expand All @@ -17,6 +18,7 @@ router.use('/analytics', analyticsRoutes);
// are mounted here behind authenticateAdmin.
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.
Expand Down
19 changes: 19 additions & 0 deletions src/routes/admin/subscriptions.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Router } from 'express';
import {
getSubscriptionController,
listSubscriptionPaymentsController,
listSubscriptionsController,
} from '../../controllers/admin-subscription.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);

// Declared before `/:id` so Express does not capture "payments" as an id.
router.get('/payments', listSubscriptionPaymentsController);
router.get('/', listSubscriptionsController);
router.get('/:id', getSubscriptionController);

export default router;
204 changes: 204 additions & 0 deletions src/services/subscription.services.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import type {
Prisma,
SubscriptionPlan,
SubscriptionStatus as PrismaSubscriptionStatus,
Transaction,
} from '@prisma/client';
import prisma from '../config/prisma.js';
import type {
SubscribedEventData,
SubscriptionChargedEventData,
SubscriptionPlanCreatedEventData,
} from '../indexer/types.js';
import type {
AdminSubscriptionListFilters,
AdminSubscriptionListPagination,
AdminSubscriptionPaymentsFilters,
SubscriptionListSortBy,
SubscriptionListSortDir,
} from '../utils/subscription.validation.js';
import { AppError } from '../utils/errors.js';
import { recordDailyStats, recordVolumeEvent } from './analytics.services.js';
import { recordAuditLog, ActorType } from './audit-log.services.js';

Expand Down Expand Up @@ -258,3 +268,197 @@ export const applySubscriptionCharge = async (
return { subscription: updatedSubscription, transaction };
});
};

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

/**
* Public-facing view of a subscription. `plan.amount` is serialized to a string
* because `BigInt` is not JSON-serializable. `Subscription` deliberately has no
* Merchant relation (see the schema), so merchantAddress is resolved to a
* merchant id before filtering, never reached through the plan.
*/
export const sanitizeSubscription = (subscription: {
id: string;
subscriptionId: number;
planId: string;
merchantId: string;
customer: string;
status: PrismaSubscriptionStatus;
lastCharged: Date | null;
createdAt: Date;
updatedAt: Date;
plan?: {
planId: number;
description: string;
token: string;
amount: bigint;
interval: number;
active: boolean;
};
}) => ({
id: subscription.id,
subscriptionId: subscription.subscriptionId,
planId: subscription.planId,
merchantId: subscription.merchantId,
customer: subscription.customer,
status: subscription.status,
lastCharged: subscription.lastCharged,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
// Inlined so the admin does not need a second request for the plan's
// description, amount and interval.
plan: subscription.plan
? {
planId: subscription.plan.planId,
description: subscription.plan.description,
token: subscription.plan.token,
amount: subscription.plan.amount.toString(),
interval: subscription.plan.interval,
active: subscription.plan.active,
}
: undefined,
});

export const listSubscriptions = async (
filters: AdminSubscriptionListFilters,
pagination: AdminSubscriptionListPagination,
sortBy: SubscriptionListSortBy,
sortDir: SubscriptionListSortDir,
) => {
const where: Prisma.SubscriptionWhereInput = {};

if (filters.status) {
where.status = filters.status;
}

if (filters.planId) {
where.planId = filters.planId;
}

if (filters.customer) {
where.customer = filters.customer;
}

// Subscription.merchantId is a scalar column holding the Merchant.id copied
// from the plan at creation time, so an address filter first resolves the
// address to a merchant id and then matches it directly. An address with no
// Merchant row simply matches no subscriptions.
if (filters.merchantAddress) {
const merchant = await prisma.merchant.findUnique({
where: { address: filters.merchantAddress },
select: { id: true },
});
if (!merchant) {
return { data: [], pagination: { ...pagination, total: 0 } };
}
where.merchantId = merchant.id;
}

// The literal `id` tiebreaker keeps ordering stable across pages so a
// reordered row cannot shift between offsets, matching listAuditLogs.
const orderBy: Prisma.SubscriptionOrderByWithRelationInput[] = [
{ [sortBy]: sortDir },
{ id: 'desc' },
];

const [subscriptions, total] = await Promise.all([
prisma.subscription.findMany({
where,
take: pagination.limit,
skip: pagination.offset,
orderBy,
include: { plan: true },
}),
prisma.subscription.count({ where }),
]);

return {
data: subscriptions.map(sanitizeSubscription),
pagination: {
limit: pagination.limit,
offset: pagination.offset,
total,
},
};
};

export const getSubscription = async (id: string) => {
const subscription = await prisma.subscription.findUnique({
where: { id },
include: { plan: true },
});

if (!subscription) {
throw new AppError(404, 'Subscription not found');
}

return sanitizeSubscription(subscription);
};

/**
* Public-facing view of a subscription charge. `amount` is serialized to a
* string because `BigInt` is not JSON-serializable.
*/
export const sanitizeSubscriptionPayment = (transaction: Transaction) => ({
id: transaction.id,
transactionType: transaction.transactionType,
refId: transaction.refId,
amount: transaction.amount.toString(),
token: transaction.token,
merchantId: transaction.merchantId,
description: transaction.description,
date: transaction.date,
createdAt: transaction.createdAt,
});

/**
* Lists the subscription charge history for the admin dashboard.
*
* ---------------------------------------------------------------------------
* DEPENDENCY: Transaction rows with transactionType = 'SUBSCRIPTION_CHARGE'
* are written by a single place — applySubscriptionCharge above, from the
* indexer's subscriptionCharged.ts handler. Until that handler runs, this
* endpoint returns an empty result set by design: it means "no charges have
* been indexed yet", not "subscriptions are broken". A non-empty result is
* therefore evidence the SubscriptionChargedEvent path is working.
* ---------------------------------------------------------------------------
*/
export const listSubscriptionPayments = async (
filters: AdminSubscriptionPaymentsFilters,
pagination: AdminSubscriptionListPagination,
) => {
const where: Prisma.TransactionWhereInput = {
transactionType: TransactionType.SUBSCRIPTION_CHARGE,
};

if (filters.merchantAddress) {
where.merchant = { address: filters.merchantAddress };
}

if (filters.startDate || filters.endDate) {
where.date = {};
if (filters.startDate) where.date.gte = filters.startDate;
if (filters.endDate) where.date.lte = filters.endDate;
}

const [transactions, total] = await Promise.all([
prisma.transaction.findMany({
where,
take: pagination.limit,
skip: pagination.offset,
// The literal `id` tiebreaker keeps ordering stable across pages when
// charges share a timestamp, matching listSubscriptions and listAuditLogs.
orderBy: [{ date: 'desc' }, { id: 'desc' }],
}),
Comment thread
shogun444 marked this conversation as resolved.
prisma.transaction.count({ where }),
]);

return {
data: transactions.map(sanitizeSubscriptionPayment),
pagination: {
limit: pagination.limit,
offset: pagination.offset,
total,
},
};
};
Loading
Loading