Advanced

Trivik ERP

Offline-first ERP for Indian snack manufacturers with double-entry accounting, GST compliance, and IndexedDB sync.

Full Stack Engineer
2024 – present
Solo build
2024
  • Next.js
  • Prisma
  • PostgreSQL
  • Dexie
  • TanStack Query
  • TypeScript

Problem

Indian snack (namkeen) manufacturers run on tight margins in places where the internet is not a given. The factory floor, the godown, and the delivery van frequently have no connection, yet the business still needs to record production, issue GST-compliant invoices, and keep books that would survive an audit. Off-the-shelf ERPs assume always-on connectivity and rarely understand Indian statutory requirements out of the box.

Target user: small and mid-size Indian food manufacturers and their accountants.

Constraints:

  • Must work offline. A dropped connection cannot stop billing or production entry.
  • Must be statutorily correct. GST, HSN codes, and return filing are legal requirements, not features.
  • Books must balance. Real double-entry accounting, not a spreadsheet.

Business goal: give small manufacturers audit-grade books and GST compliance in an app that keeps working when the network does not.

Architecture

Trivik is an offline-first PWA. The client is the primary system of record during a session; the server reconciles.

  • Next.js PWA with a Service Worker and Dexie (IndexedDB) as the local store. Writes are optimistic and local-first; a background sync process batches operations to the server when connectivity returns.
  • Prisma + PostgreSQL (Neon) on the server, with a deliberate pooled + direct connection split: pooled connections for serverless request traffic, a direct connection for migrations and long transactions.
  • TanStack Query manages server-state caching, background refetching, and optimistic updates on the client, layered over the Dexie local store.
  • Zod validates every payload at the client/server boundary and inside the sync reconciler.
  • Double-entry voucher accounting produces Trial Balance, Profit & Loss, and Balance Sheet from the same ledger.
  • Indian GST compliance: HSN codes, CGST/SGST/IGST split, GSTR-1 and GSTR-3B generation, e-Way bills, and e-Invoicing.
  • Inventory: FIFO and weighted-average COGS, BOM versioning for recipes, and FSSAI batch/expiry tracking for food safety.
  • Tested with Playwright (e2e) and fast-check (property-based) for the accounting and sync invariants.

Engineering Decisions

Offline-first with Dexie, not online-first with a cache. The difference is philosophical: the local IndexedDB store is authoritative for the session, and sync is a reconciliation problem, not a fetch problem. This is why billing never blocks on the network. Tradeoff: conflict resolution and idempotent sync are hard — that complexity is the price of never losing a sale.

Neon pooled + direct connection split. Serverless functions exhaust Postgres connections fast, so request traffic uses Neon's pooled endpoint, while migrations and multi-statement accounting transactions use a direct connection that supports the full protocol. Tradeoff: two connection strings to manage, but it prevents both connection exhaustion and broken migrations.

Double-entry accounting from day one. It would have been faster to store "invoices" and "expenses" as flat records, but then Balance Sheet and audit trails are impossible. Every financial event posts balanced debit/credit voucher lines, so the three core reports are derivations of one consistent ledger. Tradeoff: more upfront modeling, but correctness is not retrofittable.

Zod at every boundary. Offline data can be stale or malformed by the time it syncs; validating with Zod on both write and reconcile keeps corrupt state out of the ledger.

Source Code Walkthrough

Optimistic local write with a sync outbox

Every mutation writes to Dexie immediately and enqueues a sync operation. The UI never waits for the server.

// db/mutations.ts
// Why: the sale must record even with no network. Local write is authoritative
// for the session; the outbox guarantees eventual server reconciliation.
export async function recordVoucher(voucher: VoucherInput) {
  const parsed = VoucherSchema.parse(voucher); // Zod guards the ledger.
 
  await db.transaction("rw", db.vouchers, db.outbox, async () => {
    await db.vouchers.put(parsed);            // optimistic local write (~8ms)
    await db.outbox.add({                     // enqueue for background sync
      op: "upsert_voucher",
      payload: parsed,
      clientTs: Date.now(),
      status: "pending",
    });
  });
 
  void syncOutbox(); // fire-and-forget; retries on reconnect.
}

Reconciling the outbox in idempotent batches

// sync/reconcile.ts
// Why: batches reduce round-trips; idempotency keys make retries safe so a
// dropped connection mid-sync never double-posts a voucher.
export async function syncOutbox() {
  if (!navigator.onLine) return;
  const pending = await db.outbox.where("status").equals("pending").limit(50).toArray();
  if (pending.length === 0) return;
 
  const res = await fetch("/api/sync", {
    method: "POST",
    body: JSON.stringify({ ops: pending }),
    headers: { "idempotency-key": batchKey(pending) },
  });
 
  if (res.ok) {
    const { applied } = await res.json();
    await db.outbox.bulkDelete(applied); // only clear what the server confirmed
  }
}

Performance considerations: local writes are ~8ms; sync batches 50 ops per round-trip (~430ms) instead of one request per op. Improvements: vector clocks for richer conflict detection instead of last-write-wins on clientTs.

Posting a balanced voucher (the accounting invariant, enforced)

The ledger's one non-negotiable rule — total debits equal total credits — is enforced at the point of posting, not discovered later in a report. A voucher that does not balance is never allowed to exist.

// ledger/postVoucher.ts
// Why: double-entry only works if every posting balances. Enforcing it here
// (a single choke point) means Trial Balance nets to zero by construction,
// not by luck — the three financial reports are then pure derivations.
export function buildBalancedVoucher(lines: VoucherLine[]): BalancedVoucher {
  if (lines.length < 2) {
    throw new LedgerError("A voucher needs at least two lines (Dr and Cr).");
  }
 
  // Work in integer paise, never floats — 0.1 + 0.2 !== 0.3 will corrupt books.
  const debits = lines.filter((l) => l.side === "debit");
  const credits = lines.filter((l) => l.side === "credit");
  const totalDr = debits.reduce((s, l) => s + l.paise, 0);
  const totalCr = credits.reduce((s, l) => s + l.paise, 0);
 
  if (totalDr !== totalCr) {
    throw new LedgerError(
      `Voucher does not balance: Dr ${totalDr}p vs Cr ${totalCr}p`,
    );
  }
  if (totalDr === 0) {
    throw new LedgerError("Refusing to post a zero-value voucher.");
  }
 
  return { id: ulid(), lines, totalPaise: totalDr, postedAt: new Date() };
}

Why this way: money is stored and summed as integer paise, so rounding never drifts the ledger — a class of bug that is invisible until an audit. Balancing is checked before an ID is even minted, so an unbalanced voucher cannot be persisted. Improvements: attach the originating document (invoice, production run) as a typed reference so every posting is traceable to its cause.

Applying a sync batch idempotently on the server

The client trusts the server to tell it exactly which operations landed. The server makes that safe by keying every applied op so a retried batch is a no-op.

// app/api/sync/route.ts
// Why: a dropped connection mid-sync means the client will retry the same
// batch. Idempotency keys + an upsert on (clientId, opId) make re-delivery
// harmless, so "at least once" transport yields "exactly once" effect.
export async function POST(req: Request) {
  const { ops } = SyncBatchSchema.parse(await req.json()); // Zod at the boundary
  const idempotencyKey = req.headers.get("idempotency-key");
  if (!idempotencyKey) return badRequest("missing idempotency-key");
 
  const applied: string[] = [];
  await prisma.$transaction(async (tx) => {
    for (const op of ops) {
      // Upsert keyed by the client's op id: replaying the batch changes nothing.
      await tx.syncedOp.upsert({
        where: { clientId_opId: { clientId: op.clientId, opId: op.opId } },
        create: { ...op, batchKey: idempotencyKey },
        update: {}, // already applied — intentionally a no-op
      });
      await applyOp(tx, op); // itself idempotent (upserts, not inserts)
      applied.push(op.opId);
    }
  });
 
  return Response.json({ applied }); // client clears only these from its outbox
}

Performance considerations: the whole batch runs in one Prisma $transaction, so 50 ops cost one round-trip and either all commit or none do — the client never sees a partially-applied batch. Improvements: stream a per-op result array so a single poison op can be quarantined without failing the whole batch.

Technical Challenges

  • Sync conflicts and idempotency. The outbox uses idempotency keys so a retry after a dropped connection never double-posts. Server confirms exactly which ops applied before the client clears them.
  • Accounting correctness. Double-entry invariants (every posting balances; Trial Balance nets to zero) are enforced and property-tested with fast-check.
  • GST statutory correctness. CGST/SGST/IGST selection depends on intra- vs. inter-state supply; GSTR-1/3B generation must match filed data.
  • Serverless connection limits. The Neon pooled/direct split prevents connection exhaustion under bursty serverless traffic.
  • Food-safety tracking. FSSAI batch and expiry tracking tied to BOM versions so a recall can trace affected lots.

An honest tradeoff worth naming: last-write-wins on clientTs is the weakest link. It is correct for the common case (one operator, one device, sequential edits) and it is simple enough to reason about under pressure, but two devices editing the same voucher offline will silently resolve to whichever timestamp is larger. I chose it deliberately as a v1 — the alternative (vector clocks or CRDTs) is a large complexity budget to spend before there is evidence of real concurrent multi-device editing. The idempotency layer already prevents the dangerous bug (double-posting); LWW only risks a lost edit, which is recoverable from the outbox history. That is the right risk to carry early.

The other subtle challenge is statutory correctness being a moving target. GST rules, HSN mappings, and return-form schemas change by government notification, not by my release cycle. Hard-coding rates would make every rate change a code deploy. Rates and the intra-/inter-state CGST/SGST/IGST decision table are therefore data, versioned by effective date, so a historical invoice always re-computes with the rates that were legal on its own date — essential when regenerating GSTR-1 for a prior period.

Code Quality & Testing

Type safety. strict TypeScript everywhere, with noUncheckedIndexedAccess on so that POINTS[type]-style lookups can't silently produce undefined. Prisma generates the row types, so the database schema and the application types cannot disagree — a column rename fails the build, not production. Money never touches number arithmetic as rupees; it is integer paise end-to-end, and the paise/rupee conversion lives in exactly one typed helper.

Testing strategy — invariants, not just examples. The accounting core is where example-based tests are weakest (you test the cases you thought of), so it is covered by fast-check property tests over the invariants themselves:

// ledger/postVoucher.property.test.ts
import fc from "fast-check";
 
// Invariant 1: any voucher that this engine accepts has Dr == Cr.
test("every accepted voucher balances", () => {
  fc.assert(
    fc.property(arbBalancedLines(), (lines) => {
      const v = buildBalancedVoucher(lines);
      const dr = sumSide(v, "debit");
      const cr = sumSide(v, "credit");
      expect(dr).toBe(cr);
    }),
  );
});
 
// Invariant 2: across a random stream of postings, the Trial Balance nets to 0.
test("trial balance of any posting sequence nets to zero", () => {
  fc.assert(
    fc.property(fc.array(arbBalancedLines(), { maxLength: 200 }), (batch) => {
      const ledger = batch.map(buildBalancedVoucher);
      expect(trialBalance(ledger).netPaise).toBe(0);
    }),
  );
});

These two properties are the whole point of double-entry: if they hold for arbitrary inputs, Trial Balance, P&L, and Balance Sheet are correct by construction. fast-check also shrinks any counterexample to the minimal failing posting, which is how the original float-rounding bug was found and pinned.

End-to-end offline behavior is verified with Playwright, driving the real sync path rather than mocking it: create a voucher with the network throttled to offline, assert it renders instantly from Dexie, restore the network, and assert the outbox drains and the server row matches. A second spec replays the same batch twice against /api/sync to prove the idempotency contract holds end-to-end, not just in a unit test.

# offline → online sync, exercised for real (not mocked)
npx playwright test e2e/offline-sync.spec.ts

Error handling & observability. LedgerError is a distinct typed error so a balancing failure is never swallowed as a generic 500 — it surfaces the Dr/Cr delta in the message and is logged with the originating op id. The sync route emits structured logs for batch size, applied count, and rejected ops, which back the "sync lag / outbox depth" monitoring.

Module boundaries. The ledger engine is a pure module with no I/O — it takes lines and returns a balanced voucher or throws. Persistence (Prisma), transport (the sync route), and validation (Zod schemas) are separate layers. That purity is exactly what makes the property tests trivial to write.

CI enforcement. tsc --noEmit, ESLint, the Vitest/fast-check suite, and the Playwright offline-sync suite all run in CI; a red build blocks merge. Prisma migrations are checked against a shadow database so a migration that would fail on the direct connection is caught before it reaches Neon.

Benchmarks

Optimistic local writes complete in ~8ms, so billing feels instant regardless of network. Background sync batches 50 operations in ~430ms. The three core accounting reports — Trial Balance (~120ms), P&L (~180ms), Balance Sheet (~210ms) — are all derived from the same ledger. Two statutory GST return forms (GSTR-1, GSTR-3B) are generated directly. See the charts above.

Offline sync reconciliation (ms)
Accounting report generation (ms)
2forms

Lessons Learned

  • What succeeded: offline-first as a first principle. Nothing about billing or production entry depends on the network.
  • What failed first: an early last-write-wins sync silently clobbered concurrent edits; idempotency keys + server-confirmed application fixed the double-post class of bug.
  • What I would redesign: move from clientTs last-write-wins toward vector clocks for true conflict detection.
  • Remaining tech debt: e-Invoice/e-Way integration edge cases across states need more coverage.
  • Future improvements: offline-capable GST return preview and multi-device merge.

Media

  • Recording of billing working with the network disabled, then syncing on reconnect.
  • Screenshots of the Trial Balance / P&L / Balance Sheet reports.
  • Diagram of the Dexie outbox → sync reconciler → Postgres flow.
  • Internal docs cover the offline sync protocol, the double-entry ledger model, and the Indian GST compliance rules (HSN, CGST/SGST/IGST, GSTR-1/3B).

Build Logs

  • Requirements & planning. Two non-negotiables up front: works offline, and produces audit-grade GST-compliant books.
  • Architecture. Offline-first PWA with Dexie + Service Worker; Neon pooled/ direct split; double-entry ledger as the financial core.
  • Implementation. Built the local-first write path and sync outbox, then the voucher accounting engine and the GST return generators; Zod at every boundary.
  • Testing. fast-check property tests for accounting invariants; Playwright e2e for offline → online sync flows.
  • Deployment. Next.js PWA; Neon Postgres via Prisma with the connection split.
  • Monitoring. Sync lag, outbox depth, and report generation timings.
  • Retrospective. Offline-first paid off immediately with real factory-floor users; conflict resolution is the area with the most room to grow.