import { requireOwner, withCors, preflight } from "@/lib/admin"; import { query, sql, type SqlValue } from "@/lib/db"; import { isSettlementSide } from "@/lib/settlement"; // Recording that money actually changed hands. // // Two different real-world events, one endpoint: // // side='platform_fee' — a driver handed the company its cut of the cash // fares they collected. Clears what THEY owe US. // side='driver_payout' — the company paid a driver for the card rides they // drove. Clears what WE owe THEM. // // Settling is deliberately idempotent and one-way: a row already stamped is // skipped rather than re-stamped, so a double-tap on "mark paid" can't rewrite // when the money moved. Reversing a mistake is a separate, explicit action // (`undo: true`) so it can't happen by accident. export async function OPTIONS(request: Request) { return preflight(request); } type Body = { side?: string; /** Settle everything outstanding for this driver. */ driver_id?: number; /** Or settle these specific rides. */ ride_ids?: number[]; /** Free-text reference: a transfer id, a receipt number, "cash in office". */ note?: string; /** Reverse a settlement recorded in error. */ undo?: boolean; }; export async function POST(request: Request) { const auth = await requireOwner(request); if ("error" in auth) return withCors(request, auth.error); let body: Body; try { body = (await request.json()) as Body; } catch { return withCors( request, Response.json({ error: "Invalid JSON body." }, { status: 400 }), ); } if (!isSettlementSide(body.side)) { return withCors( request, Response.json( { error: "side must be 'platform_fee' or 'driver_payout'." }, { status: 400 }, ), ); } const rideIds = Array.isArray(body.ride_ids) ? body.ride_ids.map(Number).filter(Number.isInteger) : []; const driverId = Number(body.driver_id); const hasDriver = Number.isInteger(driverId); if (!hasDriver && rideIds.length === 0) { return withCors( request, Response.json( { error: "Provide either driver_id or a non-empty ride_ids array." }, { status: 400 }, ), ); } // Column names come from the validated `side`, never from raw input. const column = body.side === "platform_fee" ? "platform_fee_settled_at" : "driver_payout_settled_at"; const amountColumn = body.side === "platform_fee" ? "platform_fee_cents" : "driver_payout_cents"; // Only one payment type produces a transfer that a human has to make, and // it's the opposite one for each side: // // platform_fee — owed only on CASH rides. On a card ride the company // already holds its fee; there is nothing to collect. // driver_payout — owed only on CARD rides. On a cash ride the driver // already has their share in hand. // // Scoping to that payment type is what keeps an undo honest. Without it, // reversing one collected cash commission also cleared the automatically // settled fees on that driver's card rides, and the ledger then told the // operator to go and collect money the company had never been without. const payableStatus = body.side === "platform_fee" ? "cash_collected" : "paid"; const undo = body.undo === true; const params: SqlValue[] = [payableStatus]; const conds: string[] = [ "status = 'completed'", // Only money that actually materialised can be settled: an uncollected // cash fare owes nobody anything and must never appear as settled. "payment_status = $1", // Idempotent in both directions — already-settled rows are skipped when // settling, already-clear rows when undoing. undo ? `${column} IS NOT NULL` : `${column} IS NULL`, ]; if (hasDriver) { params.push(driverId); conds.push(`driver_id = $${params.length}`); } if (rideIds.length > 0) { params.push(`{${rideIds.join(",")}}`); conds.push(`ride_id = ANY($${params.length}::int[])`); } try { params.push(body.note?.trim() ? body.note.trim().slice(0, 500) : null); const noteParam = params.length; const rows = await query<{ ride_id: number; amount: number }>( `UPDATE rides SET ${column} = ${undo ? "NULL" : "CURRENT_TIMESTAMP"}, settlement_note = COALESCE($${noteParam}, settlement_note) WHERE ${conds.join(" AND ")} RETURNING ride_id, COALESCE(${amountColumn}, 0) AS amount`, params, ); const totalCents = rows.reduce((sum, r) => sum + Number(r.amount), 0); return withCors( request, Response.json({ data: { side: body.side, undone: undo, rides: rows.length, ride_ids: rows.map((r) => r.ride_id), total_cents: totalCents, }, }), ); } catch (error) { console.error("[ADMIN_SETTLE]: ", error); return withCors( request, Response.json({ error: "Internal Server Error" }, { status: 500 }), ); } } // GET — the outstanding ledger. // // Without arguments: one row per driver, answering the two questions an // operator has at the end of a shift — which drivers owe us cash commission, // and which drivers are we behind on paying. // // With ?driver_id=N&side=platform_fee: the individual rides making up that // balance, so a part-payment can be recorded against the exact trips it // covers. A driver handing over three of yesterday's five fares is a normal // thing to happen, and settling all five because the UI only offered // all-or-nothing would put the ledger out of step with the cash. export async function GET(request: Request) { const auth = await requireOwner(request); if ("error" in auth) return withCors(request, auth.error); try { const url = new URL(request.url); const driverParam = Number(url.searchParams.get("driver_id")); const sideParam = url.searchParams.get("side"); if (Number.isInteger(driverParam) && sideParam !== null) { if (!isSettlementSide(sideParam)) { return withCors( request, Response.json( { error: "side must be 'platform_fee' or 'driver_payout'." }, { status: 400 }, ), ); } // Mirrors the POST handler's rules exactly: only the payment type that // actually leaves a transfer outstanding for this side is listed, so the // picker can never show a ride that settling would refuse to touch. const settledColumn = sideParam === "platform_fee" ? "platform_fee_settled_at" : "driver_payout_settled_at"; const amountColumn = sideParam === "platform_fee" ? "platform_fee_cents" : "driver_payout_cents"; const payableStatus = sideParam === "platform_fee" ? "cash_collected" : "paid"; const rides = await query<{ ride_id: number; amount_cents: number; fare_price: number; origin_address: string; destination_address: string; completed_at: string; }>( `SELECT ride_id, COALESCE(${amountColumn}, 0) AS amount_cents, fare_price, origin_address, destination_address, completed_at FROM rides WHERE driver_id = $1 AND status = 'completed' AND payment_status = $2 AND ${settledColumn} IS NULL ORDER BY completed_at DESC`, [driverParam, payableStatus], ); return withCors( request, Response.json({ data: { side: sideParam, driver_id: driverParam, rides, total_cents: rides.reduce( (sum, r) => sum + Number(r.amount_cents), 0, ), }, }), ); } const rows = await sql<{ driver_id: number; name: string; owes_company_cents: number; owed_to_driver_cents: number; unsettled_rides: number; }>` SELECT d.id AS driver_id, TRIM(COALESCE(d.first_name,'') || ' ' || COALESCE(d.last_name,'')) AS name, COALESCE(SUM(r.platform_fee_cents) FILTER (WHERE r.platform_fee_settled_at IS NULL), 0)::int AS owes_company_cents, COALESCE(SUM(r.driver_payout_cents) FILTER (WHERE r.driver_payout_settled_at IS NULL), 0)::int AS owed_to_driver_cents, COUNT(*)::int AS unsettled_rides FROM drivers d JOIN rides r ON r.driver_id = d.id WHERE r.status = 'completed' AND r.payment_status IN ('paid','cash_collected') AND (r.platform_fee_settled_at IS NULL OR r.driver_payout_settled_at IS NULL) GROUP BY d.id, d.first_name, d.last_name ORDER BY owes_company_cents DESC, owed_to_driver_cents DESC `; return withCors(request, Response.json({ data: rows })); } catch (error) { console.error("[ADMIN_SETTLE_GET]: ", error); return withCors( request, Response.json({ error: "Internal Server Error" }, { status: 500 }), ); } }