VDrive
June 2025 - Present
- role
- Solo engineer
- scale
- 850+ invoices/year
- impact
- ~15 h/month to <30 min
Overview
VDrive is my father’s car servicing business in Edinburgh. When it started in June 2025, everything ran on paper. Mechanics wrote each job into a notebook, and at the end of every month I typed those handwritten records into Excel, collected supplier invoices, and assembled the accounts by hand. That took around 15 hours a month, and the pile grew with the business.
I built the software to make that work disappear. The whole operation now runs on a single TypeScript monorepo deployed to Cloudflare Workers: a public marketing site, a Telegram bot the mechanics use to record jobs and manage bookings, and an accounting pipeline that processes over 850 supplier invoices a year. Monthly bookkeeping takes under 30 minutes. Nobody transcribes anything.
One constraint shaped every design decision. The owner is not technical, and the mechanics could not be asked to learn new software. Whatever I built had to work with zero training, or it would not get used at all.
The system
Two Workers do the heavy lifting. A private email worker receives supplier invoices and booking emails through Cloudflare Email Routing and forwards structured payloads to the main SvelteKit app over service bindings. The app serves the trilingual public site (English, Ukrainian, Russian), handles the Telegram webhook, and runs the accounting flows. Invoices are hashed for deduplication, extracted with a two-pass LLM pipeline, stored in R2, and indexed in PostgreSQL. Redis holds bot conversation state between stateless Worker invocations, and FreeAgent sits at the end of the chain for customer invoicing and OAuth-gated accounting access.
Telegram is the entire interface
There is no admin dashboard. The mechanics already lived in Telegram, so the bot became the operations UI: recording jobs, managing the booking queue, generating invoices, running payroll.
Making a bot feel like an application took a reusable form engine. It supports checkpoint-based back navigation, single-field edits from a summary screen, paginated choice lists, and date pickers, and every workflow in the bot is built on it. When a booking email arrives from BookMyGarage or FixMyCar, it is parsed, deduplicated against reminders for the same vehicle and date, and queued. Converting a booking into a job record pre-fills the form with the platform data and drops the mechanic straight into a final editable summary, so nothing gets retyped but nothing is trusted blindly either.
const jobForm = defineForm({
fields: {
description: {
schema: z.string().min(1),
prompt: 'Describe the work:',
editable: true,
normalizeBeforeConfirm: translateDescription
},
amount: {
schema: z.number().positive(),
prompt: 'Enter the total:',
editable: true,
format: (value) => `£${Number(value).toFixed(2)}`
},
clientSatisfied: {
schema: z.boolean().optional(),
prompt: 'Is the customer satisfied?',
editable: true,
when: (data, state) => Boolean(data.phone || state.linkedCustomerId)
}
}
});
const { data } = await runForm(conv, ctx, jobForm, cancelToMenu, {
initialData: {
description: booking.service,
amount: booking.totalCost
},
skipPrefilled: true,
summaryEdit: true
}); Customer invoices are built inside the bot from itemised lines. Mechanics can add or change lines with a typed or spoken message. The model identifies the requested changes, while code applies them, preserves unmentioned lines, and prevents the total from exceeding the recorded job amount.
Unanswered website enquiries and booking-site leads trigger up to two Telegram reminders, two hours apart and only during working hours. A Durable Object holds a single alarm set to the next due lead instead of polling. Each run claims leads in the same database update that selects them, preventing overlapping alarms from sending duplicate reminders.
The newest input path is voice. A mechanic can send a voice note in any language and get back a structured booking draft: transcription, relative dates resolved against UK time, spoken registration plates normalised, then the same DVLA and customer lookup a typed registration would trigger. Audio extraction can hallucinate from silence, so audibility is the first structured field and silent or garbled notes are rejected outright. The flow asks at most two batched follow-ups and always ends at the same editable confirmation screen as manual entry.
Job descriptions arrive in Ukrainian or Russian free text. An LLM step translates and normalises them into standard British English automotive terminology before storage (“bonnet”, not “hood”), with the original preserved alongside.
The invoice pipeline
Supplier invoices arrive through forwarded email, direct Telegram upload, and the accounting CLI. R2 is the canonical store for each PDF, while the accounting CLI works through authenticated admin endpoints rather than accessing the database directly. Every file is hashed so the same invoice ingested twice is caught regardless of path. Extraction runs through zenparse, a library I first prototyped in Python, rewrote in TypeScript, and eventually refactored into a Node-free core with adapters so it could run inside Workers.
Most of the engineering here is failure handling. A failed extraction keeps its database row and the original PDF in R2, marks the expense failed, and sends a Telegram notification with the file attached. Retries run straight from that notification, individually or in bulk, and the retry path atomically claims failed rows so two retries cannot process the same invoice twice.
Recovering eight months of paper
The bot only started recording jobs in February 2026. Before that, eight months of job history existed solely in handwritten notebooks, in a mix of Ukrainian and English, across inconsistent formats.
I built a backfill pipeline to recover it. Scanned notebook pages go through a two-stage LLM pass: date context is extracted first, then injected into the full extraction so entries land on the right day. Extracted jobs are matched against SumUp card exports and Tide bank CSVs using global one-to-one payment assignment with date and amount tolerance, which guarantees no payment is claimed by two jobs. The bank feed needed filtering first, since Tide exports include settlement payouts and internal transfers that look like income. Nothing is inserted directly: everything lands in a staging CSV for human review.
Different models miss different handwritten entries, so the pipeline produces per-model comparison artifacts and reconciles gaps against the existing monthly reports. The transform logic is covered by deterministic replay tests, meaning I can swap models and compare results without rerunning OCR on the whole archive. The final run recovered over 200 job records.
From separate tools to one platform
VDrive did not evolve through four clean versions of the same application. It began as several tools solving different parts of the business. The first was a static HTML marketing site with a contact form that mostly collected spam. Separately, I built zenparse-py to explore invoice extraction through Python scripts and manual CSV editing, then added a file-based desktop UI to make those accounting workflows usable without juggling terminals.
In January 2026, those strands began converging in a TypeScript monorepo. The website was rebuilt in SvelteKit, the Telegram bot became the operations interface, and the accounting tools moved onto a shared database. Invoice extraction was rewritten as a standalone TypeScript library, then folded into the monorepo and split into a Node-free core with adapters when the platform moved from Vercel to Cloudflare Workers.
The throwaway accounting tools were still valuable. They revealed which parts of the workflow were worth automating, which needed human review, and what the eventual integrated system actually had to support.
Running it
The platform has three daily users: two mechanics recording jobs and me running the accounting. I built and maintain the system end to end, and I also work in the business, handling clients, paperwork, and parts ordering. Being the operator of your own software is a fast feedback loop: when a flow is clumsy, I feel it the same day, and it usually gets fixed the same week.