Ship a ERP

A multi-tenant ERP built around an immutable double-entry ledger, with every AI feature metered and governed through a single gateway.

  • Laravel
  • React
  • Inertia.js
  • PostgreSQL 15
  • Redis
  • Laravel Horizon
  • Laravel Reverb
  • Docker
  • AWS ECS

Overview

Ship a ERP is a multi-tenant, real-time ERP for mid-market companies. It brings CRM, operations, warehouse management, HR and finance into one platform. The backend is Laravel with a React front end delivered through Inertia.js, on PostgreSQL 15, with Redis and Laravel Horizon for caching and queues and Laravel Reverb for WebSockets. Production runs as Docker containers on AWS ECS behind Nginx.

Invoices, expenses and payments from across the platform all end up as ledger entries, so the architecture starts from the ledger and works outward.

The ledger at the center

All financial transactions (expenses, invoices, payments) flow through a single LedgerService, an immutable double-entry accounting engine.

  • Atomic. Every journal entry and its debit and credit lines are written inside one database transaction. A partial entry can never exist.
  • Idempotent. Sync methods such as syncExpense can run any number of times. When a source record changes, the service deletes its journal entry (cascading to its lines) and recreates it from the current state, instead of patching individual lines.
  • Traceable. Journal entries link polymorphically to the document that caused them (transactionable_type, transactionable_id), so every movement in the ledger leads back to a specific invoice or expense.

A chart of accounts each tenant owns

Tenants operate in different jurisdictions and number their ledgers differently, so the system never assumes that, say, account 2000 is Accounts Payable. Each account instead carries two pieces of metadata:

  • a macro category (type: asset, liability, equity, revenue, expense) that places it on the financial statements, and
  • an optional system purpose (system_purpose, e.g. accounts_payable, inventory_asset): a unique tag the automation uses to route money.
// Finding the AP account regardless of how the tenant numbered it
$apAccount = ChartOfAccount::where('tenant_id', $tenantId)
    ->where('system_purpose', 'accounts_payable')
    ->first();

Backend code only ever looks up tags, so tenants keep full control over their numbering without breaking automated postings.

Balances as projections

Bank balances follow a CQRS split. The source of truth is the sum of debits and credits in journal_entry_lines. The projection is a projected_balance column on the bank account, used by dashboards. Each time a payment syncs, the ledger service recalculates the balance from the ledger and overwrites the projection rather than incrementing it, so the dashboard and the balance sheet cannot drift apart.

Two kinds of precision for money

Amounts that someone actually pays or is charged, such as invoice totals, expenses and credit purchases, are stored as integer cents and exposed as decimals through a shared MoneyCast. Every ledger-facing column uses that one representation.

AI usage breaks that rule on purpose. A single AI call can cost $0.0002, and rounding those amounts to cents would turn most of them into $0.00 and make margin analysis meaningless. So rates and per-call costs are stored in micro-USD or high-precision decimals, and MoneyCast is deliberately not applied to them.

One gateway for every AI feature

Every AI-powered feature (proposal drafting, natural-language finance questions, document OCR, lead research) goes through a single AI Gateway instead of calling a provider directly. The gateway authorizes each call, reserves funds, dispatches it and settles the result, so metering, permissions and billing are enforced in one place.

  • Credits, not raw tokens. Tenants spend an internal unit, AI Credits, pegged to USD through a versioned price book with per-model, per-unit rates. Price books are never edited in place, so historical usage is never re-priced.
  • An append-only usage log. Each call records who triggered it, where in the product, why (purpose and prompt version), what it cost and how it was paid for. A correlation ID ties together the steps of a multi-step agent action.
  • Wallets derived from a ledger. Credit balances are never written directly. A database trigger derives them from an append-only credit ledger of purchases, allowances, usage, expirations and adjustments, so any balance can be rebuilt and audited.
  • Reserve, then settle. Before a call goes out, a worst-case hold is placed on the wallet, and it settles to the actual cost afterward. Concurrent requests cannot overspend a cap, and holds expire, so a crashed call never locks funds.
  • Bring-your-own-key fails closed. Enterprise tenants can connect their own provider key for zero-retention or compliance reasons. Those calls are still metered but never charged, and that is enforced by the database, not just the application:
DB::statement("ALTER TABLE ai_usage_events ADD CONSTRAINT byok_never_charged
    CHECK (funding_source <> 'byok' OR (credits_charged_micro = 0 AND platform_cost_usd_micro = 0))");

If a tenant’s own key fails, the call fails. It falls back to platform keys only if the tenant has opted in.

  • Budgets per person and per feature. Monthly budgets cap spending per user, position, role or workspace, and separately per AI feature. Both apply: a generous user budget never lifts a feature cap.
  • Costs shown before they are spent. Buttons that trigger paid AI work show an estimate up front, based on the tenant’s own recent calls for that feature.
  • Policy, not code. How much of each prompt is logged, how long it is kept and how PII is redacted are per-tenant settings, which mirrors the “tags, not hardcoded IDs” approach of the chart of accounts.

AI suggests, people decide

Every AI feature runs at a suggest or read-only tier. The model drafts, extracts or classifies, and a person approves before anything becomes a record. Each feature’s guardrail is enforced in code rather than in the prompt.

Feature What the AI does Guardrail
Proposal Builder Drafts a scope of work and pricing from an opportunity and meeting notes The model only names catalog products; prices come from the real catalog or are left blank
Proposal Designer Writes the content of one presentation section Financial figures render live from the proposal and can never be AI-authored
Account and Lead Prospecting Researches companies and decision-makers on the public web Findings stay “suggested” until a person accepts them; sources must be pages the search returned; contact details are never guessed
Receipt and Bill Scanning Extracts vendor, date and line items from an uploaded document Only pre-fills the form; a person posts the expense to the ledger
Finance Assistant Answers plain-language questions about receivables, payables and spending The model only classifies the question; every number comes from a permission-checked query

The proposal designer shows how strict that boundary is. Its financial_summary block stores no data and renders the proposal’s live totals at display and export time. The model is never offered that block, a single normalizer strips it from AI output, and AI-drafted pricing tiers come back with blank prices for a person to fill in.

Infrastructure details

  • One Nginx config for every environment. The FastCGI upstream is templated from an environment variable, so the same configuration points at the app container in local Docker Compose and at 127.0.0.1 inside an ECS task.
  • Real-time updates. Nginx proxies WebSocket upgrades to Laravel Reverb, and the Content Security Policy explicitly allows ws:// and wss://, so the browser doesn’t block the handshake.
  • CSV imports that survive Excel. Imports convert the raw bytes to UTF-8 (trying Windows-1252, ISO-8859-1 and Mac Roman) in a memory stream before parsing, which prevents garbled accents from legacy spreadsheet exports.
  • Ready-to-use workspaces. A provisioning job gives every new tenant a tagged chart of accounts, a default bank account mapped to cash, a department structure, demo records linked to the new ledger accounts, and a starter library of proposal templates.

Key decisions

  • Recompute the truth instead of patching it. Journal entries are rebuilt on sync and balances are recalculated from the ledger. That costs extra writes, but it rules out a whole class of drift bugs.
  • Put invariants in the database. Credit wallets come from a trigger and the BYOK billing rule is a CHECK constraint, so they hold even if application code has a bug.
  • Tag accounts by purpose instead of hardcoding numbers. That keeps the platform usable across jurisdictions without per-tenant code paths.
  • Keep AI away from final numbers. Wherever money appears, AI can draft the words but the figures come from the catalog, the ledger or a person.