From 6a0bf42c6537f28c872ab6998a9ecc9eddf8b1f9 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 25 May 2026 23:34:11 +0300 Subject: [PATCH] feat(payments): model payment methods as platform catalog with guest access --- .../migration.sql | 26 ++++++++++++++ apps/edr-passenger-api/prisma/schema.prisma | 19 ++++++++--- apps/edr-passenger-api/prisma/seed.ts | 17 ++++++++++ .../modules/payments/payments.controller.ts | 34 +++++++++++++------ .../src/modules/payments/payments.dto.ts | 24 +++++++++++-- .../src/modules/payments/payments.service.ts | 33 +++++++++++++++--- 6 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql new file mode 100644 index 000000000..0b22437b5 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql @@ -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"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index a6ed4f9e0..342944edb 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -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") } diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 11f3af07c..12b872a0e 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -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' }, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 5c070c8da..3ff1fee32 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -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); } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 8e28783e1..9d8467c3f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -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 { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 0e2fa4a8d..661c0819e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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;