feat(payments): model payment methods as platform catalog with guest access

This commit is contained in:
Abubeker Yasin
2026-05-25 23:34:11 +03:00
parent 80cea364c7
commit 6a0bf42c65
6 changed files with 131 additions and 22 deletions

View File

@@ -0,0 +1,26 @@
/*
Warnings:
- You are about to drop the column `maskedHint` on the `PaymentMethod` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `PaymentMethod` table. All the data in the column will be lost.
- A unique constraint covering the columns `[type]` on the table `PaymentMethod` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `PaymentMethod` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- DropIndex
DROP INDEX "PaymentMethod_userId_isDefault_idx";
-- AlterTable
ALTER TABLE "PaymentMethod" DROP COLUMN "maskedHint",
DROP COLUMN "userId",
ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'ETB',
ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");

View File

@@ -97,6 +97,15 @@ enum BookingStatus {
@@schema("passenger")
}
enum PaymentRegion {
ETHIOPIA
DJIBOUTI
INTERNATIONAL
GLOBAL
@@schema("passenger")
}
enum PaymentMethodType {
TELEBIRR
CBE_BIRR
@@ -518,14 +527,16 @@ model BookingSeat {
model PaymentMethod {
id String @id @default(uuid())
userId String
type PaymentMethodType
type PaymentMethodType @unique
displayName String
maskedHint String?
region PaymentRegion @default(GLOBAL)
currency String @default("ETB")
providerId String?
isDefault Boolean @default(false)
enabled Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
@@index([userId, isDefault])
updatedAt DateTime @updatedAt
@@schema("passenger")
}

View File

@@ -499,6 +499,23 @@ async function seedSupportingData(seatClasses: any[]) {
],
});
// Supported Payment Methods (platform-wide catalog)
const paymentMethods = [
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 1, isDefault: true },
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 2 },
{ type: 'EBIRR', displayName: 'E-Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 3 },
{ type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI', currency: 'DJF', sortOrder: 4 },
{ type: 'CARD', displayName: 'Credit / Debit Card', region: 'INTERNATIONAL', currency: 'USD', sortOrder: 5 },
{ type: 'WALLET', displayName: 'EDR Wallet', region: 'GLOBAL', currency: 'ETB', sortOrder: 6 },
] as const;
for (const pm of paymentMethods) {
await prisma.paymentMethod.upsert({
where: { type: pm.type as any },
update: { displayName: pm.displayName, region: pm.region as any, currency: pm.currency, sortOrder: pm.sortOrder, enabled: true },
create: { ...pm, region: pm.region as any, type: pm.type as any },
});
}
// Notification Templates
await prisma.notificationTemplate.upsert({
where: { code: 'BOOKING_CONFIRMED' },

View File

@@ -1,13 +1,14 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse } from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto } from './payments.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { RolesGuard } from '../../common/roles.guard';
import { Roles } from '../../common/roles.decorator';
import { UserRole } from '@prisma/client';
@ApiTags('Payment')
@Controller('payments')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class PaymentsController {
constructor(private service: PaymentsService) {}
@@ -40,14 +41,25 @@ export class PaymentsController {
getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); }
@Post('refund')
@ApiOperation({ summary: 'Refund a confirmed booking' })
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Refund a confirmed booking (staff/agent only)' })
refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
@Post('methods')
@ApiOperation({ summary: 'Add a payment method' })
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Add a payment system to the platform catalog (admin only)' })
addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
@Get('methods/:userId')
@ApiOperation({ summary: 'Get payment methods for user' })
getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); }
@Get('methods')
@ApiOperation({
summary: 'List payment systems supported by the platform',
description: 'Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger\'s nationality.',
})
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
}

View File

@@ -1,7 +1,14 @@
import { IsString, IsEnum, IsOptional, IsIn } from 'class-validator';
import { IsString, IsEnum, IsOptional, IsIn, IsBoolean, IsInt } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentIntentStatus } from '@prisma/client';
export enum PaymentRegionEnum {
ETHIOPIA = 'ETHIOPIA',
DJIBOUTI = 'DJIBOUTI',
INTERNATIONAL = 'INTERNATIONAL',
GLOBAL = 'GLOBAL',
}
export enum PaymentMethodTypeEnum {
TELEBIRR = 'TELEBIRR', // Ethiopia
CBE_BIRR = 'CBE_BIRR', // Ethiopia
@@ -33,10 +40,21 @@ export class RefundDto {
}
export class AddPaymentMethodDto {
@ApiProperty() @IsString() userId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum;
@ApiProperty() @IsString() displayName: string;
@ApiPropertyOptional() @IsOptional() @IsString() maskedHint?: string;
@ApiProperty({ enum: PaymentRegionEnum }) @IsEnum(PaymentRegionEnum) region: PaymentRegionEnum;
@ApiPropertyOptional({ example: 'ETB' }) @IsOptional() @IsString() currency?: string;
@ApiPropertyOptional() @IsOptional() @IsString() providerId?: string;
@ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() enabled?: boolean;
@ApiPropertyOptional({ default: 0 }) @IsOptional() @IsInt() sortOrder?: number;
}
export class SupportedPaymentMethodDto {
@ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum;
@ApiProperty({ example: 'Telebirr' }) displayName: string;
@ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum;
@ApiProperty({ example: 'ETB', description: 'Settlement currency for this method' }) currency: string;
@ApiProperty({ description: 'Whether the platform currently accepts this method' }) enabled: boolean;
}
export class ClientActionDto {

View File

@@ -3,8 +3,8 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto } from './payments.dto';
import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto';
import { ClientAction, PaymentProvider, ProviderStatus } from './payments.types';
import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
@@ -282,9 +282,34 @@ export class PaymentsService {
return { refunded: true, bookingRef: booking?.bookingRef };
}
addPaymentMethod(dto: AddPaymentMethodDto) { return this.prisma.paymentMethod.create({ data: dto }); }
addPaymentMethod(dto: AddPaymentMethodDto) {
const data = {
type: dto.type as unknown as PaymentMethodType,
displayName: dto.displayName,
region: dto.region as unknown as PaymentRegion,
currency: dto.currency ?? 'ETB',
providerId: dto.providerId,
enabled: dto.enabled ?? true,
sortOrder: dto.sortOrder ?? 0,
};
return this.prisma.paymentMethod.upsert({
where: { type: data.type },
update: data,
create: data,
});
}
getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); }
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
enabled: true,
...(region
? { region: { in: [region, PaymentRegionEnum.GLOBAL] as unknown as PaymentRegion[] } }
: {}),
},
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
});
}
async finalizePaymentSuccess(input: {
intentId: string;