CRM
GitHubA two-sided B2B trade workspace: the sales side qualifies accounts and contacts, quotes from a product catalogue, runs the pipeline to won, and confirms the deal; the purchase side raises purchase orders against suppliers and confirms the buy. Both sides work from master data imported from the company's system of record — an ERP or accounting system that owns customers, items, and vendors — and export their committed documents in a fixed, versioned shape. One entry, no re-keying.
This is an executable Bolt template, not a production-operations manual. It demonstrates server-enforced document lifecycles, revision-safe quoting, snapshot line items, money arithmetic that holds up to reconciliation, a cost-secrecy boundary drawn by policy omission, and idempotent master-data imports keyed on the external system's own codes.
The mental model
external system of record (owns customers, items, vendors)
│ master exports, imported
▼
accounts · products · suppliers (the masters — keyed on external_code, edited in place)
sales chain: contact → quote → quote_lines →(confirm)→ sales_invoices → sales_invoice_lines
quote ──▶ contract_signings · quote ──▶ settlements (received)
buy chain: supplier → purchase_orders → purchase_order_lines
purchase_orders ──▶ goods_receipts → goods_receipt_lines
purchase_orders ──▶ purchase_invoices → purchase_invoice_lines
purchase orders & invoices ──▶ settlements (paid)Four ideas carry the whole workspace:
- Masters in, documents out.
accounts,products, andsuppliershold the external system's masters: every row carries the system's own key inexternal_code, and each arrives through its collection'simportpipeline, which skips codes already on file so a re-imported export is a no-op. Committed documents go the other way:quotesandpurchase_ordersdeclare anexportpipeline that serializes a confirmed document and its lines. - A document is a lifecycle, not a row. Every document collection carries a status enum and a
+collection.tstransform that enforces a transition map.draftis the only editable state — lines, prices, and terms lock the moment a document leaves draft — and the terminal states are the ones that export, which is what makes their figures safe to hand across the boundary. - History is snapshots. Quote, order, and invoice lines snapshot the product code, name, unit, and price at creation, so a later catalogue edit never rewrites a historical document. Documents snapshot their account or supplier the same way.
- Money is decided in one place. Each line computes
net,tax, andline_totalonce, from the parent document's currency and tax mode; a document total is the sum of already-rounded lines. Paid / partial / unpaid is never stored — it is derived at render from settlements against the document gross, and only for committed documents.
Lifecycles
quote: draft ──▶ sent ──▶ won ──▶ confirmed (terminal)
sent ──▶ draft = revision (revision_number+1, revision_of set)
draft/sent/won ──▶ lost ──▶ won (a lost deal may reopen)
draft/sent/won ──▶ cancelled (terminal, reason required)
purchase order: draft ──▶ submitted ──▶ confirmed (terminal) · cancelled
sales invoice: draft ──▶ issued (terminal) · cancelled
purchase invoice: draft ──▶ confirmed (terminal, the three-way match checkpoint) · cancelled
contract signing: unstamped ──▶ counterparty_stamped ──▶ acknowledged · voided (re-signing)
goods receipts: no status — a receipt is an immutable eventConfirming is re-checked against the masters: the account or supplier must still be active, the
document must carry at least one line, and every line's product must still be active — a document
never confirms against stale master data. A quote under adverse credit (account on hold, or over its limit)
confirms only with an explicit credit_acknowledged, which lands in the audit trail. Cancelling
any document requires a reason. Sent quotes past valid_until are caught by the daily automation.
Entities
| Collection | Role |
|---|---|
accounts |
Customer companies — the ERP customer master, carrying the credit position. |
contacts |
People at accounts: decision-makers, buyers, day-to-day contacts. |
quotes |
The sales pipeline document, with trade terms on the header and a revision lineage. |
quote_lines |
Line items: product snapshot plus computed amounts. Editable only while draft. |
sales_invoices |
Billing raised against a confirmed quote; lines allocate quoted quantities. |
sales_invoice_lines |
One billed quantity per quote line, capped across live invoices. |
contract_signings |
The confirmed quote's contract lifecycle; binding_hash fingerprints the quote substance at generation. |
activities |
Polymorphic interaction log (call / meeting / email / task / note) linked by regarding_type + regarding_id. |
products |
Sellable catalogue — the ERP item master. Sell prices and tax rate only; cost never lives here. |
settlements |
Payments in or out against any committed document. Paid status derived at render. |
suppliers |
Vendors — the ERP vendor master, with contact, category, and payment terms. |
purchase_orders |
The buying pipeline document, snapshotting the supplier and inheriting its currency. |
purchase_order_lines |
Line items carrying the struck unit cost — a buy-side fact sales has no grant to read. |
goods_receipts |
Received-against-order events; remaining-to-receive is derived, never stored. |
goods_receipt_lines |
Received quantities per order line, capped at the ordered quantity. |
purchase_invoices |
Supplier invoices booked against a confirmed order; draft → confirmed is the three-way match checkpoint. |
purchase_invoice_lines |
Invoiced quantities and costs per order line, capped across live invoices. |
What ships
Apps
| App | What a user does |
|---|---|
crm |
Sales CRM. The account selector in the header scopes the page (defaults to the first active account). Pipeline kanban over the active quote statuses with a rep filter, then quotes, quote lines, contacts, activities, invoices, invoice lines, contracts, and payments for that account — plus the accounts and products catalogues. |
crm_purchase |
Purchasing workspace. A dashboard of PO counts per status, committed spend per currency, and top suppliers; then purchase orders, PO lines, suppliers, goods receipts, receipt lines, purchase invoices, invoice lines, and payments. |
Automation
quote_expiry_watch — daily at 06:00, a read-only sweep of sent quotes past valid_until, written
to an expired-quotes.json export attachment. It never mutates a quote.
Pipelines and policies
- Import — the ERP's masters.
accounts,products, andsupplierseach declare animportpipeline (lib/erp-feed.ts) that decodes a delivered page of customers, items, or vendors and writes the returned rows. A malformed page fails the whole batch; a code already on file is skipped, so importing the same export twice changes nothing. - Export — confirmed documents.
quotesandpurchase_ordersdeclare anexportpipeline that builds a versioned JSON attachment (norbital.crm.confirmed_quote.v1, …) from the document and its lines. It is field-enumerated, so cost and other internal facts can never serialize.
| Policy | Apps | What it owns |
|---|---|---|
accounts_read |
— | The sole account-read grant, composed into Sales and the sales envoy. |
products_read |
— | The sole product-read grant, composed into both desks and the sales envoy. |
suppliers_manage |
— | The sole supplier read/mutate grant (new and existing), composed into Procurement. |
commercial_shared |
— | The settlement ledger (settlements read plus mutate for new records), shared by both desks and owned once. |
sales_rep |
crm |
Requestor-scoped quotes, sales invoices, and contract signings, plus their lines, contacts, and activities. |
procurement_officer |
crm_purchase |
Purchase orders and lines, goods receipts, and purchase invoices and lines. |
The sales/procurement split is drawn by omission, not masking. Bolt policies are
collection-scoped, so buy cost stays off the sales surface because sales has no grant for
purchase_order_lines (the only collection carrying a cost column) — and the buy side gets no
quote grant, so it never sees sell prices or margin. The shared catalogue grant exposes sell prices
only.
Functions
| Function | Purpose |
|---|---|
purchase_matching |
Ordered / received / invoiced per order line — the three-way match review. Cancelled invoices do not count. |
settlement_summary |
Paid-to-date per document for one regarding type — the input to derived paid / partial / unpaid badges. |
Neither function is mounted on a default surface: purchase_matching is the review a tenant
wires into its own match screen, and settlement_summary powers payment-status columns wherever a
tenant wants them. Both are ready to call through client.invoke. The mounted sales and purchasing
dashboards read their collections directly and derive their presentation locally, so the sync engine
updates them without a remote live-query function or refresh control.
Channel
sales_desk — a Telegram channel (src/channels/+sales_desk.ts) for customer-facing sales
enquiries, answered by the sales_desk envoy. The agent answers under the
same accounts_read, products_read, commercial_shared, and sales_rep policy set as the Sales
team, so a message from a customer cannot become a way around the permission model.
Seed
None. A fresh tenant starts empty: masters arrive by importing the ERP's customer, item, and vendor
exports, and everything else is entered by operators through the apps. There is deliberately no
+seed.ts — this workspace's data enters either through an import or through the UI.
Under the hood
src/
├── collections/ 17 collections, each in its own directory
│ ├── +relationship.ts one-to-many and many-to-one relations; line collections cascade
│ └── <collection>/
│ ├── +model.ts storage: columns, enums, indexes, recordLabel, icon
│ ├── +collection.ts the write contract: what a caller may submit, and the transform that
│ │ numbers, defaults, prices, caps and polices it
│ ├── +pipelines.ts master imports and confirmed-document exports
│ └── +representation.svelte create/edit form with human-readable relation labels
├── apps/ the two app surfaces
├── automations/ quote_expiry_watch, and one line roll-up per line collection and event
├── functions/ the two on-demand query handlers above
├── access/policies/ narrow shared coordinate owners plus sales and procurement
├── channels/ sales_desk, the Telegram channel
├── envoys/ sales_desk, the agent on that channel
├── lib/
│ ├── pricing.ts the only place rounding is decided
│ ├── document-lines.ts document totals from lines; the allocation ledger behind every cap
│ ├── document-rollup.ts the line-to-document roll-up the automations run
│ ├── document-numbers.ts PREFIX-YYYY-NNNN document numbering
│ ├── lifecycle.ts transition maps, batch pairing, and the small shared refusals
│ ├── erp-feed.ts the import every master feed lands through
│ ├── desk-date.ts calendar-day derivation in the desk's timezone
│ └── clock.ts the injected workflow clock
└── i18n/ messages.en.json + messages.zh.json, identical key sets- Collections declare what a caller may submit —
doc_no, snapshots and money columns are never in the selection, the transform derives them — and their transform runs once per batch: two read waves keyed by the inputs, then one decision per input. Transforms own the transition maps, document numbering, quantity caps (received and invoiced quantities can never pass the ordered or quoted quantity) and the credit gate. Lines are written on their own, so the line-to-document roll-up that keepsnet/tax/grossequal to the sum of the printed lines is a change-triggered automation per line collection, acting underdocument_rollup, which may write nothing but those three columns. - Document numbering (
lib/document-numbers.ts) issuesQT-,PO-,SI-,PI-, andGRN-YYYY-NNNNnumbers by reading the highest number already issued in the series; the unique index ondoc_nois what actually guarantees uniqueness, and the losing transaction fails and is retried. - Money (
lib/pricing.ts):roundHalfUpshifts the decimal exponent so1.005rounds to1.01, tax-inclusive lines take tax as the residualgross − net, anddocumentTotalssums already-rounded lines in minor units so a total always equals what a reader can add up. - Calendar days (
lib/desk-date.ts) resolve inAsia/Singapore—new Date().toISOString()would be the UTC day, a day behind for part of every day on a server west of Greenwich. Taskdue_datedefaults and purchase-orderexpected_date(two weeks out) use it. - Apps are declarative:
$statefor operator input (account selector, rep filter),$derivedfor everything downstream — label maps and queries. Collection surfaces bulk-resolve relation columns, while standalone relation pickers use the generic relationship renderer; authored code never queries platform-owned identity tables and never renders a UUID. The platform's user table remains internal and is not duplicated as a workspace collection. - Representations are the collection-owned create/edit surfaces. Relation fields use the
RelationshipRendererwith human labels (doc_no: title,code · name,first last), and the activities and settlements forms switch their target field byregarding_type. - i18n: app and component copy lives in
messages.en.json(source of truth) andmessages.zh.jsonwith the same key set; apps useuseI18n<TenantI18nKeys>(). App metadata in<svelte:head>stays static English, and the sidebar label localizes throughapp.<appId>.title.
Changing the template
Run from the template directory; .norbital/ generated output is rebuilt and never hand-edited:
pnpm sync # bolt sync — regenerates .norbital/, may add a migration
pnpm lint # prettier --check + svelte-checksync also emits the deployable portable artifact at .norbital/artifact/bundle.mjs; there is no
separate per-template build command. The templates repository provides the same loops across every
template (pnpm --dir crm sync, pnpm --dir crm lint, and repo-root pnpm templates:verify,
which proves each template installs, syncs, and lints from tracked files alone).
bolt syncmay create or update.norbital/migrations/. That directory is generated but committed — commit it with the authored change.workspaceSchemaFingerprinthashes the committed mutation-visible schema, so never edit the generated lineage by hand.- There is no seed script, so deployed data evolves through committed migrations, not seeds: for a
change that must apply to existing tenants, write the next lineage entry with
pnpm exec bolt migrate --name <name>, edit its SQL, and run it through the update flow below. - Publishing: pushing to
mainof the templates repository republishesrefs/heads/templates/crm— a fast-forward-only subtree split of this directory. A tenant is forked from the exact advertised commit when Colony provisions it, so it shares ancestry but never moves merely because the ref advances. From the realm root,pnpm run env -- linktests local OSS packages inside this template; it does not link a template release into Colony or update a tenant. The templates repository README documents the full release and tenant lifecycle.