Partner payments API
The routes behind the partner portal's Payments page and the Pay online / I have paid buttons on a partner's booking. Rules: PTR-081 (a claim), PTR-093 (the web portal), FIN-032 (a claim stays pending). Screen: Partners → Payments on the web portal.
Handler: handlePartnerPayments in src/lib/partnerPayments.ts, loaded on demand by
apiFetch (like /screens/*), so the API chunk every screen downloads does not carry it.
Client: src/services/partnerPaymentsService.ts.
Who may call them
A partner's login (role = agent). There is no matrix permission
(PERMISSIONS.md §4.5): the database decides by ownership.
partner_submit_payment()takes the agency from the session (auth_agent_id()) and refuses a booking or invoice that is not the agency's.razorpay-orderasksonline_payment_actor(), with the caller's own session, whether the caller is the booking's agency.- The reads run as the partner, under the agency row security the app reads through
(PTR-010); the handler also keeps only rows whose
Booking.agentIdorGroupInvoice.payerIdis the agency, so a login that is also a traveller does not see its own trips here.
GET /portals/agent/payments
Everything the page shows, from six reads sent side by side — one round trip
(PRF-010): partner_my_account(),
the agency's bookings (latest 500), its group invoices (latest 200), payments pending or
verified (latest 200), payments rejected in the last 90 days (latest 50, with
rejectionReason) and online orders still created in the last hour.
{
"account": { "agentId": "…", "company": "…", "partnerCode": "BP-0001", "status": "active",
"currency": "INR", "creditLimit": 500000, "outstanding": 180000, "creditLeft": 320000 },
"bookingsDue": [{ "id": "…", "bookingNo": "BK-…", "customerName": "…", "groupName": "…",
"departureDate": "2026-10-20", "currency": "INR", "total": 105000, "paid": 20000,
"balance": 85000, "pending": 10000, "claimable": 75000, "payableOnline": true, "awaitingApproval": false,
"onlineStarted": null }],
"invoicesDue": [{ "id": "…", "invoiceNumber": "INV-…", "groupName": "…", "issuedAt": "…", "dueDate": "…",
"currency": "INR", "total": 90000, "balanceDue": 90000, "pending": 0, "claimable": 90000 }],
"pending": [{ "id": "…", "amount": 10000, "method": "upi", "reference": "…", "status": "pending",
"online": false, "createdAt": "…", "verifiedAt": null,
"target": { "kind": "booking", "id": "…", "label": "Booking BK-…" } }],
"recent": [ … the same shape, status "verified", the latest 20 … ],
"rejected": [ … the same shape, status "rejected", decided in the last 90 days, the latest 20,
with "rejectionReason": "No credit for UTR … on our statement" … ]
}
-
Every row has
rejectionReason; it isnullexcept on a rejected claim (COMM-037). A rejected claim is not counted inpendingorclaimable. -
outstandingisportal_agent_outstanding(), as the app shows it. creditLeftisnullwhen no credit limit is set.balanceis the booking's agreed due — price + GST − receipts − cancellation credit (computed fieldbooking_agreed_due), approved voucher or not — whatrazorpay-ordercaps an online payment at (FIN-033).awaitingApprovalis true while finance has not approved the booking voucher; the page then shows "Awaiting finance approval — ₹X will be due".claimableis the agreed due less the claims waiting — whatpartner_submit_paymentwill still accept. For an invoice,balanceDueless the claims waiting.payableOnlineis false for a booking not priced in rupees.- Cancelled and refused bookings, draft, paid and cancelled invoices and credit notes are left out.
404 when the login is not linked to a partner.
POST /portals/agent/payments — I have paid
Input — { amount, method, reference?, bookingId? , groupInvoiceId? }: exactly one of
bookingId and groupInvoiceId; method one of bank_transfer, upi, cheque, cash,
card. The route refuses a missing or double target, an unknown method or an amount of zero
with 400 before the database; everything else is partner_submit_payment()'s decision:
| Refusal | Status | Sentence (the database's) |
|---|---|---|
| more than is still due | 409 | "Amount is more than the outstanding balance of … (including payments awaiting verification)" |
| no reference on a non-cash payment | 400 | "A upi payment needs its reference (UTR, UPI id, cheque number)" |
| a cancelled or refused booking; an invoice not issued | 409 | "Payments cannot be submitted for a cancelled booking" / "This invoice is paid and cannot be paid" |
| another agency's booking or invoice | 403 | "You do not have access to this booking" |
Output — { id, status: "pending", amount, currency }, or
{ id, status: "pending", amount, duplicate: true } for the same claim sent again within two
minutes (recorded once). The claim's note reads "Submitted by partner through the app" for web
and app alike.
Not accepted: a payment date, a note, a receipt file. The function takes none of them.
POST /portals/agent/payments/online-order — Pay online
Input — { bookingId, amount } (rupees). Forwards to
razorpay-order with
purpose: "Partner portal (web)" and answers with its order
(orderId, amount in paise, keyId, prefill, …). Its refusals come through with their status and
sentence — including 503 "Online payment is not switched on yet — …" until the Razorpay keys
are set.
The page then opens Razorpay Checkout (src/lib/razorpayCheckout.ts). The payment is recorded
by razorpay-webhook, never by the page.
GET /portals/agent/payments/online-order/:id
{ status: "paid" | "created", paymentId }: paid once OnlinePaymentOrder.status is paid
or a verified Payment carries that razorpayOrderId. The page asks every three seconds for up
to a minute after Checkout reports success.
Where to look
| Concern | Path |
|---|---|
| Routes | src/lib/partnerPayments.ts (+ api.partnerPayments.test.ts) |
| Page | src/pages/partner/PartnerPayments.tsx (+ .test.tsx) |
| Dialogs | src/components/partner/PartnerPaymentDialogs.tsx |
| Checkout in the browser | src/lib/razorpayCheckout.ts (+ .test.ts); the CSP hosts in public/_headers (Security headers) |
| The claim function | partner_submit_payment() — supabase/migrations/20260929140000_profiles_and_a_paused_account.sql; test supabase/tests/the_partner_in_the_app.sql |