diff --git a/docs/agenstra/deployment/local-development.md b/docs/agenstra/deployment/local-development.md index fefc0eece..f6d3ddce5 100644 --- a/docs/agenstra/deployment/local-development.md +++ b/docs/agenstra/deployment/local-development.md @@ -217,6 +217,17 @@ docker ps # Linux: sudo systemctl start docker ``` +## Demo Data + +To fill a local stack with demo workspaces, environments, tickets, automation runs, knowledge and statistics, start both stacks with their `start-containers` targets, wait for the APIs to finish their migrations, and run: + +```bash +nx run demo-data:seed:agenstra # replaces previously seeded demo data +nx run demo-data:reset:agenstra # removes all demo data again +``` + +The seeder also connects the controller containers to the agent-manager network so seeded workspaces reach the local manager. Accounts and details are listed in [`tools/demo-data/README.md`](../../../tools/demo-data/README.md). + ## Troubleshooting ### Database Connection Issues diff --git a/docs/decabill/deployment/local-development.md b/docs/decabill/deployment/local-development.md index dc3df4101..c9fb1c40a 100644 --- a/docs/decabill/deployment/local-development.md +++ b/docs/decabill/deployment/local-development.md @@ -210,6 +210,17 @@ When `QUEUE_ROLE=all` or `api` with `QUEUE_BULL_BOARD_ENABLED=true`: See **[Background Jobs](./background-jobs.md)**. +## Demo Data + +To fill a local stack with plausible demo data (every tenant, all account, subscription, invoice and offer states, dummy provisioned servers), start the containers with `nx run decabill-backend-billing-manager:start-containers`, wait for the API to finish its migrations, and run: + +```bash +nx run demo-data:seed:decabill # replaces previously seeded demo data +nx run demo-data:reset:decabill # removes all demo data again +``` + +Only rows created by the tool are touched. Accounts, the shared demo password and details are listed in [`tools/demo-data/README.md`](../../../tools/demo-data/README.md). + ## Troubleshooting ### Database Connection Issues diff --git a/graph/graph.json b/graph/graph.json index 7d0a959f5..b022f5332 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-05T19:13:17.421Z", + "generatedAt": "2026-10-06T20:03:43.002Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -1830,6 +1830,27 @@ "featureGroup": "app" } }, + { + "id": "project:demo-data", + "type": "tool", + "attrs": { + "name": "demo-data", + "root": "tools/demo-data", + "tags": [ + "npm:private", + "type:tool", + "scope:repo" + ], + "type": "tool", + "targets": [ + "lint", + "build", + "test", + "seed", + "reset" + ] + } + }, { "id": "project:graph", "type": "tool", @@ -13167,6 +13188,24 @@ "projectName": "release-integrity" } }, + { + "id": "file:tools/demo-data/README.md", + "type": "readme", + "attrs": { + "path": "tools/demo-data/README.md", + "languageOrKind": "md", + "projectName": "demo-data" + } + }, + { + "id": "file:tools/demo-data/src/lib/agenstra/agenstra-controller.builder.ts", + "type": "controller", + "attrs": { + "path": "tools/demo-data/src/lib/agenstra/agenstra-controller.builder.ts", + "languageOrKind": "ts", + "projectName": "demo-data" + } + }, { "id": "file:tools/graph/README.md", "type": "readme", @@ -24829,6 +24868,16 @@ "domain": "agenstra" } }, + { + "id": "concept:agenstra-demo-data", + "type": "concept", + "attrs": { + "title": "Demo Data", + "docPath": "docs/agenstra/deployment/local-development.md", + "sectionAnchor": "demo-data", + "domain": "agenstra" + } + }, { "id": "concept:agenstra-operator-runbook", "type": "concept", @@ -28739,6 +28788,16 @@ "domain": "decabill" } }, + { + "id": "concept:decabill-demo-data", + "type": "concept", + "attrs": { + "title": "Demo Data", + "docPath": "docs/decabill/deployment/local-development.md", + "sectionAnchor": "demo-data", + "domain": "decabill" + } + }, { "id": "concept:decabill-troubleshooting", "type": "concept", @@ -41651,6 +41710,16 @@ "to": "file:tools/release-integrity/README.md", "type": "contains" }, + { + "from": "project:demo-data", + "to": "file:tools/demo-data/README.md", + "type": "contains" + }, + { + "from": "project:demo-data", + "to": "file:tools/demo-data/src/lib/agenstra/agenstra-controller.builder.ts", + "type": "contains" + }, { "from": "project:graph", "to": "file:tools/graph/README.md", @@ -47266,6 +47335,11 @@ "to": "concept:agenstra-linux-sudo-systemctl-start-docker", "type": "contains" }, + { + "from": "file:docs/agenstra/deployment/local-development.md", + "to": "concept:agenstra-demo-data", + "type": "contains" + }, { "from": "file:docs/agenstra/deployment/local-development.md", "to": "concept:agenstra-troubleshooting", @@ -49956,6 +50030,11 @@ "to": "concept:decabill-bull-board-local", "type": "contains" }, + { + "from": "file:docs/decabill/deployment/local-development.md", + "to": "concept:decabill-demo-data", + "type": "contains" + }, { "from": "file:docs/decabill/deployment/local-development.md", "to": "concept:decabill-troubleshooting", @@ -73106,6 +73185,11 @@ "to": "project:decabill-frontend-docs", "type": "documents" }, + { + "from": "concept:decabill-demo-data", + "to": "project:decabill-backend-billing-manager", + "type": "documents" + }, { "from": "concept:decabill-install-verification-checklist", "to": "api:HTTP:GET:/health", @@ -79516,6 +79600,11 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "concept:agenstra-demo-data", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "concept:agenstra-operator-runbook", "to": "domain:agenstra", @@ -81471,6 +81560,11 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "concept:decabill-demo-data", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "concept:decabill-troubleshooting", "to": "domain:decabill", diff --git a/tools/demo-data/.eslintrc.json b/tools/demo-data/.eslintrc.json new file mode 100644 index 000000000..49a3fa532 --- /dev/null +++ b/tools/demo-data/.eslintrc.json @@ -0,0 +1,39 @@ +{ + "extends": ["../../.eslintrc.json", "../../.eslintrc.overrides.json"], + "ignorePatterns": ["!**/*", "dist/**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredFiles": [ + "{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}" + ] + } + ] + } + }, + { + "files": ["./package.json", "./generators.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/nx-plugin-checks": "error" + } + } + ] +} diff --git a/tools/demo-data/README.md b/tools/demo-data/README.md new file mode 100644 index 000000000..36aa8b978 --- /dev/null +++ b/tools/demo-data/README.md @@ -0,0 +1,139 @@ +# @forepath/demo-data + +Nx project **`demo-data`**: seeds plausible random demo data into the **Decabill** and **Agenstra** databases of a local container stack, and removes it again. + +The seeders assume the stacks were started with each app's `start-containers` target, so the containers use the values in `.start-containers.env`: + +```bash +nx run decabill-backend-billing-manager:start-containers +nx run agenstra-backend-agent-manager:start-containers +nx run agenstra-backend-agent-controller:start-containers +``` + +Wait until the API containers are healthy before seeding. They run the migrations on start, and the seeder refuses to run against an incomplete schema. + +## Usage + +```bash +nx run demo-data:seed # Decabill + Agenstra +nx run demo-data:seed:decabill +nx run demo-data:seed:agenstra + +nx run demo-data:reset # remove all demo rows again +nx run demo-data:reset:decabill +nx run demo-data:reset:agenstra +``` + +CLI (after `nx run demo-data:build`, from the repository root): + +```bash +node tools/demo-data/dist/src/cli.js seed decabill --seed 42 +node tools/demo-data/dist/src/cli.js seed all --dry-run --sql-out tmp/demo-sql +node tools/demo-data/dist/src/cli.js reset agenstra +``` + +| Option | Description | +| ----------------- | ----------------------------------------------------------------- | +| `--seed ` | Random seed; the same seed produces the same data set | +| `--dry-run` | Build SQL only; no container is contacted | +| `--sql-out ` | Write the generated SQL scripts (useful for review and debugging) | + +- **`seed`** first removes previously seeded rows and then inserts a fresh data set, so it can be repeated. +- **`reset`** only removes rows created by this tool. Every seeded row has an id starting with **`5eedda7a-`**. Real data in the same database is never touched. +- Each database is written in a single transaction (`psql --single-transaction`). If any statement fails, nothing is applied. + +## How it connects + +The compose files do not publish the Postgres ports. The tool runs SQL through `docker exec psql`. It reads database names and `ENCRYPTION_KEY` from each app's `.start-containers.env`, and falls back to the compose defaults for empty values. Encrypted columns use the same AES-256-GCM format as `@forepath/shared/backend/util-crypto`. + +| Variable | Default | +| -------------------------------------------------- | --------------------------------- | +| `DEMO_DATA_DECABILL_POSTGRES_CONTAINER` | `billing-manager-postgres` | +| `DEMO_DATA_AGENSTRA_CONTROLLER_POSTGRES_CONTAINER` | `agent-controller-postgres` | +| `DEMO_DATA_AGENSTRA_MANAGER_POSTGRES_CONTAINER` | `agent-manager-postgres` | +| `DEMO_DATA_AGENSTRA_MANAGER_ENDPOINT` | `http://agent-manager-api:` | +| `DEMO_DATA_AGENSTRA_CONNECT_NETWORK` | `true` | + +## Accounts + +All demo accounts use the password **`Demo-Passw0rd!`**. Accounts with TOTP use the base32 secret **`KRUGKIDROVUWG2ZAMJZG653OEBTG66BAJJ2W24DT`**. Pending email confirmations and password resets use the code **`DEMO42`**. These are public demo values. Never use them outside local environments. + +One account exists per state (local part = state key): + +| Account | State | +| ----------------------------- | --------------------------------------- | +| `admin` | Administrator | +| `admin-totp` | Administrator with authenticator app | +| `user` | Active user | +| `user-email-2fa` | Email 2FA opt-in | +| `user-totp` | Authenticator app | +| `user-unconfirmed` | Registered, email not confirmed | +| `user-locked` | Locked | +| `user-password-reset` | Password reset pending | +| `user-password-reset-expired` | Password reset expired | +| `user-sso` | Keycloak-linked (no local password) | +| `user-sessions-revoked` | Sessions revoked (bumped token version) | +| `user-billing-day` | Fixed billing day of month | +| `controller` (Agenstra only) | Service account role | + +- **Decabill:** `@.decabill.example` for **every** tenant (`default` plus `TENANTS`). Send the matching `X-Tenant` header, or use the tenant's console URL. +- **Agenstra:** `@agenstra.example`. + +With `DISABLE_FORCE_LOGIN_2FA=false` (the default), every password login also asks for an emailed code. Read it in MailHog (Decabill: http://localhost:8026, Agenstra: http://localhost:8025). + +## What gets seeded + +### Decabill (per tenant) + +- **Customers:** customer profiles with complete, incomplete and missing data; VAT ids in all validation states; trust levels; auto billing on and off. Customers come from DE, AT, NL, FR, PL, CH and US, so domestic VAT, reverse charge, OSS and third-country tax modes all appear. +- **Catalog:** + - Service types and plans: hourly, daily, monthly, quarterly and yearly; billing-only; reduced VAT; inactive plans. + - Meters, add-ons and cloud-init configs. + - Promotions: running, expired, upcoming, paused and capped. +- **Subscriptions:** every status. Server items are provisioned with hostname, IP and server snapshot, plus some failed and pending items. Also add-ons in every status, config changes, usage records, promotion redemptions, backorders and open positions. +- **Invoices:** every status, with line items, payment attempts, refunds and promotion applications. +- **Offers:** every status. +- **Projects:** milestones, tickets in every status and priority (with sub-tickets), comments, activity, and billed and unbilled time entries. +- **Back office:** suppliers, contracts and supplier invoices; DATEV debtor/creditor accounts and exports; OSS ledger; audit logs; webhooks with deliveries; email delivery log; personal access tokens. + +**Dummy provisioning:** server products use the provider id `demo`. It is not a registered provisioning module, so no remote resources are ever created. The provisioning job just marks such items active. Live server lookups fail and fall back to the seeded snapshot. Start, stop and restart fail, because there is no server. + +### Agenstra + +- **Agent manager:** six environments (agents) of every container type. Each has chat sessions and history, environment variables, deployment configurations and runs, and synced filter rules. Agents have no container, so the history is readable but new chat messages cannot run. +- **Controller:** + - **Workspaces:** three point to the local agent-manager; one remote Keycloak workspace is offline. + - Members with workspace roles, and agent credentials. + - **Tickets:** every status and priority, sub-tickets, comments, activity, AI body generation sessions. + - **Automation:** settings, runs in every final state with steps and leases. + - **Knowledge:** folders, pages and relations. + - **Statistics:** about 45 days of chat usage, filter drops and flags, entity events. + - **Configuration and integrations:** console filter rules with sync states, OpenCode workspace configs with MCP allow/deny lists, Atlassian imports, webhooks, the email log and personal access tokens. + +The local controller and manager run in separate compose networks. After seeding, the tool runs `docker network connect agent-manager-network` for the controller API, worker and scheduler, so workspaces can reach `http://agent-manager-api:3000`. `start-containers` recreates the containers, so seed again (or connect again) after restarting them. Set `DEMO_DATA_AGENSTRA_CONNECT_NETWORK=false` to skip this step. + +## Background jobs + +The seeded data is shaped so that the schedulers leave it mostly alone: + +- Next billing dates are at period end. +- Open backorders have a future retry date. +- Accepted offers are already fulfilled. +- Automation runs only on tickets that are not open. +- Atlassian imports are disabled. + +A few transitional states are processed by the running jobs within minutes, as they would be in production: + +- Decabill: pending instant cancellation, pending config changes, pending add-ons, and hourly/daily subscriptions. +- Agenstra: the automation scheduler only picks up approved open tickets, and the seed leaves none of those. + +Search results come from OpenSearch, which is filled on the next reindex run (`SEARCH_REINDEX_INTERVAL`). List pages read Postgres and show the data immediately. + +## Development + +```bash +nx run demo-data:build +nx run demo-data:test +``` + +Seeders live in `src/lib/decabill` and `src/lib/agenstra`. Shared helpers live in `src/lib/core`: SQL building, encryption, account states, random data and `docker exec` access. When a migration changes a seeded table, update the matching builder and the table order in `*.tables.ts`. diff --git a/tools/demo-data/jest.config.ts b/tools/demo-data/jest.config.ts new file mode 100644 index 000000000..38341226d --- /dev/null +++ b/tools/demo-data/jest.config.ts @@ -0,0 +1,7 @@ +export default { + displayName: 'demo-data', + preset: '../../jest.preset.cjs', + testEnvironment: 'node', + coverageDirectory: '../../coverage/tools/demo-data', + testMatch: ['**/src/**/*.spec.ts'], +}; diff --git a/tools/demo-data/package.json b/tools/demo-data/package.json new file mode 100644 index 000000000..9b019043d --- /dev/null +++ b/tools/demo-data/package.json @@ -0,0 +1,10 @@ +{ + "name": "@forepath/demo-data", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./dist/src/index.js", + "dependencies": { + "tslib": "2.8.1" + } +} diff --git a/tools/demo-data/project.json b/tools/demo-data/project.json new file mode 100644 index 000000000..00aad1b25 --- /dev/null +++ b/tools/demo-data/project.json @@ -0,0 +1,58 @@ +{ + "name": "demo-data", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "tools/demo-data/src", + "tags": ["type:tool", "scope:repo"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "tools/demo-data/dist", + "main": "tools/demo-data/src/cli.ts", + "tsConfig": "tools/demo-data/tsconfig.lib.json", + "generatePackageJson": false + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "tools/demo-data/jest.config.ts" + } + }, + "seed": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "cache": false, + "options": { + "command": "node tools/demo-data/dist/src/cli.js seed all" + }, + "configurations": { + "decabill": { + "command": "node tools/demo-data/dist/src/cli.js seed decabill" + }, + "agenstra": { + "command": "node tools/demo-data/dist/src/cli.js seed agenstra" + } + } + }, + "reset": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "cache": false, + "options": { + "command": "node tools/demo-data/dist/src/cli.js reset all" + }, + "configurations": { + "decabill": { + "command": "node tools/demo-data/dist/src/cli.js reset decabill" + }, + "agenstra": { + "command": "node tools/demo-data/dist/src/cli.js reset agenstra" + } + } + } + } +} diff --git a/tools/demo-data/src/cli.spec.ts b/tools/demo-data/src/cli.spec.ts new file mode 100644 index 000000000..46c991118 --- /dev/null +++ b/tools/demo-data/src/cli.spec.ts @@ -0,0 +1,24 @@ +import { parseCliArgs } from './cli'; + +describe('parseCliArgs', () => { + it('should default to all products', () => { + expect(parseCliArgs(['seed'])).toMatchObject({ command: 'seed', products: ['decabill', 'agenstra'] }); + }); + + it('should accept a single product and options', () => { + expect(parseCliArgs(['reset', 'decabill', '--seed', '7', '--dry-run', '--sql-out', 'tmp/sql'])).toEqual({ + command: 'reset', + products: ['decabill'], + seed: 7, + dryRun: true, + sqlOutDir: 'tmp/sql', + }); + }); + + it('should reject unknown commands, products and options', () => { + expect(() => parseCliArgs(['drop'])).toThrow('Unknown command'); + expect(() => parseCliArgs(['seed', 'other'])).toThrow('Unknown product'); + expect(() => parseCliArgs(['seed', '--force'])).toThrow('Unknown option'); + expect(() => parseCliArgs(['seed', '--seed', 'abc'])).toThrow('integer'); + }); +}); diff --git a/tools/demo-data/src/cli.ts b/tools/demo-data/src/cli.ts new file mode 100644 index 000000000..c89ac7e7e --- /dev/null +++ b/tools/demo-data/src/cli.ts @@ -0,0 +1,113 @@ +import { AgenstraSeeder } from './lib/agenstra/agenstra.seeder'; +import { DemoCommand, DemoDataSeeder, SeederContext } from './lib/core/seeder'; +import { DecabillSeeder } from './lib/decabill/decabill.seeder'; + +const SEEDERS: Record DemoDataSeeder> = { + decabill: () => new DecabillSeeder(), + agenstra: () => new AgenstraSeeder(), +}; +const DEFAULT_SEED = 20261006; +const USAGE = `Usage: demo-data [decabill|agenstra|all] [options] + +Commands: + seed Remove previously seeded demo data, then insert a fresh data set + reset Remove all seeded demo data (rows created by this tool only) + +Options: + --seed Random seed for reproducible data (default: ${DEFAULT_SEED}) + --dry-run Build the SQL without touching any database + --sql-out Write the generated SQL scripts to + --help Show this help`; + +export interface CliOptions { + command: DemoCommand; + products: string[]; + seed: number; + dryRun: boolean; + sqlOutDir?: string; +} + +export function parseCliArgs(argv: string[]): CliOptions { + const positional: string[] = []; + let seed = DEFAULT_SEED; + let dryRun = false; + let sqlOutDir: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === '--dry-run') { + dryRun = true; + } else if (arg === '--seed') { + seed = Number(argv[++i]); + + if (!Number.isInteger(seed)) { + throw new Error('--seed expects an integer'); + } + } else if (arg === '--sql-out') { + sqlOutDir = argv[++i]; + + if (!sqlOutDir) { + throw new Error('--sql-out expects a directory'); + } + } else if (arg.startsWith('--')) { + throw new Error(`Unknown option ${arg}`); + } else { + positional.push(arg); + } + } + + const [command, product = 'all'] = positional; + + if (command !== 'seed' && command !== 'reset') { + throw new Error(`Unknown command "${command ?? ''}"`); + } + + if (product !== 'all' && !(product in SEEDERS)) { + throw new Error(`Unknown product "${product}" (expected decabill, agenstra or all)`); + } + + return { + command, + products: product === 'all' ? Object.keys(SEEDERS) : [product], + seed, + dryRun, + sqlOutDir, + }; +} + +async function main(): Promise { + const argv = process.argv.slice(2); + + if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) { + // eslint-disable-next-line no-console + console.log(USAGE); + + return; + } + + const options = parseCliArgs(argv); + const context: SeederContext = { + workspaceRoot: process.cwd(), + seed: options.seed, + dryRun: options.dryRun, + sqlOutDir: options.sqlOutDir, + // eslint-disable-next-line no-console + log: (message) => console.log(message), + }; + + for (const product of options.products) { + const seeder = SEEDERS[product](); + + context.log(`\n== ${options.command} ${seeder.product} ==`); + await seeder[options.command](context); + } +} + +if (require.main === module) { + main().catch((error: unknown) => { + // eslint-disable-next-line no-console + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/tools/demo-data/src/index.ts b/tools/demo-data/src/index.ts new file mode 100644 index 000000000..cf9c39693 --- /dev/null +++ b/tools/demo-data/src/index.ts @@ -0,0 +1,6 @@ +export * from './lib/agenstra/agenstra.seeder'; +export * from './lib/core/demo-ids'; +export * from './lib/core/encryption'; +export * from './lib/core/seeder'; +export * from './lib/core/sql'; +export * from './lib/decabill/decabill.seeder'; diff --git a/tools/demo-data/src/lib/agenstra/agenstra-context.ts b/tools/demo-data/src/lib/agenstra/agenstra-context.ts new file mode 100644 index 000000000..4a757ecec --- /dev/null +++ b/tools/demo-data/src/lib/agenstra/agenstra-context.ts @@ -0,0 +1,78 @@ +import { ColumnEncryptor } from '../core/encryption'; +import { PasswordHasher } from '../core/passwords'; +import { DemoRandom } from '../core/random'; + +export interface DemoAgent { + id: string; + name: string; + /** Plaintext login password (hashed in the manager DB, encrypted in the controller DB). */ + password: string; + containerType: string; + /** Workspace the agent is attributed to in statistics. */ + homeWorkspaceKey: string; + chatSessionIds: string[]; + createdAt: Date; +} + +export interface DemoWorkspaceSpec { + key: string; + name: string; + description: string; + topic: string; + /** Workspaces that point to the locally started agent-manager. */ + reachable: boolean; +} + +export const DEMO_WORKSPACES: readonly DemoWorkspaceSpec[] = [ + { + key: 'webshop', + name: 'Webshop Relaunch', + description: 'Headless storefront, checkout and CMS integration.', + topic: 'e-commerce', + reachable: true, + }, + { + key: 'mobile', + name: 'Mobile Banking App', + description: 'React Native app with biometric login and push notifications.', + topic: 'mobile', + reachable: true, + }, + { + key: 'data', + name: 'Data Platform', + description: 'Ingestion pipelines, dbt models and the analytics warehouse.', + topic: 'data', + reachable: true, + }, + { + key: 'infra', + name: 'Infrastructure as Code (remote)', + description: 'Terraform modules on a remote agent-manager that is currently unreachable.', + topic: 'infrastructure', + reachable: false, + }, +]; + +export const DEMO_AGENT_SPECS = [ + { name: 'webshop-frontend', workspace: 'webshop', containerType: 'generic', repo: 'webshop/storefront' }, + { name: 'webshop-checkout', workspace: 'webshop', containerType: 'docker', repo: 'webshop/checkout-service' }, + { name: 'mobile-app', workspace: 'mobile', containerType: 'generic', repo: 'mobile/banking-app' }, + { name: 'mobile-api', workspace: 'mobile', containerType: 'docker', repo: 'mobile/backend-for-frontend' }, + { name: 'data-pipelines', workspace: 'data', containerType: 'kubernetes', repo: 'data/pipelines' }, + { name: 'infra-terraform', workspace: 'data', containerType: 'terraform', repo: 'platform/terraform-modules' }, +] as const; + +export const DEMO_CHAT_MODELS = [ + 'anthropic/claude-sonnet-4-5', + 'openai/gpt-5', + 'google/gemini-2.5-pro', + 'ollama/qwen3-coder', +] as const; + +export interface AgenstraSeedContext { + random: DemoRandom; + hasher: PasswordHasher; + controllerEncryptor: ColumnEncryptor; + managerEncryptor: ColumnEncryptor; +} diff --git a/tools/demo-data/src/lib/agenstra/agenstra-controller.builder.ts b/tools/demo-data/src/lib/agenstra/agenstra-controller.builder.ts new file mode 100644 index 000000000..1dc20d2f3 --- /dev/null +++ b/tools/demo-data/src/lib/agenstra/agenstra-controller.builder.ts @@ -0,0 +1,1140 @@ +import { createHash } from 'crypto'; + +import { ACCOUNT_STATES, AccountStateTemplate, buildPersonalAccessTokenRows, buildUserRow } from '../core/accounts'; +import { LOREM_SENTENCES, SOFTWARE_TOPICS, TICKET_VERBS } from '../core/fixtures'; +import { createPerson } from '../core/people'; +import { RowCollector } from '../core/row-collector'; +import { json, SqlRow } from '../core/sql'; + +import { + AgenstraSeedContext, + DEMO_CHAT_MODELS, + DEMO_WORKSPACES, + DemoAgent, + DemoWorkspaceSpec, +} from './agenstra-context'; +import { ManagerFilterRule } from './agenstra-manager.builder'; + +const AGENSTRA_PAT_SCOPES = ['clients:read', 'clients:write', 'tickets:read', 'tickets:write', 'knowledge:read']; +const WEBHOOK_EVENTS = [ + 'ticket.created', + 'ticket.updated', + 'ticket.comment.created', + 'client.created', + 'agent.chat.failed', +]; +const TICKET_STATUSES = ['draft', 'todo', 'in_progress', 'prototype', 'done', 'closed'] as const; +const TICKET_PRIORITIES = ['low', 'medium', 'high', 'critical'] as const; +const RUN_OUTCOMES = [ + { status: 'succeeded', phase: 'finalize', failureCode: null, activity: 'AUTOMATION_SUCCEEDED' }, + { status: 'failed', phase: 'verify', failureCode: 'verify_command_failed', activity: 'AUTOMATION_FAILED' }, + { status: 'timed_out', phase: 'agent_loop', failureCode: 'max_runtime_exceeded', activity: 'AUTOMATION_TIMED_OUT' }, + { status: 'escalated', phase: 'agent_loop', failureCode: 'budget_exceeded', activity: 'AUTOMATION_ESCALATED' }, + { status: 'cancelled', phase: 'workspace_prep', failureCode: null, activity: 'AUTOMATION_CANCELLED' }, +] as const; + +interface DemoUser { + id: string; + email: string; + role: string; + state: AccountStateTemplate; + statisticsId: string; +} + +interface DemoWorkspace { + spec: DemoWorkspaceSpec; + id: string; + statisticsId: string; + ownerId: string; + members: { userId: string; role: 'admin' | 'user'; id: string; statisticsId: string }[]; + ticketIds: string[]; + ticketShas: Map; +} + +export interface ControllerSeedResult { + loginEmails: string[]; +} + +export interface ControllerSeedOptions { + managerEndpoint: string; + managerApiKey: string; + emailDomain: string; + includeGlobalOpencodeConfig: boolean; +} + +function sha1(value: string): string { + return createHash('sha1').update(value).digest('hex'); +} + +export class AgenstraControllerBuilder { + private readonly users: DemoUser[] = []; + private readonly workspaces: DemoWorkspace[] = []; + private readonly agentStatisticsIds = new Map(); + + constructor( + private readonly context: AgenstraSeedContext, + private readonly rows: RowCollector, + private readonly agents: DemoAgent[], + private readonly managerRules: ManagerFilterRule[], + private readonly options: ControllerSeedOptions, + ) {} + + async build(): Promise { + await this.addUsers(); + this.addWorkspaces(); + this.addAgentStatistics(); + + for (const workspace of this.workspaces) { + this.addTickets(workspace); + this.addKnowledge(workspace); + this.addReadState(workspace); + } + + this.addAutomation(); + this.addChatStatistics(); + this.addFilterRules(); + this.addOpencodeConfig(); + this.addAtlassianImports(); + this.addNotifications(); + + return { loginEmails: this.users.filter((user) => user.state.key !== 'random').map((user) => user.email) }; + } + + // --------------------------------------------------------------------------- + // Users and workspaces + // --------------------------------------------------------------------------- + + private async addUsers(): Promise { + const { random, hasher, controllerEncryptor } = this.context; + const states: AccountStateTemplate[] = [ + ...ACCOUNT_STATES, + { key: 'controller', role: 'controller', description: 'Service account for automation', confirmed: true }, + ]; + const usedLocalParts = new Set(states.map((state) => state.key)); + const randomUserState: AccountStateTemplate = { + ...ACCOUNT_STATES.find((state) => state.key === 'user')!, + key: 'random', + }; + const entries: { email: string; state: AccountStateTemplate }[] = [ + ...states.map((state) => ({ email: `${state.key}@${this.options.emailDomain}`, state })), + ...Array.from({ length: 10 }, () => ({ + email: `${createPerson(random, usedLocalParts).emailLocalPart}@${this.options.emailDomain}`, + state: randomUserState, + })), + ]; + + for (const entry of entries) { + const id = random.id(); + const createdAt = random.daysAgo(random.int(60, 500)); + + this.rows.add( + 'users', + await buildUserRow({ + id, + tenantId: 'default', + email: entry.email, + state: entry.state, + createdAt, + random, + hasher, + encryptor: controllerEncryptor, + }), + ); + + const statisticsId = random.id(); + + this.rows.add('statistics_users', { + id: statisticsId, + original_user_id: id, + role: entry.state.role, + created_at: createdAt, + updated_at: createdAt, + }); + this.addEntityEvent('created', 'user', id, createdAt, { + statistics_user_id: statisticsId, + statistics_users_id: statisticsId, + }); + this.users.push({ id, email: entry.email, role: entry.state.role, state: entry.state, statisticsId }); + + if (entry.state.role === 'admin') { + this.rows.addMany( + 'user_personal_access_tokens', + await buildPersonalAccessTokenRows(random, hasher, id, AGENSTRA_PAT_SCOPES), + ); + } + } + } + + private get admin(): DemoUser { + return this.users.find((user) => user.state.key === 'admin')!; + } + + private get activeUsers(): DemoUser[] { + return this.users.filter((user) => user.state.confirmed && !user.state.locked && user.role !== 'controller'); + } + + private addWorkspaces(): void { + const { random, controllerEncryptor } = this.context; + const owners = this.activeUsers; + + for (const spec of DEMO_WORKSPACES) { + const id = random.id(); + const statisticsId = random.id(); + const createdAt = random.daysAgo(random.int(150, 300)); + const owner = spec.key === 'webshop' ? this.admin : random.pick(owners); + + this.rows.add('clients', { + id, + name: spec.name, + description: spec.description, + endpoint: spec.reachable ? this.options.managerEndpoint : 'https://agents.remote-office.example:3000', + authentication_type: spec.reachable ? 'api_key' : 'keycloak', + api_key: spec.reachable ? controllerEncryptor.encrypt(this.options.managerApiKey) : null, + keycloak_client_id: spec.reachable ? null : 'agent-manager', + keycloak_client_secret: spec.reachable ? null : controllerEncryptor.encrypt(random.alphanumeric(32)), + keycloak_realm: spec.reachable ? null : 'agenstra', + user_id: owner.id, + created_at: createdAt, + updated_at: createdAt, + }); + this.rows.add('statistics_clients', { + id: statisticsId, + original_client_id: id, + name: spec.name, + endpoint: spec.reachable ? this.options.managerEndpoint : 'https://agents.remote-office.example:3000', + authentication_type: spec.reachable ? 'api_key' : 'keycloak', + created_at: createdAt, + updated_at: createdAt, + }); + this.addEntityEvent('created', 'client', id, createdAt, { statistics_clients_id: statisticsId }); + + const workspace: DemoWorkspace = { + spec, + id, + statisticsId, + ownerId: owner.id, + members: [], + ticketIds: [], + ticketShas: new Map(), + }; + const memberCandidates = this.users.filter((user) => user.id !== owner.id && user.role !== 'controller'); + + for (const [index, member] of random.pickMany(memberCandidates, random.int(4, 8)).entries()) { + const membership = { + userId: member.id, + role: (index === 0 ? 'admin' : 'user') as 'admin' | 'user', + id: random.id(), + statisticsId: random.id(), + }; + + workspace.members.push(membership); + this.rows.add('client_users', { + id: membership.id, + user_id: member.id, + client_id: id, + role: membership.role, + created_at: createdAt, + updated_at: createdAt, + }); + this.rows.add('statistics_client_users', { + id: membership.statisticsId, + original_client_user_id: membership.id, + statistics_client_id: statisticsId, + statistics_user_id: member.statisticsId, + role: membership.role, + created_at: createdAt, + updated_at: createdAt, + }); + this.addEntityEvent('created', 'client_user', membership.id, createdAt, { + statistics_client_users_id: membership.statisticsId, + }); + } + + if (spec.reachable) { + // The local manager lists all of its agents for every workspace pointing at it, so each + // workspace needs credentials for every agent to open chats. + for (const agent of this.agents) { + this.rows.add('client_agent_credentials', { + id: random.id(), + client_id: id, + agent_id: agent.id, + password: controllerEncryptor.encrypt(agent.password), + created_at: agent.createdAt, + updated_at: agent.createdAt, + }); + } + } else { + this.addProvisioningReference(workspace, createdAt); + } + + this.workspaces.push(workspace); + } + } + + private addProvisioningReference(workspace: DemoWorkspace, createdAt: Date): void { + const { random, controllerEncryptor } = this.context; + const id = random.id(); + const statisticsId = random.id(); + const serverId = String(random.int(40000000, 49999999)); + const publicIp = `198.51.100.${random.int(2, 254)}`; + const metadata = controllerEncryptor.encryptJson({ location: 'fsn1', serverType: 'cx32', image: 'ubuntu-24.04' }); + + this.rows.add('provisioning_references', { + id, + client_id: workspace.id, + provider_type: 'hetzner', + server_id: serverId, + server_name: 'agents-remote-office', + public_ip: publicIp, + private_ip: '10.0.0.2', + provider_metadata: metadata, + created_at: createdAt, + updated_at: createdAt, + }); + this.rows.add('statistics_provisioning_references', { + id: statisticsId, + original_provisioning_reference_id: id, + statistics_client_id: workspace.statisticsId, + provider_type: 'hetzner', + server_id: serverId, + server_name: 'agents-remote-office', + public_ip: publicIp, + private_ip: '10.0.0.2', + provider_metadata: metadata, + created_at: createdAt, + updated_at: createdAt, + }); + this.addEntityEvent('created', 'provisioning_reference', id, createdAt, { + statistics_provisioning_references_id: statisticsId, + }); + } + + private addAgentStatistics(): void { + const { random } = this.context; + + for (const agent of this.agents) { + const workspace = this.workspaces.find((candidate) => candidate.spec.key === agent.homeWorkspaceKey)!; + const statisticsId = random.id(); + + this.agentStatisticsIds.set(agent.id, statisticsId); + this.rows.add('statistics_agents', { + id: statisticsId, + original_agent_id: agent.id, + statistics_client_id: workspace.statisticsId, + agent_type: 'opencode', + container_type: agent.containerType, + name: agent.name, + description: `Coding agent ${agent.name}`, + created_at: agent.createdAt, + updated_at: agent.createdAt, + }); + this.addEntityEvent('created', 'agent', agent.id, agent.createdAt, { statistics_agents_id: statisticsId }); + } + } + + private addEntityEvent( + eventType: 'created' | 'updated' | 'deleted', + entityType: string, + originalId: string, + occurredAt: Date, + references: SqlRow, + ): void { + this.rows.add('statistics_entity_events', { + id: this.context.random.id(), + event_type: eventType, + entity_type: entityType, + original_entity_id: originalId, + occurred_at: occurredAt, + ...references, + }); + } + + // --------------------------------------------------------------------------- + // Tickets + // --------------------------------------------------------------------------- + + private addTickets(workspace: DemoWorkspace): void { + const { random } = this.context; + const statusDeck = random.deck(TICKET_STATUSES); + const priorityDeck = random.deck(TICKET_PRIORITIES); + const authors = [workspace.ownerId, ...workspace.members.map((member) => member.userId)]; + const workspaceAgents = this.agents.filter((agent) => agent.homeWorkspaceKey === workspace.spec.key); + const count = random.int(16, 24); + + for (let i = 0; i < count; i++) { + const id = random.id(); + const longSha = sha1(id); + const status = statusDeck(); + const createdAt = random.daysAgo(random.int(1, 120), 12); + const authorId = random.pick(authors); + const parentId = workspace.ticketIds.length > 3 && random.chance(0.25) ? random.pick(workspace.ticketIds) : null; + const title = `${random.pick(TICKET_VERBS)} ${random.pick(SOFTWARE_TOPICS)}`; + + workspace.ticketIds.push(id); + workspace.ticketShas.set(id, longSha); + this.rows.add('tickets', { + id, + client_id: workspace.id, + parent_id: parentId, + title, + content: [ + '## Problem', + random.pick(LOREM_SENTENCES), + '', + '## Acceptance criteria', + ...random.pickMany(LOREM_SENTENCES, 3).map((sentence) => `- [ ] ${sentence}`), + ].join('\n'), + long_sha: longSha, + priority: priorityDeck(), + status, + created_by_user_id: authorId, + preferred_chat_agent_id: workspaceAgents.length && random.chance(0.6) ? random.pick(workspaceAgents).id : null, + preferred_chat_model: random.chance(0.5) ? random.pick(DEMO_CHAT_MODELS) : null, + created_at: createdAt, + updated_at: random.addDays(createdAt, random.int(0, 5)), + }); + this.addTicketActivity(workspace, { id, title, status, createdAt, authorId, parentId }, authors); + } + } + + private addTicketActivity( + workspace: DemoWorkspace, + ticket: { id: string; title: string; status: string; createdAt: Date; authorId: string; parentId: string | null }, + authors: string[], + ): void { + const { random } = this.context; + const { id: ticketId, status, createdAt, authorId, parentId } = ticket; + const activity = (actorType: string, actorUserId: string | null, actionType: string, payload: object, at: Date) => + this.rows.add('ticket_activity', { + id: random.id(), + ticket_id: ticketId, + occurred_at: at, + actor_type: actorType, + actor_user_id: actorUserId, + action_type: actionType, + payload: json(payload), + }); + + activity('human', authorId, 'CREATED', { title: ticket.title, clientId: workspace.id, parentId }, createdAt); + + if (random.chance(0.3)) { + const generatedAt = random.addMinutes(createdAt, 5); + + activity('human', authorId, 'BODY_GENERATION_STARTED', {}, generatedAt); + activity('ai', null, 'CONTENT_APPLIED_FROM_AI', { fields: ['content'] }, random.addMinutes(generatedAt, 1)); + this.rows.add('ticket_body_generation_sessions', { + id: random.id(), + ticket_id: ticketId, + user_id: authorId, + agent_id: random.pick(this.agents).id, + expires_at: random.addMinutes(generatedAt, 30), + consumed_at: random.chance(0.7) ? random.addMinutes(generatedAt, 1) : null, + created_at: generatedAt, + }); + } + + if (status !== 'draft') { + activity( + 'human', + random.pick(authors), + 'STATUS_CHANGED', + { fields: { status: { old: 'draft', new: status } } }, + random.addDays(createdAt, 1), + ); + } + + if (random.chance(0.3)) { + activity( + 'human', + random.pick(authors), + 'PRIORITY_CHANGED', + { fields: { priority: { old: 'medium', new: 'high' } } }, + random.addDays(createdAt, 2), + ); + } + + const count = random.int(0, 4); + + for (let i = 0; i < count; i++) { + const commentId = random.id(); + const commentAuthor = random.pick(authors); + const commentedAt = random.addDays(createdAt, i + 1); + + this.rows.add('ticket_comments', { + id: commentId, + ticket_id: ticketId, + author_user_id: commentAuthor, + body: random.pick(LOREM_SENTENCES), + created_at: commentedAt, + }); + activity('human', commentAuthor, 'COMMENT_ADDED', { commentId }, commentedAt); + } + } + + // --------------------------------------------------------------------------- + // Automation + // --------------------------------------------------------------------------- + + private addAutomation(): void { + const { random } = this.context; + const reachable = this.workspaces.filter((workspace) => workspace.spec.reachable); + + for (const workspace of reachable) { + const workspaceAgents = this.agents.filter((agent) => agent.homeWorkspaceKey === workspace.spec.key); + + workspaceAgents.forEach((agent, index) => { + this.rows.add('client_agent_autonomy', { + client_id: workspace.id, + agent_id: agent.id, + // Only the first agent per workspace is enabled; see the scheduler note below. + enabled: index === 0, + pre_improve_ticket: random.chance(0.5), + max_runtime_ms: 3600000, + max_iterations: random.pick([10, 20, 30]), + token_budget_limit: random.pick([null, 200000, 500000]), + created_at: agent.createdAt, + updated_at: agent.createdAt, + }); + }); + + if (workspaceAgents.length === 0) { + continue; + } + + for (const ticketId of random.pickMany(workspace.ticketIds, 6)) { + this.addTicketAutomation(workspace, ticketId, workspaceAgents); + } + } + } + + private addTicketAutomation(workspace: DemoWorkspace, ticketId: string, agents: DemoAgent[]): void { + const { random } = this.context; + const ticketRow = this.findTicketRow(ticketId); + const ticketStatus = ticketRow.status as string; + // The scheduler starts runs for eligible todo/in_progress tickets with an approved, enabled agent. + // Open tickets therefore either require (missing) approval or stay ineligible. + const open = ticketStatus === 'todo' || ticketStatus === 'in_progress'; + const requiresApproval = open || random.chance(0.3); + const createdAt = random.addDays(ticketRow.created_at as Date, 1); + + this.rows.add('ticket_automation', { + ticket_id: ticketId, + eligible: open ? random.chance(0.5) : true, + allowed_agent_ids: json(agents.map((agent) => agent.id)), + preferred_model: random.pick(DEMO_CHAT_MODELS), + include_workspace_context: true, + context_environment_ids: json([]), + auto_enrichment_enabled: random.chance(0.7), + verifier_profile: json({ commands: [{ cmd: 'npm test' }, { cmd: 'npm run lint' }] }), + requires_approval: requiresApproval, + approved_at: requiresApproval && !open ? random.addDays(createdAt, 1) : null, + approved_by_user_id: requiresApproval && !open ? workspace.ownerId : null, + approval_baseline_ticket_updated_at: null, + default_branch_override: null, + automation_branch_strategy: random.pick(['reuse_per_ticket', 'new_per_run']), + force_new_automation_branch_next_run: false, + next_retry_at: null, + consecutive_failure_count: 0, + created_at: createdAt, + updated_at: createdAt, + }); + + if (open) { + return; + } + + const runCount = random.int(1, 3); + let lastRunId: string | null = null; + let lastAgentId: string | null = null; + let lastFinishedAt = createdAt; + + for (let i = 0; i < runCount; i++) { + const outcome = i === runCount - 1 && ticketStatus === 'done' ? RUN_OUTCOMES[0] : random.pick(RUN_OUTCOMES); + const agent = random.pick(agents); + const runId = random.id(); + const startedAt = random.addMinutes(lastFinishedAt, random.int(30, 600)); + const finishedAt = random.addMinutes(startedAt, random.int(3, 55)); + const iterations = outcome.status === 'cancelled' ? 0 : random.int(2, 18); + + this.rows.add('ticket_automation_run', { + id: runId, + ticket_id: ticketId, + client_id: workspace.id, + agent_id: agent.id, + status: outcome.status, + phase: outcome.phase, + ticket_status_before: 'todo', + branch_name: `automation/${sha1(ticketId).slice(0, 8)}${i > 0 ? `-${i + 1}` : ''}`, + base_branch: 'main', + base_sha: random.hex(40), + started_at: startedAt, + finished_at: finishedAt, + updated_at: finishedAt, + iteration_count: iterations, + completion_signal_seen: outcome.status === 'succeeded', + verification_passed: outcome.status === 'succeeded' ? true : outcome.status === 'failed' ? false : null, + failure_code: outcome.failureCode, + summary: json({ + filesChanged: random.int(1, 12), + commits: outcome.status === 'succeeded' ? random.int(1, 4) : 0, + note: random.pick(LOREM_SENTENCES), + }), + cancel_requested_at: outcome.status === 'cancelled' ? random.addMinutes(startedAt, 1) : null, + cancelled_by_user_id: outcome.status === 'cancelled' ? workspace.ownerId : null, + cancellation_reason: outcome.status === 'cancelled' ? 'user_requested' : null, + }); + this.addRunSteps(runId, outcome.status, startedAt, iterations); + this.rows.add('ticket_activity', { + id: random.id(), + ticket_id: ticketId, + occurred_at: finishedAt, + actor_type: 'system', + actor_user_id: null, + action_type: outcome.activity, + payload: json({ runId, code: outcome.failureCode }), + }); + lastRunId = runId; + lastAgentId = agent.id; + lastFinishedAt = finishedAt; + } + + if (lastRunId && lastAgentId) { + this.rows.add('ticket_automation_lease', { + ticket_id: ticketId, + holder_agent_id: lastAgentId, + run_id: lastRunId, + lease_version: runCount, + expires_at: random.addMinutes(lastFinishedAt, 5), + status: random.chance(0.8) ? 'released' : 'expired', + created_at: lastFinishedAt, + updated_at: lastFinishedAt, + }); + } + } + + private addRunSteps(runId: string, status: string, startedAt: Date, iterations: number): void { + const { random } = this.context; + const steps: { phase: string; kind: string; excerpt: string | null }[] = [ + { phase: 'workspace_prep', kind: 'vcs_prepare', excerpt: 'Fetched origin and checked out main' }, + { phase: 'workspace_prep', kind: 'vcs_branch', excerpt: 'Created automation branch' }, + ]; + + if (status !== 'cancelled') { + for (let i = 0; i < Math.min(iterations, 4); i++) { + steps.push({ phase: 'agent_loop', kind: 'agent_turn', excerpt: random.pick(LOREM_SENTENCES) }); + } + } + + if (status === 'succeeded') { + steps.push( + { phase: 'finalize', kind: 'git_commit', excerpt: 'feat: implement requested change' }, + { phase: 'finalize', kind: 'git_push', excerpt: 'Pushed automation branch' }, + ); + } + + steps.push({ phase: 'finalize', kind: 'clean', excerpt: null }); + steps.forEach((step, index) => { + this.rows.add('ticket_automation_run_step', { + id: random.id(), + run_id: runId, + step_index: index, + phase: step.phase, + kind: step.kind, + payload: json({}), + excerpt: step.excerpt, + created_at: random.addMinutes(startedAt, index * 2), + }); + }); + } + + private findTicketRow(ticketId: string): SqlRow { + return this.rowsOf('tickets').find((row) => row.id === ticketId)!; + } + + private rowsOf(table: string): SqlRow[] { + return this.rows.rowsFor(table); + } + + // --------------------------------------------------------------------------- + // Knowledge + // --------------------------------------------------------------------------- + + private addKnowledge(workspace: DemoWorkspace): void { + const { random } = this.context; + const authors = [workspace.ownerId, ...workspace.members.map((member) => member.userId)]; + const tree: { title: string; pages: string[]; children?: { title: string; pages: string[] }[] }[] = [ + { + title: 'Architecture', + pages: ['System overview', 'Decision log', 'Integration points'], + children: [{ title: 'Decision records', pages: ['ADR-001: Use PostgreSQL', 'ADR-002: Event-driven sync'] }], + }, + { title: 'Runbooks', pages: ['Deploying to production', 'Rotating secrets', 'Incident checklist'] }, + { title: 'Onboarding', pages: ['Local setup', 'Coding guidelines'] }, + ]; + const pageIds: string[] = []; + const addNode = (type: 'folder' | 'page', title: string, parentId: string | null, sortOrder: number): string => { + const id = random.id(); + const createdAt = random.daysAgo(random.int(10, 140)); + + this.rows.add('knowledge_nodes', { + id, + client_id: workspace.id, + node_type: type, + parent_id: parentId, + title, + content: type === 'page' ? this.pageContent(title, workspace) : null, + sort_order: sortOrder, + long_sha: sha1(id), + created_at: createdAt, + updated_at: random.addDays(createdAt, random.int(0, 9)), + }); + + if (type === 'page') { + pageIds.push(id); + this.addPageActivity(id, createdAt, random.pick(authors)); + } + + return id; + }; + + tree.forEach((folder, folderIndex) => { + const folderId = addNode('folder', folder.title, null, folderIndex); + + folder.pages.forEach((page, pageIndex) => addNode('page', page, folderId, pageIndex)); + folder.children?.forEach((child, childIndex) => { + const childId = addNode('folder', child.title, folderId, folder.pages.length + childIndex); + + child.pages.forEach((page, pageIndex) => addNode('page', page, childId, pageIndex)); + }); + }); + + addNode('page', 'Glossary', null, tree.length); + + for (let i = 0; i < 6; i++) { + const ticketId = random.pick(workspace.ticketIds); + const pageId = random.pick(pageIds); + const pageToTicket = random.chance(0.5); + + this.rows.add('knowledge_relations', { + id: random.id(), + client_id: workspace.id, + source_type: pageToTicket ? 'page' : 'ticket', + source_id: pageToTicket ? pageId : ticketId, + target_type: pageToTicket ? 'ticket' : 'page', + target_node_id: pageToTicket ? null : pageId, + target_ticket_long_sha: pageToTicket ? workspace.ticketShas.get(ticketId) : null, + created_at: random.daysAgo(random.int(1, 30)), + }); + } + } + + private pageContent(title: string, workspace: DemoWorkspace): string { + const body = this.context.random.pickMany(LOREM_SENTENCES, 4); + + return [`# ${title}`, '', `Applies to ${workspace.spec.name}.`, '', ...body].join('\n'); + } + + private addPageActivity(pageId: string, createdAt: Date, authorId: string): void { + const { random } = this.context; + + this.rows.add('knowledge_page_activity', { + id: random.id(), + page_id: pageId, + occurred_at: createdAt, + actor_type: 'human', + actor_user_id: authorId, + action_type: 'CREATED', + payload: json({}), + }); + + if (random.chance(0.6)) { + this.rows.add('knowledge_page_activity', { + id: random.id(), + page_id: pageId, + occurred_at: random.addDays(createdAt, random.int(1, 9)), + actor_type: random.pick(['human', 'ai']), + actor_user_id: authorId, + action_type: 'CONTENT_UPDATED', + payload: json({}), + }); + } + } + + private addReadState(workspace: DemoWorkspace): void { + const { random } = this.context; + + if (!workspace.spec.reachable) { + return; + } + + for (const agent of this.agents.filter((candidate) => candidate.homeWorkspaceKey === workspace.spec.key)) { + this.rows.add('user_environment_read_state', { + id: random.id(), + user_id: this.admin.id, + client_id: workspace.id, + agent_id: agent.id, + last_read_at: random.daysAgo(random.int(0, 5)), + last_read_agent_message_id: null, + created_at: agent.createdAt, + updated_at: random.daysAgo(0), + }); + this.rows.add('user_chat_session_read_state', { + id: random.id(), + user_id: this.admin.id, + client_id: workspace.id, + agent_id: agent.id, + chat_session_id: agent.chatSessionIds[0], + last_read_at: random.daysAgo(random.int(0, 5)), + last_read_agent_message_id: null, + created_at: agent.createdAt, + updated_at: random.daysAgo(0), + }); + } + } + + // --------------------------------------------------------------------------- + // Statistics + // --------------------------------------------------------------------------- + + private addChatStatistics(): void { + const { random } = this.context; + const interactionKinds = [ + 'chat', + 'chat', + 'chat', + 'prompt_enhancement', + 'ticket_body_generation', + 'autonomous_ticket_run', + 'autonomous_ticket_run_turn', + 'autonomous_ticket_commit_message', + 'auto_context_enrichment', + ]; + + for (const agent of this.agents) { + const workspace = this.workspaces.find((candidate) => candidate.spec.key === agent.homeWorkspaceKey)!; + const statisticsAgentId = this.agentStatisticsIds.get(agent.id)!; + const participants = this.users.filter((user) => + [workspace.ownerId, ...workspace.members.map((member) => member.userId)].includes(user.id), + ); + + for (let day = 45; day >= 0; day--) { + const interactions = random.int(0, day % 7 >= 5 ? 2 : 8); + + for (let i = 0; i < interactions; i++) { + const occurredAt = random.daysAgo(day, 20); + const user = random.pick(participants); + const kind = random.pick(interactionKinds); + const inputWords = random.int(8, 160); + const outputWords = random.int(40, 900); + const base = { + statistics_agent_id: statisticsAgentId, + statistics_client_id: workspace.statisticsId, + statistics_user_id: kind.startsWith('autonomous') ? null : user.statisticsId, + interaction_kind: kind, + }; + + this.rows.add('statistics_chat_io', { + id: random.id(), + ...base, + direction: 'input', + word_count: inputWords, + char_count: inputWords * 6, + input_tokens: inputWords * 2 + random.int(500, 4000), + output_tokens: null, + reasoning_tokens: null, + cache_read_tokens: random.int(0, 20000), + cache_write_tokens: random.int(0, 4000), + cost_usd: null, + occurred_at: occurredAt, + }); + this.rows.add('statistics_chat_io', { + id: random.id(), + ...base, + direction: 'output', + word_count: outputWords, + char_count: outputWords * 6, + input_tokens: null, + output_tokens: outputWords * 2, + reasoning_tokens: random.int(0, 3000), + cache_read_tokens: null, + cache_write_tokens: null, + cost_usd: random.decimal(0.002, 0.45, 6), + occurred_at: random.addMinutes(occurredAt, random.int(1, 4)), + }); + + if (random.chance(0.05)) { + const table = random.chance(0.5) ? 'statistics_chat_filter_drops' : 'statistics_chat_filter_flags'; + const { interaction_kind: _kind, ...filterBase } = base; + + this.rows.add(table, { + id: random.id(), + ...filterBase, + filter_type: 'regex', + filter_display_name: table.endsWith('drops') ? 'Credit card numbers' : 'API keys', + filter_reason: table.endsWith('drops') ? 'Message dropped by filter rule' : 'Sensitive content redacted', + direction: random.pick(['incoming', 'outgoing']), + word_count: inputWords, + char_count: inputWords * 6, + occurred_at: occurredAt, + }); + } + } + } + } + } + + // --------------------------------------------------------------------------- + // Filter rules, OpenCode configuration, imports and notifications + // --------------------------------------------------------------------------- + + private addFilterRules(): void { + const { random } = this.context; + const reachable = this.workspaces.filter((workspace) => workspace.spec.reachable); + // The disabled rule was never pushed to a manager, so it has no manager-side counterpart. + const disabledRule: Omit & { id: null } = { + id: null, + pattern: 'internal-only', + direction: 'incoming', + filterType: 'none', + replaceContent: null, + }; + const specs = [ + { managerRule: this.managerRules[0], isGlobal: true, enabled: true }, + { managerRule: this.managerRules[1], isGlobal: false, enabled: true }, + { managerRule: disabledRule, isGlobal: false, enabled: false }, + ]; + + specs.forEach((spec, index) => { + const ruleId = random.id(); + const createdAt = random.daysAgo(random.int(20, 90)); + const targets = spec.isGlobal ? reachable : random.pickMany(this.workspaces, 2); + + this.rows.add('agent_console_regex_filter_rules', { + id: ruleId, + pattern: spec.managerRule.pattern, + regex_flags: spec.enabled ? 'g' : 'gi', + direction: spec.managerRule.direction, + filter_type: spec.managerRule.filterType, + replace_content: spec.managerRule.replaceContent, + priority: index * 10, + enabled: spec.enabled, + is_global: spec.isGlobal, + created_at: createdAt, + updated_at: createdAt, + }); + + for (const workspace of targets) { + if (!spec.isGlobal) { + this.rows.add('agent_console_regex_filter_rule_clients', { + id: random.id(), + rule_id: ruleId, + client_id: workspace.id, + }); + } + + const synced = workspace.spec.reachable && spec.managerRule.id !== null; + + this.rows.add('agent_console_regex_filter_rule_sync_targets', { + id: random.id(), + rule_id: ruleId, + client_id: workspace.id, + manager_rule_id: synced ? spec.managerRule.id : null, + desired_on_manager: spec.enabled, + sync_status: synced ? 'synced' : spec.enabled ? 'failed' : 'synced', + last_error: !synced && spec.enabled ? 'Failed to connect to client endpoint' : null, + last_synced_at: synced ? random.daysAgo(random.int(0, 3)) : null, + updated_at: random.daysAgo(0), + }); + } + }); + } + + private addOpencodeConfig(): void { + const { random, controllerEncryptor } = this.context; + + for (const workspace of this.workspaces.filter((candidate) => candidate.spec.reachable)) { + this.rows.add('client_opencode_config', { + id: random.id(), + client_id: workspace.id, + config: json({ + model: random.pick(DEMO_CHAT_MODELS), + mcp_allow: ['io.modelcontextprotocol/filesystem', 'io.github.github/github-mcp-server'], + mcp_deny: workspace.spec.key === 'mobile' ? ['custom'] : [], + instructions: [`Follow the ${workspace.spec.topic} coding guidelines in the knowledge base.`], + }), + overrides: json({}), + locks: json([]), + secrets: controllerEncryptor.encryptJson({}), + created_at: random.daysAgo(60), + updated_at: random.daysAgo(random.int(0, 20)), + }); + } + + if (this.options.includeGlobalOpencodeConfig) { + this.rows.add('global_opencode_config', { + id: random.id(), + config: json({ + mcp_allow: [ + 'io.modelcontextprotocol/filesystem', + 'io.github.github/github-mcp-server', + 'com.atlassian/atlassian-mcp-server', + ], + mcp_deny: ['io.example/untrusted-shell'], + model_deny: ['ollama/*'], + }), + overrides: json({}), + locks: json(['mcp_deny']), + secrets: controllerEncryptor.encryptJson({}), + created_at: random.daysAgo(90), + updated_at: random.daysAgo(10), + }); + } + } + + private addAtlassianImports(): void { + const { random, controllerEncryptor } = this.context; + const connectionId = random.id(); + const [jiraWorkspace, confluenceWorkspace] = this.workspaces; + const createdAt = random.daysAgo(60); + + this.rows.add('atlassian_site_connections', { + id: connectionId, + label: 'Company Atlassian', + base_url: 'https://example-company.atlassian.net', + account_email: `integrations@${this.options.emailDomain}`, + api_token: controllerEncryptor.encrypt(`ATATT3x${random.alphanumeric(40)}`), + created_at: createdAt, + updated_at: createdAt, + }); + + const jiraConfigId = random.id(); + const confluenceConfigId = random.id(); + const parentFolder = this.rowsOf('knowledge_nodes').find( + (row) => row.client_id === confluenceWorkspace.id && row.node_type === 'folder', + ); + + this.rows.add('external_import_configs', { + id: jiraConfigId, + provider: 'atlassian', + import_kind: 'jira', + atlassian_connection_id: connectionId, + client_id: jiraWorkspace.id, + // Disabled so the scheduler does not call the (fictional) Atlassian site. + enabled: false, + jira_board_id: 42, + jql: 'project = SHOP AND statusCategory != Done', + import_target_ticket_status: 'draft', + agenstra_parent_ticket_id: jiraWorkspace.ticketIds[0], + agenstra_parent_folder_id: null, + last_run_at: random.daysAgo(2), + last_error: null, + created_at: createdAt, + updated_at: createdAt, + }); + this.rows.add('external_import_configs', { + id: confluenceConfigId, + provider: 'atlassian', + import_kind: 'confluence', + atlassian_connection_id: connectionId, + client_id: confluenceWorkspace.id, + enabled: false, + confluence_space_key: 'MOB', + confluence_root_page_id: '123456789', + cql: null, + agenstra_parent_ticket_id: null, + agenstra_parent_folder_id: parentFolder?.id ?? null, + last_run_at: random.daysAgo(3), + last_error: '401 Unauthorized: API token expired', + created_at: createdAt, + updated_at: createdAt, + }); + + jiraWorkspace.ticketIds.slice(1, 4).forEach((ticketId, index) => { + this.rows.add('external_import_sync_markers', { + id: random.id(), + import_config_id: jiraConfigId, + external_type: 'jira_issue', + external_id: `SHOP-${101 + index}`, + local_ticket_id: ticketId, + local_knowledge_node_id: null, + content_hash: random.hex(64), + last_imported_at: random.daysAgo(2), + created_at: createdAt, + updated_at: createdAt, + }); + }); + } + + private addNotifications(): void { + const { random, controllerEncryptor } = this.context; + const endpoints = [ + { name: 'Team chat', url: 'https://chat.example.com/hooks', auth: 'none', enabled: true, failures: 0 }, + { name: 'Tracker sync', url: 'https://tracker.example.com/h', auth: 'query_param', enabled: true, failures: 3 }, + { name: 'Old automation', url: 'https://ops.example.org/h', auth: 'authorization', enabled: false, failures: 30 }, + ]; + + for (const endpoint of endpoints) { + const endpointId = random.id(); + const createdAt = random.daysAgo(random.int(30, 200)); + + this.rows.add('webhook_endpoints', { + id: endpointId, + scope_key: 'instance', + client_id: null, + name: endpoint.name, + url: endpoint.url, + http_method: endpoint.auth === 'query_param' ? 'GET' : 'POST', + subscribed_events: json(random.pickMany(WEBHOOK_EVENTS, random.int(2, WEBHOOK_EVENTS.length))), + enabled: endpoint.enabled, + auth_type: endpoint.auth, + auth_header_name: endpoint.auth === 'query_param' ? 'token' : null, + auth_value: endpoint.auth === 'none' ? null : controllerEncryptor.encrypt(random.alphanumeric(24)), + signing_secret: controllerEncryptor.encrypt(`whsec_${random.alphanumeric(32)}`), + consecutive_failures: endpoint.failures, + disabled_reason: endpoint.enabled ? null : 'Disabled after 30 consecutive failures', + delivery_log_retention_days: 14, + delivery_log_max_entries: 200, + created_at: createdAt, + updated_at: createdAt, + }); + + const count = random.int(4, 10); + + for (let i = 0; i < count; i++) { + const success = endpoint.enabled && random.chance(endpoint.failures > 0 ? 0.6 : 0.95); + + this.rows.add('webhook_deliveries', { + id: random.id(), + endpoint_id: endpointId, + event_id: random.id(), + event_type: random.pick(WEBHOOK_EVENTS), + payload: json({ demo: true }), + http_status: success ? 200 : random.pick([403, 500, 503]), + response_body: success ? 'ok' : 'Service unavailable', + success, + attempt: success ? 1 : random.int(1, 5), + error_message: success ? null : 'Request failed with non-2xx status', + created_at: random.pastDate(20, 0), + }); + } + } + + for (let i = 0; i < 20; i++) { + const success = random.chance(0.9); + + this.rows.add('email_deliveries', { + id: random.id(), + event_id: random.id(), + event_type: random.pick(['ticket.comment.created', 'identity.email_confirmation', 'identity.login_2fa']), + scope_key: 'instance', + template_key: random.pick(['ticket-comment-created', 'email-confirmation', 'login-2fa-code']), + recipient: controllerEncryptor.encrypt(random.pick(this.users).email), + template_context: controllerEncryptor.encryptJson({ demo: true }), + success, + attempt: success ? 1 : random.int(2, 4), + error_message: success ? null : controllerEncryptor.encrypt('SMTP connection refused'), + created_at: random.pastDate(30, 0), + }); + } + } +} diff --git a/tools/demo-data/src/lib/agenstra/agenstra-manager.builder.ts b/tools/demo-data/src/lib/agenstra/agenstra-manager.builder.ts new file mode 100644 index 000000000..fa6ddadaa --- /dev/null +++ b/tools/demo-data/src/lib/agenstra/agenstra-manager.builder.ts @@ -0,0 +1,245 @@ +import { SOFTWARE_TOPICS } from '../core/fixtures'; +import { RowCollector } from '../core/row-collector'; + +import { AgenstraSeedContext, DEMO_AGENT_SPECS, DemoAgent } from './agenstra-context'; + +const USER_PROMPTS = [ + 'Can you summarize the open pull requests and what is blocking them?', + 'Please add unit tests for the price calculation module.', + 'Why does the build fail on the main branch since this morning?', + 'Refactor the session handling so tokens are refreshed in the background.', + 'Draft release notes for the changes merged this week.', + 'Find out which endpoints are missing rate limiting.', +] as const; +const AGENT_REPLIES = [ + 'I went through the repository and found three places that need changes. I updated them and added tests.', + 'The failure comes from an outdated lockfile. I regenerated it and the build passes locally.', + 'Here is a summary:\n\n1. Two PRs wait for review\n2. One PR has merge conflicts\n3. One PR is blocked by CI', + 'Done. The new tests cover the edge cases for rounding and negative discounts.', + 'I could not reproduce the issue. Could you share the exact command you ran?', +] as const; + +/** Builds agents (environments) with chat history, env vars, deployments and filter rules. */ +export async function buildManagerData(context: AgenstraSeedContext, rows: RowCollector): Promise { + const { random, hasher, managerEncryptor } = context; + const agents: DemoAgent[] = []; + + for (const spec of DEMO_AGENT_SPECS) { + const id = random.id(); + const password = random.alphanumeric(24); + const createdAt = random.daysAgo(random.int(30, 200)); + const agent: DemoAgent = { + id, + name: spec.name, + password, + containerType: spec.containerType, + homeWorkspaceKey: spec.workspace, + chatSessionIds: [], + createdAt, + }; + + rows.add('agents', { + id, + name: spec.name, + description: `Coding agent for ${spec.repo}`, + hashed_password: await hasher.hash(password, 10), + // No container: the manager would try to restart it on boot. Chat history stays readable. + container_id: null, + volume_path: null, + agent_type: 'opencode', + container_type: spec.containerType, + opencode_server_password: managerEncryptor.encrypt(random.alphanumeric(32)), + opencode_user_config: null, + opencode_user_overrides: null, + opencode_user_secrets: null, + git_repository_url: `https://git.example.com/${spec.repo}.git`, + git_repository_setup_mode: random.chance(0.8) ? 'clone' : 'empty', + created_at: createdAt, + updated_at: random.daysAgo(random.int(0, 10)), + }); + + addChatSessions(context, rows, agent); + addEnvironmentVariables(context, rows, agent); + + if (spec.containerType !== 'terraform') { + addDeployments(context, rows, agent, spec.repo); + } + + agents.push(agent); + } + + return agents; +} + +function serializeAgentReply(result: string): string { + return JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result }); +} + +function addChatSessions(context: AgenstraSeedContext, rows: RowCollector, agent: DemoAgent): void { + const { random } = context; + const userSessionCount = random.int(1, 3); + // User chats use their own id in the resume suffix, like agent-chat-sessions.service does. + const sessions = [ + { id: random.id(), kind: 'primary', title: null as string | null, suffix: '' }, + ...Array.from({ length: userSessionCount }, () => { + const chatId = random.id(); + const title = `About the ${random.pick(SOFTWARE_TOPICS)}`; + + return { id: chatId, kind: 'user', title, suffix: `-chat-${chatId}` }; + }), + ]; + + for (const session of sessions) { + const sessionId = session.id; + let messageAt = random.addDays(agent.createdAt, random.int(1, 20)); + const messageCount = random.int(2, 6) * 2; + + agent.chatSessionIds.push(sessionId); + + for (let i = 0; i < messageCount; i++) { + const fromUser = i % 2 === 0; + + messageAt = random.addMinutes(messageAt, fromUser ? random.int(30, 600) : random.int(1, 6)); + rows.add('agent_messages', { + id: random.id(), + agent_id: agent.id, + chat_session_id: sessionId, + actor: fromUser ? 'user' : 'agent', + // Agent messages store the serialized agent response, user messages the plain prompt. + message: fromUser ? random.pick(USER_PROMPTS) : serializeAgentReply(random.pick(AGENT_REPLIES)), + filtered: !fromUser && random.chance(0.05), + created_at: messageAt, + updated_at: messageAt, + }); + } + + rows.add('agent_chat_sessions', { + id: sessionId, + agent_id: agent.id, + title: session.title, + kind: session.kind, + resume_session_suffix: session.suffix, + last_message_at: messageAt, + created_at: agent.createdAt, + updated_at: messageAt, + }); + } +} + +function addEnvironmentVariables(context: AgenstraSeedContext, rows: RowCollector, agent: DemoAgent): void { + const { random, managerEncryptor } = context; + const variables: [string, string][] = [ + ['NODE_ENV', 'development'], + ['LOG_LEVEL', random.pick(['debug', 'info'])], + ['DATABASE_URL', `postgres://app:${random.alphanumeric(12)}@db.internal:5432/app`], + ['FEATURE_FLAGS', 'new-checkout,beta-search'], + ]; + + for (const [variable, content] of random.pickMany(variables, random.int(2, variables.length))) { + rows.add('agent_environment_variables', { + id: random.id(), + agent_id: agent.id, + variable, + content: managerEncryptor.encrypt(content), + created_at: agent.createdAt, + updated_at: agent.createdAt, + }); + } +} + +function addDeployments(context: AgenstraSeedContext, rows: RowCollector, agent: DemoAgent, repo: string): void { + const { random, managerEncryptor } = context; + const configurationId = random.id(); + + rows.add('deployment_configurations', { + id: configurationId, + agent_id: agent.id, + provider_type: 'github', + repository_id: repo, + default_branch: 'main', + workflow_id: 'deploy.yml', + provider_token: managerEncryptor.encrypt(`ghp_demo${random.alphanumeric(30)}`), + provider_base_url: null, + created_at: agent.createdAt, + updated_at: agent.createdAt, + }); + + const outcomes = [ + { status: 'completed', conclusion: 'success' }, + { status: 'completed', conclusion: 'success' }, + { status: 'completed', conclusion: 'failure' }, + { status: 'completed', conclusion: 'cancelled' }, + { status: 'in_progress', conclusion: null }, + { status: 'queued', conclusion: null }, + ]; + + outcomes.forEach((outcome, index) => { + const startedAt = random.daysAgo(outcomes.length - index, 12); + const runNumber = random.int(100, 999); + + rows.add('deployment_runs', { + id: random.id(), + configuration_id: configurationId, + provider_run_id: String(random.int(1000000000, 9999999999)), + run_name: `Deploy #${runNumber}`, + status: outcome.status, + conclusion: outcome.conclusion, + ref: 'main', + sha: random.hex(40), + workflow_id: 'deploy.yml', + workflow_name: 'Deploy', + started_at: outcome.status === 'queued' ? null : startedAt, + completed_at: outcome.status === 'completed' ? random.addMinutes(startedAt, random.int(2, 15)) : null, + html_url: `https://github.com/${repo}/actions/runs/${runNumber}`, + created_at: startedAt, + updated_at: startedAt, + }); + }); +} + +export interface ManagerFilterRule { + id: string; + pattern: string; + direction: string; + filterType: string; + replaceContent: string | null; +} + +/** Filter rules as synced from the controller (`manager_rule_id` on the controller side). */ +export function buildManagerFilterRules(context: AgenstraSeedContext, rows: RowCollector): ManagerFilterRule[] { + const { random } = context; + const rules: ManagerFilterRule[] = [ + { + id: random.id(), + pattern: 'sk-[A-Za-z0-9]{20,}', + direction: 'outgoing', + filterType: 'filter', + replaceContent: '[redacted api key]', + }, + { + id: random.id(), + pattern: '\\b\\d{4}[ -]?\\d{4}[ -]?\\d{4}[ -]?\\d{4}\\b', + direction: 'bidirectional', + filterType: 'drop', + replaceContent: null, + }, + ]; + + rules.forEach((rule, index) => { + const createdAt = random.daysAgo(random.int(20, 90)); + + rows.add('regex_filter_rules', { + id: rule.id, + pattern: rule.pattern, + regex_flags: 'g', + direction: rule.direction, + filter_type: rule.filterType, + replace_content: rule.replaceContent, + priority: index * 10, + created_at: createdAt, + updated_at: createdAt, + }); + }); + + return rules; +} diff --git a/tools/demo-data/src/lib/agenstra/agenstra.seeder.ts b/tools/demo-data/src/lib/agenstra/agenstra.seeder.ts new file mode 100644 index 000000000..4627fe69a --- /dev/null +++ b/tools/demo-data/src/lib/agenstra/agenstra.seeder.ts @@ -0,0 +1,203 @@ +import * as path from 'path'; + +import { ColumnEncryptor } from '../core/encryption'; +import { EnvValues, loadEnvFile } from '../core/env-file'; +import { PasswordHasher } from '../core/passwords'; +import { PostgresContainer, runDocker } from '../core/postgres-container'; +import { DemoRandom } from '../core/random'; +import { RowCollector } from '../core/row-collector'; +import { + applyScript, + buildDemoDeletes, + DemoDataSeeder, + DEMO_PASSWORD, + DEMO_TOTP_SECRET, + SeederContext, +} from '../core/seeder'; +import { SqlScript } from '../core/sql'; + +import { AgenstraSeedContext } from './agenstra-context'; +import { AgenstraControllerBuilder } from './agenstra-controller.builder'; +import { buildManagerData, buildManagerFilterRules } from './agenstra-manager.builder'; +import { + AGENSTRA_CONTROLLER_DELETE_TARGETS, + AGENSTRA_CONTROLLER_INSERT_ORDER, + AGENSTRA_CONTROLLER_REQUIRED_TABLES, + AGENSTRA_MANAGER_DELETE_TARGETS, + AGENSTRA_MANAGER_INSERT_ORDER, + AGENSTRA_MANAGER_REQUIRED_TABLES, +} from './agenstra.tables'; + +export const AGENSTRA_CONTROLLER_APP_DIR = 'apps/agenstra/backend-agent-controller'; +export const AGENSTRA_MANAGER_APP_DIR = 'apps/agenstra/backend-agent-manager'; + +const COMPOSE_DEFAULTS: EnvValues = { + DB_USERNAME: 'postgres', + DB_DATABASE: 'postgres', + PORT: '3000', +}; +/** Network created by the agent-manager compose file (fixed name). */ +const MANAGER_NETWORK = 'agent-manager-network'; +/** Controller containers that call the agent-manager (API, queue worker, scheduler). */ +const CONTROLLER_APP_CONTAINERS = ['agent-controller-api', 'agent-controller-worker', 'agent-controller-scheduler']; + +export interface AgenstraSettings { + controller: { container: string; user: string; database: string; encryptionKey?: string }; + manager: { container: string; user: string; database: string; encryptionKey?: string; apiKey: string }; + managerEndpoint: string; + connectNetworks: boolean; +} + +export function resolveAgenstraSettings(workspaceRoot: string, env: NodeJS.ProcessEnv = process.env): AgenstraSettings { + const controllerEnv = loadEnvFile( + path.join(workspaceRoot, AGENSTRA_CONTROLLER_APP_DIR, '.start-containers.env'), + COMPOSE_DEFAULTS, + ); + const managerEnv = loadEnvFile( + path.join(workspaceRoot, AGENSTRA_MANAGER_APP_DIR, '.start-containers.env'), + COMPOSE_DEFAULTS, + ); + + return { + controller: { + container: env.DEMO_DATA_AGENSTRA_CONTROLLER_POSTGRES_CONTAINER || 'agent-controller-postgres', + user: controllerEnv.DB_USERNAME, + database: controllerEnv.DB_DATABASE, + encryptionKey: controllerEnv.ENCRYPTION_KEY, + }, + manager: { + container: env.DEMO_DATA_AGENSTRA_MANAGER_POSTGRES_CONTAINER || 'agent-manager-postgres', + user: managerEnv.DB_USERNAME, + database: managerEnv.DB_DATABASE, + encryptionKey: managerEnv.ENCRYPTION_KEY, + apiKey: managerEnv.STATIC_API_KEY ?? '', + }, + // Container-to-container URL; reachable once the controller joins the manager network. + managerEndpoint: env.DEMO_DATA_AGENSTRA_MANAGER_ENDPOINT || `http://agent-manager-api:${managerEnv.PORT || '3000'}`, + connectNetworks: env.DEMO_DATA_AGENSTRA_CONNECT_NETWORK !== 'false', + }; +} + +export class AgenstraSeeder implements DemoDataSeeder { + readonly product = 'agenstra'; + + async seed(context: SeederContext): Promise { + const settings = resolveAgenstraSettings(context.workspaceRoot); + const { controller, manager } = this.connect(context, settings); + + if (!settings.manager.apiKey) { + context.log('Warning: STATIC_API_KEY is empty for the agent-manager; workspaces will not authenticate.'); + } + + const seedContext: AgenstraSeedContext = { + random: new DemoRandom(context.seed), + hasher: new PasswordHasher(), + controllerEncryptor: new ColumnEncryptor(settings.controller.encryptionKey), + managerEncryptor: new ColumnEncryptor(settings.manager.encryptionKey), + }; + const managerRows = new RowCollector(AGENSTRA_MANAGER_INSERT_ORDER); + const controllerRows = new RowCollector(AGENSTRA_CONTROLLER_INSERT_ORDER); + const agents = await buildManagerData(seedContext, managerRows); + const managerRules = buildManagerFilterRules(seedContext, managerRows); + const result = await new AgenstraControllerBuilder(seedContext, controllerRows, agents, managerRules, { + managerEndpoint: settings.managerEndpoint, + managerApiKey: settings.manager.apiKey, + emailDomain: 'agenstra.example', + includeGlobalOpencodeConfig: context.dryRun || this.hasNoGlobalOpencodeConfig(controller), + }).build(); + const managerScript = new SqlScript(); + + buildDemoDeletes(AGENSTRA_MANAGER_DELETE_TARGETS).forEach((statement) => managerScript.add(statement)); + managerRows.writeTo(managerScript); + + const controllerScript = new SqlScript(); + + buildDemoDeletes(AGENSTRA_CONTROLLER_DELETE_TARGETS).forEach((statement) => controllerScript.add(statement)); + controllerRows.writeTo(controllerScript); + + applyScript(context, manager, managerScript, 'agenstra-manager-seed.sql'); + applyScript(context, controller, controllerScript, 'agenstra-controller-seed.sql'); + + if (settings.connectNetworks && !context.dryRun) { + this.connectControllerToManagerNetwork(context); + } + + context.log(`Agenstra: ${managerRows.totalRows} manager rows, ${controllerRows.totalRows} controller rows`); + context.log( + [ + `Agenstra logins (password for all accounts: ${DEMO_PASSWORD}):`, + ...result.loginEmails.map((email) => ` ${email}`), + ].join('\n'), + ); + context.log(`Agenstra TOTP secret for *-totp accounts: ${DEMO_TOTP_SECRET}`); + context.log(`Workspaces reach the local agent-manager at ${settings.managerEndpoint}`); + } + + async reset(context: SeederContext): Promise { + const settings = resolveAgenstraSettings(context.workspaceRoot); + const { controller, manager } = this.connect(context, settings); + const controllerScript = new SqlScript(); + const managerScript = new SqlScript(); + + buildDemoDeletes(AGENSTRA_CONTROLLER_DELETE_TARGETS).forEach((statement) => controllerScript.add(statement)); + buildDemoDeletes(AGENSTRA_MANAGER_DELETE_TARGETS).forEach((statement) => managerScript.add(statement)); + applyScript(context, controller, controllerScript, 'agenstra-controller-reset.sql'); + applyScript(context, manager, managerScript, 'agenstra-manager-reset.sql'); + } + + private connect( + context: SeederContext, + settings: AgenstraSettings, + ): { controller: PostgresContainer; manager: PostgresContainer } { + const controller = new PostgresContainer(settings.controller); + const manager = new PostgresContainer(settings.manager); + + if (!context.dryRun) { + for (const [database, tables, app] of [ + [controller, AGENSTRA_CONTROLLER_REQUIRED_TABLES, 'agent-controller-api'], + [manager, AGENSTRA_MANAGER_REQUIRED_TABLES, 'agent-manager-api'], + ] as const) { + database.assertRunning(); + const missing = database.findMissingTables([...tables]); + + if (missing.length > 0) { + throw new Error( + `Agenstra schema on ${database.label} is incomplete (missing ${missing.join(', ')}). ` + + `Wait until ${app} has started and run its migrations.`, + ); + } + } + } + + return { controller, manager }; + } + + private hasNoGlobalOpencodeConfig(controller: PostgresContainer): boolean { + // The global OpenCode config is a singleton; never add a second row next to a real one. + const [[count] = ['0']] = controller.query( + `SELECT count(*) FROM global_opencode_config WHERE id::text NOT LIKE '5eedda7a-%'`, + ); + + return count === '0'; + } + + /** + * The controller and manager compose projects use separate bridge networks. Joining the + * controller containers to the manager network lets workspaces reach `agent-manager-api`. + * `start-containers` recreates containers, so this has to run again after each restart. + */ + private connectControllerToManagerNetwork(context: SeederContext): void { + for (const container of CONTROLLER_APP_CONTAINERS) { + const result = runDocker(['network', 'connect', MANAGER_NETWORK, container]); + const output = `${result.stdout}${result.stderr}`; + + if (result.status === 0) { + context.log(`Connected ${container} to ${MANAGER_NETWORK}`); + } else if (/already exists/i.test(output)) { + context.log(`${container} is already connected to ${MANAGER_NETWORK}`); + } else { + context.log(`Warning: could not connect ${container} to ${MANAGER_NETWORK}: ${output.trim()}`); + } + } + } +} diff --git a/tools/demo-data/src/lib/agenstra/agenstra.tables.ts b/tools/demo-data/src/lib/agenstra/agenstra.tables.ts new file mode 100644 index 000000000..3fd70cf1d --- /dev/null +++ b/tools/demo-data/src/lib/agenstra/agenstra.tables.ts @@ -0,0 +1,103 @@ +import { DemoDeleteTarget } from '../core/seeder'; + +export const AGENSTRA_MANAGER_INSERT_ORDER = [ + 'agents', + 'agent_chat_sessions', + 'agent_messages', + 'agent_environment_variables', + 'deployment_configurations', + 'deployment_runs', + 'regex_filter_rules', +] as const; + +export const AGENSTRA_MANAGER_DELETE_TARGETS: readonly DemoDeleteTarget[] = [...AGENSTRA_MANAGER_INSERT_ORDER] + .reverse() + .map((table) => ({ table })); + +export const AGENSTRA_MANAGER_REQUIRED_TABLES = ['agents', 'agent_chat_sessions', 'agent_messages']; + +export const AGENSTRA_CONTROLLER_INSERT_ORDER = [ + 'users', + 'user_personal_access_tokens', + 'clients', + 'client_users', + 'client_agent_credentials', + 'provisioning_references', + 'statistics_users', + 'statistics_clients', + 'statistics_agents', + 'statistics_client_users', + 'statistics_provisioning_references', + 'statistics_chat_io', + 'statistics_chat_filter_drops', + 'statistics_chat_filter_flags', + 'statistics_entity_events', + 'tickets', + 'ticket_comments', + 'ticket_activity', + 'ticket_body_generation_sessions', + 'client_agent_autonomy', + 'ticket_automation', + 'ticket_automation_run', + 'ticket_automation_run_step', + 'ticket_automation_lease', + 'knowledge_nodes', + 'knowledge_relations', + 'knowledge_page_activity', + 'atlassian_site_connections', + 'external_import_configs', + 'external_import_sync_markers', + 'agent_console_regex_filter_rules', + 'agent_console_regex_filter_rule_clients', + 'agent_console_regex_filter_rule_sync_targets', + 'client_opencode_config', + 'global_opencode_config', + 'user_environment_read_state', + 'user_chat_session_read_state', + 'webhook_endpoints', + 'webhook_deliveries', + 'email_deliveries', +] as const; + +/** Tables without a demo `id` column: deleted through the column that references demo rows. */ +const CONTROLLER_KEY_COLUMNS: Record = { + client_agent_autonomy: 'client_id', + ticket_automation: 'ticket_id', + ticket_automation_lease: 'ticket_id', +}; +/** + * Rows the running app may have created for demo entities (statistics mirrors, sync targets, + * read markers). They do not cascade from demo rows and would collide with a re-seed. + */ +const CONTROLLER_APP_CREATED_TARGETS: readonly DemoDeleteTarget[] = [ + { table: 'statistics_entity_events', column: 'original_entity_id' }, + { table: 'statistics_chat_io', column: 'statistics_client_id' }, + { table: 'statistics_client_users', column: 'original_client_user_id' }, + { table: 'statistics_agents', column: 'original_agent_id' }, + { table: 'statistics_provisioning_references', column: 'original_provisioning_reference_id' }, + { table: 'statistics_clients', column: 'original_client_id' }, + { table: 'statistics_users', column: 'original_user_id' }, + { table: 'user_environment_read_state', column: 'client_id' }, + { table: 'user_chat_session_read_state', column: 'client_id' }, + { table: 'opencode_config_sync_targets', column: 'agent_id' }, + { table: 'opencode_layer_file_sync_targets', column: 'client_id' }, +]; + +export const AGENSTRA_CONTROLLER_DELETE_TARGETS: readonly DemoDeleteTarget[] = [ + ...CONTROLLER_APP_CREATED_TARGETS, + ...[...AGENSTRA_CONTROLLER_INSERT_ORDER] + .reverse() + .map((table) => ({ table, column: CONTROLLER_KEY_COLUMNS[table] ?? 'id' })), +]; + +export const AGENSTRA_CONTROLLER_REQUIRED_TABLES = [ + 'users', + 'clients', + 'tickets', + 'knowledge_nodes', + 'ticket_automation_run', + 'statistics_chat_io', + 'opencode_config_sync_targets', + 'opencode_layer_file_sync_targets', + 'webhook_endpoints', +]; diff --git a/tools/demo-data/src/lib/core/accounts.ts b/tools/demo-data/src/lib/core/accounts.ts new file mode 100644 index 000000000..8fcc0d69b --- /dev/null +++ b/tools/demo-data/src/lib/core/accounts.ts @@ -0,0 +1,154 @@ +import { ColumnEncryptor } from './encryption'; +import { PasswordHasher } from './passwords'; +import { DemoRandom } from './random'; +import { DEMO_EMAIL_CODE, DEMO_PASSWORD, DEMO_TOTP_SECRET } from './seeder'; +import { json, SqlRow } from './sql'; + +export type DemoUserRole = 'admin' | 'user' | 'controller'; + +/** + * One entry per account state supported by the identity `users` table (shared by Decabill and + * Agenstra). Login behaviour per state is defined in identity `auth.service.ts`. + */ +export interface AccountStateTemplate { + key: string; + role: DemoUserRole; + description: string; + confirmed: boolean; + locked?: boolean; + totp?: boolean; + email2fa?: boolean; + passwordResetPending?: boolean; + passwordResetExpired?: boolean; + /** Keycloak-linked account without local password. */ + external?: boolean; + /** Sessions were invalidated (logout everywhere / credential change). */ + bumpedTokenVersion?: boolean; + billingDayOfMonth?: number; +} + +export const ACCOUNT_STATES: readonly AccountStateTemplate[] = [ + { key: 'admin', role: 'admin', description: 'Administrator', confirmed: true }, + { + key: 'admin-totp', + role: 'admin', + description: 'Administrator with authenticator app', + confirmed: true, + totp: true, + }, + { key: 'user', role: 'user', description: 'Active user', confirmed: true }, + { key: 'user-email-2fa', role: 'user', description: 'User with email 2FA opt-in', confirmed: true, email2fa: true }, + { key: 'user-totp', role: 'user', description: 'User with authenticator app', confirmed: true, totp: true }, + { key: 'user-unconfirmed', role: 'user', description: 'Registered, email not confirmed', confirmed: false }, + { key: 'user-locked', role: 'user', description: 'Locked account', confirmed: true, locked: true }, + { + key: 'user-password-reset', + role: 'user', + description: 'Password reset requested', + confirmed: true, + passwordResetPending: true, + }, + { + key: 'user-password-reset-expired', + role: 'user', + description: 'Password reset link expired', + confirmed: true, + passwordResetExpired: true, + }, + { key: 'user-sso', role: 'user', description: 'Keycloak (SSO) linked user', confirmed: true, external: true }, + { + key: 'user-sessions-revoked', + role: 'user', + description: 'User whose sessions were revoked', + confirmed: true, + bumpedTokenVersion: true, + }, + { + key: 'user-billing-day', + role: 'user', + description: 'User with fixed billing day', + confirmed: true, + billingDayOfMonth: 15, + }, +]; + +export interface BuildUserRowOptions { + id: string; + tenantId: string; + email: string; + state: AccountStateTemplate; + createdAt: Date; + random: DemoRandom; + hasher: PasswordHasher; + encryptor: ColumnEncryptor; +} + +/** Builds a row for the identity `users` table. */ +export async function buildUserRow(options: BuildUserRowOptions): Promise { + const { state, random, hasher, encryptor, createdAt } = options; + const passwordHash = state.external ? null : await hasher.hash(DEMO_PASSWORD); + const codeHash = await hasher.hash(DEMO_EMAIL_CODE); + const enrolledAt = random.addDays(createdAt, random.int(1, 20)); + + return { + id: options.id, + tenant_id: options.tenantId, + email: options.email.toLowerCase(), + password_hash: passwordHash, + role: state.role, + email_confirmed_at: state.confirmed ? random.addMinutes(createdAt, random.int(2, 90)) : null, + locked_at: state.locked ? random.daysAgo(random.int(1, 20)) : null, + token_version: state.bumpedTokenVersion ? random.int(2, 6) : 0, + email_confirmation_token: state.confirmed ? null : codeHash, + password_reset_token: state.passwordResetPending || state.passwordResetExpired ? codeHash : null, + password_reset_token_expires_at: state.passwordResetPending + ? random.daysFromNow(1) + : state.passwordResetExpired + ? random.daysAgo(2) + : null, + keycloak_sub: state.external ? random.id() : null, + totp_secret: state.totp ? encryptor.encrypt(DEMO_TOTP_SECRET) : null, + totp_enabled_at: state.totp ? enrolledAt : null, + email_2fa_enabled_at: state.email2fa ? enrolledAt : null, + billing_day_of_month: state.billingDayOfMonth ?? null, + created_at: createdAt, + updated_at: random.addDays(createdAt, random.int(0, 30)), + }; +} + +/** Personal access tokens in active, expiring, expired and revoked states. */ +export async function buildPersonalAccessTokenRows( + random: DemoRandom, + hasher: PasswordHasher, + userId: string, + scopes: readonly string[], +): Promise { + const variants = [ + { name: 'CI pipeline', expiresAt: random.daysFromNow(180), revokedAt: null, lastUsed: random.daysAgo(1) }, + { name: 'Reporting script', expiresAt: random.daysFromNow(5), revokedAt: null, lastUsed: random.daysAgo(9) }, + { name: 'Old laptop', expiresAt: random.daysAgo(14), revokedAt: null, lastUsed: random.daysAgo(40) }, + { name: 'Leaked token', expiresAt: null, revokedAt: random.daysAgo(3), lastUsed: random.daysAgo(4) }, + ]; + const rows: SqlRow[] = []; + + for (const variant of variants) { + // The secret is discarded: demo tokens are listed in the UI but cannot authenticate. + const secret = random.alphanumeric(40); + + rows.push({ + id: random.id(), + user_id: userId, + name: variant.name, + // `fp_pat_` + 8 characters, matching PAT_LOOKUP_PREFIX_LENGTH. + token_prefix: `fp_pat_${random.alphanumeric(8)}`, + token_hash: await hasher.hash(secret, 4), + scopes: json(random.pickMany(scopes, random.int(1, Math.min(3, scopes.length)))), + expires_at: variant.expiresAt, + revoked_at: variant.revokedAt, + last_used_at: variant.lastUsed, + created_at: random.daysAgo(random.int(60, 200)), + }); + } + + return rows; +} diff --git a/tools/demo-data/src/lib/core/demo-ids.ts b/tools/demo-data/src/lib/core/demo-ids.ts new file mode 100644 index 000000000..6bd12a39b --- /dev/null +++ b/tools/demo-data/src/lib/core/demo-ids.ts @@ -0,0 +1,22 @@ +/** + * Every row created by the demo data seeders uses a UUID starting with this prefix ("seed data"). + * Resets delete exactly those rows, so real data in the same database is never touched. + */ +export const DEMO_ID_PREFIX = '5eedda7a'; + +export const DEMO_ID_LIKE_PATTERN = `${DEMO_ID_PREFIX}-%`; + +export function isDemoId(id: string): boolean { + return id.startsWith(`${DEMO_ID_PREFIX}-`); +} + +/** + * Builds an RFC 4122 v4-shaped UUID whose first group is the demo prefix. + * `nextNibble` must return integers in [0, 15]. + */ +export function createDemoId(nextNibble: () => number): string { + const hex = (count: number): string => Array.from({ length: count }, () => nextNibble().toString(16)).join(''); + const variant = (8 + (nextNibble() % 4)).toString(16); + + return `${DEMO_ID_PREFIX}-${hex(4)}-4${hex(3)}-${variant}${hex(3)}-${hex(12)}`; +} diff --git a/tools/demo-data/src/lib/core/encryption.spec.ts b/tools/demo-data/src/lib/core/encryption.spec.ts new file mode 100644 index 000000000..d421899f0 --- /dev/null +++ b/tools/demo-data/src/lib/core/encryption.spec.ts @@ -0,0 +1,45 @@ +import { randomBytes } from 'crypto'; + +import { ColumnEncryptor } from './encryption'; + +describe('ColumnEncryptor', () => { + const key = randomBytes(32).toString('base64'); + + it('should produce iv:tag:ciphertext that decrypts to the original value', () => { + const encryptor = new ColumnEncryptor(key); + const stored = encryptor.encrypt('secret value'); + + expect(stored.split(':')).toHaveLength(3); + expect(encryptor.decrypt(stored)).toBe('secret value'); + }); + + it('should round-trip JSON payloads', () => { + const encryptor = new ColumnEncryptor(key); + + expect(JSON.parse(encryptor.decrypt(encryptor.encryptJson({ a: 1 })))).toEqual({ a: 1 }); + }); + + it('should keep null and empty strings untouched', () => { + const encryptor = new ColumnEncryptor(key); + + expect(encryptor.encrypt(null)).toBeNull(); + expect(encryptor.encrypt('')).toBe(''); + }); + + it('should fall back to the development key when no key is configured', () => { + const withFallback = new ColumnEncryptor(undefined); + const explicit = new ColumnEncryptor(Buffer.alloc(32, 0x11).toString('base64')); + + expect(explicit.decrypt(withFallback.encrypt('x'))).toBe('x'); + }); + + it('should reject keys that are not 32 bytes', () => { + expect(() => new ColumnEncryptor(Buffer.alloc(16).toString('base64'))).toThrow('32-byte'); + }); + + it('should fail to decrypt values encrypted with another key', () => { + const stored = new ColumnEncryptor(key).encrypt('x'); + + expect(() => new ColumnEncryptor(randomBytes(32).toString('base64')).decrypt(stored)).toThrow(); + }); +}); diff --git a/tools/demo-data/src/lib/core/encryption.ts b/tools/demo-data/src/lib/core/encryption.ts new file mode 100644 index 000000000..d506b3bd2 --- /dev/null +++ b/tools/demo-data/src/lib/core/encryption.ts @@ -0,0 +1,59 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +/** + * Mirrors `libs/domains/shared/backend/util-crypto/src/lib/encryption.transformer.ts`: + * AES-256-GCM, 12-byte IV, stored as `base64(iv):base64(tag):base64(ciphertext)`. + * Each backend uses its own ENCRYPTION_KEY, so build one encryptor per database. + */ +export class ColumnEncryptor { + private readonly key: Buffer; + + constructor(base64Key: string | undefined) { + const trimmed = base64Key?.trim(); + + // The transformer falls back to a fixed development key when ENCRYPTION_KEY is unset. + this.key = trimmed ? Buffer.from(trimmed, 'base64') : Buffer.alloc(32, 0x11); + + if (this.key.length !== 32) { + throw new Error('ENCRYPTION_KEY must be a base64-encoded 32-byte key'); + } + } + + encrypt(plain: string): string; + encrypt(plain: string | null): string | null; + encrypt(plain: string | null): string | null { + if (plain === null) { + return null; + } + + if (plain === '') { + return ''; + } + + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.key, iv); + const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + return `${iv.toString('base64')}:${tag.toString('base64')}:${ciphertext.toString('base64')}`; + } + + encryptJson(value: unknown): string { + return this.encrypt(JSON.stringify(value)); + } + + decrypt(stored: string): string { + const parts = stored.split(':'); + + if (parts.length !== 3) { + return stored; + } + + const [ivB64, tagB64, dataB64] = parts; + const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(ivB64, 'base64')); + + decipher.setAuthTag(Buffer.from(tagB64, 'base64')); + + return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8'); + } +} diff --git a/tools/demo-data/src/lib/core/env-file.spec.ts b/tools/demo-data/src/lib/core/env-file.spec.ts new file mode 100644 index 000000000..0f78f1679 --- /dev/null +++ b/tools/demo-data/src/lib/core/env-file.spec.ts @@ -0,0 +1,62 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { loadEnvFile, parseEnvFile, readBooleanEnv, readListEnv } from './env-file'; + +describe('parseEnvFile', () => { + it('should parse plain, quoted, exported and commented entries', () => { + const values = parseEnvFile( + [ + '# comment', + 'PLAIN=value', + 'EMPTY=', + 'export EXPORTED=yes', + 'DOUBLE="with spaces"', + "SINGLE='keep # hash'", + 'INLINE=value # trailing comment', + 'KEY_WITH_EQUALS=a=b', + 'not-an-entry', + ].join('\n'), + ); + + expect(values).toEqual({ + PLAIN: 'value', + EMPTY: '', + EXPORTED: 'yes', + DOUBLE: 'with spaces', + SINGLE: 'keep # hash', + INLINE: 'value', + KEY_WITH_EQUALS: 'a=b', + }); + }); +}); + +describe('loadEnvFile', () => { + it('should return defaults when the file does not exist', () => { + expect(loadEnvFile('/does/not/exist.env', { A: '1' })).toEqual({ A: '1' }); + }); + + it('should keep defaults for empty values like compose does', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'demo-data-env-')); + const file = path.join(dir, '.start-containers.env'); + + fs.writeFileSync(file, 'DB_DATABASE=\nTENANTS=a,b\n'); + + expect(loadEnvFile(file, { DB_DATABASE: 'postgres' })).toEqual({ DB_DATABASE: 'postgres', TENANTS: 'a,b' }); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('env helpers', () => { + it('should read booleans with a fallback', () => { + expect(readBooleanEnv({ A: 'true', B: 'false' }, 'A', false)).toBe(true); + expect(readBooleanEnv({ A: 'true', B: 'false' }, 'B', true)).toBe(false); + expect(readBooleanEnv({}, 'C', true)).toBe(true); + }); + + it('should split comma separated lists', () => { + expect(readListEnv({ TENANTS: ' a, b ,,c ' }, 'TENANTS')).toEqual(['a', 'b', 'c']); + expect(readListEnv({}, 'TENANTS')).toEqual([]); + }); +}); diff --git a/tools/demo-data/src/lib/core/env-file.ts b/tools/demo-data/src/lib/core/env-file.ts new file mode 100644 index 000000000..e7d3f49be --- /dev/null +++ b/tools/demo-data/src/lib/core/env-file.ts @@ -0,0 +1,84 @@ +import * as fs from 'fs'; + +export type EnvValues = Record; + +/** + * Parses a dotenv-style file (the format Nx loads for `.start-containers.env`). + * Supports comments, `export` prefixes and single/double quoted values. + */ +export function parseEnvFile(content: string): EnvValues { + const values: EnvValues = {}; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + + if (!line || line.startsWith('#')) { + continue; + } + + const withoutExport = line.startsWith('export ') ? line.slice('export '.length).trim() : line; + const separatorIndex = withoutExport.indexOf('='); + + if (separatorIndex <= 0) { + continue; + } + + const key = withoutExport.slice(0, separatorIndex).trim(); + let value = withoutExport.slice(separatorIndex + 1).trim(); + const quote = value[0]; + + if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length >= 2) { + value = value.slice(1, -1); + + if (quote === '"') { + value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"'); + } + } else { + // Unquoted values may carry trailing inline comments (`KEY=value # comment`). + value = value.replace(/\s+#.*$/, ''); + } + + values[key] = value; + } + + return values; +} + +/** + * Loads an env file; returns an empty object when the file does not exist so that compose + * defaults (passed as `defaults`) still apply. + */ +export function loadEnvFile(filePath: string, defaults: EnvValues = {}): EnvValues { + if (!fs.existsSync(filePath)) { + return { ...defaults }; + } + + const parsed = parseEnvFile(fs.readFileSync(filePath, 'utf8')); + const merged: EnvValues = { ...defaults }; + + for (const [key, value] of Object.entries(parsed)) { + // Compose uses `${VAR:-default}`: an empty value falls back to the default. + if (value !== '' || !(key in merged)) { + merged[key] = value; + } + } + + return merged; +} + +export function readBooleanEnv(env: EnvValues, key: string, fallback: boolean): boolean { + const value = env[key]?.trim().toLowerCase(); + + if (!value) { + return fallback; + } + + return value === 'true' || value === '1' || value === 'yes'; +} + +export function readListEnv(env: EnvValues, key: string): string[] { + return (env[key] ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} diff --git a/tools/demo-data/src/lib/core/fixtures.ts b/tools/demo-data/src/lib/core/fixtures.ts new file mode 100644 index 000000000..239107007 --- /dev/null +++ b/tools/demo-data/src/lib/core/fixtures.ts @@ -0,0 +1,185 @@ +/** Word pools used to compose plausible names, companies and addresses. */ + +export const FIRST_NAMES = [ + 'Anna', + 'Ben', + 'Clara', + 'David', + 'Elena', + 'Felix', + 'Greta', + 'Hannes', + 'Ida', + 'Jonas', + 'Katharina', + 'Lukas', + 'Marie', + 'Niklas', + 'Olivia', + 'Paul', + 'Quentin', + 'Rosa', + 'Simon', + 'Theresa', + 'Ulrich', + 'Valentina', + 'Wolfgang', + 'Xenia', + 'Yusuf', + 'Zoe', + 'Amelie', + 'Emil', + 'Leonie', + 'Mats', + 'Sofia', + 'Tobias', +] as const; + +export const LAST_NAMES = [ + 'Bauer', + 'Becker', + 'Brandt', + 'Fischer', + 'Hartmann', + 'Hoffmann', + 'Jansen', + 'Keller', + 'Koch', + 'Krüger', + 'Lange', + 'Lehmann', + 'Meyer', + 'Neumann', + 'Peters', + 'Richter', + 'Schmidt', + 'Schneider', + 'Schulz', + 'Wagner', + 'Weber', + 'Wolf', + 'Ziegler', + 'Dubois', + 'Rossi', + 'Novak', + 'Jensen', + 'van Dijk', +] as const; + +export const COMPANY_STEMS = [ + 'Nordlicht', + 'Bergwerk', + 'Blauwal', + 'Kranich', + 'Lindenhof', + 'Polarstern', + 'Rheinblick', + 'Sonnenfeld', + 'Tannenhain', + 'Weitblick', + 'Ahornweg', + 'Elbufer', + 'Falkenstein', + 'Seeblick', + 'Morgenrot', + 'Steinbach', +] as const; + +export const COMPANY_KINDS = [ + 'Digital', + 'Software', + 'Consulting', + 'Logistik', + 'Medien', + 'Systems', + 'Labs', + 'Solutions', + 'Analytics', + 'Studio', +] as const; + +export const COMPANY_LEGAL_FORMS = ['GmbH', 'GmbH & Co. KG', 'AG', 'UG (haftungsbeschränkt)', 'e.K.'] as const; + +export const STREETS = [ + 'Hauptstraße', + 'Bahnhofstraße', + 'Gartenweg', + 'Lindenallee', + 'Schillerstraße', + 'Goethestraße', + 'Am Markt', + 'Industriestraße', + 'Hafenstraße', + 'Mühlenweg', + 'Kirchplatz', + 'Rosenstraße', +] as const; + +export interface CityFixture { + city: string; + postalCode: string; + country: string; + state?: string; + vatPrefix: string; + phonePrefix: string; +} + +export const CITIES: readonly CityFixture[] = [ + { city: 'Berlin', postalCode: '10115', country: 'DE', state: 'Berlin', vatPrefix: 'DE', phonePrefix: '+49 30' }, + { city: 'Hamburg', postalCode: '20095', country: 'DE', state: 'Hamburg', vatPrefix: 'DE', phonePrefix: '+49 40' }, + { city: 'München', postalCode: '80331', country: 'DE', state: 'Bayern', vatPrefix: 'DE', phonePrefix: '+49 89' }, + { city: 'Köln', postalCode: '50667', country: 'DE', state: 'NRW', vatPrefix: 'DE', phonePrefix: '+49 221' }, + { city: 'Bielefeld', postalCode: '33602', country: 'DE', state: 'NRW', vatPrefix: 'DE', phonePrefix: '+49 521' }, + { city: 'Leipzig', postalCode: '04109', country: 'DE', state: 'Sachsen', vatPrefix: 'DE', phonePrefix: '+49 341' }, + { city: 'Wien', postalCode: '1010', country: 'AT', vatPrefix: 'ATU', phonePrefix: '+43 1' }, + { city: 'Zürich', postalCode: '8001', country: 'CH', vatPrefix: 'CHE', phonePrefix: '+41 44' }, + { city: 'Amsterdam', postalCode: '1012 AB', country: 'NL', vatPrefix: 'NL', phonePrefix: '+31 20' }, + { city: 'Paris', postalCode: '75001', country: 'FR', vatPrefix: 'FR', phonePrefix: '+33 1' }, + { city: 'Wrocław', postalCode: '50-001', country: 'PL', vatPrefix: 'PL', phonePrefix: '+48 71' }, + { city: 'Austin', postalCode: '78701', country: 'US', state: 'TX', vatPrefix: '', phonePrefix: '+1 512' }, +]; + +export const LOREM_SENTENCES = [ + 'Please double-check the numbers before the next review.', + 'The customer asked for a short status update by Friday.', + 'We aligned on the approach in yesterday’s call.', + 'This depends on the infrastructure change being deployed first.', + 'Edge cases around time zones still need a test.', + 'Documentation should be updated together with the release notes.', + 'The current behaviour is confusing for first-time users.', + 'Performance looks fine with the sample data set.', + 'Let us keep the scope small and iterate afterwards.', + 'Rollback is possible by reverting the configuration flag.', +] as const; + +export const SOFTWARE_TOPICS = [ + 'login flow', + 'invoice export', + 'search results', + 'dashboard charts', + 'notification emails', + 'onboarding wizard', + 'API rate limiting', + 'file uploads', + 'user settings', + 'audit trail', + 'dark mode', + 'webhook retries', + 'payment checkout', + 'CSV import', + 'mobile navigation', + 'permissions model', +] as const; + +export const TICKET_VERBS = [ + 'Fix', + 'Improve', + 'Refactor', + 'Add tests for', + 'Document', + 'Redesign', + 'Speed up', + 'Localize', + 'Harden', + 'Investigate', +] as const; diff --git a/tools/demo-data/src/lib/core/passwords.ts b/tools/demo-data/src/lib/core/passwords.ts new file mode 100644 index 000000000..180c79219 --- /dev/null +++ b/tools/demo-data/src/lib/core/passwords.ts @@ -0,0 +1,21 @@ +import * as bcrypt from 'bcrypt'; + +/** + * Hashes with bcrypt like the identity services do. Hashes are cached per (plaintext, rounds) + * because demo users share passwords and bcrypt at cost 12 is deliberately slow. + */ +export class PasswordHasher { + private readonly cache = new Map>(); + + hash(plain: string, rounds = 12): Promise { + const cacheKey = `${rounds}:${plain}`; + let pending = this.cache.get(cacheKey); + + if (!pending) { + pending = bcrypt.hash(plain, rounds); + this.cache.set(cacheKey, pending); + } + + return pending; + } +} diff --git a/tools/demo-data/src/lib/core/people.ts b/tools/demo-data/src/lib/core/people.ts new file mode 100644 index 000000000..7c0fa4134 --- /dev/null +++ b/tools/demo-data/src/lib/core/people.ts @@ -0,0 +1,72 @@ +import { + CITIES, + CityFixture, + COMPANY_KINDS, + COMPANY_LEGAL_FORMS, + COMPANY_STEMS, + FIRST_NAMES, + LAST_NAMES, + STREETS, +} from './fixtures'; +import { DemoRandom, slugify } from './random'; + +export interface DemoPerson { + firstName: string; + lastName: string; + fullName: string; + company: string | null; + emailLocalPart: string; + addressLine1: string; + addressLine2: string | null; + location: CityFixture; + phone: string; +} + +export function createCompanyName(random: DemoRandom): string { + return `${random.pick(COMPANY_STEMS)} ${random.pick(COMPANY_KINDS)} ${random.pick(COMPANY_LEGAL_FORMS)}`; +} + +export function createVatId(random: DemoRandom, location: CityFixture): string | null { + if (!location.vatPrefix) { + return null; + } + + return `${location.vatPrefix}${random.int(100000000, 999999999)}`; +} + +/** + * Creates a person with a unique email local part. `usedLocalParts` is shared across one + * scope (for example a tenant) to avoid unique-constraint collisions. + */ +export function createPerson( + random: DemoRandom, + usedLocalParts: Set, + options: { business?: boolean } = {}, +): DemoPerson { + const firstName = random.pick(FIRST_NAMES); + const lastName = random.pick(LAST_NAMES); + const base = `${slugify(firstName)}.${slugify(lastName)}`.replace(/-/g, ''); + let emailLocalPart = base; + let suffix = 2; + + while (usedLocalParts.has(emailLocalPart)) { + emailLocalPart = `${base}${suffix}`; + suffix++; + } + + usedLocalParts.add(emailLocalPart); + const location = random.pick(CITIES); + const business = options.business ?? random.chance(0.6); + + return { + firstName, + lastName, + fullName: `${firstName} ${lastName}`, + company: business ? createCompanyName(random) : null, + emailLocalPart, + addressLine1: `${random.pick(STREETS)} ${random.int(1, 180)}`, + addressLine2: random.chance(0.2) ? `${random.int(1, 5)}. OG` : null, + location, + phone: `${location.phonePrefix} ${random.int(1000000, 9999999)}`, + }; +} diff --git a/tools/demo-data/src/lib/core/postgres-container.ts b/tools/demo-data/src/lib/core/postgres-container.ts new file mode 100644 index 000000000..b02950ec8 --- /dev/null +++ b/tools/demo-data/src/lib/core/postgres-container.ts @@ -0,0 +1,121 @@ +import { spawnSync } from 'child_process'; + +export interface PostgresContainerOptions { + container: string; + user: string; + database: string; +} + +export interface CommandResult { + status: number; + stdout: string; + stderr: string; +} + +export function runDocker(args: string[], input?: string): CommandResult { + const result = spawnSync('docker', args, { + input, + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + }); + + if (result.error) { + throw new Error(`Failed to run docker ${args[0]}: ${result.error.message}`); + } + + return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +/** + * Talks to a Postgres server running inside a compose container via `docker exec … psql`. + * The compose files do not publish Postgres ports, so this is the only path that works with + * a plain `start-containers` setup. + */ +export class PostgresContainer { + constructor(private readonly options: PostgresContainerOptions) {} + + get label(): string { + return `${this.options.container}/${this.options.database}`; + } + + assertRunning(): void { + const result = runDocker(['inspect', '--format', '{{.State.Running}}', this.options.container]); + + if (result.status !== 0 || result.stdout.trim() !== 'true') { + throw new Error( + `Container ${this.options.container} is not running. Start it with the app's start-containers target first.`, + ); + } + } + + /** Runs a script in a single transaction; any error rolls everything back. */ + execute(sql: string): void { + const result = runDocker( + [ + 'exec', + '-i', + this.options.container, + 'psql', + '-X', + '-q', + '-v', + 'ON_ERROR_STOP=1', + '--single-transaction', + '-U', + this.options.user, + '-d', + this.options.database, + '-f', + '-', + ], + sql, + ); + + if (result.status !== 0) { + throw new Error(`psql failed on ${this.label}:\n${result.stderr.trim() || result.stdout.trim()}`); + } + } + + /** Runs a read-only query and returns rows as arrays of column strings. */ + query(sql: string): string[][] { + const result = runDocker([ + 'exec', + this.options.container, + 'psql', + '-X', + '-A', + '-t', + '-F', + '\u001f', + '-v', + 'ON_ERROR_STOP=1', + '-U', + this.options.user, + '-d', + this.options.database, + '-c', + sql, + ]); + + if (result.status !== 0) { + throw new Error(`psql query failed on ${this.label}:\n${result.stderr.trim()}`); + } + + return result.stdout + .split('\n') + .filter((line) => line.length > 0) + .map((line) => line.split('\u001f')); + } + + /** Returns the subset of `tables` that do not exist (i.e. migrations have not run yet). */ + findMissingTables(tables: string[]): string[] { + const list = tables.map((table) => `'${table.replace(/'/g, "''")}'`).join(', '); + const existing = new Set( + this.query( + `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN (${list})`, + ).map(([name]) => name), + ); + + return tables.filter((table) => !existing.has(table)); + } +} diff --git a/tools/demo-data/src/lib/core/random.spec.ts b/tools/demo-data/src/lib/core/random.spec.ts new file mode 100644 index 000000000..8e13fea7e --- /dev/null +++ b/tools/demo-data/src/lib/core/random.spec.ts @@ -0,0 +1,59 @@ +import { DEMO_ID_PREFIX, isDemoId } from './demo-ids'; +import { DemoRandom, slugify } from './random'; + +describe('DemoRandom', () => { + const now = new Date('2026-10-06T12:00:00.000Z'); + + it('should be deterministic for the same seed', () => { + const a = new DemoRandom(42, now); + const b = new DemoRandom(42, now); + + expect(Array.from({ length: 5 }, () => a.next())).toEqual(Array.from({ length: 5 }, () => b.next())); + }); + + it('should create v4-shaped ids with the demo prefix', () => { + const id = new DemoRandom(1, now).id(); + + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(id.startsWith(DEMO_ID_PREFIX)).toBe(true); + expect(isDemoId(id)).toBe(true); + }); + + it('should keep integers within bounds', () => { + const random = new DemoRandom(7, now); + + for (let i = 0; i < 200; i++) { + const value = random.int(3, 5); + + expect(value).toBeGreaterThanOrEqual(3); + expect(value).toBeLessThanOrEqual(5); + } + }); + + it('should return every deck item before repeating', () => { + const random = new DemoRandom(3, now); + const next = random.deck(['a', 'b', 'c']); + + expect([next(), next(), next()].sort()).toEqual(['a', 'b', 'c']); + }); + + it('should pick distinct items', () => { + const picked = new DemoRandom(9, now).pickMany([1, 2, 3, 4], 3); + + expect(new Set(picked).size).toBe(3); + }); + + it('should compute dates relative to the reference time', () => { + const random = new DemoRandom(1, now); + + expect(random.daysFromNow(1).toISOString()).toBe('2026-10-07T12:00:00.000Z'); + expect(random.daysAgo(2).toISOString()).toBe('2026-10-04T12:00:00.000Z'); + }); +}); + +describe('slugify', () => { + it('should create url-safe slugs', () => { + expect(slugify('Krüger & Söhne GmbH')).toBe('kruger-sohne-gmbh'); + expect(slugify(' Straße ')).toBe('strasse'); + }); +}); diff --git a/tools/demo-data/src/lib/core/random.ts b/tools/demo-data/src/lib/core/random.ts new file mode 100644 index 000000000..9224a4e45 --- /dev/null +++ b/tools/demo-data/src/lib/core/random.ts @@ -0,0 +1,143 @@ +import { createDemoId } from './demo-ids'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Seeded pseudo random generator (mulberry32). The same `--seed` produces the same data set, + * which keeps demos and screenshots reproducible. + */ +export class DemoRandom { + private state: number; + + constructor( + seed: number, + readonly now: Date = new Date(), + ) { + this.state = seed >>> 0; + } + + next(): number { + this.state = (this.state + 0x6d2b79f5) >>> 0; + let t = this.state; + + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + /** Integer in [min, max] (inclusive). */ + int(min: number, max: number): number { + return min + Math.floor(this.next() * (max - min + 1)); + } + + /** Decimal in [min, max] rounded to `decimals`. */ + decimal(min: number, max: number, decimals = 2): number { + const factor = 10 ** decimals; + + return Math.round((min + this.next() * (max - min)) * factor) / factor; + } + + chance(probability: number): boolean { + return this.next() < probability; + } + + pick(items: readonly T[]): T { + if (items.length === 0) { + throw new Error('Cannot pick from an empty list'); + } + + return items[Math.floor(this.next() * items.length)]; + } + + /** Picks `count` distinct items (or all items when the list is shorter). */ + pickMany(items: readonly T[], count: number): T[] { + return this.shuffle(items).slice(0, Math.min(count, items.length)); + } + + /** + * Returns a picker that walks through all items (shuffled) before repeating, so every state + * appears at least once as soon as the picker is called `items.length` times. + */ + deck(items: readonly T[]): () => T { + let queue: T[] = []; + + return () => { + if (queue.length === 0) { + queue = this.shuffle(items); + } + + return queue.shift() as T; + }; + } + + shuffle(items: readonly T[]): T[] { + const copy = [...items]; + + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(this.next() * (i + 1)); + + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + + return copy; + } + + id(): string { + return createDemoId(() => Math.floor(this.next() * 16)); + } + + hex(length: number): string { + return Array.from({ length }, () => Math.floor(this.next() * 16).toString(16)).join(''); + } + + alphanumeric(length: number): string { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; + + return Array.from({ length }, () => alphabet[Math.floor(this.next() * alphabet.length)]).join(''); + } + + daysAgo(days: number, jitterHours = 0): Date { + return new Date(this.now.getTime() - days * DAY_MS - this.int(0, jitterHours) * 60 * 60 * 1000); + } + + daysFromNow(days: number): Date { + return new Date(this.now.getTime() + days * DAY_MS); + } + + /** Random moment between `fromDaysAgo` and `toDaysAgo` days in the past. */ + pastDate(fromDaysAgo: number, toDaysAgo = 0): Date { + const from = this.now.getTime() - fromDaysAgo * DAY_MS; + const to = this.now.getTime() - toDaysAgo * DAY_MS; + + return new Date(from + this.next() * (to - from)); + } + + addMinutes(date: Date, minutes: number): Date { + return new Date(date.getTime() + minutes * 60 * 1000); + } + + addDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * DAY_MS); + } +} + +export function toDateOnly(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function roundMoney(value: number, decimals = 2): number { + const factor = 10 ** decimals; + + return Math.round(value * factor) / factor; +} + +export function slugify(value: string): string { + return value + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} diff --git a/tools/demo-data/src/lib/core/row-collector.ts b/tools/demo-data/src/lib/core/row-collector.ts new file mode 100644 index 000000000..0a953005d --- /dev/null +++ b/tools/demo-data/src/lib/core/row-collector.ts @@ -0,0 +1,81 @@ +import { SqlRow, SqlScript } from './sql'; + +/** + * Collects rows per table and renders them in a fixed, FK-safe order. Every table that receives + * rows must be listed in `insertOrder`; this catches ordering mistakes before psql does. + */ +export class RowCollector { + private readonly rows = new Map(); + + constructor(private readonly insertOrder: readonly string[]) {} + + add(table: string, row: SqlRow): SqlRow { + this.bucket(table).push(row); + + return row; + } + + addMany(table: string, rows: SqlRow[]): void { + this.bucket(table).push(...rows); + } + + /** Rows collected so far for `table` (mutable, e.g. to back-fill references). */ + rowsFor(table: string): SqlRow[] { + return this.rows.get(table) ?? []; + } + + /** + * Orders rows by a date column (nulls last). Document numbers are allocated in insert order, + * so sorting keeps numbers chronological. + */ + sortByDate(table: string, column: string): void { + const time = (row: SqlRow): number => { + const value = row[column]; + + return value instanceof Date ? value.getTime() : Number.POSITIVE_INFINITY; + }; + + this.rowsFor(table).sort((a, b) => time(a) - time(b)); + } + + count(table: string): number { + return this.rows.get(table)?.length ?? 0; + } + + get totalRows(): number { + let total = 0; + + for (const rows of this.rows.values()) { + total += rows.length; + } + + return total; + } + + /** Inserts every collected table into `script`; `conflictFor` may add `ON CONFLICT` clauses. */ + writeTo(script: SqlScript, conflictFor: (table: string) => string | undefined = () => undefined): void { + for (const table of this.insertOrder) { + const rows = this.rows.get(table); + + if (rows?.length) { + script.comment(`${table}: ${rows.length} rows`); + script.insert(table, rows, { onConflict: conflictFor(table) }); + } + } + } + + private bucket(table: string): SqlRow[] { + if (!this.insertOrder.includes(table)) { + throw new Error(`Table ${table} is missing from the insert order`); + } + + let bucket = this.rows.get(table); + + if (!bucket) { + bucket = []; + this.rows.set(table, bucket); + } + + return bucket; + } +} diff --git a/tools/demo-data/src/lib/core/seeder.ts b/tools/demo-data/src/lib/core/seeder.ts new file mode 100644 index 000000000..607ac6d03 --- /dev/null +++ b/tools/demo-data/src/lib/core/seeder.ts @@ -0,0 +1,84 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { DEMO_ID_LIKE_PATTERN } from './demo-ids'; +import { PostgresContainer } from './postgres-container'; +import { quoteIdent, SqlScript } from './sql'; + +export type DemoCommand = 'seed' | 'reset'; + +export interface SeederContext { + workspaceRoot: string; + /** Seed for the pseudo random generator. */ + seed: number; + /** Build SQL but do not touch any database. */ + dryRun: boolean; + /** When set, every generated SQL script is written to this directory. */ + sqlOutDir?: string; + log: (message: string) => void; +} + +export interface DemoDataSeeder { + readonly product: string; + seed(context: SeederContext): Promise; + reset(context: SeederContext): Promise; +} + +/** + * Credentials printed after seeding. Keep in sync with the README; they are intentionally + * public demo values and must never be used outside local demo environments. + */ +export const DEMO_PASSWORD = 'Demo-Passw0rd!'; + +/** Base32 TOTP secret used for every TOTP-enrolled demo account. */ +export const DEMO_TOTP_SECRET = 'KRUGKIDROVUWG2ZAMJZG653OEBTG66BAJJ2W24DT'; + +/** Code whose bcrypt hash is stored as pending email confirmation / reset token. */ +export const DEMO_EMAIL_CODE = 'DEMO42'; + +export interface DemoDeleteTarget { + table: string; + /** Column holding the demo id; defaults to `id`. */ + column?: string; + /** Custom condition instead of ` LIKE demo pattern`. */ + where?: string; +} + +/** SQL condition matching demo ids in `column`. */ +export function demoIdCondition(column: string): string { + return `${quoteIdent(column)}::text LIKE '${DEMO_ID_LIKE_PATTERN}'`; +} + +/** DELETE statements for demo rows, in the given (child-first) order. */ +export function buildDemoDeletes(targets: readonly DemoDeleteTarget[]): string[] { + return targets.map( + ({ table, column = 'id', where }) => `DELETE FROM ${quoteIdent(table)} WHERE ${where ?? demoIdCondition(column)};`, + ); +} + +/** Writes the script (optional) and runs it unless this is a dry run. */ +export function applyScript( + context: SeederContext, + database: PostgresContainer, + script: SqlScript, + fileName: string, +): void { + const sql = script.toString(); + + if (context.sqlOutDir) { + const outDir = path.resolve(context.workspaceRoot, context.sqlOutDir); + + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, fileName), sql, 'utf8'); + context.log(`Wrote ${path.join(outDir, fileName)}`); + } + + if (context.dryRun) { + context.log(`Dry run: skipped executing ${fileName} on ${database.label}`); + + return; + } + + database.execute(sql); + context.log(`Applied ${fileName} on ${database.label}`); +} diff --git a/tools/demo-data/src/lib/core/sql.spec.ts b/tools/demo-data/src/lib/core/sql.spec.ts new file mode 100644 index 000000000..8235f0eca --- /dev/null +++ b/tools/demo-data/src/lib/core/sql.spec.ts @@ -0,0 +1,59 @@ +import { buildInsert, json, raw, SqlScript, toSqlLiteral } from './sql'; + +describe('toSqlLiteral', () => { + it('should escape single quotes in strings', () => { + expect(toSqlLiteral("O'Brien")).toBe("'O''Brien'"); + }); + + it('should encode null, booleans and numbers', () => { + expect(toSqlLiteral(null)).toBe('NULL'); + expect(toSqlLiteral(true)).toBe('TRUE'); + expect(toSqlLiteral(12.5)).toBe('12.5'); + }); + + it('should encode dates as ISO strings', () => { + expect(toSqlLiteral(new Date('2026-01-02T03:04:05.000Z'))).toBe("'2026-01-02T03:04:05.000Z'"); + }); + + it('should encode json values as jsonb', () => { + expect(toSqlLiteral(json({ name: "it's" }))).toBe(`'{"name":"it''s"}'::jsonb`); + }); + + it('should inline raw expressions', () => { + expect(toSqlLiteral(raw('now()'))).toBe('now()'); + }); + + it('should reject non-finite numbers', () => { + expect(() => toSqlLiteral(Number.NaN)).toThrow('non-finite'); + }); +}); + +describe('buildInsert', () => { + it('should use the union of columns and DEFAULT for missing values', () => { + const [statement] = buildInsert('users', [{ id: 'a', email: 'x@example.com' }, { id: 'b' }]); + + expect(statement).toBe(`INSERT INTO "users" ("id", "email") VALUES\n ('a', 'x@example.com'),\n ('b', DEFAULT);`); + }); + + it('should split rows into chunks and append conflict clauses', () => { + const statements = buildInsert('t', [{ id: 1 }, { id: 2 }, { id: 3 }], { + chunkSize: 2, + onConflict: 'ON CONFLICT DO NOTHING', + }); + + expect(statements).toHaveLength(2); + expect(statements[1]).toBe('INSERT INTO "t" ("id") VALUES\n (3)\nON CONFLICT DO NOTHING;'); + }); + + it('should return no statements for empty input', () => { + expect(buildInsert('t', [])).toEqual([]); + }); +}); + +describe('SqlScript', () => { + it('should terminate statements and join them', () => { + const script = new SqlScript().add('SELECT 1').comment('note').add('SELECT 2;'); + + expect(script.toString()).toBe('SELECT 1;\n\n-- note\nSELECT 2;\n'); + }); +}); diff --git a/tools/demo-data/src/lib/core/sql.ts b/tools/demo-data/src/lib/core/sql.ts new file mode 100644 index 000000000..5a2674f22 --- /dev/null +++ b/tools/demo-data/src/lib/core/sql.ts @@ -0,0 +1,127 @@ +export class SqlRaw { + constructor(readonly sql: string) {} +} + +export class SqlJson { + constructor(readonly value: unknown) {} +} + +export type SqlValue = string | number | boolean | null | undefined | Date | SqlRaw | SqlJson; + +export type SqlRow = Record; + +/** Inlines a SQL expression (for example a function call) instead of a quoted literal. */ +export function raw(sql: string): SqlRaw { + return new SqlRaw(sql); +} + +/** Serializes the value as a `jsonb` literal. */ +export function json(value: unknown): SqlJson { + return new SqlJson(value); +} + +export function quoteIdent(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"`; +} + +export function quoteLiteral(value: string): string { + // standard_conforming_strings is on by default, so only single quotes need escaping. + return `'${value.replace(/'/g, "''")}'`; +} + +export function toSqlLiteral(value: SqlValue): string { + if (value === null || value === undefined) { + return 'NULL'; + } + + if (value instanceof SqlRaw) { + return value.sql; + } + + if (value instanceof SqlJson) { + return `${quoteLiteral(JSON.stringify(value.value))}::jsonb`; + } + + if (value instanceof Date) { + return quoteLiteral(value.toISOString()); + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Cannot encode non-finite number ${value} as SQL`); + } + + return String(value); + } + + if (typeof value === 'boolean') { + return value ? 'TRUE' : 'FALSE'; + } + + return quoteLiteral(value); +} + +export interface InsertOptions { + /** Appended verbatim, e.g. `ON CONFLICT DO NOTHING`. */ + onConflict?: string; + chunkSize?: number; +} + +/** + * Builds multi-row INSERT statements. Columns are the union of all row keys; a row that omits + * a column (or sets it to `undefined`) gets the column DEFAULT. + */ +export function buildInsert(table: string, rows: SqlRow[], options: InsertOptions = {}): string[] { + if (rows.length === 0) { + return []; + } + + const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row)))); + const chunkSize = options.chunkSize ?? 250; + const statements: string[] = []; + + for (let offset = 0; offset < rows.length; offset += chunkSize) { + const chunk = rows.slice(offset, offset + chunkSize); + const encodeCell = (row: SqlRow, column: string): string => + row[column] === undefined ? 'DEFAULT' : toSqlLiteral(row[column]); + const values = chunk.map((row) => `(${columns.map((column) => encodeCell(row, column)).join(', ')})`).join(',\n '); + const conflict = options.onConflict ? `\n${options.onConflict}` : ''; + + statements.push( + `INSERT INTO ${quoteIdent(table)} (${columns.map(quoteIdent).join(', ')}) VALUES\n ${values}${conflict};`, + ); + } + + return statements; +} + +/** Accumulates statements for a single psql run. */ +export class SqlScript { + private readonly statements: string[] = []; + + comment(text: string): this { + this.statements.push(`\n-- ${text}`); + + return this; + } + + add(statement: string): this { + this.statements.push(statement.trim().endsWith(';') ? statement : `${statement};`); + + return this; + } + + insert(table: string, rows: SqlRow[], options?: InsertOptions): this { + this.statements.push(...buildInsert(table, rows, options)); + + return this; + } + + get isEmpty(): boolean { + return this.statements.length === 0; + } + + toString(): string { + return `${this.statements.join('\n')}\n`; + } +} diff --git a/tools/demo-data/src/lib/decabill/decabill-backoffice.ts b/tools/demo-data/src/lib/decabill/decabill-backoffice.ts new file mode 100644 index 000000000..2d8f2d64b --- /dev/null +++ b/tools/demo-data/src/lib/decabill/decabill-backoffice.ts @@ -0,0 +1,318 @@ +import { CITIES, STREETS } from '../core/fixtures'; +import { createCompanyName, createVatId } from '../core/people'; +import { toDateOnly } from '../core/random'; +import { json } from '../core/sql'; + +import { datevAccountNumber, DecabillTenantContext, scopedNumber, yearlyNumber } from './decabill-context'; +import { computeLines, isEuCountry, resolveTax } from './decabill-tax'; + +const SUPPLIER_SERVICES = [ + { description: 'Cloud infrastructure usage', price: 1240 }, + { description: 'Software licenses (annual)', price: 3600 }, + { description: 'Office rent', price: 2100 }, + { description: 'Freelance design work', price: 1450 }, + { description: 'Telephone and internet', price: 89.9 }, + { description: 'Technical literature', price: 120, reduced: true }, +] as const; +const WEBHOOK_EVENTS = [ + 'invoice.created', + 'invoice.paid', + 'subscription.created', + 'subscription.provisioned', + 'subscription.canceled', +]; +const EMAIL_TEMPLATES = [ + { event: 'invoice.created', template: 'invoice-issued' }, + { event: 'subscription.created', template: 'subscription-created' }, + { event: 'subscription.renewal_reminder', template: 'subscription-renewal-reminder' }, + { event: 'identity.email_confirmation', template: 'email-confirmation' }, +]; + +export function buildSuppliers(context: DecabillTenantContext): void { + const { random, rows, tenantId, numberScope, encryptor, issuerCountry } = context; + const statusDeck = random.deck(['draft', 'issued', 'paid', 'paid', 'partially_paid', 'overdue', 'void'] as const); + + for (let s = 0; s < 4; s++) { + const supplierId = random.id(); + const location = random.pick(CITIES.filter((city) => city.country !== 'US')); + const vatId = createVatId(random, location); + const company = createCompanyName(random); + const createdAt = random.daysAgo(random.int(200, 700)); + + rows.add('billing_supplier_profiles', { + id: supplierId, + tenant_id: tenantId, + supplier_number: scopedNumber('SUP', 'billing_supplier_number_sequences', numberScope, 6), + number_scope: numberScope, + first_name: null, + last_name: null, + company, + customer_type: 'business', + vat_id: vatId, + vat_id_validation_status: vatId ? 'valid' : 'none', + vat_id_validated_at: vatId ? random.daysAgo(random.int(5, 60)) : null, + vat_id_validation_source: vatId ? 'vies_sync' : null, + address_line_1: `${random.pick(STREETS)} ${random.int(1, 99)}`, + postal_code: location.postalCode, + city: location.city, + state: location.state ?? null, + country: location.country, + email: `billing@${company.split(' ')[0].toLowerCase()}.example`, + phone: `${location.phonePrefix} ${random.int(1000000, 9999999)}`, + custom_data: encryptor.encryptJson({}), + created_at: createdAt, + updated_at: createdAt, + }); + + const contractId = random.id(); + + rows.add('billing_supplier_contracts', { + id: contractId, + supplier_id: supplierId, + contract_number: `C-${random.int(10000, 99999)}`, + created_at: createdAt, + }); + + rows.add('billing_datev_creditor_accounts', { + id: random.id(), + tenant_id: tenantId, + supplier_id: supplierId, + allocation_scope: numberScope, + creditor_number: datevAccountNumber('creditor', numberScope, context.datevCounters.creditor++), + created_at: createdAt, + }); + + const tax = resolveTax({ country: location.country, customerType: 'business', vatId }, issuerCountry); + const count = random.int(2, 4); + + for (let i = 0; i < count; i++) { + const service = random.pick(SUPPLIER_SERVICES); + const status = statusDeck(); + const issueDate = status === 'issued' ? random.daysAgo(random.int(0, 7)) : random.daysAgo(random.int(15, 200)); + const totals = computeLines( + [ + { + description: service.description, + quantity: 1, + unitPriceNet: service.price, + taxCategory: 'reduced' in service ? 'reduced' : 'standard', + }, + ], + tax, + ); + const invoiceId = random.id(); + const hasDocument = status !== 'draft' && random.chance(0.3); + + rows.add('billing_supplier_invoices', { + id: invoiceId, + supplier_id: supplierId, + contract_id: random.chance(0.6) ? contractId : null, + invoice_number: + status === 'draft' + ? null + : yearlyNumber( + 'SINV', + 'billing_supplier_invoice_number_sequences', + numberScope, + issueDate.getUTCFullYear(), + ), + status, + currency: 'EUR', + subtotal_net: totals.subtotalNet, + tax_total: totals.taxTotal, + total_gross: totals.totalGross, + balance_due: + status === 'paid' || status === 'void' + ? 0 + : status === 'partially_paid' + ? Math.round(totals.totalGross * 50) / 100 + : totals.totalGross, + tax_mode: tax.taxMode, + tax_country_code: tax.taxCountry, + tax_note: tax.note, + einvoice_tax_category_code: tax.einvoiceCode, + resolved_tax_rate: totals.resolvedRate, + supplier_vat_id: vatId, + supplier_country: location.country, + supplier_customer_type: 'business', + recipient_country: issuerCountry, + recipient_is_in_eu: isEuCountry(issuerCountry), + issue_date: toDateOnly(issueDate), + due_date: toDateOnly(random.addDays(issueDate, 30)), + issued_at: status === 'draft' ? null : issueDate, + voided_at: status === 'void' ? random.addDays(issueDate, 2) : null, + paid_at: status === 'paid' ? random.addDays(issueDate, random.int(3, 25)) : null, + // Uploaded documents are referenced but not stored; the download shows as unavailable. + document_storage_key: null, + document_source: hasDocument ? 'uploaded' : null, + has_uploaded_document: false, + created_at: issueDate, + }); + + totals.lines.forEach((line, index) => { + rows.add('billing_supplier_invoice_line_items', { + id: random.id(), + invoice_id: invoiceId, + position: index, + description: line.description, + quantity: line.quantity, + unit_price_net: line.unitPriceNet, + tax_category: line.taxCategory, + tax_rate: line.taxRate, + line_net: line.lineNet, + line_tax: line.lineTax, + line_gross: line.lineGross, + }); + }); + } + } +} + +export function buildDatevAndOss(context: DecabillTenantContext): void { + const { random, rows, tenantId } = context; + const now = random.now; + const months = [3, 4, 5].map((offset) => { + const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - offset, 1)); + + return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }; + }); + + months.forEach(({ year, month }, index) => { + const failed = index === 1; + const startedAt = new Date(Date.UTC(year, month, 1, 0, 5)); + + rows.add('billing_datev_exports', { + id: random.id(), + scope: 'tenant', + tenant_id: tenantId, + period_year: year, + period_month: month, + status: failed ? 'failed' : 'completed', + storage_key: null, + file_name: failed ? null : `EXTF_Buchungsstapel_${year}${String(month).padStart(2, '0')}.csv`, + booking_count: failed ? 0 : random.int(20, 140), + invoice_count: failed ? 0 : random.int(10, 80), + debtor_count: failed ? 0 : random.int(5, 30), + included_tenant_ids: null, + error_message: failed ? 'BILLING_DATEV_CONSULTANT_NUMBER is not configured' : null, + triggered_by: index === 2 ? 'admin' : 'scheduler', + started_at: startedAt, + completed_at: new Date(startedAt.getTime() + random.int(5, 90) * 1000), + created_at: startedAt, + updated_at: startedAt, + }); + }); + + rows.add('billing_oss_threshold_ledgers', { + id: random.id(), + tenant_id: tenantId, + calendar_year: now.getUTCFullYear() - 1, + cross_border_b2c_net_total: random.decimal(2000, 14000), + threshold_eur: 10000, + threshold_exceeded_at: random.chance(0.5) ? random.daysAgo(random.int(300, 360)) : null, + created_at: random.daysAgo(400), + updated_at: random.daysAgo(300), + }); +} + +export function buildNotifications(context: DecabillTenantContext, recipientEmails: string[]): void { + const { random, rows, tenantId, encryptor } = context; + const endpoints = [ + { name: 'ERP sync', url: 'https://erp.example.com/hooks', auth: 'authorization', enabled: true, failures: 0 }, + { name: 'Finance chat', url: 'https://chat.example.com/hooks/finance', auth: 'none', enabled: true, failures: 2 }, + { name: 'Legacy CRM', url: 'https://crm.example.org/webhook', auth: 'custom_header', enabled: false, failures: 25 }, + ]; + + for (const endpoint of endpoints) { + const endpointId = random.id(); + const createdAt = random.daysAgo(random.int(60, 300)); + + rows.add('webhook_endpoints', { + id: endpointId, + scope_key: tenantId, + client_id: null, + name: endpoint.name, + url: endpoint.url, + http_method: 'POST', + subscribed_events: json(random.pickMany(WEBHOOK_EVENTS, random.int(2, WEBHOOK_EVENTS.length))), + enabled: endpoint.enabled, + auth_type: endpoint.auth, + auth_header_name: endpoint.auth === 'custom_header' ? 'X-Api-Key' : null, + auth_value: endpoint.auth === 'none' ? null : encryptor.encrypt(`demo-${random.alphanumeric(24)}`), + signing_secret: encryptor.encrypt(`whsec_${random.alphanumeric(32)}`), + consecutive_failures: endpoint.failures, + disabled_reason: endpoint.enabled ? null : 'Disabled after 25 consecutive failures', + delivery_log_retention_days: 30, + delivery_log_max_entries: 500, + created_at: createdAt, + updated_at: createdAt, + }); + + const count = random.int(4, 10); + + for (let i = 0; i < count; i++) { + const success = endpoint.enabled && random.chance(endpoint.failures > 0 ? 0.5 : 0.95); + + rows.add('webhook_deliveries', { + id: random.id(), + endpoint_id: endpointId, + event_id: random.id(), + event_type: random.pick(WEBHOOK_EVENTS), + payload: json({ demo: true, tenantId }), + http_status: success ? 200 : random.pick([401, 500, 502, 504]), + response_body: success ? '{"ok":true}' : 'Upstream error', + success, + attempt: success ? 1 : random.int(1, 5), + error_message: success ? null : 'Request failed with non-2xx status', + created_at: random.pastDate(30, 0), + }); + } + } + + for (let i = 0; i < 25; i++) { + const template = random.pick(EMAIL_TEMPLATES); + const success = random.chance(0.9); + + rows.add('email_deliveries', { + id: random.id(), + event_id: random.id(), + event_type: template.event, + scope_key: tenantId, + template_key: template.template, + recipient: encryptor.encrypt(random.pick(recipientEmails)), + template_context: encryptor.encryptJson({ demo: true }), + success, + attempt: success ? 1 : random.int(2, 4), + error_message: success ? null : encryptor.encrypt('SMTP connection timed out'), + created_at: random.pastDate(60, 0), + }); + } +} + +export function buildGlobalSnapshots(context: DecabillTenantContext): void { + const { random, rows } = context; + + for (const serverType of ['demo-standard', 'demo-large']) { + rows.add('billing_provider_price_snapshots', { + id: random.id(), + provider: 'demo', + provider_product_id: serverType, + raw_price_payload: json({ monthly: serverType === 'demo-standard' ? 4.51 : 15.59 }), + resolved_base_price: serverType === 'demo-standard' ? 4.51 : 15.59, + currency: 'EUR', + created_at: random.daysAgo(1), + }); + + for (const region of ['fsn1', 'nbg1', 'hel1']) { + rows.add('billing_availability_snapshots', { + id: random.id(), + provider: 'demo', + region, + server_type: serverType, + is_available: !(region === 'hel1' && serverType === 'demo-large'), + raw_response: json({ source: 'demo-data' }), + captured_at: random.daysAgo(0, 6), + }); + } + } +} diff --git a/tools/demo-data/src/lib/decabill/decabill-catalog.ts b/tools/demo-data/src/lib/decabill/decabill-catalog.ts new file mode 100644 index 000000000..bd2b0f559 --- /dev/null +++ b/tools/demo-data/src/lib/decabill/decabill-catalog.ts @@ -0,0 +1,464 @@ +import { json } from '../core/sql'; + +import { DEMO_PROVIDER, DecabillTenantContext, DemoCatalog, DemoPlan } from './decabill-context'; + +interface PlanSpec { + name: string; + description: string; + price: number; + intervalType: DemoPlan['intervalType']; + intervalValue: number; + serviceTypeKey: string | null; + taxCategory?: DemoPlan['taxCategory']; + billInAdvance?: boolean; + isActive?: boolean; + meters?: string[]; + highlights?: { icon: string; text: string }[]; + minCommitmentDays?: number; + noticeDays?: number; +} + +const SERVICE_TYPES = [ + { + key: 'demo-cloud-server', + name: 'Managed Cloud Server', + description: 'Virtual server with managed updates, backups and monitoring.', + isActive: true, + }, + { + key: 'demo-app-hosting', + name: 'Application Hosting', + description: 'Container based hosting for web applications with automatic TLS.', + isActive: true, + }, + { + key: 'demo-legacy-vps', + name: 'Legacy VPS', + description: 'Discontinued VPS product line kept for existing contracts.', + isActive: false, + }, +] as const; +const METERS = [ + { key: 'demo-traffic', name: 'Outbound traffic', unit: 'GB', aggregator: 'sum', price: 0.01, included: 1000 }, + { key: 'demo-cpu-hours', name: 'CPU hours', unit: 'h', aggregator: 'sum_positive_deltas', price: 0.004, included: 0 }, + { key: 'demo-storage', name: 'Block storage', unit: 'GB', aggregator: 'max', price: 0.05, included: 40 }, + { key: 'demo-api-calls', name: 'API calls', unit: '1k calls', aggregator: 'sum', price: 0.2, included: 50 }, +] as const; +const PLANS: PlanSpec[] = [ + { + name: 'Cloud Server S', + description: '2 vCPU, 4 GB RAM, 40 GB NVMe', + price: 9.9, + intervalType: 'month', + intervalValue: 1, + serviceTypeKey: 'demo-cloud-server', + meters: ['demo-traffic', 'demo-storage'], + highlights: [ + { icon: 'cpu', text: '2 vCPU' }, + { icon: 'memory', text: '4 GB RAM' }, + ], + }, + { + name: 'Cloud Server M', + description: '4 vCPU, 8 GB RAM, 80 GB NVMe', + price: 19.9, + intervalType: 'month', + intervalValue: 1, + serviceTypeKey: 'demo-cloud-server', + meters: ['demo-traffic', 'demo-storage', 'demo-cpu-hours'], + highlights: [ + { icon: 'cpu', text: '4 vCPU' }, + { icon: 'shield-check', text: 'Daily backups' }, + ], + }, + { + name: 'Cloud Server L (yearly)', + description: '8 vCPU, 16 GB RAM, 160 GB NVMe, billed yearly', + price: 399, + intervalType: 'year', + intervalValue: 1, + serviceTypeKey: 'demo-cloud-server', + billInAdvance: true, + minCommitmentDays: 365, + noticeDays: 30, + }, + { + name: 'App Hosting Starter', + description: 'One container, 1 GB RAM, custom domain', + price: 14.5, + intervalType: 'month', + intervalValue: 1, + serviceTypeKey: 'demo-app-hosting', + meters: ['demo-api-calls'], + }, + { + name: 'Build Runner (hourly)', + description: 'On-demand CI runner billed per hour', + price: 0.06, + intervalType: 'hour', + intervalValue: 1, + serviceTypeKey: 'demo-app-hosting', + }, + { + name: 'Sandbox Day Pass', + description: 'Temporary test environment billed per day', + price: 1.2, + intervalType: 'day', + intervalValue: 1, + serviceTypeKey: 'demo-app-hosting', + }, + { + name: 'Support Retainer', + description: 'Eight hours of priority support per month', + price: 490, + intervalType: 'month', + intervalValue: 1, + serviceTypeKey: null, + billInAdvance: true, + }, + { + name: 'Quarterly Maintenance', + description: 'Security patching and quarterly health check', + price: 750, + intervalType: 'month', + intervalValue: 3, + serviceTypeKey: null, + }, + { + name: 'Knowledge Base Subscription', + description: 'Digital publications (reduced VAT rate)', + price: 29, + intervalType: 'year', + intervalValue: 1, + serviceTypeKey: null, + taxCategory: 'reduced', + }, + { + name: 'VPS Classic (discontinued)', + description: 'Legacy plan, no longer orderable', + price: 6.5, + intervalType: 'month', + intervalValue: 1, + serviceTypeKey: 'demo-legacy-vps', + isActive: false, + }, +]; + +export function buildCatalog(context: DecabillTenantContext): DemoCatalog { + const { random, rows, encryptor, tenantId } = context; + const createdAt = random.daysAgo(random.int(400, 500)); + const serviceTypeIds = new Map(); + const meterIds = new Map(); + + for (const type of SERVICE_TYPES) { + const id = random.id(); + + serviceTypeIds.set(type.key, id); + rows.add('billing_service_types', { + id, + key: type.key, + tenant_id: tenantId, + name: type.name, + description: type.description, + provider: DEMO_PROVIDER, + allowed_providers: json([DEMO_PROVIDER]), + config_schema: json({}), + is_active: type.isActive, + disallow_statutory_withdrawal: type.key === 'demo-app-hosting', + provider_defaults: encryptor.encryptJson({}), + created_at: createdAt, + updated_at: createdAt, + }); + } + + for (const meter of METERS) { + const id = random.id(); + + meterIds.set(meter.key, id); + rows.add('billing_meters', { + id, + tenant_id: tenantId, + key: meter.key, + name: meter.name, + description: `${meter.name} measured in ${meter.unit}`, + unit_label: meter.unit, + aggregator: meter.aggregator, + default_unit_price_net: meter.price, + default_included_usage: meter.included, + is_active: meter.key !== 'demo-api-calls' || random.chance(0.5), + created_at: createdAt, + updated_at: createdAt, + }); + } + + rows.add('billing_service_type_meters', { + id: random.id(), + service_type_id: serviceTypeIds.get('demo-cloud-server'), + meter_id: meterIds.get('demo-traffic'), + unit_price_net: null, + included_usage: null, + source: 'provider', + required: true, + created_at: createdAt, + updated_at: createdAt, + }); + + const cloudInitId = random.id(); + + rows.add('billing_cloud_init_configs', { + id: cloudInitId, + tenant_id: tenantId, + key: 'demo-nginx', + name: 'Static website (nginx)', + provisioning_mode: 'simple', + description: 'Serves a static website from an nginx container.', + docker_image: 'nginx:1.27-alpine', + container_port: 80, + host_port: 80, + work_dir: '/opt/custom-app', + environment_variables: json([]), + service_tabs: json([]), + env_default_values: encryptor.encryptJson({}), + is_active: true, + created_at: createdAt, + updated_at: createdAt, + }); + rows.add('billing_cloud_init_configs', { + id: random.id(), + tenant_id: tenantId, + key: 'demo-compose-stack', + name: 'Compose stack (app + database)', + provisioning_mode: 'compose-template', + description: 'Application container with a PostgreSQL sidecar.', + docker_image: null, + docker_compose_template: [ + 'services:', + ' app:', + ' image: ghcr.io/example/app:latest', + ' ports: ["80:8080"]', + ' db:', + ' image: postgres:16-alpine', + ].join('\n'), + environment_variables: json([]), + service_tabs: json([]), + env_default_values: encryptor.encryptJson({}), + is_active: false, + created_at: createdAt, + updated_at: createdAt, + }); + + const addons = [ + { key: 'demo-backup', name: 'Daily backups', price: 2.9 }, + { key: 'demo-monitoring', name: 'Uptime monitoring', price: 4.9 }, + { key: 'demo-ipv4', name: 'Additional IPv4 address', price: 0.9 }, + ].map((addon) => { + const id = random.id(); + + rows.add('billing_addons', { + id, + tenant_id: tenantId, + key: addon.key, + name: addon.name, + description: `${addon.name} for managed servers.`, + implementation_type: 'cloud_init_script', + script_template: `#!/bin/sh\necho "${addon.key} enabled"`, + deprovision_script_template: `#!/bin/sh\necho "${addon.key} disabled"`, + config_schema: json({}), + config_default_values: encryptor.encryptJson({}), + compatible_providers: json([DEMO_PROVIDER]), + base_price: addon.price, + price_interval_type: 'month', + price_interval_value: 1, + is_active: true, + created_at: createdAt, + updated_at: createdAt, + }); + + return { id, name: addon.name, price: addon.price }; + }); + + rows.add('billing_addon_meters', { + id: random.id(), + addon_id: addons[1].id, + meter_id: meterIds.get('demo-api-calls'), + unit_price_net: 0.15, + included_usage: 10, + source: 'manual', + required: false, + created_at: createdAt, + updated_at: createdAt, + }); + + const plans = PLANS.map((spec): DemoPlan => { + const id = random.id(); + const serviceTypeId = spec.serviceTypeKey ? (serviceTypeIds.get(spec.serviceTypeKey) ?? null) : null; + const planMeterIds = (spec.meters ?? []).map((key) => meterIds.get(key) as string); + + rows.add('billing_service_plans', { + id, + service_type_id: serviceTypeId, + tenant_id: tenantId, + name: spec.name, + description: spec.description, + billing_interval_type: spec.intervalType, + billing_interval_value: spec.intervalValue, + billing_day_of_month: null, + cancel_at_period_end: true, + bill_in_advance: spec.billInAdvance ?? false, + auto_recalculate_price_daily: false, + min_commitment_days: spec.minCommitmentDays ?? 0, + notice_days: spec.noticeDays ?? 0, + base_price: spec.price, + margin_percent: serviceTypeId ? 15 : null, + margin_fixed: null, + provider_config_defaults: json( + serviceTypeId + ? { serverType: 'demo-standard', location: 'fsn1', allowedAddonIds: addons.map((addon) => addon.id) } + : {}, + ), + ordering_highlights: json(spec.highlights ?? []), + is_active: spec.isActive ?? true, + allow_customer_location_selection: Boolean(serviceTypeId), + allow_customer_server_type_selection: false, + allowed_server_types: json([]), + allow_customer_provider_selection: false, + allowed_providers: json(serviceTypeId ? [DEMO_PROVIDER] : []), + tax_category: spec.taxCategory ?? 'standard', + created_at: createdAt, + updated_at: createdAt, + }); + + for (const meterId of planMeterIds) { + rows.add('billing_service_plan_meters', { + id: random.id(), + service_plan_id: id, + meter_id: meterId, + unit_price_net: null, + included_usage: null, + source: 'manual', + required: false, + created_at: createdAt, + updated_at: createdAt, + }); + } + + return { + id, + name: spec.name, + price: spec.price, + intervalType: spec.intervalType, + intervalValue: spec.intervalValue, + serviceTypeId, + taxCategory: spec.taxCategory ?? 'standard', + billInAdvance: spec.billInAdvance ?? false, + isActive: spec.isActive ?? true, + meterIds: planMeterIds, + }; + }); + const promotions = buildPromotions(context, plans); + + return { plans, addons, promotions }; +} + +function buildPromotions(context: DecabillTenantContext, plans: DemoPlan[]): DemoCatalog['promotions'] { + const { random, rows, tenantId } = context; + const specs: Array<{ + code: string; + name: string; + type: string; + config: Record; + from: number; + to: number; + active: boolean; + eligibility: string; + maxTotal?: number; + }> = [ + { + code: 'WELCOME10', + name: 'Welcome discount', + type: 'fixed_amount_net', + config: { amountNet: 10 }, + from: -120, + to: 120, + active: true, + eligibility: 'new', + }, + { + code: 'TRYMONTH', + name: 'First month free', + type: 'free_days', + config: { days: 30 }, + from: -60, + to: 60, + active: true, + eligibility: 'both', + }, + { + code: 'LOYAL2', + name: 'Two periods on us', + type: 'free_billing_periods', + config: { periods: 2 }, + from: -200, + to: 30, + active: true, + eligibility: 'existing', + maxTotal: 3, + }, + { + code: 'SUMMER25', + name: 'Summer sale (ended)', + type: 'fixed_amount_net', + config: { amountNet: 25 }, + from: -400, + to: -300, + active: true, + eligibility: 'both', + }, + { + code: 'BLACKFRIDAY', + name: 'Black Friday (upcoming)', + type: 'free_days', + config: { days: 14 }, + from: 40, + to: 50, + active: true, + eligibility: 'new', + }, + { + code: 'PARTNER50', + name: 'Partner discount (paused)', + type: 'fixed_amount_net', + config: { amountNet: 50 }, + from: -30, + to: 300, + active: false, + eligibility: 'both', + }, + ]; + + return specs.map((spec) => { + const id = random.id(); + const createdAt = random.daysAgo(Math.max(1, -spec.from) + 5); + + rows.add('billing_promotions', { + id, + tenant_id: tenantId, + code: spec.code, + name: spec.name, + description: `${spec.name} — demo promotion`, + redeemable_from: random.daysFromNow(spec.from), + redeemable_to: random.daysFromNow(spec.to), + max_total_redemptions: spec.maxTotal ?? null, + max_per_user_redemptions: 1, + is_active: spec.active, + advantage_type: spec.type, + advantage_config: json(spec.config), + applicable_plan_ids: spec.code === 'LOYAL2' ? json(plans.slice(0, 2).map((plan) => plan.id)) : null, + subscription_eligibility: spec.eligibility, + created_at: createdAt, + updated_at: createdAt, + }); + + return { id, code: spec.code, advantageType: spec.type, config: spec.config }; + }); +} diff --git a/tools/demo-data/src/lib/decabill/decabill-commerce.ts b/tools/demo-data/src/lib/decabill/decabill-commerce.ts new file mode 100644 index 000000000..7f9efa9f5 --- /dev/null +++ b/tools/demo-data/src/lib/decabill/decabill-commerce.ts @@ -0,0 +1,1070 @@ +import { createHash } from 'crypto'; + +import { LOREM_SENTENCES, SOFTWARE_TOPICS, TICKET_VERBS } from '../core/fixtures'; +import { roundMoney, slugify, toDateOnly } from '../core/random'; +import { json, SqlRow } from '../core/sql'; + +import { + DecabillTenantContext, + DEMO_PROVIDER, + DemoCatalog, + DemoCustomer, + DemoPlan, + pickDemoLocation, + scopedNumber, + yearlyNumber, +} from './decabill-context'; +import { computeLines, isEuCountry, LineInput, resolveTax } from './decabill-tax'; + +type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'partially_paid' | 'overdue' | 'void'; + +type SubscriptionStatus = + | 'active' + | 'pending_backorder' + | 'pending_cancel' + | 'pending_withdrawal' + | 'pending_instant_cancel' + | 'pending_config_change' + | 'canceled'; + +const OFFER_STATUSES = ['draft', 'archived', 'accepted', 'declined', 'expired', 'revoked'] as const; + +type OfferStatus = (typeof OFFER_STATUSES)[number]; + +const DAY_MS = 24 * 60 * 60 * 1000; +const CANCEL_REQUESTED_STATUSES: readonly SubscriptionStatus[] = [ + 'pending_cancel', + 'pending_withdrawal', + 'pending_instant_cancel', + 'canceled', +]; + +interface InvoiceSpec { + customer: DemoCustomer; + status: InvoiceStatus; + issuedAt: Date; + lines: LineInput[]; + subscriptionId?: string; + projectId?: string; + offerId?: string; +} + +interface CreatedInvoice { + id: string; + status: InvoiceStatus; + subtotalNet: number; + totalGross: number; + issuedAt: Date; +} + +export class DecabillCommerceBuilder { + private readonly invoiceStatusDeck: () => InvoiceStatus; + private readonly subscriptionStatusDeck: () => SubscriptionStatus; + + constructor( + private readonly context: DecabillTenantContext, + private readonly catalog: DemoCatalog, + private readonly admins: { userId: string; email: string }[], + ) { + const { random } = context; + + this.invoiceStatusDeck = random.deck([ + 'issued', + 'paid', + 'paid', + 'partially_paid', + 'overdue', + 'void', + ]); + this.subscriptionStatusDeck = random.deck([ + 'active', + 'active', + 'active', + 'active', + 'pending_cancel', + 'pending_withdrawal', + 'pending_instant_cancel', + 'pending_config_change', + 'pending_backorder', + 'canceled', + ]); + } + + build(customers: DemoCustomer[]): void { + const { random } = this.context; + + for (const customer of customers) { + const subscriptionCount = random.int(1, 3); + + for (let i = 0; i < subscriptionCount; i++) { + this.addSubscription(customer, this.subscriptionStatusDeck()); + } + + if (customer.customerType === 'business' && random.chance(0.7)) { + this.addProject(customer); + } + + if (random.chance(0.6)) { + this.addOffer(customer); + } + + if (random.chance(0.4)) { + this.addInvoice({ + customer, + status: this.invoiceStatusDeck(), + issuedAt: random.pastDate(90, 1), + lines: [ + { description: 'Onboarding workshop (half day)', quantity: 1, unitPriceNet: 640 }, + { description: 'Travel expenses', quantity: 1, unitPriceNet: random.decimal(40, 180) }, + ], + }); + } + } + + // Always provide every offer status and at least one draft invoice per tenant. + for (const status of OFFER_STATUSES) { + this.addOffer(random.pick(customers), status); + } + + for (let i = 0; i < 2; i++) { + this.addInvoice({ + customer: random.pick(customers), + status: 'draft', + issuedAt: random.now, + lines: [{ description: 'Custom development (draft)', quantity: random.int(4, 24), unitPriceNet: 110 }], + }); + } + + this.addBackorders(customers); + } + + // --------------------------------------------------------------------------- + // Subscriptions + // --------------------------------------------------------------------------- + + private addSubscription(customer: DemoCustomer, status: SubscriptionStatus): void { + const { random, rows, numberScope, encryptor } = this.context; + const activePlans = this.catalog.plans.filter((plan) => plan.isActive); + const activeServerPlans = activePlans.filter((plan) => plan.serviceTypeId); + // Backorders and config changes only exist for server products. + const needsServer = status === 'pending_backorder' || status === 'pending_config_change'; + const plan = + status === 'canceled' && random.chance(0.5) + ? (this.catalog.plans.find((candidate) => !candidate.isActive) ?? random.pick(activePlans)) + : random.pick(needsServer ? activeServerPlans : activePlans); + const subscriptionId = random.id(); + const periodMs = this.periodLengthMs(plan); + const periodStart = new Date(random.now.getTime() - random.next() * periodMs); + const periodEnd = new Date(periodStart.getTime() + periodMs); + // A subscription starts on a period boundary at or before the current period. + const createdAt = new Date(periodStart.getTime() - random.int(0, 4) * periodMs - random.int(1, 3) * DAY_MS); + const isCanceled = status === 'canceled'; + const cancelRequested = CANCEL_REQUESTED_STATUSES.includes(status); + const cancelRequestedAt = cancelRequested ? random.daysAgo(random.int(1, 20)) : null; + + rows.add('billing_subscriptions', { + id: subscriptionId, + number: scopedNumber('SUB', 'billing_subscription_number_sequences', numberScope, 6), + number_scope: numberScope, + plan_id: plan.id, + user_id: customer.userId, + status, + current_period_start: isCanceled ? random.daysAgo(60) : periodStart, + current_period_end: isCanceled ? random.daysAgo(30) : periodEnd, + next_billing_at: isCanceled ? null : periodEnd, + cancel_requested_at: cancelRequestedAt, + cancel_effective_at: isCanceled ? random.daysAgo(30) : status === 'pending_cancel' ? periodEnd : null, + resumed_at: status === 'active' && random.chance(0.2) ? random.daysAgo(random.int(5, 30)) : null, + // Withdrawals are processed once `withdrawn_at` has passed; keep it in the future. + withdrawn_at: status === 'pending_withdrawal' ? random.daysFromNow(random.int(2, 10)) : null, + withdraw_phase: status === 'pending_withdrawal' ? 'withdrawal_period' : null, + statutory_withdrawal_restarted_at: null, + instant_removal: status === 'pending_instant_cancel', + instant_canceled_at: null, + auto_backorder: status === 'pending_backorder', + created_at: createdAt, + updated_at: random.daysAgo(random.int(0, 20)), + }); + + const itemId = random.id(); + const location = pickDemoLocation(random); + + if (plan.serviceTypeId) { + const provisioningStatus = status === 'pending_backorder' ? 'pending' : random.chance(0.12) ? 'failed' : 'active'; + const provisioned = provisioningStatus === 'active'; + const hostname = `${slugify(customer.person.company ?? customer.person.lastName).slice(0, 20)}-${random.hex(6)}`; + const publicIp = `203.0.113.${random.int(2, 254)}`; + const providerReference = `${DEMO_PROVIDER}-${random.int(10000000, 99999999)}`; + + rows.add('billing_subscription_items', { + id: itemId, + subscription_id: subscriptionId, + service_type_id: plan.serviceTypeId, + config_snapshot: encryptor.encryptJson({ + provider: DEMO_PROVIDER, + serverType: 'demo-standard', + location: location.location, + service: 'custom', + }), + provisioning_status: provisioningStatus, + provisioned_at: provisioned ? random.addMinutes(createdAt, random.int(2, 12)) : null, + provider_reference: provisioned ? providerReference : null, + hostname: provisioned ? hostname : null, + display_name: provisioned && random.chance(0.6) ? `${plan.name} – ${customer.person.lastName}` : null, + server_info_snapshot: provisioned + ? json({ + serverId: providerReference, + name: hostname, + publicIp, + privateIp: `10.0.${random.int(0, 9)}.${random.int(2, 254)}`, + status: isCanceled ? 'off' : random.pick(['running', 'running', 'running', 'off']), + metadata: { provider: DEMO_PROVIDER, ...location }, + }) + : null, + ssh_private_key: null, + created_at: createdAt, + updated_at: createdAt, + }); + + if (provisioned) { + rows.add('billing_reserved_hostnames', { + id: random.id(), + hostname, + subscription_item_id: itemId, + }); + } + + this.addSubscriptionAddons(subscriptionId, createdAt); + this.addConfigChanges(subscriptionId, plan, status); + } else { + rows.add('billing_subscription_items', { + id: itemId, + subscription_id: subscriptionId, + service_type_id: null, + config_snapshot: encryptor.encryptJson({}), + provisioning_status: 'active', + provisioned_at: createdAt, + created_at: createdAt, + updated_at: createdAt, + }); + } + + this.addUsage(subscriptionId, plan); + const redemptionId = this.maybeAddRedemption(customer, subscriptionId, createdAt); + const invoices = this.addSubscriptionInvoices(customer, subscriptionId, plan, periodStart, createdAt); + + if (redemptionId) { + const firstPaid = invoices.find((invoice) => invoice.status === 'paid'); + + if (firstPaid) { + rows.add('billing_invoice_promotion_applications', { + id: random.id(), + invoice_id: firstPaid.id, + redemption_id: redemptionId, + amount_applied_net: roundMoney(Math.min(10, firstPaid.subtotalNet)), + periods_consumed: 0, + created_at: firstPaid.issuedAt, + }); + } + } + + if (plan.billInAdvance && !isCanceled) { + this.addOpenPositions(customer, subscriptionId, plan, periodEnd, invoices[invoices.length - 1]); + } + } + + private periodLengthMs(plan: DemoPlan): number { + const unit = { hour: DAY_MS / 24, day: DAY_MS, month: 30 * DAY_MS, year: 365 * DAY_MS }[plan.intervalType]; + + return unit * plan.intervalValue; + } + + private addSubscriptionAddons(subscriptionId: string, createdAt: Date): void { + const { random, rows, encryptor } = this.context; + const statuses = ['active', 'active', 'inactive', 'failed', 'pending', 'tearing_down']; + + for (const addon of random.pickMany(this.catalog.addons, random.int(0, 2))) { + const status = random.pick(statuses); + const activatedAt = random.addDays(createdAt, random.int(0, 20)); + + rows.add('billing_subscription_addons', { + id: random.id(), + subscription_id: subscriptionId, + addon_id: addon.id, + status, + config_snapshot: encryptor.encryptJson({}), + unit_price_snapshot: addon.price, + price_interval_type: 'month', + price_interval_value: 1, + addon_name_snapshot: addon.name, + activated_at: status === 'pending' || status === 'failed' ? null : activatedAt, + deactivated_at: status === 'inactive' ? random.daysAgo(random.int(1, 15)) : null, + created_at: createdAt, + updated_at: random.daysAgo(random.int(0, 15)), + }); + } + } + + private addConfigChanges(subscriptionId: string, plan: DemoPlan, status: SubscriptionStatus): void { + const { random, rows, encryptor } = this.context; + const variants: string[] = status === 'pending_config_change' ? ['pending'] : []; + + if (random.chance(0.35)) { + variants.push(random.pick(['completed', 'completed', 'failed'])); + } + + for (const changeStatus of variants) { + const requestedAt = changeStatus === 'pending' ? random.daysAgo(0, 2) : random.daysAgo(random.int(5, 120)); + const done = changeStatus !== 'pending'; + + rows.add('billing_subscription_config_changes', { + id: random.id(), + subscription_id: subscriptionId, + status: changeStatus, + requested_payload: encryptor.encryptJson({ serverType: 'demo-large', previousServerType: 'demo-standard' }), + billing_disclaimer_snapshot: json({ currentPrice: plan.price, newPrice: roundMoney(plan.price * 1.8) }), + applied_steps: json(done ? ['resize_server', 'update_billing'] : []), + billing_outcome: changeStatus === 'completed' ? random.pick(['charged', 'credited', 'deferred']) : null, + reclaim_count: changeStatus === 'failed' ? random.int(1, 3) : 0, + error_code: changeStatus === 'failed' ? 'provider_unavailable' : null, + error_message: changeStatus === 'failed' ? 'Server type temporarily unavailable in this location.' : null, + requested_at: requestedAt, + processing_started_at: done ? random.addMinutes(requestedAt, 1) : null, + processed_at: done ? random.addMinutes(requestedAt, random.int(2, 15)) : null, + created_at: requestedAt, + updated_at: requestedAt, + }); + } + } + + private addUsage(subscriptionId: string, plan: DemoPlan): void { + const { random, rows } = this.context; + + for (const meterId of plan.meterIds) { + for (let day = 14; day >= 1; day--) { + const periodStart = new Date(Math.floor(random.daysAgo(day).getTime() / DAY_MS) * DAY_MS); + + rows.add('billing_usage_records', { + id: random.id(), + subscription_id: subscriptionId, + period_start: periodStart, + period_end: new Date(periodStart.getTime() + DAY_MS), + usage_source: 'demo-data', + usage_payload: json({ collector: 'demo-data' }), + meter_id: meterId, + value: random.decimal(0.5, 120, 3), + attachment_type: 'plan', + addon_id: null, + created_at: new Date(periodStart.getTime() + DAY_MS + 60 * 60 * 1000), + }); + } + } + } + + private maybeAddRedemption(customer: DemoCustomer, subscriptionId: string, createdAt: Date): string | null { + const { random, rows } = this.context; + + if (!random.chance(0.3)) { + return null; + } + + const promotion = random.pick(this.catalog.promotions); + const status = random.pick(['active', 'active', 'exhausted', 'expired', 'cancelled']); + const id = random.id(); + + rows.add('billing_promotion_redemptions', { + id, + promotion_id: promotion.id, + user_id: customer.userId, + subscription_id: subscriptionId, + code_snapshot: promotion.code, + redemption_context: random.pick(['new', 'existing']), + status, + redeemed_at: createdAt, + benefit_starts_at: createdAt, + benefit_ends_at: status === 'active' ? random.daysFromNow(random.int(5, 60)) : random.daysAgo(random.int(1, 30)), + remaining_amount_net: + promotion.advantageType === 'fixed_amount_net' + ? status === 'active' + ? promotion.config.amountNet / 2 + : 0 + : null, + remaining_billing_periods: + promotion.advantageType === 'free_billing_periods' ? (status === 'active' ? 1 : 0) : null, + created_at: createdAt, + }); + + return id; + } + + private addSubscriptionInvoices( + customer: DemoCustomer, + subscriptionId: string, + plan: DemoPlan, + currentPeriodStart: Date, + createdAt: Date, + ): CreatedInvoice[] { + const { random } = this.context; + const invoices: CreatedInvoice[] = []; + + if (plan.intervalType === 'hour' || plan.intervalType === 'day') { + const quantity = plan.intervalType === 'hour' ? random.int(20, 300) : random.int(3, 25); + + invoices.push( + this.addInvoice({ + customer, + subscriptionId, + status: this.invoiceStatusDeck(), + issuedAt: random.pastDate(25, 1), + lines: [ + { + description: `${plan.name} – ${quantity} ${plan.intervalType === 'hour' ? 'hours' : 'days'}`, + quantity, + unitPriceNet: plan.price, + taxCategory: plan.taxCategory, + }, + ], + }), + ); + + return invoices; + } + + const periodMs = this.periodLengthMs(plan); + const maxPast = Math.floor((currentPeriodStart.getTime() - createdAt.getTime()) / periodMs); + const pastPeriods = Math.max(0, Math.min(4, maxPast)); + + for (let i = pastPeriods; i >= 1; i--) { + const periodStart = new Date(currentPeriodStart.getTime() - i * periodMs); + const periodEnd = new Date(periodStart.getTime() + periodMs); + const issuedAt = plan.billInAdvance ? periodStart : periodEnd; + + invoices.push( + this.addInvoice({ + customer, + subscriptionId, + // Older periods are settled; only the latest invoice shows an interesting state. + status: i === 1 ? this.invoiceStatusDeck() : 'paid', + issuedAt: issuedAt.getTime() > random.now.getTime() ? random.daysAgo(1) : issuedAt, + lines: [ + { + description: `${plan.name} (${toDateOnly(periodStart)} – ${toDateOnly(periodEnd)})`, + quantity: 1, + unitPriceNet: plan.price, + taxCategory: plan.taxCategory, + }, + ], + }), + ); + } + + return invoices; + } + + private addOpenPositions( + customer: DemoCustomer, + subscriptionId: string, + plan: DemoPlan, + periodEnd: Date, + lastInvoice: CreatedInvoice | undefined, + ): void { + const { random, rows } = this.context; + + rows.add('billing_open_positions', { + id: random.id(), + subscription_id: subscriptionId, + user_id: customer.userId, + description: `${plan.name} – next period`, + bill_until: periodEnd, + skip_if_no_billable_amount: true, + adjustment_net: null, + adjustment_kind: null, + source_ref: null, + created_at: random.daysAgo(random.int(1, 10)), + invoice_ref_id: null, + }); + + if (random.chance(0.5)) { + rows.add('billing_open_positions', { + id: random.id(), + subscription_id: subscriptionId, + user_id: customer.userId, + description: 'Goodwill credit for maintenance window', + bill_until: periodEnd, + skip_if_no_billable_amount: false, + adjustment_net: -roundMoney(plan.price * 0.1), + adjustment_kind: 'goodwill_credit', + source_ref: `demo-goodwill-${random.hex(8)}`, + created_at: random.daysAgo(random.int(1, 10)), + invoice_ref_id: null, + }); + } + + if (lastInvoice) { + rows.add('billing_open_positions', { + id: random.id(), + subscription_id: subscriptionId, + user_id: customer.userId, + description: `${plan.name} – billed period`, + bill_until: lastInvoice.issuedAt, + skip_if_no_billable_amount: true, + created_at: random.addDays(lastInvoice.issuedAt, -1), + invoice_ref_id: lastInvoice.id, + }); + } + } + + private addBackorders(customers: DemoCustomer[]): void { + const { random, rows, encryptor } = this.context; + const serverPlans = this.catalog.plans.filter((plan) => plan.serviceTypeId && plan.isActive); + + for (const status of ['pending', 'retrying', 'failed', 'cancelled', 'fulfilled']) { + const plan = random.pick(serverPlans); + const createdAt = random.daysAgo(random.int(1, 40)); + + rows.add('billing_backorders', { + id: random.id(), + user_id: random.pick(customers).userId, + service_type_id: plan.serviceTypeId, + plan_id: plan.id, + requested_config_snapshot: encryptor.encryptJson({ + provider: DEMO_PROVIDER, + serverType: 'demo-large', + location: 'hel1', + }), + status, + failure_reason: status === 'failed' || status === 'retrying' ? 'Server type sold out in hel1' : null, + provider_errors: json(status === 'fulfilled' ? {} : { [DEMO_PROVIDER]: 'resource_unavailable' }), + preferred_alternatives: json({ locations: ['fsn1', 'nbg1'] }), + // Keeps the retry job from picking open backorders up immediately. + retry_after: status === 'pending' || status === 'retrying' ? random.daysFromNow(random.int(7, 30)) : null, + created_at: createdAt, + updated_at: random.addDays(createdAt, 1), + }); + } + } + + // --------------------------------------------------------------------------- + // Invoices and payments + // --------------------------------------------------------------------------- + + addInvoice(spec: InvoiceSpec): CreatedInvoice { + const { random, rows, numberScope, issuerCountry } = this.context; + const { customer, status } = spec; + const tax = resolveTax(customer, issuerCountry); + const totals = computeLines(spec.lines, tax); + const id = random.id(); + const isDraft = status === 'draft'; + const issuedAt = isDraft ? null : spec.issuedAt; + // Issued invoices must still be within the payment term, otherwise the overdue job flips them. + const effectiveIssuedAt = + status === 'issued' + ? random.daysAgo(random.int(0, 8)) + : status === 'overdue' + ? random.daysAgo(random.int(20, 60)) + : issuedAt; + const dueDate = effectiveIssuedAt ? random.addDays(effectiveIssuedAt, 14) : null; + const paidAt = status === 'paid' && effectiveIssuedAt ? random.addDays(effectiveIssuedAt, random.int(0, 12)) : null; + const balanceDue = + status === 'paid' || status === 'void' + ? 0 + : status === 'partially_paid' + ? roundMoney(totals.totalGross / 2) + : totals.totalGross; + const year = (effectiveIssuedAt ?? random.now).getUTCFullYear(); + const viaStripe = customer.autoBilling || random.chance(0.3); + + rows.add('billing_invoices', { + id, + subscription_id: spec.subscriptionId ?? null, + user_id: customer.userId, + project_id: spec.projectId ?? null, + offer_id: spec.offerId ?? null, + invoice_number: isDraft ? null : yearlyNumber('INV', 'billing_invoice_number_sequences', numberScope, year), + status, + currency: 'EUR', + subtotal_net: totals.subtotalNet, + tax_total: totals.taxTotal, + total_gross: totals.totalGross, + balance_due: balanceDue, + tax_mode: tax.taxMode, + tax_country_code: tax.taxCountry, + tax_note: tax.note, + einvoice_tax_category_code: tax.einvoiceCode, + resolved_tax_rate: totals.resolvedRate, + buyer_vat_id: customer.vatId, + buyer_country: customer.country, + buyer_customer_type: customer.customerType, + issuer_country: issuerCountry, + issuer_is_in_eu: isEuCountry(issuerCountry), + issued_at: effectiveIssuedAt, + due_date: dueDate ? toDateOnly(dueDate) : null, + voided_at: status === 'void' && effectiveIssuedAt ? random.addDays(effectiveIssuedAt, random.int(1, 5)) : null, + paid_at: paidAt, + // PDFs are generated on first download (requires BILLING_ISSUER_* to be configured). + pdf_storage_key: null, + payment_processor: viaStripe && (status === 'paid' || status === 'partially_paid') ? 'stripe' : null, + external_payment_id: viaStripe && status === 'paid' ? `pi_demo${random.alphanumeric(16)}` : null, + auto_payment_status: this.autoPaymentStatus(customer, status), + auto_payment_attempt_count: this.autoPaymentStatus(customer, status) === 'idle' ? 0 : random.int(1, 3), + auto_payment_next_retry_at: null, + created_at: effectiveIssuedAt ?? random.daysAgo(random.int(0, 3)), + }); + + totals.lines.forEach((line, index) => { + rows.add('billing_invoice_line_items', { + id: random.id(), + invoice_id: id, + position: index, + description: line.description, + quantity: line.quantity, + unit_price_net: line.unitPriceNet, + tax_category: line.taxCategory, + tax_rate: line.taxRate, + line_net: line.lineNet, + line_tax: line.lineTax, + line_gross: line.lineGross, + }); + }); + + if (effectiveIssuedAt) { + this.addPayments(id, status, totals.totalGross, effectiveIssuedAt, viaStripe); + this.addAuditLog(id, customer.userId, 'invoice-issue', 'info', 'Invoice issued', effectiveIssuedAt); + } + + return { + id, + status, + subtotalNet: totals.subtotalNet, + totalGross: totals.totalGross, + issuedAt: effectiveIssuedAt ?? random.now, + }; + } + + private autoPaymentStatus(customer: DemoCustomer, status: InvoiceStatus): string { + if (!customer.autoBilling) { + return 'idle'; + } + + switch (status) { + case 'paid': + return 'succeeded'; + case 'overdue': + return 'exhausted'; + case 'void': + return 'canceled'; + default: + return 'idle'; + } + } + + private addPayments( + invoiceId: string, + status: InvoiceStatus, + total: number, + issuedAt: Date, + viaStripe: boolean, + ): void { + const { random, rows } = this.context; + const attempt = (attemptStatus: string, amount: number, offsetDays: number, metadata: object = {}): void => { + const createdAt = random.addDays(issuedAt, offsetDays); + const id = random.id(); + + rows.add('billing_payment_attempts', { + id, + invoice_id: invoiceId, + processor: 'stripe', + external_id: `pi_demo${random.alphanumeric(16)}`, + status: attemptStatus, + amount, + currency: 'EUR', + idempotency_key: `demo-${id}`, + metadata: json(metadata), + created_at: createdAt, + updated_at: random.addMinutes(createdAt, 3), + }); + }; + + if (!viaStripe) { + return; + } + + switch (status) { + case 'paid': + if (random.chance(0.3)) { + attempt('failed', total, 1, { failureCode: 'card_declined' }); + } + + attempt('succeeded', total, 2); + + if (random.chance(0.1)) { + rows.add('billing_payment_refunds', { + id: random.id(), + invoice_id: invoiceId, + amount: roundMoney(total / 4), + currency: 'EUR', + processor: 'stripe', + external_refund_id: `re_demo${random.alphanumeric(16)}`, + status: random.pick(['succeeded', 'pending', 'failed']), + reason: 'goodwill', + created_at: random.addDays(issuedAt, 10), + updated_at: random.addDays(issuedAt, 10), + }); + } + + break; + case 'partially_paid': + attempt('succeeded', roundMoney(total / 2), 2); + break; + case 'overdue': + attempt('failed', total, 1, { failureCode: 'insufficient_funds' }); + attempt('failed', total, 4, { failureCode: 'insufficient_funds' }); + this.addAuditLog(invoiceId, null, 'auto-payment', 'warn', 'Automatic payment attempts exhausted', issuedAt); + break; + case 'issued': + attempt('pending', total, 0); + break; + case 'void': + attempt('canceled', total, 0); + break; + default: + break; + } + } + + private addAuditLog( + invoiceId: string | null, + userId: string | null, + process: string, + level: 'info' | 'warn' | 'error', + message: string, + createdAt: Date, + offerId: string | null = null, + ): void { + const { random, rows, tenantId } = this.context; + + rows.add('billing_audit_logs', { + id: random.id(), + correlation_id: random.hex(16), + process, + invoice_id: invoiceId, + offer_id: offerId, + user_id: userId, + tenant_id: tenantId, + level, + message, + context: json({ source: 'demo-data' }), + created_at: createdAt, + }); + } + + // --------------------------------------------------------------------------- + // Offers + // --------------------------------------------------------------------------- + + private addOffer(customer: DemoCustomer, status: OfferStatus = this.context.random.pick(OFFER_STATUSES)): void { + const { random, rows, numberScope, issuerCountry } = this.context; + const tax = resolveTax(customer, issuerCountry); + const plan = random.pick(this.catalog.plans.filter((candidate) => candidate.isActive)); + const lines: (LineInput & { lineType: string })[] = [ + { lineType: 'standard', description: 'Requirements workshop', quantity: 1, unitPriceNet: 980, unitLabel: 'day' }, + { + lineType: 'standard', + description: `Implementation: ${random.pick(SOFTWARE_TOPICS)}`, + quantity: random.int(16, 120), + unitPriceNet: 115, + unitLabel: 'h', + }, + { lineType: 'plan_template', description: plan.name, quantity: 1, unitPriceNet: plan.price, unitLabel: 'period' }, + ]; + const totals = computeLines(lines, tax); + const offerId = random.id(); + const createdAt = random.daysAgo(random.int(5, 120)); + const sentAt = random.addDays(createdAt, 1); + const decidedAt = random.addDays(sentAt, random.int(2, 10)); + + rows.add('billing_offers', { + id: offerId, + user_id: customer.userId, + // Numbers are assigned when an offer is archived (sent); drafts have none yet. + offer_number: + status === 'draft' + ? null + : yearlyNumber('OFF', 'billing_offer_number_sequences', numberScope, sentAt.getUTCFullYear()), + number_scope: numberScope, + status, + currency: 'EUR', + subtotal_net: totals.subtotalNet, + tax_total: totals.taxTotal, + total_gross: totals.totalGross, + tax_mode: tax.taxMode, + tax_country_code: tax.taxCountry, + tax_note: tax.note, + einvoice_tax_category_code: tax.einvoiceCode, + resolved_tax_rate: totals.resolvedRate, + buyer_vat_id: customer.vatId, + buyer_country: customer.country, + buyer_customer_type: customer.customerType, + issuer_country: issuerCountry, + issuer_is_in_eu: isEuCountry(issuerCountry), + // The expiration job only acts on archived offers whose expiry has passed. + expires_at: status === 'expired' ? random.daysAgo(random.int(1, 20)) : random.daysFromNow(random.int(10, 45)), + archived_at: status === 'draft' ? null : sentAt, + accepted_at: status === 'accepted' ? decidedAt : null, + declined_at: status === 'declined' ? decidedAt : null, + expired_at: status === 'expired' ? random.daysAgo(random.int(0, 5)) : null, + revoked_at: status === 'revoked' ? decidedAt : null, + bill_to_open_positions: random.chance(0.3), + pdf_storage_key: null, + created_at: createdAt, + updated_at: status === 'draft' ? createdAt : decidedAt, + }); + + totals.lines.forEach((line, index) => { + const accepted = status === 'accepted'; + + rows.add('billing_offer_line_items', { + id: random.id(), + offer_id: offerId, + position: index, + line_type: lines[index].lineType, + description: line.description, + quantity: line.quantity, + unit_label: line.unitLabel ?? null, + unit_price_net: line.unitPriceNet, + tax_category: line.taxCategory, + tax_rate: line.taxRate, + line_net: line.lineNet, + line_tax: line.lineTax, + line_gross: line.lineGross, + // Accepted offers are already fulfilled so the fulfillment job leaves them alone. + fulfillment_status: accepted ? 'completed' : 'pending', + scheduled_at: accepted ? decidedAt : null, + fulfilled_at: accepted ? random.addMinutes(decidedAt, 5) : null, + plan_id: lines[index].lineType === 'plan_template' ? plan.id : null, + plan_name_snapshot: lines[index].lineType === 'plan_template' ? plan.name : null, + pricing_snapshot: lines[index].lineType === 'plan_template' ? json({ basePrice: plan.price }) : null, + auto_backorder: false, + }); + }); + + this.addAuditLog(null, customer.userId, 'offer', 'info', `Offer ${status}`, createdAt, offerId); + + if (status === 'accepted') { + this.addInvoice({ + customer, + offerId, + status: random.pick(['paid', 'issued'] as const), + issuedAt: random.addDays(decidedAt, 1), + lines: toLineInputs(totals.lines.slice(0, 2)), + }); + } + } + + // --------------------------------------------------------------------------- + // Projects + // --------------------------------------------------------------------------- + + private addProject(customer: DemoCustomer): void { + const { random, rows } = this.context; + const projectId = random.id(); + const status = random.chance(0.75) ? 'active' : 'archived'; + const createdAt = random.daysAgo(random.int(30, 300)); + const hourlyRate = random.pick([95, 110, 125, 145]); + const topic = random.pick(SOFTWARE_TOPICS); + const staff = this.admins[0]; + + rows.add('billing_projects', { + id: projectId, + user_id: customer.userId, + name: `${customer.person.company ?? customer.person.lastName}: ${topic}`, + description: `Delivery project covering the ${topic} for ${customer.person.company ?? customer.person.fullName}.`, + status, + hourly_rate_net: hourlyRate, + target_hours: random.pick([40, 80, 120, null]), + currency: 'EUR', + created_at: createdAt, + updated_at: random.daysAgo(random.int(0, 20)), + }); + + const milestoneIds = ['Discovery', 'Implementation', 'Rollout', 'Hypercare'] + .slice(0, random.int(2, 4)) + .map((name, index) => { + const id = random.id(); + + rows.add('billing_project_milestones', { + id, + project_id: projectId, + name, + description: `${name} phase`, + target_date: toDateOnly(random.addDays(createdAt, 30 * (index + 1))), + sort_order: index, + locked_at: index === 0 || status === 'archived' ? random.addDays(createdAt, 30 * (index + 1)) : null, + created_at: createdAt, + updated_at: createdAt, + }); + + return id; + }); + const statusDeck = random.deck(['draft', 'todo', 'in_progress', 'prototype', 'done', 'closed']); + const priorityDeck = random.deck(['low', 'medium', 'high', 'critical']); + const ticketIds: string[] = []; + const count = random.int(6, 12); + + for (let i = 0; i < count; i++) { + const id = random.id(); + const ticketStatus = status === 'archived' ? 'closed' : statusDeck(); + const ticketCreatedAt = random.addDays(createdAt, random.int(0, 25)); + const parentId = ticketIds.length > 2 && random.chance(0.25) ? random.pick(ticketIds) : null; + + ticketIds.push(id); + rows.add('billing_project_tickets', { + id, + project_id: projectId, + milestone_id: random.chance(0.8) ? random.pick(milestoneIds) : null, + parent_id: parentId, + title: `${random.pick(TICKET_VERBS)} ${random.pick(SOFTWARE_TOPICS)}`, + content: [ + '## Context', + random.pick(LOREM_SENTENCES), + '', + '## Acceptance criteria', + ...random.pickMany(LOREM_SENTENCES, 2).map((sentence) => `- ${sentence}`), + ].join('\n'), + long_sha: createHash('sha1').update(id).digest('hex'), + priority: priorityDeck(), + status: ticketStatus, + locked: ticketStatus === 'closed' && random.chance(0.5), + created_by_user_id: random.chance(0.5) ? customer.userId : staff.userId, + created_at: ticketCreatedAt, + updated_at: random.addDays(ticketCreatedAt, random.int(0, 10)), + }); + this.addTicketHistory(id, ticketStatus, ticketCreatedAt, customer.userId, staff.userId); + } + + this.addTimeEntries(customer, projectId, ticketIds, hourlyRate, createdAt, staff.userId); + } + + private addTicketHistory( + ticketId: string, + status: string, + createdAt: Date, + customerId: string, + staffId: string, + ): void { + const { random, rows } = this.context; + + rows.add('billing_project_ticket_activities', { + id: random.id(), + ticket_id: ticketId, + occurred_at: createdAt, + actor_type: 'human', + actor_user_id: staffId, + action_type: 'CREATED', + payload: json({}), + }); + + if (status !== 'draft') { + rows.add('billing_project_ticket_activities', { + id: random.id(), + ticket_id: ticketId, + occurred_at: random.addDays(createdAt, 1), + actor_type: random.pick(['human', 'human', 'system']), + actor_user_id: staffId, + action_type: 'STATUS_CHANGED', + payload: json({ fields: { status: { old: 'draft', new: status } } }), + }); + } + + const count = random.int(0, 3); + + for (let i = 0; i < count; i++) { + const commentId = random.id(); + const author = random.chance(0.5) ? customerId : staffId; + const commentedAt = random.addDays(createdAt, i + 1); + + rows.add('billing_project_ticket_comments', { + id: commentId, + ticket_id: ticketId, + user_id: author, + body: random.pick(LOREM_SENTENCES), + created_at: commentedAt, + }); + rows.add('billing_project_ticket_activities', { + id: random.id(), + ticket_id: ticketId, + occurred_at: commentedAt, + actor_type: 'human', + actor_user_id: author, + action_type: 'COMMENT_ADDED', + payload: json({ commentId }), + }); + } + } + + private addTimeEntries( + customer: DemoCustomer, + projectId: string, + ticketIds: string[], + hourlyRate: number, + projectCreatedAt: Date, + staffId: string, + ): void { + const { random, rows } = this.context; + const entries: { row: SqlRow; minutes: number }[] = []; + const count = random.int(6, 18); + + for (let i = 0; i < count; i++) { + const startedAt = random.pastDate(Math.max(1, (random.now.getTime() - projectCreatedAt.getTime()) / DAY_MS), 0); + const minutes = random.pick([30, 45, 60, 90, 120, 180, 240]); + const row = rows.add('billing_project_time_entries', { + id: random.id(), + project_id: projectId, + ticket_id: random.chance(0.8) ? random.pick(ticketIds) : null, + recorded_by_user_id: staffId, + duration_minutes: minutes, + description: random.pick(LOREM_SENTENCES), + started_at: startedAt, + ended_at: random.addMinutes(startedAt, minutes), + recorded_at: random.addMinutes(startedAt, minutes + 5), + invoice_id: null, + billed_at: null, + created_at: random.addMinutes(startedAt, minutes + 5), + }); + + entries.push({ row, minutes }); + } + + // Bill roughly the older half of the entries through a project invoice. + const billed = entries + .sort((a, b) => (a.row.started_at as Date).getTime() - (b.row.started_at as Date).getTime()) + .slice(0, Math.floor(entries.length / 2)); + + if (billed.length === 0) { + return; + } + + const hours = roundMoney(billed.reduce((sum, entry) => sum + entry.minutes, 0) / 60); + const invoice = this.addInvoice({ + customer, + projectId, + status: random.pick(['paid', 'issued', 'partially_paid'] as const), + issuedAt: random.daysAgo(random.int(3, 25)), + lines: [{ description: `Consulting services (${hours} h)`, quantity: hours, unitPriceNet: hourlyRate }], + }); + + for (const entry of billed) { + entry.row.invoice_id = invoice.id; + entry.row.billed_at = invoice.issuedAt; + } + } +} + +function toLineInputs(lines: LineInput[]): LineInput[] { + return lines.map(({ description, quantity, unitPriceNet, taxCategory }) => ({ + description, + quantity, + unitPriceNet, + taxCategory, + })); +} diff --git a/tools/demo-data/src/lib/decabill/decabill-context.ts b/tools/demo-data/src/lib/decabill/decabill-context.ts new file mode 100644 index 000000000..dd21dec03 --- /dev/null +++ b/tools/demo-data/src/lib/decabill/decabill-context.ts @@ -0,0 +1,133 @@ +import { ColumnEncryptor } from '../core/encryption'; +import { PasswordHasher } from '../core/passwords'; +import { DemoPerson } from '../core/people'; +import { DemoRandom } from '../core/random'; +import { RowCollector } from '../core/row-collector'; +import { quoteLiteral, raw, SqlRaw } from '../core/sql'; + +import { BuyerTaxProfile, TaxCategory } from './decabill-tax'; + +export interface DecabillTenantContext { + tenantId: string; + /** `__shared__` unless TENANTS_SHARED_NUMBERS=false, then the tenant id. */ + numberScope: string; + issuerCountry: string; + emailDomain: string; + random: DemoRandom; + rows: RowCollector; + encryptor: ColumnEncryptor; + hasher: PasswordHasher; + /** Shared across tenants: DATEV debtor/creditor numbers are unique per allocation scope. */ + datevCounters: { debtor: number; creditor: number }; +} + +export interface DemoCustomer extends BuyerTaxProfile { + userId: string; + email: string; + person: DemoPerson; + hasCompleteProfile: boolean; + autoBilling: boolean; +} + +export interface DemoPlan { + id: string; + name: string; + price: number; + intervalType: 'hour' | 'day' | 'month' | 'year'; + intervalValue: number; + serviceTypeId: string | null; + taxCategory: TaxCategory; + billInAdvance: boolean; + isActive: boolean; + meterIds: string[]; +} + +export interface DemoCatalog { + plans: DemoPlan[]; + addons: { id: string; name: string; price: number }[]; + promotions: { id: string; code: string; advantageType: string; config: Record }[]; +} + +const DEMO_PROVIDER_LOCATIONS = [ + { location: 'fsn1', locationName: 'Falkenstein', datacenter: 'fsn1-dc14' }, + { location: 'nbg1', locationName: 'Nuremberg', datacenter: 'nbg1-dc3' }, + { location: 'hel1', locationName: 'Helsinki', datacenter: 'hel1-dc2' }, +] as const; + +/** + * Provider id for demo server products. It is not a registered provisioning module, so the + * provisioning job marks items active without creating remote resources, and live server-info + * lookups fall back to the cached snapshot. + */ +export const DEMO_PROVIDER = 'demo'; + +export function pickDemoLocation(random: DemoRandom): (typeof DEMO_PROVIDER_LOCATIONS)[number] { + return random.pick(DEMO_PROVIDER_LOCATIONS); +} + +export function scopedNumber(prefix: string, table: string, scope: string, width: number): SqlRaw { + const next = `pg_temp.demo_next_scoped(${quoteLiteral(table)}, ${quoteLiteral(scope)})`; + + return raw(`${quoteLiteral(`${prefix}-`)} || lpad(${next}::text, ${width}, '0')`); +} + +export function yearlyNumber(prefix: string, table: string, scope: string, year: number): SqlRaw { + const next = `pg_temp.demo_next_yearly(${quoteLiteral(table)}, ${quoteLiteral(scope)}, ${year})`; + + return raw(`${quoteLiteral(`${prefix}-${year}-`)} || lpad(${next}::text, 5, '0')`); +} + +/** + * DATEV account number: highest non-demo number in the allocation scope (or the start of the + * default range) plus `offset`. The app allocates MAX+1, so real accounts continue afterwards. + */ +export function datevAccountNumber(kind: 'debtor' | 'creditor', scope: string, offset: number): SqlRaw { + return raw(`pg_temp.demo_datev_number(${quoteLiteral(kind)}, ${quoteLiteral(scope)}, ${offset})`); +} + +/** + * Helper functions used inside the seed transaction to allocate document numbers from the + * same sequence tables the API uses, so demo numbers never collide with real ones. + */ +export const DECABILL_NUMBER_FUNCTIONS_SQL = ` +CREATE OR REPLACE FUNCTION pg_temp.demo_next_scoped(p_table text, p_scope text) RETURNS integer +LANGUAGE plpgsql AS $fn$ +DECLARE next_value integer; +BEGIN + EXECUTE format( + 'INSERT INTO %I AS s (scope_key, last_value) VALUES ($1, 1) + ON CONFLICT (scope_key) DO UPDATE SET last_value = s.last_value + 1 RETURNING last_value', + p_table + ) INTO next_value USING p_scope; + RETURN next_value; +END +$fn$; + +CREATE OR REPLACE FUNCTION pg_temp.demo_next_yearly(p_table text, p_scope text, p_year integer) RETURNS integer +LANGUAGE plpgsql AS $fn$ +DECLARE next_value integer; +BEGIN + EXECUTE format( + 'INSERT INTO %I AS s (tenant_id, year, last_value) VALUES ($1, $2, 1) + ON CONFLICT (tenant_id, year) DO UPDATE SET last_value = s.last_value + 1 RETURNING last_value', + p_table + ) INTO next_value USING p_scope, p_year; + RETURN next_value; +END +$fn$; + +CREATE OR REPLACE FUNCTION pg_temp.demo_datev_number(p_kind text, p_scope text, p_offset integer) RETURNS integer +LANGUAGE plpgsql AS $fn$ +DECLARE base_value integer; +BEGIN + IF p_kind = 'debtor' THEN + SELECT COALESCE(MAX(debtor_number), 9999) INTO base_value FROM billing_datev_debtor_accounts + WHERE allocation_scope = p_scope AND id::text NOT LIKE '5eedda7a-%'; + ELSE + SELECT COALESCE(MAX(creditor_number), 69999) INTO base_value FROM billing_datev_creditor_accounts + WHERE allocation_scope = p_scope AND id::text NOT LIKE '5eedda7a-%'; + END IF; + RETURN base_value + p_offset; +END +$fn$; +`; diff --git a/tools/demo-data/src/lib/decabill/decabill-customers.ts b/tools/demo-data/src/lib/decabill/decabill-customers.ts new file mode 100644 index 000000000..2e7220942 --- /dev/null +++ b/tools/demo-data/src/lib/decabill/decabill-customers.ts @@ -0,0 +1,170 @@ +import { ACCOUNT_STATES, buildPersonalAccessTokenRows, buildUserRow } from '../core/accounts'; +import { createPerson, createVatId, DemoPerson } from '../core/people'; + +import { datevAccountNumber, DecabillTenantContext, DemoCustomer, scopedNumber } from './decabill-context'; + +const DECABILL_PAT_SCOPES = ['invoices:read', 'subscriptions:read', 'usage:read', 'usage:write', 'catalog:write']; +/** Number of additional random customers per tenant on top of one user per account state. */ +const RANDOM_CUSTOMERS_PER_TENANT = 14; + +export interface DecabillUsers { + admins: { userId: string; email: string }[]; + /** Customers eligible for commerce data (confirmed or locked, with a profile). */ + customers: DemoCustomer[]; +} + +export async function buildUsers(context: DecabillTenantContext): Promise { + const { random, rows, tenantId, emailDomain } = context; + const usedLocalParts = new Set(); + const admins: DecabillUsers['admins'] = []; + const customers: DemoCustomer[] = []; + const profileVariants = random.deck(['complete', 'complete', 'complete', 'incomplete', 'none'] as const); + + for (const state of ACCOUNT_STATES) { + const userId = random.id(); + const email = `${state.key}@${emailDomain}`; + const createdAt = random.daysAgo(random.int(200, 700)); + + usedLocalParts.add(state.key); + rows.add( + 'users', + await buildUserRow({ + id: userId, + tenantId, + email, + state, + createdAt, + random, + hasher: context.hasher, + encryptor: context.encryptor, + }), + ); + + if (state.role === 'admin') { + admins.push({ userId, email }); + rows.addMany( + 'user_personal_access_tokens', + await buildPersonalAccessTokenRows(random, context.hasher, userId, DECABILL_PAT_SCOPES), + ); + + continue; + } + + if (!state.confirmed) { + continue; + } + + // The fixed-state accounts always get a complete profile so their pages are filled. + customers.push(addCustomerProfile(context, userId, email, createPerson(random, usedLocalParts), 'complete')); + } + + for (let i = 0; i < RANDOM_CUSTOMERS_PER_TENANT; i++) { + const person = createPerson(random, usedLocalParts); + const userId = random.id(); + const email = `${person.emailLocalPart}@${emailDomain}`; + const createdAt = random.daysAgo(random.int(30, 900)); + const variant = profileVariants(); + + rows.add( + 'users', + await buildUserRow({ + id: userId, + tenantId, + email, + state: ACCOUNT_STATES.find((state) => state.key === 'user')!, + createdAt, + random, + hasher: context.hasher, + encryptor: context.encryptor, + }), + ); + + if (variant === 'none') { + continue; + } + + const customer = addCustomerProfile(context, userId, email, person, variant); + + if (variant === 'complete') { + customers.push(customer); + } + } + + return { admins, customers }; +} + +function addCustomerProfile( + context: DecabillTenantContext, + userId: string, + email: string, + person: DemoPerson, + variant: 'complete' | 'incomplete', +): DemoCustomer { + const { random, rows, encryptor, numberScope } = context; + const business = person.company !== null; + const vatId = business ? createVatId(random, person.location) : null; + const vatStatus = vatId ? random.pick(['valid', 'valid', 'valid', 'pending', 'invalid', 'unavailable']) : 'none'; + const trustScore = random.int(5, 99); + const trustLevel = trustScore >= 70 ? 'green' : trustScore >= 35 ? 'yellow' : 'red'; + const complete = variant === 'complete'; + const autoBilling = complete && random.chance(0.35); + const createdAt = random.daysAgo(random.int(30, 600)); + + rows.add('billing_customer_profiles', { + id: random.id(), + user_id: userId, + customer_number: scopedNumber('CUS', 'billing_customer_number_sequences', numberScope, 6), + number_scope: numberScope, + first_name: person.firstName, + last_name: person.lastName, + company: person.company, + customer_type: business ? 'business' : 'consumer', + vat_id: vatId, + vat_id_validation_status: vatStatus, + vat_id_validated_at: vatStatus === 'valid' || vatStatus === 'invalid' ? random.daysAgo(random.int(1, 90)) : null, + vat_id_validation_source: vatStatus === 'none' ? null : random.pick(['vies_sync', 'vies_async', 'admin']), + address_line_1: complete ? person.addressLine1 : null, + address_line_2: complete ? person.addressLine2 : null, + postal_code: complete ? person.location.postalCode : null, + city: person.location.city, + state: person.location.state ?? null, + country: complete ? person.location.country : null, + email, + phone: random.chance(0.7) ? person.phone : null, + stripe_customer_id: autoBilling ? `cus_demo${random.alphanumeric(14)}` : null, + auto_billing_enabled: autoBilling, + default_payment_method_external_id: autoBilling ? `pm_demo${random.alphanumeric(14)}` : null, + trust_score: complete ? trustScore : null, + trust_level: complete ? trustLevel : null, + trust_score_updated_at: complete ? random.daysAgo(random.int(0, 7)) : null, + custom_data: encryptor.encryptJson( + random.chance(0.5) + ? { crmId: `CRM-${random.int(10000, 99999)}`, segment: random.pick(['SMB', 'Enterprise']) } + : {}, + ), + created_at: createdAt, + updated_at: random.addDays(createdAt, random.int(0, 20)), + }); + + if (complete) { + rows.add('billing_datev_debtor_accounts', { + id: random.id(), + tenant_id: context.tenantId, + user_id: userId, + allocation_scope: numberScope, + debtor_number: datevAccountNumber('debtor', numberScope, context.datevCounters.debtor++), + created_at: createdAt, + }); + } + + return { + userId, + email, + person, + country: person.location.country, + customerType: business ? 'business' : 'consumer', + vatId: vatStatus === 'invalid' ? null : vatId, + hasCompleteProfile: complete, + autoBilling, + }; +} diff --git a/tools/demo-data/src/lib/decabill/decabill-tax.spec.ts b/tools/demo-data/src/lib/decabill/decabill-tax.spec.ts new file mode 100644 index 000000000..d344f7c21 --- /dev/null +++ b/tools/demo-data/src/lib/decabill/decabill-tax.spec.ts @@ -0,0 +1,53 @@ +import { computeLines, resolveTax } from './decabill-tax'; + +describe('resolveTax', () => { + it('should apply domestic VAT for buyers in the issuer country', () => { + expect(resolveTax({ country: 'DE', customerType: 'consumer', vatId: null }, 'DE')).toMatchObject({ + taxMode: 'domestic_vat', + standardRate: 19, + reducedRate: 7, + }); + }); + + it('should use reverse charge for EU businesses with a VAT id', () => { + expect(resolveTax({ country: 'NL', customerType: 'business', vatId: 'NL123' }, 'DE')).toMatchObject({ + taxMode: 'eu_reverse_charge', + einvoiceCode: 'AE', + standardRate: 0, + }); + }); + + it('should charge destination VAT for EU consumers', () => { + expect(resolveTax({ country: 'AT', customerType: 'consumer', vatId: null }, 'DE')).toMatchObject({ + taxMode: 'eu_b2c_oss', + standardRate: 20, + }); + }); + + it('should not charge VAT for third countries', () => { + expect(resolveTax({ country: 'US', customerType: 'business', vatId: null }, 'DE').taxMode).toBe( + 'third_country_b2b_no_vat', + ); + expect(resolveTax({ country: 'CH', customerType: 'consumer', vatId: null }, 'DE').taxMode).toBe( + 'third_country_b2c_no_domestic_vat', + ); + }); +}); + +describe('computeLines', () => { + it('should compute line and invoice totals with standard and reduced rates', () => { + const tax = resolveTax({ country: 'DE', customerType: 'business', vatId: null }, 'DE'); + const totals = computeLines( + [ + { description: 'Hosting', quantity: 2, unitPriceNet: 10 }, + { description: 'Book', quantity: 1, unitPriceNet: 20, taxCategory: 'reduced' }, + ], + tax, + ); + + expect(totals.lines.map((line) => line.lineTax)).toEqual([3.8, 1.4]); + expect(totals.subtotalNet).toBe(40); + expect(totals.taxTotal).toBe(5.2); + expect(totals.totalGross).toBe(45.2); + }); +}); diff --git a/tools/demo-data/src/lib/decabill/decabill-tax.ts b/tools/demo-data/src/lib/decabill/decabill-tax.ts new file mode 100644 index 000000000..f02f9532a --- /dev/null +++ b/tools/demo-data/src/lib/decabill/decabill-tax.ts @@ -0,0 +1,141 @@ +import { roundMoney } from '../core/random'; + +export type TaxCategory = 'standard' | 'reduced' | 'custom'; + +export interface BuyerTaxProfile { + country: string; + customerType: 'business' | 'consumer'; + vatId: string | null; +} + +export interface ResolvedTax { + taxMode: string; + einvoiceCode: string; + standardRate: number; + reducedRate: number; + taxCountry: string; + note: string | null; +} + +const EU_COUNTRIES = new Set(['AT', 'BE', 'DE', 'DK', 'ES', 'FI', 'FR', 'IE', 'IT', 'NL', 'PL', 'PT', 'SE']); +const EU_B2C_RATES: Record = { + AT: [20, 10], + FR: [20, 5.5], + NL: [21, 9], + PL: [23, 8], +}; + +export function isEuCountry(country: string): boolean { + return EU_COUNTRIES.has(country); +} + +/** + * Simplified version of the backend tax resolution for an issuer in `issuerCountry` + * (domestic VAT, EU reverse charge, OSS for EU consumers, no VAT for third countries). + */ +export function resolveTax(buyer: BuyerTaxProfile, issuerCountry: string): ResolvedTax { + if (buyer.country === issuerCountry) { + return { + taxMode: 'domestic_vat', + einvoiceCode: 'S', + standardRate: 19, + reducedRate: 7, + taxCountry: issuerCountry, + note: null, + }; + } + + if (isEuCountry(buyer.country)) { + if (buyer.customerType === 'business' && buyer.vatId) { + return { + taxMode: 'eu_reverse_charge', + einvoiceCode: 'AE', + standardRate: 0, + reducedRate: 0, + taxCountry: buyer.country, + note: 'Reverse charge: VAT liability transfers to the recipient (Art. 196 VAT Directive).', + }; + } + + const [standardRate, reducedRate] = EU_B2C_RATES[buyer.country] ?? [19, 7]; + + return { + taxMode: 'eu_b2c_oss', + einvoiceCode: 'S', + standardRate, + reducedRate, + taxCountry: buyer.country, + note: 'VAT charged at the rate of the member state of consumption (OSS).', + }; + } + + return buyer.customerType === 'business' + ? { + taxMode: 'third_country_b2b_no_vat', + einvoiceCode: 'O', + standardRate: 0, + reducedRate: 0, + taxCountry: buyer.country, + note: 'Not taxable in Germany (place of supply abroad).', + } + : { + taxMode: 'third_country_b2c_no_domestic_vat', + einvoiceCode: 'O', + standardRate: 0, + reducedRate: 0, + taxCountry: buyer.country, + note: 'Not taxable in Germany (place of supply abroad).', + }; +} + +export interface LineInput { + description: string; + quantity: number; + unitPriceNet: number; + taxCategory?: TaxCategory; + unitLabel?: string; +} + +export interface ComputedLine extends Required> { + unitLabel?: string; + taxRate: number; + lineNet: number; + lineTax: number; + lineGross: number; +} + +export interface ComputedTotals { + lines: ComputedLine[]; + subtotalNet: number; + taxTotal: number; + totalGross: number; + resolvedRate: number; +} + +export function computeLines(lines: LineInput[], tax: ResolvedTax): ComputedTotals { + const computed = lines.map((line): ComputedLine => { + const taxCategory = line.taxCategory ?? 'standard'; + const taxRate = taxCategory === 'reduced' ? tax.reducedRate : tax.standardRate; + const lineNet = roundMoney(line.quantity * line.unitPriceNet); + const lineTax = roundMoney((lineNet * taxRate) / 100); + + return { + ...line, + taxCategory, + taxRate, + lineNet, + lineTax, + lineGross: roundMoney(lineNet + lineTax), + }; + }); + const subtotalNet = roundMoney(computed.reduce((sum, line) => sum + line.lineNet, 0)); + const taxTotal = roundMoney(computed.reduce((sum, line) => sum + line.lineTax, 0)); + + return { + lines: computed, + subtotalNet, + taxTotal, + totalGross: roundMoney(subtotalNet + taxTotal), + resolvedRate: computed[0]?.taxRate ?? tax.standardRate, + }; +} diff --git a/tools/demo-data/src/lib/decabill/decabill.seeder.ts b/tools/demo-data/src/lib/decabill/decabill.seeder.ts new file mode 100644 index 000000000..aa455ce34 --- /dev/null +++ b/tools/demo-data/src/lib/decabill/decabill.seeder.ts @@ -0,0 +1,174 @@ +import * as path from 'path'; + +import { ColumnEncryptor } from '../core/encryption'; +import { EnvValues, loadEnvFile, readBooleanEnv, readListEnv } from '../core/env-file'; +import { PasswordHasher } from '../core/passwords'; +import { PostgresContainer } from '../core/postgres-container'; +import { DemoRandom } from '../core/random'; +import { RowCollector } from '../core/row-collector'; +import { + applyScript, + buildDemoDeletes, + DemoDataSeeder, + DEMO_PASSWORD, + DEMO_TOTP_SECRET, + SeederContext, +} from '../core/seeder'; +import { SqlScript } from '../core/sql'; + +import { buildDatevAndOss, buildGlobalSnapshots, buildNotifications, buildSuppliers } from './decabill-backoffice'; +import { buildCatalog } from './decabill-catalog'; +import { DecabillCommerceBuilder } from './decabill-commerce'; +import { DECABILL_NUMBER_FUNCTIONS_SQL, DecabillTenantContext } from './decabill-context'; +import { buildUsers } from './decabill-customers'; +import { + DECABILL_DELETE_TARGETS, + DECABILL_INSERT_ORDER, + DECABILL_REQUIRED_TABLES, + DECABILL_SKIP_ON_CONFLICT, +} from './decabill.tables'; + +export const DECABILL_APP_DIR = 'apps/decabill/backend-billing-manager'; + +/** Tables with sequence-allocated numbers and the date their numbers should follow. */ +const DECABILL_NUMBERED_TABLES: [string, string][] = [ + ['billing_customer_profiles', 'created_at'], + ['billing_subscriptions', 'created_at'], + ['billing_offers', 'created_at'], + ['billing_invoices', 'issued_at'], + ['billing_supplier_profiles', 'created_at'], + ['billing_supplier_invoices', 'issued_at'], +]; +/** Values from docker-compose.yaml used when `.start-containers.env` leaves them empty. */ +const COMPOSE_DEFAULTS: EnvValues = { + DB_USERNAME: 'postgres', + DB_DATABASE: 'postgres', + TENANTS_ALLOW_DEFAULT: 'true', + TENANTS_SHARED_NUMBERS: 'true', + BILLING_ISSUER_COUNTRY: 'DE', +}; + +export interface DecabillSettings { + container: string; + user: string; + database: string; + tenants: string[]; + sharedNumbers: boolean; + issuerCountry: string; + encryptionKey: string | undefined; +} + +export function resolveDecabillSettings(workspaceRoot: string, env: NodeJS.ProcessEnv = process.env): DecabillSettings { + const appEnv = loadEnvFile(path.join(workspaceRoot, DECABILL_APP_DIR, '.start-containers.env'), COMPOSE_DEFAULTS); + const tenants = readListEnv(appEnv, 'TENANTS'); + + if (readBooleanEnv(appEnv, 'TENANTS_ALLOW_DEFAULT', true) && !tenants.includes('default')) { + tenants.unshift('default'); + } + + return { + container: env.DEMO_DATA_DECABILL_POSTGRES_CONTAINER || 'billing-manager-postgres', + // The compose postgres service always creates the `postgres` superuser; DB_DATABASE selects the app database. + user: appEnv.DB_USERNAME, + database: appEnv.DB_DATABASE, + tenants: tenants.length > 0 ? tenants : ['default'], + sharedNumbers: readBooleanEnv(appEnv, 'TENANTS_SHARED_NUMBERS', true), + issuerCountry: appEnv.BILLING_ISSUER_COUNTRY || 'DE', + encryptionKey: appEnv.ENCRYPTION_KEY, + }; +} + +export class DecabillSeeder implements DemoDataSeeder { + readonly product = 'decabill'; + + async seed(context: SeederContext): Promise { + const settings = resolveDecabillSettings(context.workspaceRoot); + const database = this.connect(context, settings); + const random = new DemoRandom(context.seed); + const rows = new RowCollector(DECABILL_INSERT_ORDER); + const encryptor = new ColumnEncryptor(settings.encryptionKey); + const hasher = new PasswordHasher(); + const datevCounters = { debtor: 1, creditor: 1 }; + const loginSummary: string[] = []; + + context.log(`Decabill: building demo data for tenants ${settings.tenants.join(', ')}`); + + for (const [index, tenantId] of settings.tenants.entries()) { + const tenantContext: DecabillTenantContext = { + tenantId, + numberScope: settings.sharedNumbers ? '__shared__' : tenantId, + issuerCountry: settings.issuerCountry, + emailDomain: `${tenantId}.decabill.example`, + random, + rows, + encryptor, + hasher, + datevCounters, + }; + const users = await buildUsers(tenantContext); + const catalog = buildCatalog(tenantContext); + + new DecabillCommerceBuilder(tenantContext, catalog, users.admins).build(users.customers); + buildSuppliers(tenantContext); + buildDatevAndOss(tenantContext); + buildNotifications( + tenantContext, + users.customers.map((customer) => customer.email), + ); + + if (index === 0) { + buildGlobalSnapshots(tenantContext); + } + + loginSummary.push(` ${tenantId}: admin@${tenantContext.emailDomain} (X-Tenant: ${tenantId})`); + } + + for (const [table, column] of DECABILL_NUMBERED_TABLES) { + rows.sortByDate(table, column); + } + + const script = new SqlScript(); + + script.comment('Remove previously seeded demo rows'); + buildDemoDeletes(DECABILL_DELETE_TARGETS).forEach((statement) => script.add(statement)); + script.comment('Number allocation helpers (session-local)'); + script.add(DECABILL_NUMBER_FUNCTIONS_SQL); + rows.writeTo(script, (table) => (DECABILL_SKIP_ON_CONFLICT.has(table) ? 'ON CONFLICT DO NOTHING' : undefined)); + + applyScript(context, database, script, 'decabill-seed.sql'); + context.log(`Decabill: ${rows.totalRows} rows across ${settings.tenants.length} tenants`); + context.log([`Decabill logins (password for all accounts: ${DEMO_PASSWORD}):`, ...loginSummary].join('\n')); + context.log(`Decabill TOTP secret for *-totp accounts: ${DEMO_TOTP_SECRET}`); + } + + async reset(context: SeederContext): Promise { + const settings = resolveDecabillSettings(context.workspaceRoot); + const database = this.connect(context, settings); + const script = new SqlScript(); + + buildDemoDeletes(DECABILL_DELETE_TARGETS).forEach((statement) => script.add(statement)); + applyScript(context, database, script, 'decabill-reset.sql'); + } + + private connect(context: SeederContext, settings: DecabillSettings): PostgresContainer { + const database = new PostgresContainer({ + container: settings.container, + user: settings.user, + database: settings.database, + }); + + if (!context.dryRun) { + database.assertRunning(); + const missing = database.findMissingTables(DECABILL_REQUIRED_TABLES); + + if (missing.length > 0) { + throw new Error( + `Decabill schema is incomplete (missing ${missing.join(', ')}). ` + + 'Wait until billing-manager-api has started and run its migrations.', + ); + } + } + + return database; + } +} diff --git a/tools/demo-data/src/lib/decabill/decabill.tables.ts b/tools/demo-data/src/lib/decabill/decabill.tables.ts new file mode 100644 index 000000000..ce4a76e4c --- /dev/null +++ b/tools/demo-data/src/lib/decabill/decabill.tables.ts @@ -0,0 +1,115 @@ +import { DemoDeleteTarget, demoIdCondition } from '../core/seeder'; + +/** FK-safe insert order; resets delete in reverse. */ +export const DECABILL_INSERT_ORDER = [ + 'users', + 'user_personal_access_tokens', + 'billing_customer_profiles', + 'billing_service_types', + 'billing_meters', + 'billing_service_type_meters', + 'billing_cloud_init_configs', + 'billing_addons', + 'billing_addon_meters', + 'billing_service_plans', + 'billing_service_plan_meters', + 'billing_promotions', + 'billing_provider_price_snapshots', + 'billing_availability_snapshots', + 'billing_projects', + 'billing_project_milestones', + 'billing_project_tickets', + 'billing_project_ticket_comments', + 'billing_project_ticket_activities', + 'billing_subscriptions', + 'billing_subscription_items', + 'billing_reserved_hostnames', + 'billing_subscription_addons', + 'billing_subscription_config_changes', + 'billing_usage_records', + 'billing_backorders', + 'billing_promotion_redemptions', + 'billing_offers', + 'billing_offer_line_items', + 'billing_invoices', + 'billing_invoice_line_items', + 'billing_invoice_promotion_applications', + 'billing_payment_attempts', + 'billing_payment_refunds', + 'billing_project_time_entries', + 'billing_open_positions', + 'billing_supplier_profiles', + 'billing_supplier_contracts', + 'billing_supplier_invoices', + 'billing_supplier_invoice_line_items', + 'billing_datev_debtor_accounts', + 'billing_datev_creditor_accounts', + 'billing_datev_exports', + 'billing_oss_threshold_ledgers', + 'billing_audit_logs', + 'webhook_endpoints', + 'webhook_deliveries', + 'email_deliveries', +] as const; + +/** Tables whose unique keys may collide with real data; conflicting demo rows are skipped. */ +export const DECABILL_SKIP_ON_CONFLICT = new Set([ + 'billing_datev_debtor_accounts', + 'billing_datev_creditor_accounts', + 'billing_datev_exports', + 'billing_oss_threshold_ledgers', +]); + +/** + * Rows the running app may have created for demo users or on demo catalog entries (orders, + * invoices, redemptions, …). They have regular ids and would otherwise block the RESTRICT / + * NO ACTION foreign keys when demo parents are deleted. + */ +const DECABILL_APP_CREATED_TARGETS: readonly DemoDeleteTarget[] = [ + { + table: 'billing_invoice_promotion_applications', + where: + 'redemption_id IN (SELECT id FROM billing_promotion_redemptions WHERE ' + + ['id', 'user_id', 'subscription_id', 'promotion_id'].map(demoIdCondition).join(' OR ') + + ')', + }, + { table: 'billing_promotion_redemptions', column: 'user_id' }, + { table: 'billing_promotion_redemptions', column: 'promotion_id' }, + { table: 'billing_subscription_addons', column: 'addon_id' }, + { table: 'billing_usage_records', column: 'meter_id' }, + { table: 'billing_usage_records', column: 'addon_id' }, + { table: 'billing_service_plan_meters', column: 'meter_id' }, + { table: 'billing_service_type_meters', column: 'meter_id' }, + { table: 'billing_addon_meters', column: 'meter_id' }, + { table: 'billing_open_positions', column: 'user_id' }, + { table: 'billing_invoices', column: 'user_id' }, + { table: 'billing_subscription_items', column: 'service_type_id' }, + { table: 'billing_subscriptions', column: 'user_id' }, + { table: 'billing_subscriptions', column: 'plan_id' }, + { table: 'billing_backorders', column: 'user_id' }, + { table: 'billing_projects', column: 'user_id' }, + { table: 'billing_offers', column: 'user_id' }, + { table: 'billing_customer_profiles', column: 'user_id' }, + { table: 'billing_datev_debtor_accounts', column: 'user_id' }, + { table: 'billing_supplier_invoices', column: 'supplier_id' }, + { table: 'billing_supplier_contracts', column: 'supplier_id' }, + { table: 'billing_datev_creditor_accounts', column: 'supplier_id' }, + { table: 'billing_audit_logs', column: 'user_id' }, +]; + +export const DECABILL_DELETE_TARGETS: readonly DemoDeleteTarget[] = [ + ...DECABILL_APP_CREATED_TARGETS, + ...[...DECABILL_INSERT_ORDER].reverse().map((table) => ({ table })), +]; + +/** Tables that must exist before seeding (i.e. the API container has run its migrations). */ +export const DECABILL_REQUIRED_TABLES = [ + 'users', + 'billing_subscriptions', + 'billing_invoices', + 'billing_offers', + 'billing_projects', + 'billing_supplier_invoices', + 'billing_stored_files', + 'webhook_endpoints', +]; diff --git a/tools/demo-data/src/types/bcrypt.d.ts b/tools/demo-data/src/types/bcrypt.d.ts new file mode 100644 index 000000000..305a63e16 --- /dev/null +++ b/tools/demo-data/src/types/bcrypt.d.ts @@ -0,0 +1,5 @@ +// The workspace ships `bcrypt` without `@types/bcrypt`; only the API used by the seeders is declared. +declare module 'bcrypt' { + export function hash(data: string, saltOrRounds: number): Promise; + export function compare(data: string, encrypted: string): Promise; +} diff --git a/tools/demo-data/tsconfig.json b/tools/demo-data/tsconfig.json new file mode 100644 index 000000000..766dfeee8 --- /dev/null +++ b/tools/demo-data/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "target": "es2019", + "strict": true + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/tools/demo-data/tsconfig.lib.json b/tools/demo-data/tsconfig.lib.json new file mode 100644 index 000000000..5e93a7e2e --- /dev/null +++ b/tools/demo-data/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"] +} diff --git a/tools/demo-data/tsconfig.spec.json b/tools/demo-data/tsconfig.spec.json new file mode 100644 index 000000000..f226b894b --- /dev/null +++ b/tools/demo-data/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 512b9b313..08eec78b6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,6 +47,7 @@ "libs/domains/identity/frontend/index.ts" ], "@forepath/ai": ["tools/ai/src/index.ts"], + "@forepath/demo-data": ["tools/demo-data/src/index.ts"], "@forepath/release-integrity": ["tools/release-integrity/src/index.ts"], "@forepath/sbom": ["tools/sbom/src/index.ts"], "@forepath/shared/backend": ["libs/domains/shared/backend/index.ts"],