§ 01 Scope & goals
NF-1143 replaces the legacy attestation /
attestationrow /
attestationtemplate tables and the procedural code around them
with a domain-driven module under domain/attestation. The new
module models attestation as a per-document state machine driven by a versioned policy:
- Per-company (per-
dbid) policies with versioned slot lists. - Threshold-based slot bypass driven by invoice amount, plus a credit-invoice variant.
- Time-bounded delegation resolved dynamically at query time (not snapshotted).
- Supplier-level bypass for low-amount recurring invoices (skips the entire chain).
- Email reminders for tenant/board attestants with batched-urgent and daily-digest cadences.
- In-app notifications for internal users (financial administrators).
- Full activity log per attestation (state changes + comments + bypasses).
- Side-by-side data migration of legacy in-flight attestations and templates.
Today only DocumentType.SUPPLIER_INVOICE is wired up; the
document_type column is open-ended so future document classes
can plug in.
§ 02 Actors & applications
The attestation surface spans five FE clients with different auth contexts. Two roles do most of the work: internal financial administrators (förvaltare) and board-member attestants. The rest (Nest, Go) are legacy.
Roles
-
Internal förvaltare (financial administrator). Identified at runtime by
FinancialAdministratorService.findByUserId(...)returning present. Receives in-app inbox notifications only; never email reminders. Works in Home (single-company power tool) and Flow (cross-company aggregation). - Board-member attestant (and other tenant-side attestants). Anyone in the eligible-attestant pool who is not a financial administrator. Receives email reminders (digest + urgent batch); never in-app notifications. Works in board-portal.
-
Legacy Go user (
@Authorized). Has read-only attestation-policy access viaAttestationPolicyResource; the Go supplier-invoice UI still calls a couple of dead legacy endpoints (no replacement on the new system).
Frontend applications
Home
Förvaltare · single co.
Single-company power tool. Owns submission, all FA-side state transitions
(withdraw, returnToAttestation,
resubmit, cancel), policy/version/slot
CRUD, delegations, manual reminders, and the activity timeline.
Flow
Förvaltare · cross-co.
Cross-company action-required dashboard. Read + attest +
addComment + manual sendReminder.
Heavy actions deep-link out to Home via requestHomeLoginToken(dbId).
board-portal
Board attestant
The attestant's tool. Read + attest +
sendForReview (UI label: Utred) +
addComment. Email reminders (urgent + digest) link straight here.
Nest
Legacy admin
Still hosts legacy attestation-template management
(/nest/attestation/template) for boards. Superseded by Home's
settings page, but not yet retired.
Go
Förvaltare · uppladdning
AngularJS supplier-invoice UI is still the active invoice-creation surface.
On save, SupplierInvoiceCoordinationService.createSupplierInvoice
calls attestationCoordinationService.submitSupplierInvoice
with the policy chosen in the Attestflöde dropdown. This is the entry
point into the new attestation system. The detail view also renders the activity
timeline (legacy AttestRows shape, served from the new
attestation_activity table via
buildLegacyAttestationData).
§ 03 Frontend coverage matrix
Every action below is implemented end-to-end on the backend. The matrix shows where each action is reachable from a UI today. Legend: ● wired in UI · ◐ service method exists, no UI · · not present.
| Action | Home | Flow | board-portal | Nest | Go |
|---|---|---|---|---|---|
submitSupplierInvoice | ◐ | · | · | · | ● |
attest | ● | ● | ● | · | · |
sendForReview (Utred) | · | · | ● | · | · |
return (to submitter) | · | · | ◐ | · | · |
returnToAttestation (Återgå) | ● | · | · | · | · |
withdraw | ● | · | · | · | · |
resubmit | ● | · | · | · | · |
cancel | ● | · | · | · | · |
addComment | ● | ● | ● | · | · |
sendReminder (manual) | ● | ● | · | · | · |
| Policy / version / slot CRUD | ● | · | · | · | ◐ |
| Delegation CRUD | ● | · | · | · | · |
| Supplier-bypass settings | ◐ | · | · | · | · |
| Cross-company action-required inbox | · | ● | · | · | · |
| Cross-company summary | · | ● | · | · | · |
| Delegated-during-absence list | ● | · | · | · | · |
Legacy attestationtemplate CRUD | · | · | · | ● | · |
Backend resource → role table
| Resource | Path | Auth | Owns |
|---|---|---|---|
HomeAttestationResource |
/home/attestation |
@HomeAuthorized |
Single-company FA surface: full state machine, policies, delegations, supplier-bypass, manual reminder, activities. |
FlowAttestationResource |
/flow/attestation/{dbId} |
@FlowAuthorized |
Cross-company FA surface: /summary, /action-required, attest, comment, manual reminder. Heavy actions defer to Home. |
BoardPortalAttestationResource |
/portal/attestation |
@BoardPortalAuthorized |
Attestant surface: attest, return, review (sendForReview), comment, list. |
AttestationPolicyResource |
/attestationpolicy |
@Authorized |
Read-only list for Go's policy dropdown. |
- No FE wires
return(PENDING → RETURNED). Board-portal'sattestation.service.tshas the method but no component invokes it.RETURNEDis currently an unreachable state. - Home's supplier-bypass UI is orphaned:
supplier-bypass-slide-in.component.tsexists with full CRUD but no template references itslib-supplier-bypass-slide-inselector. - Home's
submit()service method is unused: it exists onattestation.service.tsbut no component calls it. Invoice submission still goes through Go (POST /supplierinvoice?policyId=…→SupplierInvoiceCoordinationService.createSupplierInvoice). - Go's old
/attestation/minamount+/attestation/notattestedcalls hit non-existent endpoints and silently fail. They are cosmetic. The rest of Go's attestation surface (policy dropdown, activity timeline, save-with-policy) works.
§ 04 Flow: standard happy path
Single-company invoice with two attestants: first a board member, then a financial administrator. Most common shape. Role pills mark where each step happens.
-
Förvaltare · Go
Upload + create the supplier invoice with a policy
FA fills in supplier, amount, accounts and selects an Attestflöde in the dropdown (data from
GET /attestationpolicy). On save,POST /supplierinvoice?financialyear&policyId(legacy@Authorized) lands atSupplierInvoiceCoordinationService.createSupplierInvoice, which callsattestationCoordinationService.submitSupplierInvoice(ctx, invoiceId, policyId). The coordination service evaluates required slots (or supplier-bypasses entirely), creates theattestationrow, snapshotsattestation_slots, setssupplierinvoice.state = PENDING_ATTESTATION, activates slot 1, and writes a PENDING_ATTESTATION activity. -
System
Activate slot 1 (board member) and queue the reminder
activateSlotstampsslot_activated_at;AttestationReminderService.onSlotActivatedINSERTs a row inattestation_reminderwith the slot's user as recipient and (only if the invoice is already urgent) a populatedurgent_send_after. No in-app notification is created (the recipient isn't a financial administrator). -
System
Email the board member (digest or urgent batch)
Non-urgent: the daily digest job at 16:00 Stockholm picks up the row when the recipient's cooldown clears (1 day if any urgent, else 5 days). Urgent at activation: the every-minute job batches all the recipient's urgent rows and sends one mail 30 min after activation.
-
Board member · board-portal
Click the email link, attest in board-portal
Trigger:
POST /portal/attestation/{id}/attest. Slot 1 is stamped attested; the reminder row is DELETEd. Slot 2 (the financial administrator) is activated. -
System
Notify slot 2 (financial administrator)
notifyActiveAttestantresolvesslot.effectiveUserId ?? slot.userId, detects FA viaFinancialAdministratorService.findByUserId, and creates an in-app notification (NotificationCategory.ECONOMY,NotificationType.ATTESTATION). No reminder row is created (FA → no email). -
Förvaltare · Flow
Attest from the cross-company dashboard
The FA also operates other companies; she opens Flow, sees the invoice in Att attestera grouped under this company, opens the slide-in and clicks Attestera. Trigger:
POST /flow/attestation/{dbId}/{id}/attest. Slot 2 is stamped, no further pending slot exists, so the attestation transitions toATTESTEDandcompletedAtis stamped. -
System
Post the invoice and notify the submitter
postDocumentAfterAttestationcallslegacySupplierInvoiceService.postInvoice. The supplierinvoice state moves toAPPROVED. The submitter (an FA) receives an in-app notification "Attestering slutförd".
§ 05 Flow: attestant unsure (the “Utred” loop)
Board-portal is the only client that exposes sendForReview.
The loop bounces back through Home.
-
Board member · board-portal
Click Utred with a comment
Trigger:
POST /portal/attestation/{id}/review. State:PENDING_ATTESTATION → UNDER_REVIEW. The comment becomes an activity row. The reminder row for the active slot is DELETEd (slot deactivated). The supplierinvoice state moves toUNDER_REVIEW. -
System
In-app notification to submitting FA
Coordination service sends "Utredning begärd" notification to the FA who originally submitted the invoice. (Internal users only; board-member submitters wouldn't get one.)
-
Förvaltare · Home
Reply via comment, then click Återgå till attestering
Trigger:
POST /home/attestation/{id}/return-to-attestation. State:UNDER_REVIEW → PENDING_ATTESTATION. The active slot is re-activated withslotActivatedAt = now()(resetting the urgent timer if applicable), and a new reminder row is INSERTed. -
Board member · board-portal
Re-evaluate, attest
Now back in
PENDING_ATTESTATION; reminder cadence restarts; the attestant sees the comment trail and proceeds.
§ 06 Flow: förvaltare on vacation (delegation)
Delegation is configured in Home but resolved dynamically at query time via
resolveEffectiveUser. No background job mutates pending slots.
-
Förvaltare A · Home
Create a delegation before leaving
POST /home/attestation/policies/{policyId}/delegationswithdelegatorUserId = A,delegateUserId = B, and a date window. Service checks for overlap on(policyId, delegatorUserId). -
System
Subsequent slots resolve to B at query time
resolveEffectiveUser(ctx, policyId, originalUserId)joinsattestation_delegationon today's date when slots are read or activated.notifyActiveAttestantpicksslot.effectiveUserId(= B) for routing. Same goes for reminder recipients. -
Förvaltare B · Flow
Attest as delegate
B sees the attestation in their action-required dashboard via Flow's cross-company query, driven by
findDbIdsWithActiveDelegationForUser∪ managed companies. B attests; the slot is stamped witheffectiveUserId = B,delegatedFromUserId = A. -
Förvaltare A · Home (returns)
Inspect what was delegated
GET /home/attestation/?view=delegator→listDelegatedDuringAbsenceshows pending attestations still attached to A but resolving to a delegate. After the delegation window closes,resolveEffectiveUserreturns to A automatically; no rebalance job.
§ 07 Flow: supplier bypass (fully automatic)
-
Förvaltare · Go
Save a small invoice from a bypassed supplier
-
System
Bypass + auto-approve
submitSupplierInvoicefinds a matchingattestation_policy_supplier_bypass; if|invoice.total| < bypass.minAmount, setssupplierinvoice.state = APPROVEDdirectly, writes a singleEventLogEntry("Leverantörsbypass: belopp under gränsvärde"), and returnsnull. Noattestationrow is created.
§ 08 Domain model
Records (entities, package-private; never leave the domain):
| Type | Role | Key fields |
|---|---|---|
AttestationPolicy |
Per-BRF identity + reminder cadence config. | name, isDefault, currentVersionId, urgent/digest tunables |
AttestationPolicyVersion |
Immutable snapshot; the versioning unit. | policyId, version |
AttestationPolicySlot |
One slot in a version. | policyVersionId, userId, slotOrder, maxAmount |
AttestationDelegation |
Date-bounded substitute for a delegator inside a policy. | policyId, delegatorUserId, delegateUserId, startDate, endDate |
AttestationSupplierBypass |
Min-amount per-supplier full bypass. | policyId, supplierNo, minAmount |
Attestation |
The per-document workflow instance. | policyId, policyVersionId, documentType, documentId, state |
AttestationSlot |
Snapshot of the policy slot for this attestation, plus runtime state. | attestationId, policySlotId, userId, effectiveUserId, delegatedFromUserId, attestedAt, slotActivatedAt |
AttestationActivity |
Append-only event row: state change, comment, or bypass. | attestationId, state, comment, changedBy, delegatedFromUserId |
Service split
AttestationService: pure state machine onattestation+attestation_slot. Knows nothing about documents, notifications, or event logs.AttestationPolicyService: policy / version / slot / delegation / supplier-bypass CRUD + theSlotEvaluationalgorithm +DelegationResolution.AttestationCoordinationService: orchestrator that wires the state machine toSupplierInvoiceService,EventLogService,NotificationService, and the document state column.AttestationReminderService: owns the reminder table; called by both jobs and the state-machine hooks (onSlotActivated/onSlotDeactivated).
Resources: HomeAttestationResource (@HomeAuthorized) and
FlowAttestationResource (@FlowAuthorized).
§ 09 State machine
Per-attestation states live in AttestationState. Cross-app
coverage of each transition is in § 03; the named flows are
in §§ 04–07.
AttestationService. cancel is allowed from any in-flight state.Active-slot lifecycle inside PENDING_ATTESTATION
Every PENDING_ATTESTATION attestation has exactly one
active slot (smallest slotOrder with
attestedAt IS NULL). On
attest:
- Verify the caller is the resolved effective user (delegation-aware) for the active slot.
- Stamp the slot:
attestedAt = now(),effectiveUserId,delegatedFromUserId. - Notify reminder service (
onSlotDeactivated): deletes pending reminder row. - Append
attestation_activityrow (state=ATTESTED). - If a next pending slot exists →
activateSlot(setsslotActivatedAt, calls reminderonSlotActivated). - Otherwise transition the parent attestation to
ATTESTEDand stampcompletedAt.
Coordination layer side-effects
AttestationCoordinationService wraps every state transition in a
transaction and is responsible for:
- Updating
supplierinvoice.state(PENDING_ATTESTATION/UNDER_REVIEW/APPROVED/NULL). - Booking the invoice (
legacySupplierInvoiceService.postInvoice) on terminalATTESTED. - Writing one
EventLogEntryper action (EventLogType.ATTESTATION_EVENT). - Sending an in-app notification to the next active attestant iff they are a financial administrator (see § 14).
§ 10 Slot evaluation
Implemented in AttestationPolicyService.evaluateRequiredSlots
(and …ForCredit). Walks the ordered slot list from the current
version and decides which slots are required vs bypassed:
public SlotEvaluation evaluateRequiredSlots(UserContext ctx, UUID policyId, BigDecimal amount) { List<AttestationPolicySlot> ordered = dao.getCurrentSlots(ctx.dbId(), policyId); List<AttestationPolicySlot> required = new ArrayList<>(); int i = 0; for (; i < ordered.size(); i++) { AttestationPolicySlot slot = ordered.get(i); required.add(slot); if (slot.maxAmount() != null && amount.compareTo(slot.maxAmount()) <= 0) { i++; break; } } List<AttestationPolicySlot> bypassed = new ArrayList<>(ordered.subList(i, ordered.size())); return new SlotEvaluation(required, bypassed, BypassReason.AMOUNT_COVERED, amount); }
Rules
- Slot 1 is always required.
- The first slot whose
maxAmountis set and covers the amount ends the chain. Everything after it is bypassed. - A slot with
maxAmount IS NULLnever terminates the chain. The chain continues past it. - If no slot covers the amount, the entire chain is required.
Credit-invoice variant
For total < 0,
AttestationCoordinationService.submitSupplierInvoice routes to
evaluateRequiredSlotsForCredit:
- Magnitude is irrelevant; any slot with a
maxAmountset is treated as covering. - Walks slots in order; stops at the first slot with a non-null
maxAmount. - If no slot has a
maxAmount, the entire chain is required.
Bypass audit trail
After the PENDING_ATTESTATION activity row is written,
AttestationService.recordBypassActivities writes one
attestation_activity row per bypassed slot with
state = NULL and a Swedish comment from
AttestationPolicyService.buildBypassComment:
| Reason | Comment template |
|---|---|
AMOUNT_COVERED | “Attestant N krävs ej. Belopp under {covering.maxAmount} kr.” |
CREDIT_INVOICE | “Attestant N krävs ej för kreditfakturor.” |
Same transaction as the attestation creation; atomic.
§ 11 Delegation resolution
Delegations are not snapshotted onto pending slots; they are resolved
at query time by AttestationPolicyService.resolveEffectiveUser:
DelegationResolution resolveEffectiveUser(UserContext ctx, UUID policyId, UUID originalUserId) { Optional<AttestationDelegation> delegation = dao.findActiveDelegation( ctx.dbId(), policyId, originalUserId, LocalDate.now()); if (delegation.isPresent()) { return new DelegationResolution(delegation.get().delegateUserId(), originalUserId); } return new DelegationResolution(originalUserId, null); }
AttestationService.attest,
returnAttestation, and
sendForReview all call resolveEffectiveUser
to authorize the caller. attestation_slot.effectiveUserId /
delegatedFromUserId are written only when the slot is
attested, capturing who actually acted.
Dynamic resolution (rather than snapshotting + reconciliation jobs) was chosen so that delegation can start/end without touching pending slot rows or running periodic jobs. Active delegations are computed by today's date inside the slot lookup query.
Validation rules
- No overlap: For a given
(policyId, delegatorUserId), two delegations cannot overlap. Enforced in service viahasOverlappingDelegationon create + update (withexcludeIdon update). - Update window:
endDatecan never move before the most recent attestation that happened under this delegation (getLatestEffectiveDatesByPolicyjoinsattestation_slotondelegated_from_user_id+effective_user_id). - Delete window: A delegation is only deletable when no
attestation_slotrow references it via the same join. OtherwiseDELETEreturns 400 with a Swedish reason.
The latest-effective-date map
The DTO AttestationDelegationDto carries
CanDelete and LatestEffectiveDate so
the FE can both hide the delete affordance and clamp the date picker. Computed once per
listDelegations call:
SELECT d.id AS delegation_id, MAX(s.attested_at)::date AS latest_attested FROM attestation_delegation d JOIN attestation a ON a.policy_id = d.policy_id AND a.dbid = d.dbid JOIN attestation_slot s ON s.attestation_id = a.id AND s.dbid = a.dbid WHERE d.dbid = ? AND d.policy_id = ? AND s.delegated_from_user_id = d.delegator_user_id AND s.effective_user_id = d.delegate_user_id AND s.attested_at IS NOT NULL AND s.attested_at::date BETWEEN d.start_date AND d.end_date GROUP BY d.id
§ 12 Supplier bypass
attestation_policy_supplier_bypass models a per-supplier
minAmount. On submitSupplierInvoice:
- Look up the bypass for
(policyId, invoice.supplierNo). - If found and
|invoice.total| < bypass.minAmount, setsupplierinvoice.state = APPROVED, log a singleEventLogEntrywith reason “Leverantörsbypass: belopp under gränsvärde”, and returnnull. Noattestationrow is created. - Otherwise, fall through to normal slot evaluation.
Supplier bypass uses strict less-than (amount.compareTo(min) < 0).
An invoice equal to minAmount goes through the chain.
§ 13 Reminder pipeline
The reminder system is owned by AttestationReminderService
and runs against the attestation_reminder table. One row per
currently-active slot whose recipient is a tenant. Internal users (financial
administrators) are explicitly skipped here and notified through
NotificationService instead (see § 14).
Triggers
| Trigger | Job / endpoint | Effect |
|---|---|---|
| Slot activated | AttestationService.activateSlot → onSlotActivated |
INSERT row. If slot is urgent at activation (duedate ≤ today + urgent_due_threshold_days), stamp urgent_send_after = now() + URGENT_BATCH_DELAY_MINUTES. |
| Slot deactivated | attest / return / sendForReview / cancel / withdraw |
DELETE the row. |
| Urgent batch | AttestationReminderJob (every minute) |
Find ripe rows (urgent_send_after <= now()), group by recipient, pull in all their active rows (clear their urgent_send_after), send one digest mail. |
| Daily digest | AttestationDigestReminderJob (16:00 Stockholm) |
Per recipient, evaluate cooldown (1 day if any urgent, else 5 days) on a Stockholm calendar-day basis: a row is past cooldown when daysBetween(last_sent_at::dateSE, todaySE) ≥ cooldownDays (or last_sent_at is NULL). Send only if every row in the recipient's set is past cooldown. Day-granularity (not Instant precision) so the send-completion timestamp can't drift the interval to N+1 days. Update last_sent_at on send. |
| Manual | POST /home/attestation/{id}/send-reminder |
Send immediately. Does not update last_sent_at nor clear urgent_send_after. Updates last_manual_sent_at for UI display. |
SendGrid template
Single dynamic template; both urgent batch and daily digest use it; subject and header
switch on contains_urgent. Template ID lives in
SendGridUtil.SENDGRID_TEMPLATE_ATTESTATION_REMINDER_DIGEST.
In non-live envs SendGridUtil.post(...) short-circuits and returns
true; reminder rows get marked sent without delivery.
Dynamic-template payload
| Field | Type | Notes |
|---|---|---|
subject | string | Auto-added by MailService. Urgent → Fakturor förfaller inom kort; otherwise Fakturor att attestera. From AttestationDigestReminderMail.getSubject. |
company_name | string | From CompanyService. |
contains_urgent | boolean | Header banner toggle. |
rows | array | One per active attestation. Sorted by due_date asc; missing dates last. |
invoice_number | string | |
supplier_name | string | "-" if missing. |
due_date | ISO string | "" if missing. |
amount | string | sv-SE formatted, suffixed " kr". |
urgent | boolean | duedate ≤ today + threshold. |
Tunables on the policy
urgent_due_threshold_days: default 5.urgent_batch_delay_minutes: default 30.urgent_digest_cooldown_days: default 1.non_urgent_digest_cooldown_days: default 5.
These live on attestation_policy directly; changing them does not mint a new version.
§ 14 In-app notifications
Distribution rule, enforced inside
AttestationCoordinationService.sendNotification:
- Tenant / board-member attestants → email reminders only (via
attestation_reminder). - Internal users (financial administrators detected via
FinancialAdministratorService.findByUserId) → in-app inbox notification only.
Both paths converge on notifyActiveAttestant, which picks
slot.effectiveUserId ?? slot.userId for the active slot.
Reciprocal handoff: AttestationReminderService.onSlotActivated
explicitly skips internal users with the comment that they're "handled by NotificationService".
Notification taxonomy:
NotificationCategory.ECONOMYNotificationType.ATTESTATION- Reference:
attestation-{id}-{userId}(idempotent; supersedes prior notification on the same slot).
§ 15 Audit trail
Two parallel logs:
-
attestation_activity: domain log; one row per state change, comment, or bypass. Read by the FE timeline. Writers:- State transitions:
AttestationService.{submit, withdraw, attest, returnAttestation, sendForReview, returnToAttestation, cancel, resubmit}. - Comments:
AttestationService.addComment. - Bypasses:
recordBypassActivities(state=NULL, comment populated).
- State transitions:
-
eventlog: global cross-domain event log. Written byAttestationCoordinationService.logEventfor every action. Carries anAttestationEventpayload (attestationId, documentType, documentId, action, stateBefore, stateAfter, comment, delegatedFrom). Used by the events tab in Home and by external auditing.
Supplier-bypass (entire-attestation skip) is only in the global event log; no
attestation row exists.
§ 16 REST API
All endpoints under /home/attestation use @HomeAuthorized. Mirror endpoints under /flow/attestation/{dbId} use @FlowAuthorized.
Policy / version / slot
Delegation
CanDelete + LatestEffectiveDate.Supplier bypass
Attestation actions
200 with no body if supplier-bypassed.RETURNED; re-evaluates against current version.PENDING_ATTESTATION with no attested slot.UNDER_REVIEW; resets reminder timer.Read
state filter or view=delegator.§ 17 Database schema
All tables live in the public schema, with dbid as the multi-tenancy partition key (§ 20).
| Table | Role | Notable constraints / indexes |
|---|---|---|
attestation_policy |
Per-BRF policy + reminder tunables. | Partial unique (dbid) WHERE is_default; unique (dbid, name). |
attestation_policy_version |
Immutable version snapshot. | Unique (policy_id, version); FK policy_id ON DELETE CASCADE. |
attestation_policy_slot |
Ordered slots per version. | Unique (policy_version_id, slot_order); max_amount nullable. |
attestation_policy_supplier_bypass |
Per-supplier full-bypass. | Unique (policy_id, supplier_no). |
attestation_delegation |
Date-bounded substitution. | CHECK end_date ≥ start_date; overlap prevention is application-layer. |
attestation |
Per-document workflow row. | Unique (dbid, document_type, document_id); CHECK state IN (...). |
attestation_slot |
Snapshot + runtime state. | policy_slot_id is a reference, not a FK. |
attestation_activity |
Domain event log. | CHECK state IS NOT NULL OR comment IS NOT NULL. |
attestation_reminder |
Pending email reminder rows. | Unique (dbid, slot_id); partial index on urgent_send_after WHERE NOT NULL. |
§ 18 Legacy migration
V2026_03_27_1000__NF-1143.sql is a single transaction that
renames the legacy tables and seeds the new schema from them.
-
Rename
attestation→attestation_legacy,attestationrow→attestationrow_legacy,attestationtemplate→attestationtemplate_legacy(with constraint renames). Guarded by an existence check so re-running fails fast. -
Create all new tables. Adds
supplierinvoice.stateandsupplierinvoice.pausedcolumns. -
Seed policies: one default
attestation_policyper BRF that has active templates. A backstop empty default policy is created for any BRF with legacy attestations but no active template; without it the LEFT JOIN in step 6 would producepolicy_id = NULLrows. -
Seed slots from
attestationtemplate_legacy(active rows only).slot_orderis renumbered withROW_NUMBER()to match the 1-indexed convention. -
Seed delegations from active templates with
replacementid IS NOT NULL. Missing dates default toCURRENT_DATE/CURRENT_DATE + 1 year. -
Migrate in-flight attestations. State derivation:
certified=true→ATTESTED; active legacy row withstatus=2→UNDER_REVIEW; otherwisePENDING_ATTESTATION. -
Snapshot legacy rows to
attestation_slot.effective_user_idset only onstatus=1.delegated_from_user_idalways NULL; legacy substituted the replacement directly intouserid. -
Backfill
attestation_reminderfor every active slot whose user is inglobal_tenant. Internal users are skipped; they're handled byNotificationService. -
Backfill
attestation_activitywith submission, attested-slot, UNDER_REVIEW round, and synthetic “return to attestation” rows reconstructing closed legacy review rounds. -
Backfill
supplierinvoice.statefrom the newattestation.staterather than the legacycertifiedflag. -
Verification block: counts every legacy and new table and emits a
RAISE NOTICEsummary, plus the count of orphaned legacy rows.
§ 19 Concurrency & transactions
- All write operations on
AttestationServiceandAttestationCoordinationServiceare wrapped inTransactionTemplate.execute(...). - State transitions use
dao.retrieveForUpdate(SELECT … FOR UPDATE) to take a row lock on the parentattestationbefore any check or mutation. - Policy versioning uses optimistic concurrency:
CreatePolicyVersionCommand.currentVersionIdmust match the policy's current version, otherwise409 Conflict. RequestObjectAdapter.adapt(...)requires an active transaction; the coordination layer wraps even read-only methods that call it (e.g.getActivities,retrieve) in aTransactionTemplate.- Slot activation + reminder write happen in the same transaction as the state change. Reminder cleanup on slot deactivation is also same-transaction.
§ 20 Multi-tenancy
Every query in AttestationDao, AttestationPolicyDao, and AttestationReminderDao includes dbid = ? in the WHERE clause, even single-row primary-key lookups. This is the project-wide guardrail (see backend/CLAUDE.md) and the new module follows it strictly.
The cross-tenant read entry points are explicitly bounded:
findActionRequiredForUser(Collection<UUID> dbIds, …): Flow's “requires my action” inbox; takes a caller-supplied dbid set.findDbIdsWithActiveDelegationForUser(UUID userId): used to discover which companies the caller has cross-tenant delegate visibility into.
§ 21 Testing notes
Unit tests under backend/src/test/java/se/nest/backend/domain/attestation/:
AttestationPolicyServiceTest: 14 cases over slot evaluation (positive + credit) and bypass-comment construction.AttestationServiceTest: 3 cases asserting bypass activity rows are written exactly once per bypassed slot, in the same transaction.
Run:
mvn test -Dtest='AttestationPolicyServiceTest*,AttestationServiceTest*' -DfailIfNoTests=false
Surefire 2.22.2 + JUnit 5 @Nested: -Dtest=AttestationPolicyServiceTest matches only the top-level (0 tests). Use the wildcard form (…Test*) to pick up nested classes.
Manual verification matrix
| Scenario | Policy [A,B,C] | Amount | Required slots | Bypass activities |
|---|---|---|---|---|
| Small invoice covered by slot 1 | [1000, 5000, null] | 500 | A | 2 (slot 2, slot 3) |
| Mid invoice covered by slot 2 | [1000, 5000, null] | 3000 | A, B | 1 (slot 3) |
| Large invoice; full chain | [1000, 5000, null] | 10000 | A, B, C | 0 |
| Credit, mixed thresholds | [1000, 5000, null] | −10000 | A | 2 (credit-flavored) |
| Credit, slot 1 unlimited | [null, 5000, null] | −10000 | A, B | 1 (credit-flavored) |
| Credit, all unlimited | [null, null, null] | −10000 | A, B, C | 0 |
| Supplier bypass below min | any, bypass min=2000 | −500 | — | No attestation row; one event-log entry. |
| Supplier bypass above min | any, bypass min=2000 | −5000 | credit-aware chain | per credit-aware evaluation |
end · NF-1143