Story AUTH-006: Signed-link auto-revocation when all contracts are paid¶
Module: identity
Slice: S4 (arch §11, AllContractsPaid handler) — V0 envelope AUTH-002 (arch §0.1)
Side: [BACKEND]
Version target: V0 — v0.0.1
Priority: 6
Depends on: AUTH-005; consumes PAY AllContractsPaid
Can develop concurrently with: AUTH-007
Merge order: After AUTH-005
Estimated complexity: S
PRD User Stories: AUTH-CP-07 (customer link auto-revocation triggers)
Wireframe: N/A — backend only (the "already paid" customer screen is served by the CP module; AUTH provides the state + French copy)
Objective¶
v0.0.1 introduces merchant payment. Once a customer has paid all the contracts a signed link covers, the link must stop working so it can't be reused. This story adds the AllContractsPaid event handler that auto-revokes the link, and makes sure a partial payment does NOT revoke it. It also confirms the v0.0.1 security posture: the signed-link token is stored only as a SHA-256 hash at rest (never in cleartext) now that the link gates a real payment journey.
Backend Scope¶
Entities¶
Updates signed_links.state → AUTO_REVOKED_PAID. No new tables.
Service Layer¶
@ApplicationModuleListener void on(AllContractsPaid e)(arch §10.4) →signedLinkService.autoRevoke(e.linkId(), RevocationReason.ALL_PAID).autoRevokesets stateAUTO_REVOKED_PAIDand emits the audit event. A subsequentverifyLinkon an auto-revoked link returns a terminal "already paid" result instead ofVALID.- Partial payment does NOT revoke (R-CP-015): the link remains usable for the unpaid contracts. Only
AllContractsPaid(every eligible contract paid) triggers revocation. - In-flight payment at the moment of revocation: PAY's idempotency key lets the running payment finish; no further interaction is accepted on the link afterwards (R-CP-007c equivalent).
- Confirm SHA-256-hash-at-rest:
signed_links.token_hashholds only the SHA-256 hash; the cleartext token lives solely in the URL (arch §7.7). This is the v0.0.1 hardening milestone called out in the V0 sub-version plan.
API Endpoints¶
None new. Reached via the existing CP boundary: a click on an auto-revoked link routes through SignedLinkService.verifyLink(...) which returns the terminal status.
Validation Rules¶
- Only
AllContractsPaid(all eligible contracts paid) revokes. Any partial-payment event must NOT revoke. autoRevokeis idempotent (re-delivery of the event leaves the link inAUTO_REVOKED_PAID).
Multi-Tenant Considerations¶
The handler revokes only the link identified in the event, which is tenant-scoped on signed_links. AUTH uses the platform datasource. No cross-tenant effect.
Audit & Logging¶
signed_link_auto_revoked_paid—campaign_id,file_id.AUTH_LINK_REJECTED_REVOKED—campaign_id,file_id,revoke_reason(emitted when a customer clicks the now-revoked link).
Frontend Scope¶
N/A — backend only. The customer "already paid" screen is a CP story; AUTH supplies the state and the French copy below.
French copy provided to CP (design §4.7 / §10.1)¶
- « Tous vos contrats sont en règle. Vous avez déjà payé l'ensemble des contrats de cette échéance. Merci. »
- Optional line: « Un reçu vous a été envoyé par e-mail. »
UI States (ALL REQUIRED)¶
N/A — backend only.
Acceptance Criteria¶
AC-001: All contracts paid revokes the link
Given a link covers contracts {C1, C2, C3} and all three are paid
When PAY publishes AllContractsPaid for that link
Then the link state becomes AUTO_REVOKED_PAID
And signed_link_auto_revoked_paid is audit-logged
AC-002: Partial payment does not revoke
Given a link covers {C1, C2, C3} and only C1 and C2 are paid
When the customer returns
Then the link still verifies as usable for C3
And no revocation occurs
AC-003: Clicking an auto-revoked-paid link shows the "already paid" result
Given a link in state AUTO_REVOKED_PAID
When the customer clicks it
Then verifyLink returns the terminal "already paid" status (copy: "Tous vos contrats sont en règle. Vous avez déjà payé l'ensemble des contrats de cette échéance. Merci.")
And AUTH_LINK_REJECTED_REVOKED is audit-logged with revoke_reason
Compliance Rules¶
- CIMA 001-2024 art. 26 single-use link = VÉRIFIÉ.
- [LEGAL-REVIEW]: the link remains an authentication mechanism, not proof of consent (red flag #5 unchanged). Auto-revocation does not change this posture.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md(@ApplicationModuleListener, idempotent handlers).docs/standards/critical-rules.md(SHA-256 token at rest; tenant isolation).docs/standards/api-guidelines.md.
Testing Requirements¶
Unit Tests¶
autoRevokeis idempotent.- Partial-payment input does not revoke.
Integration Tests¶
- AllContractsPaid → state AUTO_REVOKED_PAID + audit; subsequent verifyLink returns the terminal "already paid" status + AUTH_LINK_REJECTED_REVOKED.
- Token stored only as SHA-256 hash (assert no cleartext column/value).
What QA Will Validate¶
- Paying everything kills the link; paying part of it does not; the revoked link shows the "déjà payé" message; tokens are hash-only at rest.
Out of Scope¶
- Expiry / TTL enforcement — v0.1.0 (AUTH-010).
- Attempt-limit / lockout / permanent revoke — v0.1.0 (AUTH-011).
- Operator manual re-issue — V1 (AUTH-CP-08).
- The payment flow itself and the customer "already paid" React screen — PAY / CP modules.
Definition of Done¶
- [ ] AllContractsPaid handler auto-revokes the link (state AUTO_REVOKED_PAID)
- [ ] Partial payment never revokes
- [ ] Clicking a revoked-paid link returns the terminal "already paid" result with exact French copy
- [ ] Handler is idempotent
- [ ] Token confirmed SHA-256-hash-at-rest (no cleartext)
- [ ] Audit entries signed_link_auto_revoked_paid and AUTH_LINK_REJECTED_REVOKED
- [ ] Unit + integration tests written and passing
- [ ] Tenant isolation verified (link scoped by tenant_id; platform DB)
- [ ] No silent failures on event handling