mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Fix price on voucher
This commit is contained in:
49
apps/edr-passenger-api/prisma/fix-payment-method-currency.ts
Normal file
49
apps/edr-passenger-api/prisma/fix-payment-method-currency.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* One-off data fix: corrects PaymentMethod.currency for methods whose settlement currency
|
||||
* was never set at seed time and silently defaulted to the schema's ETB default.
|
||||
*
|
||||
* payments.service.ts's chargeCurrency resolution reads this column directly (see the
|
||||
* comment above `chargeCurrency` in `initiatePayment`): WAAFI settles in DJF, CARD in USD.
|
||||
* With WAAFI stuck on the ETB default, live Waafi payments were charged in ETB instead of
|
||||
* being converted to DJF — not just a mislabeled report. This script only touches the
|
||||
* PaymentMethod config row; it does NOT rewrite any existing PaymentIntent/Booking records,
|
||||
* since correcting historical transaction currency is a financial decision, not a data-fix
|
||||
* this script should make unilaterally.
|
||||
*
|
||||
* Safe to re-run. Only updates rows that already exist; does not create new ones.
|
||||
*
|
||||
* Usage: node --env-file=.env -r ts-node/register prisma/fix-payment-method-currency.ts
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const CORRECTIONS: { type: string; currency: string }[] = [
|
||||
{ type: 'WAAFI', currency: 'DJF' },
|
||||
{ type: 'CARD', currency: 'USD' },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
for (const { type, currency } of CORRECTIONS) {
|
||||
const existing = await prisma.paymentMethod.findUnique({ where: { type: type as any } });
|
||||
if (!existing) {
|
||||
console.log(` ⚠️ No PaymentMethod row for ${type} — skipping (nothing to correct).`);
|
||||
continue;
|
||||
}
|
||||
if (existing.currency === currency) {
|
||||
console.log(` ℹ️ ${type} already set to ${currency} — no change.`);
|
||||
continue;
|
||||
}
|
||||
await prisma.paymentMethod.update({ where: { type: type as any }, data: { currency } });
|
||||
console.log(` ✅ ${type}: ${existing.currency} → ${currency}`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ fix-payment-method-currency failed:', e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw, X } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
@@ -56,18 +56,28 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
);
|
||||
|
||||
function PaymentsPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// A link can pre-filter this page — the dashboard's Revenue card links here with
|
||||
// status=SUCCEEDED&bookingStatus=CONFIRMED,BOARDED so "view payments" shows exactly the
|
||||
// payments that make up that revenue figure, not every payment attempt.
|
||||
const initialBookingStatus = searchParams.get('bookingStatus') ?? '';
|
||||
const [pageTab, setPageTab] = useState<PageTab>('payments');
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: searchParams.get('status') ?? '',
|
||||
method: '',
|
||||
bookingStatus: initialBookingStatus,
|
||||
bookingStatus: searchParams.get('bookingStatus') ?? '',
|
||||
});
|
||||
|
||||
// useState's initializer only runs on first mount — if this page was already mounted from
|
||||
// an earlier visit (e.g. the sidebar link), Next's client-side navigation to a new
|
||||
// ?status=...&bookingStatus=... URL does NOT remount the component, so the filters above
|
||||
// would silently keep whatever was set before. Re-sync whenever the URL itself changes.
|
||||
useEffect(() => {
|
||||
const status = searchParams.get('status') ?? '';
|
||||
const bookingStatus = searchParams.get('bookingStatus') ?? '';
|
||||
setFilters((f) => (f.status === status && f.bookingStatus === bookingStatus ? f : { ...f, status, bookingStatus }));
|
||||
}, [searchParams]);
|
||||
const [selectedPayment, setSelectedPayment] = useState<any>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [paymentToDelete, setPaymentToDelete] = useState<any>(null);
|
||||
@@ -319,7 +329,7 @@ function PaymentsPageContent() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters({ ...filters, status: '', bookingStatus: '' })}
|
||||
onClick={() => { setFilters({ ...filters, status: '', bookingStatus: '' }); router.replace('/payments'); }}
|
||||
className="flex items-center gap-1 font-medium hover:underline shrink-0 ml-3"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" /> Clear
|
||||
|
||||
@@ -455,8 +455,11 @@ interface VoucherData {
|
||||
// /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 =
|
||||
// outbound, leg 2 = return), each with that leg's own seat — see bookings.service.ts's
|
||||
// getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when
|
||||
// grouping leg rows back into one passenger below.
|
||||
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; seat?: { number: string; coach: string; seatClass: string } }>;
|
||||
// grouping leg rows back into one passenger below. fareMinor is that specific row's own
|
||||
// fare (ETB minor units) — e.g. a free child's row is 0 even though other passengers on
|
||||
// the same booking paid full fare — mirrored from the same field the booking detail
|
||||
// page's "Fare breakdown" section already reads (booking/detail/page.tsx).
|
||||
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; fareMinor?: number; seat?: { number: string; coach: string; seatClass: string } }>;
|
||||
schedule: VoucherSchedule;
|
||||
returnSchedule?: VoucherSchedule | null;
|
||||
totalMinor: number;
|
||||
@@ -475,32 +478,46 @@ interface VoucherData {
|
||||
}
|
||||
|
||||
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
||||
// The amount shown is always a single raw field straight from the API — the settled
|
||||
// payment amount when available, otherwise the booking total — never a derived value
|
||||
// (previously this fell back to Math.round(totalMinor / passengers.length), which
|
||||
// doesn't correspond to any real field and could disagree with what was actually
|
||||
// charged). Same value on every passenger's voucher; no /100, no per-passenger split.
|
||||
// Currency always comes straight from the booking/payment data, never hardcoded — the
|
||||
// settled payment currency when a payment has settled, otherwise the booking's own
|
||||
// display currency (falling back to the internal ETB currency field).
|
||||
const settledAmountMinor = booking.payment?.amountMinor;
|
||||
const settledCurrency = booking.payment?.currency;
|
||||
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
||||
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
||||
// Use displayTotalMinor when available so the voucher shows the passenger's currency amount.
|
||||
const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
||||
// Basis total for the currency this voucher displays — the settled payment amount when
|
||||
// available, otherwise the display-currency total (falling back to the raw ETB total).
|
||||
const voucherBasisTotal = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
||||
// Ratio that converts a passenger's own ETB fareMinor into the same currency/amount basis
|
||||
// as voucherBasisTotal above — e.g. if the settled payment is 60% of the ETB total (a
|
||||
// currency conversion), each passenger's own ETB fare is scaled by that same 60% ratio.
|
||||
// This is NOT an equal split: two passengers with different fares still get different
|
||||
// scaled amounts, in exact proportion to what each of them actually paid. Falls back to no
|
||||
// scaling (ratio 1) only when the totals are missing or already equal, mirroring the same
|
||||
// fallback the booking detail page's "Fare breakdown" section uses for this identical ratio.
|
||||
const etbTotalMinor = booking.totalMinor;
|
||||
const fareScaleFactor =
|
||||
!etbTotalMinor || !voucherBasisTotal || etbTotalMinor === voucherBasisTotal
|
||||
? 1
|
||||
: voucherBasisTotal / etbTotalMinor;
|
||||
|
||||
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
||||
|
||||
// Group leg rows back into one entry per real passenger — without this, a round trip
|
||||
// produced two half-passenger vouchers (one per leg, each showing only its own leg's
|
||||
// seat) instead of one voucher per passenger covering both legs.
|
||||
// seat) instead of one voucher per passenger covering both legs. Each leg row's own
|
||||
// fareMinor is summed here too, so a round trip's voucher reflects both legs' fares
|
||||
// and a one-way voucher reflects just its single row's fare.
|
||||
type SeatInfo = VoucherData['passengers'][number]['seat'];
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
|
||||
{ fullName: string; category: string; fareMinor: number; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
|
||||
>();
|
||||
booking.passengers.forEach((p) => {
|
||||
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
|
||||
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined };
|
||||
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, fareMinor: 0, outboundSeat: undefined, returnSeat: undefined };
|
||||
entry.fareMinor += p.fareMinor ?? 0;
|
||||
if (p.leg === 2) entry.returnSeat = p.seat;
|
||||
else entry.outboundSeat = p.seat;
|
||||
grouped.set(key, entry);
|
||||
@@ -522,6 +539,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
||||
// if it doesn't match what's actually on file.
|
||||
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
||||
|
||||
// This passenger's own fare, scaled onto the same currency/amount basis as the rest of
|
||||
// the voucher — not the booking's overall total, and not an equal share of it.
|
||||
const passengerFareMinor = Math.round(p.fareMinor * fareScaleFactor);
|
||||
|
||||
await generatePassengerVoucherPDF({
|
||||
bookingRef: booking.bookingRef,
|
||||
ticketNumber,
|
||||
@@ -536,7 +557,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
||||
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
|
||||
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
||||
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
|
||||
fareMinor: voucherFareMinor,
|
||||
fareMinor: passengerFareMinor,
|
||||
currency: voucherCurrency,
|
||||
fareIsMajorUnits: useSettledAmount,
|
||||
createdAt: booking.createdAt,
|
||||
|
||||
Reference in New Issue
Block a user