Skip to content
Nest Attestation · Technical specification ← Back to index

Chapter 02 · English

Attestation flow — tech spec

Per-document approval workflow for supplier invoices in Home: domain model, state machine, slot-evaluation algorithm, reminder pipeline, REST surface and database schema.

for developers Stable Updated 2026-05-13 Branch NF-1143-attestation Domain se.nest.backend.domain.attestation

§ 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 via AttestationPolicyResource; 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

ResourcePathAuthOwns
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.
FE gaps known as of 2026-05-13
  • No FE wires return (PENDING → RETURNED). Board-portal's attestation.service.ts has the method but no component invokes it. RETURNED is currently an unreachable state.
  • Home's supplier-bypass UI is orphaned: supplier-bypass-slide-in.component.ts exists with full CRUD but no template references its lib-supplier-bypass-slide-in selector.
  • Home's submit() service method is unused: it exists on attestation.service.ts but no component calls it. Invoice submission still goes through Go (POST /supplierinvoice?policyId=…SupplierInvoiceCoordinationService.createSupplierInvoice).
  • Go's old /attestation/minamount + /attestation/notattested calls 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.

  1. 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 at SupplierInvoiceCoordinationService.createSupplierInvoice, which calls attestationCoordinationService.submitSupplierInvoice(ctx, invoiceId, policyId). The coordination service evaluates required slots (or supplier-bypasses entirely), creates the attestation row, snapshots attestation_slots, sets supplierinvoice.state = PENDING_ATTESTATION, activates slot 1, and writes a PENDING_ATTESTATION activity.

  2. System

    Activate slot 1 (board member) and queue the reminder

    activateSlot stamps slot_activated_at; AttestationReminderService.onSlotActivated INSERTs a row in attestation_reminder with the slot's user as recipient and (only if the invoice is already urgent) a populated urgent_send_after. No in-app notification is created (the recipient isn't a financial administrator).

  3. 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.

  4. 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.

  5. System

    Notify slot 2 (financial administrator)

    notifyActiveAttestant resolves slot.effectiveUserId ?? slot.userId, detects FA via FinancialAdministratorService.findByUserId, and creates an in-app notification (NotificationCategory.ECONOMY, NotificationType.ATTESTATION). No reminder row is created (FA → no email).

  6. 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 to ATTESTED and completedAt is stamped.

  7. System

    Post the invoice and notify the submitter

    postDocumentAfterAttestation calls legacySupplierInvoiceService.postInvoice. The supplierinvoice state moves to APPROVED. 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.

  1. 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 to UNDER_REVIEW.

  2. 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.)

  3. 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 with slotActivatedAt = now() (resetting the urgent timer if applicable), and a new reminder row is INSERTed.

  4. 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.

  1. Förvaltare A · Home

    Create a delegation before leaving

    POST /home/attestation/policies/{policyId}/delegations with delegatorUserId = A, delegateUserId = B, and a date window. Service checks for overlap on (policyId, delegatorUserId).

  2. System

    Subsequent slots resolve to B at query time

    resolveEffectiveUser(ctx, policyId, originalUserId) joins attestation_delegation on today's date when slots are read or activated. notifyActiveAttestant picks slot.effectiveUserId (= B) for routing. Same goes for reminder recipients.

  3. 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 with effectiveUserId = B, delegatedFromUserId = A.

  4. Förvaltare A · Home (returns)

    Inspect what was delegated

    GET /home/attestation/?view=delegatorlistDelegatedDuringAbsence shows pending attestations still attached to A but resolving to a delegate. After the delegation window closes, resolveEffectiveUser returns to A automatically; no rebalance job.

§ 07 Flow: supplier bypass (fully automatic)

  1. Förvaltare · Go

    Save a small invoice from a bypassed supplier

  2. System

    Bypass + auto-approve

    submitSupplierInvoice finds a matching attestation_policy_supplier_bypass; if |invoice.total| < bypass.minAmount, sets supplierinvoice.state = APPROVED directly, writes a single EventLogEntry ("Leverantörsbypass: belopp under gränsvärde"), and returns null. No attestation row is created.

§ 08 Domain model

Records (entities, package-private; never leave the domain):

TypeRoleKey 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 on attestation + attestation_slot. Knows nothing about documents, notifications, or event logs.
  • AttestationPolicyService: policy / version / slot / delegation / supplier-bypass CRUD + the SlotEvaluation algorithm + DelegationResolution.
  • AttestationCoordinationService: orchestrator that wires the state machine to SupplierInvoiceService, 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.

NOT_SUBMITTED PENDING_ATTESTATION UNDER_REVIEW RETURNED ATTESTED CANCELLED submit withdraw attest (last slot) sendForReview returnToAttestation return (with comment) resubmit cancel
Transitions enforced by 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:

  1. Verify the caller is the resolved effective user (delegation-aware) for the active slot.
  2. Stamp the slot: attestedAt = now(), effectiveUserId, delegatedFromUserId.
  3. Notify reminder service (onSlotDeactivated): deletes pending reminder row.
  4. Append attestation_activity row (state=ATTESTED).
  5. If a next pending slot exists → activateSlot (sets slotActivatedAt, calls reminder onSlotActivated).
  6. Otherwise transition the parent attestation to ATTESTED and stamp completedAt.

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 terminal ATTESTED.
  • Writing one EventLogEntry per 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 maxAmount is set and covers the amount ends the chain. Everything after it is bypassed.
  • A slot with maxAmount IS NULL never 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 maxAmount set 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:

ReasonComment 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.

Design note

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 via hasOverlappingDelegation on create + update (with excludeId on update).
  • Update window: endDate can never move before the most recent attestation that happened under this delegation (getLatestEffectiveDatesByPolicy joins attestation_slot on delegated_from_user_id + effective_user_id).
  • Delete window: A delegation is only deletable when no attestation_slot row references it via the same join. Otherwise DELETE returns 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:

  1. Look up the bypass for (policyId, invoice.supplierNo).
  2. If found and |invoice.total| < bypass.minAmount, set supplierinvoice.state = APPROVED, log a single EventLogEntry with reason “Leverantörsbypass: belopp under gränsvärde”, and return null. No attestation row is created.
  3. Otherwise, fall through to normal slot evaluation.
Caveat

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

TriggerJob / endpointEffect
Slot activated AttestationService.activateSlotonSlotActivated 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

FieldTypeNotes
subjectstringAuto-added by MailService. Urgent → Fakturor förfaller inom kort; otherwise Fakturor att attestera. From AttestationDigestReminderMail.getSubject.
company_namestringFrom CompanyService.
contains_urgentbooleanHeader banner toggle.
rowsarrayOne per active attestation. Sorted by due_date asc; missing dates last.
  invoice_numberstring
  supplier_namestring"-" if missing.
  due_dateISO string"" if missing.
  amountstringsv-SE formatted, suffixed " kr".
  urgentbooleanduedate ≤ 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.ECONOMY
  • NotificationType.ATTESTATION
  • Reference: attestation-{id}-{userId} (idempotent; supersedes prior notification on the same slot).

§ 15 Audit trail

Two parallel logs:

  1. 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).
  2. eventlog: global cross-domain event log. Written by AttestationCoordinationService.logEvent for every action. Carries an AttestationEvent payload (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

GET/policiesList policies for current company.
GET/policies/{policyId}Single policy with current version + slots.
POST/policiesCreate policy. First policy auto-becomes default.
PUT/policies/{policyId}Update name / default flag / reminder tunables.
DELETE/policies/{policyId}Cannot delete default or one with active attestations.
POST/policies/{policyId}/versionsMint new version (optimistic-locked on currentVersionId).
GET/policies/{policyId}/slotsCurrent version's slots.

Delegation

GET/policies/{policyId}/delegationsIncludes CanDelete + LatestEffectiveDate.
POST/policies/{policyId}/delegationsCreate. 400 on overlap.
PUT/policies/{policyId}/delegations/{delegationId}Date-only update. 400 if endDate < latest attestation.
DELETE/policies/{policyId}/delegations/{delegationId}400 if delegate has already attested.

Supplier bypass

GET/policies/{policyId}/supplier-bypass
POST/policies/{policyId}/supplier-bypass
PUT/policies/{policyId}/supplier-bypass/{bypassId}Update min amount.
DELETE/policies/{policyId}/supplier-bypass/{bypassId}

Attestation actions

POST/submit?invoiceId=&policyId=Returns 200 with no body if supplier-bypassed.
POST/{id}/resubmitOnly from RETURNED; re-evaluates against current version.
POST/{id}/withdrawOnly from PENDING_ATTESTATION with no attested slot.
POST/{id}/attestCaller must be the resolved active user.
POST/{id}/return-to-attestationOnly from UNDER_REVIEW; resets reminder timer.
POST/{id}/cancelAllowed from any in-flight state.
POST/{id}/commentsActivity row only; no state change.
POST/{id}/send-reminderManual; bypasses cooldown bookkeeping.

Read

GET/?state=…&view=…List with optional state filter or view=delegator.
GET/{id}
GET/by-document/{documentId}Lookup by supplier-invoice external ref.
GET/{id}/activitiesDomain timeline.
GET/{id}/eventsGlobal event-log entries for this attestation.
GET/{id}/reminder-statusLast-sent / next-scheduled reminder for the active slot.
GET/attestantsEligible attestant pool (board members ∪ FAs).

§ 17 Database schema

All tables live in the public schema, with dbid as the multi-tenancy partition key (§ 20).

TableRoleNotable 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.

  1. Rename attestationattestation_legacy, attestationrowattestationrow_legacy, attestationtemplateattestationtemplate_legacy (with constraint renames). Guarded by an existence check so re-running fails fast.
  2. Create all new tables. Adds supplierinvoice.state and supplierinvoice.paused columns.
  3. Seed policies: one default attestation_policy per 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 produce policy_id = NULL rows.
  4. Seed slots from attestationtemplate_legacy (active rows only). slot_order is renumbered with ROW_NUMBER() to match the 1-indexed convention.
  5. Seed delegations from active templates with replacementid IS NOT NULL. Missing dates default to CURRENT_DATE / CURRENT_DATE + 1 year.
  6. Migrate in-flight attestations. State derivation: certified=trueATTESTED; active legacy row with status=2UNDER_REVIEW; otherwise PENDING_ATTESTATION.
  7. Snapshot legacy rows to attestation_slot. effective_user_id set only on status=1. delegated_from_user_id always NULL; legacy substituted the replacement directly into userid.
  8. Backfill attestation_reminder for every active slot whose user is in global_tenant. Internal users are skipped; they're handled by NotificationService.
  9. Backfill attestation_activity with submission, attested-slot, UNDER_REVIEW round, and synthetic “return to attestation” rows reconstructing closed legacy review rounds.
  10. Backfill supplierinvoice.state from the new attestation.state rather than the legacy certified flag.
  11. Verification block: counts every legacy and new table and emits a RAISE NOTICE summary, plus the count of orphaned legacy rows.

§ 19 Concurrency & transactions

  • All write operations on AttestationService and AttestationCoordinationService are wrapped in TransactionTemplate.execute(...).
  • State transitions use dao.retrieveForUpdate (SELECT … FOR UPDATE) to take a row lock on the parent attestation before any check or mutation.
  • Policy versioning uses optimistic concurrency: CreatePolicyVersionCommand.currentVersionId must match the policy's current version, otherwise 409 Conflict.
  • RequestObjectAdapter.adapt(...) requires an active transaction; the coordination layer wraps even read-only methods that call it (e.g. getActivities, retrieve) in a TransactionTemplate.
  • 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 quirk

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

ScenarioPolicy [A,B,C]AmountRequired slotsBypass activities
Small invoice covered by slot 1[1000, 5000, null]500A2 (slot 2, slot 3)
Mid invoice covered by slot 2[1000, 5000, null]3000A, B1 (slot 3)
Large invoice; full chain[1000, 5000, null]10000A, B, C0
Credit, mixed thresholds[1000, 5000, null]−10000A2 (credit-flavored)
Credit, slot 1 unlimited[null, 5000, null]−10000A, B1 (credit-flavored)
Credit, all unlimited[null, null, null]−10000A, B, C0
Supplier bypass below minany, bypass min=2000−500No attestation row; one event-log entry.
Supplier bypass above minany, bypass min=2000−5000credit-aware chainper credit-aware evaluation

end · NF-1143