mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import {
|
||||
@@ -18,7 +19,15 @@ const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||
export class PaymentEventsConsumer {
|
||||
private readonly logger = new Logger(PaymentEventsConsumer.name);
|
||||
|
||||
constructor(private readonly paymentsService: PaymentsService) {}
|
||||
// IMPORTANT: do NOT constructor-inject PaymentsService here. It is a REQUEST/TRANSIENT-scoped
|
||||
// provider (its scope bubbles up from a scoped dependency), so it has no singleton instance at
|
||||
// bootstrap. Constructor-injecting it makes THIS consumer scoped too — and golevelup binds the
|
||||
// @RabbitSubscribe handler to the singleton instance it discovers at bootstrap. With no such
|
||||
// instance, the subscription still registers but delivered messages are never dispatched to
|
||||
// handle(): they pile up unacked and the booking never confirms. Injecting only the lightweight
|
||||
// (singleton) ModuleRef keeps this consumer a clean singleton; PaymentsService is resolved per
|
||||
// message via resolve() (get() throws for scoped providers).
|
||||
constructor(private readonly moduleRef: ModuleRef) {}
|
||||
|
||||
@IsPublic()
|
||||
@RabbitSubscribe({
|
||||
@@ -37,7 +46,13 @@ export class PaymentEventsConsumer {
|
||||
`RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`,
|
||||
);
|
||||
try {
|
||||
const result = await this.paymentsService.handlePaymentEvent(
|
||||
// resolve() (not get()) because PaymentsService is scoped — get() throws for scoped providers.
|
||||
const paymentsService = await this.moduleRef.resolve(
|
||||
PaymentsService,
|
||||
undefined,
|
||||
{ strict: false },
|
||||
);
|
||||
const result = await paymentsService.handlePaymentEvent(
|
||||
event as unknown as PaymentEventDto,
|
||||
);
|
||||
this.logger.log(
|
||||
|
||||
@@ -154,6 +154,13 @@ export class IntentStatusDto {
|
||||
@ApiPropertyOptional() paidAt?: string;
|
||||
@ApiPropertyOptional() failureCode?: string;
|
||||
@ApiPropertyOptional() failureMessage?: string;
|
||||
@ApiPropertyOptional({
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
"Raw provider payload (initiation response merged with the latest status query) for inspection/debugging. Provider-specific shape; never trusted for state.",
|
||||
})
|
||||
providerResponse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class BookingAmountResponseDto {
|
||||
|
||||
@@ -432,6 +432,9 @@ export class PaymentsService {
|
||||
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
|
||||
failureCode: snapshot.failureCode ?? null,
|
||||
failureMessage: snapshot.failureMessage ?? null,
|
||||
rawInitiation: snapshot.providerResponse
|
||||
? (snapshot.providerResponse as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
};
|
||||
return this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId },
|
||||
@@ -589,6 +592,10 @@ export class PaymentsService {
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
providerResponse:
|
||||
intent.rawInitiation && typeof intent.rawInitiation === "object"
|
||||
? (intent.rawInitiation as Record<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send, CheckCircle, XCircle, RotateCcw, PlusCircle } from 'lucide-react';
|
||||
import { 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';
|
||||
import { useCreateSupplementaryCharge } from './useSupplementaryCharges';
|
||||
|
||||
type Tab = 'create' | 'list';
|
||||
const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER'];
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER'];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: 'warning',
|
||||
PAID: 'success',
|
||||
WAIVED: 'info',
|
||||
EXPIRED: 'error',
|
||||
};
|
||||
|
||||
export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('create');
|
||||
const [listFilters, setListFilters] = useState({ bookingRef: '', status: '' });
|
||||
|
||||
// Create form state
|
||||
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [createSuccess, setCreateSuccess] = useState<string | null>(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.`);
|
||||
setCreateSuccess('Charge created and payment link sent.');
|
||||
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
|
||||
setFormError(null);
|
||||
setTimeout(() => { setCreateSuccess(null); setTab('list'); }, 2000);
|
||||
setTimeout(() => { setCreateSuccess(null); onClose(); }, 2000);
|
||||
});
|
||||
|
||||
const markPaidMutation = useMarkSupplementaryPaid();
|
||||
const waiveMutation = useWaiveSupplementaryCharge();
|
||||
const resendMutation = useResendSupplementaryLink();
|
||||
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
|
||||
const flash = (msg: string) => {
|
||||
setActionSuccess(msg);
|
||||
setTimeout(() => setActionSuccess(null), 3000);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setFormError(null);
|
||||
const amountMinor = Math.round(parseFloat(form.amountEtb) * 100);
|
||||
@@ -73,218 +37,46 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Supplementary Charges" size="xl">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-5 border-b border-muted">
|
||||
{(['create', 'list'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-4 py-2 text-sm font-medium capitalize border-b-2 transition-colors ${
|
||||
tab === t
|
||||
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t === 'create' ? '+ Raise Charge' : 'All Charges'}
|
||||
</button>
|
||||
))}
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Raise Supplementary Charge" size="lg">
|
||||
<div className="space-y-4">
|
||||
{createSuccess && (
|
||||
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200">✓ {createSuccess}</div>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{formError}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
|
||||
<input className="input" placeholder="e.g. EDR-20240001" value={form.bookingRef} onChange={(e) => setForm({ ...form, bookingRef: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
|
||||
<input className="input" type="number" min="0.01" step="0.01" placeholder="e.g. 50.00" value={form.amountEtb} onChange={(e) => setForm({ ...form, amountEtb: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Reason <span className="text-red-500">*</span></label>
|
||||
<select className="input" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })}>
|
||||
{REASONS.map((r) => <option key={r} value={r}>{r.replace('_', ' ')}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="label">Notes (optional)</label>
|
||||
<textarea className="input resize-none" rows={2} placeholder="e.g. Passenger paid 350 ETB, correct fare is 400 ETB" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={onClose}>Cancel</ActionButton>
|
||||
<ActionButton icon={PlusCircle} onClick={handleCreate} loading={createMutation.isPending}>Raise Charge</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── CREATE TAB ── */}
|
||||
{tab === 'create' && (
|
||||
<div className="space-y-4">
|
||||
{createSuccess && (
|
||||
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200">✓ {createSuccess}</div>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{formError}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="e.g. EDR-20240001"
|
||||
value={form.bookingRef}
|
||||
onChange={(e) => setForm({ ...form, bookingRef: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="e.g. 50.00"
|
||||
value={form.amountEtb}
|
||||
onChange={(e) => setForm({ ...form, amountEtb: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Reason <span className="text-red-500">*</span></label>
|
||||
<select className="input" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })}>
|
||||
{REASONS.map((r) => <option key={r} value={r}>{r.replace('_', ' ')}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="label">Notes (optional)</label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
rows={2}
|
||||
placeholder="e.g. Passenger paid 350 ETB, correct fare is 400 ETB"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setTab('list')}>Cancel</ActionButton>
|
||||
<ActionButton icon={PlusCircle} onClick={handleCreate} loading={createMutation.isPending}>
|
||||
Raise Charge
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── LIST TAB ── */}
|
||||
{tab === 'list' && (
|
||||
<div className="space-y-4">
|
||||
{actionSuccess && (
|
||||
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200">✓ {actionSuccess}</div>
|
||||
)}
|
||||
{actionError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{actionError}</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Booking Ref</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search booking ref…"
|
||||
value={listFilters.bookingRef}
|
||||
onChange={(e) => setListFilters({ ...listFilters, bookingRef: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select className="input" value={listFilters.status} onChange={(e) => setListFilters({ ...listFilters, status: e.target.value })}>
|
||||
<option value="">All</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="PAID">Paid</option>
|
||||
<option value="WAIVED">Waived</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">Loading…</p>
|
||||
) : charges.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">No supplementary charges found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-muted">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2">Booking</th>
|
||||
<th className="px-3 py-2">Amount</th>
|
||||
<th className="px-3 py-2">Reason</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">Created</th>
|
||||
<th className="px-3 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-muted">
|
||||
{charges.map((c: any) => (
|
||||
<tr key={c.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs">{c.booking?.bookingRef ?? c.bookingId.substring(0, 8)}</td>
|
||||
<td className="px-3 py-2 font-semibold">{formatCurrency(c.amountMinor, c.currency ?? 'ETB')}</td>
|
||||
<td className="px-3 py-2 text-xs">{c.reason}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="status" status={STATUS_COLORS[c.status] ?? c.status}>{c.status}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{formatDateTime(c.createdAt)}</td>
|
||||
<td className="px-3 py-2">
|
||||
{c.status === 'PENDING' && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
title="Mark paid"
|
||||
onClick={() => handleMarkPaid(c.id)}
|
||||
className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600"
|
||||
>
|
||||
<CheckCircle size={15} />
|
||||
</button>
|
||||
<button
|
||||
title="Waive"
|
||||
onClick={() => handleWaive(c.id)}
|
||||
className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500"
|
||||
>
|
||||
<XCircle size={15} />
|
||||
</button>
|
||||
<button
|
||||
title="Resend link"
|
||||
onClick={() => handleResend(c.id)}
|
||||
className="p-1 rounded hover:bg-blue-100 dark:hover:bg-blue-900/30 text-blue-500"
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={onClose}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Eye, Trash2, AlertCircle } from 'lucide-react';
|
||||
import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
@@ -11,6 +11,21 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { paymentsApi, apiClient } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import SupplementaryChargesModal from './SupplementaryChargesModal';
|
||||
import {
|
||||
useSupplementaryCharges,
|
||||
useMarkSupplementaryPaid,
|
||||
useWaiveSupplementaryCharge,
|
||||
useResendSupplementaryLink,
|
||||
} from './useSupplementaryCharges';
|
||||
|
||||
type PageTab = 'payments' | 'supplementary';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: 'warning',
|
||||
PAID: 'success',
|
||||
WAIVED: 'info',
|
||||
EXPIRED: 'error',
|
||||
};
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
@@ -26,6 +41,7 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
);
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [pageTab, setPageTab] = useState<PageTab>('payments');
|
||||
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
|
||||
const [selectedPayment, setSelectedPayment] = useState<any>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
@@ -40,6 +56,33 @@ export default function PaymentsPage() {
|
||||
});
|
||||
const [supplementaryOpen, setSupplementaryOpen] = useState(false);
|
||||
|
||||
// Supplementary tab state
|
||||
const [suppFilters, setSuppFilters] = useState({ bookingRef: '', status: '' });
|
||||
const [suppActionError, setSuppActionError] = useState<string | null>(null);
|
||||
const [suppActionSuccess, setSuppActionSuccess] = useState<string | null>(null);
|
||||
const { data: chargesData, isLoading: loadingCharges } = useSupplementaryCharges(suppFilters);
|
||||
const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []);
|
||||
const markPaidMutation = useMarkSupplementaryPaid();
|
||||
const waiveMutation = useWaiveSupplementaryCharge();
|
||||
const resendMutation = useResendSupplementaryLink();
|
||||
|
||||
const flashSupp = (msg: string) => { setSuppActionSuccess(msg); setTimeout(() => setSuppActionSuccess(null), 3000); };
|
||||
const handleMarkPaid = async (id: string) => {
|
||||
setSuppActionError(null);
|
||||
try { await markPaidMutation.mutateAsync({ id }); flashSupp('Marked as paid'); }
|
||||
catch (e: any) { setSuppActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); }
|
||||
};
|
||||
const handleWaive = async (id: string) => {
|
||||
setSuppActionError(null);
|
||||
try { await waiveMutation.mutateAsync({ id }); flashSupp('Charge waived'); }
|
||||
catch (e: any) { setSuppActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); }
|
||||
};
|
||||
const handleResend = async (id: string) => {
|
||||
setSuppActionError(null);
|
||||
try { await resendMutation.mutateAsync(id); flashSupp('Payment link resent'); }
|
||||
catch (e: any) { setSuppActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); }
|
||||
};
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -137,11 +180,103 @@ export default function PaymentsPage() {
|
||||
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Supplementary Charges</ActionButton>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
{pageTab === 'supplementary' && (
|
||||
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Raise Charge</ActionButton>
|
||||
)}
|
||||
{pageTab === 'payments' && (
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page-level tabs */}
|
||||
<div className="flex gap-1 border-b border-muted">
|
||||
{(['payments', 'supplementary'] as PageTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setPageTab(t)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
pageTab === t
|
||||
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t === 'payments' ? 'Payments' : 'Supplementary Charges'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── SUPPLEMENTARY TAB ── */}
|
||||
{pageTab === 'supplementary' && (
|
||||
<div className="space-y-4">
|
||||
{suppActionSuccess && (
|
||||
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200">✓ {suppActionSuccess}</div>
|
||||
)}
|
||||
{suppActionError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{suppActionError}</div>
|
||||
)}
|
||||
<div className="card grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Booking Ref</label>
|
||||
<input className="input" placeholder="Search booking ref…" value={suppFilters.bookingRef} onChange={(e) => setSuppFilters({ ...suppFilters, bookingRef: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select className="input" value={suppFilters.status} onChange={(e) => setSuppFilters({ ...suppFilters, status: e.target.value })}>
|
||||
<option value="">All</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="PAID">Paid</option>
|
||||
<option value="WAIVED">Waived</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{loadingCharges ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">Loading…</p>
|
||||
) : charges.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">No supplementary charges found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-muted">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2">Booking</th>
|
||||
<th className="px-3 py-2">Amount</th>
|
||||
<th className="px-3 py-2">Reason</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">Created</th>
|
||||
<th className="px-3 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-muted">
|
||||
{charges.map((c: any) => (
|
||||
<tr key={c.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs">{c.booking?.bookingRef ?? c.bookingId.substring(0, 8)}</td>
|
||||
<td className="px-3 py-2 font-semibold">{formatCurrency(c.amountMinor, c.currency ?? 'ETB')}</td>
|
||||
<td className="px-3 py-2 text-xs">{c.reason}</td>
|
||||
<td className="px-3 py-2"><Badge variant="status" status={STATUS_COLORS[c.status] ?? c.status}>{c.status}</Badge></td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{formatDateTime(c.createdAt)}</td>
|
||||
<td className="px-3 py-2">
|
||||
{c.status === 'PENDING' && (
|
||||
<div className="flex gap-1">
|
||||
<button title="Mark paid" onClick={() => handleMarkPaid(c.id)} className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600"><CheckCircle size={15} /></button>
|
||||
<button title="Waive" onClick={() => handleWaive(c.id)} className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500"><XCircle size={15} /></button>
|
||||
<button title="Resend link" onClick={() => handleResend(c.id)} className="p-1 rounded hover:bg-blue-100 dark:hover:bg-blue-900/30 text-blue-500"><RotateCcw size={15} /></button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PAYMENTS TAB ── */}
|
||||
{pageTab === 'payments' && (
|
||||
<>
|
||||
<div className="card">
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">✓ {successMessage}</div>
|
||||
@@ -285,6 +420,9 @@ export default function PaymentsPage() {
|
||||
error={deleteError ?? undefined}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
<SupplementaryChargesModal isOpen={supplementaryOpen} onClose={() => setSupplementaryOpen(false)} />
|
||||
|
||||
{/* Export Modal */}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { XCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PayBalanceFailedPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment failed</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Your payment could not be completed. Please try again.
|
||||
</p>
|
||||
<Link href={`/pay-balance/${token}`} className="btn-primary inline-block px-6 py-2.5 font-semibold">
|
||||
Try again
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { PaymentMethod } from "@/types";
|
||||
import {
|
||||
Loader2,
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
Wallet,
|
||||
Landmark,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
const getIconForMethod = (methodId: string) => {
|
||||
if (methodId.includes("CARD")) return CreditCard;
|
||||
if (methodId.includes("WALLET")) return Wallet;
|
||||
if (methodId.includes("CAC")) return Landmark;
|
||||
return Smartphone;
|
||||
};
|
||||
|
||||
export default function PayBalancePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const router = useRouter();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
|
||||
const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({
|
||||
queryKey: ["supplementary-charge", token],
|
||||
queryFn: () => apiClient.get<any>(`/payments/supplementary/by-token/${token}`),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: paymentMethods = [], isLoading: loadingMethods } = useQuery<PaymentMethod[]>({
|
||||
queryKey: ["paymentMethods"],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<PaymentMethod[]>("/payments/methods");
|
||||
return Array.isArray(res) ? res : [];
|
||||
},
|
||||
enabled: !!charge,
|
||||
});
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: string) =>
|
||||
apiClient.post<any>(`/payments/supplementary/by-token/${token}/pay`, {
|
||||
method,
|
||||
platform: "web",
|
||||
}),
|
||||
onSuccess: (data: any) => {
|
||||
if (data?.clientAction?.type === "REDIRECT") {
|
||||
window.location.href = data.clientAction.url;
|
||||
return;
|
||||
}
|
||||
// Immediate success (e.g. wallet)
|
||||
router.push(`/pay-balance/${token}/success`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setPaymentError(
|
||||
err?.response?.data?.message ?? err?.message ?? "Payment failed. Please try again."
|
||||
);
|
||||
setIsProcessing(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handlePay = () => {
|
||||
if (!selectedMethod) return;
|
||||
setIsProcessing(true);
|
||||
setPaymentError(null);
|
||||
payMutation.mutate(selectedMethod);
|
||||
};
|
||||
|
||||
if (loadingCharge) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-10 h-10 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chargeError || !charge) {
|
||||
const msg = (chargeError as any)?.response?.data?.message ?? "This payment link is invalid or has expired.";
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<AlertCircle className="w-14 h-14 text-red-500 mx-auto" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Link unavailable</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{msg}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const amountDisplay = (charge.amountMinor / 100).toFixed(2);
|
||||
const currency = charge.currency ?? "ETB";
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Outstanding balance</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Booking <span className="font-semibold text-gray-700 dark:text-gray-300">{charge.booking?.bookingRef}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Charge summary */}
|
||||
<div className="card space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Reason</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{charge.reason}</span>
|
||||
</div>
|
||||
{charge.notes && (
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 shrink-0">Notes</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 text-right">{charge.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100">Amount due</span>
|
||||
<span className="text-2xl font-bold text-primary">{currency} {amountDisplay}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment methods */}
|
||||
<div className="card space-y-3">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100">Select payment method</h2>
|
||||
{loadingMethods ? (
|
||||
<div className="flex items-center justify-center py-6 gap-2">
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{paymentMethods.filter((m) => m.enabled).map((method) => {
|
||||
const Icon = getIconForMethod(method.type);
|
||||
const isSelected = selectedMethod === method.type;
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => setSelectedMethod(method.type)}
|
||||
disabled={isProcessing}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/8 dark:bg-primary/15 shadow-md"
|
||||
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50"
|
||||
} ${isProcessing ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? "bg-primary" : "bg-gray-100 dark:bg-gray-700"}`}>
|
||||
<Icon className={`w-5 h-5 ${isSelected ? "text-white" : "text-primary"}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
|
||||
</div>
|
||||
{isSelected && <CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paymentError && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm text-center">⚠️ {paymentError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${currency} ${amountDisplay}`
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">🔒 Secure & encrypted payment</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PayBalanceSuccessPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment successful</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Your outstanding balance has been settled. Thank you.
|
||||
</p>
|
||||
<Link href="/" className="btn-primary inline-block px-6 py-2.5 font-semibold">
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export interface ProviderResultInput {
|
||||
confirmedAmountMinor?: number;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
/** Raw provider status-query body, merged into the intent's audit payload when present. */
|
||||
rawResponse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -357,6 +359,7 @@ export class IntentsService {
|
||||
providerTxnId: status.providerTxnId,
|
||||
failureCode: status.failureCode,
|
||||
failureMessage: status.failureMessage,
|
||||
rawResponse: status.rawResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -388,6 +391,15 @@ export class IntentsService {
|
||||
return { alreadyTerminal: true };
|
||||
}
|
||||
|
||||
// Keep the audit payload current with the latest provider status body (surfaced as
|
||||
// `providerResponse` in the snapshot). Merged so the initiation keys are preserved.
|
||||
if (result.rawResponse) {
|
||||
intent.rawInitiation = {
|
||||
...(intent.rawInitiation ?? {}),
|
||||
statusResponse: result.rawResponse,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const paidAt = result.paidAt ?? new Date();
|
||||
intent.status = ProviderPaymentStatus.SUCCEEDED;
|
||||
@@ -482,6 +494,7 @@ export class IntentsService {
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
expiresAt: intent.expiresAt?.toISOString(),
|
||||
providerResponse: intent.rawInitiation ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,13 @@ export type PaymentIntentSnapshot ={
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
expiresAt?: string;
|
||||
/**
|
||||
* Raw provider payload for inspection/debugging — the audit copy of the provider
|
||||
* initiation response merged with the latest status-query response (secrets redacted
|
||||
* upstream). Not a contract with the provider; shape is provider-specific. Never trusted
|
||||
* for state decisions — the state machine drives `status`.
|
||||
*/
|
||||
providerResponse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PaymentEventType = "payment.succeeded" | "payment.failed";
|
||||
|
||||
Reference in New Issue
Block a user