home

VDrive

June 2025 - Present
~/vdrive
VDrive
role
Solo engineer
scale
850+ invoices/year
impact
~15 h/month to <30 min
SvelteKit
Telegram
Gemini
PostgreSQL

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.

intake
Email Routing invoices bookings
Telegram jobs uploads
accounting CLI invoices reports
processing
edge worker email cron
web worker sveltekit bot
Durable Object lead scheduler
services
FreeAgent customer invoices
Redis bot state
R2 invoice pdfs
PostgreSQL jobs expenses
The system map: intake paths converge on two Workers; a Durable Object schedules lead follow-ups, while business records land in R2, PostgreSQL, or FreeAgent.

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
});
Forms are declared as typed fields with validation, conditional steps and normalization hooks. Existing booking data skips answered questions but remains editable from the final summary.

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.