Sync fork: multi-company, flexible invoice sequences, and recurring-invoice auto-send - #7
Open
Maziak2520 wants to merge 92 commits into
Open
Maziak2520 wants to merge 92 commits into
Maziak2520 wants to merge 92 commits into
Conversation
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.
Flexible invoice sequence format
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.
Sequence picker on /invoices/new
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Full sync of the
Maziak2520fork'smaininto 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)
[scheduler] enabled/hour) that generates due recurring invoices for every company.recurring_invoices.sequence_id); generated invoices link back viainvoices.recurring_invoice_id.InvoiceEmailServiceshared by the manual send-email handler and the scheduler.Per-company invoice numbering
invoice_numberis 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-errorfix, a time-relative test, and pinnedgolangci-lintto 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 frontendcheck/lint/format:check/test suite all pass onmain.