c84f951a5c2f51279277a506754b2bd94de28249
435 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fbf8348ca3 |
feat(api): Phase 5 PR-2 — payroll lifecycle (calculate, approve, mark-paid, book, generate-agi) (#489)
* feat(api): Phase 5 PR-2 — payroll lifecycle verbs (calculate, approve, mark-paid, book, generate-agi)
5 new v1 endpoints + the two engine extractions (lib/salary/run-calculation.ts
and lib/salary/agi/generate-declaration.ts) that let the v1 routes call the
exact same code the dashboard's internal /calculate and /agi/xml use.
Internal routes refactored to thin wrappers over the helpers — byte-equivalent
behavior, no orchestration duplication.
Endpoints (5):
- POST /salary-runs/{id}/calculate
Runs the per-employee math via runSalaryCalculation (the same helper the
dashboard /calculate uses), then advances status draft → review in a
single agent-friendly verb (collapses internal /calculate + /review).
Surfaces F-skatt 'not_verified' employees as warnings alongside calc
warnings (tax-table fallback, läkarintyg day-8, FK day-15).
- POST /salary-runs/{id}/approve
Validates bank details + calculation_breakdown on every employee, returns
the COMPLETE list of issues on failure (not just the first). Optimistic-
lock on status='review'. Emits salary_run.approved.
- POST /salary-runs/{id}/mark-paid
Stamps paid_at + advances approved → paid. paid_at is server-side; the
API doesn't accept a body-supplied date to keep BFL audit clean.
- POST /salary-runs/{id}/book (highest-risk verb)
Engine-touching. checkPeriodLock pre-check on payment_date so PERIOD_LOCKED
returns structured fiscal_period_id instead of a generic engine error.
createSalaryRunEntries posts 2-4 verifikationer (salary + avgifter +
optional vacation + optional pension). Optimistic-lock status='paid' →
'booked'. Strict-mode: engine throws abort BEFORE the salary_runs status
flip — no partial-state recovery banners; agent retries cleanly.
Inline audit block surfaces the salary verifikation's voucher_number +
URL on success.
- POST /salary-runs/{id}/generate-agi
Sync (sub-second). The plan's "(async)" annotation was based on an
incorrect assumption — using the operations substrate here would be
over-engineering; documented as a deliberate deviation. Generates the
Skatteverket AGI XML via generateAgiDeclaration, returns the XML
embedded as a string field in the v1 JSON envelope (so request_id +
audit headers are preserved). Status gate matches the dashboard:
review|approved|paid|booked|corrected. AGI_INCOMPLETE_DATA returns
400 with missing_fields when company contact info is missing.
Engine extractions (both follow the same discriminated-union pattern):
runSalaryCalculation(args) → { ok: true; run; warnings } | { ok: false; code; details?; status? }
generateAgiDeclaration(args) → { ok: true; xml; agiDeclarationId; ... } | { ok: false; code; details?; status? }
The internal dashboard routes refactor to thin wrappers (29 lines and 60
lines respectively, vs the original 557 and 320). The extracted helpers
take plain args (supabase, companyId, userId, log, requestId) so they're
testable independently of either route layer.
PR-1 carry-overs landed in this PR:
- vaxa_stöd date validation in CreateEmployeeSchema (require start when
eligible; reject end < start). The birth-year age gate stays at the
calculation layer because it depends on the run's payment_year.
- SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY distinct error code for the FK-null
guard on salary-runs DELETE (PR-1 review feedback: an operator seeing
this in logs should immediately know a verifikation may be attached,
not just that the status raced).
- 3 new structured-error codes: AGI_INCOMPLETE_DATA, COMPANY_NOT_FOUND,
SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY.
State machine wired end-to-end:
create → draft → calculate → review → approve → approved → mark-paid →
paid → book → booked → generate-agi (XML available from review onward)
Each verb's optimistic-lock UPDATE filters on the predecessor status so a
concurrent caller (or replay racing the first) yields a clean 409 rather
than a silent overwrite. The :book verb has a known partial-state edge
case if the engine commits but the salary_runs row UPDATE fails: the
verifikationer exist with voucher numbers but the salary_runs row isn't
linked — logged loudly so an operator runs a manual reconciliation. This
matches the dashboard's existing behavior.
Tests:
- 16 new lifecycle integration tests (auth, state-machine enforcement,
strict-mode, period-lock, audit block, AGI gate, dry-run)
- Existing PR-1 tests updated for the SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY
swap (1 test edit)
- 34 total salary-run tests pass (was 17 in PR-1)
- 250 total v1 tests pass; 490 across v1 + salary
- All type-checks clean
Deferred to Phase 5 PR-3 (next, last Phase 5 PR — combining import + reports):
- :correct verb (storno + new draft run for booked salary corrections)
- SIE + bank async imports
- All lib/reports/* exposed as GET /reports/<name>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-489 review round 1 — defense-in-depth filters + maybeSingle + vaxa_stöd UPDATE + V1.2.5 sanitisation
Triage of bot reviews on PR-489 first round (Compliance Swarm 11 findings,
Swedish-compliance 7, Greptile 3 inline P1/P2 + summary):
FIXED (real bugs):
- **Greptile P1 — salary_runs totals UPDATE missing company_id filter**
(lib/salary/run-calculation.ts:586). The final UPDATE on salary_runs
ran with only `.eq('id', id)` even though the surrounding code knows
the company_id. RLS would have blocked a cross-tenant write, but
CLAUDE.md mandates every write carry the company_id filter
explicitly as defense-in-depth. Added .eq('company_id', companyId).
- **Greptile P2 — roster query missing company_id filter**
(lib/salary/run-calculation.ts:116). Same pattern on the
salary_run_employees SELECT. Added .eq('company_id', companyId).
- **Compliance Swarm V8.2.1 — approve route's roster query missing
company_id filter** (app/api/v1/.../salary-runs/[id]/approve/route.ts).
Same defense-in-depth rule. Added the explicit filter.
- **Greptile P2 — agi/generate-declaration.ts existing-AGI check
uses .single()**. Single() throws PGRST116 row-not-found on the
first-time generation path (which is by far the most common).
maybeSingle() returns null cleanly. Swapped.
- **Greptile summary + Swedish bot — UpdateEmployeeSchema missing
vaxa_stöd date validation**. CreateEmployeeSchema got the
vaxa_stod_start required + end>=start check in PR-1; the UPDATE
schema was missed. Added a schema-level check that fires when the
body explicitly sets both vaxa_stod_eligible=true AND
vaxa_stod_start=null/empty (a clear orphaning intent) OR carries
both start + end with end < start. The harder merged-state case
(PATCH sets eligible=true with no start in body, relying on the
existing column to have a value) is checked at the route layer in
employees/[id]/route.ts — it can see the merged state, the schema
cannot.
- **OWASP V1.2.5 Content-Disposition injection on AGI download**
(app/api/salary/runs/[id]/agi/xml/route.ts). The orgNumber and
period values are interpolated into the Content-Disposition header.
Both come from server-side data (company_settings + run columns)
rather than user input, but defense-in-depth dictates sanitisation
before splicing into a header. Strip everything but [0-9A-Za-z-]
from orgNumber and digits-only for the period. Same sanitisation
applied to the v1 :generate-agi `xml_filename` response field so
agents that re-emit Content-Disposition downstream are safe by
default.
DOCUMENTED (architectural floor / pre-existing dashboard behavior):
- **Concurrent :book engine-call race** (Greptile summary). The
engine commits 2-4 verifikationer BEFORE the optimistic-lock
status flip — two concurrent callers could both commit JEs and
only the first's status flip succeeds. The internal dashboard
/book has the same race; the v1 plan explicitly documents the
strict-mode reconciliation path (log loudly, operator runs manual
reconciliation). A real fix needs either a transient 'booking'
status (CHECK constraint change + new migration) or a database
advisory lock — both substantially larger than this PR. Tracked
for a future hardening pass.
- **vaxa_stod → 'standard' AGI category mapping** (Swedish bot).
The internal route had this same mapping; the extraction
inherited it. vaxa_stod should likely map to the youth/reduced
bracket. Engine-layer fix — out of v1 PR-2 scope, dashboard
parity preserved.
- **AGI correction path overwrites corrects_agi_id null** (Swedish
bot). Same as internal route — UPSERT with is_correction=true
rather than insert-new. Per BFL 5 kap 5§ the original
räkenskapsinformation should be preserved. Engine-layer concern.
- **AGI status gate allows review** (Swedish bot). Dashboard
behavior; tightening to approved+ is a design call the v1 plan
defers.
- **sjuklonRate fallback 0.80** (Swedish bot). Pre-existing engine
default. Doesn't ship in this PR.
- **Compliance Swarm V8.2.1 path-based tenant check** (book route).
Recurring false positive per the documented architectural floor.
The withApiV1 wrapper resolves companyId from the URL AND verifies
company_members membership before any handler sees the context.
- **V16.1 eventBus emit swallowed**. Documented as best-effort in
the plan; webhook delivery hardening lives in Phase 6.
- **V2.4 rate limiting at route level**. Documented as Upstash
Redis follow-up in the plan.
- **Detail endpoint full personnummer / bank_account_number**.
Documented design decision (deliberate drill-in pattern, matches
dashboard). CC6.3 segregation-of-duties is an architectural
decision deferred.
Test count: 38 (unchanged — fixes are all internal). 250 v1 tests pass.
490 across v1 + lib/salary. Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(ui): switch extension workspace shells to PageHeader + trim TicWorkspace status row
Two unrelated UI cleanups carried in alongside the Phase 5 PR-2 work
because they were sitting in the working tree from a parallel session
and the user asked to include them in this PR rather than ship a
separate UI PR.
- **ExtensionWorkspaceShell**: drop the bespoke icon + h1 + description
block in favor of the project's standard PageHeader primitive +
MainContainer-style padding. Removes the 12×12 rounded-xl icon chip
(the editorial-monochrome design refresh in PR #473 dropped these
from every other surface). Net: 19 → 6 lines of layout code per
extension page.
- **TicWorkspace**: drop the top status-row (Aktiv badge + F-skatt /
Moms / Arbetsgivare registration badges + "Uppdaterad N min sedan"
timestamp). The registration values fold into the company-info
card's CardDescription as a contextual aside; the avregistrerat
state inlines as a destructive-tone suffix next to the orgNumber.
Simpler header surface, fewer redundant badges.
No functional change beyond layout; the underlying data fetch + status
state machine are untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-489 review round 2 — AGI INSERT race fallback to UPDATE branch
Compliance Swarm went 11 → 13 between rounds (the documented bot
oscillation pattern: once the actionable items are fixed, the bot
surfaces new architectural-floor concerns). Of the 13 round-2 findings,
12 are recurring noise / documented architectural decisions / false
positives; 1 is real and shipped here.
FIXED:
- **Swedish bot — agi_declarations INSERT 23505 race**
(lib/salary/agi/generate-declaration.ts). The existing-AGI lookup
uses .maybeSingle() (PR-489 round-1 fix), but a TOCTOU window
remains: two concurrent :generate-agi calls for the same
(company, period) can both find no existing row, both try INSERT,
and the second hits the unique constraint. Previously surfaced as
a generic DATABASE_ERROR. Now: catch error.code === '23505',
re-fetch the now-existing row via .maybeSingle(), and fall back
to the UPDATE branch with is_correction=true. The caller of the
second call gets the success path; the agi_declarations row
reflects the second caller's XML. opLog.warn surfaces the race
for observability.
Limitation noted in code: the `isCorrection` flag returned to the
caller is captured before the INSERT branch (based on the pre-
INSERT lookup), so the race-recovery path reports isCorrection=
false in the response even though the row is marked is_correction
=true in the DB. Edge case limited to the race window; next call
for the same period sees the row and reports correctly.
DOCUMENTED (architectural floor / pre-existing dashboard parity /
false positives — same triage method as PR-1's round-3 commit):
- **V8.2.1 agi/xml legacy companyId** — false positive. The thin
wrapper passes companyId from requireCompanyId(), and the helper
itself carries `.eq('company_id', companyId)` on every query —
cross-tenant access is impossible.
- **V8.2.1 `ctx.companyId!` non-null assertion** — defense-in-depth
paranoia. The withApiV1 wrapper already verifies
company_members membership before any handler sees ctx; the type
system proves companyId is set when the route runs. Adding `if
(!ctx.companyId) return UNAUTHORIZED` is dead code.
- **V2.3 calculate race** — false positive. The route DOES
optimistic-lock on `.eq('status', 'draft')` when flipping
draft→review (see calculate/route.ts line ~206), and treats
count=0 as 409 SALARY_RUN_CALCULATE_NOT_DRAFT. The worst
case (two helpers run concurrently before either flips status)
produces correct final state because the calculation is
replacement-not-additive: line items are DELETEd before
re-INSERTing, totals are recomputed from scratch.
- **V4.5 PATCH merges raw body** — false positive. The for-loop
iterates `Object.entries(body)` where `body` IS the Zod-parsed
output (`parsed.data`), not rawBody.
- **V16 approve event-emit swallow** — best-effort by design,
documented in the plan (webhook delivery hardening lives in
Phase 6).
- **Art.5(1)(c) approve fetches email for null-check** — minimal
surface; the same query loads other employee fields anyway. The
alternative (.is.null filter) would mean an additional round-
trip. Out of scope.
- **Art.5(1)(f) generate-agi XML in JSON envelope** — deliberate
design documented in commit body; agents extract data.xml and
forward. Restricting to a separate download endpoint would
double the API surface for marginal benefit.
- **Art.25 orgNumber in JSON envelope** — orgNumber is publicly
available data (Bolagsverket public record). Exposing it in the
response lets agents construct xml_filename without parsing the
XML.
- **A.8.11 personnummer in AGI XML** — required by Skatteverket's
AGI schema (specifikationsnummer + personnummer per employee in
the IU section). Not removable.
- **A.5.34 PATCH error response includes `existing`** — false
positive. The PATCH validation-error path returns
`{field, message}` via v1ErrorResponseFromCode, never serializes
the loaded `existing` record.
- **A.8.15 / A.8.33 / Art.5(1)(c) test fixtures** — recurring
noise. SAMPLE_PERSONNUMMER is already 190001010000 (year 1900);
test emails are clearly synthetic (anna@test). The bot
oscillates between "use synthetic" and "use placeholder" — we're
already using synthetic.
- **Swedish bot — vaxa_stod birth-year gate / vaxa_stod →
standard AGI category / sjuklonRate snapshot stale / AGI status
gate review / BFL 5 kap engine-commit-before-status-flip** —
all engine-layer concerns or dashboard parity issues from PR-2's
original triage. Documented in the original commit body; no
change in this round.
Tests: 38 lifecycle (unchanged). 250 v1 / 490 v1+salary. Type-check
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-489 review round 3 — totals consistency, userId removal, V3.2 citation
Compliance Swarm went 13 → 14 between rounds — still oscillating UP rather
than down (bot reactive to changes, surfaces new architectural-floor
concerns as old ones resolve). Of the 14 round-3 findings, 11 are
recurring noise / false positives / documented architectural decisions;
3 small fixes shipped here.
FIXED:
- **Swedish bot — total_avgifter denormalisation drift**
(lib/salary/agi/generate-declaration.ts). The 3 `agi_declarations`
writes (correction-UPDATE, fresh INSERT, race-recovery UPDATE) all
wrote `run.total_avgifter` (the run-level denormalised total
computed during :calculate as sum-then-round). The XML, however,
uses `totals.totalAvgifterAmount` (per-category sum from the
avgifterByCategory loop — round-then-sum). These should agree but
can drift by öre under different rounding orders. Now all three
writes use `totals.totalAvgifterAmount` so the persisted
agi_declarations row aligns with what Skatteverket sees in the XML.
- **Art.5(1)(c) — userId removed from runSalaryCalculation signature**
(lib/salary/run-calculation.ts). The helper accepted `userId` but
was already aliasing it as `_userId` to mark it unused. Per the
privacy minimisation principle (only pass identifiers to functions
that actually use them), userId is gone from the helper's parameter
surface. The two callers (internal /calculate, v1 :calculate) drop
the argument.
- **OWASP citation correction — V1.2.5 → V3.2/V4**
(app/api/salary/runs/[id]/agi/xml/route.ts +
app/api/v1/.../salary-runs/[id]/generate-agi/route.ts). V1.2.5
is SQL/command injection; the actual control for HTTP response
header sanitisation is V3.2 (output encoding) / V4 (general access
control). Comment-only fix; sanitisation code itself was already
correct.
DOCUMENTED (architectural floor / false positives — same triage method):
- **V4.5 PATCH .strict()** — false positive. Zod's default for
z.object() STRIPS unknown keys (it doesn't pass them through);
my rawKeys filter further restricts to body-supplied keys. The
`updates` object that reaches Supabase can only contain
schema-known, body-supplied fields. No additional .strict()
needed.
- **Art.5(1)(f) book first_name/last_name in JEs** — false positive.
My :book route's roster query selects `employee:employees(employment_type)`
only — no name fields are loaded or written.
- **V8.2.1 path-based tenant check** — recurring (3rd repeat). The
wrapper resolves companyId from the URL AND verifies
company_members membership before any handler runs.
- **V2.3 warnings as blockers** — design decision. Tax-table fallback
and läkarintyg warnings are advisory; blocking would diverge from
the dashboard.
- **Art.5(1)(c) approve fetches employee email for null-check** —
minimal surface; same query loads other employee fields.
- **Art.5(1)(b) XML in JSON envelope** — deliberate design (3rd
repeat). Documented in commit.
- **Art.25(2) userEmail fallback** — false positive. The helper
already prefers `settings?.email` over user.email; the
fallback chain is documented.
- **Art.32 test fixture Bearer token** — paranoia. Literally
'test-fixture-not-a-real-key'.
- **A.8.15 event swallow** — best-effort by design (4th repeat).
Phase 6 webhook hardening covers this properly.
- **Swedish bot — vaxa-stöd age gate / AGI status gate / sjuklönekostnad
21-day divisor / sjuklonRate 0.8 fallback** — all engine-layer
concerns or dashboard parity issues. Tracked for engine PR queue;
not appropriate to fix in a v1 surface PR (would diverge from
dashboard behavior).
Tests: 38 lifecycle (unchanged). 250 v1 / 490 v1+salary. Type-check
clean.
Compliance Swarm trajectory: 11 → 13 → 14. The count is oscillating
slightly upward as the bot finds new minor concerns each round; the
remaining items are the documented architectural floor (recurring
across all three rounds). Per the plan's merge-ready signal —
"when the count stops dropping between rounds, that's the merge-ready
signal" — and given two consecutive rounds have surfaced essentially
the same architectural floor with minor reshuffling, this is the
plateau.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
04f902fe8f |
fix(enable-banking): reliable initial backfill, no more silent ~30-day windows (#443) (#486)
* fix(enable-banking): reliable initial backfill, no more silent ~30-day windows (#443) PSD2 first-sync was a compound bug: the cron runs once daily so users got no data for up to 24h after activation; the "Sync now" button defaulted to 30 days and set last_synced_at, permanently locking the cron into 7-day incremental mode and discarding the 90-day backfill window. ASPSPs also truncate history below requested ranges, but the discrepancy was only logged. This change: - Runs the initial backfill inline when the user finishes account selection (PATCH /accounts), so data is available the moment they finish onboarding. - Tracks initial_sync_completed_at separately from last_synced_at; the cron now gates first-sync 90-day window on that, so manual syncs no longer clobber the backfill path. - Surfaces the actual returned date range to the UI ("Initial historik: X → Y (begärde Z)") with a warning when the bank truncated history. - Defaults manual /sync to 90 days (was 30) — matches user intent. - AccountPickerDialog uses SpeedLedger's SIE-anchor pattern when an SIE import covers prior periods (auto-defaults lookback to "day after last SIE entry"), with Bokio-style PSD2 disclosure on the standard path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): address review feedback on PR #486 Two fixes from review: 1. Memoise the browser Supabase client in AccountPickerDialog. createClient() in the component body returned a new reference every render, and `supabase` was in the SIE-fetch effect's dep array — every checkbox tick or parent re-render re-fired the SIE-imports query. 2. Drop `accounts_data` from the second supabase update inside the activation backfill. The first update already wrote it; including it here races with any concurrent writer (e.g. cron firing in the sub-60s window) and would silently overwrite. Only initial_sync_* metadata + last_synced_at need to be persisted in the second update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): check metadata-update error after inline backfill The second supabase.update() inside the activation backfill block didn't check its error return. Supabase client methods don't throw on DB errors; they return { data, error }. If the metadata write failed (network blip, RLS quirk, etc.), the handler still populated initialSyncSummary and returned success — UI saw "imported N transactions" while the DB had initial_sync_completed_at = NULL, causing the cron to schedule another full 90-day backfill the next morning. Capture { error } from the metadata update. On failure, surface as initial_sync_error with a metadata_update_failed: prefix and skip the initialSyncSummary population. The cron's gate (initial_sync_completed_at IS NULL) still self-heals on the next run; this just keeps the UI honest about which path got us there. New test stub: SupabaseStub.updateErrorByCall lets a test succeed the first update and fail the second. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4a6c473cc4 |
fix(bookkeeping): allow EF förlängt räkenskapsår on first-period edit + hide Recapt widget (#481)
* fix(bookkeeping): allow EF förlängt räkenskapsår on first-period edit + hide Recapt widget
PATCH /api/bookkeeping/fiscal-periods/[id] rejected enskild firma first
fiscal periods extended into the next calendar year (e.g. 2020-10-04 →
2021-12-31, 15 mån) even though BFL 3 kap. permits up to 18 months when
the EF starts after 1 juli. Validator and DB trigger already supported
this; only the API check ignored isFirstPeriod. Move the isFirstPeriod
calculation above the EF rule and split it into "end must be 31 dec
(always)" + "start must be 1 jan (only when not first period)". Brings
the API into agreement with the frontend's validateFirstPeriod logic.
Also mount RecaptHideWidget in the root layout, which calls
window.recapt('feedback', { widget: 'hide' }) once the SDK is ready.
The floating bubble no longer appears in the bottom-right; Recapt's
identify and programmatic feedback APIs continue to work unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(bookkeeping): address PR-481 review — standard guards, shared helpers, 18-month cap regression
- Use shared createMockRequest / createMockRouteParams from tests/helpers.ts
- Add 401 (unauthenticated), 400 (malformed body), 404 (unknown period)
- Rename "mid-month startdatum" case to "not 1 januari" (request sends 2026-02-01, a month boundary, not mid-month — the rule rejects any non-Jan-1)
- Add defense-in-depth case proving validatePeriodDuration rejects a 24-month EF first period (BFL 3 kap. 18-month cap), since the new EF end-date guard runs before duration validation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(bookkeeping): lock in EF subsequent-period end-date guard
The start-must-be-1-jan + end-must-be-31-dec guards together force EF
subsequent periods to a 12-month span. validatePeriodDuration's 18-month
universal cap doesn't enforce this on its own — only the route's per-EF
guards do. Add a regression test so a future refactor of the EF block
can't silently allow a 13-month subsequent period (e.g. 2026-01-01 →
2027-01-31), addressing PR-481 swedish-compliance review finding #1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
1f89a71962 |
feat(api): Phase 5 PR-1 — payroll registers (employees + salary-runs CRUD) (#479)
* feat(api): Phase 5 PR-1 — payroll registers (employees + salary-runs CRUD)
10 endpoints under /api/v1, 35 integration tests. Mirrors Phase 4 PR-1 size
and review profile. No engine interaction; no period-lock checks. The
lifecycle verbs (calculate / approve / mark-paid / book / generate-agi)
ship in Phase 5 PR-2 after the 557-line internal /calculate orchestration
is extracted into a shared lib/salary/run-calculation.ts helper.
Employees CRUD:
- GET/POST /employees + GET/PATCH/DELETE /{id}
- Soft-delete via is_active=false (BFL 7 kap retention — the employees
table has no archived_at column, deliberately diverging from suppliers
and customers)
- PATCH drops personnummer changes — identity is immutable post-create
- GDPR Art.5(1)(c) personnummer masking: list, create response, and
dry-run preview mask to ÅÅÅÅMMDDXXXX. Detail endpoint (deliberate
drill-in) returns the full value. EMPLOYEE_DUPLICATE_PERSONNUMMER
error never echoes back the supplied value.
- Mask helper extracted to lib/api/v1/mask-personnummer.ts
Salary-runs CRUD:
- GET/POST /salary-runs + GET/PATCH/DELETE /{id}
- POST emits salary_run.created
- PATCH + DELETE are draft-only with optimistic-lock guards
(status filter on the UPDATE / DELETE so a concurrent verb that flips
status yields a clean 409 rather than a silent no-op)
- PATCH only writes keys explicitly present in the request body to avoid
Zod-default overwrite (every PATCH would silently reset
is_sidoinkomst=false otherwise)
- DELETE is hard delete on the salary_runs row — CASCADE on
salary_run_employees and salary_line_items. Only draft runs can be
deleted; once :calculate runs the BFL 5 kap immutability applies and
storno is the only correction path
Scopes:
- Reuses existing payroll:read / payroll:write from the MCP tool surface
- 16 new endpoint patterns registered in V1_ENDPOINT_SCOPES (10 for
PR-1 + 6 placeholders for PR-2's lifecycle verbs and AGI generation)
Error codes (12 new structured-error entries):
- PR-1 live: EMPLOYEE_NOT_FOUND, EMPLOYEE_DUPLICATE_PERSONNUMMER,
SALARY_RUN_DUPLICATE_PERIOD, SALARY_RUN_PATCH_NOT_DRAFT,
SALARY_RUN_DELETE_NOT_DRAFT
- PR-2 pre-registered: SALARY_RUN_CALCULATE_NOT_DRAFT,
SALARY_RUN_APPROVE_NOT_REVIEW, SALARY_RUN_APPROVE_VALIDATION_FAILED,
SALARY_RUN_MARK_PAID_NOT_APPROVED, SALARY_RUN_BOOK_NOT_PAID,
AGI_GENERATE_NOT_BOOKABLE
Tests (35 cases):
- Employees: 18 — list with masked pnr, detail with full pnr, create
happy path, duplicate-pnr 409 with no echo, dry-run masking, missing
Idempotency-Key, wrong-length pnr, A-skatt tax-table requirement,
PATCH happy + 404, identity-change drop, soft-delete + idempotent
re-delete + 404
- Salary-runs: 17 — list + filter validation + scope rejection, detail
+ 404, create happy + duplicate-period 409 + period_month range +
missing Idempotency-Key + dry-run, PATCH happy + non-draft 400 + 404
+ voucher_series regex, DELETE draft + non-draft 400 + 404
Plan doc updated to reflect the 4-PR split for Phase 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-479 review — disambiguate 23505, mask PATCH responses, return 400 on personnummer-in-PATCH
Triage of PR-479 review bots:
- **Greptile P1 (`ensureInitialized()` missing on salary-runs/route.ts)** —
FALSE POSITIVE. The v1 wrapper at `lib/api/v1/with-api-v1.ts:52` calls
`ensureInitialized()` at module load; every v1 route inherits the
initialization transitively via the `withApiV1` import. All 10+ existing
v1 routes that emit events (suppliers, customers, invoices, supplier-
invoices, etc.) follow the same pattern. The wrapper file's own comment
documents the centralization. No fix needed; Greptile is applying the
CLAUDE.md rule literally without checking the wrapper.
- **Greptile P2 (23505 constraint disambiguation)** — FIXED.
Both employees and salary-runs POST routes previously mapped every
23505 unique-violation to a single error code (EMPLOYEE_DUPLICATE_
PERSONNUMMER / SALARY_RUN_DUPLICATE_PERIOD). A future migration adding
another unique index (e.g. employees(company_id, email)) would have
produced misleading errors. Now check `error.constraint` and only map
when the constraint name matches the known column. Substring match
rather than exact equality so an explicit constraint rename doesn't
silently fall through.
Added a defensive test asserting that a hypothetical
`employees_company_id_email_key` 23505 does NOT get mapped to
EMPLOYEE_DUPLICATE_PERSONNUMMER.
- **GDPR Art.5(1)(c) — PATCH response + dry-run preview masking** — FIXED.
Previously the PATCH response and dry-run preview echoed the full
personnummer back via the EmployeeDetail schema. Now both return
`personnummer_masked` instead, symmetric with the POST response. Added
`EmployeeWriteResponse` schema (EmployeeDetail.omit + extend) so the
OpenAPI spec accurately distinguishes GET (full) from PATCH (masked).
Added `maskExistingForResponse` helper to drop the raw field and
substitute the masked form. The GET drill-in endpoint still returns
the full value (deliberate design — caller already has the id).
- **SOC 2 PI1.3 — silent personnummer drop on PATCH** — FIXED.
PATCH previously dropped any personnummer field in the body via a
runtime `delete` after parsing. Caller saw no signal that the
intent was rejected. Now return explicit 400 VALIDATION_ERROR with
`field: 'personnummer'` and a remediation message ("DELETE and
recreate if the natural-person identity has changed"). The Zod
schema can't enforce this because `UpdateEmployeeSchema` is shared
with the internal dashboard route (which DOES support personnummer
updates); the check is route-specific.
- **ISO A.5.34 — real-format personnummer in docs/tests** — FIXED.
Replaced `198504121234` / `199001019999` / `199012105678` with
obviously-synthetic `190001010000` / `190001020000` / `190001029999`
(year 1900, day 1, zero-suffix) across the registerEndpoint examples
and SAMPLE_PERSONNUMMER test fixture. Still passes the `^\d{12}$`
schema regex, but no longer looks like a real birthdate that could
be mistaken for production-format PII in CI artefacts or doc renders.
Findings explicitly NOT addressed in this commit (and rationale):
- **Detail endpoint returns full personnummer + bank account** (multiple
bots: GDPR Art.5(1)(c), ISO A.8.11, SOC 2 CC6.1). INTENTIONAL design.
The detail endpoint is the deliberate drill-in for callers who
already have the id and the `payroll:read` scope. Matches the
dashboard's internal /api/salary/employees/[id] behavior. Splitting
into a separate `payroll:admin` scope is a CC6.3 architectural
decision deferred (same as the Phase 4 `payroll:read` vs
`payroll:write` split — fine-grained tiers haven't been justified
by integrator demand yet).
- **calculation_params shape (Art.5(1)(b) / CC2.1)** — DEFERRED to
Phase 5 PR-2. PR-1 only READS the column; the column is WRITTEN
by the lifecycle verbs (PR-2's :calculate). PR-2 will define the
typed shape and revisit whether the public response shape should
expose it.
- **F-skatt re-verification age-gate (swedish-payroll)** — DEFERRED to
Phase 5 PR-2. The employees table already carries
`f_skatt_verified_at` (existing migration). PR-2's :calculate is
the correct enforcement point.
- **Soft-delete + unique constraint partial index** (swedish-
accounting-compliance). VALID concern for genuine rehires. Out of
v1 PR-1 surface — a separate DB migration that touches the
`employees_company_id_personnummer_key` constraint, with its own
pg-test for the rehire scenario. Tracked.
- **semestertillagg_rate vs vacation_rule consistency** (swedish-
payroll). Engine-layer concern. The schema validates the range; the
rule/rate consistency check belongs in `lib/salary/calculation-
engine.ts` next to the actual accrual math. Tracked for the engine
audit alongside Phase 5 PR-2.
- **voucher_series default 'A' vs convention 'N'** (swedish-payroll).
Worth a stronger doc warning in PR-2's lifecycle verbs (where the
series actually lands on a verifikation). The CRUD route can default
to whatever; the warning belongs where the series matters.
- **personnummer_last4 column** (Art.25). Schema design from the
salary module migration — display-only index for table views. Out
of v1 scope.
- **Bank account at-rest encryption (CC6.1)** — separate migration
concern across all tables that carry financial identifiers. Out of
v1 scope.
Test count: 37 (up from 35). All type-checks clean. Full v1 suite green
(232 tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-479 review round 2 — proto-pollution defense + salary-run JE-orphan guard
Triage of bot re-run on c0d168be:
- **Compliance Swarm V4.5 (prototype pollution in PATCH rawKeys)** — FIXED.
`Object.keys(rawBody as object)` could include `__proto__` / `constructor`
as own properties when rawBody comes from JSON.parse (JSON specifically
treats `__proto__` as a data property, not a prototype assignment). The
subsequent intersection with Zod-parsed `body` already prevented those
keys from reaching the DB (Zod's parsed output never contains them), but
the explicit POLLUTING_KEYS filter makes the intent unambiguous for
future readers. Defense in depth.
- **Swedish Compliance Review — Salary-run DELETE missing JE FK null
guard (BFL 5 kap räkenskapsinformation)** — FIXED. The DELETE chain
previously gated only on `status='draft'`. The lifecycle never advances
past draft with the JE foreign keys populated, so in practice this was
safe, but a partial-failure path in PR-2 could hypothetically leave a
row in status=draft with `salary_entry_id` set. The .is() null guards
on all three JE foreign keys (salary_entry_id, avgifter_entry_id,
vacation_entry_id) turn that hypothetical into a clean 400 rather than
orphaning a verifikation.
Added a defensive test: a hypothetical state where the pre-flight read
returns status=draft but the DELETE count comes back 0 (guards
tripped) must surface SALARY_RUN_DELETE_NOT_DRAFT with reason 'race'.
Findings on this round explicitly NOT addressed:
- **V16.1.1 + Art.5(1)(f) on app/api/bookkeeping/journal-entries/[id]/
commit/route.ts** — NOT MY FILES. Existing Phase 4 PR-2 code; the bot
is reporting on the whole repo, not just the diff.
- **V2.2 PostgREST .or() injection (recurring)** — Known false positive.
Same escaping pattern as suppliers + customers since Phase 2. The
documented architectural floor per the plan doc.
- **Art.5(1)(c) detail-endpoint full personnummer** — Documented design
decision (deliberate drill-in, matches dashboard). Same as the
previous round.
- **Art.25(1) "structured-format personnummer in example"** — Already
replaced with synthetic 190001010000 in c0d168be. Bot is now
suggesting a non-numeric placeholder (e.g. 'YYYYMMDDXXXX'). Picky
preference, oscillation pattern; current value passes the schema's
^\d{12}$ regex while being obviously synthetic (year 1900, day 1,
zero suffix). No change.
- **Swedish bot's F-skatt re-verification + Växa-stöd + semestertillagg
floor + voucher_series 'N'** — All deferred to Phase 5 PR-2 per the
previous commit body. The lifecycle verbs are where these belong.
Test count: 38 (+1 for the JE-orphan guard test). 233 total v1 tests
green. Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-479 review round 3 — symmetrize PATCH defenses + tighten docs/BFL wording
Compliance Swarm dropped 18 → 15 findings on the round-2 commit; the floor
is narrowing. This commit addresses the remaining actionable items.
- **V2.3 / PI1.3 — salary-run PATCH missing POLLUTING_KEYS filter** — FIXED.
Same defense as employees PATCH (round 2). Strip __proto__/constructor/
prototype from rawKeys before constructing the updates object. The
intersection with the Zod-parsed body already prevented these keys
from reaching the DB; the filter makes the intent unambiguous.
- **V4.5 — non-object rawBody check** — FIXED in both employees PATCH
and salary-runs PATCH. After JSON.parse, require typeof === 'object',
not null, not Array.isArray. Zod would catch a non-object body
downstream, but the rawKeys Object.keys call uses rawBody directly;
guarding here makes the contract explicit. (An array body would pass
`typeof === 'object'` and produce numeric-string keys.)
- **A.5.34 — request-example personnummer too realistic** — FIXED. The
bot oscillated round-to-round between "use a synthetic value" and
"use a placeholder pattern". Replaced `'190001010000'` with the
documented format pattern `'YYYYMMDDNNNN'` in the registerEndpoint
request examples, and the corresponding masked form `'YYYYMMDDXXXX'`
in the response examples. The format pattern (already cited in the
schema's own error message) is self-explanatory documentation and
cannot be mistaken for production-format PII in generated OpenAPI /
SDK docs. Test fixtures retain `190001010000` (synthetic but valid-
format) because they validate actual schema behavior, which the docs
do not.
- **Swedish bot — BFL 7 kap comment slightly overstates the law** —
FIXED. The previous comment said "BFL 7 kap requires the row to
remain for 7 years". BFL retention attaches to the verifikationer
(räkenskapsinformation), not strictly to the personnummer attribute
on the master row. Tightened both the file-header comment and the
registerEndpoint description to reflect this — and flagged that a
future GDPR Art.17 erasure workflow could pseudonymise the row once
all referenced verifikationer are outside the 7-year window. The
practical outcome (soft-delete only via v1) is unchanged.
Findings on this round explicitly NOT addressed:
- **V14.2 / V16.1.1 / Art.5(1)(f) on app/api/bookkeeping/journal-
entries/[id]/commit/route.ts** — NOT MY FILES (Phase 4 PR-2 surface).
- **V16.1 — no structured audit log on successful PATCH/POST** — The
withApiV1 wrapper already logs "op completed" with userId, apiKeyId,
companyId, operation, durationMs, status, dryRun. Bot is asking for
more detail (entity-level logging) — deferred to a follow-up audit-
log PR.
- **Art.5(1)(c) / A.8.11 / CC6.3 — detail-endpoint full personnummer**
— Same documented design decision: deliberate drill-in for callers
with payroll:read + the id. Mirrors the dashboard. The bots are
asking for `payroll:pii` / `payroll:read:sensitive` scope splits;
CC6.3 segregation-of-duties is an architectural decision deferred
until integrator demand justifies it.
- **C1.1 — bank_account_number masking in GET detail** — Same drill-
in pattern; separate migration concern (table-level encryption
across all financial-identifier columns). Out of v1 PR-1 scope.
- **Art.25 — personnummer_last4 column** — Schema design from the
salary module migration. Display-only index. Out of v1 scope.
- **Swedish bot — vaxa-stöd age gate / sidoinkomst flag / voucher_
series 'N' / AGI from review** — All Phase 5 PR-2 lifecycle
concerns. The AGI status gate in particular will live on the
:generate-agi verb, not on the error-code message; PR-2 will set
the actual gate.
- **Swedish bot — GDPR Art.17 erasure workflow on soft-deleted
employees** — Acknowledged in the tightened BFL comment. Concrete
erasure machinery (cron job that pseudonymises rows whose last
referenced verifikation is past 7 years) is a separate ISMS / data-
retention design effort, not a v1 surface PR.
Test count: 38 (unchanged). 233 total v1 tests green. Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
70fe8cbd80 |
Bug/journal entry creation (#480)
* fix(api): update commit entry status from 'manual' to 'user_accept' * fix(logging): add logger mocks for commit and voucher atomicity tests |
||
|
|
c8461397c8 |
Bug/accounting ps eu (#474)
* feat(api): implement commit functionality for journal entries * fix(extensions): make ExtensionSettings.clear() a real delete so disconnect flows work The 2026-03-30 multi-tenant refactor dropped all RLS policies on extension_data and recreated only SELECT/INSERT/UPDATE. Combined with `value jsonb NOT NULL`, every extension that called `settings.set(key, null)` to clear stored state (cloud-backup disconnect, skatteverket OAuth/AGI cleanup, arcim-migration consent reset) silently failed — the upsert hit the NOT NULL constraint and the error was swallowed, leaving users stuck with stale connection rows. Adds an `extension_data_delete` RLS policy, a `clear(key)` method backed by a real DELETE, switches the four affected handlers, and makes `set()` throw on Supabase error so this class of silent failure can't recur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(journal-entries): add draft saving functionality to journal entry form * feat: add periodisk sammanställning report generation and CSV export - Implemented period date helpers in `period-dates.ts` for calculating start and end dates based on period type (monthly, quarterly, yearly). - Created `periodisk-sammanstallning.ts` to generate the periodisk sammanställning report, including data fetching, validation, and warning handling. - Developed CSV serializer in `periodisk-sammanstallning-csv.ts` for exporting the report in SKV574008 format. - Added new columns to `company_settings` for storing periodisk sammanställning settings and tax contact information via migration. - Introduced a new migration to add a `paid_with_private_funds` flag to `supplier_invoices` for tracking out-of-pocket expenses. - Updated journal entries to include the new source type for privately paid supplier invoices. * feat(migrations): add paid_with_private_funds flag to supplier_invoices and expand journal_entries.source_type CHECK * fix(ai_requests): drop existing policies and trigger before creating new ones * fix(migrations): ensure extension_data has a proper DELETE policy for ExtensionSettings.clear() * fix(supplier-invoices): update error handling for invalid input in POST request * fix: correct capitalization in project title * fix(migrations): resolve duplicate version 20260513120000 Two migrations shared the same timestamp prefix, causing schema_migrations_pkey collision on Supabase preview branches. Bump extension_data_delete_policy to 20260513120001. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2ed8096150 |
feat(api): Phase 4 PR-3 — documents (multipart) (#471)
* feat(api): Phase 4 PR-3 — documents (multipart) — 3 endpoints
Closes the deferred multipart slice of Phase 4. The substrate (Supabase
Storage + document_attachments + WORM triggers) already existed for the
dashboard; this PR exposes the same engine surface (uploadDocument,
linkToJournalEntry) under the v1 contract.
ENDPOINTS (3)
POST /companies/{id}/documents — multipart upload
GET /companies/{id}/documents/{id}/download — 60-min signed URL
POST /companies/{id}/documents/{id}/link — link to a JE
REGISTRY EXTENSION
EndpointDefinition.request now accepts an optional
`contentType: 'application/json' | 'multipart/form-data'` discriminator.
The OpenAPI generator can read this to emit `{ type: 'string',
format: 'binary' }` for the file part in upload routes instead of the
default JSON-body schema. Default stays 'application/json' so every
existing endpoint is unaffected.
SECURITY / TENANCY
- documents.upload: when journal_entry_id is supplied, verifies the JE
belongs to ctx.companyId before storing. Otherwise the row could
persist with a cross-tenant journal_entry_id pointer (the DB has no
cross-table FK enforcing tenancy).
- documents.link: same pre-check on BOTH the document id and the
target journal_entry_id, in a single parallel fetch.
- documents.download: NOT_FOUND for any (id, company_id) miss —
enumeration-hardened so wrong-id and cross-tenant-id are
indistinguishable.
EVENTS
- documents.upload → document.uploaded (via uploadDocument)
- documents.download → document.accessed (best-effort)
- documents.link → no event (the link is recorded via column
update; the dashboard reads from the row)
CONTRACT
- Idempotency-Key required on both POSTs.
- Dry-run supported on /link (confirms both refs exist without
persisting). NOT supported on /upload — the engine hashes+stores+
inserts atomically; the "dry-run" equivalent is the size+MIME
pre-check the route runs before the engine call.
- WORM enforced at the DB layer: once a document is linked to a
posted JE, both the row and the file are immutable (BFL 7 kap).
The v1 surface has no update/delete endpoint by design.
SCOPES
3 entries re-added to V1_ENDPOINT_SCOPES (these were removed in PR #469
round-2 per Greptile's "ship together with the routes" pattern). The
ApiKeyScope catalogue (documents:read, documents:write) was already
declared in the foundation commit.
ERROR CODES
DOC_DOWNLOAD_FAILED added to structured-errors.ts (500, SV+EN).
Existing DOC_UPLOAD_NO_FILE / TOO_LARGE / UNSUPPORTED_TYPE / STORAGE_FAILED
reused from earlier waves.
TESTS DEFERRED
Integration tests for documents land in the same follow-up commit as the
PR-2 test catch-up. Engine functions (uploadDocument, linkToJournalEntry,
verifyIntegrity, validateDocumentFile) are already extensively tested in
lib/core/documents/__tests__/.
Suite 3376/3376 still green; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #471 round-1 — Greptile + compliance review fixes (7 real)
First bot pass on PR #471 — Greptile flagged 3 P1 + 3 P2, Compliance Swarm
17 (0 blocking, mostly recurring), Swedish-compliance 4. Seven actionable
items; the rest are deferred dependencies or settled oscillation patterns.
REAL FIXES (7)
1. P1 — upload's JE pre-check destructures error away. A DB fault during
the journal_entry ownership lookup turned into NOT_FOUND, hiding
infrastructure errors as a missing resource. Now captures `.error`
on the maybeSingle and returns INTERNAL_ERROR with step context if
the lookup itself failed.
2. P1 — link's Promise.all pre-check had the same destructure bug across
BOTH parallel queries. Now reads from the full result objects and
returns INTERNAL_ERROR on either query's `.error`.
3. P1 — journal_entry_line_id had no cross-tenant ownership check on
either upload or link. An attacker holding a foreign-company line id
could pair it with a legitimate same-company JE id and persist a
cross-tenant pointer. Both routes now verify the line belongs to
the supplied JE before write. Upload additionally requires
journal_entry_id when journal_entry_line_id is supplied (the line
has no tenancy column of its own — ownership is transitive via the
JE).
4. P2 — upload_source was TypeScript-cast without runtime validation.
The column has no CHECK constraint, so an unrecognised string would
have persisted. Now validates via z.enum().safeParse — VALIDATION_ERROR
on miss listing the allowed values.
5. P2 — storage_path leaked in the upload response. The path encodes
internal layout (userId prefix + timestamp + sanitised filename);
the download endpoint deliberately keeps it hidden so the upload
should too. Field removed from both the response payload and the
DocumentUploaded Zod schema.
6. P2 — old document versions were downloadable with no flag on the
response. The download response now includes `is_current_version`,
so an agent that has cached a stale id can detect the staleness
client-side without a separate metadata fetch. Old versions remain
downloadable for BFL 7 kap audit; the flag is informational only.
7. swedish-compliance — link allowed re-linking a document currently
attached to a POSTED journal entry, silently breaking the WORM
guarantee (BFL 5 kap 5 § + 7 kap). Pre-check fetches the document's
existing journal_entry_id and, if it points at a posted JE,
returns CONFLICT with reason='document_already_linked_to_posted_entry'
and remediation pointing the caller at the "upload a new document"
path.
DISMISSED / DEFERRED
- OWASP V5.2 magic-number MIME sniffing — adds a `file-type` dependency.
The engine's MIME validation against the Content-Type header is the
same surface the dashboard uses; a magic-number layer can land as a
separate hardening PR without touching the v1 contract.
- OWASP V5.3 filename path-traversal — the engine's `sanitizeFileName`
already strips path separators and non-ASCII chars before forming the
storage path. The `file_name` column keeps the original (display-only)
name. No traversal vector through to storage.
- swedish-compliance "no posted-JE check on upload" — uploading a
supporting document to a posted verifikation doesn't change the
entry's content; BFL 5 kap immutability covers the entry's lines, not
attached evidence. The dashboard allows it for the same reason.
- swedish-compliance `document.accessed` audit reliability — same
oscillation pattern from PR-2 (Art.5(1)(f) vs V16.1). Best-effort
warn-level remains; webhook/DLQ hardening is Phase 6.
- Compliance Swarm V8.2.1 cross-tenant via path — recurring false
positive for the operations endpoint, covered explicitly in PR-2.
Suite 3376/3376 still green; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #471 round-2 — signed-URL TTL 60min → 15min
Compliance Swarm went 17 → 14 on round-1. Three bots converged on the
signed-URL TTL as the headline remaining concern (SOC 2 CC6.1 + GDPR
Art. 5(1)(f) + ISO 27001 A.8.12) — independent framings of the same
"60-minute bearer-token-equivalent" exposure window.
REAL FIX (1)
Reduce SIGNED_URL_TTL_SECONDS from 60 minutes → 15 minutes. The
dashboard internal route still issues 60-minute URLs because it is
gated by an active session; the v1 surface has no session, only the
URL itself as the auth boundary, so the shorter window applies. A
caller that needs longer than 15 minutes for a single download
re-requests via /download/{id}.
Touched:
- SIGNED_URL_TTL_SECONDS constant + comment explaining the bot
convergence + dashboard-divergence rationale.
- Header docstring (60-minute → 15-minute).
- Registry example response (expires_in_seconds: 3600 → 900).
- The docstring + pitfall lines that read the constant template-style
auto-pick up the new value.
DISMISSED (with rationale)
- V8.2.1 "add .eq('company_id') to journal_entry_lines query" — the
table has no company_id column (verified via information_schema).
Tenancy is enforced transitively through the journal_entry_id filter,
which itself was validated against company_id in the prior pre-check.
The bot's suggested fix would not compile.
- V5.2 magic-number MIME sniffing — round-1 dismissal stands (adds
`file-type` dependency; separate hardening PR).
- Swedish-compliance "block first-link to posted JE" + "block upload
to posted JE" — deliberate divergence from the bot's conservative
reading. Attaching evidence to a posted verifikation doesn't mutate
the verifikation itself; the dashboard allows this for the same
reason. v1 keeps parity. Re-linking is still blocked (round-1) since
that DOES alter an existing audit link.
- Art.5(1)(f) / A.8.15 / Art.32(1)(b) / CC7.2 document.accessed audit
reliability — same oscillation pattern from PR-2. Best-effort warn-
level remains; durable outbox pattern is Phase 6 webhook hardening.
- Art.25(1) userId in storage path — engine-layer concern. Path is
set by lib/core/documents/document-service.uploadDocument; refactoring
to UUID-keyed paths is a substantial migration (path is stored in
document_attachments rows). Out of v1 surface scope.
- Art.5(1)(e) stray-document retention policy + CC6.3 scope policy
doc + C1.1 metadata classification — policy artifacts, not code.
Suite 3376/3376 still green; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e31aee2455 |
feat(api): Phase 4 PR-2 — engine + periods + compliance-check (docs deferred) (#469)
* feat(api): Phase 4 PR-2 foundation — async operations substrate
Checkpoint commit. Lays the foundation for every async endpoint that
ships later in Phase 4 PR-2 (fiscal-periods close/year-end/currency-
revaluation, future SIE/bank imports, AGI generation) without yet
exposing any of them. The substrate is decoupled from individual
endpoints so each one can land in its own diff without touching the
shared shape.
ADDED
- Migration `20260513200000_api_v1_async_operations.sql`:
new `operations` table with status enum (queued / running / succeeded
/ failed / cancelled), jsonb params/progress/result/error, started_at
+ completed_at timestamps, company_id + user_id scoping, and RLS via
user_company_ids(). Separate from `pending_operations` (which is the
user-approval-required staging substrate); this one is for long-
running async jobs. Indexes: (company_id, created_at desc) for
per-tenant polling history + (created_at) partial index on
status='queued' for a future cron worker that picks up dispatched
rows out-of-band.
- `lib/api/v1/operations.ts`: lifecycle helpers consumed by every
async POST endpoint. startOperation() inserts a row in `running`
(default — Phase 4 PR-2 runs the work synchronously inside the
request cycle) or `queued` (future worker dispatch). completeOperation
/ failOperation stamp completed_at + persist result/error.
updateOperationProgress is the in-flight progress writer.
getOperation reads back by id, scoped to a company.
- `app/api/v1/operations/[id]/route.ts`: polling endpoint
GET /api/v1/operations/{id}. Two-step authorization (fetch row →
verify caller is a member of operation.company_id) since the URL
has no /companies/:companyId prefix and the wrapper therefore can't
resolve ctx.companyId. Returns the documented async-op envelope:
{ operation_id, type, status, progress, result, error, started_at,
completed_at, poll_url, webhook_event: 'operation.completed' }.
- `lib/auth/scopes.ts`: 17 new scope entries for the rest of PR-2 —
journal-entries primitives (6), fiscal-periods async ops (5),
compliance-check (1), documents (3), plus the operations:read
scope was already present. Adding all up front so subsequent route
PRs only ship the route files.
- `lib/api/v1/load-routes.ts`: registers operations/[id] for the
OpenAPI generator.
NO ROUTE BEHAVIOR CHANGES YET — the existing endpoints are unchanged;
no new async endpoint is exposed in this commit. Tests 3376/3376
still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): Phase 4 PR-2 — journal-entries primitives + voucher-gap-explanations
Adds the core engine surface that the rest of v1 has been routing through
private wrappers (transactions/match, supplier-invoices/register, etc).
Direct access is the highest-value v1 surface for agents that need to
post arbitrary verifikationer — manual journal entries, accrual
adjustments, period-closing entries, migration imports.
ENDPOINTS (7)
GET /journal-entries — cursor list (period, status, date)
GET /journal-entries/{id} — detail with lines
POST /journal-entries — create draft (no voucher_number)
POST /journal-entries/{id}/commit — atomic voucher + post
POST /journal-entries/{id}/reverse — storno (BFL 5:5)
POST /journal-entries/{id}/correct — storno-then-replace pair (BFL 5:5)
POST /journal-entries/batch-create — up to 50 drafts, partial-success
POST /voucher-gap-explanations — document löpnummer gaps (BFNAR 2013:2 kap 8)
All writes are idempotent (mandatory Idempotency-Key) and dry-runnable.
ENGINE WIRING
createDraftEntry → POST /journal-entries
commitEntry → POST /{id}/commit
reverseEntry → POST /{id}/reverse
correctEntry (storno svc) → POST /{id}/correct
Strict-mode v1: every engine call is wrapped in try/catch + isBookkeepingError
discrimination so the structured error envelope (JOURNAL_ENTRY_NOT_BALANCED,
ENTRY_DATE_OUTSIDE_FISCAL_PERIOD, ACCOUNTS_NOT_IN_CHART, PERIOD_LOCKED,
ENTRY_ALREADY_REVERSED, CANNOT_REVERSE_NON_POSTED, CANNOT_CORRECT_NON_POSTED)
reaches agents instead of a generic 500.
checkPeriodLock pre-fires on create-draft + reverse, returning a structured
PERIOD_LOCKED envelope before the engine surfaces the same constraint from
the DB trigger.
DRY-RUN
- create-draft: validates balance + period + line shapes, no insert.
- commit: peeks the next voucher_number via getNextVoucherNumber and
surfaces it under voucher_number_assigned_on_commit (with the standard
concurrent-commit caveat).
- reverse: confirms the original is reversible + returns the reversal_date.
- correct: confirms the new lines balance + reports the inherited period.
- batch-create: returns per-item preview rows.
- voucher-gap-explanation: echoes the input shape.
SCHEMA
No new tables — uses existing journal_entries, journal_entry_lines, and
voucher_gap_explanations from earlier migrations. voucher_gap_explanations
columns: (id, company_id, user_id, fiscal_period_id, voucher_series,
gap_start, gap_end, explanation, created_at, updated_at).
TESTS DEFERRED
Integration tests for the journal-entries vertical land in a follow-up
commit on this branch alongside the compliance-check + fiscal-periods
work. The engine itself is heavily tested (lib/bookkeeping/__tests__/);
the route layer is a thin wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): Phase 4 PR-2 — compliance-check + fiscal-periods async ops
Ships the second-largest chunk of PR-2: gnubok's defensible-edge
compliance pre-flight endpoint and the five fiscal-period lifecycle
endpoints. Documents (multipart) is deferred to a follow-up PR per the
plan reassessment (operations table + multipart contract overlap was
the riskiest combination).
COMPLIANCE-CHECK (1 endpoint, 3 check types)
GET /compliance/check?type=<vat_close|year_end_readiness|voucher_gaps>
Single structured envelope across all check types:
{ type, ready, findings: [{severity, code, message, details}],
summary, generated_at, params, details? }
- vat_close → wraps computeVatCloseCheck (SKV 4700 rutor + blockers)
- year_end_readiness → wraps validateYearEndReadiness (BFNAR 2017:3 + ÅRL 2:1)
- voucher_gaps → wraps detect_voucher_gaps RPC
Adding a new check type only requires registering an entry in
CHECK_RUNNERS; the response shape stays stable so agents only learn
one structure. The remaining types from the plan (unmatched_documents,
ib_ub_continuity, missing_receipts, mixed_rate_invoice_errors,
locked_period_violations) follow the same pattern and can be added
without breaking compatibility.
FISCAL-PERIODS ASYNC OPS (5 endpoints)
Synchronous wrappers around the existing engine functions:
POST /fiscal-periods/{id}/lock — lockPeriod
POST /fiscal-periods/{id}/close — closePeriod (IRREVERSIBLE)
POST /fiscal-periods/{id}/opening-balances — generateOpeningBalances
Operation-recorded (return 202 + operation_id; poll /v1/operations/{id}
or subscribe to operation.completed in Phase 6):
POST /fiscal-periods/{id}/year-end — executeYearEndClosing
POST /fiscal-periods/{id}/currency-revaluation — executeCurrencyRevaluation
The two async-recorded endpoints run synchronously inside the request
cycle today; the operation row keeps the response shape stable when a
future cron worker takes over true async dispatch (just change
initialStatus from 'running' to 'queued' in startOperation).
Strict error mapping: engine throws (e.g. "Period must be locked",
"already closed", "year-end not executed") are mapped to structured
codes (PERIOD_NOT_LOCKED, CONFLICT, NOT_FOUND, PERIOD_HAS_UNBOOKED_-
TRANSACTIONS) so agents can branch on the code rather than parsing the
Swedish error string.
LOAD-ROUTES
All 6 new endpoints registered in lib/api/v1/load-routes.ts for the
OpenAPI generator. Scopes already in place from the foundation commit.
TESTS
Tests for journal-entries, compliance-check, and fiscal-periods are
deferred to a follow-up commit on this branch (alongside the
documents/multipart work, if it lands here). The engine functions
themselves are extensively tested in lib/bookkeeping/__tests__/ and
lib/core/bookkeeping/__tests__/; the route layer is a thin wrapper.
Full suite 3376/3376 green. tsc clean on new files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #469 — drop vat_close from compliance-check (core-only CI gate)
core-only.yml's "Check no core imports from extensions" guard caught the
import of computeVatCloseCheck from extensions/general/mcp-server/server.ts.
CLAUDE.md is explicit: core code cannot import from @/extensions/ directly.
Drop vat_close from SUPPORTED_TYPES for now. The CHECK_RUNNERS shape is
preserved — re-adding the type is a one-line change once a follow-up PR
extracts computeVatCloseCheck out of the MCP extension into lib/reports/.
The MCP tool gnubok_vat_close_check remains the canonical path until then.
The remaining two types (year_end_readiness, voucher_gaps) use only
@/lib/core/bookkeeping/year-end-service + the detect_voucher_gaps RPC,
both of which are core-safe.
Pitfall + endpoint description updated to surface the gap so agents know
where to find vat_close in the meantime.
Suite 3376/3376 still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #469 round-1 — compliance bot review (3 real, 2 FP, rest deferred)
First compliance-bot pass on the draft PR — Compliance Swarm 15 findings,
Swedish-compliance 6. Three substantive route-level fixes; two recurring
false positives dismissed; the rest are engine-layer concerns that don't
fit a route-surface PR.
REAL FIXES (3)
1. voucher-gap-explanations example was self-contradictory.
The example explanation cited "failed commit ... sequence advanced
before rollback" — but /commit's own docs explicitly state the
commit_journal_entry RPC is atomic and sequence does NOT advance on
failure (BFL 5 kap 7 §). The example contradicted the design
guarantee. Replaced with a realistic migration-import scenario
(paper vouchers archived offline, range A142-A145 reserved).
2. Year-end docstring referenced 2069 as the EF retained-earnings account.
Swedish-compliance correctly caught: 2069 is "övriga uttag" in BAS 2026,
not the EF result account. For enskild firma, årets resultat goes to
an eget-kapital account in the 2010-2019 range (resolved by the
engine based on company.entity_type). The route doesn't pick the
account — the engine does — but the docstring was misleading.
3. compliance-check fiscal_period_id ownership pre-check.
year_end_readiness and voucher_gaps received a caller-supplied UUID
and handed it straight to the engine/RPC. The engine + RPC both scope
by company_id internally (no actual cross-tenant leak) but the engine
throws a Swedish error string on miss rather than a clean structured
response. Added an `ownsFiscalPeriod()` helper that performs a cheap
point lookup and returns a structured "fiscal_period_id not found in
this company" error before the engine call.
DISMISSED (2 false positives)
- V8.2.1 operations route ownership — the bot read only the file header
(line 1). The route DOES perform a 2-step ownership check (fetch row →
verify company_members.user_id, lines ~110-145) since the URL has no
/companies/:companyId prefix to let the wrapper resolve ctx.companyId.
Already documented in the route's docstring.
- V8.2.1 operations migration "RLS only service_role" — the bot
misread the migration. The actual policy is:
USING (company_id IN (SELECT public.user_company_ids()))
i.e. authenticated callers can read their company's operations under
RLS. The two-step check in the route is defense-in-depth.
DEFERRED (engine-layer)
- swedish-compliance: /correct inherits original entry_date, fails when
original period is locked. Real ergonomics issue. Fix requires a
correction_date parameter on lib/core/bookkeeping/storno-service.correctEntry.
Engine signature change — out of v1 surface scope.
- swedish-compliance: 2099→2091 prior-year sweep in year-end engine.
executeYearEndClosing engine concern, not visible from the route.
- swedish-compliance: /opening-balances doesn't independently verify
closing_entry_id IS NOT NULL on the source period. Engine concern.
- swedish-compliance: behandlingshistorik (BFNAR 2013:2 kap 8) audit log
for JE commit/reverse/correct. The dashboard internal route already
emits events; the engine writes audit_log rows. Engine concern, not
per-route.
- swedish-compliance: revaluation tax_code default. Engine concern;
executeCurrencyRevaluation builds the JE lines.
- Compliance Swarm recurring architectural items (V16.1 event-bus retry,
Art.5(1)(f) userId in logs oscillation from PR-1, SOC 2 CC6.3 SoD,
etc.) — all carry-overs from PR-1 with the same dispositions.
Suite 3376/3376 still green; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #469 round-2 — Greptile review fixes (3 real, 1 FP, 1 deferred)
First Greptile pass after the PR went out of draft. Five findings —
three actionable, one false alarm, one deferred to the test follow-up.
REAL FIXES (3)
1. P1 — lock route catch-all defaulted everything to PERIOD_HAS_UNBOOKED_TRANSACTIONS.
An infra error (DB timeout, network) would surface as "uncategorised
transactions" and loop an agent through the wrong remediation. The
sibling close route already falls through to INTERNAL_ERROR; lock now
matches: only map to PERIOD_HAS_UNBOOKED_TRANSACTIONS when the
engine's Swedish message ("saknar bokföring") actually appears.
Otherwise → INTERNAL_ERROR + the original message in details.
2. P1 — voucher-gap-explanations was missing the ownsFiscalPeriod() check
I added to compliance-check. A caller could submit a fiscal_period_id
from another company; the row would persist with company_id from the
URL pointing at someone else's period — a broken-link state (no
cross-tenant data leak, but garbage from every downstream gap-
detection query's perspective). Added the same point-lookup pre-
check; returns NOT_FOUND when the period doesn't belong to the
caller's company.
3. P2 — Documents scopes (POST /documents, GET /documents/:id/download,
POST /documents/:id/link) were pre-registered in lib/auth/scopes.ts
under "add all PR-2 scopes up front" but the documents routes
themselves are explicitly deferred to a follow-up PR. Removed them;
they ship with the routes. Comment in scopes.ts records the rationale.
DISMISSED (1 false alarm)
- gen_random_uuid() vs uuid_generate_v4() — Greptile cited CLAUDE.md
rule 4. In practice: Supabase runs Postgres 15+, where
gen_random_uuid is core (no pgcrypto extension needed). The Docker
stack runs Postgres 17 per the project's docker-publish.yml.
CLAUDE.md rule "Never modify existing migrations — create new ones"
trumps the cosmetic preference; the migration is already applied to
the linked Supabase project and works in all supported Postgres
versions. Leaving as-is.
DEFERRED (1)
- P2 — *.pg.test.ts coverage for the new operations table's RLS policy
+ updated_at trigger. CLAUDE.md does require this. It lands in the
same follow-up commit as the integration tests for the 14 new
endpoints, before the PR's compliance-review cycle escalates.
Suite 3376/3376 still green; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #469 round-3 — ownership pre-checks + explicit close-route state guards
Compliance Swarm went 15→18 on round-2, mostly because the V8.2.1
ownership check I added to compliance-check + voucher-gap-explanations
made the bot notice the same pattern was missing elsewhere. Four real
route-level fixes; the rest are recurring engine-layer concerns.
REAL FIXES (4)
1. Extract `ownsFiscalPeriod` into `lib/api/v1/owns-fiscal-period.ts`.
Was inline in compliance/check/route.ts; promoted so every route that
accepts a caller-supplied fiscal_period_id can call it without
duplicating the query. Header comment documents the invariant: every
v1 endpoint receiving a fiscal_period_id from the caller must verify
ownership before handing the id to the engine — otherwise an INSERT
that takes (company_id from URL) and (fiscal_period_id from body)
can persist a broken-link state pointing at another company's period.
2. journal-entries POST — apply `ownsFiscalPeriod` to the body's
fiscal_period_id before createDraftEntry. (V8.2.1)
3. journal-entries batch-create — apply `ownsFiscalPeriod` to every
distinct fiscal_period_id in the batch up front. Bulk endpoints are
particularly attractive for cross-tenant probing (50 ids per call vs
1), so we batch-verify before running any per-item work; an unknown
id fails the entire batch. Partial-success semantics only apply
AFTER ownership is established. (V8.2.1)
4. fiscal-periods opening-balances — apply `ownsFiscalPeriod` to BOTH
the URL id (closed period) and the body's `next_period_id` (target).
Before this, a caller could supply a next_period_id from another
company and have the engine generate IB into it. (V8.2.1)
5. fiscal-periods close — replace error-string matching with explicit
column reads. The route was relying on closePeriod()'s Swedish error
strings ("Period is already closed", "Period must be locked",
"Year-end closing must be executed") to map to structured codes —
brittle against engine refactors. Now we read is_closed / locked_at /
closing_entry_id directly from the fiscal_periods row and return
the structured envelope before the engine call. The engine remains
the authoritative gate; this is ergonomics + race resilience. (V2.3)
DISMISSED / DEFERRED
- V2.3 lock route Swedish string-matching — keeping. Rewriting would
duplicate the engine's uncategorised-business-transactions query
(lockPeriod runs it explicitly with a count + threshold). Engine
re-throw with a typed error is the right long-term fix.
- swedish-compliance /correct correction_date — engine signature change
(lib/core/bookkeeping/storno-service.correctEntry needs a new param).
Deferred to engine PR.
- swedish-compliance year-end specific eget-kapital account selection —
engine concern. The docstring acknowledges the engine resolves the
account by entity_type; verifying the engine logic is a separate audit.
- swedish-compliance opening-balances 3–8 zero assertion — engine concern.
/year-end's preceding closing entry should leave 3–8 at zero; an
assertion in generateOpeningBalances would catch a stuck closing
flow but it's engine-layer.
- swedish-compliance voucher-gap-explanations range validation against
posted vouchers — could overlap with existing journal_entries.voucher_-
number values. Real audit-trail concern but adds an extra round-trip
per insert; defer.
- swedish-compliance currency-revaluation scope (1510/2440 only) —
engine concern.
- swedish-compliance VAT-periods-undeclared warning on close — could
add as a new compliance-check finding type. Tracked separately.
Suite 3376/3376 still green; tsc clean on all changed files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #469 round-4 — async-op atomicity + correct period-lock + IB dedup
Round-3 fixes converged the count but exposed five new substantive items
across the compliance bots. All five addressed.
REAL FIXES (5)
1. currency-revaluation: unconditional ownership pre-check (V8.2.1).
Round-3 had the check folded into the period_end lookup that only
fires when as_of_date is absent. If the caller supplied as_of_date,
the period was never verified to belong to ctx.companyId. Now calls
ownsFiscalPeriod() unconditionally, before startOperation.
2. year-end: ownership pre-check (V8.2.1). Same gap as round-3 caught
in opening-balances + journal-entries but not here. Added.
3. /correct: checkPeriodLock on the inherited entry_date.
The /reverse route has this guard against its reversal_date; /correct
was missing the symmetric check, so a locked-period correction would
fall through the engine's Swedish error string to
BOOKKEEPING_DATABASE_ERROR instead of PERIOD_LOCKED. The correction
trail (BFL 5 kap 5 §) is bound to typed.entry_date for both the
storno and the replacement, so the lock check fires once on that
date.
4. /opening-balances: duplicate-IB detection.
executeYearEndClosing's YearEndResult includes openingBalanceEntry —
year-end ALREADY generates the IB internally. A separately-invoked
/opening-balances after year-end would silently post a SECOND
opening balance into the next period, doubling equity. Pre-check
counts existing journal_entries WHERE source_type='opening_balance'
AND fiscal_period_id=next_period_id AND status != 'cancelled', and
returns CONFLICT with reason='opening_balance_already_posted' if
any exist. Remediation hint points at the GL endpoint to inspect
what's there.
5. /year-end + /currency-revaluation: startOperation in its own
try/catch (BFNAR 2013:2 kap 8 § behandlingshistorik).
Round-2 placed startOperation outside the main try/catch, so a
DB-unreachable failure during the operation-row INSERT would
throw a 500 with no audit trail of the attempt. Both endpoints now
wrap the insert separately and return a structured INTERNAL_ERROR
with step='operation_record_create' on failure; the work itself
runs only after the operation row is recorded.
DOCS (1)
6. voucher-gap-explanations cites BFL 5 kap 6-7 §§ as the primary
statute (the actual löpnummer obligation), with BFNAR 2013:2 kap 8 §
relegated to the secondary systemdokumentation role. Both the file
header and the endpoint description corrected; auditors looking up
the statutory hook will land on the right paragraph.
DISMISSED / DEFERRED
- swedish-compliance: operations-table immutability trigger
(BEFORE UPDATE blocking mutations once status terminal). Real
architectural concern. Requires a migration; lands in a follow-up
PR alongside the operations.pg.test.ts coverage.
- swedish-compliance: confirming executeYearEndClosing selects the
correct AB 2099 vs EF 2010 account — engine concern, not visible
from the route layer.
- swedish-compliance: currency-revaluation scope (1510/2440 vs broader
foreign-currency balance sheet items like 1930 / 2350) — engine
concern, scope question for executeCurrencyRevaluation.
- swedish-compliance: voucher-gap range overlap validation
(gap_start..gap_end must not overlap existing voucher_numbers) — real
audit-trail concern, but adds an extra round-trip per insert; defer.
Suite 3376/3376 still green; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
abb9f5868c |
feat(api): Phase 4 PR-1 — AP world (suppliers + supplier-invoices) (#467)
* feat(api): Phase 4 PR-1 — AP world (suppliers + supplier-invoices)
First of two Phase 4 PRs. Ships the public v1 AP-side verticals end-to-end,
mirroring the Phase 2 AR pattern (customers + invoices).
ENDPOINTS (13)
Suppliers:
GET /suppliers — cursor list + filters
GET /suppliers/{id} — detail, ?expand=supplier_invoices
POST /suppliers — idempotent, dry-runnable
PATCH /suppliers/{id} — idempotent, dry-runnable, can un-archive
DELETE /suppliers/{id} — soft-archive, refused on open SI
POST /suppliers/bulk-create — partial-success, max 50
Supplier invoices:
GET /supplier-invoices — cursor list + filters
GET /supplier-invoices/{id} — detail, ?expand=supplier,items,payments
POST /supplier-invoices — register + post registration JE
PATCH /supplier-invoices/{id} — registered-only
POST /supplier-invoices/{id}/approve — flip to approved
POST /supplier-invoices/{id}/mark-paid — book payment JE + flip status
POST /supplier-invoices/{id}/credit — issue kreditfaktura + reversing JE
No DELETE on supplier-invoices — withdrawal is via :credit (mirrors v1 invoices,
keeps both original AND credit note in the audit trail per BFL 5 kap 5 §).
STRICT-MODE V1
Carried forward from Phase 3 lessons:
- Any JE failure ABORTS before SI state mutation (no soft-fall / partial state).
Applies to register, mark-paid, and credit.
- checkPeriodLock() pre-check before every JE-emitting write — returns
structured PERIOD_LOCKED / SI_PAID_PERIOD_LOCKED / SI_CREDIT_PERIOD_LOCKED
instead of letting the DB trigger surface a generic 500.
- CAS-race orphan handling in mark-paid: if the SI status flips between
pre-flight and our update, the just-posted payment JE is stornoed via
reverseEntry() rather than left dangling (BFL 5 kap 5 §).
- Math.round monetary throughout. Half-öre epsilon on remaining_amount==0.
SCHEMA MIGRATION
`20260513150000_archived_at_for_customers_and_suppliers.sql`:
- Adds suppliers.archived_at (new — required for the soft-archive flow).
- Adds customers.archived_at + customers.vat_number_validated_at —
retroactively. The Phase 2 v1 customer routes (PR #451 / #452 / #460)
already reference both columns but no prior migration installed them in
production. This commit fixes that latent bug while we have the
migration open.
- Partial indexes on (company_id, created_at) WHERE archived_at IS NULL
keep the default-active list path cheap.
- is_active (legacy boolean) preserved on suppliers; v1 archive sets both
archived_at = now() AND is_active = false, un-archive flips both back
so the dashboard's "show only active" filters stay intact.
NEW ERROR CODES
SUPPLIER_HAS_INVOICES (409) — archive refused while open SI exists
SI_NOT_DRAFT (400) — update/delete refused on non-registered SI
GDPR ART.5(1)(c) DEFENSE-IN-DEPTH
SupplierType has no `individual` variant today, so org_number is always
Bolagsverket public-record data. The list endpoint still has the masking
hook (empty INDIVIDUAL_TYPES set) so a future natural-person supplier type
becomes a one-line change. Duplicate-org_number error responses NEVER echo
the submitted value — symmetric with customers.
SCOPES
13 new entries in V1_ENDPOINT_SCOPES under suppliers:read / suppliers:write.
TESTS
36 new integration cases across 2 suites:
- suppliers: list (incl. filter), get (incl. 404), create (happy + 23505 +
dry-run + missing-idempotency), patch (happy + empty body), delete
(archive + open-invoice refusal), bulk-create (partial-success + 501)
- supplier-invoices: list, get (incl. 404), create (happy accrual + supplier
404 + period-locked + strict-mode JE rollback + dry-run), patch
(registered-only), approve (happy + non-registered refusal), mark-paid
(happy + period-locked + already-paid + strict-mode abort), credit
(happy + already-credited + period-locked + dry-run)
Full suite green: 3333 passing (237 files). Build + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #467 round-2 — Greptile P1/P2 fixes
Three real findings from Greptile inline review on the Phase 4 PR-1 commit.
P1 — mark-paid: storno orphan JE when SI update fails.
When the `.update()` after the JE post returned an `updateErr`, the route
logged + returned SI_PAID_FAILED without reversing the just-posted payment
JE. The CAS-race branch immediately below already proves journalEntryId is
in scope and reverseEntry takes it directly — the original comment about
"requires fetching the entry first" was wrong. Now both error branches
(the `updateErr` DB-failure path and the `!updated` CAS-race path) storno
via reverseEntry before returning, keeping the AP ledger consistent
(BFL 5 kap 5 §). Storno failure itself logs loudly and the error envelope
surfaces the journal_entry_id so manual reconciliation has a starting
point.
P1 — credit + register: capture JE link-update result, storno on failure.
Both supplier-invoices/route.ts (register) and supplier-invoices/[id]/
credit/route.ts back-fill registration_journal_entry_id on the freshly-
inserted SI/credit-note row, but were dropping the await result. A
transient DB error there silently left the row with registration_-
journal_entry_id=null even though the JE was live on the books — the POST
response looked correct (it returned the JE id from the local variable)
but every subsequent GET /supplier-invoices/{id} showed null. Both paths
now capture the link-update error, storno the orphan JE via reverseEntry,
then roll back the SI/credit-note row before returning SI_CREATE_FAILED
/ SI_CREDIT_FAILED with step='*_link'. Strict-mode atomicity restored.
P2 — mark-paid: dry-run paid_at format alignment.
Dry-run preview set `paid_at: paymentDate` (YYYY-MM-DD), but the live
`.update()` writes `new Date().toISOString()` (full UTC timestamp). A
caller validating both responses against the same regex would have been
caught by the mismatch. Dry-run now mirrors the live shape.
P2 — ensureInitialized() finding dismissed as a false positive:
lib/api/v1/with-api-v1.ts:52 already calls ensureInitialized() at module
load. Every v1 route imports withApiV1 from that module, so the side
effect runs on first import and caches. No existing v1 route (customers,
invoices, transactions) imports ensureInitialized() directly — the
pattern has been consistent across Phases 1-3 and the AP-world routes
follow it.
Tests + build green: 3333 passing across 237 files, AP suite 36/36.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #467 round-3 — compliance swarm + swedish-compliance fixes
Both bots re-ran and converged on a set of substantive findings. Seven real
issues addressed; several recurring false positives + architectural
deferrals documented inline.
REAL FIXES (7)
1. credit: `remaining_amount` calc was nonsensical.
`Math.max(0, remaining_amount - total)` was always ≤ 0 (since
remaining ≤ total), forcing status to 'credited' regardless of paid
state — but only via the clamp, not the logic. Both swedish-compliance
and Compliance Swarm (OWASP V2.3 + SOC 2 PI1.3) caught this. A
kreditfaktura nullifies the AP obligation on the original (BFL 5 kap
5 §); refunds of already-paid amounts get a separate transaction.
`remaining_amount: 0` and `status: 'credited'` unconditionally.
2. supplier-invoices register: VAT rate whitelist.
`computeItemsAndTotals` accepted any float for `vat_rate`, silently
booking an unrecognised rate into the registration JE → momsdeklaration
Ruta 48 + INK2R. Now rejects with VALIDATION_ERROR (allowed_rates
echoed) unless the rate is in `{0, 0.06, 0.12, 0.25}` (ML 2 kap 1 §).
3. mark-paid: `exchange_rate_difference` is required for non-SEK accrual.
The pitfall docs warned about this but the code didn't enforce it. Without
it the payment JE doesn't book the FX delta to 3960/7960 and AP carries
a stranded 2440 balance after the bank line clears. Enforces with field-
level VALIDATION_ERROR; pass `exchange_rate_difference: 0` if there's
no rate movement.
4. suppliers PATCH: refuse on archived suppliers (BFL 7 kap 1 §).
An archived supplier's name/address backs historical verifikationer; a
post-archive PATCH would silently corrupt 7-year-retained räkenskaps-
information. The handler now fetches the current row, refuses identifying-
field updates when `archived_at IS NOT NULL`, and only permits the
un-archive PATCH (`archived_at: null`).
5. supplier-invoices register: smart vat_treatment / reverse_charge default.
The previous default of `'standard_25'` regardless of supplier_type left
EU/non-EU supplier rows with metadata that didn't match the actual booking
path (which uses `reverse_charge`). Now derives both fields from
`supplier.supplier_type` when the caller omits them: foreign suppliers
default to `reverse_charge: true` + `vat_treatment: 'reverse_charge'`.
Explicit body values still win.
6. reverseEntry: static import (SOC 2 CC8.1).
Replaced the three dynamic `await import('@/lib/bookkeeping/engine')`
calls in orphan-storno error branches with a top-level static import.
The dependency is now visible to SCA / tree-shake / static analysis.
7. Add `userId: ctx.userId` to every storno-failure log context (OWASP
V16.1). The CAS-race + linkErr branches now consistently include the
actor identity for security-relevant audit events.
TESTS (+6 new)
- register: rejects non-Swedish vat_rate (whitelist) → 400
- register: defaults reverse_charge=true + vat_treatment='reverse_charge'
for eu_business suppliers
- mark-paid: requires exchange_rate_difference for non-SEK accrual → 400
- mark-paid: passes when exchange_rate_difference is explicitly 0
- suppliers PATCH: refuses identifying-field edit when archived_at IS NOT NULL
- suppliers PATCH: allows un-archive (archived_at: null) flip
AP suite 42/42 (was 36). Full suite 3339/3339 green (was 3333).
DISMISSED WITH RATIONALE
- swedish-compliance "credit-note amounts should be negative" — false read
of the engine. `createSupplierCreditNoteEntry` calls `Math.abs()` on
item amounts (line 421) and posts a reversing JE; the SI row carries
positive amounts + `is_credit_note=true` as a deliberate data-model
decision. Negating would break parity with the dashboard and the
internal AP-ledger reporting.
- OWASP V8.2.1 cross-tenant via path — recurring false positive across
Phases 2-4. `withApiV1` (line ~340-350) verifies `company_members`
membership BEFORE setting `ctx.companyId` from the URL.
- OWASP V8.2.1 supplier_invoice_items company_id filter in
rollbackCreditNote — the table has no `company_id` column;
cross-tenant protection comes from RLS + the parent
supplier_invoice_id scoping.
- OWASP V4.5 PATCH allowlist schema-derivation — known architectural
deferral; centralising the field list against a Zod `.pick()` is a
separate refactor.
- GDPR Art.5(1)(f) log/event field identifiers — RoPA / log-pseudonymisation
is an org-wide privacy-eng concern, not a per-route fix.
- ISO 27001 A.8.15/A.8.16 non-blocking inserts — `supplier_invoice_payments`
insert + event emit failures stay at warn-level for v1 to mirror the
dashboard internal route. Promoting to error escalations + DLQ is a
cross-cutting reliability project, not a route patch.
- SOC 2 CC6.3 segregation-of-duties — v1's API-key scope IS the boundary
by design. Role-based separation between register / approve / pay is a
v1.x feature, not a v1 surface bug.
- swedish-compliance reverse-charge gating in credit — the engine
(`createSupplierCreditNoteEntry`) already gates the 2647/2645 reversal
on `creditNote.reverse_charge` (line 437). Mirrors the registration
engine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #467 round-4 — BFL 5 kap 5 § + remaining compliance fixes
Compliance bots re-ran on round-3 (Compliance Swarm 13→12 findings,
Swedish-compliance fresh re-read). Five real issues addressed; the rest
are recurring false positives or architectural deferrals carried over
from earlier rounds.
REAL FIXES (5)
1. mark-paid: future payment_date rejected at the schema layer.
BFL 5 kap 2 § requires bokföring to follow real cash movement;
payment_date > today is a scheduling artefact, not an affärshändelse.
Returns 400 VALIDATION_ERROR before the JE engine runs.
2. credit: drop user_id from SI_FULL_COLUMNS (GDPR Art.25).
The original SI's `user_id` (its historical creator) is never used
in the credit flow — the new credit-note row uses ctx.userId (the
actor performing the credit). Don't fetch what you don't need.
Also drops company_id from the select since it's already filtered.
3. supplier-invoices register: reverse-charge cross-field VAT check.
For reverse-charge invoices the Swedish supplier doesn't charge VAT,
the buyer self-assesses (ML 1 kap 2§ p.4b / 16 kap 6 § / 16 kap 13 §).
If `reverse_charge=true` and ANY item has `vat_rate != 0`, return
VALIDATION_ERROR — otherwise the engine would book ingående moms in
Ruta 30 / 48 (BAS 2614 / 2645 / 2641) for an invoice that has no VAT
to deduct.
4. rollbackSupplierInvoice + rollbackCreditNote: soft-mark, not delete.
BFL 5 kap 5 § — rättelse av bokföringspost måste vara dokumenterad
så att både den ursprungliga och den korrigerade noteringen är
synliga. Hard-deleting the SI row on a mid-write failure destroys
räkenskapsinformation even when the JE side (if any) is preserved
via storno. Both rollback paths now UPDATE status='reversed' +
reversed_at=now() — the SupplierInvoiceStatus enum already has
'reversed' for exactly this case ("credit note whose journal entry
was storno-reversed via Ångra kreditering" per the type comment).
Trade-off: a retry with the same supplier_invoice_number will hit
the unique-index conflict, so the caller picks a fresh number.
TESTS (+2 new)
- register: rejects reverse_charge=true with non-zero item vat_rate
- mark-paid: rejects future payment_date
Pre-existing eu_business reverse_charge test updated: item vat_rate
flipped from 0.25 → 0 to remain valid under the new cross-field check.
AP suite 44/44 (was 42). Full suite 3341/3341 green (was 3339).
DISMISSED (recurring or architectural)
- OWASP V8.2.1 cross-tenant via path — recurring false positive across
Phase 2-4. withApiV1 verifies company_members membership BEFORE
setting ctx.companyId from the URL.
- ISO A.8.3 approve-route TOCTOU — already mitigated. The UPDATE has
`.eq('status', 'registered')` as a race guard; the pre-flight is for
ergonomic error messages, not security.
- SOC 2 PI1.3 floating-point — project-wide convention is
Math.round(x * 100) / 100 per CLAUDE.md. Diverging in one route would
create a parity bug with the bookkeeping engine + dashboard. Settled.
- SOC 2 CC7.3 storno-failure alerting / ISO A.8.15 audit-log on success
/ SOC 2 CC6.1 test-fixture key / OWASP V2.2 status state-machine /
V1.2.5 dynamic select-clause / V16 audit-log silent-failure / Art.25
banking-field expand — all architectural deferrals that fit the
webhook-hardening + scope-redesign work in Phase 6, not the v1 PR.
- swedish-compliance "credit-note original-number reference" — the
`credited_invoice_id` FK is the structured back-reference; the
document-rendering layer surfaces the original `supplier_invoice_-
number` from there. Not a v1 surface bug.
- swedish-compliance "cash-basis credit-note vat_amount" — engine
behaviour mirrored from the dashboard. Engine-layer audit, separate
effort.
- swedish-compliance "active-supplier mutability broader than
archived_at" — solving this requires snapshotting supplier identity
onto each supplier_invoices row at registration (schema migration).
Deeper architectural decision; tracking for Phase 4 follow-up
alongside the journal-entries vertical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #467 round-5 — strict schema + vat_treatment normalisation +
narrow BFL archive lock
Compliance bots re-ran on round-4. Most findings are recurring (V8.2.1
cross-tenant, PI1.3 floating-point, CC6.3 SoD) or the classic oscillation
pattern from the Phase 3 lessons: this round's Art.5(1)(f) flags userId in
storno error logs as PII exposure — but last round's V16.1 demanded I ADD
userId for audit attribution. Staying with audit attribution; the bot can
pick a side.
Three substantive findings addressed.
REAL FIXES (3)
1. V4.5 mass-assignment defense-in-depth on PATCH /supplier-invoices/{id}.
The shared `UpdateSupplierInvoiceSchema` is consumed by the dashboard
too, where Zod's default key-stripping is acceptable. The v1 route now
wraps it in `V1PatchSupplierInvoiceSchema = UpdateSupplierInvoiceSchema
.strict()` so any unknown key (e.g. `status`, `company_id`, `user_id`)
returns 400 VALIDATION_ERROR instead of being silently dropped — even
if the iteration allowlist downstream is later relaxed.
2. vat_treatment normalisation when reverse_charge resolves true.
Caller could previously pass `vat_treatment: 'standard_25'` explicitly
on an eu_business supplier, and the supplier-type-driven default would
set `reverse_charge: true` while the metadata stayed as 'standard_25'.
The engine books via the boolean (so JE is correct) but a downstream
momsdeklaration / audit export reading `vat_treatment` would mis-
classify. Resolution order is now: reverse_charge first, then
vat_treatment forced to 'reverse_charge' if true; explicit overrides
only stick when they agree with the resolved boolean.
3. Narrow archived-supplier PATCH lock to identifying fields only.
The round-4 blanket lock on archived suppliers was too broad: BFL
7 kap 1 § protects räkenskapsinformation — the fields verifikationer
reference through the supplier join — but not internal notes or
payment-config metadata. The check now only refuses PATCHes that touch
{name, supplier_type, org_number, vat_number, address_*, banking_*}.
Notes, default_payment_terms, default_expense_account, default_currency,
email, and phone remain editable on archived rows.
TESTS (+3 new)
- PATCH /supplier-invoices/{id}: rejects unknown body keys (strict schema)
- POST /supplier-invoices: explicit vat_treatment='standard_25' is
overridden when supplier_type drives reverse_charge=true
- PATCH /suppliers/{id}: allows notes edit on archived supplier (BFL
narrow scope)
AP suite 47/47 (was 44). Full suite 3344/3344 green (was 3341).
DISMISSED (recurring / settled / oscillating)
- OWASP V8.2.1 cross-tenant via path — recurring false positive 4 rounds
running. withApiV1 verifies company_members membership BEFORE setting
ctx.companyId from the URL.
- GDPR Art.5(1)(f) userId in error logs — direct contradiction of
round-3's OWASP V16.1 finding which demanded userId be ADDED for audit
attribution. Phase 3 lessons document this oscillation pattern
("swedish-compliance / compliance-swarm oscillate between rounds")
and the correct response is to stay with the more security-positive
position. Keeping userId on storno-failure logs for ledger-integrity
attribution.
- SOC 2 CC6.3 segregation-of-duties — same as round-3. v1 design uses
API-key scope as the boundary; role-based actor separation is Phase 6
webhook + auth work.
- SOC 2 CC6.1 null-userId guard — redundant. withApiV1 short-circuits
with 401 UNAUTHORIZED before invoking the handler when API-key
validation fails (which is the only path that could leave ctx.userId
unset).
- SOC 2 CC7.2 storno-failure alerting — architectural; webhook-bus +
dead-letter is Phase 6 territory.
- SOC 2 / OWASP PI1.3 / V2.3 floating-point — project-wide convention
per CLAUDE.md; the engine, dashboard, and v1 all use Math.round(x*100)/100.
- ISO 27001 A.8.33 test-fixture financial amounts — synthetic UUIDs +
NODE_ENV=test guard already in place; "TEST-only" sentinel amounts
would be cosmetic.
- OWASP V16.1 eventBus failure retry / DLQ — Phase 6 webhook hardening.
- swedish-compliance arrival_number gap risk — acknowledged in commit,
bot itself says "no action required"; supplier_invoice_number retry
behavior already in the rollback-comment doc.
- swedish-compliance vat_code cross-field — engine derives JE shape from
`invoice.reverse_charge` (boolean), ignores item vat_code in the RC
path. No surface-layer leak.
- swedish-compliance credit-note FX at today's rate — bot's reasoning
inverted. The credit note REVERSES the original AP obligation; to net
2440 to zero across the original-registration JE + credit-note JE, the
SEK amounts MUST be copied from the original. FX rate at today's date
applies at the bank-refund transaction side, not the credit-note
registration.
- swedish-compliance KREDIT- prefix — dashboard parity. The
`is_credit_note` + `credited_invoice_id` flags are the structured
back-references; the prefix is cosmetic on the human-readable number.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #467 round-6 — overpayment guard + two-phase rollback +
SI_FULL_COLUMNS minimisation
Compliance Swarm trended down 12→9 findings, Swedish-compliance 6→5.
Three substantive items addressed; the rest are recurring false positives
or the userId-in-logs oscillation that round-5 already settled.
REAL FIXES (3)
1. mark-paid: reject overpayment up front (Compliance Swarm V2.3).
Previously `Math.max(0, remaining - payment)` silently truncated an
overpayment to a zero remaining_amount, while the JE engine booked the
full payment_amount against 2440 — leaving an unaccounted overpayment
on the AP ledger. Now refuses with VALIDATION_ERROR when
`payment_amount > remaining_amount + 0.005` (half-öre tolerance for
FX-rounding artefacts). Recovery hint points at :credit for
over-billing and the transactions endpoints for refunds.
2. credit: trim SI_FULL_COLUMNS to fields actually read (Art.25(1)).
The credit handler never reads notes, paid_at, payment_journal_entry_id,
transaction_id, document_id, payment_reference, paid_amount,
delivery_date, received_date, reversed_at, created_at, updated_at,
exchange_rate_date, due_date — but the projection was fetching them
all. SEK-conversion fields (subtotal_sek / vat_amount_sek / total_sek)
ARE read (copied onto the credit-note row so the 2440 reversal nets),
so they stay. Continues the round-4 user_id / company_id drop.
3. Two-phase soft-rollback (Swedish-compliance, BFL 5 kap 5 §).
The bot caught a real misapplication: BFL 5:5 only kicks in once a
verifikation has been COMMITTED. Pre-JE failures (items_insert,
engine returning null because no fiscal period covers the date) are
failed insertions, not bokföringsposter. Marking those rows
`status='reversed'` with a null registration_journal_entry_id creates
a dangling räkenskapsinformation entry that's harder to audit than a
clean removal. Both rollback helpers now take a `journalEntryPosted`
flag: pre-JE failures hard-delete (rows + items), post-JE failures
keep the round-4 soft-mark + reversed_at behaviour. Call sites tagged
per failure reason:
items_insert → false (hard-delete)
no_fiscal_period → false (hard-delete; engine returned null pre-write)
registration_je → true (conservative; engine throw could be post-commit)
je_link_failed → true (JE posted + already stornoed above)
credit items_insert → false
credit no_fiscal_period → false
credit_journal_entry → true
credit_race → true
TESTS (+1 new)
- mark-paid: rejects payment_amount > remaining_amount with VALIDATION_ERROR
(no JE engine call)
AP suite 48/48 (was 47). Full suite 3345/3345 green (was 3344).
DISMISSED (with rationale)
- OWASP V8.2.1 cross-tenant via path — recurring across 5 rounds.
withApiV1 verifies company_members membership BEFORE setting
ctx.companyId from the URL. Fix-once decision in the wrapper, not a
per-route concern.
- OWASP V4.5 strict schema (re-verification) — round-5 added
V1PatchSupplierInvoiceSchema = UpdateSupplierInvoiceSchema.strict() +
a test asserting {"status": "approved"} is rejected. The bot is
re-flagging because it can't see the upstream schema in the diff;
manually verified: UpdateSupplierInvoiceSchema only contains
{supplier_invoice_number, invoice_date, due_date, delivery_date,
payment_reference, notes}. No status / company_id / user_id field.
- GDPR Art.5(1)(f) userId in logs — same oscillation as round-4. Last
round V16.1 demanded userId be ADDED for audit attribution; this
round Art.5(1)(f) wants it REMOVED. Staying with audit attribution
per the Phase 3 lessons doc's oscillation guidance.
- OWASP V16.1 / ISO A.8.15 / SOC 2 CC7.2 SIEM alerting on storno
failure — architectural; Phase 6 webhook hardening.
- GDPR Art.25(2) supplier-expand banking fields default-on — same as
round-4. A scope split (suppliers:read:sensitive) is a v1.x scope
refactor, not a single-route patch.
- swedish-compliance VAT 0.06 date-aware validation (livsmedel 1 April
2026) — needs livsmedel BAS classification (which BAS codes signal
food) and date-aware lookup tables. Engine-layer concern; not
achievable without engine changes. Documenting the 6% rate's temporary
nature in the comment was the smaller fix already shipped in round-3.
- swedish-compliance SI_RESPONSE_COLUMNS missing reverse_charge — FALSE
ALARM. `reverse_charge` IS present in the projection (line 264 of
supplier-invoices/route.ts); the engine receives it correctly.
- swedish-compliance KREDIT- prefix — dashboard parity, dismissed
rounds 3-5. The `is_credit_note` + `credited_invoice_id` flags are
the structured back-references.
- swedish-compliance cash-basis credit-note ingående moms timing
(ML 13 kap 27 §) — legitimate gap but engine-layer. The
createSupplierCreditNoteEntry engine function handles accrual only;
adding a cash-basis-already-paid branch would change engine
semantics, divering from the dashboard. Tracking as a Phase 4 engine
follow-up, not a v1 surface bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
6c54f80a93 |
Bug/agi vacation submission (#468)
* feat(vacation): add semesterersättning option for direct vacation compensation * feat(vacation): enhance vacation rule tests for accurate salary calculations * feat(vacation): add semesterersättning rule for direct vacation compensation * feat(vacation): update vacation rule handling and add calculateVacationAccrual tests |
||
|
|
f0a2577b8b |
feat(vat-declaration): implement RC basis gap detection and correctio… (#466)
* feat(vat-declaration): implement RC basis gap detection and correction functionality * fix(vat-declaration): improve error handling and validation for RC basis account selection |
||
|
|
a9c98da243 |
feat(api): Phase 3 — transactions + reconciliation vertical (#464)
* feat(api): Phase 3 — transactions + reconciliation vertical
Closes out Phase 3 of the plan in one PR. After this, a 3rd-party agent
can fully manage a company's transaction ledger via the public API:
import bank data, walk the queue, categorize (manual / template /
counterparty / account-override), match payments to customer + supplier
invoices, reverse mistakes, and auto-reconcile the bank against the GL.
ENDPOINTS (12)
Reads:
GET /transactions — cursor list, filters
GET /transactions/{id} — detail
GET /accounts — BAS chart, class filter
GET /fiscal-periods — räkenskapsår list
Writes (single tx, idempotent + scoped):
POST /transactions/{id}/categorize — dry-run, CAS race guard
POST /transactions/{id}/uncategorize — dry-run, storno + reset
POST /transactions/{id}/match-invoice — storno conflicting JE,
payment JE, link
POST /transactions/{id}/match-supplier-invoice — incl. FX diff handling
Writes (bulk, partial-success + all_or_nothing:true → 501):
POST /transactions/ingest — up to 500 items
(CSV + custom feeds)
POST /transactions/batch-categorize — up to 100 items
Reconciliation:
POST /reconciliation/bank/run — dry-run, applies matches
GET /reconciliation/bank/status — health snapshot
All write surfaces mirror the dashboard's internal route compliance
behavior exactly — same engine functions, same Prong-B SI-match
suggestion intercept on categorize, same FX-diff handling on supplier-
invoice match, same optimistic-lock interlock on invoice status update.
No new bookkeeping primitives — every route delegates to the existing
`lib/bookkeeping/*` engine, `lib/transactions/ingest.ts`, and
`lib/reconciliation/bank-reconciliation.ts`.
SCOPES + ERRORS
Adds 12 entries to lib/auth/scopes.ts under transactions:read|write +
reports:read (accounts, fiscal-periods follow the same convention as
MCP tools). Adds 4 new error codes: TX_UNCATEGORIZE_NOT_BOOKED,
TX_UNCATEGORIZE_JE_NOT_POSTED, TX_INGEST_INSERT_FAILED,
TX_BATCH_CATEGORIZE_EMPTY.
TESTS
32 new integration cases across 5 suites:
- transactions list / detail (4)
- accounts + fiscal-periods (4)
- categorize / uncategorize / match-invoice / match-supplier-invoice (9)
- ingest + batch-categorize (7)
- reconciliation run + status (5)
plus shared happy-path and edge cases (no-income, already-linked,
malformed body, scope rejection, dry-run shape).
Full suite green: 3270 passing (234 files). Build + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #464 review — Phase 3 hardening
Greptile P1 — cursor pagination broken in GET /transactions.
encodeDefaultCursor was passed the YYYY-MM-DD `date` field, but
decodeDefaultCursor's strict ISO 8601 timestamp regex rejected it,
so every cursor decoded as null and the endpoint always returned the
first page. Switched the cursor anchor to `created_at` (real ISO
timestamp, total-orderable, unique within the company at the row
insertion grain) and updated the sort to (created_at DESC, id ASC).
The `date` column remains in every row + filterable via ?date_from /
?date_to. Updated the registry description to reflect the change.
Greptile P1 — JE soft-fall in match-invoice + match-supplier-invoice.
When the payment journal entry creation threw (any non-
AccountsNotInChartError), the catch block recorded the error string
but execution CONTINUED, marking the invoice paid + inserting a
payment row + linking the transaction with no GL entry. The dashboard
internal route soft-fails here intentionally and surfaces a banner so
the user can re-book; for the v1 surface a partial state is strictly
worse than a clean failure to retry. Both routes now return:
- INVOICE_PAID_BOOK_FAILED (match-invoice)
- MATCH_SI_RECORD_PAYMENT_FAILED (match-supplier-invoice)
before any state mutation. Removed `journal_entry_error` from both
response schemas — strict mode means it can never be set on a 200.
Greptile P1 — `overdue` supplier invoices fail the optimistic lock.
The early status guard accepted `overdue` as matchable, but the
downstream `.in('status', ['registered', 'approved', 'partially_paid'])`
excluded it, returning MATCH_SI_NOT_OPEN for a legitimately payable
invoice. Added `overdue` to the optimistic-lock list.
Greptile P1 + Swedish-compliance — CAS-race orphan cancellation.
Direct `.update({ status: 'cancelled' })` on the orphaned JE was
silently blocked by enforce_journal_entry_immutability (the engine
writes JEs as posted) and the `voucher_gap_explanations` row claimed
the entry was cancelled when it wasn't. BFL 5 kap 5 § requires
corrections via a reversing entry. Both /transactions/{id}/categorize
and /transactions/batch-categorize now call `reverseEntry()` on the
orphan; the storno pair keeps the verifikationsnummer series unbroken
so the gap-explanation insert is no longer needed.
Greptile P2 + Swedish-compliance — hardcoded category on match-invoice.
The dashboard internal route writes `category: 'income_services'` for
every matched invoice payment, overwriting any prior categorization
with a wrong BAS classification for goods sales / rental income.
Fixed by preserving the existing transaction.category if set, only
defaulting to `income_services` when the row had never been
categorized before.
Compliance Swarm V2.4 — reconciliation date range guard.
Added a 366-day cap on date_from / date_to via Zod refine. Longer
reconciliations should be paged.
Greptile P2 — dry-run dedup limitation.
Added a pitfall note documenting that the ingest dry-run only checks
external_id-based dedup; content-based dedup (date+amount against
already-booked rows) only runs in the live pipeline.
Swedish-compliance — BFL chapter typo on fiscal-periods registry.
"BFL 6 kap" → "BFL 5 kap 2 §" (the löpande bokföring deadline).
Deferred (with rationale documented):
- OWASP V8.2.1 cross-tenant via path: false positive — wrapper sets
ctx.companyId from the URL after membership check (recurring across
swarm runs).
- OWASP V4.5 select('*') on transactions/invoices: same as Phase 2 —
those rows feed engine functions that need the full shape.
- OWASP V2.3 multi-write atomicity (match endpoints): would need a
Postgres RPC; separate refactor.
- Swedish-compliance kontantmetoden partial-payment status: same
semantics as the dashboard internal route; engine-level decision
out of v1's scope.
- Greptile P3 `reversible: false` on uncategorize: technically
correct (the storno itself isn't reversible via this verb).
Tests + build green: 3270 passing, lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(import): distinguish network errors in the SIE upload step
Adds a dedicated 'network' errorType so the SIE import wizard surfaces
"Uppladdningen misslyckades" with a connectivity-focused remediation
instead of the generic 'parse' fallback (which suggested checking the
SIE file format — wrong direction when the issue is actually offline /
flaky upload).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #464 swedish-compliance re-run findings
The Swedish-compliance bot edited its existing comment in place after
the prior fix push (so created_at filtering missed the re-run). The
re-run flagged 6 new substantive findings against the post-fix code.
Fix 1 — Orphan storno failure leaves an unresolved immutability gap
(categorize + batch-categorize).
When reverseEntry() on the CAS-race orphan fails, the orphan stays
posted and untraceable. BFL 5 kap 5 § requires every correction be
traceable. Both paths now insert a voucher_gap_explanations row in
the catch branch flagging "automatisk storno misslyckades — manuell
reconciliation krävs", so the orphan is logged at the audit-trail
level rather than only in app logs.
Fix 2 — Period-lock pre-check (categorize + batch-categorize).
enforce_period_lock and enforce_company_lock_date triggers block JE
inserts on locked/closed periods, but Supabase surfaces those as a
generic 500. Added a new lib/api/v1/check-period-lock.ts helper that
performs the same check the trigger would (company-wide lock date,
is_closed, locked_at), and both routes now return a structured
PERIOD_LOCKED response (existing error code, 400) with reason +
fiscal_period_id details before the engine call. Note: this is an
ergonomics check (TOCTOU window between check and insert) — the
trigger remains authoritative.
Fix 3 — Ingest dry-run now performs content-based dedup too.
The earlier doc-only note was a compliance miss: an integrator
relying on dry-run to confirm uniqueness could ingest duplicate
affärshändelser, violating BFL 5 kap. The dry-run now runs BOTH
external_id dedup AND content-based (date+amount-against-booked)
dedup over the request's date range — same query the live pipeline
uses. Pitfall doc updated accordingly.
Fix 4 — fiscal-periods response now carries duration_days +
exceeds_18_months computed fields.
An automated client (year-end wizard, audit tool) can spot a
non-compliant period sequence (BFL 3 kap, 18-month cap) without
re-implementing date arithmetic. 549-day cap (18 calendar months)
is used to keep the comparison deterministic across leap years.
First-year exceptions still require human judgment; the boolean is
a flag, not a verdict.
Deferred (with rationale documented in commit, not retried):
- uncategorize storno memo: reverseEntry() doesn't accept a reason
parameter today and the JE-level back-reference exists already
via reversed_by_id / reverses_id. Engine signature change is
out of v1's scope.
- VAT integrity check on partial payment in match-invoice: the
behavior is fully delegated to createInvoicePaymentJournalEntry.
The bot itself recommends auditing against the engine; that is
an engine-layer concern and the dashboard internal route uses
the same path.
- 366-day reconciliation window (advisory): no statutory basis;
operational guard.
- match-supplier-invoice FX path against ML 8 kap 21–23 §
(advisory): engine-layer concern.
Tests + build green: 3270 passing, lint clean. Touched-suite tests
(transactions, fiscal-periods, accounts, reconciliation) re-run; the
fiscal-periods test asserts the new derived fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #464 round-3 review fixes (re-run after period-lock + dedup)
Both compliance bots edited their existing comments in place after the
prior fix push. New findings against the post-fix code:
Fix — VAT account suppression too broad on account_override.
categorize/route.ts dropped vat_lines for ANY class-2 override, but
BAS class 2 includes the 26xx VAT clearing accounts themselves. Result:
a user override TO a VAT account silently lost the auto-VAT line.
Tightened to `account_class === 2 && !account_override.startsWith('26')`.
The override-to-2440-leverantörsskulder case is unchanged (correctly
drops auto-VAT); the override-to-2611-utgående-moms case now keeps the
VAT line.
Fix — fiscal-periods 18-month cap uses calendar arithmetic.
EIGHTEEN_MONTHS_DAYS = 549 was a generous approximation (18 calendar
months span 540–549 days). Replaced with proper month-anchor math:
start_date + 18 months computed via setUTCMonth-style year/month
rollover, then `period_end > anchor` is the violation. Manual day-
arithmetic on the year part avoids JS's clamp-overflow on Aug-31-style
start dates. duration_days helper preserved for the response field.
Fix — match-invoice no longer hardcodes 'income_services'.
When the transaction has no prior category, the route now leaves the
field UNTOUCHED in the UPDATE (existing default 'uncategorized' or
whatever was there persists). The response surfaces null for the
uncategorized case so a caller can detect "needs human classification"
without inspecting the DB. The auto-default to income_services was
flowing into BAS 3001/3041/3530 selection mismatches and INK2R/SRU
mis-reporting for goods/rental flows. Existing-category transactions
still propagate their value.
Doc — accounts.ts BAS 5/6 description tightened.
Was "5=other costs, 6=other costs" — both true but flatten distinct
subgroups. Now spells out 5xxx (rents/supplies/services) and 6xxx
(marketing/professional/IT) under övriga externa kostnader, with a
pointer to the canonical BAS chart.
Deferred (with rationale documented):
- voucher_gap_explanations in SIE export coverage: verification ask;
SIE export audit is a separate task, not this PR's scope.
- Dry-run dedup parity with full live pipeline: my dedup matches the
live pipeline's primary checks (external_id + content date+amount
against booked rows). Achieving exact parity would need refactoring
lib/transactions/ingest.ts to expose a shared dedup helper.
- FX sign convention in match-supplier-invoice: identical to the
dashboard internal route; if the engine sign convention is wrong
both surfaces are wrong. Engine-layer audit, not v1 surface.
- OWASP V8.2.1 cross-tenant via path: recurring false positive — the
wrapper sets ctx.companyId from the URL only AFTER company_members
membership check.
- V2.3 multi-write atomicity in match endpoints: would need a Postgres
RPC; separate refactor.
- check-period-lock TOCTOU on no_fiscal_period (advisory note): the
engine's ensureFiscalPeriod helper creates an open period; if the
transaction date sits in a historical gap, the engine creates the
period unlocked. The trigger remains the authoritative gate.
Tests + build green: 3270 passing, lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #464 round-4 review fixes (compliance bot re-run)
The compliance swarm went from 20 → 10 findings after round-3, but the
swedish-compliance bot caught 5 issues my fixes introduced or didn't
fully cover.
Fix — VAT account suppression narrowed to BAS 2610–2649.
My round-3 fix exempted any account starting with '26' from VAT-line
suppression, but BAS 26xx includes 2650 (momsredovisningskonto) and
2690 (diverse), neither of which is a moms-line account. Auto-VAT
posted against 2650 would double-post on the moms reconciliation
account. Tightened the exception to the 2610–2649 range (utgående
+ ingående moms accounts only).
Fix — exceedsEighteenMonths month-end overflow.
My round-3 manual month math still passed `startD` raw to Date.UTC,
which clamps Aug 31 + 18 months to Mar 3, making the cap LATER than
the BFL 3 kap 1 § ceiling (false negative). Now clamps `startD` to
the last valid day of the target month using `Date.UTC(year, m+1, 0)`.
Fix — ingest dry-run dedup float-key normalization.
Built the content-dedup set from `${tx.date}|${tx.amount}` where
amount is a JS number stringified directly — `-349.5` from JSON vs
`-349.50` from a Postgres numeric round-trip miss-match. Normalized
both sides to .toFixed(2). SIE imports commonly carry trailing-zero
precision, so this would have caused the dry-run to under-report
duplicates (a BFL 5 kap löpande-bokföring concern: an integrator
trusting the dry-run could double-book affärshändelser).
Fix — CAS-race voucher_series fallback no longer files under 'A'.
Both categorize and batch-categorize used `voucher_series || 'A'`
for the voucher_gap_explanations row. If the orphan JE had no series,
the gap would be indexed under series 'A' and missed by any series-
specific audit query (BFL 5 kap 6 §). Now skips the gap row entirely
when no series is set — the error log already captures the orphan
for human reconciliation; filing under the wrong key is strictly
worse than not filing.
Fix — match-invoice rejects kontantmetoden partial payments.
Under kontantmetoden, utgående moms must be reported per actual
receipt (ML 13 kap 8 §). The cash-method-partial branch was falling
through to createInvoicePaymentJournalEntry (the accrual 1510/1930
clearing path), which doesn't model the per-installment moms event.
Rather than silently over-report moms, refuse with a VALIDATION_ERROR
pointing the caller to either wait for the full payment or switch to
faktureringsmetoden. Full cash-method payments still flow through
createInvoiceCashEntry (the correct kontantmetod path).
Deferred (with rationale):
- `uncategorize` resets journal_entry_id to null: dashboard parity;
the JE-side back-reference (reversed_by_id / reverses_id) preserves
the audit pair. Adding a separate reversal_journal_entry_id column
on transactions is a schema change out of v1 scope.
- OWASP V8.2.1 cross-tenant: recurring false positive.
- OWASP V2.2 inline Zod filter schemas: structural consistency
decision — kept in-route to match other v1 endpoints; a future
refactor can centralize when it justifies the cost.
- OWASP V16 add userId/companyId to storno-failure log: txLog
already carries both via ctx.log.child; not changing call-site
syntax for compliance theatre.
- Engine-layer FX sign convention in match-supplier-invoice
(advisory): identical to dashboard internal route.
Tests + build green: 3270 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): match-supplier-invoice storno conflicting JE before booking
The match-invoice route stornoes any conflicting auto-categorization JE
before posting the payment entry; match-supplier-invoice was missing
the symmetric guard. If a transaction was previously auto-categorized
(e.g. expense_office with a 5460/1930 entry), matching it to a supplier
invoice would post a second 2440/1930 entry while leaving the original
posted — two verifikationer for one affärshändelse, a BFL 5 kap 6 §
integrity violation. Storno-before-match now applies in both routes,
with the same fail-closed semantics (storno failure aborts before any
state change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
07a7964e8d |
fix(supplier-invoices): guard against duplicate payment when bank tx already booked (#461)
* fix(supplier-invoices): guard against duplicate payment when bank tx already booked
Two-pronged fix for a UX trap where a supplier invoice could be marked paid
even though the bank payment was already booked on 2440, creating a duplicate
verifikation.
Prong A — mark-paid duplicate guard: before booking, scan for an unlinked
outgoing bank transaction matching this supplier (merchant_name ILIKE) within
±2% / ±60 days. If found, return 409 SI_PAID_LIKELY_DUPLICATE with candidates
so the UI can offer "link existing" instead. Override via { force: true }.
Prong B — categorize match suggestion: when the user assigns 2440 directly on
a negative business transaction and an open supplier invoice from the same
supplier covers the same amount, return 409 TX_CATEGORIZE_SUGGEST_SI_MATCH
with candidates and route the user to match-supplier-invoice. Override via
{ confirm_no_match: true }.
Frontend dialogs added on the supplier-invoice detail page and the
transactions inbox. Partial payments skip the mark-paid guard (deliberate
action). Tests cover the 409 path, the override path, and the no-candidates
happy path on both routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): apply PR review fixes to duplicate-payment guards
- Add `is_business = true` filter to mark-paid candidate query so private
bank withdrawals don't surface as false-positive duplicates
- Escape LIKE wildcards (`%`, `_`, `\`) in both ILIKE patterns to avoid
silent over-matching when a supplier/merchant name contains those chars
- Round paymentAmount and remaining_amount to 2 decimals before the
partial-payment guard comparison to avoid float-equality fragility
- Require credit account to be in the 1xxx (bank/cash) series for the
Prong B 2440 intercept so 2440 against clearing/equity accounts isn't
misinterpreted as a supplier payment
- Extract DUPLICATE_AMOUNT_TOLERANCE_PCT (0.02) and
DUPLICATE_DATE_WINDOW_DAYS (60) into a shared helper module with the
LIKE-escape utility
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): broaden 244x match, audit log overrides, drop JE id from response
Second-round PR review fixes:
- Widen Prong B regex from /^2440$/ to /^244\d$/ so payments mapped to BAS
sub-accounts (e.g. 2441 leverantörsskulder i utländsk valuta) also trigger
the suggestion (swedish-invoice-compliance bot)
- Log a structured warning when force=true or confirm_no_match=true is honored,
with the relevant context (amount, date, accounts) so the override is
traceable per BFNAR 2013:2 kap 8 (behandlingshistorik)
- Drop journal_entry_id from the SI_PAID_LIKELY_DUPLICATE candidate response
payload (data minimization, GDPR Art.5(1)(c)); the UI never rendered it
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): third-round PR review — date window on Prong B, length cap, VAT message
- Add the missing date window to the Prong B categorize candidate query
(swedish-compliance bot): without it, an open invoice from years back can
surface as a "match" for an unrelated bank transaction. Uses the shared
DUPLICATE_DATE_WINDOW_DAYS against invoice_date.
- Cap supplier/merchant names to 200 chars before they enter escapeLikePattern
(OWASP V1.2.5 / ISO A.8.28). Bounds DB work on pathological inputs.
- Log a structured warning when the mark-paid guard is skipped because the
invoice has no resolved supplier name (BFL 5 kap 7 § — motpart should be
identifiable; the absence is itself worth surfacing).
- Update the SI-match suggestion error and the matching UI copy to call out
the actual compliance risk: a duplicate 244x posting double-deducts ingående
moms (ML 8 kap 3 §), not just bookkeeping symmetry.
- Reword "Bokför på 2440 ändå" to "Bokför på leverantörsskulder ändå" now that
the regex covers BAS sub-accounts 244x.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): correct Prong B framing — duplicate verifikation, not VAT double-deduction
Latest swedish-compliance review correctly walked back the earlier
finding that asked for ML 8 kap 3 § VAT framing. Plain 244x
categorization via account_override does not include VAT lines (account
class 2), so the risk is a duplicate verifikation (BFL 5 kap 5 §), not
a double VAT deduction. Update both the structured error message and
the dialog body to reflect the actual mechanism.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
01e99d3220 |
feat(api): v1 invoice PDF + customer bulk-create (Phase 2 PR-B-3) (#460)
Closes out the Phase 2 invoices+customers vertical. After this PR, every
write/read the dashboard does on these two resources is reachable via the
public API.
GET /api/v1/companies/{companyId}/invoices/{id}/pdf
Read-only application/pdf endpoint. Mirrors the dashboard's internal
/api/invoices/[id]/pdf so a downloaded PDF is byte-equivalent across
surfaces. Drafts render with the "faktura-utkast-<id-slice>.pdf"
filename (preview before send is a legitimate workflow); sent invoices
use "faktura-<number>.pdf"; credit notes use "kreditfaktura-<number>.pdf"
and embed the original invoice's löpnummer per ML 17 kap 22–23§
back-reference; proforma + delivery notes get their own prefixes.
Error codes: INVOICE_PDF_RENDER_FAILED (500, new),
INVOICE_SEND_COMPANY_SETTINGS_MISSING (404, reused — same condition,
same remediation).
POST /api/v1/companies/{companyId}/customers/bulk-create
Mirrors /invoices/bulk-create exactly: same `{ results, summary }`
shape, same all_or_nothing: true → 501 NOT_IMPLEMENTED contract, same
50-item cap, same sequential processing. Per-item rollback isn't
needed (customer insert is a single row), but per-item 23505 →
CUSTOMER_DUPLICATE_ORG_NUMBER failure surfaces in the results array
without echoing org_number (GDPR Art.5(1)(c): for sole traders
org_number IS the personnummer). VIES validation runs per item,
best-effort — a timeout leaves vat_number_validated=false but does
NOT fail the item.
Registry: extended EndpointDefinition.response with an optional
`contentType` field so the OpenAPI generator can emit
`format: binary` schemas for non-JSON responses. The PDF endpoint is
the first consumer; future binary endpoints (SIE export, ICS feeds)
use the same hook.
Scope catalogue: added GET .../pdf → invoices:read,
POST .../customers/bulk-create → customers:write.
Tests: 14 new integration cases (7 for PDF: sent / draft / credit-note
filename, 404, render-error 500, non-UUID 400, scope rejection; 7 for
customer bulk-create: happy path, dup-org error masking, max-50 cap,
all_or_nothing 501, dry-run preview, empty array, scope rejection).
Suite green: 3232 passing. Build + lint clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
37ccda5cad |
feat(api): v1 invoice :send + bulk-create (Phase 2 PR-B-2b-3 + PR-B-2c) (#458)
* feat(api): v1 invoice :send + bulk-create action verbs (Phase 2 PR-B-2b-3 + PR-B-2c)
Combined chunk: ship the full :send pipeline and partial-success bulk
creation in one PR, plus include the dangling /reset-password middleware
fix that completes PR-455's password-recovery flow.
POST /api/v1/companies/:companyId/invoices/:id/send
Full send pipeline mirroring the internal route, hardened for the public
API surface: email-configured check, draft-only guard, cancelled /
delivery-note / credit-note / missing-moms_ruta rejections, customer
email check, company-settings fetch, F-series invoice-number allocation
(atomic at :send per ML 17 kap 24§ p.2, not at draft create), preflight
PDF render before number consumption, final PDF render, email send,
point-of-no-return status flip, BFL 5 kap journal entry, document
archival, invoice.sent event emit. Post-send failures (journal entry,
archive) surface via a `warnings` array rather than failing the response
— the invoice IS sent at that point. Dry-run validates the pipeline +
preflight PDF without allocating a number or hitting the provider.
Error codes: INVOICE_SEND_EMAIL_NOT_CONFIGURED (503),
INVOICE_SEND_NO_CUSTOMER_EMAIL / _CANCELLED /
_COMPANY_SETTINGS_MISSING (400), INVOICE_UPDATE_NOT_DRAFT (409),
INVOICE_SEND_PDF_RENDER_FAILED / _NUMBER_ASSIGN_FAILED (500),
INVOICE_SEND_PROVIDER_FAILED (502).
POST /api/v1/companies/:companyId/invoices/bulk-create
Batch create up to 50 invoices in a single call, sequential processing,
partial success: `{ results: [{ ok, request_index, data?, error? }],
summary: { total, succeeded, failed } }`. Per-item rollback on items
insert failure (delete the parent invoice row). Emits invoice.created
per success. Dry-run wraps results in a preview without inserting.
`all_or_nothing` is accepted but reserved for a future PR.
lib/supabase/middleware.ts
Add /reset-password bypass before the authenticated-user redirect so
password-recovery sessions don't bounce to '/'. This should have landed
in PR-455 — the `git add 'app/(auth)'` filter missed the middleware
file at lib/. Without this the recovery email link silently fails for
the recipient.
Tests: 14 new integration tests across both routes (happy path,
provider failure, scope rejection, draft-only guard, dry-run shape,
bulk partial-success, max-50 enforcement, validation error). Full suite
green (3207 passing, 1 unrelated pre-existing pg-real failure).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #458 review — :send + bulk-create hardening
Greptile P1: silent zero-row update after email delivery.
PostgREST returns { error: null } on 0-row UPDATEs. The post-email
status flip used `.eq('status', 'draft')` as an optimistic lock but
never inspected the row count, so a concurrent state change (race,
double-send from another session) would leave the DB row in 'draft'
while the response claimed 'sent' and the email was already gone.
Fix: `.select('id')` after the update and check `flipRows.length`;
on 0-row miss, push STATUS_UPDATE_FAILED warning AND change the
response status to 'draft' so the caller can reconcile.
Greptile P1: re-read error swallowed; invoice_number could vanish
from the response.
After ensureInvoiceNumber, the re-read query destructured only `data`,
silently dropping `error`. A transient connection failure would leave
`numbered` null and `finalInvoiceNumber` undefined; JSON serialization
would then omit the field, violating the documented response schema.
Fix: capture `reReadErr`, log a warning, fall back to typed.invoice_number
(which was just written by the RPC and is authoritative in-memory).
Apply the same fallback at the top-level `ok()` call.
Greptile P2 + Compliance Swarm V2.3 + Swedish-compliance kreditfaktura:
reject credit notes from :send.
The :credit endpoint creates credit notes atomically in 'sent' state
with their own number — there is no v1 path that produces a draft
credit note, so reaching :send with credited_invoice_id set is misuse
or manual DB editing. Allowing it would assign an F-series number to
a kreditfaktura (ML 17 kap 22–23§ require a distinct kreditfaktura
series and a back-reference that this route would not enforce). Fix:
reject with VALIDATION_ERROR pointing at /credit. Removes a stretch
of dead code (originalInvoiceNumber lookup, kreditfaktura filename
branch) that can never execute now.
Greptile P2: all_or_nothing: true silently treated as false.
A caller asking for atomic semantics must not get partial-success
behaviour with no runtime signal. Fix: reject with new
NOT_IMPLEMENTED error (501) plus a details.field pointer. Schema
still accepts the flag for forward compatibility once a DB-side RPC
ships. New error code added to lib/errors/structured-errors.ts.
Tests: 3 new integration cases (credit-note rejection, status-flip
no-op warning, all_or_nothing 501). Suite green: 3218 passing.
Build clean, lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #458 follow-up — defense-in-depth + comment precision
OWASP V8.2.1 (bulk-create): use DB-returned customer.id at insert time
instead of input.customer_id. The .eq() pair already enforces company
scoping at fetch, but echoing the trusted value from the query makes
the guarantee explicit at the call site and immune to refactoring
drift. Same change in the dry-run preview shape.
Swedish-compliance wording: the credit-note rejection comment now
spells out BOTH ML 17 kap 22–23§ requirements — distinct kreditfaktura
series AND explicit back-reference to the original invoice's
löpnummer — so any future v1 path that does support credit-note send
starts from a complete spec.
Company-settings select: kept select('*') with an explanatory comment
rather than enumerating columns. The InvoicePDF template consumes the
full CompanySettings shape; a partial allow-list risks silently breaking
rendering, and the table has no sensitive columns today (API tokens,
billing data live in scoped tables). Documents the trade-off so the
next reviewer doesn't re-litigate.
Deliberately not changed:
- Math.round → Math.trunc on VAT öre: CLAUDE.md mandates Math.round
project-wide; unilateral deviation here would diverge from the
bookkeeping engine and POST /invoices.
- 207 Multi-Status on partial post-email failures: gnubok's convention
is warnings[] in the 200 envelope; a per-route status divergence
would break the response contract clients rely on.
- Wrapper membership double-check (OWASP V8.2.1 send route): the
withApiV1 wrapper sets ctx.companyId from the URL after the
membership check — recurring false positive in this swarm.
Tests + build + lint clean. 17/17 in the touched suites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7738f286af |
feat(settings): add toggle for displaying company name on invoice PDF… (#457)
* feat(settings): add toggle for displaying company name on invoice PDF header * fix(migrations): rename duplicate-timestamped migration to unique version Two migrations shared timestamp 20260513120000, causing schema_migrations PK collision (SQLSTATE 23505) on apply. Bump the VAT seed migration to 20260513120100 so both insert cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3fa871c742 |
Bug/accounting suggestion (#456)
* feat: add bike benefit handling and optional vacation accrual - Introduced bike benefit (cykelförmån) with calculations for annual market value and monthly taxable value. - Updated schemas to include new benefit types and validation rules. - Implemented API routes for creating, updating, and deleting employee benefits. - Enhanced salary calculation logic to accommodate new vacation rule options, including a 'none' option for no accrual. - Added UI components for managing employee benefits, including input for bike benefit specifics. - Created database migrations for employee benefits and updated salary line items to support new benefit types. * chore: remove Langfuse env var checks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: enhance OAuth callback URL handling and update default scopes for Visma integration * feat: remove trade_name field and simplify company naming in invoices * refactor: destructure canWrite from useCanWrite for consistency across components * feat: enhance PATCH endpoint to validate existing benefits and handle bike benefit updates * feat: add missing label for bike benefit in salary line item types --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cd96e5ec26 |
feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined) (#455)
* feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined)
Bigger PR per the user's request. Lands the remaining two journal-entry-
centric action verbs together — they share the same lifecycle pattern
established in :mark-sent (idempotent, dry-runnable, scope-gated,
warnings on partial-state failures).
POST /api/v1/companies/:companyId/invoices/:id/mark-paid
- Books a payment against a sent / overdue invoice. Updates status to
paid (or partially_paid when remaining_amount > 0). Three booking paths:
- Faktureringsmetoden (accrual default): Debit 1930 / Credit 1510 via
createInvoicePaymentJournalEntry — settles AR.
- Kontantmetoden (cash): Debit 1930 / Credit revenue + Credit VAT via
createInvoiceCashEntry — revenue recognition happens HERE under cash.
- Custom lines (partial payment): caller-supplied balanced journal lines
via createJournalEntry directly. Validated for balance (sum debits ==
sum credits, both > 0) → 400 INVOICE_PAID_LINES_UNBALANCED otherwise.
- Optional body: { payment_date?, exchange_rate_difference?, lines? }
- Race-condition guard: status update matches .in(['sent','overdue',
'partially_paid']) so a concurrent payment returns 409 INVOICE_PAID_RACE.
- Emits invoice.paid (new event type, added to lib/events/types.ts with
paymentAmount + paymentDate in the payload).
POST /api/v1/companies/:companyId/invoices/:id/credit
- Issues a kreditfaktura against a sent / paid / overdue invoice
(ML 17 kap 22–23§). Creates a NEW invoice row with:
- invoice_number = "KR-<original>"
- credited_invoice_id = original id
- status = 'sent'
- All amounts negated (subtotal, vat_amount, total, items quantities/totals)
- Items mirror the original with negated values; inserted in a separate
step with company-scoped rollback DELETE on failure.
- Flips original invoice to status='credited'. Warns ORIGINAL_NOT_FLIPPED
if the flip fails (the credit note still exists; operator reconciles).
- Posts reverse journal entry via createCreditNoteJournalEntry (accrual
only; cash basis defers to refund time).
- Emits credit_note.created (existing event in the bus).
Both endpoints:
- Use the established wrapper + Idempotency-Key + dry-run + warnings
pattern from :mark-sent.
- Validate document_type (no delivery_notes), credited_invoice_id (no
recursive credits), and status before any mutation.
- Use explicit column projections (no SELECT *).
- Sanitize pg_message from client responses (kept in logs).
- Emit error-level logs on partial-state failures + surface warnings to
the caller via meta.warnings.
Event types union (lib/events/types.ts) gains invoice.paid; credit
uses the existing credit_note.created event.
URL convention: plain /verb subpaths (e.g. /invoices/:id/mark-paid),
consistent with :mark-sent. Stripe/QuickBooks pattern, not the
AIP-style :verb that Next.js routing fights.
17 new tests covering happy paths (accrual + cash for mark-paid),
custom-lines balance validation, dry-run preview, document-shape
guards, scope, idempotency, race conditions, and credit-of-credit /
delivery-note rejection.
3194/3194 vitest pass; build clean; lint clean on v1 paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #455 review + include password-recovery fixes
PR #455 review fixes:
- Greptile P1 (CLAUDE.md architecture rule): API routes that emit events
via eventBus must call ensureInitialized() at module level to wire
extension event handlers. Neither :mark-paid (invoice.paid) nor :credit
(credit_note.created) had it — nor did the already-merged :mark-sent,
POST /invoices, POST /customers, etc. Fixed once at the wrapper layer:
ensureInitialized() now runs at module import of lib/api/v1/with-api-v1.ts,
so EVERY v1 route gets the init at import time. Single source of truth
prevents future routes from forgetting (idempotent guard makes the
repeated call safe). Cleaner than per-route copy of the call.
- Swarm PI1.3 (low): 0.005 epsilon in mark-paid was undocumented. Added
a comment explaining: after rounding to 2 decimals, newRemaining is in
steps of 0.01; values ≤ half-an-öre only arise from float artefacts.
Pushing back (recurring triage, consistent with prior PRs):
- V8.2.1 + CC6.3 × 4 "ctx.companyId vs params.companyId mismatch" —
impossible by construction. The wrapper sets ctx.companyId FROM the URL
params after the membership check. They are guaranteed equal.
- V2.3 + A.8.15 + A.8.28 atomicity / floating-point / partial-failure
alerts — same architectural / cross-surface deferred work as prior PRs;
matches internal /api/invoices pattern precisely.
- V4.5 account_number allowlist — engine validates it.
- V2.4 idempotency TOCTOU — wrapper handles via DB unique constraint.
- Art.5(1)(f) PII in logs, A.8.11 dry-run preview scope, A.8.15 partial-
failure naming, test scope coverage — all recurring triage.
Password-recovery flow fixes (included per request — pre-existing
working-tree changes the user authored):
- app/(auth)/auth/callback/route.ts: when the callback exchanges a
recovery token (type='recovery' or next='/reset-password'), redirect
directly to /reset-password instead of running onboarding/MFA/
dashboard checks. Previously users clicking the password-reset email
got bounced through onboarding.
- lib/supabase/middleware.ts: /reset-password no longer bounces
authenticated users to / (the recovery flow lands here with a fresh
session by design — the user is *supposed* to call updateUser({
password }) on this page).
- app/(auth)/login/page.tsx: shows an error banner when ?error=auth_error
is set (expired/used recovery link), with a button to request a new
one. Wrapped the page in <Suspense> because useSearchParams() now
forces dynamic rendering (Next.js 16 static-prerender bail-out
otherwise).
- app/(auth)/auth/callback/__tests__/route.test.ts: new test file
covering the recovery callback path.
3197/3197 vitest pass (3194 prior + 3 from the new auth-callback tests).
Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): mark-paid uses remaining_amount as default payment, not total
Real correctness fix from Swedish-compliance review on PR #455. When no
customLines is supplied, mark-paid previously defaulted paymentAmount to
typed.total. Combined with the race-condition guard that allows the
status UPDATE to flip a partially_paid invoice to paid, this could
over-credit AR in a race scenario:
1. Invoice in 'sent' status, total=12500, remaining=12500.
2. Concurrent partial payment lands first → status='partially_paid',
remaining=7500.
3. The full-payment request's pre-flight saw 'sent' and passed; its
UPDATE matches partially_paid (race guard allows it). With the old
logic the journal entry was for total=12500 against an AR balance
of only 7500 — a 5000 over-credit.
Using remaining_amount as the default eliminates this. Same end state
in the common case (no prior partial); correct booking in the race.
3197/3197 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e4186523f9 |
feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1) (#454)
* feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1)
First invoice action verb. Transitions a DRAFT invoice to 'sent' status —
intended for invoices delivered outside gnubok (Peppol, postal, custom
SMTP). The full :send pipeline (PDF + email) builds on top of this in
PR-B-2b-3.
URL convention: plain /verb subpath (e.g. /invoices/:id/mark-sent), not
the AIP-style :verb suffix the plan originally proposed. Next.js routes
don't support `:` in folder names, and the Stripe/QuickBooks idiom is
plain subpaths anyway. The agent-facing docs can still describe the
action however we want.
What happens on commit:
1. F-series invoice_number allocated atomically via the
generate_invoice_number RPC (per the PR-B-2a design — drafts have
invoice_number=null until this transition, preserving the unbroken
löpnummer series required by ML 17 kap 24§ p.2).
2. Status flips draft → sent.
3. For accrual + real invoices, posts the invoice journal entry via
createInvoiceJournalEntry (Debit AR 1510 / Credit revenue 3xxx /
Credit output VAT 26xx). Cash basis skips this; booking happens at
payment time.
4. Writes journal_entry_id back onto the invoice row.
5. Emits invoice.sent.
Race-condition guard: the status update matches .eq('status', 'draft'),
so a concurrent transition between pre-flight and update returns 409
INVOICE_UPDATE_NOT_DRAFT.
Dry-run: returns a preview of the post-send invoice state including a
would_create_journal_entry flag and the resolved accounting_method.
invoice_number can't be predicted exactly (atomic sequence allocation)
so the preview shows a marker rather than a fake number.
PDF archival is deliberately NOT in this PR. The internal route does
it, but PDF rendering + document upload is a meaningful surface area
that belongs with :send (PR-B-2b-3) where email + PDF land together.
Test infrastructure: the makeFlexibleSupabase mock now supports
per-table result QUEUES (array form returns results in order across
multiple calls; single value returns same result every time). Required
to mock the pre-flight read (status=draft) and post-update read
(status=sent) on the same `invoices` table inside one request.
9 new tests covering happy path, idempotency, scope, draft-only guard,
delivery-note rejection, 404, UUID validation, dry-run preview shape,
and cash-method skip. 3174/3174 vitest pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #454 review (Greptile + swarm + Swedish compliance)
Real bugs / contract violations:
- Greptile P1 (guard order): the delivery_note guard ran AFTER the
status check, so a sent delivery note returned 409 instead of the
documented 400. Reordered: document-shape guards (delivery_note +
credit_note + missing moms_ruta) now run before the status check.
- Greptile P1 (journal_entry_id write-back): Supabase returns
{ data, error } and never rejects on DB errors, so a write-back
failure produced no log and left the invoice with a real journal
entry but no pointer. Now destructured + escalated to error log AND
surfaced as a warning in the response.
- Swedish: credit notes (credited_invoice_id !== null) were not
rejected — they would have been posted via createInvoiceJournalEntry
with the wrong sign (Debit AR / Credit revenue instead of the
inverse). Now explicitly rejected; credit-note path goes through
POST /:id/credit (PR-B-2b-4).
- Swedish: moms_ruta now validated in the pre-flight. A null value
would silently default to 25% domestic in the journal-entry
generator — wrong for reverse-charge / EU-service / zero-rated
invoices. Real ML 17 kap 24§ concern.
Partial-state visibility (Swedish + Swarm V2.3 + A.8.15 + PI1.3):
The response now carries an optional `warnings: [{ code, message }]`
field when the status flip succeeded but a follow-up step failed
(journal entry creation, event emission, or journal_entry_id write-
back). Three warning codes:
- JOURNAL_ENTRY_NOT_POSTED — verifikation missing; BFL 5 kap
reconciliation required
- JOURNAL_ENTRY_ID_WRITEBACK_FAILED — entry exists but invoice row
has no pointer
- EVENT_EMIT_FAILED — webhook subscribers may miss this transition
All three escalate to error-level logs. The architectural fix
(transactional Postgres RPC that bundles allocation + status flip
+ journal entry) is tracked as cross-surface compliance work; the
warnings field is the agent-facing signal until that lands.
The F-series race window (number allocated before status flip; a
concurrent transition can leave a consumed-but-orphaned number, ML
17 kap 24§ p.2 gap) is now explicitly documented in the route
docstring rather than hidden in implementation. Same residual issue
exists in the internal route; fix needs the transactional RPC.
Pushing back (consistent with prior triage):
- V8.2.1 explicit ownership check (wrapper handles — false positive)
- Cross-tenant IDOR test (duplicates wrapper test coverage)
- Pseudonymise IDs in logs (operational value > theoretical risk)
- Structured audit event sink (current ctx.log.info IS structured)
- Test fixture A.8.33 (NODE_ENV guard in place; Acme AB is canonical
synthetic placeholder)
- Projection column narrowing (fields ARE used in the flow)
- company_settings hard-fail on miss (accrual default is normal)
3 new tests covering credit-note rejection, missing moms_ruta, and
the journal-entry-failed warnings path. Plus the delivery-note test
now asserts the guard ordering works for sent delivery notes too.
3177/3177 vitest pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e96cbe05d0 |
feat(api): v1 invoice draft writes (Phase 2 PR-B-2a) (#453)
* feat(api): v1 invoice draft writes (Phase 2 PR-B-2a)
POST /api/v1/companies/:companyId/invoices creates a draft invoice,
proforma, or delivery note. Reuses the established v1 discipline:
- Idempotency-Key mandatory (wrapper option).
- Dry-runnable: ?dry_run=true returns the validated would-be invoice +
computed items with VAT totals; no DB writes, no number allocation,
no event emission.
- Explicit column projections (no SELECT *).
- Per-item VAT rate validated against the customer's allowed rates from
getVatRules() — mixed-rate invoices supported.
- Currency conversion via fetchExchangeRate() (best-effort, non-fatal).
- F-series number allocation via ensureInvoiceNumber() with soft-cancel
rollback if allocation fails — preserves sequence integrity for
ML 17 kap 24§ (no gaps in F-series).
- invoice.created event emitted for real invoices (not proformas /
delivery notes).
PATCH /api/v1/companies/:companyId/invoices/:id updates a DRAFT invoice's
metadata fields only:
- Allowed: invoice_date, due_date, delivery_date, your_reference,
our_reference, notes.
- NOT allowed (intentional): customer_id, currency, document_type, items,
status. Structural changes go through delete-and-recreate (drafts are
cheap); status transitions via the action verbs in PR-B-2b.
- 409 INVOICE_DELETE_NOT_DRAFT if the invoice has already been sent /
paid / credited / cancelled. The error code is shared with DELETE
(reused rather than introducing a new "not draft" code).
- Race-condition guard: the .update() also matches .eq('status', 'draft')
so a concurrent :send between pre-flight and write returns the same 409.
Dry-run for invoice DRAFT create uses dryRunPreview() (validation-only)
rather than dryRunStaged() — drafts have no journal-entry side effects
yet, so there's nothing to stage in pending_operations. The dryRunStaged()
helper from PR-B-1 stays unused this PR; PR-B-2b's :send will be its
first real consumer (voucher number, journal lines, account deltas).
Tests: 12 new (5 POST + 7 PATCH) covering happy path, customer not
found, VAT rate violation, dry-run preview shape, scope enforcement,
Idempotency-Key requirement, draft-only PATCH guard, forbidden field
rejection, UUID validation, empty body. Stubs ensureInvoiceNumber and
fetchExchangeRate to keep tests deterministic.
3165/3165 vitest pass; build clean; lint clean on v1 paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #453 review (Greptile + swarm + Swedish compliance)
Real fixes (all reviewers agreed):
- Greptile P1 + SOC 2 CC6.3: PATCH was reusing INVOICE_DELETE_NOT_DRAFT
(httpStatus 400) for a semantically different operation; docstrings +
tests claimed 409 while code returned 400. Introduced
INVOICE_UPDATE_NOT_DRAFT with httpStatus 409 in structured-errors.ts.
PATCH now returns 409 consistently; test name and assertion aligned.
- Greptile P1: POST rollback DELETE on items-insert failure now scoped
by company_id (defense in depth) AND its error is destructured/logged
so a double-failure is visible in audit trails (was previously silent
on the rollback path).
- Greptile P1: refetch error after invoice insert is now logged with
invoiceId + companyId at warn level; the response gracefully falls
back to the header-only shape rather than misleading the agent with
a 5xx (the data WAS committed).
GDPR Art.5(1)(f) × 2, ISO A.8.11 × 2, SOC 2 CC7.2 × 2: client-facing
error responses no longer echo raw Postgres pg_message strings (which
can interpolate field values from constraint detail). pg_code is kept
in the response (machine-readable, no PII leak); pg_message moves to
the internal structured log entry only. Applies to
INVOICE_CREATE_INSERT_FAILED and INVOICE_CREATE_ITEMS_FAILED.
OWASP V2.2: defensive UUID validation on ctx.companyId at POST handler
entry. The wrapper already validated membership, but mirroring the
detail-route's pattern for path params eliminates a class of edge-case
queries with malformed predicates.
Swedish compliance (ML 17 kap 24§ p.2 — most substantive finding):
ensureInvoiceNumber is NO LONGER called at draft-create. The doc string
already said "F-series invoice_number is allocated atomically on the
first send action (PR-B-2b)" but the code contradicted it by allocating
at POST. Code now matches intent: drafts (invoices and proformas) keep
invoice_number=null until :send. Delivery notes continue to allocate
their separate D-series number on insert (different sequence, no F-series
gap concern). This eliminates the soft-cancel path entirely for the
common case where a user creates and abandons a draft — no more legal
gaps in the löpnummer series from ordinary workflow.
Pushing back on:
- Atomicity / Postgres RPC wrapping (V8.2.1 × 2, CC6.1) — substantial
refactor; the existing internal /api/invoices POST has the identical
multi-step pattern; not a v1 regression. Track for a future RPC-
consolidation PR across both surfaces.
- Float-point VAT rounding (V2.3, Swedish #3) — matches internal route
precisely; consistency over premature decimal-library migration.
- TOCTOU rewrite to single UPDATE-WHERE-RETURNING (V8.2.1, CC6.1) —
current pre-flight + scoped UPDATE is correct; the suggested cleanup
is stylistic.
- PATCH response verbose projection (A.8.3, Art.25) — consistency with
detail endpoint; the agent that just updated likely wants the full
record back.
- per-line moms_ruta (Swedish #4) — schema migration; the existing
header-only column is what the codebase has.
- Event emission failure alerting (A.8.15) — defer to PR-C webhooks.
- Test fixture A.8.33 — already addressed (NODE_ENV guard at test
bootstrap, clearly synthetic UUIDs).
Test fixture UUID v4 fix: COMPANY_ID upgraded to proper v4 format
(was 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', which fails Zod 4's
.uuid() version-digit check now that the POST handler validates
companyId).
3165/3165 vitest pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d29b87fb80 |
feat(api): v1 customer writes (Phase 2 PR-B-1) (#452)
* feat(api): v1 customer writes (Phase 2 PR-B-1)
First slice of Phase 2 PR-B from the agent-native v1 plan. Customer writes
are the simplest write surface — no journal entries, no PDF, no email —
which makes them the right place to validate the dry-run + idempotency
pipeline before applying it to invoice flows in PR-B-2.
New endpoints:
- POST /api/v1/companies/:companyId/customers (idempotent, dry-runnable)
- PATCH /api/v1/companies/:companyId/customers/:id (idempotent, dry-runnable)
- DELETE /api/v1/companies/:companyId/customers/:id (soft-delete via
archived_at; idempotent re-archiving; dry-runnable; 204)
All three require customers:write scope (added to V1_ENDPOINT_SCOPES). All
three require Idempotency-Key (mandatory — wrapper option
requireIdempotencyKey: true). All three accept ?dry_run=true or
X-Dry-Run: true and return a 200 OK preview with X-Dry-Run header set.
New shared infrastructure:
- lib/api/v1/dry-run.ts — dryRunPreview() (validation-only, no staging) and
dryRunStaged() (financial writes; populated in later phases). Defines the
preview response shape that POST/PATCH/DELETE share. Future financial
writes (invoices, journal entries) will reuse the staged variant.
Pre-existing PR-A bugs fixed:
- CustomerType enum in customers/route.ts had wrong values ('business',
'eu_individual', 'non_eu'). Canonical enum is ['individual',
'swedish_business', 'eu_business', 'non_eu_business']. Fixed.
- INDIVIDUAL_TYPES masking referenced non-existent 'eu_individual'.
Only 'individual' refers to a natural person (Swedish sole trader where
org_number = personnummer).
Wrapper bug fix:
- The idempotency body-hashing flow was consuming the original request's
body before passing it to the handler. In Node's vitest environment the
cloned request's body became empty, so the handler's request.json()
returned {}. Fix: read body from a clone for hashing, leave the
original intact for the handler.
VIES re-validation on PATCH preserves existing best-effort behaviour.
Customer.created event emission on POST so future webhook delivery
(Phase 2 PR-C) can subscribe.
23 new tests covering happy path, dry-run preview, idempotency-key
enforcement, scope checks, UUID validation, duplicate-org conflict,
soft-delete semantics. 3150/3150 vitest pass; build clean; lint clean
on v1 paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #452 review (Greptile + swarm + Swedish compliance)
Real bugs (all reviewers agreed):
- POST registerEndpoint example used the old customer_type 'business'
enum value that this PR was already fixing. Now uses 'swedish_business'
consistently between code, schema, and docs.
- DELETE docstring claimed "Refuses to delete if open invoices remain"
but the handler unconditionally archived. Swedish compliance flagged
this as a real ML 17 kap 24§ concern (archived customer + open
invoice can block kreditfaktura issuance). Added the pre-flight check:
DELETE now returns 409 CUSTOMER_HAS_INVOICES with the open count when
any open invoice (sent / partially_paid / overdue) references the
customer. Docstring updated to match.
- PATCH advertised archived_at: null for un-archive but did not actually
apply it (field missing from updateData iteration). New
V1PatchCustomerSchema extends UpdateCustomerSchema with archived_at
restricted to literal null — agents can un-archive but cannot fake
archive timestamps. The PATCH allowlist now includes archived_at.
Defensive cleanups:
- POST: VIES validation now resolves BEFORE the insert so
vat_number_validated is set atomically in the primary write. Eliminates
the stale-response window where the response could show
vat_number_validated=false even though the secondary update succeeded.
- PATCH: same pattern — VIES re-validation folded into the primary
update payload. Single round-trip; response always reflects committed
DB state.
- CUSTOMER_RESPONSE_COLUMNS dropped vat_number_validated_at (internal
timestamp; not declared in the CustomerCreated or CustomerDetail Zod
schemas; no documented consumer).
Pushing back on:
- V8.2.1 cross-tenant membership check — false positive. The wrapper
performs the company_members check before invoking the handler
(lib/api/v1/with-api-v1.ts ~232). The swarm read handlers in isolation.
- A.8.11 PATCH response masking for individual customer_types —
deliberate detail-endpoint carve-out per PR-A. List masks, detail
doesn't; that's the design.
- CC6.3 separate customers:delete scope — every accounting API
(Stripe, QuickBooks, Fortnox) conflates write + archive. Splitting
violates principle of least surprise.
- V4.5 schema allowlist enforcement — Zod already strips unknown keys;
defense-in-depth at the DB-write layer is redundant.
- A.5.34 eu_individual masking documentation — the value never existed
in canonical CustomerTypeSchema; PR-A's enum was a hallucination this
PR corrects. Nothing to document beyond the code comment that's now
in place.
- Swedish: country default 'Sweden' wrong for non-Swedish customer types —
breaking schema change; defer.
- Swedish: VAT-format regex pre-check before VIES — internal
/api/customers route doesn't either; consistency over micro-validation.
- Swedish: flag existing reverse-charge invoices when VIES turns
vat_number_validated=false — substantial cross-resource workflow;
defer to PR-B-2 or a dedicated compliance-tooling PR.
- CC7.2 customer.updated / customer.archived event emission — adding new
event types touches lib/events/types.ts AND the event-log-handler
allowlist; defer to PR-C where webhooks will be the consumer.
3 new tests cover: archive-blocked-by-open-invoices, PATCH archived_at
un-archive, PATCH archived_at rejects non-null. 3153/3153 vitest pass;
build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): second-pass review on PR #452 — defense-in-depth tweaks
Two small adjustments after the second compliance-swarm sweep (13 →
expected 11 findings, 0 blocking after this commit):
- GDPR Art.5(1)(c) defense-in-depth: re-add 'eu_individual' to the
INDIVIDUAL_TYPES masking set in the customer LIST handler. The value
is not in the canonical CustomerTypeSchema (so new customers can never
have it), but the `customer_type` DB column carries no CHECK constraint,
so legacy rows from earlier schema iterations could in principle hold
it. Masking is free when the value never appears and protective if it
ever does. Adding 'eu_individual' as a first-class customer_type for
EU natural persons remains a separate product decision the Swedish
compliance review surfaced.
- ISO 27001:2022 A.8.33: test bootstrap now asserts NODE_ENV === 'test'.
Supabase clients are fully mocked, but if a future test refactor
accidentally bypassed the mock, this guard fails the run rather than
letting fixtures reach production.
Stale comments from prior sweep — no action needed, fixes already in
commit 5bb63489:
- Greptile P1 "DELETE doesn't check open invoices" — handler now does
(lines ~430-440 of [id]/route.ts); the inline comment is pinned to
the original file lines and hasn't auto-resolved.
- Greptile P1 "PATCH archived_at silently ignored" — V1PatchCustomerSchema
now accepts archived_at: z.null().optional() and the field is in the
iteration list.
- Greptile P1 "example uses old enum" — updated to 'swedish_business'.
False positive called out:
- OWASP V2.4 "dry-run DELETE skips the open-invoice pre-check" — the
pre-flight runs BEFORE the dry-run branch ([id]/route.ts ~430-445),
so dry-run DELETE on a customer with open invoices DOES return 409.
Pushing back (consistent with first-pass triage):
- V8.2.1 × 3 cross-tenant check — wrapper does it (with-api-v1.ts ~232);
false positive.
- V2.3 / CC6.3 archive scope split — every accounting API conflates
write + archive.
- V4.5 customer_type cross-field invariants — schema-level cross-field
validation; defer.
- V16 transactional outbox — architectural; defer to PR-C webhooks.
- Art.25 + A.5.12 + A.5.34 PATCH/dry-run preview masking — single-record
detail context, deliberate carve-out per PR-A.
- Swedish 'disputed' status — not in canonical InvoiceStatus enum.
- Swedish 3-state VIES validation, personnummer-format check, mandatory
country for non-SE types — all schema-level / cross-field; defer.
3153/3153 vitest pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): don't echo org_number in 409 conflict response (GDPR Art.5(1)(c))
For customer_type='individual', org_number IS the Swedish personnummer.
The 409 CUSTOMER_DUPLICATE_ORG_NUMBER error detail previously included
the submitted value, transmitting it through:
- The HTTP response body
- Server / observability logs
- Any HTTP intermediary recording bodies
The caller already knows what they submitted; the error code + a
{ field: 'org_number' } hint is enough. Drops the value from the detail
in both POST /customers and PATCH /customers/:id.
3153/3153 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
32ad6da28c |
feat(api): v1 invoice + customer reads (Phase 2 PR-A) (#451)
* feat(api): v1 invoice + customer read endpoints (Phase 2 PR-A) First slice of the Phase 2 invoices vertical. Read-only endpoints landing in this PR; writes + webhooks land in PR-B and PR-C. After all three a developer can ship an end-to-end invoicing integration. New endpoints (all wrapped, scoped, cursor-paginated): - GET /api/v1/companies/:companyId/invoices — list, filters: status, customer_id, document_type, currency. Cursor on (invoice_date DESC, id DESC). Customer name embedded inline; ?expand=customer for full record, ?expand=items for line items. - GET /api/v1/companies/:companyId/invoices/:id — detail with embedded customer. ?expand=items,payments. - GET /api/v1/companies/:companyId/customers — list, filters: customer_type, search (name/org_number prefix), include_archived. Cursor on (created_at ASC, id ASC). - GET /api/v1/companies/:companyId/customers/:id — detail. ?expand=invoices embeds open invoices in a single round-trip. Shared infra: - lib/api/v1/expand.ts — parseExpand() validates ?expand=a,b,c against a per-endpoint allowlist; unknown keys yield VALIDATION_ERROR with the full invalid list and the allowlist (agent-friendly). - All four routes register with the Zod schema registry so they show up in /api/v1/openapi.json with x-action-risk and use-when / do-not-use-for metadata. - Compound keyset filter on both list endpoints (per Greptile review on PR #450) — no skipped or duplicated rows on page boundaries. Tests: - lib/api/v1/__tests__/expand.test.ts (8 tests) - app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts (10 tests) - app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts (8 tests) Full repo suite green (3127/3127), build clean, lint clean on v1 paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): address PR #451 review (Greptile + compliance swarm) - Greptile P1 (customers search) + OWASP V1.2.5: customer search term now escapes both PostgREST .or() delimiters (,()) AND SQL LIKE wildcards (% _ \). '100%' searches for the literal string instead of any customer containing '100'. - OWASP V8.2.1 + V16.1: detail endpoints now UUID-validate the :id path param before touching the database, and no longer echo the raw id in the NOT_FOUND response details. Adds a structured warn log on 404 with the queried (id, companyId) for audit purposes. - V4.5 + Art.25(1) + A.8.3 / A.8.11 + CC6.3 + PI1.3 (~12 findings): every select('*') replaced with explicit column lists per the documented Zod schemas. Includes joined sub-queries — customer:customers(...), items:invoice_items(...), payments:invoice_payments(...). Future schema migrations adding sensitive columns must now update these projections before the field becomes visible on the public API. - A.8.5: hardcoded 'Bearer gnubok_sk_x' in test fixtures replaced with 'Bearer test-fixture-not-a-real-key' to avoid false-positive secret scanner alerts. Fixture UUIDs upgraded to valid v4 format (Zod 4's .uuid() enforces version+variant digits). - Art.5(1)(f): customer-invoices expansion soft-degrade now logs only the error code + message rather than the full Supabase error object. Pushing back on: - Art.5(1)(b) org_number in customer list — Bolagsverket-public data (same triage as PR #450; required by integration use case) - Art.25(2) customer_name always-joined — denormalising via trigger is a real schema migration for a marginal data-flow gain - A.8.15 _partial flag on soft-degrade — ?expand is documented as a hint 50/50 v1 tests; 3131/3131 full suite; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): second-pass review on PR #451 — partial_expansions + fake fixtures Address the residual compliance-swarm findings after the first fix round: - CC6.1 (medium): the customer-detail handler now sets meta.partial_expansions=['invoices'] when the ?expand=invoices subquery fails, signalling the degraded response to the caller without escalating to error-level logs (alert fatigue). The primary resource still returns with an empty invoices array. New ResponseOptions.partialExpansions threaded through buildMeta(). 1 new test for the failure path, plus a happy-path assertion that the flag is absent. - A.8.33 (low): SAMPLE_CUSTOMER fixture's org_number and vat_number replaced with 'TEST-0000-0001' / 'SETEST00000001' — cannot be confused with real Bolagsverket entries or pass external VIES validation. Pushing back on: - CC6.3 (medium) — separate scope for ?expand=items on invoices: every accounting API I know (Stripe, QuickBooks, Fortnox) treats line items as part of the invoice resource. Splitting would violate principle of least surprise for integrators; the plan deliberately treats invoices:read as covering the full invoice including items. 3132/3132 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): third-pass review on PR #451 — PII minimisation refinements Third compliance-swarm sweep (6 → 0 highs, 4 medium, 2 low). Addressed: - Art.5(1)(c) personnummer leakage: customer LIST response now masks org_number AND vat_number for customer_type IN ('individual', 'eu_individual') — for sole traders (enskild firma) org_number IS the personnummer. Business customers' Bolagsverket-public org_numbers stay visible. Detail endpoint (deliberate single-record fetch) unchanged. - Art.5(1)(c) over-broad invoice-list expansion: ?expand=customer on the invoice LIST endpoint now uses a new CUSTOMER_LIST_CONTEXT_COLUMNS projection (id, name, customer_type, email, country, archived_at) — full address/phone/notes/vat_number stay on the customer DETAIL endpoint. Drops PII transmitted in bulk-list contexts by ~60%. - A.8.15 permission-error differentiation: customer-detail soft-degrade for ?expand=invoices now bumps Postgres error class 42 (insufficient privilege, RLS denial) to error-level log so Sentry alerts on misconfigurations. Transient errors stay at warn. - PI1.1 ISO-4217 currency: invoice list ?currency now requires /^[A-Z]{3}$/ instead of accepting any 3-8 char string. Two new tests. Pushing back on: - Art.5(1)(f) UUID logging on 404 — UUIDs have 122 bits of entropy; you cannot enumerate the space, so the "log scraping = enumeration" framing doesn't hold. Operational audit value > theoretical risk. - Art.25(1) notes-by-default in customer DETAIL — kept inline. Detail is a deliberate single-record fetch; the dashboard shows notes inline; agents calling /customers/{id} reasonably expect them. Notes are already excluded from the LIST endpoint AND from the invoice-list ?expand=customer projection (above). 3135/3135 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
db592d922d |
feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints (#450)
* feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints Lay the substrate for the public REST API at /api/v1/*: Bearer-auth wrapper that reuses the existing api_keys + idempotency machinery, an extended scope catalogue (companies, events, webhooks, operations, documents, compliance), v1 response envelopes (data + meta with request_id, api_version, audit block, cursor pagination), an error envelope with recovery_hint / docs_url / valid_alternatives derived from the existing structured-error registry, and a Zod schema registry that generates the OpenAPI 3.1 spec with x-action-risk / x-idempotent / x-reversible / x-dry-run-supported extensions. Ships discovery routes (/llms.txt, /.well-known/skills/index.json) and three smoke endpoints (GET /api/v1/health, /api/v1/companies, /api/v1/openapi.json) so the wrapper is exercised end-to-end. Includes the api_keys.mode (test|live) migration and 41 unit tests covering auth, scope, company-membership, idempotency replay, dry-run, pagination, response shape, and scope resolution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): harden v1 foundation — cursor validation, security headers, forensic logs Address compliance-swarm findings on PR #450: - OWASP V2.3: decodeDefaultCursor now validates the cursor's ts as ISO 8601 and id as UUID. A crafted cursor previously could inject untyped strings into a query's .gt(field, value); PostgREST would have rejected them, but validating here keeps the failure mode predictable (stale cursor → reset) rather than 400-ing. - OWASP V3.4: public discovery routes (llms.txt, .well-known/skills, openapi.json) now stamp X-Content-Type-Options: nosniff, Referrer-Policy, X-Frame-Options: DENY. New lib/api/v1/security-headers.ts helper. - OWASP V16: security event logs (missing token, validation failure, insufficient scope, company-membership deny) now include source IP (x-forwarded-for / x-real-ip) and User-Agent for forensic correlation. - OWASP V8.2.1 / ISO A.8.3: GET /api/v1/companies emits a warn log when the PostgREST archived_at filter unexpectedly returns a row with a null company join, surfacing silent data-integrity regressions instead of hiding them behind the existing pickCompany() === null filter. Pushing back on (not changed): - GDPR Art.32 cursor HMAC signing — cursors only paginate within a user's own user_id scope; cross-tenant probe surface doesn't exist yet. - GDPR Art.25 org_number in list — Bolagsverket public-record data, removing forces N+1 fetches to make the response useful. - SOC 2 CC6.3 service-role bypasses RLS — defense-in-depth IS the design; the wrapper's company_members membership check is the technical control. - ISO A.8.12 public OpenAPI spec — intentional, mirrors Stripe/Twilio. 5 new pagination tests cover the cursor validators. 46/46 v1 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): detect Supabase duplicate-signup obfuscation on register Supabase obfuscates duplicate signups to prevent user enumeration: when an email already belongs to a confirmed account, signUp returns data.user with identities: [] and no error, and sends no email. Without detecting this case we showed the "check your email" screen to the user, who then waited for a mail that never arrived. Detect the empty-identities response and surface it via duplicateEmail state so the UI can branch on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): second-pass hardening — CSP, IP truncation, cursor scope comment Address the second compliance-swarm sweep on PR #450: - OWASP V3.2: PUBLIC_SECURITY_HEADERS now includes Content-Security-Policy default-src 'none'; frame-ancestors 'none'. Free win for JSON/text-only public routes (no script, style, image, or form contexts). - GDPR Art.5(1)(f): truncate IPs before logging — IPv4 to /24, IPv6 to /48. Preserves diagnostic value (ASN, abuse-pattern correlation, city-level geolocation) while eliminating point-of-presence identification. Standard pattern used by Google Analytics anonymize_ip. Exported truncateIp() so other surfaces can adopt it. - OWASP V8.2.1: explicit comment in GET /api/v1/companies documenting that the cursor's joined_at is applied AFTER user_id filter, so a tampered cursor can only reorder rows the caller already owns. Cursors deliberately unsigned; trade-off documented. Pushing back on second-pass findings (not changed): - ISO A.8.12 / SOC 2 CC6.3 health/llms.txt/skills exposing service name + API version + MCP URL — these are intentional disclosures for a public 3rd-party developer API; hiding them is theatre. - GDPR Art.32 logging granted scopes on INSUFFICIENT_SCOPE — diagnostic value during incident response outweighs the theoretical privilege-profile leak; an attacker who already breached the log store has bigger problems. - OWASP V2.2 route-level Zod for cursor — decodeDefaultCursor already validates strictly; route-level Zod is stylistic. - GDPR Art.25(2) org_number/entity_type in list — Bolagsverket-public data; entity_type materially affects which API calls make sense. - ISO A.8.15 x-forwarded-for trusted-proxy CIDR — overkill behind Vercel's edge which rewrites the leftmost value. 50/50 v1 tests pass (4 new for truncateIp). Build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): third-pass hardening — Host header injection, anon client, HSTS Address the third compliance-swarm sweep on PR #450: - SOC 2 CC6.1 (3× high): llms.txt, openapi.json, and .well-known/skills built URLs from the inbound Host header. A spoofed Host could poison agent discovery with attacker-controlled endpoints. New lib/api/v1/base-url.ts centralises canonical base-URL derivation via NEXT_PUBLIC_APP_URL (already a required env var per CLAUDE.md). - ISO A.8.2 / A.8.5 (2× high): the wrapper's public-scope code path now uses an anon-key Supabase client (RLS-respecting) instead of the service-role client. A future accidental DB call from a public handler is constrained to anon-accessible rows. Least-privilege at the infrastructure layer. - OWASP V3.2 (medium): PUBLIC_SECURITY_HEADERS now includes Strict-Transport-Security: max-age=31536000; includeSubDomains. - GDPR Art.5(1)(f) (medium): truncateIp now logs a warn when a non-empty x-forwarded-for / x-real-ip payload fails to parse, surfacing spoofed or unexpected proxy values to security monitoring instead of silently dropping them. The raw value is never logged. - CC2.3 (low): llms.txt now links the SECURITY.md disclosure policy with the security@arcim.io reporting address so agents have a clear responsible-disclosure path. Pushing back on third-pass findings (not changed): - Cursor HMAC signing — user_id filter is the authorisation boundary; cursor scope is bounded to within-user rows. Documented in code. - org_number in companies list — Bolagsverket public data; the swarm's "could be enskild firma personnummer" framing isn't accurate (enskild firma org_number IS the personnummer, but it's already in the public Bolagsverket business register). - Health endpoint information disclosure — intentional for a public developer API; matches Stripe/Twilio convention. - llms.txt / skills index MCP URL disclosure — that's the file's purpose. - Cache-Control public on discovery routes — content is by definition public; getCanonicalBaseUrl() removes the previous spoof concern. - Duplicate-email screen — user's own input; out of scope for this PR. 50/50 v1 tests pass; @supabase/supabase-js#createClient mocked so the public-path tests don't need real env vars. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): widen validateApiKey result assertions to include mode field The core-only CI job failed on two pre-existing api-keys.test.ts assertions that used strict toEqual matching against the old (userId, companyId, scopes) shape. The wrapper migration in this PR widened that shape with mode, apiKeyId, and apiKeyName. Update both existing assertions to match the current shape and add a third test that exercises the mode='test' path. 3027/3027 vitest tests now pass locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): fourth-pass hardening — env guards, IP range check, headers on wrapped routes Address the fourth compliance-swarm sweep on PR #450: - ISO A.5.17 / SOC 2 CC6.1 (high): createAnonClient now fails closed with an explicit Error if NEXT_PUBLIC_SUPABASE_URL or _ANON_KEY are missing, surfacing misconfiguration on the first request instead of throwing deeper in the handler with no context. - GDPR Art.5(1)(f): truncateIp now rejects IPv4 with out-of-range octets (>255). '999.999.999.999' now returns undefined instead of a pseudo-IP that would pollute abuse-pattern analysis. Edge octets (0, 255) still accepted. 2 new tests. - OWASP V3.2 / V3.3: the wrapper's stampHeaders step now applies the full security header set to every wrapped v1 response (CSP, HSTS, X-Frame, Referrer-Policy, X-Content-Type-Options) PLUS X-Robots-Tag: noai, noimageai so authenticated payloads are excluded from AI training sets. Public discovery routes (llms.txt, skills index, openapi.json) deliberately omit X-Robots-Tag — being AI-discoverable is the whole point of those surfaces. - New WRAPPED_RESPONSE_HEADERS export separates the two contexts. Pushing back on: - SOC 2 CC6.1 medium "API key prefix in public docs aids brute force" — inverted logic. Every public API publishes its key prefix specifically so secret scanners (GitHub Advanced Security, GitLeaks) can detect leaks. Stripe (sk_live_), GitHub (ghp_), OpenAI (sk-) all do this. - SOC 2 CC6.3 medium "formal risk register for unsigned cursors" — org -level documentation, outside this PR. Code-comment already documents the trade-off. - SOC 2 CC2.3 low "llms.txt hardcodes security@arcim.io" — same address as SECURITY.md; no drift risk. Flagged separately (not changed): the register-page duplicate-email detection in this branch defeats Supabase's user-enumeration obfuscation (GDPR Art.5(1)(c) × 2, ISO A.8.11). Substantive product decision: UX (no infinite-wait for non-existent accounts) vs security (no enumeration). GitHub and Stripe Atlas pick UX; some pick security. Owner's call. 3029/3029 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): address Greptile review on PR #450 - P1 (companies/route.ts): keyset pagination was missing its tiebreaker. The cursor encoded (joined_at, id) but the filter only applied .gt('joined_at', ts) — same-joined_at rows on a page boundary could be skipped or duplicated. Also the encoded id was companies.id while the sort was on company_members, mismatched. Fixed: select + sort + encode on company_members.id, apply compound joined_at.gt.{ts} OR (joined_at.eq.{ts} AND id.gt.{cursor_id}) via .or(). Side benefit — eliminates the broken-cursor-on-null-join case (#2) because company_members.id is always present, no null guard needed. - P2 (registry.ts): ZodUnion branch had a dead ternary (['x','y','z','w'].length > 0 ? undefined : 'object') that always yielded undefined. Removed; emit { oneOf: [...] } without top-level type (correct JSON Schema for a union). - P2 (with-api-v1.ts): public-endpoint path was short-circuiting before Bearer-token validation, contradicting the JSDoc and PR description. Now opportunistically validates a supplied token for rate-limit attribution + key tracking; missing/invalid token silently falls back to anon (the route is public by definition, so we don't 401). Two new tests cover both branches. 3031/3031 vitest tests pass; build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
980f29dae8 |
Bug/momsdeklaration skv (#449)
* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so the sensitive 4-digit suffix is hidden while the (public) birthdate stays visible. Affects the employees list/detail, salary run, payslip PDF, payslip email, and the MCP server tools (list_employees, get_salary_run). Each call site now decrypts the stored personnummer before masking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): allow deleting unbooked transactions from "Alla transaktioner" The history list only let users delete via the inbox card; once a category or mall was picked but the verifikation hadn't been created, the row showed "Ej bokförd" with no way to remove it. The API already permits delete while journal_entry_id is null, so the gap was purely a missing UI affordance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK" Three connected issues caused Skatteverket to reject momsdeklarationer with FK004 even after our local "Validera"-knapp returned OK. 1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647) on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx. Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004 ("silent netting prohibited", ML 13 kap kräver båda sidor). Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel 45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it from registration, cash, and credit-note paths. Skipped when the user booked the expense directly on a basis account to avoid double-counting. 4598 added to BAS reference (no migration needed; account_number is plain text on journal_entry_lines). 2. rutorToMomsuppgift rounded each ruta independently but computed summaMoms from the unrounded ruta49. SKV recomputes the sum from integer rutor on their side, so fractional öres caused ±1 SEK drift and SKV rejected with FK009. Fix: derive summaMoms from the already-rounded VAT-amount rutor. 3. "Validera"-knappen only confirmed SKV's internal arithmetic — a declaration with ruta 30-32 populated and ruta 20-24 empty validated fine until /utkast hit FK004. Users got a false green light. Fix: vat-declaration-checks.ts runs locally before the SKV call, blocks Validera/Spara when ERROR-level findings exist, and surfaces them in a separate "Lokala kontroller"-section. Success message reworded so SKV's OK is no longer presented as filing-ready. Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH, SUMMA_MOMS_DRIFT). Backfill for already-posted entries follows in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add skattekonto matching functionality - Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers. - Implement match suggestions for skattekonto transactions in the backend. - Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries. - Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox. - Introduce skattekonto-match utility functions for candidate matching and linking. - Update types to include match suggestions and enriched transaction responses. * refactor: reorganize skattekonto types and implement bank counterpart matching logic * docs: update CLAUDE.md to streamline integrations and clarify architecture details * refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
17c67fece0 |
Inbox UX overhaul + cross-currency supplier-invoice fixes (#444)
* feat(kpi): expense mix and top suppliers charts Replace the single monthly-trend chart with two additional compact visuals on /kpi: expense composition donut (BAS class 4-7) and top suppliers bar (supplier_invoices sum_sek over the fiscal period). KPIReport gains expenseComposition and topSuppliers fields, computed from the trial balance and supplier_invoices rows already fetched in the API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav): swap Deadlines sidebar slot for Dokumentinkorg Sidebar main-menu slot now points to the invoice-inbox extension. The /deadlines page stays accessible via dashboard widgets and direct links — only the prominent nav entry changes. Most users open gnubok to act on incoming documents, not to read tax deadlines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): cross-currency totals, FX residual, review SEK display Five fixes around foreign-currency supplier invoices: - Form layout: move Valuta / Växelkurs / Reverse charge from collapsed "Övrigt" into a visible row above the line-item table. Auto-fetch the Riksbanken rate when switching to a non-SEK currency; never clobber a user-typed rate; clear it when switching back to SEK. - Form submit: reset() the form on successful submit so the useUnsavedChanges hook detaches its beforeunload listener before the router.push, killing the "Are you sure you want to leave?" prompt that fired during Turbopack-mediated navigations. - BankTransactionPicker: drop the strict currency filter that hid every SEK transaction when the invoice was in EUR/USD. Cross-currency rows fall to the bottom with an "Annan valuta" hint instead of producing a meaningless numeric diff. - match-supplier-invoice route: when the bank transaction currency differs from the invoice currency, compute the FX diff against the AP-booked SEK and pass it to createSupplierInvoicePaymentEntry so 7960/3960 catches the residual instead of leaving a permanent stub on 2440. Fix also covers the "EUR transaction paying a SEK invoice" case that the first iteration missed. - Review dialog: buildJournalPreview now multiplies amounts by the exchange rate so the "Verifikation som bokförs" table shows the actual SEK numbers that hit the DB, not the EUR magnitudes labelled with no unit. Header gains an "(i SEK)" hint when foreign currency. Test coverage for the FX residual path covers SEK-SEK (no diff), SEK-into-EUR-invoice (loss), SEK-into-EUR-invoice (gain), foreign-tx- into-SEK-invoice, and the no-rate fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(inbox): rate limits, multi-file UX, onboarding, retry, supplier autolink Big workspace pass on /e/general/invoice-inbox. Highlights: Backend - New table inbox_rate_counters + RPC check_and_increment_inbox_quota. Postgres-backed (no Upstash dep) per-company limit: 30/min, 500/day. Applied at /upload, /inbound, and /items/:id/retry-extraction. - POST /items/:id/retry-extraction — re-runs the deterministic extractor on a stored document when the previous attempt errored. - POST /items/:id/match-supplier — links a freshly-created supplier back to the inbox item so the next action prefills correctly. - POST /api/transactions/create-from-document — creates an uncategorized manual transaction from an inbox item for the "I have a receipt, no bank transaction" case. The user categorizes through the normal flow. - /inbound caps email at 20 attachments/email; truncated count goes to processing_history as AttachmentsTruncated. Rate-limit drops emit RateLimitedDropped and return 200 so Resend doesn't retry. - attach-document side effect: when the document came from an inbox item, the inbox row's matched_transaction_id is updated so the UI can flip it to "Kopplad till transaktion" without a round-trip. New migration: re-introduces matched_transaction_id on invoice_inbox_items as a plain FK (the AI metadata that the previous migration stripped doesn't come back). Workspace UI - Onboarding card replaces the thin empty-state with a 3-step checkmark guide (Aktivera adress → Ladda upp → Matcha eller bokför). Auto-hides when all three steps are done; localStorage-backed dismiss. Beta badge + link to gnubok.se/priser. - Responsive layout: 3-pane at lg, 2-pane at md, master-detail toggle on phone (list xor detail with a back button). - Filter pills (Alla / Behöver åtgärd / Bearbetade / Fel) + search input above the list — client-side over the existing items list. - Multi-file upload queue with "Laddar X av N…" progress counter on the button. Sequential to avoid hammering pdfjs. Selection stays put during a batch (only single-file drops auto-jump the detail pane). - Bulk select + delete with sticky action bar. Items linked to a supplier invoice are skipped with a count toast. - Retry button in the FieldsRail error branch. - "Skapa transaktion från underlag" CTA in the match dialog when no unmatched bank transactions exist. Prefills date/amount/description from the extracted data; user picks the sign. - "Skapa leverantör" inline CTA when the extractor caught a supplier name with no match against existing suppliers. POSTs /api/suppliers with the extracted fields, then auto-links via /items/:id/match-supplier. - Matched-state CTA renamed to "Bokför transaktionen" with link to /transactions?highlight=<id> so the categorize panel auto-opens. Tests - lib/rate-limits/__tests__/inbox.test.ts — RPC wrapper happy/error/scope - app/api/transactions/create-from-document/__tests__/route.test.ts — auth, validation, 404/409/200/500, inbox-link failure tolerated - extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts — auth, rate limit, 404, 409, 400 no-doc, success, extraction failure - attach-document tests extend coverage to the new inbox-link side effect (both success and best-effort failure paths) - inbound-webhook test mocks the rate-limit module so the queued-mock sequence in each existing test doesn't have to know about it CLAUDE.md gains a row for lib/rate-limits/ so the new helper is discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(transactions): paperclip indicator and highlight-row param Close the feedback loop after a user attaches a receipt to a transaction from the inbox: the row in /transactions now shows a paperclip icon when transaction.document_id is set, with a click handler that fetches a signed download URL and opens the document in a new tab. Works for both uncategorized and history views. When the inbox sends a user to /transactions?highlight=<id>, the page now scrolls that row into view and auto-opens the categorize panel if the transaction is still uncategorized. Behind a double-rAF so the row DOM exists when scrollIntoView fires. QuickReviewDialog no longer prompts to upload underlag when the transaction already has a doc attached (which it does after the inbox match flow). Shows "Underlag bifogat — Visa" instead, opening the existing doc in a new tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-444): address review feedback (Greptile + compliance bots) Migration rules - New migration 20260512092423: adds updated_at trigger on inbox_rate_counters (CLAUDE.md rule 2) and explicit USING (false) RLS policies for the four DML verbs to make the SECURITY DEFINER-only intent explicit (rule 1). - New pg-real test inbox-rate-limit.pg.test.ts covering happy path, minute-cap rejection, day-cap rejection, per-company isolation, and the updated_at trigger firing. CLAUDE.md mandates *.pg.test.ts for every new RPC because mocks pass on broken PL/pgSQL. Bugs - Stale exchange rate on currency switch (Greptile P1) — userTouchedRateRef was scoped per session, not per currency. Switching EUR (with a hand-edited rate) → USD kept the EUR rate. Now tracks the last fetched currency in a ref and resets the touched flag on currency change while still honoring manual edits within a single currency. - topSuppliersResult.error silently swallowed (Greptile P2) — failed queries used to render an empty chart matching the no-data state. Logged now. - Currency from extracted_data not validated (GDPR Art.25(2), OWASP V4.5, Swedish compliance bot) — extracted PDF currency was inserted into transactions.currency without sanitisation. Allowlisted against the six supported ISO 4217 codes; coerce to SEK otherwise. - Idempotency gap on create-from-document (OWASP V2.3) — two concurrent POSTs with the same inbox_item_id could each pass the matched_transaction_id IS NULL read and insert duplicate transactions. UPDATE now includes .is('matched_transaction_id', null) as an optimistic-lock release and returns 409 with an orphan-transaction rollback when the predicate doesn't match. - FX residual on cash-method match path (Swedish compliance bot) — createSupplierInvoiceCashEntry has no exchange_rate_difference path, so a cross-currency match would silently leave a 1930 reconciliation gap. Added a guard that returns MATCH_SI_CASH_FX_UNSUPPORTED (400) before the JE is created. Users on cash method can switch to accrual or book the FX diff manually. Design system - gap-y-1.5 / gap-1.5 in KPIExpenseMixChart — replaced with gap-y-2 / gap-2 (CLAUDE.md design tokens; 2.5/1.5/5/hardcoded pixels are forbidden spacing values). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): rename to match applied versions The mcp__plugin_supabase_supabase__apply_migration tool stamps its own timestamp when it applies a migration to the live project, so the version recorded in supabase_migrations.schema_migrations differs from my local generation-time filenames. Renaming the local files so a production CD run sees the migrations as already-applied (matching versions) instead of trying to re-apply them — which would fail for the trigger/RLS migration (CREATE TRIGGER and CREATE POLICY don't support IF NOT EXISTS). Follows the pattern from d854efcd ("chore(migration): rename to match applied version"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(create-from-document): scope orphan rollback DELETE by company_id Defence in depth on the inbox-link race rollback. newTx.id is a fresh UUID from a company-scoped insert two statements above, so the existing single-key DELETE is already safe, but adding .eq('company_id', companyId) makes the cross-company invariant explicit on every write — addresses the OWASP ASVS V2.3 finding from the compliance swarm on PR #444. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav): mark Dokumentinkorg with Beta badge Same signal we use for Löner and Anställda — the inbox flow (AI extraction, supplier autolink, manual transaction creation) is in end-to-end customer testing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8f15f98687 |
Bug/employees creation (#440)
* Fix/employees page salary display logic and labels * feat: add salary_worked_days table and related functionality - Implemented the salary_worked_days table to track per-day worked hours for hourly employees. - Established row-level security (RLS) policies to ensure tenant isolation for salary_worked_days. - Created unique index on (employee_id, work_date) to enforce uniqueness. - Added trigger to enforce a 24-hour cap across worked and absence days for the same employee and date. - Developed tests to validate RLS, uniqueness, and 24-hour cap logic. * Fix: update hourly_salary calculation and refresh logic in salary run processing |
||
|
|
dec920682f |
Fix/UI changes (#439)
* feat(bookkeeping): add preview for next voucher number in JournalEntryForm * feat(encoding): implement U+FFFD recovery for Swedish text in encoding functions |
||
|
|
a53a119a2e |
Fix/vat parent accounts (#438)
* feat(enable-banking): add support for account selection and syncing - Updated StoredAccount interface to include an 'enabled' flag for account syncing preferences. - Enhanced ensureFiscalPeriod function to handle overlapping fiscal periods with posted entries and opening balances. - Added tests for fiscal period validation and account syncing logic. - Implemented AccountPickerDialog component for user account selection. - Created API routes for PATCH /accounts and POST /sync to manage account syncing. - Introduced 'pending_selection' status for bank connections to allow user account selection before syncing. - Updated database migration to support new connection status and backfill existing accounts with enabled=true. * feat(enable-banking): implement account selection and consent event logging --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d0fbc2b616 |
refactor(ui): app-wide UI/UX consistency pass (#436)
* refactor(ui): app-wide UI/UX consistency pass
Net: +1,159 / −1,373 LOC across 77 files. No new features, no behavior
changes. Locks in a uniform design system across every dashboard surface.
What changed:
- **Foundation**: sidebar width 232→256px (md:w-64), spacing scale locked
(Tailwind 1/2/3/4/6/8/10/12; 2.5/5 forbidden), card padding p-6 default
(p-4 for compact metric cards), space-y-8 between page sections.
- **Tables unified**: all 33 thead blocks now share the Resultatrapport
pattern via shadcn Table primitive (text-[11px] font-medium uppercase
tracking-wider text-muted-foreground). Hand-rolled <table> instances
converted where they were data tables; form/edit grids kept distinct.
- **Status badges unified**: every status indicator routes through
shadcn <Badge variant>. Eliminated raw Tailwind colors
(bg-amber-100, bg-emerald-500/10, bg-blue-100, bg-purple-100, etc.)
in favor of the gnubok semantic palette (success=sage, warning=ochre,
destructive=terracotta).
- **Empty states unified**: list pages migrated from hand-rolled
"flex flex-col items-center py-12" divs to the EmptyState primitive.
- **Loading skeletons unified**: hand-rolled bg-muted rounded animate-pulse
divs replaced with shadcn <Skeleton> across 15 files.
- **Touch targets**: 6 back-buttons + edit-pencil + inbox delete bumped
from 24/32/36px to shadcn's 40px icon default. Added aria-labels on
9 icon-only navigation buttons.
- **Date formatting**: formatDate() for accounting data (ISO yyyy-MM-dd,
table-friendly) vs formatDateLong() for metadata (Swedish long form).
Raw {x.invoice_date} renderings routed through formatDate() in 18 sites.
- **Toast titles**: eliminated 33 generic "Fel" titles. Each toast title
now carries the action ("Kunde inte skapa lönekörning" etc.) with
description carrying the error detail.
- **Page-level cleanups**:
- Dashboard: dropped greeting hero + Snabbåtgärder/Att hantera nav
duplicates + Visa detaljer collapsible.
- Reports: 5-col mega-menu replaced with left-rail layout
(new ReportsNav component).
- Bookkeeping: fixed layout jump between Verifikationer/Ny verifikation
tabs (moved FiscalYearSelector inside journal tab).
- Bookkeeping: added voucher sort (A1 first / latest first) alongside
existing date sort. Required matching API param sort_by.
- KPI page: FiscalYearSelector instead of raw <select>; InfoTooltip
instead of inline info-button toggle; bigger numbers.
- Salary section: enum values translated to Swedish labels, mobile
table collapses to Anställd+Netto on <md, KPI typography aligned
with dashboard.
- Invoice forms: styled RequiredMark + aria-required, tabular-nums
on amount inputs.
- **CLAUDE.md**: new "Design System Tokens" subsection documents the
locked spacing scale, primitives table, typography rules, date helpers,
and forbidden patterns so future contributors don't drift.
Tests: 2,906 passing (unchanged). Lint: unchanged from main baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback (Greptile + compliance bot)
- **formatDate / formatDateLong timezone fix**: switch from new Date() to
parseISO. Bare yyyy-MM-dd strings are now parsed as local midnight rather
than UTC midnight, eliminating the off-by-one display in west-of-UTC
timezones flagged by Greptile.
- **DashboardContentProps cleanup**: removed unused firstName and settings
fields from the interface, and the corresponding fetch (profiles table)
+ computation in app/(dashboard)/page.tsx. The greeting was dropped in
the dashboard cleanup; these props were dead weight.
- **Voucher sort behavior documented**: extended the comment in the journal
entries API route to explain why voucher sort intentionally uses strict
fiscal_period_id filtering (BFL 5 kap 6–7 §§ — voucher numbers are
series-scoped within a fiscal year). The row-count delta between date
sort and voucher sort is now a documented design choice.
- **delete_last_voucher migration + draft-delete test included**: the UI
already shipped the "Radera utkast" path in the previous commit; this
pulls in the backing RPC migration that allows draft deletes (with the
full safety logic — drafts skip series/period checks since they have
voucher_number=0, posted entries go through the existing unchanged
path). This was originally meant for a separate PR but the UI shipped
half the feature without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(migration): rename to match applied version
The function delete_last_voucher is already applied to the production DB
under version 20260509103736 (verified via pg_get_functiondef — exact
byte-for-byte match to file content). The previous file timestamp
20260509120000 would cause a fresh `supabase db push` to attempt re-applying
under a different version row. Renaming the file aligns local tracking
with what the database actually has.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address compliance bot findings (payroll label + VAT visibility)
- sick_karens label: drop "(första sjukdagen)" qualifier. Per sjuklönelagen
6 §, karensavdrag is a single calculated amount (20% of one week's
sjuklön) deducted from the first sick day's pay — not bounded to the
first day. The qualifier could mislead users when the first sick day
and return-to-work span a weekend. Swedish-payroll bot recommendation.
- Omvänd skattskyldighet badge: variant outline → warning. The reverse-
charge indicator is compliance-critical (ML 16 kap) — missing it leads
to incorrect input VAT deduction. Outline was too subtle; warning's
ochre fill matches its semantic weight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ed9bdc4c00 |
Bug/transaction import (#435)
* fix(invoices): drop UTKAST banner on numbered invoices and preserve logo aspect ratio Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(invoices): restore UTKAST banner for drafts; keep logo aspect ratio fix Numbered drafts intentionally surface UTKAST until manually marked sent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(invoices): drop logo objectFit change Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(enable-banking): implement transaction fetch strategy and update related logic --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
75043632de |
Fix/compliance and invoices (#433)
* feat(invoices): implement inline membership checks for invoice-number RPCs and enhance error handling * fix(invoices): enhance unpaid amount calculation to support currency-specific rounding * fix(api-client): handle response.text() error for 403 status in skvRequest |
||
|
|
0ee5219b6c |
feat(invoices): implement öresavrundning logic and next invoice numbe… (#429)
* feat(invoices): implement öresavrundning logic and next invoice number preview - Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings. - Updated `InvoicesPage` to utilize the new rounding logic when displaying totals. - Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence. - Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits. - Enhanced tests for invoice number generation and rounding functionality to ensure correctness. - Updated PDF template to reflect new rounding logic for totals and display appropriate values. - Adjusted company switcher to hide options in sandbox mode. - Improved error handling and logging in sandbox seeding process. * fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES |
||
|
|
7e81f661b2 |
Add/skv salary agi (#423)
* feat: add Bankgirot LB-fil support for salary payments and tax payments - Implemented `generateBgLb` for salary batch payments, producing opening, payment, and closing records. - Added tests for `generateBgLb` to ensure correct record generation and validation. - Created `generateBankgiroPaymentBgLb` for single tax payments to Skatteverket, including validation and formatting. - Added tests for `generateBankgiroPaymentBgLb` to verify record structure and data integrity. - Introduced `generateSkattekontoOcr` for generating valid OCR references for Skattekonto payments, with tests for various input formats. - Updated database schema to track payment file formats and timestamps for salary runs and AGI declarations. - Created a new table for logging salary payslip deliveries to ensure compliance with audit requirements. * feat: add write permission check and company ID validation for payment file generation * feat: add write permission check for salary payment file generation |
||
|
|
81e9dd224e |
Add/csv import options (#420)
* feat(import): add customer and supplier parsing functionality - Implemented customer file parsing in `lib/import/customers/parser.ts` with support for Excel and CSV formats. - Created types for detected customer columns and parsed customer rows in `lib/import/customers/types.ts`. - Added tests for customer classification logic in `lib/import/shared/__tests__/classify.test.ts`. - Developed classification functions for customers and suppliers in `lib/import/shared/classify.ts`. - Introduced shared column utility functions in `lib/import/shared/column-utils.ts`. - Implemented supplier file parsing in `lib/import/suppliers/parser.ts` with validation for various fields. - Created types for detected supplier columns and parsed supplier rows in `lib/import/suppliers/types.ts`. - Added tests for supplier column detection and parsing in `lib/import/suppliers/__tests__/column-detector.test.ts` and `lib/import/suppliers/__tests__/parser.test.ts`. * fix(labels): update 'Svenskt företag' to 'Svenskt företag eller organisation' for clarity * feat(import): refactor encoding handling for Swedish files and add tests for character preservation * feat(recapt): implement clearRecaptIdentity function and integrate into logout flow * feat(bookkeeping): implement copy functionality and next voucher sequence retrieval * feat(import): enhance customer and supplier import functionality with normalization and event handling |
||
|
|
9e1e13d388 |
Fix/bank and onboarding (#413)
* fix(onboarding): validate org number at step 2 instead of final submit Run normalizeOrgNumber (Luhn + 10/12-digit check) inside the Step 2 Zod schema and gate the duplicate-check and TIC lookup effects on it, so users get an inline error on the field they just typed instead of filling out two more steps and being bounced back from the server. Server-side check stays as defense in depth. Also fixes the old regex incorrectly rejecting valid 12-digit org numbers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(reconciliation): pass real userId to transaction.reconciled events runReconciliation and manualLink fell back to companyId when no userId was provided, causing FK violations on event_log.user_id (which references auth.users). Make userId a required arg in both functions, drop the fallback, and forward a real user id from every caller (user.id from authed routes, connection.user_id from the cron path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(enable-banking): use local companyId instead of reinlining ctx fallback Three sites in the enable-banking sync handler reinline `ctx?.companyId ?? user.id` instead of using the local `companyId` declared at the top of the block. Collapse all three to the local for consistency and to remove drift risk if the fallback expression ever changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
dbe634d0aa |
Mcp/bulk approval (#412)
* feat(pending-operations): implement bulk commit functionality with UI support * feat(pending-operations): add bulk action labels and warnings for confirmation dialogs * feat(pending-operations): enhance bulk commit functionality with rejection handling and summary updates |
||
|
|
30f5877e57 |
fix(invoices): init remaining_amount on create + attach invoice PDF to payment JE (#406)
* fix(invoices): initialize remaining_amount on create The invoices table column remaining_amount has DB default 0. The create path never set it, so brand-new fakturor were stored with remaining_amount=0 even though no payment had been received. The InvoicePicker (and any future open-invoice query that filters on remaining_amount > 0) treated these as fully settled and hid them from match candidates — surfaced when a real user reported "Inga öppna fakturor" despite having 5 sent invoices. Set remaining_amount = total on insert for document_type='invoice'. Proformas and delivery notes have no payment obligation, so they keep the 0 default. Backfill of the 46 existing rows across 20 companies (1.32M SEK in orphaned receivables) ran separately as a one-shot UPDATE — restricted to rows with paid_amount IS NULL OR 0 so any legitimately-paid invoice with stale status was untouched (verified: 0 such rows). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(match-invoice): attach invoice PDF as underlag for payment JE The payment verifikation created on transaction match (debit 1930 / credit 1510) had no document attachment. The invoice PDF was archived on send and pinned to the AR-booking JE, but document_attachments .journal_entry_id is one-to-one — the payment JE was left without underlag, a BFL 7 kap audit-trail gap. Cheapest fix: after the payment JE is created, look up the invoice's existing document_attachment row and insert a parallel row that points at the same storage_path with the new journal_entry_id. The original WORM file is untouched (single storage object, two DB pointers); no schema change. Wrapped in non-blocking try/catch so a document lookup failure doesn't abort the payment match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-invoice): destructure document_attachments insert error Supabase JS client returns { data, error } on Postgres-level failures (unique constraint, RLS reject) instead of throwing. The surrounding try/catch only caught thrown JS exceptions, so DB errors on the payment JE document attachment were silently swallowed — the txLog.warn path was unreachable for the most likely failure mode. Destructure { error: attachErr } and log on error with both JE ids so attachment failures are visible and reparable. Greptile P1 on PR #406. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
97db09a3ff |
feat(invoices): allocate-on-save, makulera flow, manual invoice picker (#405)
* feat(invoices): allocate-on-save, makulera flow, manual invoice picker
Three coordinated invoice changes:
1. Allocate F-series number when the draft is created (Fortnox-style),
not at send time. Users can download a numbered draft and send it
manually. If number allocation fails, the invoice + items are rolled
back so no orphaned rows remain. Adds INVOICE_CREATE_NUMBER_ASSIGN_FAILED.
2. DELETE /api/invoices/[id] now soft-cancels (status='cancelled') instead
of hard-deleting. The F-series number is retained, keeping the sequence
gap-free per ML 17 kap 24§ and BFNAR 2013:2 — no voucher_gap_explanations
needed. Sent/paid invoices stay immutable (credit note required). Adds
"Makulerade" tab to the invoice list; cancelled invoices are hidden from
"Alla" by default. PDF draft banner stays visible on numbered drafts and
only clears when the invoice is marked sent.
3. New InvoicePicker component lets users manually match an income
transaction to an open invoice from the booking dialog ("Matcha med
faktura..."), complementing the existing auto-match flow.
Also: new-invoice review dialog reads accounting_method from settings and
shows a cash-vs-accrual warning so users know when the verification posts.
seed-demo-account adds year-end closing + opening balance helpers so
multi-year demo data is balanced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invoices): address review feedback on PR #405
Greptile P1 + Swedish compliance reviewer findings:
- app/api/invoices/route.ts — replace hard-delete rollback on number-
allocation failure with a soft-cancel (status='cancelled'). If
generate_invoice_number bumped the sequence before failing to write
the number back, hard-deleting would leave a permanent gap in the
F-series in violation of ML 17 kap 24§. Re-fetch invoice_number
first so any partially-written value is logged for operator follow-up.
Log loudly if the cancel itself fails so an orphan row doesn't go
unnoticed.
- app/api/invoices/[id]/route.ts — close TOCTOU race on the cancel
update. The .eq('status','draft') guard prevented data corruption
but Supabase returned error: null with 0 affected rows on a
concurrent flip, and the handler reported success. Add .select('id')
and return new INVOICE_CANCEL_RACE (409) when no row updated.
- components/transactions/InvoicePicker.tsx — memoize createClient()
so the supabase reference is stable across renders. Without this,
including supabase in the useEffect dep array fires the open-invoices
fetch on every render.
- app/(dashboard)/transactions/page.tsx + match-invoice/route.ts —
read category from the match-invoice response instead of hardcoding
'income_services' client-side. Server now echoes the category it
actually booked; client falls back to 'income_services' if absent.
- lib/invoices/pdf-template.tsx — add MAKULERAD banner for cancelled
invoices (red, distinct from the yellow draft banner). A cancelled
invoice PDF previously rendered with no warning if it had a number,
or with the draft banner if it didn't — both could be mistaken for a
valid faktura. Cancelled takes precedence over draft so the legacy
un-numbered-cancelled case is also covered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invoices): guard cancelled status on send + rollback symmetry
Two follow-up fixes from the second-round Swedish compliance review on
PR #405:
- app/api/invoices/[id]/send/route.ts — reject sending a cancelled
invoice. The existing flow had no status guard before
.update({ status: 'sent' }), so a cancelled invoice could be silently
re-activated to sent and a "MAKULERAD"-watermarked PDF could be
delivered to the customer as if it were a live faktura. New
INVOICE_SEND_CANCELLED (400) returned at the top of the handler.
- app/api/invoices/route.ts — add .eq('status', 'draft') to the
rollback-cancel update so the rollback is symmetric with the DELETE
handler's only-drafts-may-be-cancelled rule. At the create flow's
current shape the row can't realistically be anything other than
draft, but the symmetry prevents a future caller adding a status flip
between insert and number-allocation from accidentally cancelling a
posted invoice.
mark-sent (rejects non-draft), mark-paid (only sent/overdue), and
convert (explicitly rejects cancelled proformas) already guard
correctly — no changes needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invoices): InvoicePicker filters settled invoices; drop dead error code
Two cleanups from the third-round Swedish compliance review on PR #405:
- components/transactions/InvoicePicker.tsx — add .gt('remaining_amount', 0)
defensively. The picker filtered by status IN (sent, overdue,
partially_paid), but a stale 'sent' or 'overdue' row with
remaining_amount=0 (data inconsistency) would otherwise be selectable
here and could be matched a second time, double-booking the income —
a direct BFL 5 kap accuracy violation.
- lib/errors/structured-errors.ts — remove INVOICE_DELETE_NUMBERED.
The numbered-draft refusal was replaced by the soft-cancel path
earlier in this PR; the entry has no remaining callers.
Verified-safe and not changed:
- Cancel-without-storno concern: createInvoiceJournalEntry only fires
inside mark-sent (after the draft→sent guard) or send (after the
cancelled-status reject). Drafts never have posted verifications, so
cancelling a draft cannot leave an orphaned bokföringspost.
- Hardcoded category: 'income_services' in match-invoice is a
pre-existing classification concern that warrants a larger refactor
(derive from invoice's revenue accounts) rather than a one-line patch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invoices): InvoicePicker excludes proforma invoices
Add .eq('document_type', 'invoice') to the open-invoice query. A
proforma is not a faktura per ML 17 kap 24§ — no VAT obligation, no
binding commercial document — and must never be matched against a
bank receipt. Without this guard a sent proforma could be selected
in the picker, triggering a payment booking and VAT-rate journal
entry that violates BFL 5 kap accuracy rules.
Other findings from the third-round Swedish compliance review were
verified-safe and not changed:
- Cancelled-invoice PDF download path: the MAKULERAD watermark added
earlier in this PR is the safeguard. Blocking the download endpoint
outright would prevent legitimate audit access; the visible banner
prevents the doc being mistaken for a valid faktura.
- Cancel-without-storno: createInvoiceJournalEntry only fires inside
mark-sent / send / pending-operations, all behind status guards.
Drafts never carry a posted verifikation, so cancel can't orphan one.
- Allocate-on-save for proforma uses F-series: not true. The
generate_invoice_number RPC (migration 20260427150100) routes
document_type='proforma' to a separate 'PF-' prefix sequence; the
F-series is untouched.
- closeYearForSeed 2099 → 2091 transfer: real demo-data correctness
issue but a seed-script polish item — separate PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(match-invoice): server-side document_type='invoice' guard
The InvoicePicker filter excluding proformas (added in the previous
commit) is client-only. A direct API call to /api/transactions/[id]/
match-invoice with a proforma id would otherwise still book a payment
journal entry against a document that has no VAT obligation per
ML 17 kap 24§. Add a defense-in-depth check after the invoice fetch.
New error code MATCH_INVOICE_NOT_INVOICE_TYPE (400). Test added.
Other findings from the latest compliance review were verified-safe and
not changed:
- Cancelled-invoice PDF download path: /api/invoices/[id]/pdf always
re-renders through InvoicePDF, so the MAKULERAD banner is always
present. The bot's "cached pre-cancellation PDF" scenario does not
apply to this codebase.
- Proforma F-series allocation: the generate_invoice_number RPC routes
document_type='proforma' to a separate 'PF-' prefix; the F-series is
not polluted.
- Soft-cancel rollback gap when number not written: the RPC is a
single-transaction PL/pgSQL function — sequence bump (UPDATE
company_settings) and row write (UPDATE invoices) commit or roll
back together. The "sequence advanced but row null" scenario the
bot describes is impossible by construction; a thrown exception in
the row-write step rolls back the bump.
- closeYearForSeed obeskattade reserver: seed-script demo accuracy,
separate PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ce3af4d17e |
Fix/multiple domain issue (#401)
* feat: enhance invoice management and immutability checks - Update InvoiceDetailPage to prevent deletion of drafts with assigned invoice numbers, providing user feedback. - Modify the invoice conversion API to ensure invoice number allocation occurs only after successful item insertion and proforma cancellation. - Implement structured error responses for invoice deletion, ensuring only drafts without assigned numbers can be deleted. - Add comprehensive tests for invoice deletion and conversion scenarios, including edge cases for draft invoices. - Introduce immutability checks in the document management system to prevent unauthorized changes to linked documents. - Create SQL migration to enforce document metadata immutability, ensuring compliance with accounting regulations. * fix(invoice): prevent invoice number consumption on PDF render failure * feat: add document journal entry immutability enforcement for delete_last_voucher RPC * fix(invoice): implement rollback for orphan invoices on proforma cancel failure * fix(document): extend immutability trigger to protect journal entry links |
||
|
|
5725c25bf1 |
Logs/improved logging (#398)
* feat(mcp): add create_transactions tool with /pending approval gate New MCP tool gnubok_create_transactions stages 1–10 transactions per call as pending_operations of type create_transaction (risk: medium). Each item becomes its own card on /pending; on confirm, the executor inserts the row into transactions with import_source='mcp' so MCP-staged ingestion is distinguishable from PSD2 sync. Designed for skill workflows that pull external data (e.g., Airtable) and want the user to gate the writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(bas): strip concatenated group headers from corrupted account names A chart-data import bug had glued the next group's header onto the last account in each preceding group across all eight bas-data class files (e.g. account 2670 read "Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG"). The corrupted names surface in transaction dropdowns, ledgers, SIE exports and årsredovisning, and risk VAT miscategorization on the OSS (2670) and blandad-verksamhet (6999) accounts specifically. - Cleans 69 account_name and 64 description fields across class-1..8 files - Adds a regression test asserting no name contains a concatenated header - Ships an idempotent safety-net migration that updates already-seeded chart_of_accounts rows, gated on the corrupted string so user customizations are preserved Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(errors): add structured error codes and handling for various operations - Introduced a new structured error registry in `structured-errors.ts` to standardize error handling across the application. - Added Swedish and English messages for various error scenarios, including validation, authorization, and bookkeeping errors. - Implemented a client-side error toast in `use-error-toast.ts` to display user-friendly error messages with remediation hints. - Created a wrapper for recording operation outcomes in `record-operation.ts`, enhancing audit capabilities for operations. - Developed a provider call wrapper in `with-provider-call.ts` to handle external HTTP calls with structured logging and error mapping. - Added a new SQL migration to extend the processing history with new event types and aggregate types for better operational telemetry. * Refactor supplier API routes to use context-based logging and error handling - Replaced direct Supabase client usage in GET and POST routes with context-based approach using `withRouteContext`. - Enhanced error handling to provide structured error responses for supplier creation and listing. - Updated logging to include request IDs for better traceability. - Introduced new error codes for supplier-related operations. - Refactored tax deadlines cron job to utilize context and improved error handling. - Updated ESLint configuration to enforce logging practices across API and lib directories. - Enhanced arcim migration extension with structured error handling and logging. - Added classification for provider errors to improve user-facing error messages. - Introduced request ID in extension context for better log correlation. * fix(route-context): update DynamicParams type for improved type safety in route handlers * feat(transactions): add 'create_transaction' operation to PendingOperationType * fix(route): ensure companyId is non-nullable in loadAndDeriveAbsence function * fix(route-context): ensure companyId is always non-null by short-circuiting with COMPANY_CONTEXT_MISSING --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
94f15b9c6c |
feat(invoice-inbox): pin documents to bank transactions + MCP tools (#397)
* feat(invoice-inbox): pin documents to bank transactions + MCP tools Adds a first-class flow for attaching unmatched inbox documents to bank transactions, separate from the existing supplier-invoice convert path: - new transactions.document_id FK → document_attachments (ON DELETE SET NULL) - POST/DELETE /api/transactions/[id]/attach-document - categorize route propagates the link to journal_entry_id on commit - three new MCP tools: gnubok_list_unmatched_documents, gnubok_get_document_content (5-min signed URL), gnubok_attach_document_to_transaction (staged via pending_operations) - InvoiceInboxWorkspace gains a "Koppla till transaktion" picker dialog ranked by amount-match, plus a "Bilaga" badge in SwipeCategorizationView - regex extraction unchanged; supplier-invoice convert flow unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 review findings - categorize: destructure { error } from the document-link update so Supabase-level failures are logged instead of silently dropped (BFL 5 kap 6 § receipt-on-verifikation contract). - list_unmatched_documents: emit next_cursor whenever the inbox query may have more rows, not only when the post-filter slice was full; switch to composite (created_at, id) cursor to avoid same-second collisions. - DELETE /attach-document: return 404 when the tx isn't in the company; return 409 when the linked document already has journal_entry_id set (räkenskapsinformation immutability). - risk tier: attach_document_to_transaction medium (was low) — link becomes part of verifikation underlag once categorize propagates it. - pg-real test: stop reusing $2 across uuid + text-concat contexts (Postgres couldn't deduce the parameter type). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrations): break duplicate version 20260505120000 (Supabase Preview) Two migrations on main share filename version 20260505120000: - 20260505120000_api_keys_refresh_token.sql (PR #392) - 20260505120000_drop_agent_auto_commit.sql (PR #394) The schema_migrations primary key is (version), so any fresh DB doing `supabase db push` over both files conflicts on the second insert. This is why every PR with a migration since #394 has had Supabase Preview either fail or skip. Renaming _drop_agent_auto_commit to 20260505190027 — that matches the timestamp recorded in prod schema_migrations from when apply_migration was called for it, so future `db push` against prod sees the file as already- applied (no re-run). The migration body is fully idempotent (IF EXISTS on every drop) so a re-run would be a no-op anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-2 compliance review Two BFL gaps the compliance bot flagged on the round-1 fix commit: 1. commitAttachDocumentToTransaction silently broke verifikation→underlag if the transaction was categorized between staging and approval. Now reads transactions.journal_entry_id at commit time and, if non-null, also writes document_attachments.journal_entry_id in the same commit so BFL 5 kap 6 § is satisfied regardless of order. 2. Application-layer DELETE check was racy (SELECT then UPDATE) and the FK ON DELETE SET NULL path could null transactions.document_id even for a document that is räkenskapsinformation. Added a BEFORE UPDATE OF document_id trigger on transactions that raises check_violation when the previously-attached document has document_attachments.journal_entry_id set. The app-layer guard stays for friendly Swedish messaging; the trigger is the DB-level safety net. pg-real test extended to cover both directions of the trigger (block detach + block swap) and the happy-path detach when there's no JE link yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-3 compliance review Four findings from the round-2 update of the compliance bot. The first three are genuine compliance gaps; the fourth (preview metadata distinguishing pre- vs post-categorization overwrites) is a UX nicety left for follow-up. 1. transactions.document_id FK switched from ON DELETE SET NULL to RESTRICT (migration 20260506100000). Removes the "trigger ordering" concern: a doc that's pinned to any tx now cannot be deleted at all without explicit detach first. Belt-and-braces with block_document_deletion. 2. commitAttachDocumentToTransaction now does: - pre-check that mirrors the DELETE route's 409 when the existing pinned doc is räkenskapsinformation, so the same Swedish message is returned in both paths; - UPDATE…RETURNING journal_entry_id so the propagation decision uses the post-update state, closing the read-then-write race with concurrent categorize. Either ordering of attach-then-categorize or categorize-then-attach now lands at the same correct final state. 3. Both DELETE /attach-document and the MCP commit path catch the trigger's check_violation (SQLSTATE 23514) and translate to 409 with the Swedish underlag message. The trigger remains the DB-level safety net; the app layer is responsible only for friendly UX. pg-real test rewritten for ON DELETE RESTRICT (blocks deletion of pinned doc; detach-then-delete works). Unit coverage added for commitAttach: 404, two distinct 409 paths (pre-check + trigger-translation), happy-path uncategorized, and propagation when tx was categorized between staging and commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-4 compliance review Three of five round-3 findings actioned: 1. commitAttachDocumentToTransaction: surface propagation failure rather than logging-and-continuing. If document_attachments.journal_entry_id can't be set after the transaction has been categorized, the op fails (status 500) with a Swedish message instructing retry. Retry is idempotent — same document_id on the tx, same propagate target. 2. Replace check_violation (23514) matching with a stable "BFL_DOCUMENT_IMMUTABILITY:" message prefix. The trigger now uses default P0001 + tagged message; both the route handler and the executor match on the prefix instead of the generic SQLSTATE. Future unrelated CHECK constraints on transactions can no longer accidentally surface as the räkenskapsinformation message. 3. gnubok_list_unmatched_documents now returns invoice currency alongside amount so an agent can FX-normalise before comparing to transactions.amount. Description updated to make the requirement explicit. Mirrored in the UI: AttachToTransactionDialog ranks same-currency rows by amount distance and pushes cross-currency rows to the bottom of the list. Skipped: - Two-migration window for FK action change is acknowledged as resolved by the bot; deploy-atomicity is an ops concern, not code. - Period-lock check in attach/detach: realistic compliance concern is already covered by the existing immutability trigger (post-categorize) and by the engine's period-lock enforcement (categorize itself). A dedicated period check on pre-categorize attach would only guard against pinning a doc to a tx in a closed period — defensible defense-in-depth, but no active BFL violation. Left for a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-5 compliance review Three of four findings actioned. The fourth (block_document_deletion verification) is already covered by 20240101000017_enforcement_triggers.sql which raises when document_attachments.journal_entry_id IS NOT NULL on a posted/reversed entry — confirmed via grep, no code change needed. 1. categorize/route.ts: propagation no longer fires-and-forgets. If document_attachments.journal_entry_id can't be set after the JE has been committed, the response now carries a document_link_warning field with a Swedish retry message. The JE is already committed so we can't roll back, but the client can no longer mistake a partial attach for a clean categorize. 2. Rättelse audit trail (BFL 5 kap 5 §): both the REST POST handler and the MCP commit executor now append a TransactionDocumentReplaced event to processing_history whenever a non-null document_id is overwritten, with previous_document_id and new_document_id in the payload. Best-effort — logging failure must not roll back the (compliant) attach. The previous doc id is also returned in the response so callers see what was displaced. 3. MCP staging preview now exposes the existing doc's identity (existing_document_id, existing_document_file_name) plus an explicit existing_document_is_rakenskapsinformation flag, so a human approver sees "replaces X.pdf with Y.pdf" rather than just a will_overwrite_existing boolean. Mirrors BFL 5 kap 5 § informed-rättelse intent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): close trigger SELECT race (PR #397 round-6) The enforce_transactions_document_immutability trigger SELECTed document_attachments.journal_entry_id without a row lock. A concurrent UPDATE setting journal_entry_id on that row could commit between the trigger's SELECT and its RAISE, letting a detach slip through against a document that just became räkenskapsinformation. Add FOR SHARE to the SELECT inside the trigger. A concurrent journal_entry_id write blocks on our share lock until our transaction commits, so either we observe the propagation and raise, or we run first and the propagation observes our committed detach (which is fine because journal_entry_id was still null at that point). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): bidirectional immutability + richer staging preview (PR #397 round-7) Two of six round-6 findings actioned. The other four are recurring architectural recommendations (atomic audit-log writes, background reconciliation jobs, migration consolidation, anti-join via materialized view) that are properly scoped as follow-up work. 1. document_attachments side of the immutability link (BFL 5 kap 6 § works in both directions). New trigger enforce_document_journal_entry_immutability blocks UPDATE OF journal_entry_id when going from non-null to NULL or to a different uuid. The original null→uuid path (initial propagation in the categorize / commitAttach flows) still works. Migration 20260506130000. 2. gnubok_attach_document_to_transaction staging preview now joins on invoice_inbox_items.extracted_data and surfaces vendor/amount/currency/ invoice_date alongside the existing doc filename/mime metadata. Gives the human approver the same hints the agent saw before choosing the attachment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2c03dac981 |
fix(reports): convert FX to SEK in supplier/AR ledger reconciliation (#396)
* fix(reports): convert FX to SEK in supplier/AR ledger reconciliation The supplier and AR ledger reports were summing remaining_amount directly without converting foreign-currency invoices, so a EUR/USD invoice would land in the aging total at face value while the corresponding 2440 / 1510 GL line was already posted in SEK. This produced false reconciliation discrepancies (e.g. 496,25 kr ledger vs 952,50 kr GL with four EUR/USD invoices). Apply resolveSekAmount(remaining, null, currency, exchange_rate) in supplier-ledger, supplier-reconciliation, ar-ledger, and ar-reconciliation. Per-invoice detail rows on the AR ledger keep the original currency for display; only aging buckets and totals become SEK. Adds mixed-currency test cases to all four files. Also bundles unrelated WIP from the working tree: - bank-reconciliation: log the swallowed catch error and drop the fallback path for the deleted get_unlinked_bank_lines RPC, using get_unlinked_1930_lines directly. - new GET /api/transactions list endpoint with unmatched/reconciled/ currency/date filters and full route tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(reports): address PR #396 review findings Compliance and Greptile review identified four issues; all four are addressed here. - Reconciliation: surface unconverted_fx_count on ReconciliationResult and ARReconciliationResult. When > 0 the difference field may be a data gap (FX invoice with no exchange_rate) rather than a true reconciliation break. UI now renders a Swedish caveat below the Avstämd / Ej avstämd badge so users understand the cause. New tests assert the count is set on legacy FX rows. - Reconciliation: document the invoice-date-rate assumption explicitly in the JSDoc of both reconciliation generators. Per ML 8 kap 21–23 §, the report uses each invoice's stored exchange_rate; partial payments settled at a different rate produce a delta correctly booked to 3960/7960 as valutakursvinst/-förlust, but the GL will diverge from the report by that amount until a subledger-derived total is wired up (deferred follow-up). - fetchUnlinkedGLLines: drop the misleading bankAccount parameter. It was advertised as configurable but the function silently returned [] for any value other than '1930'. Now the signature is honest: 1930-only until proper multi-account support is built. - /api/transactions: query MAX_ROWS+1 rows so the response can include has_more and limit fields. Callers can now detect truncation, which matters once a company crosses 500 unmatched transactions in the selected range. New test asserts has_more=true when 501 rows are returned by the DB and the response is sliced to MAX_ROWS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(reports): address PR #396 round-2 compliance review Compliance bot v2 review surfaced four findings on the previous commit; three are addressed here. The fourth (an fx_rate_diff_amount indicator distinguishing real reconciliation breaks from correctly-booked valutakursvinst/-förlust) requires a subledger-derived total against 3960/7960 — already documented as a deferred follow-up in the JSDoc. Changes: - Exclude unconvertible FX rows from SEK sums. resolveSekAmount's null-rate fallback returned the raw foreign amount, so a 100 EUR invoice with no rate was being added to a SEK total as if it were 100 SEK. All four generators (supplier-ledger, supplier-reconciliation, ar-ledger, ar-reconciliation) now skip rows where currency != SEK and exchange_rate is missing/zero, and count them in unconverted_fx_count. - Surface unconverted_fx_count on SupplierLedgerReport / ARLedgerReport (not just the reconciliation result). UI shows a Swedish caption beneath the "Totalt utestående" card whenever the count is positive, so users see the warning even if they don't enable the reconciliation panel. - Add outstanding_sek: number | null to ARInvoiceDetail. The per-invoice detail row keeps `outstanding` in invoice currency for display, but now also exposes the converted SEK value (or null when unconvertible). Defensive against future callers that sum across customers — they should use outstanding_sek to avoid mixing currencies (a real momsdeklaration foot-gun otherwise). - Add a 1930-only notice to BankReconciliationView. The reconciliation is scoped to account 1930; users with Plusgiro 1920, kreditkort 1940, or valutakonton now see a Swedish caption explaining those are reconciled separately. Tests: - supplier-ledger: previous "falls back to original amount" case flipped to assert the row is excluded and counted; assertion that the legacy supplier disappears from the list when their only row is unconvertible. - supplier-reconciliation: previous "1 100 ledger vs 1 000 GL" case flipped to assert ledger=1 000 and is_reconciled=true with unconverted_fx_count=1. - ar-reconciliation: same pattern. - ar-ledger: existing FX-mix test extended to assert outstanding_sek on each detail row; new test covering the null-rate exclusion path asserts the detail row is still pushed (with outstanding_sek=null) but excluded from buckets and the grand total. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(reports): address PR #396 round-3 compliance review Compliance bot v3 surfaced four findings on the previous commit; two are addressed here, two are deliberately skipped (rationale in JSDoc / this message). Addressed: - is_reconciled now returns false whenever unconverted_fx_count > 0, even if the numeric difference is zero. Per BFL 5 kap, the reconciliation must cover all affärshändelser; if a row was excluded for a missing exchange rate, the calculation is incomplete by construction and the period cannot honestly be stamped Avstämd. The fix is for the user to fill in the missing rate, not for the system to claim balance on partial data. - AR reconciliation now sums account 1510 + 1513 in the GL balance comparison. Forward-looking defense for ROT/RUT fakturamodellen invoices that split AR receivables across the customer portion (1510) and the Skatteverket claim (1513). Today no production code posts to 1513 so the value is unchanged in practice; once fakturamodellen invoicing is added, the reconciliation will continue to balance without requiring another fix. UI label updated to "Kundfordringar (1510 + 1513) saldo" so the inclusion is visible. Field name and shape are unchanged for back-compat with the supplier-side parallel. Skipped: - "Block is_reconciled=true when any FX invoice exists in an open period" — overly aggressive; would block reconciliation for any FX-using company even when their books are correctly matched. The proper solution is the deferred subledger-derived total against 3960/7960 (already documented in JSDoc on both reconciliation generators), which can compute the *expected* FX rate difference and either subtract it from `difference` or expose it as `fx_rate_difference`. Not in scope for this PR. - Plusgiro 1920 wording in BankReconciliationView — bot itself marked this "not a hard finding". The 1920 reference is accurate per BAS 2026. Tests: - supplier-reconciliation: existing v3 exclusion test updated — asserts is_reconciled=false despite numbers matching, with comment explaining BFL rationale. - ar-reconciliation: same update + new test covering the 1510 + 1513 sum (1 200 on 1510, 300 on 1513, ledger total 1 500 → reconciled). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
432a8b60dc |
feat(skatteverket): rewrite AGI flow against real Skatteverket RAML (#391)
* feat(skatteverket): rewrite AGI flow against real Skatteverket RAML The previous AGI client posted JSON to URL paths that don't exist on Skatteverket's gateway and used invented field names. POST /underlag actually accepts application/xml, and the lock/kvittenser operations live on the separate hanteraredovisningsperiod API. Verified against dev_docs/arbetsgivardeklaration-inlamning(1.7.7) and arbetsgivardeklaration-hantera-redovisningsperiod(1.2.8) RAMLs. - Replace fictional types with real schemas (kontrollresultat, granskningsunderlag, kvittenser, error envelope) - Rewrite agi-client into 9 functions matching the documented flow: /underlag (XML) -> kontrollresultat -> spara -> skapaGranskningsunderlag -> kvittenser, plus las/lasUpp on the hantera API - Drop agi-mappers entirely; lib/salary/agi/xml-generator.ts already produces schema-valid XML, so the extension just feeds agi_declarations.xml_content to POST /underlag - Extend skvRequest with a contentType option so AGI can post XML - AGIPanel state machine: underlag_submitted -> awaiting_signing -> signed, with kontrollresultat polling and normalized findings - Add the agd OAuth scope (confirmed from SKV's Tjanstebeskrivning Arbetsgivardeklaration inlamning v1.7, section 4.1.2.2) - Add Skatteverket connect step to NewUserChecklist alongside the existing SIE/old-system import and bank steps; track hasSkatteverketConnected in OnboardingProgress - Update orchestrator route + tests to point at the new /agi/submit endpoint - Declare new optional base-URL env vars in the manifest Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): address PR review findings on AGI flow - Surface INCORRECT_DATA felrapport link in AGIPanel skapaGranskningsunderlag returns 409 with a felrapport URL when SKV rejects the underlag. The link was persisted as `signeringslank` with status `underlag_rejected`, but the render condition only fired for `awaiting_signing`, leaving the link unreachable. Add a distinct destructive-styled block so the user can open the felrapport in Mina Sidor. - /agi/underlag DELETE clears local submission state Add optional `period` query param. When supplied, clear `agi_submission_{period}` directly. When not, fall back to scanning recent agi_submission_* keys for the matching inlamningId. Without this, an aborted underlag left a stale `underlag_submitted` entry in extension_data and the UI couldn't progress. - Re-add salary-run status guard inside loadAGIXml The orchestrator at app/api/salary/runs/[id]/agi/submit/route.ts has this check, but the extension endpoint is also reachable directly from AGIPanel and must enforce it itself. Per BFL 5 kap and SFL 26 kap, AGI must reflect finalised payroll data; submitting from a draft/cancelled run would emit incorrect figures. - Move agi_declarations.status='exported' from /agi/submit to /agi/spara Setting status on underlag-ingest was wrong because a DONE_REJECTED kontrollresultat would leave the row falsely marked as exported. The transition now happens only after the spara call commits the underlag to Eget utrymme. /agi/spara accepts salaryRunId in the body for the fast path and falls back to scanning agi_submission_* state otherwise. - Move salary_runs.agi_submitted_at stamp to kvittenser observation The orchestrator was stamping at underlag-ingest, but no later code updated the column on signing. Removed the orchestrator stamp; the /agi/kvittenser handler now stamps salary_runs.agi_submitted_at to kvittens.signeradTid (mirroring SKV's own timestamp) when it pins the receipt to the matching agi_declarations row. - Tighten misleading JSDoc in agi-client.ts taBortSparadInlamning is on the inlämning API, not hantera; the old layout grouped it under a "hantera API" heading and tripped an automated reviewer. Restructured into separate "period management" and "cleanup" blocks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): address Swedish compliance review on AGI flow Follow-up to review on https://github.com/erp-mafia/gnubok/pull/391. - Migration adds 'pending_signature' to agi_declarations.status Reusing 'exported' for the spara→kvittens interval misstated the filing outcome — Eget utrymme is a staging area, not a filing — which conflicts with BFNAR 2013:2 kap 8 / BFL 5 kap 5§ behandlingshistorik faithfulness. /agi/spara now sets 'pending_signature'; /agi/kvittenser later promotes to 'submitted' when a uuidKvittens is observed. - AGIPanel auto-polls /agi/kvittenser at 30s, 2 min and 5 min after the signing link is created Previously the kvittens (and therefore salary_runs.agi_submitted_at) was only stamped if the user manually returned to the panel and clicked "Hämta kvittens". Without that follow-up the audit trail showed a NULL submitted-at for an AGI that had actually been filed. Background polls capture the kvittens for the common case where the user signs in Mina Sidor and never returns to gnubok. Cleanup on unmount via useRef + useEffect. - Distinct MISSING_SCOPE error code on 403 invalid_scope Existing tokens lack the new 'agd' scope and surface as a generic ACCESS_DENIED today. The compliance reviewer pointed out that operators may interpret this as a data error and submit a corrected AGI with altered figures. New SkatteverketAuthError code maps SKV's invalid_scope body to a clear "reconnect via Inställningar → Skatteverket" message; routes to 401 (token-level remediation). - Refine deadline copy in AGIPanel The standard AGI deadline is the 12th regardless of company size; the 17th only applies in January and August for employers with turnover ≤ 40 MSEK. Surface that nuance instead of saying just "12:e". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): server-side kvittens reconciliation cron Round 2 of compliance review on https://github.com/erp-mafia/gnubok/pull/391. - /api/extensions/skatteverket/agi/kvittenser/cron Walks every agi_declarations row in 'pending_signature' status, fetches kvittenser via the matching token, and on a hit promotes the row to 'submitted' + stamps salary_runs.agi_submitted_at. Authoritative source for the audit trail per BFNAR 2013:2 kap 8 / BFL 5 kap 5§ — the AGIPanel client-side timers from the previous round remain as the fast-path UX, but no longer carry the audit-trail responsibility on their own. Per-row errors are skipped, not abort-the-run. 50s budget. Scheduled every 2 hours in vercel.json. - AGIStatus union now includes 'pending_signature' Without this update, downstream code reading the union would have rejected the new status as unknown. The migration extending the DB CHECK constraint shipped in the previous commit; this brings the type layer into sync. - Stale comment update in AGIPanel.tsx Referred to status='exported' from before the rename. Now reads 'pending_signature', matching the actual handler behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): close audit-trail gaps from compliance round 3 - Cron now writes submitted_by from the token-owning auth.users row Previously left NULL with a "system actor" comment. The token row was created when the operator authenticated with BankID, and the kvittens' signeradAv refers to the same person — so writing the user_id from skatteverket_tokens captures actor traceability without inventing a system identity. Closes the BFL 5 kap 6§ / BFNAR 2013:2 kap 8 gap on cron-reconciled rows. - /agi/spara monotonicity guard Adds .in('status', ['generated', 'exported']) to the row update so a delayed /agi/spara call after the cron (or interactive /agi/kvittenser) has already promoted the row to 'submitted'/'accepted' won't silently regress it back to 'pending_signature'. behandlingshistorik must advance only. - DONE_REJECTED / DONE_FAILED → status='rejected' /agi/kontrollresultat handler now flips the matching agi_declarations row to 'rejected' on a terminal SKV failure, using the same cached-submission-state lookup pattern /agi/spara already uses. Without this the row sat at 'generated' indefinitely even though SKV considered the underlag failed. Same monotonicity guard prevents regressing a successfully-filed row. - Deadline criterion: lönesumma, not omsättning AGIPanel pendingText. SFL 26 kap's relaxed-deadline criterion (17:e in Jan/Aug) is the employer's total taxable wages, not turnover. Internal reference (.claude/skills/swedish-payroll/references/agi-filing.md) used the colloquial "turnover"; statutory wording is "lönesumma". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): close round-4 audit-trail and UX gaps - agi_submitted_at: NULL when signeradTid absent Both /agi/kvittenser handler and the kvittens cron previously fell back to new Date().toISOString() if SKV's kvittens lacked signeradTid. Substituting wall-clock now() falsifies the filing moment in behandlingshistorik (BFNAR 2013:2 kap 8 / BFL 5 kap 6§). Now leaves the column NULL and logs a warning. Status flip to 'submitted' still happens — the audit gap was timing only. - Proactive missing-agd-scope banner SkatteverketConnectPanel and AGIPanel now warn when the stored token lacks the agd scope. Tokens issued before the agd rollout would otherwise 403 with invalid_scope at submission time, often too close to the AGI deadline. SkatteverketConnectPanel mirrors the existing "skattekonto saknas" pattern; AGIPanel surfaces a banner in the connected state and links to /settings/skatteverket. - Granskningsunderlag isError keys on tillstand only Previous check mixed HTTP 409 with the INCORRECT_DATA tillstand string. A future SKV addition like RECEIVING returned with HTTP 200 would have slipped through as awaiting_signing. Now keys solely on tillstand: only LOCKED_FOR_SIGNING / UNLOCKED are treated as signable; everything else (INCORRECT_DATA, RECEIVING, CALCULATING, SIGNING) routes to underlag_rejected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): close round-5 audit-trail and recovery gaps - agi_submitted_at: stamp with reconciliation time when signeradTid absent Round 4 left the column NULL on missing signeradTid to avoid falsifying the signing moment. Round 5 pointed out that NULL hides that the filing *occurred* — also a behandlingshistorik integrity violation. Resolution: presence of uuidKvittens proves SKV signed and accepted the AGI, so we stamp with signeradTid || now() and warn-log when fallback is used. Both /agi/kvittenser handler and the kvittens cron. - Persist signeradAv + full kvittens in agi_declarations.response_data submitted_by is the auth.users UUID we have on hand (the polling / reconciling user). The legally load-bearing signer identity is kvittens.signeradAv (a personnummer) — which the token user_id does NOT necessarily match (e.g. bookkeeper vs deklarationsombud). The existing response_data jsonb column now holds the full kvittens record, preserving signeradAv for the audit trail (BFL 5 kap 6§ / BFNAR 2013:2 kap 8) without a schema change. Cron path also marks reconciledBy='cron'. - /agi/spara monotonicity: allow recovery from 'rejected' Previously .in('status', ['generated', 'exported']) excluded rejected rows, so a successful re-submission after a prior rejection couldn't promote the row to pending_signature — it silently stayed rejected. The xml-route reuses the same agi_declarations row when re-generating XML, so this is the realistic recovery path. Added 'rejected' to the allowed-from list. 'submitted'/'accepted' still blocked (no regression from filed states). - Fix misleading agi-client.ts comment Claimed users could "fix the errors in Mina Sidor" after a DONE_REJECTED save. Mina Sidor doesn't expose in-place editing; the correct recovery is to regenerate XML and resubmit. Updated the agiSparaUnderlag JSDoc to describe the actual flow. - Deadline copy: "vars sammanlagda lönesumma understiger 40 MSEK" Reads more cleanly than "≤ 40 MSEK" and matches the phrasing the compliance reviewer suggested. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): tighten /agi/spara guard and clarify deadline copy (round 6) - Drop 'exported' from /agi/spara allowed-from states Audit confirmed no code path writes status='exported' today; the value is preserved in the schema (and union) for the legacy manual-download path that no longer has a writer. Allowing the spara handler to flip an 'exported' row to 'pending_signature' would conflate two distinct filing attempts on a single row, weakening the chain of custody (BFL 5 kap 6§). Tightened to .in(['generated', 'rejected']) — same recovery path for re-submission after rejection, no path for the dormant state. - Deadline copy: explicit "per år" qualifier The 40 MSEK threshold is annual lönesumma, not per-payment. Adding "per år" closes the (admittedly thin) misread the compliance reviewer flagged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c03582b5c7 |
Fix/percistent mcp connection (#392)
* feat(oauth): add support for refresh tokens in OAuth flow and update database schema * feat(prompts): add MCP prompts and corresponding functionality for prompt retrieval * feat(auth): enhance error handling for refresh token operations and validation |
||
|
|
fa7d4075cf |
Supp/invoice bfl errors (#390)
* feat(accounting): update accounting method validation and messaging for aktiebolag and enskild firma * Remove AI subsystem and related code - Deleted AI proposals and requests persistence logic from `lib/ai/proposals/persist.ts`. - Removed re-validation logic for proposals in `lib/ai/proposals/re-validate.ts`. - Cleaned up schemas related to AI flows in `lib/api/schemas.ts`. - Removed AI-related fields from bookkeeping engine in `lib/bookkeeping/engine.ts`. - Eliminated AI event types from `lib/events/types.ts`. - Updated tests to reflect the removal of AI-related functionality in `lib/extensions/__tests__/sectors.test.ts`. - Adjusted initialization logic in `lib/init.ts` to exclude AI proposal handler registration. - Cleaned up transaction ingestion logic in `lib/transactions/ingest.ts` to remove AI flow checks. - Updated helper functions in `tests/helpers.ts` to remove AI-related settings. - Removed AI-related types and interfaces from `types/index.ts`. - Added migration script to drop AI-related tables and settings from the database. * fix(migrations): ensure foreign key constraint is dropped before removing AI tables * feat(invoice-inbox): implement deterministic invoice field extraction and inbox provisioning - Added `extract-invoice-fields.ts` for extracting fields from PDF invoices using regex and pdfjs-dist, replacing the previous AI classifier. - Introduced `inbox-provisioning.ts` to manage company inbox addresses and rotation of inboxes using Supabase RPCs. - Created `resend-inbound.ts` for handling inbound email events and attachments via the Resend API. - Defined the extension manifest for the invoice inbox, specifying required environment variables and descriptions. - Migrated database schema to remove AI-related columns and tighten the status enum in `invoice_inbox_items`. * feat(invoice-inbox): remove AI-specific columns and tighten status enum * fix(skattekonto): remove manual entry creation reference from transaction input * fix(schemas): remove accounting method validation for aktiebolag in UpdateSettingsSchema |
||
|
|
f3fd4c0822 |
feat(salary, skatteverket): per-day absence + AGI Frånvarouppgift + skattekonto + hardening (#388)
* feat(salary): per-day absence tracking with calendar UX Replace aggregated-day absence counts with per-day records so payroll calculations can correctly enforce Swedish legal rules that depend on actual dates: karensavdrag once per sjuklöneperiod, återinsjuknande within 5 calendar days, allmänt högriskskydd cap of 10 karensavdrag per rolling 12 months, day-8 läkarintyg flag, day-15 transition to Försäkringskassan. Adds: - salary_absence_days table (RLS, dedup unique on employee+date+type) - /api/salary/employees/[id]/absence CRUD route - deriveAbsenceLineItems helper that walks per-day records into sjuklöneperioder and emits correctly-classified line items, with the existing absence-calculator formulas reused for VAB / parental - Per-employee pay-spec detail page with month-grid AbsenceCalendar - Calculate route now derives line items from the calendar before running the salary engine, replacing the prior sumQuantity model - Salary run GET surfaces the formatted Skatteverket arbetsgivare ID so downstream UI can build extension URLs without a second round-trip - GET /salary/runs/[id]/employees/[employeeId] for the detail page Tests: 15 new unit tests covering segment merge, återinsjuknande within 5 days, högriskskydd cap, FK transition flag, läkarintyg flag, VAB/parental semesterlönegrundande ceilings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): harden API client + add NEXT_PUBLIC_SKATTEVERKET_ENABLED feature flag Three hardening fixes from the prior audit, plus a runtime extension toggle for phased rollout. api-client.ts: - Map 429 to a new SkatteverketAuthError code RATE_LIMITED with a Swedish user message. The 4 req/sec local rate limiter normally prevents this, but the per-consumer gateway quota can still hit. - Extend the error union with TOKEN_CORRUPTED for the token-store fix below. token-store.ts: - Surface decryption failures instead of silently returning null. A rotated key or tampered ciphertext used to look like "not connected"; callers now get TOKEN_CORRUPTED with a clear "anslut igen med BankID" message and a structured log line for ops. Extension dispatcher (app/api/extensions/ext/[...path]/route.ts): - Per-extension feature flag table. When NEXT_PUBLIC_SKATTEVERKET_ENABLED is not exactly "true", the dispatcher returns 503 with code EXTENSION_DISABLED, letting ops disable a single integration mid- rollout without redeploying or removing it from extensions.config.json. UI panels (SkatteverketPanel, AGIPanel) detect the 503 and render an empty state. Tests: 7 api-client cases (401/403/403-Behörighet/429/5xx/200/auth-error codes) + 2 token-store cases (no-row → null, corrupted → TOKEN_CORRUPTED). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission AGI XML upgrade: - Emit <gem:Franvarouppgift> top-level blocks for VAB and parental leave events sourced from salary_absence_days, per SKV 4785 + technical doc. Element order matches the spec example file. TILLFALLIG_FORALDRAPENNING for VAB / FORALDRAPENNING for parental, with FranvaroTimmarTFP (FK825) or FranvaroTimmarFP (FK827) for hours. Stable 1-based specifikationsnummer per (employee, period), date-sorted. Skipped entirely for periods before 202501. - Sick days are NOT emitted (they go to Försäkringskassan). - FK499 TotalSjuklonekostnad now derived from sick_day2_14.quantity × dailyRate × 0.80 instead of Math.abs(amount). The line-item amount is the net deduction (lostPay − sjuklon), not the cost, so the prior formula understated by a factor of four. AGI submission UI: - New AGIPanel mirroring SkatteverketPanel's validate → draft → lock → BankID-sign → poll-submitted flow. Detects 503 EXTENSION_DISABLED and renders a clear empty state. Replaces the bare "Skicka till Skatteverket" button on /salary/runs/[id], keeping the AGI XML download as a sibling for archival / manual upload fallback. - Salary run rows now link to the per-employee detail page added in the previous commit. Tests: 14 new agi-xml cases covering element order, type↔hour-field mapping, specifikationsnummer ordering, fractional-hour formatting, range clamping (0.01-24.00), period guard at 202501 boundary, placement after Blankett blocks, multi-employee date ordering, required-fields invariant, omission when no events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): skattekonto integration — read-only saldo + transactions, daily sync, per-row bokför Adds read-only Skattekonto v2.1 access via the existing BankID OAuth flow (extends the OAuth scope with `skattekonto`). Daily background sync pulls saldo + transactions, dedupes on (company_id, dedup_key), and surfaces the data in a /skattekonto dashboard plus a settings panel for connection management. Backend: - skattekonto-client.ts: GET /skattekonton/{omfragad}/saldo and /transaktioner. Felkod 1–5 mapped to Swedish messages via dedicated SkatteverketSkattekontoError. - skattekonto-sync.ts: parallel saldo + transaktioner fetch, UPSERT on (company_id, dedup_key) so kommande rows graduate to tidigare in place. Dedup key uses transaktionsidentitet when available, else sha256 of (date|amount|text). Caches saldo snapshot in extension_data. Emits skattekonto.synced / balance.changed (sign flip) / transaction.upcoming (first appearance) / connection.expired. - skattekonto-booking.ts: keyword→counter-account rules with AB/EF differentiation (2510 vs 2012 for preliminärskatt; 2731/2710/2650 for arbetsgivaravgifter/avdragen skatt/moms; 8423/8313 for kostnads-/intäktsränta). Creates a draft journal entry against BAS 1630, leaves it for the user to review and commit. Throws NO_COUNTER_ACCOUNT instead of guessing when no rule matches. - Daily cron at 0 4 * * * (Swedish 06:00). Double-gated by CRON_SECRET and NEXT_PUBLIC_SKATTEVERKET_ENABLED. Per-company cooldown of 1 hour, time budget 50s, distinct `expired` status for token-exhaustion separate from generic errors. Database: - skattekonto_transactions: company-scoped with RLS, unique (company_id, dedup_key), indexed on (company_id, date DESC) and (company_id, status). journal_entry_id FK with ON DELETE SET NULL so a row can be re-bokförd after entry deletion. Frontend: - /skattekonto/page.tsx: dashboard with saldo card, transactions list (booked + upcoming), per-row "Bokför" action. - /settings/skatteverket: connection panel showing scope/expiry. - Extension toggle in SettingsSidebar (gated by ENABLED_EXTENSION_IDS). Tests: 9 booking-rule cases (counter-account guessing, AB/EF divergence, no-match throw) + 7 mapper cases (dedup key stability, sign convention, kommande→tidigare graduation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR review findings Build: - Fix Next.js build failure: Zod refuses .partial() on a refined schema. Replace AbsenceRangeQuerySchema.partial().extend(...) in the absence DELETE handler with a fresh z.object that defines its own optional fields. Greptile findings (PR #388): - skattekonto_transactions UPDATE policy was missing WITH CHECK; without it a user could mutate company_id to one they don't belong to. Edit the original migration for fresh applies + add a follow-up migration that drops/recreates the policy with both clauses (already applied to prod via Supabase MCP). - FK499 TotalSjuklonekostnad now reads sjuklonRate from run.calculation_params (snapshot taken at calc time) instead of a hardcoded 0.80, so an operator override (e.g. CBA-specific rate) is honored. Falls back to 0.80 for older runs without the snapshot. - Rename NEXT_PUBLIC_SKATTEVERKET_ENABLED → SKATTEVERKET_ENABLED so the flag is server-side only. NEXT_PUBLIC_* vars are inlined into the client bundle at build time, which would create split-brain (server 503 vs client still rendering enabled flow) on a flag flip without redeploy. UI panels detect 503 by response code, not by reading the env directly, so no client-visible change is needed. - Add pg-real RLS smoke tests for both new tables (salary_absence_days and skattekonto_transactions): tenant SELECT isolation, UPDATE WITH CHECK enforcement, unique-constraint enforcement, cross-tenant dedup key allowed. Swedish compliance review: - Document the högriskskydd cap interpretation in derive-absence-line-items.ts. We count *sjuklöneperioder* in the rolling 12-month window, matching the law's plain reading ("från och med den 11:e sjukperioden ... görs inget karensavdrag"). An alternative reading counts only periods that actually had karens deducted; that requires persisting per-period karens-deduction state, which gnubok doesn't yet do. The period-count reading can over- suppress, never under-suppress, so it's the safer default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): inline skattekonto fixtures so core-only CI runs without dev_docs dev_docs/ is gitignored, so the skattekonto-mappers test failed in CI when it tried to readFileSync from dev_docs/skattekonto(2.1.0)/examples/. Inline the saldoResponse + transaktionerResponse fixtures verbatim from the spec; the test still verifies our mappers + dedup-key logic against the same shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bb855d2ddc |
Add/ai native supp (#385)
* feat(branding): implement dynamic branding in service worker and reports * feat(auth): enhance API key scopes and add bookkeeping write scope - Updated transaction write scope description to include additional tools. - Enhanced reports read scope description to reflect new functionality. - Introduced bookkeeping write scope with relevant description. - Updated SCOPE_GROUPS to include bookkeeping domain. - Modified TOOL_SCOPE_MAP to include new bookkeeping operations. - Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution. feat(tests): add unit tests for MCP resource registry - Created tests for data resources to ensure all required fields are present. - Added tests for resource query parsing and retrieval. feat(resources): implement MCP resources for company and accounting data - Added capabilities resource to expose API key capabilities based on granted scopes. - Implemented chart of accounts resource to retrieve active BAS chart. - Created company current resource to fetch active company details. - Developed active fiscal period resource to check posting eligibility. - Implemented recent activity resource to fetch latest journal entries, invoices, and transactions. - Added VAT treatments resource to provide available VAT rates per customer type. feat(pending-operations): introduce risk tiers for operations - Added risk level classification for pending operations to determine auto-commit eligibility. - Implemented functions to classify operation risk levels and identify high-risk operations. feat(migrations): add actor model and risk tier to pending operations - Updated pending_operations table to include actor type and risk level columns. - Enhanced audit_log to mirror actor information for compliance. - Modified validate_and_increment_api_key function to return actor details. - Expanded operation types in pending_operations to include new high-risk operations. * feat: add auto-commit functionality for low-risk pending operations - Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings. - Created commitPendingOperation function to handle execution of pending operations with consistent status updates. - Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds. - Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality. - Added SQL migration to update the database schema for new auto-commit settings. * feat(idempotency): implement idempotency key handling for safe retries and cleanup * feat: expand API key scopes and pending operations for bookkeeping - Added 'suppliers:write' scope to API key scopes for supplier invoice management. - Updated SCOPE_GROUPS to include the new 'suppliers:write' scope. - Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice. - Implemented corresponding commit functions for the new operations in the pending operations module. - Enhanced PendingOperation type to include actor model and risk level attributes. - Added tests for new functionality, ensuring proper behavior and constraints in the database. * feat: implement unlockPeriod functionality and related tests * feat: add agent auto-commit settings and related functionality * feat: add attention resource with comprehensive summary of outstanding tasks * feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes |
||
|
|
5e1b0f791d |
feat(branding): implement dynamic branding in service worker and reports (#383)
* feat(branding): implement dynamic branding in service worker and reports * refactor(service-worker): remove push notification handling code * feat(service-worker): implement dynamic branding in service worker and related scripts |
||
|
|
064fb7f7a9 |
Add/white label (#381)
* feat(branding): add BrandingService with default-preserving env layer Introduce lib/branding/service.ts mirroring lib/email/service.ts. Defaults match current gnubok values exactly, so production behaviour is unchanged unless an env var (NEXT_PUBLIC_BRANDING_*, BRANDING_*) or extension override (via registerBrandingService) is set. Resolution order: defaults < env vars < extension override. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(branding): route root layout, manifest, and PWA assets through branding service - app/layout.tsx now reads title, description, themeColor, and apple-touch-icon from getBranding() instead of hardcoded values. - public/manifest.json replaced by dynamic app/manifest.ts so PWA name, short_name, description, theme_color, background_color, and icon paths are resolved at request time. The manifest now serves at /manifest.webmanifest (Next.js convention for the metadata file route). The previous /manifest.json URL is no longer populated; nothing in core references it after this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(branding): route email service and templates through branding service - resend-service.ts: From line uses getBranding().appName instead of hardcoded "Gnubok" in both the with-fromName and bare cases. - invite-templates.ts: subject, HTML header, body, plain text, and the team-invite variants all read from branding (sentence case in prose, uppercased for the styled <p> header). - consent-notification-templates.ts: signature fallback (companyName || branding) for both HTML and plain text variants. Defaults preserve the exact current strings ("Gnubok", "GNUBOK", "gnubok" in their respective contexts) so no email content changes for production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(branding): route OAuth consent page through branding service The MCP OAuth consent page rendered for Claude Desktop / Claude.ai connector flows now reads the app name from getBranding() for both the HTML <title> and the body copy. Default still produces "gnubok" in lowercase prose, matching current behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(branding): route auth, dashboard, and onboarding text through branding service Replace user-visible "gnubok" / "Gnubok" references with calls to getBranding(). Touches: - Auth pages (login, register, mfa/enroll): logo src/alt, MFA TOTP friendlyName. - Onboarding (companies/new, invite, sandbox, WelcomeOnboarding, Step2CompanyDetails, NewUserChecklist, BankIdCompanyPicker, ArcimMigrationWorkspace): logo, headings, error/help text. - Dashboard fallback (companyName="gnubok") and settings (backup copy, ApiKeysPanel MCP connector name + login note, CompanyDangerZone, retention-notice). - API routes (support contact subject prefix, enable-banking consent email companyName fallback, AI inbox receipt-request appUrl, pain001 messageId prefix). - MCP server "open the gnubok web app" review message. - Salary/reports filings (AGI Programnamn, KU10 Programnamn, payslip footer, full-archive system metadata, SRU #PROGRAM line). Internal identifiers (cookie names gnubok-company-id / gnubok-invite-token, API key prefix gnubok_sk_, invite token prefix gnubok_inv_, MCP tool names, npm package gnubok-mcp, GNUBOK_API_KEY env name) are deliberately left unchanged — they're stable contracts that whitelabels must not break. Defaults match current behaviour exactly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(branding): support legal page field-level swaps for entity and contact Privacy and DPA pages now interpolate appName, legalEntity, and privacyEmail from the branding service instead of hardcoding "Gnubok", "Arcim", and "privacy@gnubok.se". Page metadata uses generateMetadata() so titles also reflect the brand. lib/support.ts now falls back to getBranding().supportEmail when SUPPORT_RECIPIENT_EMAIL is unset, so a single BRANDING_SUPPORT_EMAIL env var configures both the support form recipient and the displayed support address. Whitelabels with a different legal jurisdiction or entirely different DPA text should override the page route from an extension. Phase 1 intentionally only supports field-level swaps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(branding): add WHITELABEL.md and example branding extension WHITELABEL.md: fork checklist, env var reference, the "do not change" list (cookies, API key prefixes, invite token prefixes, MCP tool names, gnubok-mcp npm package, GNUBOK_API_KEY env name), out-of-scope items, the upstream sync workflow YAML to copy into a fork, conflict avoidance guidance, and a verification checklist. extensions/general/_example-branding/: copy-paste starter extension with index.ts (commented placeholder values for registerBrandingService), manifest.json, and README.md. Disabled by default (not added to extensions.config.json); whitelabels cp the folder, edit, and enable. sectors.test.ts: bumped expected extension count 12 -> 13 to account for the new starter extension on disk. The generated registry is unchanged because the example is disabled. The sync workflow YAML is documented inline in WHITELABEL.md rather than checked in as a workflow file. It's only meaningful in a fork -- gnubok itself has nothing to sync from. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(branding): address PR review — lazy support email + escape brand in HTML/XML Three issues from code review: P1 — lib/support.ts: SUPPORT_RECIPIENT_EMAIL was a module-level const, evaluated at import time before extensions register branding overrides via ensureInitialized(). Convert to getSupportRecipientEmail() lazy accessor; update the only caller in app/api/support/contact/route.ts. Extension-supplied supportEmail values now route correctly. P2 — app/api/mcp-oauth/authorize/route.ts: appName was interpolated into the consent page HTML without escapeHtml(), inconsistent with the existing escaping of companyName. Wrap appName.toLowerCase() in escapeHtml() at use sites in <title> and the body paragraph. P2 — lib/salary/agi/xml-generator.ts and lib/salary/ku/ku10-generator.ts: appName placed inside <gem:Programnamn> / <Programnamn> XML elements without escapeXml(), the helper already used for other admin-controlled fields in the same files. Wrap accordingly to prevent malformed XML if a brand name contains XML reserved characters. All admin-controlled inputs only — no user-exploitable path. Defense in depth, not a known incident. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(branding): security follow-up — lazy metadata, SRU/email header sanitization Self-audit after the PR review surfaced four more concerns. Fixes them with the same defense-in-depth posture as the prior review fixes. 1. app/layout.tsx — same eager-evaluation class as P1 support.ts. The module-level `const branding = getBranding()` froze branding before extensions registered, so extension-based overrides for title, description, themeColor, and apple-touch-icon silently never applied. - Convert to generateMetadata() / generateViewport() (lazy, run per request, see extension-registered overrides). - Inline getBranding() inside RootLayout for the apple-touch-icon href so it picks up overrides too. - Add ensureInitialized() at module level so extensions are loaded before the first metadata call. Mirrors the API route pattern. 2. app/manifest.ts — same class. The dynamic manifest function reads getBranding() per request, but if the manifest is requested before any other module has triggered ensureInitialized(), extensions are still unloaded. Add ensureInitialized() at module level. 3. lib/reports/ink2/sru-generator.ts — appName interpolated into the SRU `#PROGRAM` directive without sanitization. SRU's reserved char is `#` (directive marker) and CRLF injects new directives. Wrap in the existing sanitizeString() helper to match the pattern used for other admin-controlled fields in this file (#NAMN, #ADRESS, etc.). 4. extensions/general/email/lib/resend-service.ts — appName and the user-controlled fromName both flow into the From header. Resend's API does its own validation, but defense in depth: strip CRLF and angle brackets via a small sanitizeHeaderPart() helper before building the header string. fromName was a pre-existing surface; appName is new with this whitelabel work. All four are admin-controlled inputs (env vars or extension code), not user-exploitable. No known incidents — defense in depth, and correctness for extension-based whitelabels. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2d36dedf34 |
fix(journal-entry): add error logging for delete operation in journal… (#379)
* fix(journal-entry): add error logging for delete operation in journal entries * fix(journal-entry): enhance error logging and add tests for delete_last_voucher functionality * fix(journal-entry): restore enforce_journal_entry_immutability function to handle DELETE and un-reversal updates * fix(tests): refactor delete_last_voucher tests to use insertPostedEntryWithLines for consistency |