Silk House Billing Portal
A double-entry accounting system for a 5-firm silk saree business. Automates GST compliance, bank reconciliation, and multi-firm consolidated reporting.
01 The Problem
A real-world silk saree business running 5 independent firms (DSS, SMSS, LKC, SDS, NSS) — each with its own bank account, GSTIN, and state registration. Sales are B2C; payment arrives 1–2 months late. Transactions span both inter-state (IGST) and intra-state (CGST+SGST) tax regimes. The accountant was managing everything with physical ledgers.
Every month-end meant hours of manual reconciliation, hand-written GST return calculations, and no real-time visibility into debtor positions, firm-level profitability, or tax liability. Errors were common, and cross-firm consolidation was a spreadsheet nightmare.
This wasn't a hypothetical project — it replaced a real, paper-based accounting workflow mid-financial-year for a business handling crores in annual turnover.
02 Architecture
The system follows a modern full-stack architecture: a React single-page application backed by Supabase (PostgreSQL with Row-Level Security), with an optional Tauri desktop wrapper for offline access.
┌──────────────────────────────────────────────────────┐ │ Silk House Portal │ │ ┌────────────────────────────────────────────────┐ │ │ │ React SPA (Vite + TypeScript) │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ Dashboard │ │Invoicing │ │ Reports │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ Purchases│ │ Auth │ │ Settings │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ └──────────────────┬─────────────────────────────┘ │ │ │ HTTP + JWT │ │ ┌──────────────────▼─────────────────────────────┐ │ │ │ Supabase (PostgreSQL + RLS) │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ Sales │ │ Returns │ │ Bank │ │ │ │ │ │ Invoices │ │Credit No│ │Transacts │ │ │ │ │ ├──────────┤ ├──────────┤ ├──────────┤ │ │ │ │ │Purchases │ │ Firms │ │ GST │ │ │ │ │ │ │ │ (5) │ │ Returns │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ └────────────────────────────────────────────────┘ │ │ │ │ ┌────────────────────────────────────────────────┐ │ │ │ Tauri Desktop Wrapper (optional) │ │ │ └────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────┘
Supabase Row-Level Security was chosen to enforce firm-level data isolation at the database layer — ensuring that even if the front-end had a bug, a user from DSS could never see LKC's transactions.
03 Tech Stack
Every technology was chosen with a specific purpose: type safety across the stack, fast iteration, accessible UI, and proven production databases.
| Layer | Technology | Purpose |
|---|---|---|
| Language | TypeScript | Full-stack type safety — share types between front-end and database |
| Front-end | React 19 + Vite | Fast dev server, modern React patterns, tree-shakeable builds |
| Back-end / Database | Supabase (PostgreSQL) | Managed Postgres, built-in auth, auto-generated REST API |
| ORM | Prisma | Declarative schema, migrations, type-safe queries |
| UI Components | Radix UI | Accessible, unstyled primitives for modals, selects, dropdowns |
| Styling | Tailwind CSS | Utility-first, consistent design tokens, rapid prototyping |
| Testing | Vitest | 50+ tests — components, hooks, lib utilities, page integration |
| Desktop | Tauri | Optional native wrapper for offline access / local backups |
04 Key Challenges
-
1. Double-entry for delayed payments
Every sale books immediate credit to revenue and debit to the debtor's
account. Payment reconciliation — which can arrive 60 days late —
posts against the original invoice. The ledger must show accurate debtor
positions at all times, even mid-cycle.
Solution
Each invoice carries a unique payment status derived from linked bank transactions. A materialised view computes real-time ageing summaries for the dashboard without hammering the OLTP tables.
-
2. Multi-firm consolidation
Five separate GSTINs, bank accounts, and state registrations. Each firm
needs its own trial balance while a consolidated view sums across all
five. Cross-firm inventory or fund transfers need careful double-sided
journal entries.
Solution
Every row across all transaction tables is scoped by
firm_id. A centralfirmstable drives per-firm dashboards via RLS policies. Consolidated reports are computed by summing across firms with the same SQL views — no separate consolidation logic needed. -
3. GST compliance
Every sale must be classified as inter-state (IGST) or intra-state
(CGST+SGST) based on the shipping address vs. the firm's registered
state. Accurate return filing (GSTR-1, GSTR-3B) depends on perfect
classification — a single wrong tax code cascades into penalties.
Solution
Tax regime is computed automatically from the selected customer's state and the firm's registered state. The UI displays the applicable rates before the invoice is saved, preventing errors at the point of entry.
-
4. Real business constraints
The system replaced physical ledgers mid-financial-year.
Migration had to be lossless — every open invoice, every uncleared
cheque, every previous month's return. And the UI had to be intuitive
enough for a non-technical accountant to use daily.
Solution
A structured import process with validation reports, plus close collaboration with the accountant during UAT. The interface uses familiar metaphors (ledger, journal, voucher) and keyboard shortcuts for rapid data entry.
— By the Numbers
⚡ Code Highlight
The GST engine determines tax type from shipping addresses, then computes CGST+SGST or IGST with deterministic rounding.
export function calculateGST(input: GSTInput): GSTCalculation { const { firmStateCode, partyStateCode, taxableAmount, hsnCode, overrideRate } = input; // Intra-state → CGST+SGST, Inter-state → IGST const isIntraState = firmStateCode === partyStateCode; const gstType = isIntraState ? 'CGST_SGST' : 'IGST'; const fullRate = overrideRate ?? getGstRate(hsnCode); const round2 = (n: number) => Math.round(n * 100) / 100; if (isIntraState) { const halfRate = fullRate / 2; const cgstAmt = round2(taxableAmount * halfRate / 100); const sgstAmt = round2(taxableAmount * halfRate / 100); return { gst_type: 'CGST_SGST', cgst_rate: halfRate, sgst_rate: halfRate, igst_rate: 0, cgst_amount: cgstAmt, sgst_amount: sgstAmt, igst_amount: 0, net_amount: taxableAmount + cgstAmt + sgstAmt, }; } // IGST for inter-state — single rate applied ... }
05 Results & Impact
The portal went from zero to live production in active use by the business accountant. Here's what changed:
06 What I Learned
Building a real-world accounting system for a real accountant taught me things no tutorial ever could:
- Accounting is not CRUD. Every transaction has a double-sided effect — a sale isn't just an insert; it's a debit to receivables and a credit to revenue. The data model must mirror double-entry principles faithfully, or the numbers will never balance.
- GST is a state-level puzzle. India's GST regime treats every inter-state sale differently from intra-state. The same product sold to a customer in Maharashtra vs. Tamil Nadu triggers different tax line items. Getting this right required studying actual GST laws, not just reading API docs.
- RLS is your best friend. With five firms sharing one database, Row-Level Security wasn't a nice-to-have — it was the difference between a deployable system and a liability. Every policy was tested with multiple roles before going live.
- Non-technical users demand UX rigour. The accountant doesn't care about the stack. They care that the "Save" button works, that the ledger balances, and that they can find last month's return in two clicks. Testing with the actual end user surfaced more bugs than 50 unit tests ever did.
The most satisfying line of code I wrote wasn't a clever React hook or a fancy SQL window function. It was the migration script that successfully imported 6 months of physical ledger data — and the accountant's message: "The numbers match."