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
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 · workerThe 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:
pnpm db:push --force-reset && pnpm db:seed1. Verify the migration applied (5 min)
The new aggregates exist as 11 new tables and the Product enum has two new variants.
pnpm db:studio # opens Prisma Studio at :5555Confirm in Studio:
- [ ]
clientstable hasfs_configandxbrl_configcolumns (JSON, nullable) - [ ] AURORA row has
enabled_products = [FINANCIAL_STATEMENTS],fs_configpopulated - [ ] STELLAR row has
enabled_products = [XBRL_FILING],xbrl_configpopulated - [ ]
fs_engagements,fs_trial_balance_items,fs_generated_statements,fs_notes,fs_disclosure_responsestables exist with seed data on AURORA's engagement - [ ]
xbrl_filings,xbrl_source_documents,xbrl_line_items,xbrl_tag_mappings,xbrl_validation_results,xbrl_output_artifactstables exist with seed data on STELLAR's two filings
Or via psql:
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.
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!).
| Role | Should see in sidebar | |
|---|---|---|
admin@spade.local | PLATFORM_ADMIN | All four products + Administration |
lead@spade.local | PAYROLL_LEAD | Payroll + Administration |
executive@spade.local | PAYROLL_EXECUTIVE | Payroll only |
bookkeeper@spade.local | BOOKKEEPER | Bookkeeping only |
senior@spade.local | SENIOR_ACCOUNTANT | Bookkeeping + Financial Statements + XBRL + Administration |
fs-preparer@spade.local | FS_PREPARER | Financial Statements only |
fs-reviewer@spade.local | FS_REVIEWER | Financial Statements only |
xbrl-preparer@spade.local | XBRL_PREPARER | Financial Statements (read-only) + XBRL Filing |
xbrl-reviewer@spade.local | XBRL_REVIEWER | Financial Statements (read-only) + XBRL Filing |
Steps:
- Open
http://localhost:3000/login - Sign in as each role one by one
- 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.
pnpm --filter @breezycorp/docs dev # docs at :5174Open 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_FILEDis checked only forXBRL_REVIEWERandPLATFORM_ADMIN— notSENIOR_ACCOUNTANT - [ ]
ADD_MAPPING_SYNONYMis checked only forXBRL_REVIEWER,SENIOR_ACCOUNTANT,PLATFORM_ADMIN
Also confirm the new status-machine pages render:
- [ ]
http://localhost:5174/reference/status-machines/fs-engagementshows the 7-state diagram - [ ]
http://localhost:5174/reference/status-machines/xbrl-filingshows 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.
pnpm --filter @breezycorp/financial-statements build
pnpm --filter @breezycorp/xbrl build
node --experimental-repl-awaitIn the REPL:
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'); // → undefined6. 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.
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 voidThis 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.
pnpm db:studio # → http://localhost:5555AURORA — Financial Statements:
- Open
clients→ AURORA. Confirmenabled_products = [FINANCIAL_STATEMENTS],fs_configpopulated. - Open
fs_engagements→ the AURORA FY2024 row. Notestatus = TB_MAPPED. - Open
fs_trial_balance_itemsfiltered by that engagement's id. Expect 13 rows, every row carrying ataxonomy_key. Notice the sign convention: assets and expenses positive, liabilities/equity/revenue negative. - Open
fs_generated_statements. Expect 1 row withversion_no = 1,is_balanced = true. Click intostatements_json— full nested BS / P&L structure. - Open
fs_disclosure_responses. Expect 5 rows for DISC-001 / DISC-002 / DISC-003.
STELLAR — XBRL Filing:
- Open
clients→ STELLAR. Confirmenabled_products = [XBRL_FILING],xbrl_configshowstaxonomy_version: acra_2026_v1,entity_type: NON_LISTED_COMPANY_FULL. - Open
xbrl_filings. Expect 2 rows. One inMAPPING_IN_PROGRESS, one inVALIDATION_FAILED. - Open
xbrl_line_itemsfiltered by the MAPPING_IN_PROGRESS filing. Expect 5 rows. - Open
xbrl_tag_mappingsfiltered by that filing. Expect 5 rows: 3 withconfirmed_by_idset, 2 withNULL. This is the row state the export gate refuses to release. - Open
xbrl_validation_resultsfiltered by the VALIDATION_FAILED filing. Expect 3 rows: 1 ERROR, 1 WARNING, 1 INFO. The ERROR row referencesBR_totalling_001and 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.
node --experimental-repl-awaitconst { 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.
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/engagementsand/dashboard/xbrl/filingswill 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:
pnpm db:push --force-reset && pnpm db:seedOr, more surgically, just the new aggregates:
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:seedRelated
- Architecture: XBRL & Financial Statements integration plan
- Status machines: FS engagement · XBRL filing
- Permissions: Roles & permissions · RBAC matrix