mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(payments): model payment methods as platform catalog with guest access
This commit is contained in:
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user