Delivered remotely for a business based in Lyon, France. Names withheld by agreement.
Project Overview
Industry: Remote staffing and outsourced professional services.
Type of solution: A workforce operations and billing platform — a single-page web application for three distinct user roles, over a serverless backend of callable endpoints, event-driven triggers and scheduled batch jobs, with an integrated payment provider, a hosted search index and an analytics warehouse.
Business context: The business places contract workers with client organisations under named engagements. Clients buy hours in advance; contractors draw those hours down as they work. That single arrangement creates an unusual amount of operational complexity. Time recorded by a remote contractor has to be accurate enough to bill a client and pay a worker from the same entry — at two different rates. The prepaid balance has to be debited correctly, watched continuously, and topped up before it runs out. And every one of those movements has to reconcile at the end of a pay period, for both invoicing and payroll. Run on spreadsheets, an accounting package and email, this becomes a permanent reconciliation burden with a genuine commercial failure mode at the end of it: a contractor working hours nobody has paid for.
General users: Administrators running the business, contractors recording their time, and client organisations managing their own accounts, balances and payment arrangements.
General purpose: To make time capture, balance management, payment collection and financial reporting one connected system, so that hours worked, hours billed and hours paid for can never quietly diverge.
The Business Challenge
Every hour has two prices. The client is billed at one rate; the contractor is paid at another. Both come from the same time entry, and both must survive a later rate change without retroactively rewriting history. A system that stores only one figure, or that recalculates from the current rate, produces incorrect invoices or incorrect payroll — often without anyone noticing for a month.
The prepaid balance is the product. Clients buy hours in advance, which means the business is running a live balance per engagement, mutated by purchases, credits, refunds, transfers, automatic top-ups and time draw-downs. If that number is wrong, either the client is over-billed or the business has given away work. It cannot be a figure that is recalculated on demand and hoped to be right.
Payments settle later than they are initiated. An automatic top-up starts a charge in one process and learns the outcome from the payment provider afterwards. Between those two moments the platform must have a record of the attempt, without having credited hours that may never be paid for.
Time is recorded across time zones. Contractors work remotely against their own local clock and their own working day. The platform has to store one canonical instant per entry while keeping enough local context to display and audit correctly — including for pauses taken mid-entry.
Timers get left running. Remote workers forget to clock out. An entry left open overnight corrupts both the client's bill and the contractor's pay, and it happens often enough that it cannot be treated as an edge case.
Batch billing that never asks a question will eventually bill something wrong. A nightly job that processes every submitted timesheet without judgement is efficient right up to the first anomalous entry. The business needed automation with a defined path back to a human.
The operational database cannot answer the business's questions. Pay-period totals, per-contractor payment summaries, transaction histories — none of these are lookups. They are aggregations, and they need somewhere to run that is not the live system.
People search by relationships, not by records. Administrators look for timesheets by client name and engagements by contractor name. Those are cross-entity searches that the operational data model does not support directly.
Our Approach
Model the money as a ledger, not as a field. Every change to a balance writes an immutable record capturing what changed, what it was worth, and the balance before and after. The balance stored on the engagement is a cached projection of that history, not the source of truth. If the two ever disagree, the history wins and the discrepancy is visible rather than silent.
Never compute money in floating point. All hour and currency arithmetic — on the client and on the backend — runs through an arbitrary-precision decimal library, with explicit precision validation on every quantity entering the system. In a product where hours convert directly to money in two directions, rounding drift is not a cosmetic problem.
Snapshot the rates onto the record. Both the client rate and the contractor rate are captured on the timesheet at creation. A rate negotiated tomorrow does not change what was invoiced or paid last month.
Route exceptions to a human. Before the nightly run converts approved time into a billing draw, it applies a set of review rules. Anything that does not look ordinary moves into a pending state and waits for an administrator, rather than being processed and discovered later. Automation handles the routine case; the unusual case gets a person.
Derive status instead of setting it. When a balance crosses zero in either direction, a database trigger updates the engagement's status automatically. No caller sets it, so it cannot drift out of step with the number it describes.
Treat email as a queue. Application code writes a message record; a trigger dispatches it through the transactional email provider and records the outcome. Business logic never blocks on a mail provider, delivery status is auditable per message, and the entire notification surface can be exercised locally without sending anything.
Build read models for the questions the write model cannot answer. The document store owns writes. Alongside it, triggers maintain a hosted search index for cross-entity filtering and an analytics warehouse for all reporting, so aggregation never competes with user-facing traffic.
Make reports configuration, not code. Each report is a definition declaring its parameter contract, building a fully parameterised query, and shaping its own result rows. Adding a report is adding a definition.
Make the whole commercial flow runnable locally. The development environment runs the full emulated cloud stack with a seeded dataset and payment-provider webhook forwarding, and scheduled jobs are directly invocable in development. The nightly billing run can be triggered and inspected on a laptop.
The Solution
Role-aware web application. One application serving administrators, contractors and clients, with role-specific dashboards, route-level access control and role-specific data on every screen.
Onboarding by invitation. Administrators create user accounts; the platform issues a signed, expiring, single-use link that the recipient uses to set their own credentials. The same mechanism backs password recovery, with single use enforced transactionally so a link cannot be replayed.
Client account management. Client organisations with billing contacts, addresses, stored payment method summaries, user membership and notification preferences.
Engagement setup. Each engagement pairs a client account with a contractor and carries its own hour balance, its own client and contractor rates, its own low-balance threshold and its own notification settings, with archive and reactivation.
Prepaid hours ledger. An append-only transaction history per engagement covering purchases, credits, refunds, transfers between engagements, automated top-ups and time draw-downs, each recording the adjustment, its monetary value and the balance on both sides of it.
Payments and stored methods. Clients add a payment method through the provider's hosted flow — no payment credentials ever reach the platform — and can review and remove stored methods. Subsequent top-ups charge that method without the client present.
Two automatic top-up models. A calendar-interval reload that adds a set number of hours on a schedule, and a threshold reload that fires whenever the balance falls below a chosen level. Both can be started, paused, updated and cancelled by the client or an administrator, and both share a single charge path.
Daily time capture. Contractors work against a timesheet for a specific working day, clocking in and out with their local time zone supplied and converted server-side, with overlap prevention, task notes, and pause and resume history recorded per entry.
Automatic closure of forgotten entries. A recurring job closes entries left open, annotating the entry so it is visible that the closure was systemic rather than manual.
Submission, review and approval. Timesheets move through a defined lifecycle with submission, withdrawal, rejection with a recorded reason, and administrator approval of anything the automated review has flagged.
Nightly billing run. Approved time is converted into a ledger draw, the balance recalculated and the timesheet closed — after which the affected engagements are immediately evaluated for automatic top-up, so a reload never fires against a stale balance.
Balance notifications with suppression. Low-balance and zero-balance notifications are generated per engagement and suppressed where an automatic top-up is already configured, so clients are not warned about a problem the platform is about to solve.
Scheduled digests and reminders. A daily low-balance digest grouped by client account, and a recurring reminder to contractors with unsubmitted time.
Dynamic forms and profiles. A form engine supporting typed questions including rich text, multi-select and file upload, used for contractor profiles and for a structured recognition capability — defined as data, so both can change without a release.
Editable in-app guidance. Administrators edit markdown content documents that render as guidance cards on each role's dashboard.
Warehouse-backed reporting. A catalogue of parameterised reports covering contractor payment summaries and detail, an export variant, time entries, transactions and subscriptions, with consistent pay-period bucketing.
Search-backed filtering. Every major list screen offers type-ahead filtering that searches across related entity names, powered by a continuously mirrored search index.
Version-gated releases. The client compares its build against a published version record and reloads itself when a new release goes out, so users are never left on a stale bundle.
Key Features
Append-only prepaid hours ledger Every balance movement is an immutable record carrying the adjustment, its value and the balance before and after, so the current balance is always reconstructible and never simply asserted.
Decimal-precision money and hours All hour and currency arithmetic runs through an arbitrary-precision decimal library on both client and backend, with precision validation on every quantity entering the system.
Dual-rate engagements with historical integrity Client rate and contractor rate are held per engagement and snapshotted onto each timesheet, so later rate changes cannot rewrite past invoicing or payroll.
Time-zone-correct time capture Clock-in, clock-out and pause events are recorded with their local time zone and converted to a canonical instant, with overlap prevention across entries on the same day.
Automated closure of open entries A recurring job closes timers left running and annotates the entry so the automated closure is visible rather than indistinguishable from a manual one.
Exception-routed billing The nightly run applies review rules before converting time into a billing draw, moving anything unusual into a pending state for administrator approval instead of processing it silently.
Dual automatic top-up models Calendar-interval reloads and balance-threshold reloads, both chargeable against a stored payment method without the client present, sharing one charge path and one settlement reconciliation.
Asynchronous payment reconciliation Charges are recorded at initiation and only credited to the balance when the provider confirms settlement through a webhook, with failures recorded and communicated rather than lost.
Queued transactional email Messages are written as records and dispatched by a trigger, decoupling business logic from the mail provider and producing an auditable delivery record for every message sent.
Warehouse-backed report catalogue Reports are declarative definitions with parameter contracts, fully parameterised queries and their own result formatting, run against an analytics warehouse rather than the operational store.
Cross-entity search filters A continuously mirrored search index with denormalised related-entity names, so users can filter by client, contractor or engagement name in a single query.
Dynamic form engine Typed questions including rich text, multi-select and file upload, so profile questionnaires and structured recognition are data rather than code.
Technical Architecture
Client application. A React and TypeScript single-page application built with Vite, using a mature component library with data grids and date pickers, client-side routing with role-gated routes, a query library for server state caching and deduplication, a lightweight store for session state, and schema-validated forms. Rich text and markdown editing, drag-and-drop uploads, and decimal arithmetic mirrored from the backend.
API layer. Callable RPC endpoints on a serverless function platform, each wrapped in an authorisation decorator that verifies the caller's role from a signed identity claim. Time-tracking endpoints use a stricter decorator that combines the role check with verification that the caller owns the engagement the timesheet belongs to.
Event-driven layer. Database document triggers handle work that must follow a write rather than block it: dispatching the welcome message when a user record is created, deriving engagement status when a balance changes, mirroring records into the search index, and sending queued email.
Scheduled layer. Batch jobs on a managed scheduler run the nightly time-processing and billing run, the recurring top-up processor, the periodic closure of open time entries, the daily balance digest and the contractor reminder — all outside business hours, all directly invocable in development.
Payment layer. Hosted checkout for capturing payment methods, off-session charges for automated top-ups, and a webhook handler that reconciles settlement outcomes back to the ledger and triggers the corresponding client notification.
Read-model layer. A hosted search index and an analytics warehouse are maintained alongside the operational document store and serve the query patterns it cannot: cross-entity name search and aggregate reporting respectively.
Data layer. A document database with an extensive composite index set backing filtered list queries, object storage for attachments and assets, and identity managed by the platform's authentication service with role carried as a signed custom claim.
Environments. Three separate cloud projects for development, staging and production, with environment-switching and promotion scripts, and a fully emulated local stack seeded with a committed dataset and wired to the payment provider's webhook forwarding.
Flow: React SPA → role-checked callable endpoints → document database (append-only ledger + operational records) → document triggers (status derivation, search mirroring, email dispatch) → scheduled batch jobs (time processing, top-ups, digests) → payment provider (off-session charge → webhook settlement) → analytics warehouse and search index for reporting and filtering
Technology Stack
| Category | Technology |
|---|---|
| Frontend framework | React with TypeScript, built with Vite |
| UI component system | Material UI including data grid and date picker components, with Emotion and tss-react styling |
| Client routing & state | React Router, React Query for server state, Zustand for session state |
| Forms & validation | React Hook Form with schema-based validation resolvers |
| Client utilities | Day.js with UTC and time-zone plugins, decimal.js, lodash, phone-number parsing, markdown and rich-text editors, drag-and-drop upload |
| Backend runtime | Node 20, TypeScript, ES modules |
| Backend platform | Firebase Cloud Functions (2nd generation) — callable endpoints, HTTP endpoints, document triggers and scheduled jobs |
| Operational database | Firestore, with an extensive composite index set |
| Authentication | Firebase Authentication with role carried as a custom claim |
| Object storage | Firebase Storage / Google Cloud Storage |
| Web delivery | Firebase Hosting with single-page rewrite |
| Analytics warehouse | Google BigQuery, queried through parameterised report definitions |
| Payments | Stripe — hosted checkout, stored payment methods, off-session payment intents, webhooks |
| Transactional email | SendGrid with templated messages, dispatched from a queued message record |
| Search | Hosted search index continuously mirrored from the operational store |
| Secure links | Signed JSON Web Tokens with transactional single-use enforcement |
| Precision arithmetic | decimal.js across client and backend |
| Monitoring & feedback | Session replay and front-end analytics tooling, plus an embedded in-app feedback widget |
| Local development | Full cloud emulator suite with a seeded dataset and payment webhook forwarding |
| Environments | Three isolated cloud projects with environment-switching and promotion scripts |
Technical Challenges & Solutions
| Challenge | Our Approach |
|---|---|
| A prepaid balance mutated by six different transaction types, some settling asynchronously | An append-only ledger where every movement records the adjustment, its monetary value and the balance on both sides of it. The engagement's balance is a cached projection of that history, so a discrepancy is detectable rather than invisible, and the balance can always be reconstructed. |
| Hours converting to money in two directions, at two different rates, with rates that change over time | Both rates are held per engagement and snapshotted onto each timesheet at creation, so historical invoicing and payroll are immune to later rate changes. All arithmetic runs through an arbitrary-precision decimal library, with precision validation on every quantity entering the system. |
| Charges initiated in one process and confirmed by the provider later | The ledger record is written at initiation in a pending state and only resolved to settled or failed by the provider's webhook, which is also what credits the balance. Failures produce a recorded outcome and a client notification rather than a silent gap. |
| Remote contractors recording time against their own local clock, in their own time zone | Clock-in, clock-out and pause events carry their local time zone and are converted to a canonical instant server-side, with local context retained for display and audit, and overlap detection preventing a new entry landing inside an existing one. |
| Timers left running overnight, corrupting both billing and payroll | A recurring job closes open entries and appends a visible annotation to the entry recording that the closure was automatic — so the correction is applied consistently and remains obvious to anyone reviewing the record. |
| A nightly billing batch that must not process something anomalous silently | The run applies a set of review rules before converting time into a billing draw and moves anything unusual into a pending state for administrator approval. Automation handles the routine case and hands the rest to a person, with the reason recorded. |
| Automatic top-ups firing against a stale balance | Top-up evaluation is sequenced explicitly after time processing within the same run, and is scoped to only those engagements the run actually touched, so a reload decision is always made against a current balance. |
| A document database that cannot join or aggregate, against a business that reports constantly | Two read models maintained by triggers alongside the operational store: an analytics warehouse for all reporting, and a hosted search index for filtering. Heavy aggregation never competes with user-facing traffic. |
| Users searching by relationships the data model does not support | Related entity names are denormalised into the search index as records change, so a single query can filter by client, contractor or engagement name. |
| Scheduled batch logic that cannot be triggered on demand in a deployed environment | Batch functions are exported as scheduled jobs in deployed environments and as directly invocable endpoints in local development, so the full billing run can be triggered, inspected and demonstrated on a developer machine against a seeded dataset. |
Security & Reliability
Managed identity with signed role claims. Authentication is handled by the platform's identity service, with the user's role carried as a signed claim on the identity token rather than looked up and trusted from client input.
Authorisation as a wrapper, not a convention. Every state-changing endpoint is wrapped in a decorator that verifies the caller's role before the handler runs. Authorisation is structural rather than something each handler is trusted to remember.
Ownership checks on personal data. Time-tracking operations use a stricter decorator combining the role check with verification that the caller owns the engagement behind the timesheet, so a contractor can act on their own time records and no one else's.
Single-use, expiring links. Invitation and password-reset links are signed, time-limited and single-use, with the use counter decremented inside a database transaction so a link cannot be replayed even under concurrent requests.
Payment credentials never touch the platform. Card details are captured through the payment provider's hosted flow. Only non-sensitive summaries are retained for display, keeping sensitive payment data outside the application entirely.
Immutable financial history. Ledger records are appended, never edited in place, and each carries the balance before and after — so a balance is always explainable by the records that produced it.
Actor attribution on every record. Every document carries created-by and updated-by references, including for automated actions, which are attributed to a system identity rather than to whichever user happened to trigger the batch.
Atomic multi-document writes. Operations that touch several records — writing a ledger entry while updating a balance and closing a timesheet, or moving hours between two engagements — run inside database transactions so they cannot half-complete.
Reliability through queued side effects. Email is queued as records and dispatched by a trigger with delivery status recorded, so a provider outage delays notifications rather than failing the business operation that produced them.
Environment isolation. Development, staging and production are entirely separate cloud projects, with a local emulated stack for day-to-day work so no development activity touches production data.
Scalability & Performance
Serverless throughout. Every endpoint, trigger and batch job scales per invocation with no server capacity to plan, which suits a workload that is quiet most of the day and concentrated around submission deadlines and the nightly run.
Reporting off the hot path. All aggregation runs in a columnar analytics warehouse rather than the operational store, so a large multi-month report cannot slow down a contractor clocking in.
Search off the hot path. Type-ahead filtering runs against a hosted search index maintained by triggers, keeping list-screen interactions fast without adding query load to the operational database.
Indexed list queries. The filtered list screens that make up most of the application's day-to-day use are backed by an extensive composite index set, so query paths stay bounded as data grows.
Batch work scheduled off-peak. Time processing, top-ups, digests and reminders run on schedules outside working hours, and the one long-running backfill operation is given explicit memory and timeout allocation rather than inheriting defaults.
Client-side caching and deduplication. Server state is cached and deduplicated by a query library, so navigating between list and detail screens does not re-fetch what the client already holds.
Reduced client-side computation. Denormalised names in the search index and pre-shaped report rows mean the browser renders results rather than assembling them.
Version-gated releases. A published version record prompts clients to reload after a deploy, avoiding the long tail of users running stale bundles against a changed backend.
Business Outcomes
- Hours worked, hours billed and hours paid for come from one record, removing the reconciliation gap between time tracking, invoicing and payroll.
- The prepaid balance is explainable at any point in time, because every movement is an immutable record showing what changed and what the balance was on either side of it.
- Clients no longer run out of hours unexpectedly, with threshold and interval top-ups charging a stored payment method automatically and balance notifications suppressed where a top-up already covers the situation.
- Anomalies reach a human before they reach an invoice, because the billing run routes anything unusual to administrator review rather than processing it silently.
- Time entries left open are corrected consistently and visibly, rather than being caught by whoever notices first.
- Rate changes no longer disturb historical records, because billing and cost rates are captured on each timesheet at the point of work.
- Reporting answers pay-period and payment questions directly, from a warehouse built for aggregation rather than from exports assembled by hand.
- Administrators find records by the names they actually think in — client, contractor, engagement — rather than by identifiers.
- Onboarding questionnaires and recognition schemes change without a release, because they are defined as data rather than built into the application.
- Each role sees a workspace built for their job, with clients managing balances and payment arrangements themselves rather than by emailing the office.
Why it worked
Any platform that turns hours into money is a financial system, whether or not anyone calls it one. That distinction decides how it should be built. A field holding a balance is a number someone will eventually have to defend and cannot; a ledger of immutable movements is a number that explains itself. Floating-point arithmetic in a product where hours convert to money at two different rates produces drift that surfaces months later as a dispute. And a nightly batch that processes everything without judgement is efficient exactly until the first entry that should have been questioned.
Our team builds for that reality. We modelled the balance as an append-only ledger with the position recorded on both sides of every movement. We put arbitrary-precision decimal arithmetic on both the client and the backend and validated precision at every point money enters the system. We snapshotted rates onto the record so history stays true. We designed the billing run to escalate rather than assume, because the failure mode of silent automation in a financial process is not an error message — it is an invoice nobody questions. And we split the read models out to a search index and an analytics warehouse, because a document database is an excellent transactional store and a poor reporting engine, and pretending otherwise is how these platforms become slow.
Our teams work across serverless cloud architecture, event-driven and scheduled backend design, payment provider integration and settlement reconciliation, analytics warehouse reporting, and modern TypeScript React applications serving multiple roles from one codebase — with the domain judgement to recognise when a product is really an accounting system wearing a different name.
Final Summary
A remote staffing business sells its clients hours in advance and draws them down as its contractors work. That single commercial model creates an operational problem larger than it appears: the same time entry has to bill a client and pay a worker at two different rates, the prepaid balance behind it has to stay correct through purchases, credits, refunds, transfers and automated top-ups that settle asynchronously, and all of it has to reconcile at the end of every pay period. Run on spreadsheets and email, it is a permanent reconciliation burden with a real commercial failure mode waiting at the end of it.
Our team built the system of record for that model. Contractors capture time against their local time zone with pauses and overlaps handled properly and forgotten timers closed automatically and visibly. Submitted time passes through an automated review that routes anything unusual to an administrator rather than billing it silently, then a nightly run converts approved time into a ledger draw and immediately evaluates whether an automatic top-up should fire. Every movement of the balance — purchase, credit, refund, transfer, top-up or draw-down — writes an immutable record carrying the position on both sides of it, with all arithmetic in arbitrary-precision decimals and both rates snapshotted onto the record so history cannot be rewritten by a future rate change.
Around that sit the parts that make it usable: card-on-file top-ups in two models with settlement reconciled through the payment provider's webhooks, queued transactional email with an auditable delivery record, a warehouse-backed report catalogue that answers pay-period questions directly, cross-entity search so administrators find records by name rather than by identifier, and a dynamic form engine so onboarding and recognition change without a release. Delivered as a serverless TypeScript platform with a role-aware React application, it replaces a spreadsheet-and-inbox process with something the business can actually reconcile — which, for a company whose product is measured in hours, is the whole point.