Skip to content

Add multi-company support - #4

Open
Maziak2520 wants to merge 42 commits into
zajca:mainfrom
Maziak2520:feature/multi-company
Open

Maziak2520 wants to merge 42 commits into
zajca:mainfrom
Maziak2520:feature/multi-company

Conversation

@Maziak2520

Copy link
Copy Markdown

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

  • Design: docs/superpowers/specs/2026-05-24-multi-company-design.md (v2 approved)
  • Review feedback (v1, v2): same directory
  • Implementation plan: docs/superpowers/plans/2026-05-24-multi-company.md
  • Manual smoke test: docs/superpowers/manual-tests/multi-company.md

What changed

  • New companies table with 22 columns (identity, address, bank,
    presentation, audit). 17 keys lifted out of settings.
  • ~30 per-company tables gain company_id INTEGER NOT NULL REFERENCES companies(id) plus a covering index.
  • Composite FKs on five aggregation paths (invoice_items,
    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.
  • Backend routes restructured into a global tier
    (/api/v1/companies, /api/v1/ares, /audit-log, etc.) and a
    per-company tier under /api/v1/companies/{companyID}/…,
    resolved by a WithCompany middleware that also writes
    X-Company-Id for client-side race detection.
  • Every per-company repository, service, and handler gained
    companyID int64 parameters.
  • Frontend: rune-based currentCompany store, header dropdown
    switcher, refactored typed API client returning
    { data, submittedFor, respondedFor } for write actions so
    mid-flight switches surface a context-explaining Czech toast
    instead of a wrong-list redirect.
  • Migration 025 auto-creates the default company from existing
    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) retain UNIQUE(year) from older migrations.
Reads scope by company_id correctly; only multi-company UPSERTs
for the same year would collide. Documented in
docs/UPGRADING.md. Follow-up migration will extend the unique
constraint to (company_id, year).

Pre-existing test flake

TestRecurringInvoiceService_ProcessDue_DeactivatesAfterLastCycle
hardcodes 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

  • Migration tests: default company seeded from settings,
    identity keys stripped, non-identity preserved, fresh install
    produces empty companies, every per-company table has
    company_id, composite FK enforcement
  • Production-sized migration test (~5k invoices) completes
    under 30s with composite FK still enforced — measured 271ms
  • Repository leak detector: 62 exhaustive cross-company Get/List
    tests cover every per-company entity, all PASS
  • Integration test (tests/integration/multicompany_test.go):
    full end-to-end flow including delete protection and sequence
    collision, all 8 spec scenarios PASS
  • Frontend: store, header component, page refetch on switch
    via onCompanyChange() helper
  • Manual smoke test (see manual-tests/multi-company.md):
    onboarding, 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 launch
the migration creates a default company from settings; downgrade
is destructive for users with more than one company.

manana2520 added 30 commits May 24, 2026 11:35
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.
manana2520 added 12 commits May 24, 2026 20:36
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)
Maziak2520 referenced this pull request in Maziak2520/zfaktury May 26, 2026
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.
@zajca

zajca commented Jun 24, 2026

Copy link
Copy Markdown
Owner

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 --config file, data_dir, ZFAKTURY_DATA_DIR, and custom SQLite database paths. For managing multiple legal entities, separate config + separate data directory + separate DB per entity gives us strong isolation without turning the whole application into a multi-tenant system.

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 company_id forever. Any missed predicate or constraint becomes a cross-company data leak or corruption risk. That is a high maintenance burden for a self-hosted single-user app, especially when the desired workflow can be covered by isolated profiles.

The direction I would prefer is a first-class config/profile switcher instead:

  • keep each legal entity in its own config/data directory/database
  • add a small profile registry, e.g. zfaktury profile list/create/open
  • optionally add a desktop/UI switcher that restarts or reopens the app with the selected profile
  • document the multi-entity workflow around profiles rather than shared tables

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants