Skip to content

Sync fork: multi-company, flexible invoice sequences, and recurring-invoice auto-send - #7

Open
Maziak2520 wants to merge 92 commits into
zajca:mainfrom
Maziak2520:main
Open

Maziak2520 wants to merge 92 commits into
zajca:mainfrom
Maziak2520:main

Conversation

@Maziak2520

Copy link
Copy Markdown

Full sync of the Maziak2520 fork's main into upstream. Cumulative — it includes (and overlaps) the previously opened per-feature PRs #2 (Docker), #4 (multi-company), and #5 (sequence UX), plus the new recurring-invoice work from this round.

Major areas

Multi-company support (#4)
Per-company scoping across contacts, invoices, expenses, sequences, dashboard, reports and audit log; a global companies registry; active-company context.

Flexible invoice-number sequences + picker (#5, #6)
Configurable {prefix}{year}{number} format patterns (incl. short years), a Go + TS format renderer, partial-unique index, and a sequence picker on the new-invoice form.

Recurring-invoice scheduler with auto-send (new this round)

  • Daily stdlib scheduler (config [scheduler] enabled/hour) that generates due recurring invoices for every company.
  • Per-template auto-send, opt-in (default on for new templates), recipient = override or the customer's email.
  • Decoupled generate → sweep model: generation only creates drafts; a sweep then emails every still-unsent invoice of an auto-send template and marks it sent, so failures auto-retry on the next run.
  • Recurring templates can pick which sequence their invoices use (recurring_invoices.sequence_id); generated invoices link back via invoices.recurring_invoice_id.
  • Reusable InvoiceEmailService shared by the manual send-email handler and the scheduler.
  • Migrations 027–030.

Per-company invoice numbering
invoice_number is now unique per (company_id, invoice_number) instead of globally, so different companies may share a number (migration 028).

CI baseline (#10)
prettier/gofmt/errcheck fixes, an eslint preserve-caught-error fix, a time-relative test, and pinned golangci-lint to v2.12.2 so the pipeline stops breaking on linter drift.

Verification

CGO_ENABLED=0 go test -tags server ./..., golangci-lint run --build-tags server (0 issues), and the frontend check/lint/format:check/test suite all pass on main.

manana2520 added 30 commits May 24, 2026 09:21
The server bound to 127.0.0.1 unconditionally, which is correct for the
desktop build but prevents the server-only binary from being reachable
when run inside a container with port mapping.

- Add Server.Host to config (TOML key "host"), default 127.0.0.1
- Add --host flag to the serve command (overrides config when set)
- Use the configured host in the bind address

Behavior is unchanged for desktop and local CLI use; container images
can opt in with --host 0.0.0.0.
Multi-stage build producing a ~33 MB Alpine-based image:
- node:22-alpine builds the SvelteKit frontend
- golang:1.25-alpine builds the server binary (CGO off, server tag)
- alpine:3.20 runtime with ca-certificates and tzdata

Runtime defaults to a non-root user (UID/GID 1000:1000) and binds the
SQLite data directory at /data via the ZFAKTURY_DATA_DIR env var.
Host-specific UID overrides belong in the deployer's compose file
(docker run --user / compose user: field), not the image.

.dockerignore excludes VCS, agent metadata, secrets, the rust sibling
project, and built artifacts so the build context stays small.
Editing credentials by docker-execing into the container is awkward, but
baking secrets into the image is worse: /data is a VOLUME so the bake is
shadowed at runtime anyway, secrets leak via docker save/history, and
every credential change forces an image rebuild.

The pattern is now:
- config.toml.example: committed template documenting every section
  (server, database, log, smtp, fio, backup, backup.s3, ocr)
- config.toml: lives at the repo root, gitignored and dockerignored,
  edited like any other source file
- deploy/docker-compose.yml: example compose mounting that file at
  /data/.zfaktury/config.toml inside the container
- README: short "Docker" section pointing users at the workflow

Apply config changes with `docker restart zfaktury`; no rebuild needed.
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.
Maziak2520 and others added 30 commits May 27, 2026 14:40
Allow short years (26) in sequences create form
The delete handler awaited loadSequences() to refresh the list, but in
practice users reported the row remained visible until a browser refresh.
Filtering the deleted id out of the local 'sequences' state synchronously
guarantees the UI updates regardless of how the reload behaves. The
follow-up loadSequences still runs to stay in sync with the server.

The existing delete test only asserted the DELETE call was made; extend
it to verify the row actually disappears from the DOM.
Refresh sequences UI immediately after delete
The table-level UNIQUE(company_id, prefix, year) counted soft-deleted
rows, so deleting a sequence permanently locked that (prefix, year)
slot. The service-layer uniqueness check correctly ignores soft-deleted
rows, but the DB constraint then blocked the INSERT with a confusing
error.

Migration 026 rebuilds invoice_sequences without the table constraint
and adds a partial unique index that only enforces uniqueness for rows
where deleted_at IS NULL. Existing soft-deleted rows stay in the table
for audit and never collide with new active sequences.

Test: TestSequenceService_Create_AfterSoftDelete covers the exact flow
that was broken in production.
Allow recreating sequence after soft-delete (partial unique index)
Migration 025 moved company identity/address/bank fields from the
settings KV table onto columns on the companies table, but the
backend consumers (PDF, ISDOC, email, QR fallback, VAT-return XML)
were never updated and still read empty values from the deleted
settings keys. The /settings/firma page mirrored the same broken
assumption — it tried to read/write fields that no longer existed
in settings, so it was empty for every company.

Backend
* invoice_handler.go: drop loadPDFSupplierInfo/loadSupplierInfo;
  use new supplierFromCompany / isdocSupplierFromCompany helpers
  fed by CompanyFromContext.
* email_handler.go: same helpers; drop the now-unused settings
  GetAll call for supplier info.
* pdf_settings_handler.go: preview supplier from active company.
* vat_return_svc.go: take a CompanyRepo; build TaxpayerInfo from
  companies (identity/address/contact) + settings (tax-office codes
  only).
* Tests: new injectTestCompanyFromDB middleware that loads the
  seeded company row on every request, so identity changes flow to
  handlers under test. VAT-return tests updated to seed DIC via
  UPDATE companies.

Frontend
* /settings/firma now reads/writes the currently active company
  via companiesApi + the small set of tax-office codes via
  settingsApi. The CompanyEditForm component grew a hideActions
  prop so the parent can host a larger form around it.
* Layout sidebar hides the Firmy/Nová firma entries when only one
  company exists; multi-company users still see them.
* Tests updated for the new layout and the new firma page flow.
…tion

Unify firma + companies (PDF/email/ISDOC/VAT now use companies table)
The invoice service auto-creates an FV/ZF/DN sequence when one isn't
provided on create, regardless of what sequences already exist for the
company. This silently bypassed user-curated sequences (e.g. when a
migrated company already has a 77-26-XXX series) — invoices ended up
on a freshly-spawned FV/<year> while the user expected their own.

Add a sequence dropdown to /invoices/new:
- Lists all sequences for the active company with their next preview.
- Default pick: same year + matching prefix hint (FV/ZF/DN by type),
  falling back to same year any prefix, then to the first sequence.
- Year/type changes re-pick automatically until the user overrides.
- If no sequence exists, show an inline warning linking to
  /settings/sequences. Submit is blocked until one exists.

loadSequences guards against non-array API responses defensively, so a
mock returning {data:[...],total:N} can't crash the picker effect.

The backend service still auto-creates as a safety net for API-direct
consumers; the UI now always sends a concrete sequence_id.
Migration 025 partitioned all per-company tables but the Dashboard and
Reports repositories were never updated to filter by company_id, so
their aggregate queries (revenue, expenses, unpaid, overdue, recent
invoices/expenses, monthly/quarterly/yearly totals, top customers,
profit/loss) sweep across every company in the DB. The user noticed
that Dashboard and Přehledy showed combined totals while every other
sidebar item correctly scoped to the active company.

Backend
* dashboard_repo.go, report_repo.go: every query now takes a companyID
  parameter and adds AND company_id = ? to its WHERE clause.
* DashboardRepo / ReportRepo interface signatures updated to match.
* dashboard_svc.go GetDashboard and report_svc.go RevenueReport /
  ExpenseReport / TopCustomers / ProfitLoss take companyID and plumb
  it to the repo.
* dashboard_handler.go and report_handler.go pull the company from
  CompanyFromContext (the routes are already mounted under
  WithCompany, so the middleware loads it).
* All 135 test-call sites in the four affected test files updated to
  pass companyID = 1 (the default test company).

Frontend
* auditLogApi.list() now appends company_id = currentCompany.current.id
  to every request so the page only shows audit entries for the active
  company. The API itself stays cross-company at the route level
  (admin/diagnostic view) — this is a UI-side default scope.
* /settings/audit-log subscribes to onCompanyChange and re-fetches
  with offset reset so the table refreshes after switching companies.

Side effects: none. All other sidebar items already filter correctly
by company_id (verified by grepping every per-company repo).
Scope Dashboard / Reports / Audit-log to active company
Two fixes:

1. /api/v1/companies/{id}/invoices/{id}/send-email used to send the
   email and return {status:"sent"}, but the returned status referred
   only to the email — the invoice itself stayed in draft. The user
   noticed an invoice still showed Koncept after a manual send (and
   the same applies to any future automated send-email flows).
   After a successful send the handler now calls invoiceSvc.MarkAsSent
   when the invoice is still in draft. Already-sent/paid/cancelled
   invoices keep their status so re-emailing doesn't regress them.

2. Audit log entries were written without company_id, so the per-
   company filter we added on /settings/audit-log couldn't surface
   anything. Introduce internal/companyctx, a small package that
   stashes/extracts the active company id on the request context.
   WithCompany middleware now stores it alongside the *domain.Company,
   and AuditService.Log reads it back when recording each entry, so
   audit_log.company_id is populated automatically for every action
   under a per-company route. System-level actions (no active company)
   leave it NULL, matching the existing filter semantics.
…-company

Mark draft invoices as sent after email + audit log company_id
main's CI was red from tooling drift independent of any feature work:

- prettier: reformat all files flagged by `prettier --check .`
- client.ts: attach cause to the re-thrown delete() error (preserve-caught-error)
- company_repo: check rows.Close() error (errcheck)
- tax_child_credit_repo: gofmt
- ci.yml: pin golangci-lint to v2.12.2 (was `latest`) so a new linter
  release can't break CI again without an explicit bump

No behavior changes. Go (build, golangci-lint --build-tags server) and
frontend (lint, format:check, check, test) all pass.
The test hardcoded March 2026 dates and relied on time.Now(); once the wall
clock passed the end date it deactivated instead of generating (count=0).
Use dates relative to now so it is deterministic.
chore: fix CI baseline (prettier, gofmt, errcheck, pin golangci-lint)
Recurring invoices previously only generated when a user clicked "process
due", and never emailed -- so a recurring setup produced nothing and no mail
went out. This adds:

- A daily stdlib scheduler (config [scheduler] enabled/hour) that generates
  due recurring invoices for every company and, for templates that opt in,
  emails the generated invoice to the customer (or an override recipient).
  Auto-send is best-effort: SMTP/recipient failures are logged and the invoice
  is left as a draft; generation and the next-issue-date advance still complete.
  Auto-send fires only on the scheduled path, not the manual generate button.

- A reusable InvoiceEmailService that owns PDF/ISDOC building + SMTP send +
  draft->sent transition, shared by the manual send-email handler and the
  scheduler (supplier mapping moved into the pdf/isdoc packages).

- auto_send / auto_send_recipient on recurring invoices across domain,
  repository, service, handler DTOs, and the SvelteKit create/edit/detail UI.

- Migration 028: invoice_number is now unique per company instead of globally,
  so two companies may legitimately share a number (verified against a copy of
  the production database; data preserved, per-company uniqueness enforced).

Also hardens the recurring repo Update to report not-found, and bounds the
auto-send email with a timeout.
- company_repo: check rows.Close() error (errcheck)
- tax_child_credit_repo: gofmt
- client.ts: attach cause to re-thrown delete() error (preserve-caught-error);
  prettier formatting
- prettier formatting for the recurring auto-send files

golangci-lint (--build-tags server) and eslint both clean; gofmt clean.
Recurring-invoice scheduler with optional auto-email
Recurring generation hard-coded an "FV" prefix via the auto-assign path, so
generated invoices ignored the company's real sequence (e.g. Suntiari's "77"
series) and landed in a separate FV series. Recurring templates now carry an
optional sequence_id, picked in the create/edit forms like the new-invoice form;
generation passes it through so invoices follow the chosen sequence. A zero
sequence_id keeps the previous auto-assign behaviour, so existing templates are
unaffected until edited.

- Migration 029: nullable sequence_id on recurring_invoices.
- Domain/repository/service/handler threaded end to end; createInvoiceFromTemplate
  sets the invoice's SequenceID.
- Frontend: sequence picker on the recurring create + edit forms, read-only
  "Číselná řada" row on the detail view.
- Tests: repo round-trip, service generation honours the template's sequence,
  handler DTO, frontend picker payload.
Recurring invoices use a chosen invoice-number sequence
Previously auto-send fired only at the moment the scheduler generated an
invoice, so it couldn't pick up invoices that already existed as unsent drafts,
and a failed send was lost. This switches to a sweep:

- Generation only creates drafts (ProcessDue no longer sends; dropped its
  autoSend flag). Each generated invoice is linked to its template via a new
  invoices.recurring_invoice_id (migration 030).
- The scheduler, after generating, runs SweepAutoSend per company: it emails
  every still-draft invoice belonging to an auto-send template (recipient =
  template override or the customer's email) and marks it sent. Failures leave
  the draft in place, so the next run retries — more robust and systematic.
- The manual "process due" button still only generates; nothing emails outside
  the scheduler.

Tests updated to the sweep model (send happens in SweepAutoSend, not ProcessDue;
failure leaves a draft for retry).
Auto-send recurring invoices via an unsent-draft sweep
New recurring-invoice templates now start with auto-send enabled (blank
recipient = the customer's own email); uncheck to keep an invoice a manual
draft. Updates the create form default and its tests.
…ault

Default new recurring invoices to auto-send
NextDate() advanced dates with time.AddDate, so '31 May + 1 month'
normalized to 1 July and skipped June entirely. Add a shared
addMonthsEOM helper that clamps the day to the target month's length
and preserves end-of-month anchors (31 May -> 30 Jun -> 31 Jul). Both
RecurringInvoice and RecurringExpense use it for monthly/quarterly/
yearly stepping; weekly is unchanged.
Fix month-end recurring dates skipping a month
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.

2 participants