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';
|
'use client';
|
||||||
|
|
||||||
import { Suspense, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw, X } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
@@ -56,18 +56,28 @@ const SectionHeader = ({ title }: { title: string }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
function PaymentsPageContent() {
|
function PaymentsPageContent() {
|
||||||
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
// A link can pre-filter this page — the dashboard's Revenue card links here with
|
// 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
|
// status=SUCCEEDED&bookingStatus=CONFIRMED,BOARDED so "view payments" shows exactly the
|
||||||
// payments that make up that revenue figure, not every payment attempt.
|
// payments that make up that revenue figure, not every payment attempt.
|
||||||
const initialBookingStatus = searchParams.get('bookingStatus') ?? '';
|
|
||||||
const [pageTab, setPageTab] = useState<PageTab>('payments');
|
const [pageTab, setPageTab] = useState<PageTab>('payments');
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
search: '',
|
search: '',
|
||||||
status: searchParams.get('status') ?? '',
|
status: searchParams.get('status') ?? '',
|
||||||
method: '',
|
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 [selectedPayment, setSelectedPayment] = useState<any>(null);
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [paymentToDelete, setPaymentToDelete] = useState<any>(null);
|
const [paymentToDelete, setPaymentToDelete] = useState<any>(null);
|
||||||
@@ -319,7 +329,7 @@ function PaymentsPageContent() {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="flex items-center gap-1 font-medium hover:underline shrink-0 ml-3"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" /> Clear
|
<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 =
|
// /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
|
// 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
|
// getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when
|
||||||
// grouping leg rows back into one passenger below.
|
// grouping leg rows back into one passenger below. fareMinor is that specific row's own
|
||||||
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; seat?: { number: string; coach: string; seatClass: string } }>;
|
// 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;
|
schedule: VoucherSchedule;
|
||||||
returnSchedule?: VoucherSchedule | null;
|
returnSchedule?: VoucherSchedule | null;
|
||||||
totalMinor: number;
|
totalMinor: number;
|
||||||
@@ -475,32 +478,46 @@ interface VoucherData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
||||||
// The amount shown is always a single raw field straight from the API — the settled
|
// Currency always comes straight from the booking/payment data, never hardcoded — the
|
||||||
// payment amount when available, otherwise the booking total — never a derived value
|
// settled payment currency when a payment has settled, otherwise the booking's own
|
||||||
// (previously this fell back to Math.round(totalMinor / passengers.length), which
|
// display currency (falling back to the internal ETB currency field).
|
||||||
// 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.
|
|
||||||
const settledAmountMinor = booking.payment?.amountMinor;
|
const settledAmountMinor = booking.payment?.amountMinor;
|
||||||
const settledCurrency = booking.payment?.currency;
|
const settledCurrency = booking.payment?.currency;
|
||||||
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||||
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
||||||
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
||||||
// Use displayTotalMinor when available so the voucher shows the passenger's currency amount.
|
// Basis total for the currency this voucher displays — the settled payment amount when
|
||||||
const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
// 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;
|
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
||||||
|
|
||||||
// Group leg rows back into one entry per real passenger — without this, a round trip
|
// 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
|
// 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'];
|
type SeatInfo = VoucherData['passengers'][number]['seat'];
|
||||||
const grouped = new Map<
|
const grouped = new Map<
|
||||||
string,
|
string,
|
||||||
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
|
{ fullName: string; category: string; fareMinor: number; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
|
||||||
>();
|
>();
|
||||||
booking.passengers.forEach((p) => {
|
booking.passengers.forEach((p) => {
|
||||||
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
|
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;
|
if (p.leg === 2) entry.returnSeat = p.seat;
|
||||||
else entry.outboundSeat = p.seat;
|
else entry.outboundSeat = p.seat;
|
||||||
grouped.set(key, entry);
|
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.
|
// if it doesn't match what's actually on file.
|
||||||
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
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({
|
await generatePassengerVoucherPDF({
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
@@ -536,7 +557,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
|||||||
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
|
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
|
||||||
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
||||||
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
|
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
|
||||||
fareMinor: voucherFareMinor,
|
fareMinor: passengerFareMinor,
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
fareIsMajorUnits: useSettledAmount,
|
fareIsMajorUnits: useSettledAmount,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user