feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

View File

@@ -0,0 +1,23 @@
import { type PaymentType } from '@prisma/client';
import { type DomainEvent } from '@modules/shared';
/**
* Emitted when an admin manually confirms a VN bank transfer payment.
*
* Carries enough metadata for downstream consumers (audit logging,
* subscription activation, accounting) without requiring a re-read
* of the payment aggregate.
*/
export class BankTransferConfirmedEvent implements DomainEvent {
readonly eventName = 'payment.bank_transfer_confirmed';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly userId: string,
public readonly type: PaymentType,
public readonly amountVND: bigint,
public readonly confirmedBy: string,
public readonly bankReference: string | null,
) {}
}

View File

@@ -0,0 +1,63 @@
import { ConfirmBankTransferCommand } from '../../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command';
import { AdminPaymentsController } from '../admin-payments.controller';
describe('AdminPaymentsController', () => {
let controller: AdminPaymentsController;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
const mockAdmin = { sub: 'admin-1', phone: '0901234567', role: 'ADMIN' } as any;
beforeEach(() => {
mockCommandBus = { execute: vi.fn() };
controller = new AdminPaymentsController(mockCommandBus as any);
});
describe('POST /admin/payments/:id/confirm-transfer', () => {
it('dispatches ConfirmBankTransferCommand with admin sub + bankReference', async () => {
const expected = {
paymentId: 'pay-1',
status: 'COMPLETED',
confirmedBy: 'admin-1',
};
mockCommandBus.execute.mockResolvedValue(expected);
const result = await controller.confirmBankTransfer(
'pay-1',
{ bankReference: 'FT123456' } as any,
mockAdmin,
);
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.any(ConfirmBankTransferCommand),
);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as ConfirmBankTransferCommand;
expect(cmd.paymentId).toBe('pay-1');
expect(cmd.confirmedBy).toBe('admin-1');
expect(cmd.bankReference).toBe('FT123456');
expect(result).toEqual(expected);
});
it('supports omitted bankReference', async () => {
mockCommandBus.execute.mockResolvedValue({
paymentId: 'pay-2',
status: 'COMPLETED',
confirmedBy: 'admin-1',
});
await controller.confirmBankTransfer('pay-2', {} as any, mockAdmin);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as ConfirmBankTransferCommand;
expect(cmd.paymentId).toBe('pay-2');
expect(cmd.confirmedBy).toBe('admin-1');
expect(cmd.bankReference).toBeUndefined();
});
it('propagates errors from the command bus', async () => {
mockCommandBus.execute.mockRejectedValue(new Error('validation failed'));
await expect(
controller.confirmBankTransfer('pay-3', {} as any, mockAdmin),
).rejects.toThrow('validation failed');
});
});
});

View File

@@ -0,0 +1,52 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { CurrentUser, JwtAuthGuard, type JwtPayload, Roles, RolesGuard } from '@modules/auth';
import { ConfirmBankTransferCommand } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command';
import { type ConfirmBankTransferResult } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.handler';
import { type ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto';
/**
* Admin-only controller for manual payment reconciliation.
*
* Separated from the user-facing `PaymentsController` so the audit/RBAC
* surface is clearly scoped under `/admin/payments/*`.
*/
@ApiTags('admin-payments')
@ApiBearerAuth('JWT')
@Controller('admin/payments')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
export class AdminPaymentsController {
constructor(private readonly commandBus: CommandBus) {}
@Post(':id/confirm-transfer')
@ApiOperation({
summary: 'Confirm a VN bank transfer payment (admin only)',
description:
'Marks a pending/processing BANK_TRANSFER payment as COMPLETED. ' +
'Emits payment.completed + payment.bank_transfer_confirmed events ' +
'so audit logs and subscription activation fire automatically.',
})
@ApiParam({ name: 'id', description: 'Payment id to confirm' })
@ApiResponse({ status: 201, description: 'Bank transfer confirmed successfully' })
@ApiResponse({ status: 400, description: 'Payment is not a bank transfer or invalid status' })
@ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
@ApiResponse({ status: 404, description: 'Payment not found' })
async confirmBankTransfer(
@Param('id') id: string,
@Body() dto: ConfirmBankTransferDto,
@CurrentUser() user: JwtPayload,
): Promise<ConfirmBankTransferResult> {
return this.commandBus.execute(
new ConfirmBankTransferCommand(id, user.sub, dto.bankReference),
);
}
}