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>
90 lines
3.7 KiB
TypeScript
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 };
|
|
}
|
|
};
|