Skip to content

Manual testing — Financial Statements & XBRL Filing

How to manually verify the FS + XBRL modules in the current state of the monorepo (Phase 0 cross-cutting + Phase 1/2 foundations landed; service layer / worker handlers / Web UI for the new flows still to come).

IMPORTANT

The full operational surface (TB upload, mapping, statement generation, XBRL ingest, validation runs, exports) is not wired yet. What is testable today is: the schema, the constitution loaders, the status machines, the export gate, the RBAC matrix, and the dashboard sidebar visibility. Everything else has to be exercised through Prisma Studio / direct DB / Node REPL until the services land.


0. One-time setup

bash
pnpm install
docker compose up -d                       # postgres:17, minio, mailpit
cp .env.example .env                       # if you haven't already
pnpm db:generate                           # regenerate Prisma client
pnpm db:migrate                            # apply 20260509120000_add_fs_xbrl_aggregates
pnpm db:seed                               # 5 clients incl. AURORA + STELLAR
pnpm dev                                   # api :3001 · web :3000 · worker

The seed script's tail prints every URL, IDs, and credentials you need. Copy from there rather than guessing.

If you ever need to start clean:

bash
pnpm db:push --force-reset && pnpm db:seed

1. Verify the migration applied (5 min)

The new aggregates exist as 11 new tables and the Product enum has two new variants.

bash
pnpm db:studio                             # opens Prisma Studio at :5555

Confirm in Studio:

  • [ ] clients table has fs_config and xbrl_config columns (JSON, nullable)
  • [ ] AURORA row has enabled_products = [FINANCIAL_STATEMENTS], fs_config populated
  • [ ] STELLAR row has enabled_products = [XBRL_FILING], xbrl_config populated
  • [ ] fs_engagements, fs_trial_balance_items, fs_generated_statements, fs_notes, fs_disclosure_responses tables exist with seed data on AURORA's engagement
  • [ ] xbrl_filings, xbrl_source_documents, xbrl_line_items, xbrl_tag_mappings, xbrl_validation_results, xbrl_output_artifacts tables exist with seed data on STELLAR's two filings

Or via psql:

bash
docker exec -it breezycorp-monorepo-postgres-1 psql -U spade -d breezycorp -c "
  SELECT enumlabel FROM pg_enum WHERE enumtypid = 'Product'::regtype ORDER BY enumsortorder;
"
# Expect: PAYROLL, BOOKKEEPING, FINANCIAL_STATEMENTS, XBRL_FILING

docker exec -it breezycorp-monorepo-postgres-1 psql -U spade -d breezycorp -c "
  SELECT client_code, enabled_products FROM clients ORDER BY client_code;
"
# Expect 5 rows incl. AURORA → {FINANCIAL_STATEMENTS} and STELLAR → {XBRL_FILING}

2. Run the new package tests (1 min)

The constitution loaders, status machines, and export gate ship with 42 tests. Running them is the fastest signal that everything is wired up.

bash
pnpm --filter @breezycorp/financial-statements test    # 18 tests
pnpm --filter @breezycorp/xbrl test                    # 24 tests, including:
#   ✓ Rule 1 enforcer (no element literals outside taxonomy/)
#   ✓ Rule 4 (loadTaxonomy rejects unknown versions)
#   ✓ Rules 2 & 3 (export gate)

Both should report green.


3. Verify the dashboard sidebar respects the new roles (10 min)

The web app's nav shows the Financial Statements + XBRL sections only to roles that hold the matching VIEW action.

The seed script creates one staff user per role, all with the same dev password printed at the end of pnpm db:seed (default DevAdmin-Password-1!).

EmailRoleShould see in sidebar
admin@spade.localPLATFORM_ADMINAll four products + Administration
lead@spade.localPAYROLL_LEADPayroll + Administration
executive@spade.localPAYROLL_EXECUTIVEPayroll only
bookkeeper@spade.localBOOKKEEPERBookkeeping only
senior@spade.localSENIOR_ACCOUNTANTBookkeeping + Financial Statements + XBRL + Administration
fs-preparer@spade.localFS_PREPARERFinancial Statements only
fs-reviewer@spade.localFS_REVIEWERFinancial Statements only
xbrl-preparer@spade.localXBRL_PREPARERFinancial Statements (read-only) + XBRL Filing
xbrl-reviewer@spade.localXBRL_REVIEWERFinancial Statements (read-only) + XBRL Filing

Steps:

  1. Open http://localhost:3000/login
  2. Sign in as each role one by one
  3. After each login, eyeball /dashboard/: confirm the sidebar matches the table above

NOTE

Clicking the Financial Statements → Engagements or XBRL → Filings link will land on a 404 today — those page routes are part of the not-yet-built service layer. Sidebar visibility is what's being tested here.


4. Verify the RBAC matrix doc renders the new actions (5 min)

The RBAC matrix doc page is auto-generated from @breezycorp/auth/src/rbac.ts. After the migration, it should show 20 new actions and the 4 new roles.

bash
pnpm --filter @breezycorp/docs dev              # docs at :5174

Open http://localhost:5174/reference/rbac-matrix and confirm:

  • [ ] Action column includes UPLOAD_TRIAL_BALANCE, CONFIRM_FS_MAPPING, GENERATE_STATEMENTS, GENERATE_NOTES, EXPORT_FS_DOCUMENT, INGEST_XBRL_DOCUMENT, CONFIRM_TAG_MAPPING, RUN_XBRL_VALIDATION, EXPORT_BIZFINX, EXPORT_XBRL_XML, MARK_XBRL_FILED, ADD_MAPPING_SYNONYM, etc.
  • [ ] Role columns include FS_PREPARER, FS_REVIEWER, XBRL_PREPARER, XBRL_REVIEWER
  • [ ] MARK_XBRL_FILED is checked only for XBRL_REVIEWER and PLATFORM_ADMIN — not SENIOR_ACCOUNTANT
  • [ ] ADD_MAPPING_SYNONYM is checked only for XBRL_REVIEWER, SENIOR_ACCOUNTANT, PLATFORM_ADMIN

Also confirm the new status-machine pages render:

  • [ ] http://localhost:5174/reference/status-machines/fs-engagement shows the 7-state diagram
  • [ ] http://localhost:5174/reference/status-machines/xbrl-filing shows the 8-state diagram with the validation/mapping recovery loop

5. Exercise the constitution loaders in a Node REPL (5 min)

Quickest way to verify the constitution is loadable + the disclosure trigger DSL works.

bash
pnpm --filter @breezycorp/financial-statements build
pnpm --filter @breezycorp/xbrl build
node --experimental-repl-await

In the REPL:

js
const fs = await import('@breezycorp/financial-statements');
const xbrl = await import('@breezycorp/xbrl');

// FS taxonomy
fs.loadSfrsTaxonomy().meta.version;            // → "1.0"
fs.findTaxonomyItem('cash');                   // → { key: 'cash', section: 'current_assets', ... }
fs.itemsForSection('current_assets').length;   // → 5+ items

// FS disclosure rules
const rules = fs.loadDisclosureRules();
rules.length;                                  // → 23
fs.evaluateTriggerCondition('always', new Set());                      // → true
fs.evaluateTriggerCondition("tb_contains_any(['ppe'])", new Set());    // → false
fs.evaluateTriggerCondition("tb_contains_any(['ppe'])", new Set(['ppe'])); // → true

// FS gaps for an empty TB → only "always" rules fire
fs.evaluateDisclosureGaps(new Set()).map(r => r.rule_id);
// → ["DISC-001", "DISC-002", "DISC-003", ...]

// FS mandatory policies
fs.getMandatoryPolicy('income_tax').text.includes('Section 29');  // → true

// XBRL taxonomy (Rule 4: version is first-class)
const lookup = new xbrl.TaxonomyLookup('acra_2026_v1');
lookup.size();                                 // → 1871
lookup.byElementId('sg-as_TotalAssets')?.label; // → "TotalAssets"
lookup.minimumRequirementsForEntryPoint('FULL_XBRL').length;  // → 40+

// Rule 4 enforcement
try { xbrl.loadTaxonomy('acra_2099_v9'); } catch (e) { e.name; }
// → "XbrlUnknownTaxonomyVersionError"

// XBRL validation rules
xbrl.loadValidationRules('acra_2026_v1').rules.length;            // → 144
xbrl.rulesForEntryPoint('acra_2026_v1', 'FULL_XBRL').length;      // → 100+

// XBRL mapping library (exact match is case-insensitive)
xbrl.exactMatch('Total Assets')?.target_element_id;               // → "sg-as_TotalAssets" or similar
xbrl.exactMatch('TOTAL ASSETS')?.target_element_id;               // → same
xbrl.exactMatch('does-not-exist');                                 // → undefined

6. Exercise the export gate (Rules 2 & 3) in the REPL (3 min)

The gate is the load-bearing piece that makes the cursorrules non-negotiables enforceable. It's standalone and pure — easy to poke.

js
const xbrl = await import('@breezycorp/xbrl');

// Happy path
xbrl.assertExportAllowed({
  validationResults: [{ severity: 'INFO', passed: true }],
  tagMappings: [{ lineItemId: 'a', confirmedById: 'staff-1' }],
  hasLineItems: true,
});                                            // → returns void; no throw

// Rule 3 — unconfirmed mapping blocks export
try {
  xbrl.assertExportAllowed({
    validationResults: [{ severity: 'INFO', passed: true }],
    tagMappings: [
      { lineItemId: 'a', confirmedById: 'staff-1' },
      { lineItemId: 'b', confirmedById: null },
    ],
    hasLineItems: true,
  });
} catch (e) { e.reason; }                      // → "UNCONFIRMED_TAG_MAPPING"

// Rule 2 — ERROR-severity rule blocks export
try {
  xbrl.assertExportAllowed({
    validationResults: [
      { severity: 'WARNING', passed: false },
      { severity: 'ERROR', passed: false },
    ],
    tagMappings: [{ lineItemId: 'a', confirmedById: 'staff-1' }],
    hasLineItems: true,
  });
} catch (e) { e.reason; }                      // → "VALIDATION_ERROR_PRESENT"

// Warnings do NOT block — export proceeds even with WARNING-severity failures
xbrl.assertExportAllowed({
  validationResults: [{ severity: 'WARNING', passed: false }],
  tagMappings: [{ lineItemId: 'a', confirmedById: 'staff-1' }],
  hasLineItems: true,
});                                            // → returns void

This is the hard contract. When the export endpoints land, they call this same function — there is no override path.


7. Inspect the seed data via Prisma Studio (10 min)

The seed creates the data shape downstream services will operate on. Visiting the rows in Studio is the closest thing to "using" the modules today.

bash
pnpm db:studio                                 # → http://localhost:5555

AURORA — Financial Statements:

  1. Open clients → AURORA. Confirm enabled_products = [FINANCIAL_STATEMENTS], fs_config populated.
  2. Open fs_engagements → the AURORA FY2024 row. Note status = TB_MAPPED.
  3. Open fs_trial_balance_items filtered by that engagement's id. Expect 13 rows, every row carrying a taxonomy_key. Notice the sign convention: assets and expenses positive, liabilities/equity/revenue negative.
  4. Open fs_generated_statements. Expect 1 row with version_no = 1, is_balanced = true. Click into statements_json — full nested BS / P&L structure.
  5. Open fs_disclosure_responses. Expect 5 rows for DISC-001 / DISC-002 / DISC-003.

STELLAR — XBRL Filing:

  1. Open clients → STELLAR. Confirm enabled_products = [XBRL_FILING], xbrl_config shows taxonomy_version: acra_2026_v1, entity_type: NON_LISTED_COMPANY_FULL.
  2. Open xbrl_filings. Expect 2 rows. One in MAPPING_IN_PROGRESS, one in VALIDATION_FAILED.
  3. Open xbrl_line_items filtered by the MAPPING_IN_PROGRESS filing. Expect 5 rows.
  4. Open xbrl_tag_mappings filtered by that filing. Expect 5 rows: 3 with confirmed_by_id set, 2 with NULL. This is the row state the export gate refuses to release.
  5. Open xbrl_validation_results filtered by the VALIDATION_FAILED filing. Expect 3 rows: 1 ERROR, 1 WARNING, 1 INFO. The ERROR row references BR_totalling_001 and explains the deliberately-broken balance sheet.

8. Test the gate against real seed data (5 min)

Combine the seed with the gate function to confirm the pieces fit together end-to-end.

bash
node --experimental-repl-await
js
const { PrismaClient } = await import('@prisma/client');
const xbrl = await import('@breezycorp/xbrl');
const prisma = new PrismaClient();

// Find the MAPPING_IN_PROGRESS filing
const filing1 = await prisma.xbrlFiling.findFirst({
  where: { status: 'MAPPING_IN_PROGRESS' },
  include: { tagMappings: true, validationResults: true, lineItems: { select: { id: true } } },
});

try {
  xbrl.assertExportAllowed({
    validationResults: filing1.validationResults,
    tagMappings: filing1.tagMappings.map((m) => ({
      lineItemId: m.lineItemId,
      confirmedById: m.confirmedById,
    })),
    hasLineItems: filing1.lineItems.length > 0,
  });
} catch (e) { console.log(e.reason, '—', e.message); }
// → NO_VALIDATION_RUN — Validation has not been run for this filing.

// Now the VALIDATION_FAILED filing
const filing2 = await prisma.xbrlFiling.findFirst({
  where: { status: 'VALIDATION_FAILED' },
  include: { tagMappings: true, validationResults: true, lineItems: { select: { id: true } } },
});

try {
  xbrl.assertExportAllowed({
    validationResults: filing2.validationResults,
    tagMappings: filing2.tagMappings.map((m) => ({
      lineItemId: m.lineItemId,
      confirmedById: m.confirmedById,
    })),
    hasLineItems: filing2.lineItems.length > 0,
  });
} catch (e) { console.log(e.reason, '—', e.message); }
// → VALIDATION_ERROR_PRESENT — Export blocked: 1 validation error(s) must be resolved.

await prisma.$disconnect();

9. Round-trip a status transition (3 min)

Quick check that the FSMs accept the right transitions and reject the wrong ones.

js
const fs = await import('@breezycorp/financial-statements');
const xbrl = await import('@breezycorp/xbrl');
const { FsEngagementStatus, XbrlFilingStatus } = await import('@breezycorp/contracts');

// FS: TB_MAPPED → STATEMENTS_GENERATED is allowed
fs.canTransitionFsEngagement(FsEngagementStatus.TB_MAPPED, FsEngagementStatus.STATEMENTS_GENERATED);
// → true

// FS: TB_MAPPED → ARCHIVED is not (must go through STATEMENTS_GENERATED → NOTES_COMPLETE)
fs.canTransitionFsEngagement(FsEngagementStatus.TB_MAPPED, FsEngagementStatus.ARCHIVED);
// → false

// XBRL: VALIDATION_FAILED → MAPPING_IN_PROGRESS (recovery loop)
xbrl.canTransitionXbrlFiling(XbrlFilingStatus.VALIDATION_FAILED, XbrlFilingStatus.MAPPING_IN_PROGRESS);
// → true

// XBRL: DRAFT → EXPORTED is not (must pass through validation first)
xbrl.canTransitionXbrlFiling(XbrlFilingStatus.DRAFT, XbrlFilingStatus.EXPORTED);
// → false

// What's allowed from EXPORTED?
xbrl.getNextXbrlFilingStatuses(XbrlFilingStatus.EXPORTED);
// → ['FILED', 'MAPPING_IN_PROGRESS']

10. What's deliberately not testable yet

Setting expectations so nothing surprises you:

  • No FS / XBRL API endpoints. /ops/financial-statements/* and /ops/xbrl/* return 404. Service layer + Fastify route registration is the next chunk.
  • No FS / XBRL Web pages. The sidebar links to /dashboard/financial-statements/engagements and /dashboard/xbrl/filings will 404. Sidebar visibility (step 3) is the only Web behavior in this slice.
  • No worker handlers. No TB parser, no statement generator, no notes generator, no document ingestor, no validation engine, no exporters. The pg-boss queue won't have new handler names registered.
  • No portal routes. /portal/financial-statements/[token] and /portal/xbrl/[token] are not built.
  • No Claude integration. The AI mapping + notes prompts described in the integration plan are not yet implemented.

If you need to drive these forward, the integration plan at docs/reference/architecture/xbrl-finstatement-integration.md is the playbook — Phase 1 + Phase 2 sections list every service, handler, and route still to build.


11. Resetting between test runs

If a test mutated the seed data and you want to start fresh:

bash
pnpm db:push --force-reset && pnpm db:seed

Or, more surgically, just the new aggregates:

bash
docker exec -it breezycorp-monorepo-postgres-1 psql -U spade -d breezycorp -c "
  TRUNCATE
    fs_disclosure_responses, fs_notes, fs_generated_statements,
    fs_trial_balance_items, fs_engagements,
    xbrl_output_artifacts, xbrl_validation_results, xbrl_tag_mappings,
    xbrl_line_items, xbrl_source_documents, xbrl_filings
  RESTART IDENTITY CASCADE;
"
pnpm db:seed

Internal use only — BreezyCorp