Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Abubeker Yasin
2026-07-16 12:06:00 +03:00
16 changed files with 1393 additions and 177 deletions

View File

@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "SupplementaryCharge" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"paymentToken" TEXT NOT NULL,
"providerTxnId" TEXT,
"notes" TEXT,
"createdBy" TEXT NOT NULL,
"paidAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SupplementaryCharge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SupplementaryCharge_paymentToken_key" ON "SupplementaryCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "SupplementaryCharge_bookingId_idx" ON "SupplementaryCharge"("bookingId");
-- CreateIndex
CREATE INDEX "SupplementaryCharge_paymentToken_idx" ON "SupplementaryCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "SupplementaryCharge_status_idx" ON "SupplementaryCharge"("status");
-- AddForeignKey
ALTER TABLE "SupplementaryCharge" ADD CONSTRAINT "SupplementaryCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -567,6 +567,7 @@ model Booking {
cancellation BookingCancellation?
baggage BaggageBooking[]
excessBaggageCharges ExcessBaggageCharge[]
supplementaryCharges SupplementaryCharge[]
journey Journey?
@@index([passengerId, status])
@@ -1234,6 +1235,28 @@ model BaggageBooking {
@@schema("passenger")
}
model SupplementaryCharge {
id String @id @default(uuid())
bookingId String
reason String // e.g. "UNDERPAYMENT", "FARE_CORRECTION"
amountMinor Int
currency String @default("ETB")
status String @default("PENDING") // PENDING | PAID | WAIVED | EXPIRED
paymentToken String @unique @default(uuid())
providerTxnId String?
notes String?
createdBy String
paidAt DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId])
@@index([paymentToken])
@@index([status])
@@schema("passenger")
}
model ExcessBaggageCharge {
id String @id @default(uuid())
bookingId String

View File

@@ -39,12 +39,34 @@ import {
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
import { SupplementaryChargesService } from "./supplementary-charges.service";
import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
class CreateSupplementaryChargeDto {
@ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string;
@ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number;
@ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
class WaiveSupplementaryChargeDto {
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
class PaySupplementaryChargeDto {
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile';
}
@ApiTags("Payment")
@Controller("payments")
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class PaymentsController {
constructor(private service: PaymentsService) {}
constructor(
private service: PaymentsService,
private supplementaryService: SupplementaryChargesService,
) {}
@Delete(":id")
@PassengerStaff([PASSENGER_PERMS.admin])
@@ -315,6 +337,92 @@ export class PaymentsController {
}
}
// ── Supplementary Charges ──────────────────────────────────────────────────
@Post('supplementary')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
createSupplementaryCharge(
@Body() dto: CreateSupplementaryChargeDto,
@Headers('x-iam-user-id') iamUserId?: string,
) {
return this.supplementaryService.create({
...dto,
createdBy: iamUserId ?? 'staff',
});
}
@Get('supplementary')
@PassengerStaff([PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List supplementary charges (staff only)' })
@ApiQuery({ name: 'bookingRef', required: false })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
listSupplementaryCharges(
@Query('bookingRef') bookingRef?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.supplementaryService.getAll({
bookingRef,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20,
});
}
@Get('supplementary/by-token/:token')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Get supplementary charge by payment token (public — for self-pay page)' })
getSupplementaryByToken(@Param('token') token: string) {
return this.supplementaryService.getByToken(token);
}
@Post('supplementary/by-token/:token/pay')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' })
paySupplementaryCharge(
@Param('token') token: string,
@Body() dto: PaySupplementaryChargeDto,
) {
return this.supplementaryService.pay(token, dto.method, dto.platform);
}
@Post('supplementary/:id/mark-paid')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
markSupplementaryPaid(
@Param('id') id: string,
@Body() body: { providerTxnId?: string },
) {
return this.supplementaryService.markPaid(id, body.providerTxnId);
}
@Post('supplementary/:id/waive')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
waiveSupplementaryCharge(
@Param('id') id: string,
@Body() dto: WaiveSupplementaryChargeDto,
@Headers('x-iam-user-id') iamUserId?: string,
) {
return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff');
}
@Post('supplementary/:id/resend')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
resendSupplementaryLink(@Param('id') id: string) {
return this.supplementaryService.resendLink(id);
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, """);
return `<!DOCTYPE html>

View File

@@ -12,6 +12,7 @@ import {
} from "@edr/types";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { SupplementaryChargesService } from "./supplementary-charges.service";
import { InternalPaymentsController } from "./internal-payments.controller";
import { PaymentClientService } from "./payment-client.service";
import { PaymentEventsConsumer } from "./payment-events.consumer";
@@ -21,6 +22,8 @@ import { TicketsModule } from "../tickets/tickets.module";
import { CurrencyModule } from "../currency/currency.module";
import { AuditModule } from "../../common/audit.module";
import { NotificationsModule } from "../notifications/notifications.module";
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
function rabbitMQImport(): DynamicModule[] {
@@ -55,8 +58,7 @@ function rabbitMQImport(): DynamicModule[] {
TicketsModule,
CurrencyModule,
AuditModule,
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
NotificationsModule,
HttpModule.register({
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
}),
@@ -65,6 +67,7 @@ function rabbitMQImport(): DynamicModule[] {
controllers: [PaymentsController, InternalPaymentsController],
providers: [
PaymentsService,
SupplementaryChargesService,
PaymentClientService,
PaymentEventsConsumer,
ServiceAuthGuard,

View File

@@ -840,19 +840,46 @@ export class PaymentsService {
return { alreadyFinalized: false };
}
private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise<MarkPaidResponseDto> {
if (event.eventType === 'payment.failed') {
this.logger.warn(`supplementary charge ${event.referenceId} payment failed`);
return { processed: true };
}
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } });
if (!charge) {
this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`);
return { processed: false, reason: 'charge-not-found' };
}
if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true };
await this.prisma.supplementaryCharge.update({
where: { id: charge.id },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
return { processed: true };
}
async handlePaymentEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
if (
event.service !== PaymentServiceEnum.PASSENGER ||
event.referenceType !== PaymentReferenceType.BOOKING
) {
if (event.service !== PaymentServiceEnum.PASSENGER) {
this.logger.warn(
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
);
return { processed: false, reason: "foreign-reference" };
}
if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
return this.handleSupplementaryChargeEvent(event);
}
if (event.referenceType !== PaymentReferenceType.BOOKING) {
this.logger.warn(
`mark-paid: ignoring unknown referenceType ${event.referenceType}`,
);
return { processed: false, reason: "foreign-reference" };
}
if (event.eventType === "payment.failed") {
const intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: event.referenceId },

View File

@@ -0,0 +1,193 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { EmailClientService } from '../notifications/email-client.service';
import { PaymentClientService } from './payment-client.service';
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
@Injectable()
export class SupplementaryChargesService {
private readonly logger = new Logger(SupplementaryChargesService.name);
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private smsClient: SmsClientService,
private emailClient: EmailClientService,
private paymentClient: PaymentClientService,
) {}
async create(dto: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
createdBy: string;
}) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef: dto.bookingRef },
include: { passenger: { include: { user: true } } },
});
if (!booking) throw new NotFoundException('Booking not found');
if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) {
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to raise a supplementary charge');
}
if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive');
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
const charge = await this.prisma.supplementaryCharge.create({
data: {
bookingId: booking.id,
reason: dto.reason,
amountMinor: dto.amountMinor,
notes: dto.notes ?? null,
createdBy: dto.createdBy,
expiresAt,
},
});
const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
await this.sendLink(charge, booking.bookingRef, phone, email);
await this.auditService.log({
action: 'CREATE',
entityType: 'SupplementaryCharge',
entityId: charge.id,
newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason },
});
return charge;
}
async getAll(filters: { bookingRef?: string; status?: string; page?: number; pageSize?: number }) {
const { bookingRef, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (status) where.status = status;
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
await this.prisma.supplementaryCharge.updateMany({
where: { status: 'PENDING', expiresAt: { lt: new Date() } },
data: { status: 'EXPIRED' },
});
const [items, total] = await Promise.all([
this.prisma.supplementaryCharge.findMany({
where,
include: { booking: { select: { bookingRef: true, status: true, contactPhone: true, contactEmail: true } } },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.supplementaryCharge.count({ where }),
]);
return { items, total, page, pageSize };
}
async getByToken(token: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { paymentToken: token },
include: { booking: { select: { bookingRef: true } } },
});
if (!charge) throw new NotFoundException('Payment link not found');
if (charge.status === 'PAID') throw new BadRequestException('This charge has already been paid');
if (charge.status === 'WAIVED') throw new BadRequestException('This charge has been waived');
if (charge.status === 'EXPIRED' || (charge.expiresAt && new Date() > charge.expiresAt)) {
if (charge.status === 'PENDING') {
await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, data: { status: 'EXPIRED' } });
}
throw new BadRequestException('This payment link has expired');
}
return charge;
}
async markPaid(id: string, providerTxnId?: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') return charge;
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } });
return updated;
}
async pay(token: string, method: string, platform?: 'web' | 'mobile') {
const charge = await this.getByToken(token); // validates status/expiry
const paymentMethod = method as ProviderMethod;
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
referenceId: charge.id,
orderRef: `SC-${charge.id.substring(0, 8)}`,
amountMinor: charge.amountMinor,
currency: charge.currency,
provider: paymentMethod,
platform,
returnUrl,
failureUrl,
});
return snapshot;
}
async waive(id: string, notes: string, waivedBy: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') throw new BadRequestException('Cannot waive a paid charge');
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
data: { status: 'WAIVED', notes },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } });
return updated;
}
async resendLink(id: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { id },
include: { booking: { select: { bookingRef: true, contactPhone: true, contactEmail: true } } },
});
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status !== 'PENDING') throw new BadRequestException('Can only resend link for PENDING charges');
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
});
await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail);
return { sent: true };
}
private async sendLink(charge: any, bookingRef: string, phone: string | null, email: string | null) {
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const payUrl = `${portalUrl}/pay-balance/${charge.paymentToken}`;
const amount = (charge.amountMinor / 100).toFixed(2);
const msg = `EDR: A balance of ${amount} ETB is outstanding for booking ${bookingRef}. Pay here: ${payUrl}`;
if (phone) {
try { await this.smsClient.sendSms({ to: phone, message: msg }); }
catch (err) { this.logger.warn(`SMS failed for supplementary charge ${charge.id}: ${err}`); }
}
if (email) {
try {
await this.emailClient.sendEmail({
to: email,
subject: `EDR — Outstanding balance for booking ${bookingRef}`,
text: msg,
});
} catch (err) { this.logger.warn(`Email failed for supplementary charge ${charge.id}: ${err}`); }
}
if (!phone && !email) {
this.logger.warn(`No contact info for supplementary charge ${charge.id}`);
}
}
}