diff --git a/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql new file mode 100644 index 000000000..fce916917 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql @@ -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; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 2721bb156..cef7d0033 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -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 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 50cd99ec4..9a0288656 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -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 ` diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 3c08eb3cd..6e4be1aa0 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -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, 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 458175fe2..8bf07c6bf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -840,19 +840,46 @@ export class PaymentsService { return { alreadyFinalized: false }; } + private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise { + 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 { - 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 }, diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts new file mode 100644 index 000000000..8b604d905 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -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}`); + } + } +} diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx new file mode 100644 index 000000000..9fb0de6bb --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useState } from 'react'; +import { Send, CheckCircle, XCircle, RotateCcw, PlusCircle } from 'lucide-react'; +import Modal from '@/components/ui/Modal'; +import ActionButton from '@/components/ui/ActionButton'; +import Badge from '@/components/ui/Badge'; +import { formatCurrency, formatDateTime } from '@/lib/utils'; +import { + useSupplementaryCharges, + useCreateSupplementaryCharge, + useMarkSupplementaryPaid, + useWaiveSupplementaryCharge, + useResendSupplementaryLink, +} from './useSupplementaryCharges'; + +type Tab = 'create' | 'list'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER']; + +const STATUS_COLORS: Record = { + PENDING: 'warning', + PAID: 'success', + WAIVED: 'info', + EXPIRED: 'error', +}; + +export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { + const [tab, setTab] = useState('create'); + const [listFilters, setListFilters] = useState({ bookingRef: '', status: '' }); + + // Create form state + const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + const [formError, setFormError] = useState(null); + const [createSuccess, setCreateSuccess] = useState(null); + + const { data: chargesData, isLoading } = useSupplementaryCharges(listFilters); + const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []); + + const createMutation = useCreateSupplementaryCharge(() => { + setCreateSuccess(`Charge created and payment link sent.`); + setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + setFormError(null); + setTimeout(() => { setCreateSuccess(null); setTab('list'); }, 2000); + }); + + const markPaidMutation = useMarkSupplementaryPaid(); + const waiveMutation = useWaiveSupplementaryCharge(); + const resendMutation = useResendSupplementaryLink(); + + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + + const flash = (msg: string) => { + setActionSuccess(msg); + setTimeout(() => setActionSuccess(null), 3000); + }; + + const handleCreate = async () => { + setFormError(null); + const amountMinor = Math.round(parseFloat(form.amountEtb) * 100); + if (!form.bookingRef.trim()) return setFormError('Booking reference is required'); + if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount'); + try { + await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined }); + } catch (e: any) { + setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge'); + } + }; + + const handleMarkPaid = async (id: string) => { + setActionError(null); + try { + await markPaidMutation.mutateAsync({ id }); + flash('Marked as paid'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + const handleWaive = async (id: string) => { + setActionError(null); + try { + await waiveMutation.mutateAsync({ id }); + flash('Charge waived'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + const handleResend = async (id: string) => { + setActionError(null); + try { + await resendMutation.mutateAsync(id); + flash('Payment link resent'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + return ( + + {/* Tabs */} +
+ {(['create', 'list'] as Tab[]).map((t) => ( + + ))} +
+ + {/* ── CREATE TAB ── */} + {tab === 'create' && ( +
+ {createSuccess && ( +
✓ {createSuccess}
+ )} + {formError && ( +
{formError}
+ )} + +
+
+ + setForm({ ...form, bookingRef: e.target.value })} + /> +
+
+ + setForm({ ...form, amountEtb: e.target.value })} + /> +
+
+ + +
+
+ +