Files
accounted/lib/api
Jakob Wennberg 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>
2026-05-12 22:31:46 +02:00
..
2026-05-05 09:53:37 +02:00
2026-05-04 11:12:29 +02:00
2026-05-06 11:12:02 +02:00