Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/seed-billing-addresses-territory-demo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'hotcrm': patch
---

Give the nine demo accounts a billing address, so the two territory sharing
rules match real records instead of an empty set. `north_america_territory` and
`europe_territory` filter on `crm_account.billing_country`, and until now not
one seeded account carried a `billing_address` for that column to be projected
from — both rules installed correctly and then covered zero accounts, so Setup
showed two territories with nothing behind them. The addresses partition the
set deliberately across all three outcomes the rules can produce: six in North
America (five US, one CA), two in Europe (DE, UK) and one outside both (SG),
with the account phone numbers moved to match their new countries. A rule with
no matching record is indistinguishable from a rule that never seeded, so
`test/territory-seed-coverage.test.ts` now walks the whole chain — seed record →
the real `account_protection` projection → the seeder's own CEL compiler → which
accounts each territory covers — and fails when any bucket empties out.

`billing_country` is deliberately still not authored in the seeds: hooks DO run
over seed writes (the loader's `skipTriggers` suppresses record-change
automation, not lifecycle hooks), so the projection is computed at seed time,
and the seed doctrine block that claimed the opposite has been corrected.

The seed fixtures are now split by object family — `catalog`, `sales`,
`service`, `marketing` and `revenue` `*.seed.ts` modules with `src/data/index.ts`
reduced to the aggregating `CrmSeedData` export. The single file was 1.5KB under
the 100KB source-hygiene cap, so this change would not have fit; the split makes
where-to-add-a-record follow from the object, and a new test fails if a family
module's dataset is never wired into `CrmSeedData`. Fixes #638. Refs #635, #617,
#621.
93 changes: 93 additions & 0 deletions src/data/_shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Seed helpers shared by every `*.seed.ts` family module.
*
* The seed data used to be one 100KB `src/data/index.ts`, which sat ~1.5KB
* under the `pnpm hygiene` byte cap and turned the next demo record into a
* build failure (#635). It is now split by object family — `catalog`, `sales`,
* `service`, `marketing`, `revenue` — with `index.ts` reduced to the
* aggregating `CrmSeedData` export. This module holds what more than one
* family needs.
*/
import { cel } from '@objectstack/spec';

/**
* A note on system-computed fields in seeds (#490, #617).
*
* ### Hooks DO run over seed writes — automations do not
*
* The seed loader writes with `{ isSystem: true, skipTriggers: true,
* seedReplay: true }`, and the premise this file used to state — "hooks do NOT
* run over seed rows" — was wrong (#617). Measured against 17.0.0-rc.1, and
* matching `SeedLoaderService.SEED_OPTIONS`' own contract:
*
* - `isSystem` bypasses RBAC and DISABLES the security plugin's injection
* of `organization_id` / `owner_id` — which is why seeded rows
* land ownerless and `demo_bootstrap` has to claim them.
* - `skipTriggers` suppresses record-change AUTOMATION (autolaunched flows),
* not lifecycle hooks. Seed data is pre-existing end state,
* not a stream of user events.
* - `seedReplay` skips the `state_machine` entry/transition guards, so a
* mid-lifecycle row (a `closed_won` deal) is accepted.
*
* Lifecycle HOOKS — derived fields, defaults, validation — still run. The boot
* log shows them firing (`opportunity_amount_rollup`, `quote_total_rollup`),
* and `crm_account.billing_country` is only ever populated because
* `account_protection` projects it from `billing_address` on the seed insert
* (#638). `test/territory-seed-coverage.test.ts` pins that dependency.
*
* ### What that means for authoring
*
* The old conclusion survives the corrected premise, and is still the rule:
*
* 1. Seeding historical values into readonly fields (`created_date`,
* `stage_entry_date`, `last_contacted_date`, `actual_revenue`) is legitimate
* and load-bearing — it is the only way demo reports get history — and the
* platform explicitly preserves explicit seed values.
* 2. Every seeded value of a hook-owned field MUST equal what the hook would
* compute (`is_closed` ⇔ status, `resolution_time_hours` ⇔ closed−created,
* opportunity `probability`/`forecast_category`/`expected_revenue` ⇔ stage,
* forecast `period_label` ⇔ period_start). Under the corrected premise a
* mismatch is rewritten immediately, at seed time, instead of on the first
* user edit — either way the demo does not show what was authored.
* 3. A field the hook DERIVES and the schema marks `readonly` is not authored
* here at all (`crm_account.billing_country`): duplicating it would create a
* second source of truth for a value the hook already owns.
*
* Autonumber fields (`case_number`, `contract_number`, `quote_number`) are
* NEVER seeded: the runtime owns those sequences (the SQL driver bootstraps
* each counter past existing rows), so hand-numbering them only invites
* drift. Upsert identity uses a natural key instead (subject / name /
* description).
*/

/**
* Build a CEL `daysAgo(N)` expression from a runtime number. Mirrors the
* existing tagged-template usage (`cel\`daysAgo(N)\``) so we can produce
* timestamps inside `.map()` generators without manufacturing fake template
* string arrays.
*/
export const celDaysAgo = (n: number) => cel`daysAgo(${n})`;
export const celDaysFromNow = (n: number) => cel`daysFromNow(${n})`;

/**
* A configured product line. `unit_price` is the NEGOTIATED price (what the
* rep sold at); the catalog `list_price` is stamped separately from the
* product record, exactly as the price-fill hook would.
*/
export type LineSpec = {
readonly product: string;
readonly quantity: number;
readonly unit_price: number;
/** Line-level discount %, defaults to 0. */
readonly discount?: number;
readonly description: string;
};

/** `quantity × unit_price × (1 − discount/100)`, rounded exactly as the rollup hooks round. */
export const lineTotal = (l: LineSpec): number =>
Math.round(l.quantity * l.unit_price * (1 - (l.discount ?? 0) / 100) * 100) / 100;

export const linesTotal = (lines: readonly LineSpec[]): number =>
Math.round(lines.reduce((sum, l) => sum + lineTotal(l), 0) * 100) / 100;
228 changes: 228 additions & 0 deletions src/data/catalog.seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Product catalogue seeds — and the two helpers that read prices back out of
* it, so a line item's `list_price` can never disagree with the catalogue.
*
* Split out of the former monolithic `src/data/index.ts` (#635). Seed doctrine
* lives in `./_shared.ts`.
*/
import { defineSeed } from '@objectstack/spec/data';
import { Product } from '../objects/product.object';
import type { LineSpec } from './_shared';

// ─── Products ─────────────────────────────────────────────────────────
// The catalog is what every line item below prices against, so it has to be
// wide enough to configure a realistic deal: an edition, an add-on, a
// per-seat subscription, support tiers and professional services. Four
// products could not (#591).
//
// `cost` is populated on every product: `cost_less_than_price` is a real
// validation rule that "has never once evaluated" while the field was blank
// everywhere, and margin is what the product-mix analytics are for. Every row
// keeps cost strictly below list price.
export const products = defineSeed(Product, {
mode: 'upsert',
externalId: 'name',
records: [
{
name: 'ObjectStack Platform',
description: 'The enterprise edition: unlimited objects, AI agents, governance and audit.',
category: 'software',
family: 'enterprise',
sku: 'OS-PLAT-ENT',
list_price: 50000,
cost: 12000,
billing_type: 'annual',
unit_of_measure: 'license',
is_active: true,
},
{
name: 'ObjectStack Platform (SMB Edition)',
description: 'The mid-market edition: core CRM objects and automation, capped agent usage.',
category: 'software',
family: 'smb',
sku: 'OS-PLAT-SMB',
list_price: 18000,
cost: 4500,
billing_type: 'annual',
unit_of_measure: 'license',
is_active: true,
},
{
name: 'Cloud Hosting (Annual)',
description: 'Managed hosting with regional data residency and a 99.9% availability target.',
category: 'subscription',
family: 'cloud',
sku: 'OS-CLOUD-HOST',
list_price: 12000,
cost: 4200,
billing_type: 'annual',
unit_of_measure: 'each',
is_active: true,
},
{
name: 'Sandbox Environment (Annual)',
description: 'A full-copy non-production environment for testing metadata changes before release.',
category: 'subscription',
family: 'cloud',
sku: 'OS-CLOUD-SBX',
list_price: 7500,
cost: 2600,
billing_type: 'annual',
unit_of_measure: 'each',
is_active: true,
},
{
name: 'AI Agent Seat (Annual)',
description: 'A named-user seat for the Co-Pilot and agent surfaces, billed annually.',
category: 'subscription',
family: 'cloud',
sku: 'OS-AI-SEAT',
list_price: 1000,
cost: 260,
billing_type: 'annual',
unit_of_measure: 'seat',
is_active: true,
},
{
name: 'Analytics Add-on',
description: 'Dashboards, cubes and scheduled reporting for the revenue organization.',
category: 'software',
family: 'enterprise',
sku: 'OS-ADDON-ANL',
list_price: 22000,
cost: 6500,
billing_type: 'annual',
unit_of_measure: 'license',
is_active: true,
},
{
name: 'Integration Connector Pack',
description: 'Pre-built connectors for ERP, marketing automation and data warehouse targets.',
category: 'software',
family: 'enterprise',
sku: 'OS-ADDON-INT',
list_price: 16000,
cost: 5200,
billing_type: 'annual',
unit_of_measure: 'license',
is_active: true,
},
{
name: 'Field Service Mobile',
description: 'Offline-capable mobile app for field technicians and route-based service work.',
category: 'software',
family: 'smb',
sku: 'OS-ADDON-FSM',
list_price: 14000,
cost: 4200,
billing_type: 'annual',
unit_of_measure: 'license',
is_active: true,
},
{
name: 'Premium Support',
description: '24×7 support with a one-hour P1 response target and a named technical account manager.',
category: 'support',
family: 'services',
sku: 'OS-SUP-PREM',
list_price: 25000,
cost: 9000,
billing_type: 'annual',
unit_of_measure: 'each',
is_active: true,
},
{
name: 'Standard Support',
description: 'Business-hours support with a next-business-day response target.',
category: 'support',
family: 'services',
sku: 'OS-SUP-STD',
list_price: 9000,
cost: 3600,
billing_type: 'annual',
unit_of_measure: 'each',
is_active: true,
},
{
name: 'Implementation Services',
description: 'Guided implementation: discovery, metadata build, integration and go-live support.',
category: 'service',
family: 'services',
sku: 'OS-SVC-IMPL',
list_price: 75000,
cost: 41000,
billing_type: 'one_time',
unit_of_measure: 'each',
is_active: true,
},
{
name: 'Data Migration Services',
description: 'Extraction, mapping and reconciliation of legacy CRM and spreadsheet data.',
category: 'service',
family: 'services',
sku: 'OS-SVC-MIGR',
list_price: 35000,
cost: 19000,
billing_type: 'one_time',
unit_of_measure: 'each',
is_active: true,
},
{
name: 'Admin Training Workshop',
description: 'A one-day workshop for administrators on metadata, permissions and analytics.',
category: 'service',
family: 'services',
sku: 'OS-SVC-TRN',
list_price: 6000,
cost: 2400,
billing_type: 'one_time',
unit_of_measure: 'day',
is_active: true,
},
]
});

/**
* Catalog price of a seeded product, read back from the dataset above so the
* price lives in exactly one place.
*
* Line items carry `list_price` explicitly because hooks do NOT run over seeds
* (#490): the shared price-fill hook (`_line-item-price-fill.ts`) is what
* stamps `list_price` from `crm_product.list_price` on a real write, so a
* seeded line has to arrive already carrying what that hook would have
* written. Reading it from the catalog record makes that literally impossible
* to get wrong.
*/
export const catalogPrice = (productName: string): number => {
const product = products.records.find((r) => r.name === productName);
if (!product || typeof product.list_price !== 'number') {
throw new Error(`Seed error: no catalog product named "${productName}"`);
}
return product.list_price;
};

/**
* Flatten a `{ parent → lines }` table into seed records for one line-item
* object. `list_price` and `line_number` are the two fields a real write gets
* from machinery a seed cannot count on (the price-fill hook, and the quote /
* opportunity line editors), so both are materialised here — the row is then
* correct whether or not the hook fires over a seed write (#617).
*/
export const lineItemRecords = <K extends string>(
parentField: K,
table: Record<string, readonly LineSpec[]>,
): Array<Record<string, unknown>> =>
Object.entries(table).flatMap(([parent, lines]) =>
lines.map((l, i) => ({
[parentField]: parent,
crm_product: l.product,
description: l.description,
quantity: l.quantity,
list_price: catalogPrice(l.product),
unit_price: l.unit_price,
discount: l.discount ?? 0,
line_number: i + 1,
})),
);
Loading
Loading