feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user