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
19 changes: 19 additions & 0 deletions src/organizer/dto/update-bank-details.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 15 additions & 0 deletions src/organizer/organizer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
HttpStatus,
Param,
Post,
Put,
Query,
Res,
} from '@nestjs/common';
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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)
Expand Down
129 changes: 129 additions & 0 deletions src/organizer/organizer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
});
});
59 changes: 59 additions & 0 deletions src/organizer/organizer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { ConfigService } from '@nestjs/config';
import {
EventStatus,
PayoutStatus,
Prisma,
TicketStatus,
TransactionStatus,
Expand All @@ -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[] = [
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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);

Expand Down
28 changes: 28 additions & 0 deletions src/paystack/paystack.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | null = null;

constructor(private readonly configService: ConfigService) {
this.secretKey =
this.configService.getOrThrow<string>('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<string | null> {
try {
if (!this.bankNamesByCode) {
const banks =
await this.request<Array<{ name: string; code: string }>>(
'/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<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
Expand Down
Loading