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 mirror master data in from the company's external system of record — an ERP or accounting system that owns customers, items, and vendors — and hand their committed documents back out across the boundary. 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 a sync registry that keeps the workspace in step with an external system.
The mental model
external system of record (owns customers, items, vendors)
▲ │ hourly pull of changed masters
│ confirmed quote / confirmed PO ▼
│ booked across the boundary accounts · products · suppliers
│ (the mirrors — 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:
- Mirrors in, documents out.
accounts,products, andsuppliersare the external system's tables: every row carries the system's own key inexternal_code, and a scheduled pull keeps them in step. The workspace never invents a customer, item, or vendor. Committed documents go the other way: confirming a quote or a purchase order hands it across the boundary, where the system of record books it. - A document is a lifecycle, not a row. Every document collection carries a status enum and a
hook 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 the external system books, 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, books into the ERP)
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, books into the ERP) · 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 — stale master
data never books into the ERP. 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 mirror, 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 mirror. 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 mirror, 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.
Integrations and policies
One erp connection (a placeholder baseUrl, and a bearer token referenced by name from
src/+env.ts — never a secret value in the workspace):
- Inbound — the ERP syncs its masters over.
accounts,products, andsuppliersdeclare a scheduled pull (customers_changed,items_changed,vendors_changed, hourly at minute 15). The host fetches with the connection's credential, parses the body against the binding's schema, hands it to the collection'simportpipeline, and writes the returned rows into the mirror. The resume point is the platform's cursor, so a missed window resumes where it stopped; codes already on file are skipped. - Outbound — confirmed documents are handed over.
quotesandpurchase_ordersdeclare a send binding on thedraft → confirmedtransition. The mutation writes the record to the platform's transactional outbox in the same transaction — a delivery is never queued for a write that rolled back. The host drains the outbox: the collection'sexportpipeline builds the payload (field-enumerated, so cost and other internal facts can never serialize), the binding'stransformshapes it into the request body (POST /docs/confirmed), and delivery retries with capped backoff and dead-letters after ten attempts.
| Policy | Apps | What it owns |
|---|---|---|
commercial_shared |
— | The shared book: catalogue read (products) and the settlement ledger (settlements read/create), owned once so the composition stays unambiguous. |
sales_rep |
crm |
Its own quotes, sales invoices, and contract signings (scoped to the requestor), their lines, contacts and activities; reads accounts. The catalogue and settlements come from commercial_shared. |
procurement_officer |
crm_purchase |
Suppliers, purchase orders and lines, goods receipts, purchase invoices and lines; the settlement ledger and catalogue come from commercial_shared. |
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 for customer-facing sales enquiries. The agent answers under the
sales_rep policy, so a message from a customer cannot become a way around the permission model.
Seed
None. A fresh tenant starts empty: masters arrive through the ERP pull once the tenant's connection
is provisioned (baseUrl + EXTERNAL_SYSTEM_TOKEN), and everything else is entered by operators
through the apps. There is deliberately no +seed.ts — this workspace's data enters either through
the integration 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
│ ├── +hooks.ts lifecycle enforcement: transitions, defaults, caps, rollups
│ ├── +pipelines.ts canonical import/export shaping for the integration
│ ├── +integrations.ts the erp connection: pull bindings, outbox send bindings
│ └── +representation.svelte create/edit form with human-readable relation labels
├── apps/ the two app surfaces
├── automations/ quote_expiry_watch
├── functions/ the two on-demand query handlers above
├── access/policies/ commercial_shared, sales_rep, procurement_officer
├── envoys/ sales_desk
├── lib/
│ ├── pricing.ts the only place rounding is decided
│ ├── numbering.ts PREFIX-YYYY-NNNN document numbering
│ └── calendar.ts calendar-day derivation in the desk's timezone
├── i18n/ messages.en.json + messages.zh.json, identical key sets
└── +env.ts EXTERNAL_SYSTEM_TOKEN, declared by name only- Hooks validate and return the accepted input, then make same-transaction reads. They own the
transition maps, document numbering, quantity caps (received and invoiced quantities can never
pass the ordered or quoted quantity), the credit gate, and the line-to-document rollup that keeps
net/tax/grosson every document equal to the sum of its printed lines. - Document numbering (
lib/numbering.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/calendar.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. Every relation column renders through a label map from one page-level query (client.db.user, the scoped quote/invoice lists), never a query per row, and never a UUID. Owner names come fromclient.db.user; the platform's user table is not duplicated. - 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 templates:sync, templates:lint, and templates:verify, which proves each
template installs, syncs, lints, and exposes its compiled contracts from tracked files alone).
bolt syncmay create or update.norbital/migrations/. That directory is generated but committed — commit it with the authored change.migrationFingerprinthashes its raw bytes, so never reformat it 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.pnpm yalc:linkis only for testing 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.