文件
hyb-oa/.trellis/spec/hyb-oa/backend-fastify-prisma.md
T
wangsli fedcaed1a1
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
feat: 补齐报销付款待办交接(09-11-reimbursement-payment-handoffs)
2026-09-11 09:43:37 +08:00

39 KiB

Backend Fastify / Prisma

Scope

Server work lives under server/. Prefer existing route, service, utility and QA script patterns over new abstractions.

Rules

  • Validate Prisma changes with cd server && npm run prisma:validate.
  • For route/service edits, run node --check on the touched files and the focused regression listed in the current Trellis task.
  • Prisma migrations require rollback notes in the Trellis task design or PRD.
  • Do not resolve migration drift, reset databases, or rewrite migration history without explicit approval.
  • Workflow, approval and permission behavior must be verified at process level, not only syntax level.

Common Checks

  • cd server && node --check src/routes/reimbursements.js
  • cd server && node --check src/routes/workflow.js
  • cd server && ./scripts/regression-workflow-actions.sh
  • cd server && ./scripts/regression-all.sh

Scenario: Read-only Reimbursement/Payment Handoffs

1. Scope / Trigger

The portal must expose business work between reimbursement return, approval, batch creation, independent review and payment recording, without a new todo store or notification dependency.

2. Signatures

loadReimbursementPaymentHandoffs(fastify, employee) supplies businessHandoffs: { total, items } on the authenticated GET /portal/home route. Each item has key, kind, reference, title, nextStep, ownerLabel, href, mobileHref; kind is one of reimbursement_edit | reimbursement_batch | payment_review | payment_execute.

3. Contracts

Use a RepeatableRead, READ ONLY transaction (5s acquisition, 10s execution limit). Select necessary identity/reference/stage/department facts, never bank snapshots or amounts. Stable stage/object keys are unique; total equals all returned items, with no silent eight-row cutoff. Mutations retain their own authoritative gates. No schema, new environment setting, persistent todo, decryption or notification send is introduced.

4. Validation & Error Matrix

  • Returned work requires participant + submit/read rights and the latest workflow's final return/reject fact; new, withdrawn, paid and deleted drafts are excluded.
  • Unbatched approved sources require manage permission, exclude deleted/paid and non-cancelled reservations. Missing bank data remains work, not proof of readiness.
  • Review requires manager/ceo + review permission, a different maker, draft/unreviewed status and pending items. Reviewed/failed processing requires manage.
  • Payment work requires both global and source-department payment gates. Missing/deleted batch sources do not yield actionable handoffs.
  • Query failure follows the existing API error boundary and frontend retry state; never convert it into a successful zero-items response.

5. Good / Base / Bad Cases

Good: review moves work from the non-maker reviewer to finance. Base: genuinely no qualifying work returns zero items. Bad: a historical return followed by withdrawal, self-review, or a hidden source must not produce an action.

6. Tests Required

Service/route tests cover permissions, department gates, latest-history selection, deleted sources, no read side effects and stable counts. Real isolated return/resubmit/create/review/receipt/paid transitions must prove work appears and disappears. PC/mobile tests cover refresh, errors, more than eight items and no double-counting of legacy finance aggregates; source-only or mock-only checks are insufficient for final acceptance.

7. Wrong vs Correct

Wrong: treat a notice count or pending-batch aggregate as the complete finance worklist. Correct: derive four business handoffs from current facts; when the new DTO is present, omit the old pending/failed aggregates from action counts while preserving independent risk reminders. Notification outages do not erase business work.

Scenario: Business Error Reasons And Payment Creation Readiness

1. Scope / Trigger

  • Adding or changing user-facing business validation, especially reimbursement, invoice binding/unbinding, payment batches, leave and attendance correction.
  • Root cause addressed by task 09-05-error-explainability-payment-readiness: server details and UI text evolved independently; successful static coverage was incorrectly inferred from shared details keys or string counts.

2. Signatures

  • Registry: server/src/constants/errorReasons.js, ERROR_REASONS.
  • Builder: buildBusinessValidationError(reasonCode, legacyDetails, statusCode).
  • Client map: client/src/api/errorReasons.ts.
  • Gate: node scripts/qa/check-error-reason-contract.mjs --strict; negative subprocess fixtures: the same command with --self-test.
  • Candidate API accepts paymentChannel; each candidate adds readiness: { ready, blockers }.
  • POST /api/payment-batches/readiness accepts only entityType, entityIds, and optional defaultPaymentChannel; this is authenticated, manage-authorized and read-only. Creation remains independently authoritative.

3. Contracts

  • Keep HTTP status, top-level error code and legacy details. Add the registered domain-scoped details.reasonCode; do not infer business identity from a details key alone. The builder rejects unregistered reasons.
  • 迁移一条遗留原因码 = 服务端注册 + 客户端映射 + 具体内容断言,三项缺一不可。 Fixed expected text lives in server/tests/unit/fixtures/error-reason-messages.json and is asserted against both registry and client rendering; do not replace these assertions with broad message regexes or expectations derived at runtime.
  • Legacy entries are versioned AST-shape SHA-256 fingerprints plus relative file/operation, occurrence count, owner, explanation and expiry date (UTC). Whitespace, quotes, indentation, trailing commas and parenthesization must not change identity. A changed node is a new obligation; one deletion cannot offset one addition.
  • The first-wave route files and invoice bind/unbind/shared item resolver are zero-exception operations. Do not baseline their new or old generic errors.
  • Candidate rows, combination preflight and the locked creation transaction all consume resolvePaymentBatchCreationReadiness from paymentBatchReadiness.js. loadPaymentCreationFacts loads sources, reservations, accounts and reviewers in batches. Department flag reads are memoized only inside that fact load; do not cache readiness across requests or write readiness to the database.
  • Creation takes source locks, reloads facts, runs the resolver and only then allocates/creates a batch, items, success audit and notifications. Blocked preflight/create must not emit any of those success side effects.
  • Public blockers contain only reason code and optional source association. Neither decrypted account numbers nor account snapshots enter readiness DTOs. Bank-channel constraints must not silently become constraints on cash or other already-supported channels. No bank capability, role or maker-checker changes.
  • Execution keeps persisted failure_code/failure_message and their API fields compatible. Store only the registered reason code in the existing execution-risk payload_json, with a safe detail; refresh that open risk's explanatory metadata inside the same transaction without changing status, creator or notification rules. This is not a readiness field or a new server logging mechanism.

4. Validation & Error Matrix

  • Missing source -> 404 VALID_001 on create; invalid input -> 400 VALID_001.
  • Status, occupancy, department, missing company/payee/account, decryption, cross-company and missing independent reviewer -> safe blockers; create 409.
  • Three synthetic missing fields return three distinct actionable blockers.
  • Missing mapping/content fixture, typo, duplicate, changed/new/stale fingerprint, expired exception or primary-operation exception -> gate non-zero exit.

5. Good/Base/Bad Cases

  • Good: individually ready records from different companies produce a blocked combination; changing source/account facts changes the next response.
  • Base: complete bank fixture produces { ready: true, blockers: [] } and can create; a competing reservation after preflight is refused under the lock.
  • Bad: hide invalid candidates, copy create conditions into an API/UI, approve permanent path exemptions, or mark deployment acceptance from local fixtures.

6. Tests Required

  • Gate self-test must launch failing subprocesses and assert their exit codes, including formatting stability, one-added/one-removed and primary exceptions.
  • Focused route tests assert exact reason, HTTP status and retained details.
  • Shared readiness matrix plus route tests cover batch loading, repaired facts, combination rules, post-lock changes and zero batch/item/audit/notification.
  • Keep existing B2, invoice, leave, correction, receipt and maker-checker tests.

7. Wrong vs Correct

  • Wrong: count generic error strings, or details.status as the cross-domain key.
  • Correct: register payment_batch.source_status_invalid, map and assert its concrete safe text, and consume the shared readiness result at every boundary.

Scenario: Prisma Client Defaults In Raw SQL

1. Scope / Trigger

  • Trigger: a script or route uses $queryRaw, pg or handwritten INSERT SQL against a Prisma-managed table whose schema declares @default(uuid()) or another client-side default.

2. Signatures

  • Prisma signature: id String @id @default(uuid()) @db.Uuid.
  • Database signature may still be id UUID NOT NULL without a database DEFAULT.
  • Raw insert must include id and bind an explicit UUID, for example $1::uuid.

3. Contracts

  • Prisma schema defaults are not assumed to exist in PostgreSQL; inspect the applied migration or live column default before omitting a raw-SQL field.
  • Raw audit inserts generate a fresh UUID in application code and keep the audit insert in the same transaction as the protected write.
  • Dry-run may exercise audit inserts, but it must roll the transaction back and report zero committed audit rows.

4. Validation & Error Matrix

  • Raw insert omits a required client-default field with no DB default -> PostgreSQL not-null failure; transaction rolls back.
  • Explicit UUID has invalid syntax -> PostgreSQL cast failure; transaction rolls back.
  • Audit insert fails after the business update -> the complete transaction rolls back; no partial rule update is allowed.

5. Good/Base/Bad Cases

  • Good: explicit UUID, request ID, actor, target and JSON audit payload are bound and tested position by position.
  • Base: Prisma Client create() may rely on the Prisma default because the client supplies it.
  • Bad: infer from @default(uuid()) that handwritten PostgreSQL may omit id.

6. Tests Required

  • Assert the raw SQL column list contains the required ID and the bound value is a valid UUID.
  • Assert every parameter position maps to its intended DB type.
  • Assert dry-run begins a serializable transaction and executes ROLLBACK; audit failure paths must remain fail-closed.

7. Wrong vs Correct

Wrong

INSERT INTO audit_logs (request_id, actor_id, extra)
VALUES ($1, $2::uuid, $3::jsonb);

Correct

INSERT INTO audit_logs (id, request_id, actor_id, extra)
VALUES ($1::uuid, $2, $3::uuid, $4::jsonb);

Scenario: Local Regression Migration Drift

1. Scope / Trigger

  • Trigger: a local or attached E2E fails with Prisma missing column, column does not exist, enum missing, or route 500 immediately after a schema-backed feature was added.
  • Applies to server/prisma/schema.prisma, server/prisma/migrations/, local regression databases such as hyb_oa_regression, and Playwright tests that use real API/database flows.

2. Signatures

  • Read-only drift check: cd server && npx prisma migrate status.
  • Apply committed migrations after explicit confirmation: cd server && npx prisma migrate deploy.
  • Local service smoke for attached E2E: HOST=127.0.0.1 PORT=3000 JOBS_ENABLED=false SERVE_CLIENT_DIST=true npm run dev.
  • Server entrypoints that call getConfig() before buildApp() must load dotenv/config before reading config.

3. Contracts

  • migrate status is safe and read-only; it may be run before asking the user.
  • migrate deploy writes database schema state and requires explicit user confirmation unless the current task already approved that exact local DB.
  • Do not use prisma migrate reset for E2E recovery without separate backup and destructive-action approval.
  • Attached local E2E should disable background jobs with JOBS_ENABLED=false unless the test explicitly validates job behavior.

4. Validation & Error Matrix

  • migrate status reports pending migrations -> record the migration names, inspect whether they are additive/destructive, then ask before applying.
  • Missing JWT_SECRET during npm run dev while .env exists -> verify the entrypoint loads dotenv/config before getConfig().
  • Missing column after migrate deploy -> inspect actual columns and migration history; do not patch tests to hide schema drift.
  • E2E selector failure after schema is fixed -> treat as page/test contract drift and update the focused E2E assertion, not the database.

5. Good/Base/Bad Cases

  • Good: run migrate status, apply only committed migrations after approval, seed dev users, then rerun the focused Playwright flow.
  • Base: if migrations are already current, skip DB writes and debug the test failure directly.
  • Bad: use migrate reset or manually add columns to unblock a test.
  • Bad: start the server with an ad hoc node -r dotenv/config workaround and leave npm run dev broken.

6. Tests Required

  • cd server && npm run prisma:validate.
  • bash scripts/qa/check-local-migration-drift.sh when local DB drift was the cause.
  • cd server && node --check src/index.js if server bootstrap changed.
  • Focused Playwright flow that previously failed.
  • Frontend npm run build when Playwright or client behavior changed.

7. Wrong vs Correct

Wrong

cd server && npx prisma migrate reset

Correct

cd server && npx prisma migrate status
# after explicit approval for the local regression database only
cd server && npx prisma migrate deploy

Scenario: Portal Announcement Acknowledgement

1. Scope / Trigger

  • Trigger: extending announcements_v1 from read-only announcements into formal employee acknowledgement.
  • Applies to server/prisma/schema.prisma, server/src/routes/announcements.js, client/src/api/announcements.ts, employee /announcements, and admin /system/announcements.
  • Use this only for lightweight policy acknowledgement; do not treat it as electronic signature, CA signing, contract signing, or full policy CMS.

2. Signatures

  • DB: PortalAnnouncement may carry category, requires_acknowledgement, acknowledgement_statement, acknowledgement_due_at.
  • DB: PortalAnnouncementAcknowledgement stores one receipt per (announcement_id, employee_id) with statement/title/summary snapshots and actor_id.
  • API: GET /api/announcements?manage=0 returns visible announcements and the current employee's acknowledgement status.
  • API: GET /api/announcements?manage=1 requires system.settings and returns acknowledgement summary plus minimal pending employees.
  • API: POST /api/announcements/:id/acknowledge accepts { "confirmed": true }.

3. Contracts

  • Required acknowledgement status values: not_required, pending, acknowledged, overdue.
  • Employee response must not include other employees' acknowledgement rows or pending lists.
  • Admin pending list may include only id, employeeNo, name, and status; do not include phone, email, WeCom userid, account identifiers, or sensitive profile fields.
  • Acknowledgement is explicit only: opening an announcement or clicking its link must never create a receipt.
  • Policy content changes after any acknowledgement require a new announcement version; do not mutate title, summary, category, required flag, or statement in place.

4. Validation & Error Matrix

  • announcements_v1 disabled -> 409 VALID_001.
  • policy acknowledgement without statement -> 400 VALID_001.
  • manage=1 without system.settings -> 403 PERM_001.
  • acknowledge invisible/missing announcement -> 404 ANN_404.
  • acknowledge normal announcement -> 409 VALID_001.
  • acknowledge without { confirmed: true } -> 400 VALID_001.
  • edit protected policy fields after acknowledgement exists -> 409 VALID_001.

5. Good/Base/Bad Cases

  • Good: admin creates a policy announcement, employee checks the confirmation box, server creates one receipt and one audit log.
  • Base: normal announcement remains compatible and returns not_required.
  • Bad: frontend displays another employee's receipt status on /announcements.
  • Bad: admin updates an acknowledged policy title instead of creating a new version.

6. Tests Required

  • Prisma validate after schema/migration changes.
  • Route focused tests for required statement, permission, idempotency, normal/invisible announcement rejection, admin summary, and protected edit rejection.
  • Frontend typecheck and build.
  • Playwright flow for admin create -> employee acknowledge -> admin summary update when UI changes.

7. Wrong vs Correct

Wrong

await prisma.portalAnnouncement.update({
  where: { id },
  data: { title: "制度 V2" },
});

Correct

const acknowledgementCount = await prisma.portalAnnouncementAcknowledgement.count({
  where: { announcement_id: id },
});
if (acknowledgementCount > 0) {
  throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409 });
}

Scenario: Safe Development Seed

1. Scope / Trigger

  • Trigger: changing or running server/src/scripts/seed-dev.js, its safety guard, development account defaults or the seed:dev npm command.

2. Signatures

  • Command: SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM=<db> npm run seed:dev.
  • Optional reset: SEED_DEV_RESET_PASSWORDS=1.
  • Target: DATABASE_URL parsed with the same pg semantics used by Prisma's PostgreSQL adapter.

3. Contracts

  • NODE_ENV=production is permanently refused.
  • The explicit allow switch and exact database-name confirmation are both required.
  • The effective PostgreSQL host, including query-string overrides, must be loopback; checking only the URI authority is insufficient.
  • Existing passwords are preserved by default. Newly created accounts and explicitly rotated accounts set must_change_password=true.
  • The guard and seed logs never print credentials, complete targets or account passwords.

4. Validation & Error Matrix

  • Production -> production_forbidden.
  • Missing allow switch -> explicit_allow_required.
  • Invalid/non-PostgreSQL URL -> database_url_invalid or database_protocol_invalid.
  • Effective non-loopback host, including ?host=... -> database_host_not_local.
  • Missing/mismatched confirmation -> database_confirmation_mismatch.

5. Good/Base/Bad Cases

  • Good: loopback test database, exact confirmation, new accounts require a password change.
  • Base: rerun without reset preserves existing password hashes.
  • Bad: accept postgresql://[email protected]/db?host=remote.internal because only URL.hostname was checked.

6. Tests Required

  • Pure guard tests for production, missing allow, remote authority, host-query override, exact confirmation and redacted errors.
  • Unit tests for existing-password preservation, explicit rotation and new account first-login change.
  • Do not validate refusal paths by connecting to a real database.

7. Wrong vs Correct

Wrong

if (new URL(env.DATABASE_URL).hostname === "127.0.0.1") allowSeed();

Correct

const client = new Client({ connectionString: env.DATABASE_URL });
if (!LOOPBACK_HOSTS.has(client.host)) refuse("database_host_not_local");

Scenario: Database-Free App Contract Tests

1. Scope / Trigger

  • Trigger: unit tests call the production buildApp() factory to verify health, readiness, static serving or other app-level contracts in CI without a DB.

2. Signatures

  • Production: buildApp().
  • Test seam: buildApp({ prismaPlugin, startJobs }).

3. Contracts

  • Omitted dependencies always use the production Prisma plugin and job starter.
  • Unit tests inject a fastify-plugin-wrapped Prisma decorator and a no-op job starter; they must not rely on ESM vi.mock intercepting a CJS require.
  • App-level test doubles expose only methods used by the tested requests.
  • DATABASE_URL='' JWT_SECRET='' must not make isolated app tests access a developer .env or local PostgreSQL instance.

4. Validation & Error Matrix

  • Production call with missing DB target -> normal startup refusal.
  • Test call with injected Prisma plugin -> no production client construction.
  • Test double missing a method exercised by the request -> focused unit failure.
  • Injected dependencies leaking into buildApp() callers without options -> contract regression.

5. Good/Base/Bad Cases

  • Good: health/static tests inject Prisma and pass with empty DB/JWT process variables after setting their own test JWT.
  • Base: API/Worker call buildApp() without options and retain production wiring.
  • Bad: a hoisted vi.mock("../prismaClient") appears green only because a local .env database is reachable.

6. Tests Required

  • Run health and static tests with DATABASE_URL='' JWT_SECRET=''.
  • Run the full scripts/check-minimal.sh under the same empty environment.
  • Keep the production default path covered by normal startup/smoke checks.

7. Wrong vs Correct

Wrong

vi.mock("../../src/prismaClient", () => ({ createPrismaClient: fake }));
await buildApp(); // CJS plugin may still construct the real client

Correct

await buildApp({ prismaPlugin: testPrismaPlugin, startJobs: noOpStartJobs });

Scenario: Authentication Challenge Serialization

1. Scope / Trigger

  • Trigger: changing password reset, email-code login, refresh rotation, password login session creation, user password change or administrator reset.
  • Applies when a credential event can consume a challenge, change token_version, create/revoke a refresh session or invalidate another challenge.

2. Signatures

  • Row barrier: SELECT ... FROM employees WHERE ... FOR UPDATE.
  • Challenge rows: password_reset_codes and email_login_codes.
  • Session rows: auth_sessions.
  • Security epoch: employees.token_version.

3. Contracts

  • Use one lock order for credential mutations: Employee first, then challenge and session rows. Refresh rotation must lock Employee and re-read the session inside the same transaction before rotating it.
  • Capture expiry, cooldown and lockout time after the Employee lock is acquired; a request queued behind another credential event must not use stale time.
  • Creating a new challenge invalidates the previous unused challenge while the Employee lock is held. An active challenge with a wrong code increments the account-level failure state; no active challenge returns the generic failure without penalizing concurrent correct-request losers.
  • Successful reset, user password change and administrator reset increment token_version, revoke active refresh sessions and invalidate unused challenges in the same transaction as the password change and audit event.
  • Pepper-backed hashes use distinct purpose domains for email login and password reset. Production rejects a missing, placeholder, weak or JWT-reused pepper.

4. Validation & Error Matrix

  • Unknown/inactive/locked account or missing/expired/consumed challenge -> stable generic response; no account enumeration detail.
  • Wrong code for the current active challenge -> increment account and challenge attempts; reaching the bound locks and invalidates the challenge.
  • Reset commits before login/refresh -> the queued request rechecks state and cannot create a usable session.
  • Login/refresh commits before reset -> reset observes and revokes the newly created session before it commits.
  • Concurrent consumers of one valid challenge -> exactly one successful state transition; losers do not create audits, sessions or a recovery lock.

5. Good/Base/Bad Cases

  • Good: every credential path uses Employee -> challenge/session ordering and is covered by a deterministic PostgreSQL lock race.
  • Base: sequential invalid-code attempts use the same account-level counters and return the same external error contract.
  • Bad: refresh locks a session before Employee while reset locks Employee before sessions; this can leave a newly inserted session outside reset's statement snapshot.
  • Bad: treat !challenge after another request consumed the code as a new failed guess and lock the legitimate account.

6. Tests Required

  • Focused route tests for CSPRNG/HMAC domain separation, cooldown, account lockout, missing-challenge losers, conditional consumption and session revocation.
  • Real isolated PostgreSQL checks for concurrent challenge requests, 20-way same-code consumption, reset-first/login-first ordering and refresh/reset.
  • After reset, assert the pre-reset access token and refresh cookie both fail, the active-session count is zero and only one success audit exists.
  • Prisma validate plus clean migration deployment from an empty database.

7. Wrong vs Correct

Wrong

await tx.authSession.updateMany({ where: { id: sessionId, revoked_at: null } });
await tx.authSession.create({ data: replacement });

Correct

await lockEmployeeForUpdate(tx, employeeId);
const currentSession = await tx.authSession.findUnique({ where: { id: sessionId } });
if (!currentSession || currentSession.revoked_at) return invalidRefresh();
await rotateSession(tx, currentSession);

Scenario: Small-Team Payment Batch Consistency

1. Scope / Trigger

  • Trigger: changing payment-batch creation, review, execution, retry, cancellation, receipt evidence or the legacy direct-payment path.

2. Signatures

  • Candidate API: GET /api/payment-batches/candidates?q=&page=&pageSize=.
  • State APIs: POST /api/payment-batches/:id/review, POST /api/payment-batches/:id/execute, POST /api/payment-batches/:id/cancel with { reason }.
  • Receipt invalidation accepts { reason } and retains the record.
  • DB: payment_batches.cancelled_reason TEXT NULL.
  • Isolated concurrency gate: bash scripts/qa/run-web-gates.sh payment-concurrency-local.

3. Contracts

  • Normal flow is finance maker/executor plus one independent manager|ceo reviewer; the maker cannot review their own batch.
  • Lock the batch before state checks, then lock source entities in stable order and recheck status, amount and reservation inside the same transaction.
  • When payment_batch_v1 is enabled, an approved reimbursement can enter only one active batch and the legacy direct-payment route must not form a second write path.
  • Retry processes only pending|failed items. Existing executed items never repeat payment writes, follow-ups or notifications. Persisted execution summary is recomputed from every final item state; the audit separately records the current attempt.
  • Cancellation requires a 2-500 character reason. Receipt evidence is voided with audit history, not physically deleted.

4. Validation & Error Matrix

  • Missing review, maker-reviewer conflict or terminal state -> 409 VALID_001.
  • Concurrent loser after a valid winner -> deterministic 409, zero duplicate state, audit or notification writes.
  • Missing/invalid cancellation or void reason -> 400 VALID_001.
  • Source already paid, amount changed or reserved by another active batch -> 409 VALID_001 and no partial batch mutation.

5. Good/Base/Bad Cases

  • Good: one executor wins, a concurrent cancel or second executor receives 409, and the final payment/audit/notification counts remain one.
  • Base: a failed batch retries only failed items and reaches executed with an all-item final summary.
  • Bad: count only the retry attempt in execution_summary_json, or allow the direct route to pay while the same source is in a valid batch.

6. Tests Required

  • Focused route tests for candidate authority, maker-checker, retry summary, cancellation, receipt void and zero-side-effect refusals.
  • Real disposable PostgreSQL races for double execute, execute/cancel, double create and direct-payment/batch competition; assert unique audits and notifications and zero residual test databases.
  • Prisma validate, clean empty-database migration application and a read-only reviewer after payment-state changes.

7. Wrong vs Correct

Wrong

summary.successCount = successesInThisRetry;
batch.status = failuresInThisRetry ? "failed" : "executed";

Correct

const finalSummary = summarizeFinalItemStatuses(finalItemStatuses);
batch.execution_summary_json = finalSummary;
batch.status = finalSummary.failedCount > 0 ? "failed" : "executed";

Scenario: PostgreSQL Advisory-Lock Namespaces

1. Scope / Trigger

  • Trigger: allocating an application-level sequence or serializing a small cross-row critical section that cannot be protected by locking one existing row.
  • Use this only for PostgreSQL-backed paths whose correctness is also verified against a disposable real database; do not introduce advisory locks for ordinary single-row updates already covered by FOR UPDATE.

2. Signatures

  • Shared registry: server/src/constants/advisoryLocks.js.
  • Lock shape: pg_advisory_xact_lock(namespace_int4, object_key_int4).
  • Registered namespace: paymentBatchDailySequence = 1001; its object key is the company-local YYYYMMDD integer.
  • Prisma call for a void-returning lock statement: tx.$executeRaw.

3. Contracts

  • Register every new first-argument namespace in the shared constant file; never reuse an existing namespace for an unrelated critical section.
  • Use the two-int4 form with an explicit, inspectable object key. Do not use hashtext or an undocumented magic pair at the call site.
  • Acquire the transaction-level lock before reading the protected maximum or current state. Lock acquisition, protected read and write must stay in the same Prisma interactive transaction so commit or rollback releases the lock.
  • Keep one stable lock order when a transaction needs more than one lock.
  • A PostgreSQL uniqueness error aborts the transaction. Do not catch P2002 and retry inside the same interactive transaction; any fallback must retry the complete transaction from the beginning.

4. Validation & Error Matrix

  • $queryRaw reads SELECT pg_advisory_xact_lock(...) -> Prisma driver may fail with P2010 because PostgreSQL returns a void column.
  • $executeRaw executes the same SELECT -> lock is acquired without result deserialization; continue to the protected query.
  • Namespace or object key outside signed int4 -> fail before business writes; correct the registry/key design rather than silently casting or hashing.
  • Protected daily sequence exhausted -> stable 409 VALID_001 and rollback; do not widen or randomize the public identifier format at the boundary.
  • Concurrent callers with the same pair -> one waits, then reads the committed maximum and allocates the next value.

5. Good/Base/Bad Cases

  • Good: two simultaneous payment-batch creations lock (1001, YYYYMMDD), then commit distinct consecutive identifiers.
  • Base: different company-local dates use different object keys and do not contend.
  • Bad: query the maximum before taking the lock, use a process mutex, or retry a unique violation inside the already-aborted transaction.

6. Tests Required

  • Unit test the exact call order and error boundary with mocked $executeRaw/$queryRaw; assert no create, item or audit write on exhaustion.
  • Run bash scripts/qa/run-web-gates.sh payment-concurrency-local against a disposable PostgreSQL database. The daily-sequence race must create two different sources concurrently and assert two consecutive unique numbers.
  • Run node --check on the route, namespace registry and concurrency script, plus Prisma validate and a read-only review for affected payment paths.

7. Wrong vs Correct

Wrong

await tx.$queryRaw`SELECT pg_advisory_xact_lock(${namespace}, ${dateKey})`;
const latest = await readLatestSequence(tx);

Correct

await tx.$executeRaw`
  SELECT pg_advisory_xact_lock(${namespace}::int, ${dateKey}::int)
`;
const latest = await readLatestSequence(tx);

Scenario: Terminal Contract State and Required Audit

1. Scope / Trigger

  • Trigger: adding a terminal state or irreversible action to a Prisma-backed business ledger, especially when the state needs explanatory fields and an audit trail.
  • Applies to ContractRegistry termination, archive corrections, all import or batch writers, and any future endpoint that can mutate the same row.

2. Signatures

  • POST /api/contracts-registry/:id/terminate with { reason } where the trimmed reason is 10–1000 characters.
  • POST /api/contracts-registry/:id/corrections with { summary } for an archived row only.
  • DB fields: status, terminated_at, terminated_reason, terminated_by; the employee foreign key uses ON DELETE RESTRICT.
  • Required audit helper: writeRequiredAudit(tx, request, action, targetId, extra); it must throw on insert failure.

3. Contracts

  • status = terminated iff all three terminated_* fields are non-null and non-empty; every other persisted status iff all three are null.
  • Only the dedicated termination transaction may create terminated; general POST/PATCH and CSV/import writers reject it. Existing terminated rows are immutable to import updates, and existing archived rows are immutable to import updates (use the correction API).
  • Termination, status changes, renewal and correction audit writes stay inside the same transaction as the protected write. A correction claims the current status + updated_at version before appending its audit record.
  • List DTOs use a minimal mapper; terminal reason, operator and timestamp are detail-only fields.

4. Validation & Error Matrix

  • Partial termination fields or a terminal row with missing fields -> stable 500 SYS_001; do not serialize an inconsistent record.
  • General write with terminated, terminal edit/renew/archive, or a stale correction version -> 409 VALID_001 and zero business/audit side effects.
  • Required audit insert failure -> transaction rollback and 5xx; never use a swallowing best-effort helper for an irreversible/status-changing operation.
  • Import status terminated/expired, a status transition, or update of an existing terminated/archived row -> recorded validation error; no upsert.

5. Good/Base/Bad Cases

  • Good: termination writes status, three fields and audit atomically; a failed audit leaves the source row active and the audit table unchanged.
  • Base: an archived correction updates only the version timestamp and appends a safe audit summary; a concurrent restore loses the optimistic guard and returns 409.
  • Bad: write status first, call a best-effort audit afterward, or let a CSV upsert set terminated without the three fields and dedicated audit.

6. Tests Required

  • Focused route tests for complete/partial invariants, terminal filters and effective status, general-write rejection, required-audit rollback, renewal rollback, correction version conflicts, and safe list/detail DTOs.
  • Unit test every non-HTTP import status guard and existing-terminal refusal.
  • Prisma validate, migration status, node --check on touched scripts/routes, focused Vitest, and Playwright coverage for desktop/mobile action boundaries.
  • Read-only reviewer must inspect all write paths, including scripts and exports, after implementation.

7. Wrong vs Correct

Wrong

await prisma.contractRegistry.update({ where: { id }, data: { status: "terminated" } });
await writeAudit(prisma, request, "CONTRACT_REGISTRY_TERMINATED", id); // swallows errors

Correct

await prisma.$transaction(async (tx) => {
  const current = await readAndValidateContract(tx, id);
  await tx.contractRegistry.updateMany({
    where: { id, status: current.status, updated_at: current.updated_at },
    data: buildTerminalFields(request, reason),
  });
  await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", id, { reason });
});

Scenario: Reimbursement Draft Business-Fact Visibility

1. Scope / Trigger

  • Trigger: changing reimbursement list/detail/material/search/export/statistics read scope or adding a new record-level read surface.
  • expense.view_all covers submitted business facts; it does not turn another employee's never-submitted draft into a shared ledger record.

2. Signatures

  • Shared builder: buildReadableReimbursementWhere({ employee, scope, filters }) in server/src/services/reimbursementVisibility.js.
  • Object check: canReadReimbursementBusinessFact(prisma, reimbursement, employeeId).
  • Workflow fact: workflow_instances.entity_type='reimbursement' AND entity_id=<reimbursement-id>; Prisma relation filters may use workflow_instances: { some: {} } because the relation is reimbursement-specific.

3. Contracts

  • Owner or submitter keeps existing access to their draft. A non-draft keeps the existing role/team scope. Another employee's draft is readable only if at least one reimbursement workflow instance exists.
  • Returned and withdrawn drafts remain business facts regardless of the latest workflow status or outcome. Never replace existence with a pending/latest/outcome whitelist.
  • Apply the shared condition before pagination/counting to list, global search, reimbursement exports and dashboard/statistics. Raw SQL uses an indexed EXISTS on entity_type + entity_id.
  • Detail, attachment file, attachment preview URL, material bundle and read-only precheck fail closed through the same fact check. A current or historical workflow reviewer remains independently readable through task assignment and is not granted edit permission.

4. Validation & Error Matrix

  • Other employee + draft + no workflow -> excluded from collections and 403 PERM_001 on known-ID reads without revealing why.
  • Other employee + draft + workflow exists -> existing global/team/reviewer scope applies.
  • Owner/submitter draft -> existing own permission applies without requiring workflow history.
  • Missing status/object or failed fact lookup -> deny; never assume a missing value is non-draft.

5. Good/Base/Bad Cases

  • Good: finance sees a returned draft but not a colleague's unfinished first draft; the same result holds in list, search, export, statistics and materials.
  • Base: a reviewer without expense.view_all opens the reimbursement attached to a current or historical assigned task.
  • Bad: hide drafts only in React, filter every draft, or let precheck expose policy/risk facts for an otherwise unreadable draft.

6. Tests Required

  • Table-driven global/team/own scope and owner/submitter/non-draft/returned/withdrawn/never-submitted classification.
  • Route tests cover all five object-level read entries, including precheck, plus a reviewer non-regression.
  • Structural and route tests keep list, search, both reimbursement exports, dashboard/statistics and raw SQL on the shared fact contract.

7. Wrong vs Correct

Wrong

if (hasPermission(employee, "expense.view_all")) return reimbursement;

Correct

const where = buildReadableReimbursementWhere({ employee, scope: "all", filters });
await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);