Fix price on voucher

Fix price on voucher
This commit is contained in:
robiman
2026-08-14 08:25:12 +03:00
committed by GitHub
3 changed files with 98 additions and 18 deletions

View 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();
});

View File

@@ -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

View File

@@ -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,