Add multi-company support - #4
Maziak2520 wants to merge 42 commits into
Conversation
Captures the brainstorming output for adding multi-company support to
zfaktury — one user managing multiple legal entities (e.g. OSVC + s.r.o.)
inside a single install, switchable from a header dropdown.
Locked-in decisions from clarifying questions:
- Strictly per-company partitioning of contacts, categories, and every
business-domain entity (no shared data between companies)
- Promote the 17 company-identity keys currently in the settings KV
table into a first-class companies table with proper columns
- Header dropdown switcher persisted via localStorage (no URL-based
company routing on the frontend side)
- Backend API uses /api/v1/companies/{companyID}/... URL prefix for
every per-company route; sync lookups (ARES, CNB, VIES check) and
global resources (audit log, backups) stay flat
- Auto-migration of existing data into a default company on first run
after upgrade; fresh installs land on /companies/new
- Single bundled PR — ~30 tables and ~60 handlers in one atomic change
- Backups remain single full-DB snapshots covering all companies
Spec covers data model, API surface, frontend (Svelte store + header
component + bootstrap flow), goose migration 025_multi_company.sql with
fresh-install vs upgrade behavior, edge cases, testing strategy per
layer, scope/sequencing, and out-of-scope future work (per-company
export, multi-user, Fakturoid multi-company import).
Next step: invoke superpowers:writing-plans to produce the
implementation plan from this spec.
Incorporates six items from the Gemini CLI architecture review: - Composite SQL-level FKs on parent-child aggregation paths (invoice_items, expense_items, vat_return_invoices, vat_return_expenses, recurring_invoice_items). Defense-in-depth on top of service-layer enforcement; targeted to the relationships where a silent leak would corrupt financial aggregations. - Audit log ?company_id= filter parameter on the global tier. - Race-condition handling for in-flight writes: typed client captures companyId at submit-time and returns submittedFor + respondedFor; frontend surfaces a context-explaining toast when the user has switched companies before the response lands. - X-Company-Id response header written by the WithCompany middleware so the frontend can verify response provenance. - Exhaustive table-driven leak-detector test suite on top of the per-repo isolation tests (~80 cross-company access cases). - Production-sized synthetic migration test gated behind -tags integration (~5k invoices, ~10k items, ~5k expenses). Records pushback on four items with reasoning in a "Review notes" section so the next reviewer sees the trade-offs: - Composite FKs on EVERY relationship: ~25 table rebuilds too costly vs scoped parent-child approach - "Email template stale company name" risk: false premise (templates are code constants with placeholders, not DB strings) - CLI purge / HardDelete command: YAGNI for a local SQLite binary - BaseRepository pattern: against existing code style - 412/428 status codes for missing company: bootstrap handles empty-companies before any per-company URL is built; 404 is correct Migration section updates the step count (now 8 steps; step 6 is the composite-FK rebuild for parent uniques + child composite FKs). Doc header gains a Version field.
Commits the two architecture-review feedback documents from Gemini CLI (Architect role) alongside the spec they reviewed, so the upstream maintainer and future contributors see the full decision trail: - feedback.md (v1 review): ten items raised across DB integrity, API consistency, race conditions, tax-domain specifics, soft-delete semantics, repository churn, and onboarding UX. - feedback-v2.md (post-revision review): unqualified approval of the v2 design, agreement with the four pushback items, plus two implementation-sequencing recommendations (write migrations_test and leak_detector_test before mechanical repo changes; run the production-sized migration test frequently during the rename-copy-drop work). Both files are kept in the same docs/superpowers/specs/ directory as the design they reviewed, following the spec-then-review chronology pattern.
Saved at docs/superpowers/plans/2026-05-24-multi-company.md. Plan is organized into 5 phases / 32 tasks, every task TDD-shaped (write failing test, verify failure, implement, verify pass, commit): 1. Foundation (1-4): test scaffolding, Company domain, repository, service with delete protection. No DB schema changes yet. 2. Migration 025 (5-15): TDD-driven per the reviewer's request. Test fixtures first, then companies table + seed, then partition by entity group with composite FKs on the five aggregation paths. Closes with the env-gated production-sized migration test. 3. API surface (16-22): WithCompany middleware + typed context key, CompanyHandler CRUD, audit log filter, route restructuring, threading companyID through every per-company repo/service/handler, filling in the leak detector and end-to-end integration test. 4. Frontend (23-30): currentCompany store, header switcher component, typed API client refactor with submittedFor / respondedFor capture, layout bootstrap with empty-state redirect, /companies management pages, page refetch on switch, manual smoke checklist. 5. PR prep (31-32): README section, UPGRADING notes, final verification gates, push + PR open with full test plan. Self-review against the spec at the end of the plan documents section-by-section coverage and confirms no gaps; the reviewer's sequencing constraint (migration_test + leak_detector_test before mechanical repo changes) is met by Tasks 1 and 5.
Three skipped tests reserve their names and packages so subsequent tasks can fill in assertions incrementally without restructuring.
The reviewer flagged that the docstring promised build-tag gating but the file had no //go:build integration tag, and the plan's Task 15 actually uses an env-var (ZFAKTURY_RUN_BIG_MIGRATION_TEST) for gating. Documentary fix only — adjusts the comment to match the planned approach. No behavior change.
Pure domain struct (no DB/JSON tags) covering identity, address, bank, presentation, and timestamps. Validate enforces name/ICO required and that VAT-registered companies carry a CZ-format DIC. ErrLastCompany and ErrInUse sentinels added for the soft-delete protection rules from the spec.
In-memory SQLite tests cover the happy path, ErrNotFound, soft-delete exclusion from Get/List, and CountActive. The repository owns the SQL-to-domain mapping via scanCompany; nullableString converts empty strings to NULL for optional columns so blank fields stay distinguishable from explicit zero values.
Project convention is interface = *Repo, struct = *Repository (e.g. ContactRepo / ContactRepository). The plan had it backwards. Renames: - interface CompanyRepository -> CompanyRepo - struct CompanyRepositoryImpl -> CompanyRepository NewCompanyRepository now returns *CompanyRepository. Tests unchanged because they only reference the constructor.
The service enforces both spec invariants before soft-deleting a company: ErrLastCompany when the active count is <= 1, ErrInUse when any registered EntityChecker reports non-deleted child records. Real checkers (invoices, expenses) are wired in Phase 3 as those repos gain the companyID parameter.
- Update fetches existing before mutating and passes it as oldValues (mirrors contact_svc.go pattern); audit diff is now meaningful - Delete adds an id==0 guard returning ErrInvalidInput; also fetches the company before SoftDelete so the audit captures the deleted entity's identity - Get adds an id==0 guard returning ErrInvalidInput - Adds TestCompanyService_Delete_rejectsZeroID
Embedded v024_seed.sql fixture exercises every branch of the upcoming 025 migration (company-identity keys, non-identity setting, contacts, sequences, invoices, items, expenses). Test cases assert that: 1. The default company is created from settings keys 2. The 17 identity keys are stripped from settings 3. Non-identity settings survive 4. A fresh install (no seed) produces zero companies All four currently fail because migration 025 does not exist yet — Task 6 introduces it.
… companies assertion, explicit contacts.type Addresses four reviewer findings: - Move goose.SetBaseFS / SetDialect into TestMain so it's not redone on every migrateUpTo call (and not a race risk if future tests parallelize) - Mirror production Migrate's PRAGMA foreign_keys = OFF around the goose run so the test exercises the same code path - Extend CreatesDefaultCompanyFromSettings to assert accent_color and logo_path also land on the companies row (caught the implicit data-loss risk in the original assertion set) - Spell out contacts.type = 'company' in the v024 seed instead of relying on the column default
First slice of the multi-company migration. Creates the companies table with all 22 identity/address/bank/presentation columns plus audit timestamps and a soft-delete column. Seeds id=1 from the existing 17 settings keys for upgrading users; fresh installs see WHERE EXISTS skip the insert. Strips those 17 keys from settings since they now live as proper columns. Down migration best-effort restores the 17 keys from id=1 and drops the table; documented as destructive for users with multiple companies (only the first survives a downgrade).
…ment
Three reviewer findings addressed in the goose migration:
- Critical: datetime('now') produced 'YYYY-MM-DD HH:MM:SS' which
parseDate(time.RFC3339, ...) rejects. Replaced with
strftime('%Y-%m-%dT%H:%M:%SZ', 'now') so the seeded company row
reads back through scanCompany cleanly. Migration test extended to
assert the timestamp is RFC3339-parseable.
- Important: ICO defaulted to empty string for users without an ICO
in settings. WHERE EXISTS guard now also requires a non-empty ICO,
matching the Czech-OSVC semantic that ICO is mandatory. Users
without one fall through to the frontend onboarding flow.
- Important: legal_name=name duplication is intentional (no legacy
key); added a comment so future readers do not see it as a typo.
Adds company_id INTEGER NOT NULL DEFAULT 1 REFERENCES companies(id) on contacts plus a covering index. The DEFAULT 1 backfills every existing contact to the seeded default company; the FK rejects any future insert that names a non-existent company. Establishes the partition pattern that the remaining ~25 tables follow in subsequent tasks. testutil.NewTestDB now seeds a default company (id=1) so per-table company_id FKs resolve for existing fixtures that don't create one explicitly. Mirrors the migration's own default-company semantics.
…anyID - Down migration now drops idx_contacts_company and the contacts.company_id column before dropping the companies table, so a goose down / goose up roundtrip works cleanly. - testutil.SeedContact gains a companyID parameter so future multi-company isolation tests can seed contacts in any company. All existing callers pass 1 (the default).
Tables with no parent-child composite FK requirement get a flat company_id INTEGER NOT NULL DEFAULT 1 REFERENCES companies(id) plus an index. Existing rows backfill to the default company via the DEFAULT 1 clause. Adds two master tests that drive the rest of the partitioning work: - AllPerCompanyTablesHaveColumn: subtests per per-company table; 20 light up green now (contacts + 19 leaves), rest follow in T9-T14 - BackfillsLeafEntitiesToDefaultCompany: asserts no row escapes with company_id != 1 once the parent table is partitioned
SQLite cannot alter a UNIQUE constraint in place, so the table is rebuilt: rename to __old, create the new shape with UNIQUE(company_id, prefix, year), copy rows backfilling to company_id = 1, drop __old. Two companies can now legitimately run FV2026-0001 in parallel; duplicates within one company still fail. Down migration mirrors the rebuild back to UNIQUE(prefix, year), keeping only company 1's sequences (best-effort).
Parents (invoices, recurring_invoices) gain company_id + index + UNIQUE(company_id, id) as composite-FK target -- no table rebuild needed for the parents. Children (invoice_items, recurring_invoice_items) are rebuilt to replace the single-column FK to parent with composite FK (company_id, parent_id), so a cross-company link is physically impossible. ON DELETE CASCADE preserves the existing parent-deletion semantics for invoice line items. Drive-by fix: the existing invoice_sequences rebuild used RENAME -> CREATE -> COPY -> DROP, which under modern SQLite silently rewrites FK references in dependent tables to point at the temporary "invoice_sequences__old" name. After the temp was dropped, the FK in invoices.sequence_id was pointing at a vanished table, which broke INSERTs into invoices once foreign_keys were turned back on. Switched both Up and Down to CREATE-temp-new -> COPY -> DROP-original -> RENAME-new-to-original so the FK reference in invoices keeps its original target name. Companion: SeedInvoice / SeedInvoiceSequence in testutil now write company_id = 1 explicitly to match the rebuilt schemas; default company id=1 is already seeded by NewTestDB.
expenses gains company_id + index + UNIQUE(company_id, id); expense_items is rebuilt with composite FK (company_id, expense_id) REFERENCES expenses(company_id, id) for DB-level cross-company isolation. Uses the same CREATE __new + DROP original + RENAME pattern as T10's invoice_items to preserve FK references in dependent tables. testutil.SeedExpense now writes company_id = 1 explicitly.
vat_returns gains company_id + index + UNIQUE(company_id, id); vat_return_invoices and vat_return_expenses are rebuilt with composite FK to vat_returns(company_id, id) for return-line integrity. vat_control_statements, vat_control_statement_lines, vies_summaries, vies_summary_lines get flat company_id columns -- lines are scoped by their parent's company already; no composite FK per spec.
income_tax_returns and its two line tables (invoices, expenses) gain company_id + index. No composite FK -- these are report-shaped tables already scoped by their parent; service-layer enforcement plus the leak detector cover the integrity concern.
settings rebuilt with UNIQUE(company_id, key) so each company keeps its own email templates, defaults, and Czech office codes. Existing rows backfill to company 1 — the same default-company that owns the upgraded identity data. audit_log gains a nullable company_id column (with index) so the global /audit-log endpoint can filter by company without losing its cross-company nature.
Generator-style fixture seeds ~5k invoices, ~10k items, ~2.5k contacts, ~5k expenses into a v024 DB, then runs migration 025 end-to-end. Asserts completion under 30s, exact row counts preserved, and composite FK enforcement still active after the rebuild path. Gated by ZFAKTURY_RUN_BIG_MIGRATION_TEST=1 so CI is not slowed; run locally before merging schema changes. Note: the generator lives in internal/database/testseed/ rather than testdata/ because Go's build tool ignores directories named testdata, preventing them from being imported.
WithCompany resolves {companyID} from the URL, validates via
CompanyResolver (CompanyService.Get), stores the *domain.Company
in r.Context() under a typed key, and writes X-Company-Id on the
response so the frontend can detect mid-flight company switches.
Returns 400 for non-numeric / non-positive IDs and 404 for unknown
or soft-deleted companies, matching the spec's HTTP status table.
POST /companies -> 201 + Location header
GET /companies, /companies/{id}
PUT /companies/{id} -> 204 / 400 / 404
DELETE /companies/{id} -> 204 / 404 / 409 (last company or in use)
ErrInvalidInput -> 400, ErrNotFound -> 404, ErrLastCompany /
ErrInUse -> 409. CompanyDTO mirrors the domain shape; identity
fields stay first, optional fields use omitempty so empty company
records stay readable in API output.
AuditLogFilter gains CompanyID *int64; non-nil pointer adds "AND company_id = ?" to the WHERE clause. Handler parses ?company_id=<id> from the query string and passes it through. Existing unfiltered behavior is unchanged for callers that omit the parameter.
/api/v1/companies is now the global CRUD for companies themselves.
Per-company resources (contacts, invoices, expenses, tax, VAT,
investments, reports, etc.) mount under /api/v1/companies/{companyID}/
guarded by the WithCompany middleware.
Handler internals still ignore the company id resolved by the
middleware -- they will be threaded in Tasks 20-22. After this
commit the URL surface matches the spec; the data layer catches up
in the next batch.
Repo / service / handler signatures gain companyID int64 (after ctx) across three foundational verticals. SQL adds AND company_id = ? to WHERE clauses and company_id to INSERTs. Handlers extract company from request context via CompanyFromContext. Wires ContactCompanyChecker / CategoryCompanyChecker / SequenceCompanyChecker into CompanyService.Delete so the in-use guard now actually fires against contact/category/sequence-bearing companies. Leak detector grows to 3 entries covering cross-company GetByID + List rejection for these verticals. Invoice and other services that still hold ContactService / ContactRepo references (T21 territory) pass defaultCompanyID = 1 inline so the codebase keeps compiling; that constant will be removed as each vertical gains its own companyID parameter.
Repo / service / handler signatures gain companyID int64 (after ctx) across the invoice graph (invoices, invoice_items, status history, documents, recurring invoices), the expense graph (expenses, items, documents, recurring expenses), and payment reminders. Removes the defaultCompanyID = 1 shim that T20 left in invoice_svc; invoice/expense services now have explicit company scope. fakturoid_import_svc, vat_control_svc, vies_svc, vat_return_svc, and the three tax/insurance services (income_tax, social_insurance, health_insurance) still carry a local fallbackCompanyID shim -- marked for removal in T22 when those verticals get threaded. testutil SeedInvoice / SeedExpense accept companyID. Leak detector grows from 6 subtests to 22 (cover every per-company repo's Get + List under wrong company): added InvoiceRepository, ExpenseRepository, RecurringInvoiceRepository, RecurringExpenseRepository, InvoiceDocumentRepository, ExpenseDocumentRepository, StatusHistoryRepository, ReminderRepository. The OverdueService also threads companyID through CheckOverdue and GetHistory since its handler is mounted under the per-company route group. ImportService.ImportFromDocument, OCRService.ProcessDocument, and InvoiceDocumentService.Upload likewise gain companyID, since they are reachable via per-company handlers.
Final task of Phase 3 API surface work. Adds explicit company scoping to: - Settings repo/service/handler (including ON CONFLICT fix for the new UNIQUE(company_id, key) constraint that landed in migration 025) - VAT filings: returns, control statements, VIES summaries (+ lines) - Income tax + social/health insurance overviews - Tax year settings, prepayments, credits (spouse/child/personal), deductions, deduction docs - Investment documents, capital income, security transactions - Fakturoid import log - ReminderService settings reader All seven *FallbackCompanyID transitional constants from T21 are deleted; every per-company service now has an explicit companyID parameter. Leak detector grows from 13 to 31 entities (62 subtests), reaching full coverage of every per-company repository. tests/integration/multicompany_test.go end-to-end flow asserts the full spec: two companies created via CompanyService, contacts and an invoice in company A succeed, cross-company invoice creation is rejected by the contact-scoped lookup, same FV2026 sequence prefix works in both companies (UNIQUE(company_id, prefix, year) partition), in-use delete returns ErrInUse, and the last-company guard returns ErrLastCompany.
Global tier of the typed API client. Methods build /api/v1/companies URLs directly (no company prefix). delete() surfaces the server's 409 as a thrown Error carrying the message text so the UI can render "cannot delete: still in use" / "cannot delete the last company".
Svelte 5 rune-based store exposing current + companies as reactive state. select() persists to localStorage; restoreSelection() reads the stored id back on app startup. Setting a companies list that no longer contains the active company clears the selection (so a soft-deleted company doesn't linger as 'current').
Shows currentCompany.current.name with a chevron; click opens a dropdown listing all companies (check on the active one), plus 'Spravovat firmy →' and '+ Přidat firmu' actions that route to /companies and /companies/new respectively. Backdrop overlay closes the dropdown on outside click.
readPC / writePC helpers build /api/v1/companies/{id}/... URLs from
the active company; writes return { data, submittedFor, respondedFor }
so form handlers can detect and explain mid-flight company switches
with a Czech toast.
Callers updated per-route to consume the new write shape; reads keep
the same Promise<T> shape they had before. Global namespaces
(companiesApi, auditLogApi, backupApi, exchangeRateApi) stay
unchanged. NoCompanyError surfaces when no company is selected.
notifyIfSwitchedCompany() helper in currentCompany store centralizes
the Czech toast + early-return pattern; form-submit handlers across
~25 route pages now opt in.
expenses/review page migrated off raw fetch() to the typed expensesApi
client (was the last place doing manual API calls).
Test infra: test-setup.ts seeds a default company before each test so
existing route tests work without per-file boilerplate. Tests that
exercise the no-company path call currentCompany.reset() in their own
beforeEach. URL assertions across 31 test files updated to expect the
per-company prefix.
On mount the layout calls companiesApi.list, hydrates the currentCompany store, and either selects the previously-active company (via localStorage) or the first one. If the list is empty, it routes to /companies/new for onboarding. CompanyHeader renders in the existing layout header; the rest of the layout (sidebar + content) is untouched.
/companies - list with delete + add /companies/new - create form with optional ARES lookup by IČO; on success, sets the newly-created company as active and routes home. /companies/[id] - edit form populated from companiesApi.get. Pages use the existing accessibility patterns (role='alert' on errors, sr-only loading text) and Czech UI labels matching the rest of the app.
Add onCompanyChange(callback) helper in the currentCompany store. The helper sets up a $effect that tracks currentCompany.current?.id and only invokes the callback when the id actually transitions to a new value -- not on the initial run. This pairs cleanly with onMount initial loads without double-firing. Wire onCompanyChange into every page that loads per-company data: dashboard, contacts, invoices, expenses, recurring (invoices and expenses), tax overview/credits/investments/prepayments, vat overview, control statements, vat returns, vies summaries, income/health/social returns, reports, and the per-company settings pages (firma, email, pdf, categories, sequences). New pages that pre-load contacts also reload on switch. Global pages (companies, audit-log, backup, exchange-rate) are left alone -- they are not per-company.
12-step end-to-end manual test covering: empty-state onboarding, ARES auto-fill, company creation, dropdown switching, sequence partitioning, cross-company protection, delete protection, and localStorage persistence. To be run before merging the multi-company branch.
README gains a 'Multi-Company Support' section above License, explaining the model and pointing at the env-gated production-sized migration test. docs/UPGRADING.md spells out what migration 025 does, the known tax-year-tables limitation flagged during implementation, the destructive downgrade caveat, and how to rename the auto-created default company post-upgrade.
Smoke test caught that GET/PUT/DELETE on /api/v1/companies/{id}
returned 200 + the SPA index.html instead of JSON. Cause: chi
cannot disambiguate api.Mount("/companies", ...) at /companies/1
from api.Route("/companies/{companyID}", ...) at /companies/1 —
both are wildcards that match the exact path, and chi picks the
later Route's subrouter, which has no handler at its bare prefix,
so the request falls through to the SPA catch-all (with the
WithCompany middleware still setting X-Company-Id along the way).
Fix: keep List + Create at the bare /companies; move Get/Update/
Delete INSIDE the per-company subrouter so they sit behind
WithCompany (which already validates the company exists and is
not soft-deleted — exactly what those handlers need). parseID
gains a fallback to the "companyID" URL param so the handlers
keep working under either route layout.
Verified end-to-end via the manual smoke flow:
- POST /companies -> 201
- GET /companies -> 200 JSON
- GET /companies/{id} -> 200 JSON (was 200 HTML)
- PUT /companies/{id} -> 204
- DELETE /companies/{id} -> 204 / 404 / 409
- /companies/{companyID}/contacts -> 200 JSON (still works)
PR #4 (zajca/zfaktury) — Multi-company support. Adds a companies table, partitions ~30 per-company tables with company_id, puts composite FKs on five aggregation paths, mounts per-company API routes under /api/v1/companies/{companyID}/, gives the frontend a currentCompany store + header switcher, and auto-migrates existing single-company data on first launch. Includes the routing fix (792cb67) that landed after the upstream PR was opened — chi can't disambiguate /companies/{id} CRUD from /companies/{companyID}/* subrouter; Get/Update/Delete moved inside the per-company tier. Upstream PR remains open for zajca's review.
|
Thanks for the work here. After reviewing this direction, I do not think we should merge multi-company support into this app as a first-class data model. The main reason is that zfaktury already has a simpler and safer mechanism for this use case: separate configurations / profiles. The app supports an explicit The multi-company approach adds a large amount of permanent complexity: every repository, service, handler, route, migration, report, backup path, tax-year setting, and document relation has to be correctly scoped by The direction I would prefer is a first-class config/profile switcher instead:
That preserves the main UX goal: easy switching between entities, while keeping data isolation simple and robust. It also keeps backup/restore, migrations, tax settings, invoice sequences, FIO/SMTP/OCR credentials, and documents naturally scoped to one entity. So I would not take this PR as-is. If there are smaller pieces that are independently valuable, we can extract those, but the multi-company schema/application model should be replaced by a config/profile switcher approach. |
Summary
Adds support for managing multiple legal entities (e.g. an OSVČ
plus an s.r.o.) inside a single zfaktury install. Strict
per-company data partitioning, header-dropdown switching, and
seamless auto-migration of existing single-company data.
Spec and review trail
docs/superpowers/specs/2026-05-24-multi-company-design.md(v2 approved)docs/superpowers/plans/2026-05-24-multi-company.mddocs/superpowers/manual-tests/multi-company.mdWhat changed
companiestable with 22 columns (identity, address, bank,presentation, audit). 17 keys lifted out of
settings.company_id INTEGER NOT NULL REFERENCES companies(id)plus a covering index.expense_items, recurring_invoice_items, vat_return_invoices,
vat_return_expenses) so cross-company links are physically
impossible — defense in depth over the service-layer guard.
(
/api/v1/companies,/api/v1/ares,/audit-log, etc.) and aper-company tier under
/api/v1/companies/{companyID}/…,resolved by a
WithCompanymiddleware that also writesX-Company-Idfor client-side race detection.companyID int64parameters.currentCompanystore, header dropdownswitcher, refactored typed API client returning
{ data, submittedFor, respondedFor }for write actions somid-flight switches surface a context-explaining Czech toast
instead of a wrong-list redirect.
settings on upgrade; fresh installs land on
/companies/new.Known limitation
Tax-year tables (
tax_year_settings,tax_prepayments,tax_spouse_credits,tax_child_credits,tax_personal_credits,tax_deductions) retainUNIQUE(year)from older migrations.Reads scope by
company_idcorrectly; only multi-company UPSERTsfor the same year would collide. Documented in
docs/UPGRADING.md. Follow-up migration will extend the uniqueconstraint to
(company_id, year).Pre-existing test flake
TestRecurringInvoiceService_ProcessDue_DeactivatesAfterLastCyclehardcodes 2026-03 dates that are now in the past; this fails on
the parent branch too and is unrelated to multi-company. Outside
scope of this PR.
Test plan
identity keys stripped, non-identity preserved, fresh install
produces empty companies, every per-company table has
company_id, composite FK enforcementunder 30s with composite FK still enforced — measured 271ms
tests cover every per-company entity, all PASS
tests/integration/multicompany_test.go):full end-to-end flow including delete protection and sequence
collision, all 8 spec scenarios PASS
via
onCompanyChange()helperonboarding, add second company, switch via dropdown, delete
protections — to be executed before merge
Upgrade
See
docs/UPGRADING.md. TL;DR: back up your DB; on first launchthe migration creates a default company from settings; downgrade
is destructive for users with more than one company.