diff --git a/apps/edr-freight-api/py/prod-check-invoices-payments.sql b/apps/edr-freight-api/py/prod-check-invoices-payments.sql new file mode 100644 index 000000000..fa94c481d --- /dev/null +++ b/apps/edr-freight-api/py/prod-check-invoices-payments.sql @@ -0,0 +1,114 @@ +-- ============================================================================ +-- Production DB drift check + fix for the batch/window flow. +-- +-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which +-- queries freight.invoices.payments (a jsonb ledger added by migration +-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on +-- production (snapshot/restore drift — the migration can read as "applied" in +-- freight.migrations while the DDL never took effect), every reserve() throws +-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass, +-- and you see exactly: +-- * only ONE booking gets a pay window (the loop dies after the first reserve +-- whose invoice sync throws), and +-- * reservations never expire cleanly (the settle path hits the same query). +-- +-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2 +-- (idempotent, additive — safe to run even if partially applied). +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing, +-- production has the drift and STEP 2 is required. +-- --------------------------------------------------------------------------- +SELECT column_name +FROM information_schema.columns +WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name IN ( + 'payments', 'subtotal_amount', 'tax_amount', + 'paid_amount', 'balance_amount', 'paid_at' + ) +ORDER BY column_name; +-- Also confirm the enum has the partial-payment statuses: +SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status; +-- Expect ISSUED and PARTIALLY_PAID to be present. + + +-- --------------------------------------------------------------------------- +-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns. +-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so +-- re-running is safe. Wrapped so the enum additions (which cannot run inside a +-- transaction block with immediate use) are applied first, then the columns. +-- --------------------------------------------------------------------------- + +-- Enum values (no-op if they already exist). +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING'; +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID'; + +-- Money-tracking + payments ledger columns. +ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + +-- Backfill derived money fields for existing rows (only rows not already set). +UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount + WHERE subtotal_amount = 0 AND balance_amount = 0; + +UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = COALESCE(paid_at, updated_at) + WHERE status = 'PAID' AND paid_amount = 0; + +-- --------------------------------------------------------------------------- +-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should +-- now be present. After this, deploy the freight_feature/usermanagement branch +-- and the batch will reserve ALL fitting bookings + expire non-payers + top up. +-- --------------------------------------------------------------------------- + + +-- ============================================================================ +-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid +-- invoices.payments can hide OTHER columns the batch flow selects. reserve() +-- and settleReserved() load the FULL Booking entity, so ANY missing booking +-- column throws mid-loop (e.g. we already hit +-- `column Booking.consolidation_resume_status does not exist`). This lists every +-- booking column the entity expects that is MISSING from production — expect +-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied. +-- ============================================================================ +WITH expected(col) AS ( + SELECT unnest(ARRAY[ + 'reference','customer_id','company_id','company_profile_id','is_government', + 'government_institution','train_id','status','contract_id','contract_route_id', + 'booking_type','contract_kind','created_by_role','created_by_user_id', + 'scheduled_date','estimated_shipment_date','expires_at','total_amount', + 'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason', + 'contract_validity_days','contract_valid_from','contract_valid_until', + 'payment_status','contract_type','service_type_id','customs_clearing_enabled', + 'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id', + 'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id', + 'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity', + 'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at', + 'pricing_breakdown','locked_at','priority_score','consolidation_partner_id', + 'consolidation_resume_status','wagons_required','scheduling_status', + 'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id', + 'loaded_at','arrived_at','payment_deadline','selected_for_batch_at', + 'gl_station_yard_id','clearance_current_phase','duty_required', + 'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason', + 'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at' + ]) +) +SELECT e.col AS missing_booking_column +FROM expected e +LEFT JOIN information_schema.columns c + ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col +WHERE c.column_name IS NULL +ORDER BY e.col; +-- If any rows come back, tell me which columns — I'll give you the exact +-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent). diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index d064197d5..e2c7bffcc 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -455,8 +455,6 @@ function PriceConfirmModal({ const hasPairingBlock = pairingErrors.length > 0; const capacityErrors = validation?.capacityErrors ?? []; const hasCapacityBlock = capacityErrors.length > 0; - const confirmDisabled = - loading || validationLoading || hasPairingBlock || hasCapacityBlock; // Authoritative server breakdown — the SAME BookingPricingService pass that // prices the booking on create, so it carries every line the booking will be @@ -479,6 +477,17 @@ function PriceConfirmModal({ }; }, [validation, baseTotal]); + // Block confirm until the authoritative server price is in hand. The client + // baseTotal fallback is display-only; booking on it (e.g. after a validation + // error clears validationLoading with no data) would let the customer confirm + // an un-validated, possibly wrong price. + const confirmDisabled = + loading || + validationLoading || + hasPairingBlock || + hasCapacityBlock || + !serverTotal; + // Fallback while the server preview loads: the contract's frozen unit rates // (container/bulk + hazard/reefer only) with the overweight surcharge folded // in. Replaced by the full server breakdown the moment it arrives.