From ccd0387f5aab463aa6ceb0e6039f2af601262c2f Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 14 Jun 2026 11:15:08 +0100 Subject: [PATCH] feat(organizer): update bank details (#47) Add PUT /api/organizer/bank-details (ORGANIZER). Re-verifies the account via Paystack (account name taken from the resolve, never the client), looks up the bank display name, and persists onto OrganizerProfile with bankVerified=true. Blocked with 400 while a payout is PROCESSING; 403 for non-organizers; account number masked in the response (012****789). - New PaystackService.getBankName(code): cached, best-effort bank-list lookup (returns null on failure so the verified account still saves). - Reuses verifyBankAccount so the dev SKIP_BANK_VERIFICATION bypass works. NOTE: under split settlement the live settlement bank lives on the provider subaccount; propagating this change to the Paystack/Monnify subaccount is a deliberate follow-up, not handled here. 5 tests cover the happy path (mask + Paystack-sourced name), the PROCESSING-payout block, non-organizer 403, and bank-name fallback. --- src/organizer/dto/update-bank-details.dto.ts | 19 +++ src/organizer/organizer.controller.ts | 15 +++ src/organizer/organizer.service.spec.ts | 129 +++++++++++++++++++ src/organizer/organizer.service.ts | 59 +++++++++ src/paystack/paystack.service.ts | 28 ++++ 5 files changed, 250 insertions(+) create mode 100644 src/organizer/dto/update-bank-details.dto.ts diff --git a/src/organizer/dto/update-bank-details.dto.ts b/src/organizer/dto/update-bank-details.dto.ts new file mode 100644 index 0000000..fb5538b --- /dev/null +++ b/src/organizer/dto/update-bank-details.dto.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; + +/** + * Body for `PUT /api/organizer/bank-details`. Mirrors the bank fields of + * the provider-enable DTOs: a 3-digit-ish NUBAN bank code and a 10-digit + * account number. The account name is resolved from Paystack, never + * trusted from the client. + */ +export class UpdateBankDetailsDto { + @IsString() + @IsNotEmpty() + bankCode: string; + + @IsString() + @IsNotEmpty() + @Length(10, 10, { message: 'Account number must be exactly 10 digits' }) + @Matches(/^\d{10}$/, { message: 'Account number must contain only digits' }) + accountNumber: string; +} diff --git a/src/organizer/organizer.controller.ts b/src/organizer/organizer.controller.ts index 66c8786..fb247c8 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -6,6 +6,7 @@ import { HttpStatus, Param, Post, + Put, Query, Res, } from '@nestjs/common'; @@ -19,6 +20,7 @@ import { EnableMonnifyDto } from './dto/enable-monnify.dto'; import { EnablePaystackDto } from './dto/enable-paystack.dto'; import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; import { QueryAttendeesDto } from './dto/query-attendees.dto'; +import { UpdateBankDetailsDto } from './dto/update-bank-details.dto'; import { OrganizerService } from './organizer.service'; /** @@ -90,6 +92,19 @@ export class OrganizerController { return csv; } + @Put('bank-details') + @Roles(UserRole.ORGANIZER) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Update settlement bank details (Paystack-verified)', + }) + updateBankDetails( + @CurrentUser('id') userId: string, + @Body() dto: UpdateBankDetailsDto, + ) { + return this.organizer.updateBankDetails(userId, dto); + } + @Post('providers/paystack/enable') @Roles(UserRole.ORGANIZER) @HttpCode(HttpStatus.OK) diff --git a/src/organizer/organizer.service.spec.ts b/src/organizer/organizer.service.spec.ts index 1e0a097..4d12e6c 100644 --- a/src/organizer/organizer.service.spec.ts +++ b/src/organizer/organizer.service.spec.ts @@ -6,6 +6,7 @@ import { PaystackService } from '../paystack/paystack.service'; import { MonnifyProvider } from '../payments/providers/monnify.provider'; import { OrganizerService } from './organizer.service'; import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; +import { UpdateBankDetailsDto } from './dto/update-bank-details.dto'; /** * getMyEvents leans on prisma aggregations. We stub each call the method @@ -571,3 +572,131 @@ describe('OrganizerService.getAttendees / exportAttendeesCSV', () => { ); }); }); + +describe('OrganizerService.updateBankDetails', () => { + const dto: UpdateBankDetailsDto = { + bankCode: '058', + accountNumber: '0123456789', + }; + + function bankSvc(opts: { + user?: unknown; + processingPayout?: { id: string } | null; + resolve?: { accountNumber: string; accountName: string }; + bankName?: string | null; + }) { + const userFindUnique = jest.fn().mockResolvedValue( + opts.user === undefined + ? { + id: 'u-1', + role: UserRole.ORGANIZER, + firstName: 'John', + lastName: 'Doe', + organizerProfile: { id: 'prof-1', bankName: 'Old Bank' }, + } + : opts.user, + ); + const payoutFindFirst = jest + .fn() + .mockResolvedValue(opts.processingPayout ?? null); + const profileUpdate = jest.fn().mockImplementation(({ data }) => + Promise.resolve({ + id: 'prof-1', + bankName: data.bankName, + bankCode: data.bankCode, + accountNumber: data.accountNumber, + accountName: data.accountName, + bankVerified: data.bankVerified, + updatedAt: new Date('2026-03-12T15:00:00Z'), + }), + ); + + const resolveBankAccount = jest.fn().mockResolvedValue( + opts.resolve ?? { + accountNumber: '0123456789', + accountName: 'JOHN DOE', + }, + ); + const getBankName = jest + .fn() + .mockResolvedValue( + opts.bankName === undefined ? 'Guaranty Trust Bank' : opts.bankName, + ); + + const prisma = { + user: { findUnique: userFindUnique }, + payout: { findFirst: payoutFindFirst }, + organizerProfile: { update: profileUpdate }, + } as unknown as PrismaService; + const paystack = { + resolveBankAccount, + getBankName, + } as unknown as PaystackService; + const config = { get: () => undefined } as unknown as ConfigService; + + const svc = new OrganizerService( + prisma, + config, + paystack, + {} as unknown as MonnifyProvider, + ); + return { svc, resolveBankAccount, getBankName, profileUpdate }; + } + + it('verifies via Paystack, resolves bank name, persists, returns masked', async () => { + const { svc, resolveBankAccount, profileUpdate } = bankSvc({}); + + const res = await svc.updateBankDetails('u-1', dto); + + expect(resolveBankAccount).toHaveBeenCalledWith('0123456789', '058'); + expect(profileUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'prof-1' }, + data: expect.objectContaining({ + bankCode: '058', + accountName: 'JOHN DOE', + bankVerified: true, + }), + }), + ); + expect(res).toEqual({ + bankName: 'Guaranty Trust Bank', + bankCode: '058', + accountNumber: '012****789', // masked + accountName: 'JOHN DOE', // from Paystack, not the client + bankVerified: true, + updatedAt: new Date('2026-03-12T15:00:00Z'), + }); + }); + + it('rejects with 400 while a payout is PROCESSING (no verify call)', async () => { + const { svc, resolveBankAccount } = bankSvc({ + processingPayout: { id: 'p-1' }, + }); + await expect(svc.updateBankDetails('u-1', dto)).rejects.toThrow( + 'Cannot update bank details while a payout is processing', + ); + expect(resolveBankAccount).not.toHaveBeenCalled(); + }); + + it('rejects a non-organizer with 403', async () => { + const { svc } = bankSvc({ + user: { + id: 'u-1', + role: UserRole.BUYER, + firstName: 'J', + lastName: 'D', + organizerProfile: null, + }, + }); + await expect(svc.updateBankDetails('u-1', dto)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('falls back to the existing bank name when the lookup returns null', async () => { + const { svc } = bankSvc({ bankName: null }); + const res = await svc.updateBankDetails('u-1', dto); + expect(res.bankName).toBe('Old Bank'); + }); +}); diff --git a/src/organizer/organizer.service.ts b/src/organizer/organizer.service.ts index 7cef697..403ccd1 100644 --- a/src/organizer/organizer.service.ts +++ b/src/organizer/organizer.service.ts @@ -9,6 +9,7 @@ import { import { ConfigService } from '@nestjs/config'; import { EventStatus, + PayoutStatus, Prisma, TicketStatus, TransactionStatus, @@ -21,6 +22,7 @@ import { EnableMonnifyDto } from './dto/enable-monnify.dto'; import { EnablePaystackDto } from './dto/enable-paystack.dto'; import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; import { QueryAttendeesDto } from './dto/query-attendees.dto'; +import { UpdateBankDetailsDto } from './dto/update-bank-details.dto'; /** Ticket statuses that count as a completed sale. */ const SOLD_STATUSES: TicketStatus[] = [ @@ -38,6 +40,11 @@ function csvEscape(value: string): string { return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; } +/** Mask a NUBAN as first-3 + **** + last-3 (e.g. 0123456789 → 012****789). */ +function maskAccountNumber(acct: string): string { + return acct.length <= 6 ? acct : `${acct.slice(0, 3)}****${acct.slice(-3)}`; +} + /** Inclusive list of UTC day keys from `from` to `to`. */ function eachDay(from: Date, to: Date): string[] { const out: string[] = []; @@ -542,6 +549,58 @@ export class OrganizerService { return { id: event.id, slug: event.slug }; } + /** + * Update the organizer's settlement bank. Re-verifies the account via + * Paystack (account name is taken from the resolve, never the client), + * resolves the bank's display name, and persists onto OrganizerProfile. + * Blocked while a payout is PROCESSING so funds can't route mid-flight. + * + * NOTE: under split settlement the live settlement bank lives on the + * Paystack/Monnify subaccount; pushing this change to the provider + * subaccount is a deliberate follow-up, not handled here. + */ + async updateBankDetails(userId: string, dto: UpdateBankDetailsDto) { + const { user, profile } = await this.loadOrganizer(userId); + + const processing = await this.prisma.payout.findFirst({ + where: { organizerId: userId, status: PayoutStatus.PROCESSING }, + select: { id: true }, + }); + if (processing) { + throw new BadRequestException( + 'Cannot update bank details while a payout is processing', + ); + } + + const bankData = await this.verifyBankAccount( + user, + dto.accountNumber, + dto.bankCode, + ); + const bankName = await this.paystack.getBankName(dto.bankCode); + + const updated = await this.prisma.organizerProfile.update({ + where: { id: profile.id }, + data: { + bankCode: dto.bankCode, + accountNumber: bankData.accountNumber, + accountName: bankData.accountName, + bankName: bankName ?? profile.bankName, + bankVerified: true, + }, + }); + + this.logger.log(`Bank details updated for user ${userId}`); + return { + bankName: updated.bankName, + bankCode: updated.bankCode, + accountNumber: maskAccountNumber(bankData.accountNumber), + accountName: bankData.accountName, + bankVerified: updated.bankVerified, + updatedAt: updated.updatedAt, + }; + } + async enablePaystack(userId: string, dto: EnablePaystackDto) { const { profile, user } = await this.loadOrganizer(userId); diff --git a/src/paystack/paystack.service.ts b/src/paystack/paystack.service.ts index 2c5fe77..dec8a07 100644 --- a/src/paystack/paystack.service.ts +++ b/src/paystack/paystack.service.ts @@ -12,11 +12,39 @@ export class PaystackService { private readonly secretKey: string; private readonly baseUrl = 'https://api.paystack.co'; + /** Lazy code→name cache for the (effectively static) NG bank list. */ + private bankNamesByCode: Map | null = null; + constructor(private readonly configService: ConfigService) { this.secretKey = this.configService.getOrThrow('paystack.secretKey'); } + /** + * Resolve a bank code to its display name via Paystack's bank list. + * Best-effort: returns null if the list can't be fetched or the code + * isn't found, so callers can still persist the verified account. + */ + async getBankName(bankCode: string): Promise { + try { + if (!this.bankNamesByCode) { + const banks = + await this.request>( + '/bank?currency=NGN', + ); + this.bankNamesByCode = new Map(banks.map((b) => [b.code, b.name])); + } + return this.bankNamesByCode.get(bankCode) ?? null; + } catch (err) { + this.logger.warn( + `Paystack bank-name lookup failed for code ${bankCode}: ${ + err instanceof Error ? err.message : 'unknown error' + }`, + ); + return null; + } + } + private async request(path: string, options?: RequestInit): Promise { const response = await fetch(`${this.baseUrl}${path}`, { ...options,