Files
waseel/lib/settlement.ts
T
KrikoriosandClaude Opus 5 8807ff41c5 Waseel: driver capture, chat/calls, dispatch, and session fixes
Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:17:55 +03:00

90 lines
3.7 KiB
TypeScript

// Who is holding the money, and who still has to hand it over.
//
// A completed ride splits into a driver payout and a platform fee, but the
// split alone doesn't say whether anyone has actually been paid. That depends
// on how the rider paid, because it decides who ends up holding the cash:
//
// Card — the rider pays the platform. The company already has its fee the
// moment the card settles, and now OWES THE DRIVER their payout.
//
// Cash — the driver takes the whole fare at the kerb. They already have
// their payout in their pocket, and now OWE THE COMPANY its fee.
//
// Unpaid — a cash ride the driver couldn't collect. Nobody has been paid and
// nothing is owed between them; the fare itself is simply lost.
//
// So "has the company collected?" has no single answer per ride — it's one
// question for card rides and the opposite question for cash ones. This module
// is the single place that knows the difference, so the admin ledger, the
// driver's balance and the settle endpoint can't drift apart.
/** Payment states in which a completed ride actually produced money. */
export const SETTLED_PAYMENT_STATUSES = ["paid", "cash_collected"] as const;
export const isPaidRide = (paymentStatus: string): boolean =>
(SETTLED_PAYMENT_STATUSES as readonly string[]).includes(paymentStatus);
/** Which side of a ride's money a settlement action refers to. */
export const SETTLEMENT_SIDES = ["platform_fee", "driver_payout"] as const;
export type SettlementSide = (typeof SETTLEMENT_SIDES)[number];
export const isSettlementSide = (v: unknown): v is SettlementSide =>
typeof v === "string" && (SETTLEMENT_SIDES as readonly string[]).includes(v);
export type RideMoney = {
status: string;
payment_status: string;
platform_fee_cents: number | null;
driver_payout_cents: number | null;
platform_fee_settled_at: string | null;
driver_payout_settled_at: string | null;
};
/**
* What a single completed ride still owes, and to whom.
*
* `companyOwedCents` is money the company is waiting on — a cash ride whose
* fee the driver hasn't remitted. `driverOwedCents` is money the company still
* has to pay out — a card ride the driver hasn't been paid for. A ride that
* never happened, or was never paid for, owes nothing in either direction.
*/
export const rideBalance = (
ride: RideMoney,
): { companyOwedCents: number; driverOwedCents: number } => {
if (ride.status !== "completed" || !isPaidRide(ride.payment_status)) {
return { companyOwedCents: 0, driverOwedCents: 0 };
}
const fee = ride.platform_fee_cents ?? 0;
const payout = ride.driver_payout_cents ?? 0;
return {
companyOwedCents: ride.platform_fee_settled_at === null ? fee : 0,
driverOwedCents: ride.driver_payout_settled_at === null ? payout : 0,
};
};
/**
* The settlement timestamps a ride should be born with, given how it was paid.
*
* Whoever physically ends up holding their own share is settled the instant
* the ride completes — there is no transfer left to make. Only the other side
* is left outstanding, and that's the one somebody has to act on.
*/
export const initialSettlement = (
paymentStatus: string,
): { platformFeeSettled: boolean; driverPayoutSettled: boolean } => {
switch (paymentStatus) {
// Company holds the fare: its fee is in hand, the driver is owed.
case "paid":
return { platformFeeSettled: true, driverPayoutSettled: false };
// Driver holds the fare: their payout is in hand, the company is owed.
case "cash_collected":
return { platformFeeSettled: false, driverPayoutSettled: true };
// Nobody was paid; there is nothing to settle between them.
default:
return { platformFeeSettled: false, driverPayoutSettled: false };
}
};