Building Vehicash: Architecture Decisions for a Nigerian Mobility-Tech Startup
The real cost of a bad architecture decision on a platform that touches money is not a failed test — it is a financial edge case you discover in production. Here are the decisions that shaped Vehicash, and what each one was actually optimizing for.
Start with the Lifecycle, Not the Stack
There is a discipline that financially complex products demand before a single line of code is written: mapping every state a piece of data can be in, and every transition between those states.
Not just the happy path. The cancellations, the partial completions, the cases that seem unlikely until they are not. What happens when an advertiser funds a campaign and no vehicles are matched? When an active campaign is cancelled mid-flight? When a driver completes three of four campaign weeks and the remaining budget is not enough for a full payout?
Every undefined case becomes an implicit state — a place where future developers, or future bugs, will have to make a decision you avoided. The output of this exercise is a state machine with explicit preconditions and a clear owner for each transition. It is a document, not code, and it should precede every schema design and every API contract.
The specific lesson: the places where states are ambiguous or two states seem equivalent are exactly where production incidents live.
Phone-First Authentication
Most authentication systems default to email as the primary identifier. Vehicash does not, and the decision was deliberate.
Vehicash's driver-side audience is vehicle owners across Nigerian cities — commercial operators, ride-share drivers, logistics riders. A meaningful portion of this demographic does not actively use email. Building email-first authentication for them means introducing friction at the very first interaction, before the user has seen what the product does.
The solution is phone number as the primary credential. Registration requires only a phone number, verified via OTP. Email is an optional upgrade — users can add and verify an email address to enable email-and-password login and account recovery, but it is never required.
This changes the data model in a consequential way. phoneNumber is the required unique identifier; email is a nullable, separately-verified field. The auth service handles both flows independently, and the access token carries information about which credential was used.
The point worth noting: this is not a technical limitation or a workaround. It is a product decision made concrete in the schema. The schema should reflect who your users actually are.
Progressive Onboarding
A two-sided marketplace with distinct roles faces a structural onboarding problem. Drivers and advertisers have different information requirements, and neither should encounter a form that asks for everything at once.
Vehicash has two distinct onboarding paths based on the role selected at registration.
For drivers: personal identity first, vehicle details in a separate subsequent step. The platform never asks for vehicle documentation before establishing who you are. For advertisers: personal identity first, then business details — with the business step branching based on registrant type. An individual advertiser has lighter requirements than a registered company.
Progressive onboarding serves two purposes. It reduces drop-off by keeping each step cognitively bounded. And it makes questions feel appropriate to the moment — you are asked for things when there is a clear reason to need them, not because the system has one long intake form.
Onboarding state is persisted server-side, so partial completions are resumable. A user who stops halfway through vehicle registration picks up exactly where they left off.
Financial Data Design
The most consequential architectural decision for any platform that handles money is how you model and move funds. The approach here is separation and explicit transfer records.
Advertiser wallets, campaign escrow accounts, and driver earnings are distinct models. No balance is ever updated without a corresponding transaction record that captures the amount, the direction, the source, and the reason. There are no in-place balance updates without a linked event.
When a campaign is funded, the budget is atomically debited from the advertiser's wallet and locked into a campaign-specific escrow account. Both operations happen inside a database transaction — they either both succeed or neither does:
await prisma.$transaction(async (tx) => {
const wallet = await tx.advertiserWallet.findUniqueOrThrow({
where: { advertiserId },
});
if (wallet.balance < campaign.totalBudget) {
throw new BadRequestException("Insufficient wallet balance");
}
await tx.advertiserWallet.update({
where: { advertiserId },
data: { balance: { decrement: campaign.totalBudget } },
});
await tx.campaignEscrow.create({
data: {
campaignId: campaign.id,
totalLocked: campaign.totalBudget,
released: 0,
remaining: campaign.totalBudget,
},
});
});Funds release from escrow in weekly tranches as activity milestones are verified. The release amount per driver per week is computed from fixed inputs — campaign budget, duration, driver route category — and not recalculated at payout time. There is no discretionary math when money actually moves.
One deliberate simplification: unused campaign funds return to the advertiser as platform credit rather than processed refunds. Refunds introduce timing uncertainty and processing overhead. Credit is immediate and keeps the advertiser in the ecosystem. It is also a testable assumption about what advertisers prefer — and that is the right framing. Simplifications at this stage should be conscious bets, not accidents.
The underlying principle: financial state should be derivable entirely from the transfer log. Current balances should be computable by summing transactions, not trusted as a primary source. This makes auditing straightforward and debugging payment discrepancies a query problem rather than a reconstruction exercise.
GPS-Gated Compensation
Driver compensation in Vehicash is activity-based, not time-based. Drivers earn by completing verified route activity during the campaign period — not simply by having a sticker on their vehicle.
This design choice creates a data quality dependency: compensation decisions are only as reliable as the GPS data feeding them.
The compensation model is a weekly threshold: drivers who cross a verified activity minimum for the week earn their full weekly allocation. Below threshold, no payout for that week. The binary threshold is a deliberate simplification — it reduces payout calculation complexity while ensuring compensation tracks genuine activity.
The GPS pipeline has three layers that are kept architecturally separate: collection, validation, and aggregation. Collection is background location tracking on the mobile app. Validation applies noise filtering and discards points that are clearly anomalous — GPS on mid-range Android devices is noisy, especially in dense urban areas. Aggregation runs on a schedule, computing weekly totals per driver per campaign and enqueuing payout records for processing.
The separation matters for operations. When a driver disputes a payout, you can inspect each layer independently. A gap in the collection layer is a different problem from a validation error or an aggregation bug, and treating them as different problems is what allows you to resolve them.
One forward-looking design note: raw GPS readings accumulate quickly at scale — an active driver generates hundreds of records per day. The right long-term storage for this data is a time-series system, not the primary relational database. The data model is designed with this migration in mind. The raw points table is treated as append-only, and the rest of the system depends on aggregated daily summaries rather than the raw records directly. The migration path is planned, not improvised.
The Matching Engine
An early design for vehicle matching used binary quality tiers: vehicles were classified as Premium or Standard based on inspection criteria. Advertisers set a minimum tier and the engine filtered accordingly.
This failed in design review. Binary tiers create cliff effects. A vehicle just below the tier boundary receives no campaigns while an essentially equivalent vehicle just above it receives all of them. The threshold is invisible to drivers, so there is no actionable feedback for improvement. Advertisers have no way to express genuine preference within a tier.
The replacement is a continuous condition score assigned during inspection. Advertisers set a minimum score threshold. The matching engine produces a ranked list of eligible vehicles, sorted by score, route coverage, vehicle type, and owner reliability. A human reviewer confirms the match before offers go to drivers.
The continuous model preserves nuance on both sides. Drivers have a score they understand and can improve toward. Advertisers can express meaningful preference rather than accepting a binary. And the human review step means no irrevocable matching decisions are made without accountability.
This is not just a better algorithm — it is a better product model for the platform.
On Building Financially Complex Systems
The practices that keep a complex financial system manageable come down to making implicit things explicit before they become implicit bugs: state machines before schemas, transfer records before balance updates, explicit preconditions before state transitions.
One of the best sequencing decisions was building the internal operations tooling early. Operations-facing screens ship faster when the users can tolerate rough edges and give direct feedback. Real operational data and real feedback from early use informed each subsequent feature in ways that design documents alone cannot.
Architecture that holds up is not necessarily architecture that was clever on day one. It is architecture that put the right seams in the right places before the complexity arrived — so that adding the next feature means extending a structure, not working around one.