fix: simplify balance billing and govern operation logs

This commit is contained in:
hectorzhao
2026-07-14 17:40:32 +08:00
parent f35691f185
commit 28dad93e3e
40 changed files with 740 additions and 395 deletions
+7 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { AuditService, CreateOperationLogDto } from './audit.service';
@@ -9,8 +9,12 @@ export class AuditController {
constructor(private readonly audit: AuditService) {}
@Get()
list(@TenantId() tenantId?: string) {
return this.audit.list(tenantId);
list(
@TenantId() tenantId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.audit.list(tenantId, Number(page), Number(pageSize));
}
@Post()
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
import { OperationLogRetentionService } from './operation-log-retention.service';
@Module({
controllers: [AuditController],
providers: [AuditService],
providers: [AuditService, OperationLogRetentionService],
exports: [AuditService],
})
export class AuditModule {}
+26
View File
@@ -0,0 +1,26 @@
import { AuditService } from './audit.service';
describe('AuditService', () => {
it('returns bounded paginated operation logs', async () => {
const prisma = {
operationLog: {
findMany: jest.fn().mockResolvedValue([{ id: 'log-1' }]),
count: jest.fn().mockResolvedValue(1),
},
};
const service = new AuditService(prisma as never);
await expect(service.list('tenant-1', 2, 1_000)).resolves.toEqual({
items: [{ id: 'log-1' }],
total: 1,
page: 2,
pageSize: 100,
});
expect(prisma.operationLog.findMany).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: 100,
take: 100,
});
});
});
+19 -5
View File
@@ -17,11 +17,20 @@ export interface CreateOperationLogDto {
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
list(tenantId?: string) {
return this.prisma.operationLog.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
});
async list(tenantId?: string, pageInput?: number, pageSizeInput?: number) {
const page = positiveInteger(pageInput, 1);
const pageSize = Math.min(100, positiveInteger(pageSizeInput, 20));
const where = tenantId ? { tenantId } : undefined;
const [items, total] = await Promise.all([
this.prisma.operationLog.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.operationLog.count({ where }),
]);
return { items, total, page, pageSize };
}
create(data: CreateOperationLogDto) {
@@ -38,3 +47,8 @@ export class AuditService {
return this.prisma.operationLog.create({ data: createData });
}
}
function positiveInteger(value: number | undefined, fallback: number) {
const normalized = Number(value);
return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback;
}
@@ -0,0 +1,28 @@
import { OperationLogRetentionService } from './operation-log-retention.service';
describe('OperationLogRetentionService', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it('archives expired logs in bounded batches using the configured retention window', async () => {
process.env.OPERATION_LOG_RETENTION_DAYS = '90';
process.env.OPERATION_LOG_ARCHIVE_BATCH_SIZE = '2';
process.env.OPERATION_LOG_ARCHIVE_MAX_BATCHES = '3';
const prisma = {
$executeRaw: jest.fn()
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(1),
};
const service = new OperationLogRetentionService(prisma as never);
await expect(service.archiveExpiredLogs(new Date('2026-07-14T00:00:00.000Z'))).resolves.toEqual({
archived: 3,
cutoff: new Date('2026-04-15T00:00:00.000Z'),
retentionDays: 90,
});
expect(prisma.$executeRaw).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,115 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_RETENTION_DAYS = 180;
const DEFAULT_BATCH_SIZE = 1_000;
const DEFAULT_MAX_BATCHES = 20;
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1_000;
const INITIAL_DELAY_MS = 60_000;
@Injectable()
export class OperationLogRetentionService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(OperationLogRetentionService.name);
private initialTimer?: ReturnType<typeof setTimeout>;
private intervalTimer?: ReturnType<typeof setInterval>;
private running = false;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
if (!operationLogArchiveEnabled()) {
return;
}
this.initialTimer = setTimeout(() => void this.runSafely(), INITIAL_DELAY_MS);
this.initialTimer.unref?.();
this.intervalTimer = setInterval(() => void this.runSafely(), positiveIntegerEnv('OPERATION_LOG_ARCHIVE_INTERVAL_MS', DEFAULT_INTERVAL_MS));
this.intervalTimer.unref?.();
}
onModuleDestroy() {
if (this.initialTimer) clearTimeout(this.initialTimer);
if (this.intervalTimer) clearInterval(this.intervalTimer);
}
async archiveExpiredLogs(now = new Date()) {
const retentionDays = positiveIntegerEnv('OPERATION_LOG_RETENTION_DAYS', DEFAULT_RETENTION_DAYS);
const batchSize = Math.min(10_000, positiveIntegerEnv('OPERATION_LOG_ARCHIVE_BATCH_SIZE', DEFAULT_BATCH_SIZE));
const maxBatches = Math.min(100, positiveIntegerEnv('OPERATION_LOG_ARCHIVE_MAX_BATCHES', DEFAULT_MAX_BATCHES));
const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1_000);
let total = 0;
for (let batch = 0; batch < maxBatches; batch += 1) {
const moved = await this.archiveBatch(cutoff, batchSize);
total += moved;
if (moved < batchSize) {
break;
}
}
return { archived: total, cutoff, retentionDays };
}
private async archiveBatch(cutoff: Date, batchSize: number) {
return this.prisma.$executeRaw(Prisma.sql`
WITH candidates AS (
SELECT "id", "tenantId", "userId", "action", "resource", "resourceId",
"ipAddress", "userAgent", "detail", "createdAt"
FROM "OperationLog"
WHERE "createdAt" < ${cutoff}
ORDER BY "createdAt" ASC, "id" ASC
LIMIT ${batchSize}
FOR UPDATE SKIP LOCKED
), archived AS (
INSERT INTO "OperationLogArchive" (
"originalId", "tenantId", "userId", "action", "resource", "resourceId",
"ipAddress", "userAgent", "detail", "createdAt", "archiveMonth", "archivedAt"
)
SELECT "id", "tenantId", "userId", "action", "resource", "resourceId",
"ipAddress", "userAgent", "detail", "createdAt", TO_CHAR("createdAt", 'YYYY-MM'), NOW()
FROM candidates
ON CONFLICT ("originalId") DO NOTHING
RETURNING "originalId"
)
DELETE FROM "OperationLog" source
USING candidates
WHERE source."id" = candidates."id"
AND (
EXISTS (
SELECT 1 FROM archived
WHERE archived."originalId" = source."id"
)
OR EXISTS (
SELECT 1 FROM "OperationLogArchive" archive
WHERE archive."originalId" = source."id"
)
)
`);
}
private async runSafely() {
if (this.running) {
return;
}
this.running = true;
try {
const result = await this.archiveExpiredLogs();
if (result.archived > 0) {
this.logger.log(`Archived ${result.archived} operation logs older than ${result.cutoff.toISOString()}`);
}
} catch (error) {
this.logger.error('Operation log archival failed', error instanceof Error ? error.stack : String(error));
} finally {
this.running = false;
}
}
}
function operationLogArchiveEnabled() {
const configured = String(process.env.OPERATION_LOG_ARCHIVE_ENABLED ?? 'true').trim().toLowerCase();
return configured !== 'false' && configured !== '0';
}
function positiveIntegerEnv(name: string, fallback: number) {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 ? value : fallback;
}
-27
View File
@@ -6,8 +6,6 @@ import {
BillingService,
BillingActionDto,
CreateManualRechargeDto,
CreateRechargeOrderDto,
CreateBillingPlanDto,
CreateBillingRuleDto,
CreateSmsBillingRecordDto,
CreateTenantAccountDto,
@@ -19,16 +17,6 @@ import {
export class BillingController {
constructor(private readonly billing: BillingService) {}
@Get('plans')
listPlans() {
return this.billing.listPlans();
}
@Post('plans')
createPlan(@Body() body: CreateBillingPlanDto) {
return this.billing.createPlan(body);
}
@Get('accounts')
listAccounts() {
return this.billing.listAccounts();
@@ -44,11 +32,6 @@ export class BillingController {
return this.billing.listRechargeOrders(tenantId);
}
@Post('recharges')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body);
}
@Get('manual-recharges')
listManualRechargeRecords(@TenantId() tenantId?: string) {
return this.billing.listManualRechargeRecords(tenantId);
@@ -123,16 +106,6 @@ export class BillingController {
export class ClientBillingController {
constructor(private readonly billing: BillingService) {}
@Get('plans')
listPlans() {
return this.billing.listPlans();
}
@Post('orders')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body);
}
@Get('orders')
listRechargeOrders(@TenantId() tenantId?: string) {
return this.billing.listRechargeOrders(tenantId);
+16 -29
View File
@@ -1,21 +1,15 @@
import { BillingService } from './billing.service';
function createPrismaMock() {
const accountState = { tenantId: 'tenant-1', balanceCents: 1000, smsUnits: 20, creditCents: 200 };
const accountState = { tenantId: 'tenant-1', balanceCents: 1000 };
return {
accountState,
billingPlan: {
findMany: jest.fn(),
create: jest.fn(),
findUnique: jest.fn(),
},
tenantAccount: {
findMany: jest.fn(),
create: jest.fn(),
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
update: jest.fn().mockImplementation(({ data }) => {
accountState.balanceCents = data.balanceCents;
accountState.smsUnits = data.smsUnits;
return Promise.resolve({ ...accountState });
}),
},
@@ -68,38 +62,33 @@ describe('BillingService', () => {
);
});
it('checks balance, credit, and package units before sending', async () => {
it('checks only the cash balance before sending', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1100, smsUnits: 20 })).resolves.toEqual(
expect.objectContaining({ availableAmount: 1200, availableSmsUnits: 20, canSend: true }),
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 5 })).resolves.toEqual(
expect.objectContaining({ availableAmount: 1000, canSend: true }),
);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1300, smsUnits: 20 })).resolves.toEqual(
expect.objectContaining({ canSend: false }),
);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 21 })).resolves.toEqual(
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
expect.objectContaining({ canSend: false }),
);
});
it('creates recharge orders and account transactions from plans', async () => {
it('creates cash recharge orders and account transactions without plans', async () => {
const prisma = createPrismaMock();
prisma.billingPlan.findUnique.mockResolvedValue({ id: 'plan-1', priceCents: 500, smsUnits: 100 });
const service = new BillingService(prisma as never);
const order = await service.createRechargeOrder({ tenantId: 'tenant-1', planId: 'plan-1', remark: 'manual top up' });
const order = await service.createRechargeOrder({ tenantId: 'tenant-1', amountCents: 500, remark: 'manual top up' });
expect(order).toEqual(expect.objectContaining({ amountCents: 500, smsUnits: 100, status: 'paid' }));
expect(order).toEqual(expect.objectContaining({ amountCents: 500, status: 'paid' }));
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
data: { balanceCents: 1500, smsUnits: 120 },
data: { balanceCents: 1500 },
});
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
transactionType: 'recharge',
amountCents: 500,
smsUnits: 100,
balanceAfter: 1500,
relatedType: 'recharge_order',
relatedId: 'order-1',
@@ -114,7 +103,6 @@ describe('BillingService', () => {
const order = await service.createManualRecharge({
tenantId: 'tenant-1',
amountCents: 2000,
smsUnits: 0,
operatorId: 'admin-1',
remark: '线下转账人工充值',
});
@@ -175,7 +163,6 @@ describe('BillingService', () => {
const order = await service.createManualRecharge({
tenantId: 'tenant-1',
amountCents: -300,
smsUnits: 0,
operatorId: 'admin-1',
remark: '人工冲正',
});
@@ -183,7 +170,7 @@ describe('BillingService', () => {
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
data: { balanceCents: 700, smsUnits: 20 },
data: { balanceCents: 700 },
});
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -205,11 +192,11 @@ describe('BillingService', () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
await service.freeze({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 2, relatedType: 'sms_batch_task', relatedId: 'task-1' });
await service.charge({ tenantId: 'tenant-1', amountCents: 50, smsUnits: 1, relatedType: 'sms_message_record', relatedId: 'msg-1' });
await service.release({ tenantId: 'tenant-1', amountCents: 25, smsUnits: 1 });
await service.refund({ tenantId: 'tenant-1', amountCents: 10, smsUnits: 1 });
await service.adjust({ tenantId: 'tenant-1', amountCents: 5, smsUnits: 0 });
await service.freeze({ tenantId: 'tenant-1', amountCents: 100, relatedType: 'sms_batch_task', relatedId: 'task-1' });
await service.charge({ tenantId: 'tenant-1', amountCents: 50, relatedType: 'sms_message_record', relatedId: 'msg-1' });
await service.release({ tenantId: 'tenant-1', amountCents: 25 });
await service.refund({ tenantId: 'tenant-1', amountCents: 10 });
await service.adjust({ tenantId: 'tenant-1', amountCents: 5 });
expect(prisma.accountTransaction.create.mock.calls.map(([arg]) => arg.data.transactionType)).toEqual([
'frozen',
@@ -218,7 +205,7 @@ describe('BillingService', () => {
'refunded',
'adjusted',
]);
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890, smsUnits: 19 }));
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
});
it('creates SMS billing records linked to message and task identifiers', async () => {
+5 -60
View File
@@ -2,20 +2,9 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateBillingPlanDto {
name: string;
priceCents: number;
smsUnits: number;
validDays: number;
status?: string;
description?: string;
}
export interface CreateTenantAccountDto {
tenantId: string;
balanceCents?: number;
smsUnits?: number;
creditCents?: number;
status?: string;
}
@@ -23,7 +12,6 @@ export interface CreateAccountTransactionDto {
tenantId: string;
transactionType: string;
amountCents?: number;
smsUnits?: number;
balanceAfter?: number;
relatedType?: string;
relatedId?: string;
@@ -40,9 +28,7 @@ export interface CreateBillingRuleDto {
export interface CreateRechargeOrderDto {
tenantId: string;
planId?: string;
amountCents?: number;
smsUnits?: number;
amountCents: number;
payMethod?: string;
operatorId?: string;
remark?: string;
@@ -51,7 +37,6 @@ export interface CreateRechargeOrderDto {
export interface CreateManualRechargeDto {
tenantId: string;
amountCents: number;
smsUnits?: number;
operatorId?: string;
remark?: string;
}
@@ -68,7 +53,6 @@ export interface EstimateSmsCostDto {
export interface BillingActionDto {
tenantId: string;
amountCents?: number;
smsUnits?: number;
relatedType?: string;
relatedId?: string;
remark?: string;
@@ -88,23 +72,6 @@ export interface CreateSmsBillingRecordDto {
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
listPlans() {
return this.prisma.billingPlan.findMany({ orderBy: { createdAt: 'desc' } });
}
createPlan(data: CreateBillingPlanDto) {
return this.prisma.billingPlan.create({
data: {
name: data.name,
priceCents: data.priceCents,
smsUnits: data.smsUnits,
validDays: data.validDays,
status: data.status ?? 'active',
description: data.description,
},
});
}
listAccounts() {
return this.prisma.tenantAccount.findMany({
include: { tenant: true },
@@ -116,8 +83,6 @@ export class BillingService {
const createData: Prisma.TenantAccountUncheckedCreateInput = {
tenantId: data.tenantId,
balanceCents: data.balanceCents ?? 0,
smsUnits: data.smsUnits ?? 0,
creditCents: data.creditCents ?? 0,
status: data.status ?? 'active',
};
return this.prisma.tenantAccount.create({ data: createData });
@@ -126,7 +91,6 @@ export class BillingService {
listRechargeOrders(tenantId?: string) {
return this.prisma.rechargeOrder.findMany({
where: tenantId ? { tenantId } : undefined,
include: { plan: true },
orderBy: { createdAt: 'desc' },
});
}
@@ -137,7 +101,6 @@ export class BillingService {
tenantId,
payMethod: 'manual_topup',
},
include: { plan: true },
orderBy: { createdAt: 'desc' },
});
const orderIds = orders.map((order) => order.id);
@@ -161,16 +124,12 @@ export class BillingService {
}
async createRechargeOrder(data: CreateRechargeOrderDto) {
const plan = data.planId ? await this.prisma.billingPlan.findUnique({ where: { id: data.planId } }) : null;
const amountCents = data.amountCents ?? plan?.priceCents ?? 0;
const smsUnits = data.smsUnits ?? plan?.smsUnits ?? 0;
const amountCents = data.amountCents;
const order = await this.prisma.rechargeOrder.create({
data: {
tenantId: data.tenantId,
planId: data.planId,
orderNo: `R${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
amountCents,
smsUnits,
status: 'paid',
payMethod: data.payMethod ?? 'manual',
paidAt: new Date(),
@@ -183,7 +142,6 @@ export class BillingService {
tenantId: data.tenantId,
transactionType: 'recharge',
amountCents,
smsUnits,
relatedType: 'recharge_order',
relatedId: order.id,
remark: data.remark,
@@ -196,7 +154,6 @@ export class BillingService {
const order = await this.createRechargeOrder({
tenantId: data.tenantId,
amountCents: data.amountCents,
smsUnits: data.smsUnits ?? 0,
payMethod: 'manual_topup',
operatorId: data.operatorId,
remark: data.remark,
@@ -210,7 +167,6 @@ export class BillingService {
resourceId: order.id,
detail: {
amountCents: data.amountCents,
smsUnits: data.smsUnits ?? 0,
orderNo: order.orderNo,
remark: data.remark,
} as Prisma.InputJsonValue,
@@ -239,15 +195,12 @@ export class BillingService {
async checkAccount(data: BillingActionDto) {
const account = await this.getAccountOrCreate(data.tenantId);
const requiredAmount = data.amountCents ?? 0;
const requiredUnits = data.smsUnits ?? 0;
const availableAmount = account.balanceCents + account.creditCents;
const availableAmount = account.balanceCents;
return {
tenantId: data.tenantId,
requiredAmount,
requiredUnits,
availableAmount,
availableSmsUnits: account.smsUnits,
canSend: availableAmount >= requiredAmount && account.smsUnits >= requiredUnits,
canSend: availableAmount >= requiredAmount,
};
}
@@ -256,7 +209,6 @@ export class BillingService {
...data,
transactionType: 'frozen',
amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
});
}
@@ -265,7 +217,6 @@ export class BillingService {
...data,
transactionType: 'charged',
amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
});
}
@@ -274,7 +225,6 @@ export class BillingService {
...data,
transactionType: 'released',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
@@ -283,7 +233,6 @@ export class BillingService {
...data,
transactionType: 'refunded',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
@@ -292,7 +241,6 @@ export class BillingService {
...data,
transactionType: 'adjusted',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
@@ -348,19 +296,17 @@ export class BillingService {
return this.prisma.tenantAccount.upsert({
where: { tenantId },
update: {},
create: { tenantId, balanceCents: 0, smsUnits: 0, creditCents: 0, status: 'active' },
create: { tenantId, balanceCents: 0, status: 'active' },
});
}
private async applyAccountDelta(data: CreateAccountTransactionDto) {
const account = await this.getAccountOrCreate(data.tenantId);
const nextBalance = account.balanceCents + (data.amountCents ?? 0);
const nextUnits = account.smsUnits + (data.smsUnits ?? 0);
await this.prisma.tenantAccount.update({
where: { tenantId: data.tenantId },
data: {
balanceCents: nextBalance,
smsUnits: nextUnits,
},
});
@@ -369,7 +315,6 @@ export class BillingService {
tenantId: data.tenantId,
transactionType: data.transactionType,
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
balanceAfter: nextBalance,
relatedType: data.relatedType,
relatedId: data.relatedId,
@@ -92,8 +92,13 @@ export class AdminOperationsController {
}
@Get('audit-logs')
auditLogs(@Query('tenantId') tenantId?: string, @Query('userId') userId?: string) {
return this.operations.auditLogs({ tenantId, userId });
auditLogs(
@Query('tenantId') tenantId?: string,
@Query('userId') userId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.operations.auditLogs({ tenantId, userId, page: Number(page), pageSize: Number(pageSize) });
}
@Get('audit-summary')
+31 -1
View File
@@ -26,7 +26,7 @@ function createPrismaMock() {
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
},
accountTransaction: {
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }),
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20 } }),
},
tenantAccount: {
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 1000, tenant: { name: '租户A' } }]),
@@ -331,6 +331,36 @@ describe('OperationsService', () => {
);
});
it('applies operation-log level filters before database pagination', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await service.systemLogs({ level: 'error', page: 2, pageSize: 5 });
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
AND: expect.objectContaining({ OR: expect.any(Array) }),
}),
skip: 5,
take: 5,
}));
expect(prisma.operationLog.count).toHaveBeenCalledWith({
where: expect.objectContaining({ AND: expect.objectContaining({ OR: expect.any(Array) }) }),
});
});
it('caps legacy audit-log reads with pagination', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual(expect.objectContaining({
total: 1,
page: 1,
pageSize: 100,
}));
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 }));
});
it('returns paginated gateway submit dead letters', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
+65 -16
View File
@@ -180,8 +180,8 @@ export class OperationsService {
_count: { _all: true },
}),
this.prisma.accountTransaction.aggregate({
where: { tenantId: query.tenantId },
_sum: { amountCents: true, smsUnits: true },
where: { tenantId: query.tenantId, transactionType: 'refunded', createdAt: { gte: sinceToday } },
_sum: { amountCents: true },
_count: { _all: true },
}),
this.prisma.cmppConnectionState.groupBy({
@@ -296,21 +296,31 @@ export class OperationsService {
});
}
auditLogs(query: { tenantId?: string; userId?: string }) {
return this.prisma.operationLog.findMany({
where: { tenantId: query.tenantId, userId: query.userId },
orderBy: { createdAt: 'desc' },
});
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId };
const [items, total] = await Promise.all([
this.prisma.operationLog.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.operationLog.count({ where }),
]);
return { items, total, page, pageSize };
}
async systemLogs(query: OperationLogQuery) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 10));
const where: Prisma.OperationLogWhereInput = {
tenantId: query.tenantId,
userId: query.userId,
createdAt: createdAtRange(query.range),
resource: query.module && query.module !== 'all' ? query.module : undefined,
AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined,
OR: query.keyword ? [
{ action: { contains: query.keyword } },
{ resource: { contains: query.keyword } },
@@ -324,7 +334,7 @@ export class OperationsService {
this.prisma.operationLog.findMany({
where,
include: { tenant: true, user: true },
orderBy: { createdAt: 'desc' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
@@ -336,12 +346,9 @@ export class OperationsService {
orderBy: { resource: 'asc' },
}),
]);
const normalizedItems = items
.map((item) => normalizeOperationLog(item))
.filter((item) => !query.level || query.level === 'all' || item.level === query.level);
return {
items: normalizedItems,
total: query.level && query.level !== 'all' ? normalizedItems.length : total,
items: items.map((item) => normalizeOperationLog(item)),
total,
page,
pageSize,
modules: modules.map((item) => item.resource),
@@ -722,7 +729,7 @@ export class OperationsService {
relatedId: query.taskId,
},
_count: { _all: true },
_sum: { amountCents: true, smsUnits: true },
_sum: { amountCents: true },
}),
]);
const messageAmount = messages._sum.amountCents ?? 0;
@@ -969,6 +976,48 @@ function groupDownstreamByApplication(
return [...summaryMap.values()];
}
function positiveInteger(value: number | undefined, fallback: number) {
const normalized = Number(value);
return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback;
}
function operationLogLevelWhere(level: string): Prisma.OperationLogWhereInput {
const error: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'failed' } },
{ action: { contains: 'reject' } },
{ detail: { path: ['result'], string_contains: 'fail' } },
{ detail: { path: ['status'], string_contains: 'fail' } },
],
};
const warning: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'warning' } },
{ action: { contains: 'risk' } },
],
};
const success: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'approve' } },
{ action: { contains: 'recharge' } },
{ action: { contains: 'connected' } },
],
};
if (level === 'error') {
return error;
}
if (level === 'warning') {
return { AND: [{ NOT: error }, warning] };
}
if (level === 'success') {
return { AND: [{ NOT: error }, { NOT: warning }, success] };
}
if (level === 'info') {
return { NOT: { OR: [error, warning, success] } };
}
return {};
}
function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
const detail = (log.detail ?? {}) as Record<string, unknown>;
const result = String(detail.result ?? detail.status ?? '');
+34 -5
View File
@@ -249,11 +249,13 @@ function createPrismaMock() {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
gatewayDownstreamRecoveryStatus: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue({
id: 'recover-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
account: '100001',
gatewayInstanceId: 'gateway-a',
state: 'waiting_connection',
lockOwner: 'gateway-a',
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
@@ -341,7 +343,7 @@ describe('SendChainService', () => {
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }),
]),
});
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, smsUnits: 2, relatedId: 'task-1' }));
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, relatedId: 'task-1' }));
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
@@ -376,7 +378,7 @@ describe('SendChainService', () => {
dispatched: 1,
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
});
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { batchTaskId: 'task-1', status: 'scheduled' },
data: { status: 'queued' },
@@ -854,8 +856,8 @@ describe('SendChainService', () => {
where: { id: 'record-1' },
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }),
});
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'MSG-1' }));
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'MSG-1' }));
expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }),
});
@@ -1412,12 +1414,39 @@ describe('SendChainService', () => {
}));
expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
action: 'gateway.downstream_recovery_status_sync',
action: 'gateway.downstream_recovery_status_changed',
resource: 'gateway_downstream_recovery_status',
}),
}));
});
it('does not append recovery audit logs when only periodic timestamps change', async () => {
const { service, prisma } = createService();
prisma.gatewayDownstreamRecoveryStatus.findUnique.mockResolvedValue({
state: 'waiting_connection',
gatewayInstanceId: 'gateway-a',
lockOwner: 'gateway-a',
failureCategory: 'client_disconnected',
lastError: 'downstream client is not connected',
lastSkipReason: null,
});
await service.recordGatewayDownstreamRecoveryStatus({
account: '100001',
gatewayInstanceId: 'gateway-a',
state: 'waiting_connection',
lastAttemptAt: '2026-07-08T12:01:00.000Z',
nextRetryAt: '2026-07-08T12:11:00.000Z',
attemptCount: 3,
lockOwner: 'gateway-a',
lockExpiresAt: '2026-07-08T12:01:30.000Z',
lastError: 'downstream client is not connected',
});
expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalled();
expect(prisma.operationLog.create).not.toHaveBeenCalled();
});
it('marks downstream delivery as failed after reaching retry limit', async () => {
const { service, prisma } = createService();
const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
+60 -46
View File
@@ -279,10 +279,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const accountCheck = await this.billing.checkAccount({
tenantId: data.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
});
if (!accountCheck.canSend) {
throw new BadRequestException('企业账户余额、套餐余量或授信额度不足');
throw new BadRequestException('企业账户余额不足');
}
}
const task = await this.prisma.smsBatchTask.create({
@@ -305,11 +304,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
createdById: data.createdById,
},
});
if (shouldReserveBalance && billing.amountCents + billing.totalBillingUnits > 0) {
if (shouldReserveBalance && billing.amountCents > 0) {
await this.billing.freeze({
tenantId: data.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: '发送任务创建冻结',
@@ -637,16 +635,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
take: 100000,
});
const amountCents = messages.reduce((sum, message) => sum + message.amountCents, 0);
const smsUnits = messages.reduce((sum, message) => sum + message.billingUnits, 0);
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents, smsUnits });
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
if (!accountCheck.canSend) {
throw new BadRequestException('定时任务到点时企业账户余额、套餐余量或授信额度不足');
throw new BadRequestException('定时任务到点时企业账户余额不足');
}
if (amountCents + smsUnits > 0) {
if (amountCents > 0) {
await this.billing.freeze({
tenantId: task.tenantId,
amountCents,
smsUnits,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: '定时任务到点冻结',
@@ -1114,9 +1110,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
const recoveryStatuses = (this.prisma as PrismaService & {
gatewayDownstreamRecoveryStatus: {
findUnique: (args: Record<string, unknown>) => Promise<any>;
upsert: (args: Record<string, unknown>) => Promise<any>;
};
}).gatewayDownstreamRecoveryStatus;
const previous = await recoveryStatuses.findUnique({
where: { account },
select: {
state: true,
gatewayInstanceId: true,
lockOwner: true,
failureCategory: true,
lastError: true,
lastSkipReason: true,
},
});
const application = await this.prisma.smsApplication.findUnique({
where: { cmppAccount: account },
select: { id: true, tenantId: true, name: true },
@@ -1167,27 +1175,30 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
lockOwner?: string | null;
lockExpiresAt?: Date | null;
};
await this.prisma.operationLog.create({
data: {
tenantId: updated.tenantId ?? undefined,
action: 'gateway.downstream_recovery_status_sync',
resource: 'gateway_downstream_recovery_status',
resourceId: updated.id,
detail: {
account,
state: updated.state,
lockOwner: normalizedUpdated.lockOwner,
lockExpiresAt: normalizedUpdated.lockExpiresAt,
attemptCount: updated.attemptCount,
nextRetryAt: updated.nextRetryAt,
failureCategory: normalizedUpdated.failureCategory,
applicationId: updated.applicationId,
applicationName: application?.name,
lastError: updated.lastError,
lastSkipReason: updated.lastSkipReason,
if (hasRecoveryAuditStateChanged(previous, updated)) {
await this.prisma.operationLog.create({
data: {
tenantId: updated.tenantId ?? undefined,
action: 'gateway.downstream_recovery_status_changed',
resource: 'gateway_downstream_recovery_status',
resourceId: updated.id,
detail: {
account,
previousState: previous?.state ?? null,
state: updated.state,
gatewayInstanceId: updated.gatewayInstanceId,
lockOwner: normalizedUpdated.lockOwner,
attemptCount: updated.attemptCount,
nextRetryAt: updated.nextRetryAt,
failureCategory: normalizedUpdated.failureCategory,
applicationId: updated.applicationId,
applicationName: application?.name,
lastError: updated.lastError,
lastSkipReason: updated.lastSkipReason,
},
},
},
});
});
}
return updated;
}
@@ -1701,16 +1712,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
});
if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足');
await reject('BALANCE', '企业账户余额不足');
} else {
if (billing.amountCents + billing.totalBillingUnits > 0) {
if (billing.amountCents > 0) {
await this.billing.freeze({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 模板不匹配待审核短信冻结',
@@ -1764,16 +1773,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
});
if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足');
await reject('BALANCE', '企业账户余额不足');
} else {
if (billing.amountCents + billing.totalBillingUnits > 0) {
if (billing.amountCents > 0) {
await this.billing.freeze({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 入站短信冻结',
@@ -2253,16 +2260,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
amountCents: number;
}) {
const amountCents = message.amountCents ?? 0;
const smsUnits = message.billingUnits ?? 0;
const billingUnits = message.billingUnits ?? 0;
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
if (exists?.billingStatus === 'charged') {
return;
}
if (amountCents + smsUnits > 0) {
if (amountCents > 0) {
await this.billing.release({
tenantId: message.tenantId,
amountCents,
smsUnits,
relatedType: 'sms_batch_task',
relatedId: message.batchTaskId,
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
@@ -2271,7 +2277,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const transaction = await this.billing.charge({
tenantId: message.tenantId,
amountCents,
smsUnits,
relatedType: 'sms_message_record',
relatedId: message.messageId,
remark: '提交成功扣费',
@@ -2283,7 +2288,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
messageId: message.messageId,
phoneNumber: message.phoneNumber,
contentLength: [...message.content].length,
billingUnits: smsUnits,
billingUnits,
unitPrice: message.unitPrice ?? 0,
amountCents,
billingStatus: 'charged',
@@ -2300,7 +2305,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number; billingUnits: number },
remark: string,
) {
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
if ((message.amountCents ?? 0) <= 0) {
return;
}
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
@@ -2316,7 +2321,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
await this.billing.release({
tenantId: message.tenantId,
amountCents: message.amountCents,
smsUnits: message.billingUnits,
relatedType: 'sms_message_record',
relatedId: message.messageId,
remark: `${remark}: ${message.messageId}`,
@@ -2327,7 +2331,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
message: { tenantId: string; messageId: string; amountCents: number; billingUnits: number },
remark: string,
) {
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
if ((message.amountCents ?? 0) <= 0) {
return;
}
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
@@ -2341,7 +2345,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const transaction = await this.billing.refund({
tenantId: message.tenantId,
amountCents: message.amountCents,
smsUnits: message.billingUnits,
relatedType: 'sms_message_record',
relatedId: message.messageId,
remark,
@@ -2919,6 +2922,17 @@ function octetString(value: string, fixedLength: number) {
return value + '\0'.repeat(fixedLength - value.length);
}
function hasRecoveryAuditStateChanged(
previous: Record<string, unknown> | null,
current: Record<string, unknown>,
) {
if (!previous) {
return true;
}
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason']
.some((key) => (previous[key] ?? null) !== (current[key] ?? null));
}
function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
const explicit = String(data.failureCategory ?? '').trim();
if (explicit) {
@@ -437,6 +437,36 @@ describe('SmsConfigService', () => {
});
});
it.each([
['heartbeat', 'lastHeartbeatAt'],
['submit', 'lastSubmitAt'],
['deliver', 'lastDeliverAt'],
] as const)('updates downstream %s state without appending high-frequency operation logs', async (status, timestampField) => {
const prisma = createPrismaMock();
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({
id: 'downstream-1',
connectedAt: new Date('2026-07-11T11:00:00.000Z'),
lastHeartbeatAt: new Date('2026-07-11T11:00:00.000Z'),
lastSubmitAt: null,
lastDeliverAt: null,
lastError: null,
});
const service = new SmsConfigService(prisma as never);
await service.recordDownstreamConnectionEvent({
account: '100001',
connectionId: 'gateway-1-1',
status,
observedAt: '2026-07-11T11:00:30.000Z',
});
expect(prisma.cmppDownstreamConnection.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'downstream-1' },
data: expect.objectContaining({ [timestampField]: new Date('2026-07-11T11:00:30.000Z') }),
}));
expect(prisma.operationLog.create).not.toHaveBeenCalled();
});
it('lists enterprise signatures with keyword filters and real relations', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
+9 -7
View File
@@ -593,13 +593,15 @@ export class SmsConfigService {
const connection = existing
? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload })
: await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } });
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
applicationId: application.id,
account: data.account,
remoteIp: data.remoteIp,
protocol: data.protocol,
status: connection.status,
});
if (data.status === 'connected' || data.status === 'disconnected') {
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
applicationId: application.id,
account: data.account,
remoteIp: data.remoteIp,
protocol: data.protocol,
status: connection.status,
});
}
return connection;
}
+12 -3
View File
@@ -23,11 +23,14 @@ function createPrismaMock() {
update: jest.fn().mockResolvedValue({ id: 'cert-1' }),
},
tenantAccount: {
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, smsUnits: 300, creditCents: 5000, status: 'active' }]),
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, status: 'active' }]),
},
smsMessageRecord: {
groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 350 } }]),
},
accountTransaction: {
groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 125 } }]),
},
};
}
@@ -78,7 +81,7 @@ describe('TenantsService', () => {
expect(prisma.tenant.update).not.toHaveBeenCalled();
});
it('lists management rows with real account and today spend fields', async () => {
it('lists management rows with real account, today spend and actual refund fields', async () => {
const prisma = createPrismaMock();
const service = new TenantsService(prisma as never);
@@ -86,8 +89,9 @@ describe('TenantsService', () => {
expect.objectContaining({
id: 'tenant-1',
name: '测试企业',
account: expect.objectContaining({ balanceCents: 12000, creditCents: 5000 }),
account: expect.objectContaining({ balanceCents: 12000 }),
todaySpendCents: 350,
todayRefundCents: 125,
}),
]);
expect(prisma.tenant.findMany).toHaveBeenCalledWith(expect.objectContaining({
@@ -99,5 +103,10 @@ describe('TenantsService', () => {
by: ['tenantId'],
_sum: { amountCents: true },
}));
expect(prisma.accountTransaction.groupBy).toHaveBeenCalledWith(expect.objectContaining({
by: ['tenantId'],
where: expect.objectContaining({ transactionType: 'refunded' }),
_sum: { amountCents: true },
}));
});
});
+8 -1
View File
@@ -45,7 +45,7 @@ export class TenantsService {
async listManagementRows() {
const sinceToday = startOfToday();
const [tenants, accounts, todaySpendGroups] = await Promise.all([
const [tenants, accounts, todaySpendGroups, todayRefundGroups] = await Promise.all([
this.prisma.tenant.findMany({
where: { status: { not: 'deleted' } },
include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } },
@@ -57,13 +57,20 @@ export class TenantsService {
where: { queuedAt: { gte: sinceToday } },
_sum: { amountCents: true },
}),
this.prisma.accountTransaction.groupBy({
by: ['tenantId'],
where: { transactionType: 'refunded', createdAt: { gte: sinceToday } },
_sum: { amountCents: true },
}),
]);
const accountsByTenant = new Map(accounts.map((account) => [account.tenantId, account]));
const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
const todayRefundByTenant = new Map(todayRefundGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
return tenants.map((tenant) => ({
...withEnterpriseProfile(tenant),
account: accountsByTenant.get(tenant.id) ?? null,
todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0,
todayRefundCents: todayRefundByTenant.get(tenant.id) ?? 0,
}));
}