PRD + FSD - notifications (NOTIF) module¶
Module prefix:
NOTIF· Plane: Control · Version scope: V1 · Country scope V1:CIonly Owner author: ANALYST · Source PRD:docs/prd/prd-v1.md(§§10, 11.4, 11.7, 15.2, 16.3, 16.5, 18, 24, 27, 30) · Source vision:docs/prd/prd-vision-v2-v3.md(V2 §8.3 OTP, V2 §8.6 audit; V3 §5.3 omnichannel) Source legal audit:docs/legal/audit/Legal-Compliance-audit.md(disposition: RE-RESEARCH NEEDED - CRITICAL gap #6 "cross-border data flow for WhatsApp Cloud API" bears directly on this module; HIGH gap #8 lawful basis; HIGH gap #11 retention) Sibling PRDs already binding contracts on NOTIF:audit-log-prd.md(NOTIF as producer ofNOTIF_SENDING/NOTIF_FAILURE, mandatory fields per §B.3);identity-prd.md(AUTH provides the signed-link payload that NOTIF carries);tenant-admin-prd.md(per-tenant sender provisioning + per-tenant daily cap setting);customer-portal-prd.md(CP-OQ-04 + R-CP-024: requires NOTIF dispatch onPaymentConfirmed). Last reworked: ANALYST session v2, 2026-04-30 - alignment pass withcustomer-portal-prd.mdto add the transactional payment-receipt push (CP-OQ-04).
V0 Envelope Scope (ajout 2026-05-21)¶
Ce bloc est strictement additif : il décrit comment le périmètre NOTIF de ce PRD V1 est tranché en sous-versions V0 (v0.0.0 à v0.1.0). Le contenu V1 existant reste la référence cible. Référence :
docs/prd/prd-v0.md§ 4 (ligneNotifications), § 5 (anchors transverses), § 6 (posture conformité) et § 8 (mapping stories).
Découpage par sous-version¶
| Sous-version | Périmètre NOTIF v0 vs V1 PRD | Story IDs |
|---|---|---|
| v0.0.0 | Envoi réel SMS + WhatsApp Cloud API en parallèle (PAS de cascade WhatsApp-first / SMS-fallback ; les deux canaux partent simultanément). Lookup provider via la table country-profile, multi-pays câblé Day 1 mais seul CI est seedé. Les 5 mitigations ARTCI obligatoires sont actives dès v0.0.0 (cf. bloc "Verrouillé" plus bas). [FOUNDER-RISK-ACCEPTED 2026-05-20] sur l'envoi réel avant les autorisations ARTCI B5+B10. |
NOTIF-001 |
| v0.0.1 | UI de consentement côté Customer Portal + lien d'opt-out fonctionnel de bout en bout (suppression effective dans le NOTIF côté serveur). Couplage avec l'émission AUDIT du send (AUDIT-002 livre en parallèle). | NOTIF-002 |
| v0.0.2 | File d'attente d'envoi par agence (clé de partitionnement agency_id introduite côté NOTIF en même temps que l'arrivée de l'agence dans le modèle TENANT). Pas encore de quota / rate-limit ; uniquement isolation des files. |
NOTIF-003 |
| v0.1.0 | Rate-limit transverse (Redis) + politique de retry idempotente alignée sur le webhook provider. C'est à v0.1.0 que le cap par tenant + le kill-switch plateforme du V1 PRD §15.2 deviennent actifs. | NOTIF-004 |
Verrouillé pour V0 (5 mitigations ARTCI obligatoires dès v0.0.0)¶
Posture risque : l'envoi réel démarre avant que les autorisations ARTCI B5 (traitement de numéros de téléphone) et B10 (transfert hors UE vers Meta / WhatsApp Cloud API) soient délivrées (memo memo-conformite-CI-v2.md §B5 + §B10). Le dossier ARTCI est déposé en parallèle. Le go-live production reste BLOQUANT tant que les deux autorisations ne sont pas notifiées. En attendant, les 5 mitigations ci-dessous sont non négociables et actives dès v0.0.0 :
- Lien d'opt-out dans chaque message sortant (SMS + WhatsApp). Format court, sans authentification supplémentaire, idempotent.
- Case à cocher de consentement explicite sur le formulaire Customer Portal au premier accès (
R-NOTIF-CONSENT-01). La livraison UI est en v0.0.1 ; le drapeau de consentement est porté par la donnée d'ingestion dès v0.0.0 (pré-coché côté broker pour les renouvellements en cours, audit-trail à l'appui). - Lien vers la notice de confidentialité (page FR plateforme par défaut, override tenant reporté à V1 - cf. § 6.2 #3 de
prd-v0.md) dans chaque envoi et sur le Customer Portal. - Audit-log par envoi : chaque tentative produit un événement
NOTIF_SENDINGpuisNOTIF_SENT/NOTIF_FAILURE, conformément au contrat producteur côté AUDIT (§B.3 audit-log-prd.md). Émission câblée dès v0.0.0 même si la console AUDIT n'existe pas avant V1. - No-resend après opt-out : table
notification_opt_out (tenant_id, msisdn)consultée avant chaque send ; toute tentative ultérieure est dropée silencieusement et tracéeNOTIF_SUPPRESSED_OPT_OUT.
Autres locks V0 :
- Country-profile resolver opérationnel dès v0.0.0 (le code lit
tenant.country_code->country_profile.notif_provider_*), même si seulCIest seedé. Aucune dépendance dure àCIne doit apparaître dans NOTIF. - Pas de cascade canal en v0.0.0 : SMS et WhatsApp partent en parallèle. La stratégie "WhatsApp-first + SMS fallback" (NOTIF-US-02 du V1 PRD) est reportée.
- Quiet-hours dispatch window (NOTIF-US-03) : reportée à V1.
Hors V0 (renvoyé à V1 ou plus tard)¶
- Email : entièrement hors V0 (et hors V1). Pas de canal
EMAILdans le producer-API V0. - Cascade WhatsApp-first / SMS-fallback (V1 PRD NOTIF-US-02).
- Quiet-hours (NOTIF-US-03), cap par tenant + kill-switch (NOTIF-US-04) câblés mais activés seulement à v0.1.0.
- Vue historique opérateur + retry manuel (NOTIF-US-05) : reportée à V1.
- Disclosure cross-border détaillée côté portail (NOTIF-US-08) : la version v0.0.1 ne fournit que la notice de confidentialité plateforme ; la disclosure modulaire par tenant est V1.
BLOQUANT production : les autorisations ARTCI B5 + B10 doivent être notifiées avant la GA V1 (~Q3 2026). Le drapeau
[FOUNDER-RISK-ACCEPTED 2026-05-20]couvre la phase de démonstration et NSIA pilote pré-GA, pas la production grand public. Aucun statutINCERTAIN - avocat requisdu memo n'est levé par le découpage V0.
Change Log¶
v2 - 2026-04-30 - Alignment pass with customer-portal PRD (CP-OQ-04)¶
The customer-portal PRD was authored after this PRD; this rework folds in the runtime contract that CP-PRD now expects from NOTIF - namely a transactional payment-receipt push dispatched on PAY's PaymentConfirmed (CP-07, R-CP-024). Rework scope was capped to the diagnostic items below; everything else is unchanged.
| ID | Item | Sections touched |
|---|---|---|
| D-1 | New transactional message-type RENEWAL_PAYMENT_RECEIPT added to the V1 catalog. Source trigger: PAY's PaymentConfirmed. Channels: SMS + WhatsApp (mirror dispatch). Idempotent against event redelivery. |
§A.1, §B.1, §B.2, §B.3 |
| D-2 | New user story NOTIF-US-11 - payment-receipt push, with G/W/T ACs covering dispatch, idempotency, channel selection, French copy, quiet-hours bypass, failure handling. | §A.3 (new NOTIF-US-11) |
| D-3 | New functional rules FR-14 (PaymentConfirmed consumer + idempotency) and FR-15 (transactional templates bypass quiet-hours). | §B.7 (FR-14, FR-15) |
| D-4 | Two new audit events NOTIF_PAYMENT_RECEIPT_DISPATCHED, NOTIF_PAYMENT_RECEIPT_FAILED. Catalog extension required at AUDIT (folded into existing OQ-NOTIF-04). |
§B.2 |
| D-5 | New Spring Modulith application event notification.payment_receipt_dispatched (consumed by OP file-timeline + CP-side audit cross-correlation). |
§B.3 |
| D-6 | §A.1 narrative: lift "no payment-receipt push" exclusion. New scope: "transactional payment-receipt push for CP-confirmed payments only; no marketing, no inbound conversational handling beyond STOP detection." | §A.1 |
| D-7 | §A.5 V1/V2 table updated: new message-type listed in V1; "Payment-confirmation push" row removed from V2 out-of-scope. | §A.5 |
| D-8 | Three new Open Questions: OQ-NOTIF-09 (lawful basis for receipt push), OQ-NOTIF-10 (quiet-hours bypass legitimacy), OQ-NOTIF-11 (cross-border disclosure adequacy for the transactional template). | §A.6 |
| D-9 | Two new Temporarily Validated: TV-08 (transactional receipt push covered by the same legitimate-interest basis as reminders), TV-09 (transactional templates bypass quiet hours; lawyer pin required). | §A.7 |
| D-10 | §B.10 cross-module dependencies updated: PAY now an upstream producer (PaymentConfirmed consumer); CP serves the per-transaction reçu deep link rendered in the message body. |
§B.10 |
| D-11 | §C.5 out-of-scope updated: removed "No payment-confirmation push" line; added explicit non-objectives for what's still out (rich-media receipts, attestation push). | §C.5 |
Rework markers (<!-- REWORKED v2 -->) annotate each section that changed. Existing rule numbering is preserved; new rules use unused IDs in the same series.
Part A - Product Requirements¶
A.1 Executive Summary¶
The notifications module is the outbound delivery primitive of Papillon Collection Solution. It is the only path by which a platform-generated message reaches an end customer in V1. Every other module that needs to talk to a customer - the operator console (OP) when a campaign is validated, identity (AUTH) for the signed-link payload that goes out as a renewal reminder - calls NOTIF. Customers do not interact with NOTIF directly; they receive its output as an SMS or a WhatsApp message.
The narrow V1 problem statement:
When a brokerage cabinet validates a campaign of N policyholders whose contracts are due, it must be possible - within minutes, within a regulated time window, with provable delivery, without leaking PII, without double-charging, without sending after STOP - to push a French renewal reminder carrying a signed link to each customer's preferred channel; to fall back automatically when WhatsApp is not reachable; to retain delivery proof for ten years; and to surface every send + every failure to the operator with one click of retry.
V1 deliberately keeps the surface small: three message templates (initial reminder + weekly reminder + transactional payment receipt), two channels (WhatsApp first, SMS fallback for reminders; SMS + WhatsApp parallel mirror for the receipt), one quiet-hours window for reminders (07:00–20:00 Africa/Abidjan; transactional receipt push bypasses the window - TV-09), no OTP, no marketing-category templates, no inbound conversational handling beyond STOP detection, no per-tenant template editing, no platform-admin provider-config UI (managed via deploy config). Those are V2/V3 work, with split conditions captured in §A.6. The payment-receipt push is the V1 cross-module contract for customer-portal-prd.md CP-07 / R-CP-024.
A.2 User Personas¶
| Persona | Side | Scope | V1 |
|---|---|---|---|
| Producer modules | [BACKEND] |
OP (campaign engine), AUTH (signed-link delivery as part of reminder), TENANT (sender-ID provisioning hooks). Each calls NOTIF's internal dispatch API. | V1 |
| Branch operator | [OPERATOR] |
Reads the per-customer NOTIF history; manually retries a single failed notification; sees suppression status. | V1 |
| Cabinet admin | [OPERATOR] |
Same surface as branch operator at agency level. May see tenant-scoped daily-cap status surfaced from NOTIF. | V1 |
| Platform admin | [ADMIN] |
Operates the platform-wide kill switch; configures provider credentials via deploy pipeline (no UI in V1); investigates NOTIF outages with AUDIT. | V1 |
| End customer (assuré) | [CUSTOMER] |
Receives SMS / WhatsApp messages; replies STOP to opt out; reads cross-border disclosure on the customer portal landing page (CP-owned surface, NOTIF-driven copy). | V1 |
| Tenant admin (template editor) | [OPERATOR] |
Out of V1 scope. No tenant-side template editing. V2 will revisit. | V2 |
| Customer (preferred-channel chooser) | [CUSTOMER] |
Out of V1 scope. No customer-portal toggle for channel preference. V2 ties to the V2 enhanced-auth surface. | V2 |
A.3 User Stories (with Given/When/Then ACs)¶
NOTIF-US-01 [V1] [BACKEND] - Producer-API dispatch (idempotent)¶
As a producer module (OP / AUTH) I want to call a single internal NOTIF endpoint to dispatch one message-type to one customer with a payload So that I do not have to know which channel will be used, which provider, which template ID, or which sender ID.
ACs:
- AC-01.1 - Single dispatch contract: GIVEN a producer call to POST /notif/dispatch with {tenant_id, agency_id, customer_id, contract_id, message_type ∈ {RENEWAL_REMINDER_INITIAL, RENEWAL_REMINDER_WEEKLY}, iteration, signed_link, due_date, amount_minor_xof, correlation_id, idempotency_key}, WHEN the request is well-formed, THEN NOTIF returns HTTP 202 with {notification_id, status: "PENDING"} and enqueues the dispatch.
- AC-01.2 - Idempotency-Key wins: WHEN the same idempotency_key is replayed within 24h, THEN NOTIF returns HTTP 200 with the existing notification_id and the current status; no duplicate notification row is created.
- AC-01.3 - Natural-key sanity guard: GIVEN two requests with different idempotency_key values but identical (tenant_id, campaign_id, customer_id, message_type, iteration), WHEN the second arrives within 60 s of the first, THEN NOTIF rejects with HTTP 409 {reason: "natural_key_conflict"} to defend against producer bugs.
- AC-01.4 - Mandatory-field gate: WHEN any field marked required in §B.6 is missing/null, THEN NOTIF rejects with HTTP 400 and a structured field error; producer is responsible for failing closed.
NOTIF-US-02 [V1] [BACKEND] - WhatsApp-first with automatic SMS fallback¶
As a Papillon I want every send to attempt WhatsApp first and fall back to SMS automatically on a hard provider error or after retry exhaustion So that customers are reached on the cheapest preferred channel without operator intervention, while still always landing the message.
ACs:
- AC-02.1 - WA-first: GIVEN a notification not subject to a WA suppression flag (NOTIF-US-07), WHEN dispatch begins, THEN NOTIF first creates a delivery_attempt row on channel WA and submits to the WA BSP.
- AC-02.2 - Hard error → immediate SMS fallback: WHEN the WA BSP returns a hard error class (USER_NOT_ON_WHATSAPP, INVALID_PHONE, TEMPLATE_REJECTED, BUSINESS_BLOCKED), THEN NOTIF creates a second delivery_attempt on channel SMS within 5 s and submits to the SMS provider. No retry on WA in this case.
- AC-02.3 - Transient retry then fallback: WHEN the WA BSP returns a transient error (5xx, network timeout, throttled), THEN NOTIF retries the WA submit up to 3 times with exponential backoff {15s, 1m, 5m}. If all 3 fail, NOTIF creates a delivery_attempt on channel SMS and submits.
- AC-02.4 - SMS final-failure terminates: WHEN the SMS attempt reaches a terminal failure (provider hard-error, 3 transient retries exhausted, or 24h reconciliation poll returned no terminal status - see NOTIF-US-06), THEN the notification status becomes FAILED and notification.failed is published.
NOTIF-US-03 [V1] [BACKEND] - Quiet-hours dispatch window¶
As a Papillon I want NOTIF to refuse to push messages outside the country-profile quiet hours So that customers are not woken up by reminders and the brand stays compliant with operator-T&C and ARTCI good-practice expectations.
ACs:
- AC-03.1 - Window enforced from country profile: GIVEN the tenant's country profile, WHEN dispatch is attempted outside [07:00, 20:00) Africa/Abidjan for country_code = CI, THEN the notification is held in PENDING_QUIET_HOURS and released at the next 07:00 boundary.
- AC-03.2 - Window evaluated at dispatch, not at enqueue: GIVEN a notification enqueued at 18:30 that does not start dispatching until 19:55 due to a backlog, WHEN dispatch crosses 20:00 mid-batch, THEN remaining notifications hold over to the next 07:00; in-flight ones complete naturally.
- AC-03.3 - Audit on hold: WHEN a notification is held due to quiet hours, THEN the event is recorded with reason quiet_hours on the notification row (not as a NOTIF_FAILURE).
NOTIF-US-04 [V1] [BACKEND] - Tenant per-day cap + platform kill switch¶
As a Papillon I want a tenant-level daily cap and a platform-wide kill switch So that a misconfigured campaign cannot generate runaway costs and the platform can be paused during a provider incident.
ACs:
- AC-04.1 - Cap default: GIVEN a tenant without an explicit cap, THEN daily_cap = 5000 sends per UTC-day-rolled-at-Africa/Abidjan-midnight applies.
- AC-04.2 - Cap configurable on tenant: GIVEN a tenant whose notif.daily_cap setting was changed via TENANT (TENANT-side AC), WHEN NOTIF reads tenant config at dispatch, THEN it honors the new cap. Setting < 1 is rejected at TENANT write time.
- AC-04.3 - Cap reached: WHEN dispatching the (cap+1)-th send for a tenant in the current local day, THEN NOTIF rejects with {reason: "tenant_daily_cap_exceeded"}, holds the notification in PENDING_CAP, and emits one NOTIF_CAP_EXCEEDED audit event per cap-rollover (deduplicated within the day to one entry per tenant).
- AC-04.4 - Auto-release at midnight: WHEN local midnight rolls, THEN held notifications resume in FIFO order, subject to AC-04.1 fresh budget.
- AC-04.5 - Platform kill switch: GIVEN a platform-admin call to POST /notif/admin/kill-switch {paused: true, reason}, WHEN any subsequent dispatch is attempted, THEN NOTIF holds in PENDING_PLATFORM_PAUSED and emits NOTIF_KILL_SWITCH_ENGAGED. paused: false releases.
NOTIF-US-05 [V1] [OPERATOR] - Per-customer history view + manual retry¶
As a branch operator I want to open a customer record and see the chronological history of notifications sent to them, with status, channel, failure reason, and a one-click retry on failed sends So that I can diagnose a deliverability issue and recover without engineering help.
ACs:
- AC-05.1 - Timeline view: GIVEN a customer in my agency, WHEN I open the customer's notification timeline, THEN I see one row per notification_id ordered by enqueued_at DESC, showing {message_type, last_attempted_channel, status, last_attempted_at, failure_reason_fr_short}. Status pill set: EN_ATTENTE, ENVOYÉ, LIVRÉ, ÉCHEC, OPT_OUT, EN_HEURE_CREUSE, PLAFOND_ATTEINT.
- AC-05.2 - Phone visible cleartext to operator: GIVEN an operator role with agency scope matching the customer, WHEN the timeline is rendered, THEN the customer's phone is shown in cleartext on each row. WHEN an export of the timeline is generated (CSV/PDF), THEN the phone is masked in the exported file (+225 07 ** ** 56 78).
- AC-05.3 - Manual retry: WHEN I click "Réessayer" on a row in ÉCHEC status, THEN NOTIF re-runs the dispatch pipeline (WA→SMS fallback) for the same notification, capped at 3 manual retries per notification_id lifetime. The 4th attempt is rejected with "Limite de relance manuelle atteinte". Each manual retry produces an NOTIF_MANUAL_RETRY audit event.
- AC-05.4 - Retry refused on opt-out: WHEN I click retry on a row whose customer is opted out on every channel, THEN the retry is refused with "Client en opposition (STOP)". No re-dispatch attempted.
NOTIF-US-06 [V1] [BACKEND] - Provider webhook + status reconciliation¶
As a Papillon
I want to confirm delivery status from provider webhooks (signed) and reconcile via polling when no webhook arrives
So that lost or delayed webhooks do not leave notifications stuck in ENVOYÉ indefinitely and proof of delivery (§16.5) remains reliable.
ACs:
- AC-06.1 - Signature mandatory: GIVEN any inbound provider webhook, WHEN the signature is missing or invalid, THEN NOTIF rejects with HTTP 401 and emits NOTIF_WEBHOOK_REJECTED. The provider's secret rotation is documented in deploy runbook.
- AC-06.2 - Webhook idempotency: GIVEN a webhook for a provider_message_id already at terminal status (DELIVERED or FAILED), WHEN it is replayed, THEN NOTIF returns 200 and is a no-op. De-dup key: (provider_id, provider_message_id, status).
- AC-06.3 - Status timeline persisted: WHEN a webhook updates a delivery_attempt status, THEN the transition {status, at, raw_provider_response (≤ 8 KB JSON)} is appended to the attempt's status_timeline array. The full transition history is retained for 10 years (§B.6 retention rule).
- AC-06.4 - Polling fallback: GIVEN a delivery_attempt in SENT (provider accepted) without a terminal status after 5 minutes, WHEN the next reconciliation tick fires, THEN NOTIF queries the provider's status API. Polling continues every 5 min for up to 24 h. After 24 h with no terminal status → attempt status UNKNOWN_TIMEOUT, treated as FAILED for the parent notification (which has already triggered its WA→SMS fallback in real time per NOTIF-US-02; SMS attempt is the only one left to resolve).
- AC-06.5 - Producer-event idempotency: GIVEN repeated terminal-status webhooks, WHEN the parent notification has already published notification.delivered or notification.failed, THEN NOTIF does NOT re-publish. Producer events fire exactly once per terminal transition per notification.
NOTIF-US-07 [V1] [BACKEND] - Inbound STOP / opt-out suppression¶
As a Papillon I want to capture inbound STOP replies (SMS) and WhatsApp business-block events as a per-channel suppression for the (tenant, customer) pair So that customers who object are not contacted on that channel again and law n°2013-450 right of objection is honored without operator effort.
ACs:
- AC-07.1 - STOP detection (SMS): GIVEN an inbound SMS from a customer phone matching one of {STOP, ARRET, ARRÊT, NON, NO} (case-insensitive, leading/trailing whitespace tolerated, alone on the message body), WHEN received via the SMS provider's inbound webhook, THEN NOTIF persists a suppression row (tenant_id, customer_id, channel=SMS, suppressed_at, reason="customer_stop").
- AC-07.2 - Block detection (WhatsApp): GIVEN a WA provider event indicating the user has blocked the business number or unsubscribed, WHEN received, THEN NOTIF persists (tenant_id, customer_id, channel=WA, suppressed_at, reason="customer_block").
- AC-07.3 - Suppression honored before dispatch: GIVEN a suppression for (tenant, customer, channel), WHEN NOTIF would dispatch on that channel, THEN it skips that channel. If the other channel is also suppressed, the notification is set to OPT_OUT and notification.opted_out is published. No SMS/WA traffic is generated.
- AC-07.4 - Per-tenant scope: GIVEN a customer who is suppressed for tenant A on SMS, WHEN tenant B (different tenant_id) attempts to dispatch to the same physical phone, THEN the suppression does NOT cross the tenant boundary. (Tenants are independent data controllers.)
- AC-07.5 - Suppression visible in operator history: GIVEN a suppressed customer-channel pair, WHEN the operator opens the timeline (NOTIF-US-05), THEN a chip "OPT-OUT (canal SMS, depuis le DD/MM/YYYY)" appears at the top of the customer's notification list.
- AC-07.6 - Removal is platform-admin only in V1: GIVEN a suppression row, WHEN any operator attempts to remove it via the UI, THEN the action is refused. Only platform admin can remove (via internal tool, audited as NOTIF_SUPPRESSION_REMOVED). V2 may add tenant-admin removal with consent re-capture.
NOTIF-US-08 [V1] [CUSTOMER] - Cross-border data flow disclosure on customer portal¶
As a customer (assuré) receiving a renewal reminder I want to be told, on the landing page I reach via the signed link, that my data may transit through Meta (WhatsApp) and our SMS aggregator under sub-processing chains So that law n°2013-450 transparency duty is met without me having to ask.
ACs: - AC-08.1 - Disclosure surface (cross-module on CP): GIVEN the customer-portal landing page (CP-owned), WHEN the page renders, THEN a French link "Mentions légales et données personnelles" is visible at the page footer (touch target ≥ 44 px). NOTIF supplies the canonical FR copy block via a shared content asset; CP renders it. - AC-08.2 - Disclosure content names sub-processors: WHEN the customer opens the disclosure, THEN the text explicitly names: (a) Meta Platforms Inc. (WhatsApp Cloud API, US/EU) as a sub-processor, (b) the BSP (chosen per OQ-NOTIF-01) as the contractual front, (c) the SMS aggregator (chosen per OQ-NOTIF-01) as a sub-processor. The text states the legal basis (legitimate interest of the data controller - the broker - in the execution of the renewal contract), and the right of objection (STOP). - AC-08.3 - Bundle weight: GIVEN V1 PRD §30 < 200 KB initial-payload budget on CP, WHEN the disclosure asset is delivered, THEN it is server-side rendered or lazy-loaded and does NOT inflate the initial payload above the budget.
NOTIF-US-09 [V1] [BACKEND] - AUDIT producer contract¶
As a Papillon I want every NOTIF state-change of regulatory interest to produce an audit entry per the AUDIT contract So that the §16.5 chronology obligation is satisfied without ad-hoc instrumentation.
ACs:
- AC-09.1 - NOTIF_SENDING on dispatch: WHEN a delivery_attempt is submitted to a provider, THEN NOTIF emits NOTIF_SENDING with {tenant_id, agency_id, country_code, customer_id, contract_id, signed_link_id, correlation_id, channel, provider_id, provider_message_id, attempt_index, result: "INFO"}. Mandatory fields per audit-log §B.3 NOTIF row.
- AC-09.2 - NOTIF_FAILURE on terminal failure: WHEN a notification reaches FAILED status (after exhausting WA + SMS path), THEN NOTIF emits NOTIF_FAILURE with {tenant_id, agency_id, country_code, customer_id, contract_id, correlation_id, last_channel, failure_class, attempt_count, result: "FAILURE"}.
- AC-09.3 - NOTIF_OPTED_OUT (catalog extension request): WHEN a notification reaches OPT_OUT status, THEN NOTIF emits NOTIF_OPTED_OUT with {tenant_id, customer_id, suppressed_channels[], correlation_id, result: "INFO"}. Note: this event type is NOT in the V1 AUDIT catalog (§B.1) as of audit-log-prd v1; an AUDIT catalog extension is required (see OQ-NOTIF-04).
- AC-09.4 - Producer latency budget: GIVEN audit-log NFR-02 (≤ 20 ms p95 added to producer endpoints), WHEN NOTIF records audit events, THEN the audit recording MUST be off the request-critical path (transactional outbox / Spring application event); the dispatch endpoint p95 is unchanged by audit instrumentation.
- AC-09.5 - Pseudonymization at producer side: GIVEN audit-log AC-06 (assuré PII pseudonymized in payloads), WHEN NOTIF builds the audit payload, THEN it MUST replace any raw phone / name in the payload with hash_v1(value, salt[tenant_id]). The raw phone remains on the NOTIF notification row (which is NOT audit-log; cleartext-to-operator policy applies there per AC-05.2).
NOTIF-US-10 [V1] [ADMIN] - Platform kill switch operations¶
As a platform admin (Papillon ops) I want to pause and resume all NOTIF dispatch from one endpoint So that during a provider incident or a discovered tenant abuse, no further customer messages are sent until I explicitly resume.
ACs:
- AC-10.1 - Engage: GIVEN platform-admin role, WHEN POST /notif/admin/kill-switch {paused: true, reason} is called, THEN within 5 s no new dispatch is started; in-flight provider submits complete naturally; NOTIF_KILL_SWITCH_ENGAGED audit event.
- AC-10.2 - Disengage: WHEN POST /notif/admin/kill-switch {paused: false}, THEN held notifications resume in FIFO; NOTIF_KILL_SWITCH_RELEASED audit event.
- AC-10.3 - Status query: GIVEN platform admin role, WHEN GET /notif/admin/kill-switch is called, THEN current state, engaged_at, engaged_by, reason are returned.
- AC-10.4 - Operator visibility: GIVEN the kill switch is engaged, WHEN an operator opens the campaign-validation screen (OP), THEN OP MUST display a non-dismissible banner "Envoi suspendu plateforme - réessayer plus tard". Cross-module dependency on OP.
NOTIF-US-11 [V1] [BACKEND] - Transactional payment-receipt push (CP-OQ-04)¶
As a Papillon
I want to dispatch a transactional French message carrying the payment reference, amount and re-download link of the reçu PDF, on every confirmed customer payment, on SMS AND WhatsApp (when the customer was reachable on WA at link-issuance time)
So that the customer has an authoritative confirmation in their inbox the moment PAY confirms the transaction, and the cross-module contract from customer-portal-prd.md CP-07 / R-CP-024 is honored.
ACs:
- AC-11.1 - Trigger from PAY's PaymentConfirmed: GIVEN a Spring Modulith PaymentConfirmed{tenant_id, agency_id, customer_id, contract_ids[], transaction_reference, amount_total_minor, currency, customer_session_id, signed_link_id, recu_deep_link, channels_reached_at_issuance[], confirmed_at}, WHEN NOTIF receives the event, THEN NOTIF synthesizes one notification of message_type=RENEWAL_PAYMENT_RECEIPT and dispatches per AC-11.3 below. The producer-event consumer runs through the same dispatch pipeline as NOTIF-US-01 (§B.7 FR-14), with idempotency_key = transaction_reference (PAY guarantees uniqueness).
- AC-11.2 - Idempotency on event redelivery: WHEN PAY redelivers the same PaymentConfirmed (transactional outbox retry), THEN NOTIF detects the existing notification (matched on idempotency_key=transaction_reference) and is a no-op. No second send. Audit emits a single NOTIF_PAYMENT_RECEIPT_DISPATCHED per transaction; redeliveries do not re-emit.
- AC-11.3 - Channel selection (SMS always; WA when on file): GIVEN event.channels_reached_at_issuance lists which channels AUTH dispatched the original signed link on (per identity-prd.md AUTH-CP-01 second AC), WHEN NOTIF dispatches the receipt, THEN - IF WA ∈ channels_reached_at_issuance AND no WA suppression for (tenant, customer) THEN dispatch on both WA and SMS in parallel (one delivery_attempt per channel). ELSE dispatch on SMS only. Email is NOT a V1 receipt-push channel even when the customer record carries an email (V2 expansion).
- AC-11.4 - French copy locked V1: WHEN the message is rendered, THEN the body is exactly: "Paiement reçu - {tenant_name}. Référence {transaction_reference}, montant {amount_xof_formatted}. Téléchargez votre reçu : {recu_deep_link}. Pour ne plus recevoir de message, répondez STOP." (final wording locked by DESIGNER + LEGAL pass; see OQ-NOTIF-11 for the cross-border disclosure adequacy question).
- AC-11.5 - Quiet-hours bypass (TV-09): GIVEN the dispatch fires at 22:30 Africa/Abidjan immediately after confirmed_at, WHEN NOTIF processes the event, THEN the quiet-hours gate (NOTIF-US-03 / FR-03) is BYPASSED for message_type=RENEWAL_PAYMENT_RECEIPT per FR-15. Rationale: a payment receipt is a transactional, customer-initiated artifact - the customer just paid and expects immediate confirmation. Lawyer to confirm via OQ-NOTIF-10.
- AC-11.6 - Daily-cap behaviour: WHEN the dispatch is gated by the tenant daily cap (FR-04), THEN - DEFAULT in V1: transactional receipts are counted against the cap but NEVER held by the cap. They dispatch through even at cap+1 (cap-overrun is logged but not enforced). Rationale: blocking a transactional receipt because of a misconfigured reminder cap would be customer-hostile. NOTIF_CAP_EXCEEDED is still emitted at the cap boundary; the receipt produces a separate NOTIF_TRANSACTIONAL_OVER_CAP informational audit. (Decision flagged at OQ-NOTIF-09 for legal review.)
- AC-11.7 - Suppression honored, but partial: GIVEN a suppression on WA only for (tenant, customer), WHEN dispatch occurs, THEN AC-11.3's "both" rule degrades to SMS-only - receipt is still delivered. GIVEN suppressions on BOTH channels, WHEN dispatch occurs, THEN status OPT_OUT; emit notification.opted_out + NOTIF_OPTED_OUT; the customer receives the receipt only via the in-portal CP-07 surface (the reçu PDF stays downloadable). The operator timeline for the file shows the OPT_OUT status so they can call the customer.
- AC-11.8 - Failure handling + retries: WHEN the WA leg fails terminally AND the SMS leg also fails terminally, THEN status FAILED; emit notification.failed + NOTIF_PAYMENT_RECEIPT_FAILED (in addition to the generic NOTIF_FAILURE). Operator manual retry (NOTIF-US-05) is permitted for transactional receipts on the same 3-retry cap. WHEN one leg succeeds and the other fails, THEN status is DELIVERED (any successful channel suffices); the failed leg's delivery_attempt is preserved for proof.
- AC-11.9 - Producer event for OP timeline: WHEN the receipt is dispatched on at least one channel, THEN NOTIF publishes Spring Modulith application event notification.payment_receipt_dispatched carrying {notification_id, tenant_id, customer_id, contract_ids[], transaction_reference, channels_dispatched[], dispatched_at, correlation_id}. OP subscribes for the file timeline (mirrors CP's audit cross-correlation per CP-14 of customer-portal-prd.md).
- AC-11.10 - Tenant-status guard: GIVEN the tenant is SUSPENDED or DELETED at the moment PaymentConfirmed arrives, WHEN NOTIF processes the event, THEN the dispatch is HELD with status PENDING_TENANT_FROZEN (no provider call); resumes on TenantReactivated. Rationale: a suspended tenant should not be sending customer messages (mirrors AUTH R-OP-014). PAY's idempotency contract guarantees no double-charge regardless.
A.4 Non-Functional Requirements¶
| ID | NFR |
|---|---|
| NFR-01 | Dispatch endpoint latency: p95 ≤ 200 ms (enqueue only - actual provider submit is async). |
| NFR-02 | Throughput: sustain ≥ 50 messages/s at the provider-submit boundary (rate-limit hard cap; can be lower if provider tier requires). |
| NFR-03 | End-to-end SLA: p95 from POST /notif/dispatch to provider.SENT ≤ 30 s, inside the quiet-hours window and outside cap-pause. |
| NFR-04 | Webhook ingestion: 100% signature-verified; idempotent on (provider_id, provider_message_id, status). |
| NFR-05 | Reconciliation polling: every 5 min for up to 24 h on attempts in non-terminal status (NOTIF-US-06). |
| NFR-06 | Proof retention: every notification + delivery_attempt + status_timeline retained ≥ 10 years from notification.enqueued_at. |
| NFR-07 | Tenant isolation: every NOTIF row carries tenant_id; suppression rows scoped per tenant; daily cap scoped per tenant. |
| NFR-08 | Localization: all customer-facing copy in French; quiet hours in Africa/Abidjan; phone format +225 E.164; XOF amount with space thousands separator + 0 dec. |
| NFR-09 | Observability: queue depth, attempts/s by channel, p95 dispatch-to-SENT latency, fallback rate, opt-out rate, cap-hit rate, kill-switch state surfaced as metrics. |
| NFR-10 | Provider-agnostic: WA BSP and SMS aggregator are pluggable; the provider_id enum is open for new entries without schema change. |
| NFR-11 | PII visibility: phone cleartext to operator role inside the application; masked in any timeline export; never stored in audit-log payloads (pseudonymized). |
| NFR-12 | Connectivity (CP-side disclosure surface): NOTIF-US-08 disclosure asset MUST NOT inflate CP initial payload beyond V1 PRD §30 200 KB budget. |
A.5 MVP Scope (V1) vs V2 / V3¶
In scope V1¶
- Three message types:
RENEWAL_REMINDER_INITIAL,RENEWAL_REMINDER_WEEKLY(both reminder-class, both carrying a signed link, both French-only); andRENEWAL_PAYMENT_RECEIPT(transactional receipt, dispatched on PAY'sPaymentConfirmed, French-only - D-1). - Two channels: WhatsApp first, SMS fallback for reminders (WA→SMS automatic per NOTIF-US-02). For the receipt: SMS + WA dispatched in parallel (mirror) when both reachable (NOTIF-US-11 AC-11.3).
- Provider-agnostic abstraction (WA BSP + SMS aggregator chosen via OQ-NOTIF-01).
- Per-tenant sender ID (provisioned via TENANT onboarding; whitelisted per provider).
- Quiet hours 07:00–20:00 Africa/Abidjan from country profile.
- Per-tenant daily cap (default 5000; configurable via TENANT settings).
- Platform-wide kill switch (admin endpoint; no UI in V1).
- Idempotency-Key + natural-key dual guard.
- WA→SMS fallback (hard-error immediate; transient 3-retry then fallback).
- Webhook signature verification + idempotent ingestion + 5-min polling reconciliation up to 24 h.
- Inbound STOP detection + per-(tenant, customer, channel) suppression.
- Operator timeline view + manual retry (3 max per notification).
- Cross-border disclosure copy block (rendered by CP per NOTIF-US-08).
- Producer events to AUDIT:
NOTIF_SENDING,NOTIF_FAILURE,NOTIF_OPTED_OUT(catalog extension required), plus self-eventsNOTIF_KILL_SWITCH_ENGAGED/RELEASED,NOTIF_CAP_EXCEEDED,NOTIF_MANUAL_RETRY,NOTIF_WEBHOOK_REJECTED,NOTIF_SUPPRESSION_REMOVED. - Spring Modulith application events:
notification.dispatched,notification.delivered,notification.failed,notification.opted_out. - Storage in platform-shared DB regardless of tenant DB profile (mirror AUDIT TV-05).
Out of scope V1 - explicit V2/V3 boundary¶
| Item | Version |
|---|---|
| Tenant-side template editing | V2 |
| ~~Payment-confirmation push (separate message_type after PAY succeeds)~~ - moved to V1 (D-7); tracked via NOTIF-US-11. | V1 |
| OTP send primitive (V1 PRD §15.3 Level 2 - "extension proche") | V2 |
| Email channel; voice/IVR; in-app/push (V3 §5.3 omnichannel) | V2/V3 |
| Per-customer channel preference toggle (customer-portal setting) | V2 |
| Bulk retry of all failed in a campaign | V2 |
| Provider-payload inspection in operator history (raw response JSON visible to operators) | V2 |
| Tenant-admin / cabinet-admin self-service removal of a suppression | V2 |
| Marketing-category templates (Meta classification) - V1 only "utility" | V2 |
| Per-tenant operator-history exports beyond CSV/PDF mask (e.g. signed PDF batches) | V2 |
| Per-tenant provider account (BYO-provider) | V2 |
| Adaptive channel routing (cost-optimized, segment-driven) | V3 |
| Inbound conversational handling beyond STOP | V3 |
| Multi-language (V1 = FR only; CIMA-zone variants in scope V2/V3) | V2/V3 |
| Per-tenant data residency for NOTIF rows (BYO-DB stores NOTIF rows) | V2 |
| Real-time alerting on patterns (e.g. "fallback rate > 30% in last hour" → page ops) | V2 |
| ARTCI authorization filing for cross-border WA flow (lawyer-driven) | OQ-NOTIF-02 |
A.6 Open Questions¶
| ID | Question | Owner | V1 blocker |
|---|---|---|---|
| OQ-NOTIF-01 | Concrete provider selection: which WA BSP (LetsTalk / 360dialog / Hub2 / other) and which SMS aggregator (Orange CI direct / Hub2 / LetsTalk / Africa's Talking / other) for the CI country profile? Includes negotiated DPA + sub-processor disclosure list. | Founder + ARCHITECT | Yes - required before pilot WA dispatch goes live. |
| OQ-NOTIF-02 | Cross-border data flow legal basis under law n°2013-450 (legal-audit gap #6). Does the WA Cloud sub-processing chain require an ARTCI authorization filing, or does legitimate-interest + transparency-notice + DPAs suffice? Lawyer must pin. | LEGAL_AUDITOR (re-research) | Yes - without resolution, V1 ships SMS-only at pilot |
| OQ-NOTIF-03 | Customer auth acceptability under CIMA Reg. 01-24 (legal-audit gap #1) intersects NOTIF only via the signed-link payload it carries (AUTH-owned). Confirm no extra mandatory clause is required in the message body itself. | LEGAL_AUDITOR (re-research) | No (but content gap if missed) |
| OQ-NOTIF-04 | AUDIT V1 catalog extension to admit NOTIF_OPTED_OUT, NOTIF_KILL_SWITCH_*, NOTIF_CAP_EXCEEDED, NOTIF_MANUAL_RETRY, NOTIF_WEBHOOK_REJECTED, NOTIF_SUPPRESSION_REMOVED. Each is internal-evidence-relevant; AUDIT PRD update required (FR-01 catalog gate). |
ANALYST (next AUDIT rework) | Yes - AUDIT PRD must be reworked before NOTIF ships |
| OQ-NOTIF-05 | Quiet-hours edge case: a campaign validated at 19:55 with N=2000. Do we want to "best-effort flush before 20:00, hold the rest" (current AC-03.2) or "refuse validation" (push the brake to OP)? Current is the lighter UX. Founder confirmation. | Founder | No |
| OQ-NOTIF-06 | SMS encoding cost: French accents push SMS to UCS-2 (70 char/segment vs 160 GSM-7). Templates currently include é/è/à. Strip accents to halve SMS cost? Customer experience vs cost trade-off. |
Founder | No (cosmetic/cost) |
| OQ-NOTIF-07 | Suppression revocation lawful basis: when platform admin removes a suppression (AC-07.6), is there a regulatory consent-recapture obligation under law n°2013-450, or is operator/admin authority sufficient? Lawyer to confirm. | LEGAL_AUDITOR (re-research) | No (procedural) |
| OQ-NOTIF-08 | Phone-number invalid-at-NOTIF case: ING is supposed to validate phone format upstream. If a malformed phone reaches NOTIF anyway, does it count against the daily cap? Current rule: rejected at AC-01.4 with HTTP 400, no cap consumption, no AUDIT NOTIF_SENDING. | ARCHITECT | No |
| OQ-NOTIF-09 | Lawful basis for the transactional payment-receipt push under law n°2013-450. Working assumption (TV-08): same legitimate-interest basis as the renewal reminder, with the additional argument that the customer has just transacted and expects a confirmation. Lawyer must confirm whether a separate basis (contract execution? consent at first dispatch?) applies, and whether the customer-portal disclosure block (NOTIF-US-08) needs an additional bullet for transactional templates. | LEGAL_AUDITOR (re-research) | Yes for production launch - receipt push will ship behind a feature flag in pilot if unresolved. |
| OQ-NOTIF-10 | Quiet-hours bypass for the transactional payment-receipt push. Working assumption (TV-09): a transactional receipt (the customer just paid, payment was confirmed within seconds) bypasses the 07:00–20:00 window. Legal audit gap #6 + #8 do not directly anchor this; lawyer must confirm whether bypass is acceptable, or whether NOTIF should hold even transactional receipts to the morning. Customer experience risk if held: customer thinks the payment failed. | LEGAL_AUDITOR (re-research) + Founder | Yes for production launch - pilot will go live with bypass; reversal is a 1-line config flip. |
| OQ-NOTIF-11 | Cross-border disclosure adequacy for the transactional template. OQ-NOTIF-02 covers reminders. The transactional receipt may carry slightly different data (transaction_reference, amount) but the routing and sub-processor chain is identical (Meta WA + SMS aggregator). Working assumption: the existing NOTIF-US-08 disclosure block covers the receipt without modification. Lawyer to confirm - and to confirm whether the receipt's body itself must carry an inline disclosure micro-line (worst-case adds ~30 chars to every SMS). | LEGAL_AUDITOR (re-research) | No (content gap if missed) |
A.7 Temporarily Validated¶
The legal audit reports RE-RESEARCH NEEDED; CRITICAL gap #6 ("cross-border data flow for outbound notifications, WhatsApp Cloud API") is the largest unresolved exposure for this module. The following V1 assumptions are made temporarily, pending re-researched primary sources and lawyer confirmation:
| ID | Assumption | Audit gap reference |
|---|---|---|
| TV-01 | The lawful basis for outbound renewal-reminder notifications under law n°2013-450 is legitimate interest of the broker (data controller) in executing the renewal of the underlying contract, not consent. | Legal-audit gap #8 + #6. |
| TV-02 | The transparency-notice surface in the customer portal (NOTIF-US-08) + DPAs with the BSP and SMS aggregator + standard contractual clauses with Meta (via the BSP) are sufficient under law n°2013-450 for cross-border WA. | Legal-audit gap #6. |
| TV-03 | Right of objection is honored via the STOP suppression mechanism (NOTIF-US-07); no per-customer pre-consent needed at first send V1. | Legal-audit gap #6 + #8. |
| TV-04 | Quiet hours [07:00, 20:00) Africa/Abidjan is a reasonable customer-respect rule; not anchored in a regulatory text. |
(No specific audit gap; prudential.) |
| TV-05 | Storing NOTIF rows in the platform-shared DB even for BYO-DB tenants is an acceptable V1 trade-off and is consistent with AUDIT TV-05; documented in the BYO-DB onboarding contract. | Legal-audit gap #1 (positioning). |
| TV-06 | NOTIF_OPTED_OUT and the four NOTIF self-events (KILL_SWITCH_ENGAGED/RELEASED, CAP_EXCEEDED, MANUAL_RETRY, WEBHOOK_REJECTED, SUPPRESSION_REMOVED) belong in the AUDIT V1 catalog. AUDIT PRD must be reworked (OQ-NOTIF-04). |
Legal-audit gap #2. |
| TV-07 | Cleartext phone in the operator timeline (AC-05.2) is acceptable minimization given the operator's lawful basis (callback for renewal); masking on export is the compensating control. | Legal-audit gap #8. |
| TV-08 | The transactional payment-receipt push (NOTIF-US-11) is covered by the same legitimate interest of the broker basis as the renewal reminder (TV-01), strengthened by the fact that the customer just paid and reasonably expects an acknowledgement. To be confirmed by OQ-NOTIF-09. |
Legal-audit gap #6 + #8. |
| TV-09 | Transactional payment-receipt push BYPASSES the quiet-hours window (FR-15). Rationale: the receipt is causally bound to a customer-initiated action (the payment) that just confirmed; holding it until 07:00 would damage trust and trigger support calls. Lawyer pin via OQ-NOTIF-10. The reminder templates remain quiet-hours-gated (FR-03). | (No specific audit gap; prudential.) |
Part B - Functional Specifications¶
B.1 V1 Message-Type Catalog¶
| message_type | Channel category (Meta WA) | Mandatory variables | Iteration counter source | Class |
|---|---|---|---|---|
RENEWAL_REMINDER_INITIAL |
utility | tenant_name, customer_first_name, contract_ref, amount_minor_xof, due_date, signed_link |
OP (always 1) | reminder |
RENEWAL_REMINDER_WEEKLY |
utility | same | OP (1, 2, 3 …) | reminder |
RENEWAL_PAYMENT_RECEIPT |
utility (Meta) - transactional class | tenant_name, transaction_reference, amount_minor_xof, recu_deep_link |
PAY (always 1 per transaction_reference) |
transactional |
FR template - reminders (canonical, locked V1) - same body shape, different opening verb:
Bonjour {customer_first_name}, votre contrat {contract_ref} chez {tenant_name} arrive à échéance le {due_date}. Montant : {amount_xof_formatted}. Renouvelez en quelques clics : {signed_link}. Pour ne plus recevoir de message, répondez STOP.
(V1 ships exactly this body; the only varying word is arrive → arrive toujours for the weekly reminder. Final wording locked by DESIGNER + LEGAL.)
FR template - payment receipt (canonical, locked V1):
Paiement reçu - {tenant_name}. Référence {transaction_reference}, montant {amount_xof_formatted}. Téléchargez votre reçu : {recu_deep_link}. Pour ne plus recevoir de message, répondez STOP.
The recu_deep_link is a stable per-transaction URL served by CP (per customer-portal-prd.md §B.7) that re-issues a fresh short-lived signed object URL on each click; NOTIF MUST NOT inline a raw MinIO signed URL (those expire in 5 min and would break by the time SMS lands). Final wording locked by DESIGNER + LEGAL.
B.2 V1 Producer-Event Catalog (events emitted by NOTIF to AUDIT)¶
Per audit-log §B.1 + OQ-NOTIF-04:
| event_type | Trigger |
|---|---|
NOTIF_SENDING |
Each delivery_attempt submitted to a provider (WA or SMS). |
NOTIF_FAILURE |
Notification reaches terminal FAILED status (after WA→SMS exhaustion). |
NOTIF_OPTED_OUT |
Notification reaches OPT_OUT due to active suppressions on every viable channel. |
NOTIF_KILL_SWITCH_ENGAGED |
Platform admin engaged the kill switch (NOTIF-US-10). |
NOTIF_KILL_SWITCH_RELEASED |
Platform admin released the kill switch. |
NOTIF_CAP_EXCEEDED |
Tenant daily cap hit (deduplicated to one entry per (tenant, local-day)). |
NOTIF_MANUAL_RETRY |
Operator clicked "Réessayer" on a failed notification (NOTIF-US-05). |
NOTIF_WEBHOOK_REJECTED |
Inbound webhook rejected for missing/invalid signature. |
NOTIF_SUPPRESSION_REMOVED |
Platform admin removed a suppression row. |
NOTIF_PAYMENT_RECEIPT_DISPATCHED |
Transactional receipt dispatched on at least one channel (NOTIF-US-11 AC-11.9). Fields: {tenant_id, agency_id, country_code, customer_id, contract_ids[], transaction_reference, channels_dispatched[], correlation_id, result: "INFO"}. |
NOTIF_PAYMENT_RECEIPT_FAILED |
Transactional receipt failed terminally on every viable channel (NOTIF-US-11 AC-11.8). Fields: {tenant_id, agency_id, country_code, customer_id, contract_ids[], transaction_reference, last_channel, failure_class, attempt_count, correlation_id, result: "FAILURE"}. Pairs with the generic NOTIF_FAILURE; carries the transaction_reference for cross-correlation with PAY. |
NOTIF_TRANSACTIONAL_OVER_CAP |
Informational: a transactional receipt dispatched while the tenant was over the daily cap (NOTIF-US-11 AC-11.6). Fields: {tenant_id, transaction_reference, daily_cap, count_at_dispatch, correlation_id, result: "INFO"}. Catalog-extension scope is identical to the other NOTIF self-events (folded into OQ-NOTIF-04). |
B.3 Spring Modulith Application Events (consumed by OP, not AUDIT)¶
| event | Payload |
|---|---|
notification.dispatched |
{notification_id, tenant_id, agency_id, customer_id, contract_id, channel, provider_id, dispatched_at, correlation_id} |
notification.delivered |
{notification_id, …, channel, delivered_at, correlation_id} |
notification.failed |
{notification_id, …, last_channel, failure_class, failed_at, correlation_id} |
notification.opted_out |
{notification_id, …, suppressed_channels[], correlation_id} |
notification.payment_receipt_dispatched |
{notification_id, tenant_id, agency_id, customer_id, contract_ids[], transaction_reference, channels_dispatched[], dispatched_at, correlation_id} |
OP subscribes; updates contract follow-up status and renders notification.payment_receipt_dispatched on the file timeline (mirrors CP's audit cross-correlation per CP-14 of customer-portal-prd.md). AUDIT does NOT use these events as its source - AUDIT receives its own outbox-driven NOTIF_* audit entries per §B.2.
B.4 State Machine - Notification lifecycle¶
stateDiagram-v2
[*] --> PENDING: producer POST /notif/dispatch
PENDING --> PENDING_QUIET_HOURS: outside 07:00–20:00 Africa/Abidjan (AC-03.1)
PENDING --> PENDING_CAP: tenant daily cap reached (AC-04.3)
PENDING --> PENDING_PLATFORM_PAUSED: kill switch engaged (AC-04.5)
PENDING --> PENDING_TENANT_FROZEN: tenant suspended (AC-11.10, v2 D-2)
PENDING --> OPT_OUT: every viable channel suppressed (AC-07.3)
PENDING_QUIET_HOURS --> PENDING: window opens
PENDING_CAP --> PENDING: midnight Africa/Abidjan rolls
PENDING_PLATFORM_PAUSED --> PENDING: kill switch released
PENDING_TENANT_FROZEN --> PENDING: TenantReactivated (v2 D-2)
PENDING --> DISPATCHING_WA: WA channel attempted (AC-02.1)
DISPATCHING_WA --> DISPATCHING_SMS: WA hard-error (AC-02.2) OR 3 transient retries exhausted (AC-02.3)
DISPATCHING_WA --> SENT: WA provider acknowledged
DISPATCHING_SMS --> SENT: SMS provider acknowledged
DISPATCHING_SMS --> FAILED: SMS terminal failure (AC-02.4)
SENT --> DELIVERED: terminal-status webhook (delivered)
SENT --> FAILED: terminal-status webhook (failed) OR 24h reconciliation timeout
DELIVERED --> [*]
FAILED --> DISPATCHING_WA: operator manual retry, ≤ 3 lifetime (AC-05.3)
FAILED --> [*]
OPT_OUT --> [*]
B.5 State Machine - Delivery attempt lifecycle (per channel)¶
stateDiagram-v2
[*] --> SUBMITTING: provider call dispatched
SUBMITTING --> SENT: provider 2xx with provider_message_id
SUBMITTING --> HARD_ERROR: provider hard-error class
SUBMITTING --> TRANSIENT_ERROR: 5xx / timeout / throttled
TRANSIENT_ERROR --> SUBMITTING: backoff retry (15s/1m/5m), ≤ 3 retries
TRANSIENT_ERROR --> HARD_ERROR: 3rd retry exhausted
SENT --> DELIVERED: webhook(delivered)
SENT --> FAILED: webhook(failed)
SENT --> POLLING: 5 min without webhook
POLLING --> DELIVERED: status API → delivered
POLLING --> FAILED: status API → failed
POLLING --> POLLING: still pending, every 5 min
POLLING --> UNKNOWN_TIMEOUT: 24 h without terminal status
B.6 Decision Table - channel selection and fallback¶
| State | suppression(WA)? | suppression(SMS)? | Action |
|---|---|---|---|
| PENDING | no | no | Dispatch on WA |
| PENDING | yes | no | Dispatch on SMS directly (skip WA) |
| PENDING | no | yes | Dispatch on WA only (no SMS fallback available) |
| PENDING | yes | yes | Set OPT_OUT; emit notification.opted_out + NOTIF_OPTED_OUT |
| DISPATCHING_WA + hard_error | n/a | no | Fallback to SMS |
| DISPATCHING_WA + hard_error | n/a | yes | Set FAILED; emit notification.failed + NOTIF_FAILURE |
| DISPATCHING_WA + transient (retry < 3) | n/a | n/a | Backoff and retry WA |
| DISPATCHING_WA + transient (retry == 3) | n/a | no | Fallback to SMS |
| DISPATCHING_WA + transient (retry == 3) | n/a | yes | Set FAILED |
| DISPATCHING_SMS + hard_error | n/a | n/a | Set FAILED |
| DISPATCHING_SMS + transient (retry < 3) | n/a | n/a | Backoff and retry SMS |
| DISPATCHING_SMS + transient (retry == 3) | n/a | n/a | Set FAILED |
B.7 Functional Rules¶
FR-01 - Dispatch endpoint contract (catalog gate)
- IF message_type ∉ {RENEWAL_REMINDER_INITIAL, RENEWAL_REMINDER_WEEKLY} THEN reject with HTTP 400 {reason: "unknown_message_type"}.
- IF any required variable per §B.1 is missing/empty THEN reject with HTTP 400 {reason: "missing_variable", field: …}.
FR-02 - Idempotency dual guard
- IF Idempotency-Key already seen within 24h THEN return existing notification (AC-01.2).
- ELSE IF natural key (tenant_id, campaign_id, customer_id, message_type, iteration) exists within 60 s THEN reject 409 (AC-01.3).
- ELSE create new notification.
FR-03 - Quiet hours
- IF now_local(Africa/Abidjan) ∉ [07:00, 20:00) THEN status PENDING_QUIET_HOURS; release at next 07:00 (AC-03.1).
FR-04 - Tenant daily cap
- Counter is count(notifications WHERE tenant_id = … AND DATE(enqueued_at AT TIME ZONE 'Africa/Abidjan') = current_local_date).
- IF counter >= tenant.notif.daily_cap THEN status PENDING_CAP; emit NOTIF_CAP_EXCEEDED once per (tenant, local_day) (AC-04.3).
FR-05 - Platform kill switch
- IF kill switch engaged THEN status PENDING_PLATFORM_PAUSED; do NOT submit to provider.
FR-06 - Suppression check
- Evaluated AT dispatch time (not enqueue time). A STOP arriving between enqueue and dispatch DOES suppress.
- IF suppression(tenant_id, customer_id, WA) THEN skip WA path entirely; go to SMS.
- IF suppression(tenant_id, customer_id, SMS) THEN do not fall back to SMS even on WA failure.
- IF both THEN status OPT_OUT (decision table B.6 row 4).
FR-07 - WA→SMS fallback (canonical)
- Per AC-02.2 / AC-02.3 / decision table §B.6.
- Fallback creates a NEW delivery_attempt row on channel SMS; the WA attempt is preserved with its HARD_ERROR or TRANSIENT_ERROR final state for proof.
FR-08 - Manual retry limit
- Counter is count(audit NOTIF_MANUAL_RETRY WHERE notification_id = …).
- IF counter >= 3 THEN refuse retry (AC-05.3).
FR-09 - Webhook signature gate
- IF signature missing/invalid THEN HTTP 401, emit NOTIF_WEBHOOK_REJECTED, do NOT process payload.
- ELSE validate (provider_id, provider_message_id, status) not already terminal-applied; if so, no-op (AC-06.2).
FR-10 - Reconciliation polling cadence
- IF a delivery_attempt is in SENT for ≥ 5 min without terminal webhook THEN poll provider status API.
- Repeat every 5 min.
- IF still non-terminal at 24 h THEN status UNKNOWN_TIMEOUT; treat parent notification as FAILED if no other attempt is still in flight.
FR-11 - STOP detection
- Body normalize: trim, uppercase, strip accents.
- IF body ∈ {STOP, ARRET, NON, NO} THEN persist suppression(tenant_id, customer_id, channel=SMS, reason=customer_stop).
- (Body must equal exactly one of those tokens; "I want to stop" does NOT trigger.)
FR-12 - Producer events fire exactly once per terminal transition
- IF notification.delivered already published for a notification THEN do not re-publish on a subsequent webhook.
- Same for notification.failed, notification.opted_out.
FR-13 - AUDIT pseudonymization
- IF an audit payload would carry phone or customer name THEN replace with hash_v1(value, salt[tenant_id]) before sending to AUDIT (per audit-log AC-06).
- The notification row itself keeps cleartext phone (operator-history AC-05.2).
FR-14 - PaymentConfirmed consumer + idempotency
- NOTIF subscribes to PAY's Spring Modulith PaymentConfirmed. On reception:
- IF the tenant is SUSPENDED or DELETED THEN status PENDING_TENANT_FROZEN (AC-11.10); resume on TenantReactivated.
- ELSE synthesize a notification with message_type=RENEWAL_PAYMENT_RECEIPT, idempotency_key = transaction_reference, iteration = 1, correlation_id propagated from the event.
- The natural-key guard (FR-02 AC-01.3) is BYPASSED for receipts - the same transaction_reference arriving twice (PAY redelivery) is the idempotent expected case, not a producer bug.
- IF a notification with this idempotency_key already exists THEN no-op; do NOT re-dispatch; do NOT re-emit NOTIF_PAYMENT_RECEIPT_DISPATCHED.
- The handler is at-least-once at the PAY → NOTIF boundary; idempotency at NOTIF guarantees exactly-one delivery per terminal transition.
FR-15 - Transactional templates bypass quiet hours (TV-09)
- IF message_type ∈ TRANSACTIONAL_CLASS = {RENEWAL_PAYMENT_RECEIPT} THEN FR-03 is BYPASSED - the notification dispatches regardless of now_local(Africa/Abidjan) ∈ [07:00, 20:00).
- The reminder class (RENEWAL_REMINDER_INITIAL, RENEWAL_REMINDER_WEEKLY) remains gated by FR-03.
- IF OQ-NOTIF-10 resolves against bypass THEN flipping back is a one-flag config change at the application boundary; no schema change.
FR-16 - Transactional templates count-but-do-not-hold against the cap (AC-11.6)
- IF message_type ∈ TRANSACTIONAL_CLASS AND tenant counter >= notif.daily_cap THEN - emit NOTIF_TRANSACTIONAL_OVER_CAP; STILL dispatch (do NOT set PENDING_CAP).
- The counter (FR-04) increments normally so reminders sent later in the day correctly see the cumulative load.
- Rationale: a customer who just paid must receive their receipt; cap violations are operational signals, not product gates for transactional confirmations.
FR-17 - Receipt dual-channel mirror dispatch (AC-11.3)
- For message_type=RENEWAL_PAYMENT_RECEIPT, NOTIF does NOT use the WA→SMS fallback chain of FR-07. Instead:
- IF WA ∈ event.channels_reached_at_issuance AND no WA suppression for (tenant, customer) THEN create TWO delivery_attempt rows in parallel: one channel=WA, one channel=SMS. Each follows its own provider lifecycle (FR-09 webhook, FR-10 reconciliation).
- ELSE create ONE delivery_attempt on channel=SMS (or WA-only if SMS suppressed).
- Notification status transitions to DELIVERED when ANY one attempt reaches DELIVERED; transitions to FAILED only when EVERY attempt is terminal-failed.
- Both attempts are preserved for proof regardless of outcome.
B.8 Data Requirements¶
Three V1 entities, all in the platform-shared DB (TV-05).
notification¶
| Column | Type | Notes |
|---|---|---|
notification_id |
UUID v7 | PK. |
tenant_id |
UUID | NOT NULL. Indexed. |
agency_id |
UUID | NOT NULL. |
country_code |
CHAR(2) | Snapshotted from tenant. Indexed. |
customer_id |
UUID | NOT NULL. Indexed. |
contract_id |
UUID | NOT NULL. |
campaign_id |
UUID | Optional (manual ad-hoc retries from operator console may have NULL). |
message_type |
TEXT | Enum per §B.1. |
iteration |
SMALLINT | 1 for initial, 1..N for weekly. |
idempotency_key |
UUID | UNIQUE (tenant_id, idempotency_key). Mandatory. |
signed_link_id |
UUID | The AUTH-issued link this notification carries. |
signed_link_url |
TEXT | The actual URL inserted into the message body. Sensitive - minimum-access. |
customer_phone_e164 |
TEXT | Cleartext (operator legitimate-interest). E.164 with +225 prefix. |
customer_first_name |
TEXT | For template variable. Cleartext on this row; pseudonymized in audit. |
tenant_name_snapshot |
TEXT | Snapshot of broker display name at dispatch (audit-friendly). |
due_date |
DATE | Template variable. |
amount_minor_xof |
BIGINT | XOF in minor units; 0 dec on display. |
correlation_id |
UUID | NOT NULL - passed through to AUDIT (audit-log AC-03.2 chain). |
enqueued_at |
TIMESTAMPTZ | NOT NULL. |
status |
TEXT | Enum: PENDING / PENDING_QUIET_HOURS / PENDING_CAP / PENDING_PLATFORM_PAUSED / PENDING_TENANT_FROZEN <!-- v2 D-2 --> / DISPATCHING_WA / DISPATCHING_SMS / SENT / DELIVERED / FAILED / OPT_OUT. |
transaction_reference |
TEXT NULL | Set only for message_type=RENEWAL_PAYMENT_RECEIPT. Mirrors the value on payment_attempts (PAY-owned). Indexed for cross-correlation. |
last_status_at |
TIMESTAMPTZ | |
last_channel |
TEXT | WA / SMS / NULL. |
failure_class |
TEXT | NULL when not failed; one of INVALID_PHONE / USER_NOT_ON_WHATSAPP / TEMPLATE_REJECTED / BUSINESS_BLOCKED / SMS_HARD_ERROR / SMS_TRANSIENT_EXHAUSTED / UNKNOWN_TIMEOUT. |
Indexes:
- (tenant_id, customer_id, enqueued_at DESC) - operator timeline (AC-05.1).
- (tenant_id, idempotency_key) UNIQUE - FR-02.
- (tenant_id, campaign_id, customer_id, message_type, iteration) UNIQUE - FR-02 natural key.
- (status, last_status_at) partial WHERE status LIKE 'PENDING%' - release-gate scanning.
- (correlation_id) - AUDIT case-key lookup.
- (tenant_id, transaction_reference) partial WHERE transaction_reference IS NOT NULL - receipt cross-correlation with PAY.
delivery_attempt¶
| Column | Type | Notes |
|---|---|---|
attempt_id |
UUID v7 | PK. |
notification_id |
UUID | FK → notification. Indexed. |
tenant_id |
UUID | Denormalized for tenant-scoped scans. |
channel |
TEXT | WA / SMS. |
provider_id |
TEXT | e.g. letstalk_wa_ci, orange_sms_ci. Open enum (NFR-10). |
provider_message_id |
TEXT | Set after provider 2xx. UNIQUE with provider_id. |
attempt_index |
SMALLINT | 1..3 within the same channel (transient retries). |
status |
TEXT | SUBMITTING / SENT / DELIVERED / FAILED / HARD_ERROR / TRANSIENT_ERROR / UNKNOWN_TIMEOUT. |
submitted_at |
TIMESTAMPTZ | |
last_status_at |
TIMESTAMPTZ | |
status_timeline |
JSONB | Array of {status, at, raw_provider_response_truncated}. Each raw_provider_response_truncated ≤ 8 KB. |
Indexes:
- (notification_id, attempt_index) UNIQUE.
- (provider_id, provider_message_id) UNIQUE - webhook idempotency.
- (status, submitted_at) partial WHERE status = 'SENT' - reconciliation polling scan.
suppression¶
| Column | Type | Notes |
|---|---|---|
suppression_id |
UUID v7 | PK. |
tenant_id |
UUID | NOT NULL. |
customer_id |
UUID | NOT NULL. |
channel |
TEXT | WA / SMS. |
reason |
TEXT | customer_stop / customer_block / admin_override. |
suppressed_at |
TIMESTAMPTZ | NOT NULL. |
removed_at |
TIMESTAMPTZ | NULL while active. Set on platform-admin removal (AC-07.6). |
removed_by_user_id |
TEXT | Keycloak sub of platform admin who removed. |
Indexes:
- (tenant_id, customer_id, channel) UNIQUE WHERE removed_at IS NULL - FR-06 lookup.
Storage sizing: assume 200 bytes per notification + 600 bytes per attempt (incl. 8 KB cap on response, typically smaller). 1 M notifications/year/tenant ⇒ ~0.2 GB notifications + ~1.2 GB attempts/year/tenant; 10-year horizon ⇒ ~14 GB per tenant per decade. Sizing planning falls under the ARCHITECT.
B.9 Multi-Tenant Implications¶
- Shared DB / shared schema (default): All NOTIF tables in platform DB. Every row carries
tenant_id. Tenant-aware datasource resolver applies on every call. - BYO-DB: NOTIF rows live in the platform DB regardless (TV-05; mirrors AUDIT TV-05). Documented in BYO-DB onboarding contract; TENANT-side onboarding flow surfaces this trade-off to the platform admin.
- Country-profile:
country_codesnapshotted on each notification row. Quiet hours, provider routing (whichprovider_idto use), and currency formatting are resolved through the country profile lookup at dispatch time. - Per-tenant sender ID: TENANT module owns the provisioning. NOTIF reads
tenant.notif.sms_sender_idandtenant.notif.wa_business_idat dispatch. Missing values block dispatch (HTTP 400, structured error to OP). - Per-tenant daily cap: TENANT-managed. Default 5000.
- Group / holding (V2): NOTIF does not know about groups today. V2 will allow group-level suppression policy or aggregated reporting; non-breaking under current schema.
B.10 Cross-Module Dependencies¶
NOTIF consumes (inputs):
- AUTH: signed-link issuance contract - signed_link_id + signed_link_url. NOTIF carries the link; never generates it. (audit-log AUTH_LINK_ISSUED precedes NOTIF_SENDING in the chronology.)
- TENANT: tenant settings - notif.daily_cap, notif.sms_sender_id, notif.wa_business_id, notif.wa_consent_acknowledged_at (TBD if Round-2 V2 introduces it). country_code of the tenant. Display name tenant_name. Tenant lifecycle events (TenantSuspended, TenantReactivated, TenantSoftDeleted) for the receipt freeze gate (NOTIF-US-11 AC-11.10).
- OP: producer of dispatch calls; campaign_id, iteration, correlation_id.
- PAY : producer of PaymentConfirmed for the transactional receipt push (FR-14 + NOTIF-US-11). Event payload contract: {tenant_id, agency_id, customer_id, contract_ids[], transaction_reference, amount_total_minor, currency, customer_session_id, signed_link_id, recu_deep_link, channels_reached_at_issuance[], confirmed_at, correlation_id}.
- CP : serves the per-transaction recu_deep_link URL inlined in the receipt body. NOTIF MUST NOT inline a raw MinIO signed URL (would expire by the time the message lands); CP's stable URL re-issues a fresh signed object URL on each click.
- Country-profile lookup: quiet hours, provider routing, phone format prefix.
NOTIF exposes (outputs):
- HTTP/RPC: POST /notif/dispatch, POST /notif/admin/kill-switch, GET /notif/admin/kill-switch, operator-console internal API for the timeline view + manual retry.
- Inbound webhook endpoints per provider (signed): WA delivery status, SMS delivery status, SMS inbound (STOP detection), WA inbound (block events).
- Spring Modulith application events: notification.dispatched / .delivered / .failed / .opted_out - consumed by OP.
- AUDIT producer outbox entries per §B.2.
- Operational metrics (NFR-09): queue depth, dispatch-to-SENT p95, fallback rate, opt-out rate, cap-hit rate, kill-switch state.
Cross-module dependencies on consumers (work that must happen in OTHER modules to make NOTIF whole):
- AUDIT (ANALYST): catalog extension to admit NOTIF_OPTED_OUT + 5 self-events (OQ-NOTIF-04). Audit-log PRD must be reworked before NOTIF ships.
- TENANT (closed 2026-05-21): tenant_settings extended with notif_daily_cap, notif_sms_sender_id, notif_wa_business_id, notif_wa_consent_acknowledged_at. Activation gate enforces non-NULL notif_sms_sender_id + notif_wa_business_id before PENDING -> ACTIVE. See TENANT PRD §B tenant_settings entity + R-TEN-NOTIF-01.
- OP (ANALYST): add Vous êtes sur le point d'envoyer N notifications. Confirmer ? confirmation when batch > 1000 (decided here, must be carried into OP-prd); subscribe to NOTIF Spring application events; render the kill-switch banner per AC-10.4.
- CP (DESIGNER + ANALYST): render the NOTIF-supplied "Mentions légales et données personnelles" disclosure copy block at the landing page footer (NOTIF-US-08).
- AUTH (already binding): signed_link_id propagation - already part of the audit-log AUTH→NOTIF chain.
- PAY (forthcoming PRD) : must publish PaymentConfirmed with the payload contract listed under "NOTIF consumes" above. Must guarantee transaction_reference uniqueness (used as NOTIF's idempotency key per FR-14). Must serve recu_deep_link resolution via CP. NOTIF expects PAY's transactional outbox to deliver the event at-least-once; idempotency at NOTIF guarantees exactly-one user-visible receipt.
- CP (already authored, customer-portal-prd v1) : must serve recu_deep_link as a stable per-transaction URL. The customer's click on the link in the SMS/WA body lands on the existing CP-13 ("déjà réglé" / receipt re-download) surface; CP issues the short-lived MinIO signed URL on demand.
B.11 API Surfaces (V1, internal)¶
These are summary surfaces - full schemas are an ARCHITECT artifact.
| Surface | Method | Caller | Idempotent | Notes |
|---|---|---|---|---|
/notif/dispatch |
POST | OP / AUTH | Yes (FR-02) | Producer dispatch (NOTIF-US-01). |
/notif/customers/{id}/timeline |
GET | operator UI | Yes | Per-customer timeline (NOTIF-US-05). |
/notif/notifications/{id}/retry |
POST | operator UI | Idempotent on 3-cap | Manual retry (AC-05.3). |
/notif/admin/kill-switch |
POST | platform admin | Yes | Engage / release (NOTIF-US-10). |
/notif/admin/kill-switch |
GET | platform admin | Yes | Status query. |
/notif/admin/suppressions/{id}/remove |
POST | platform admin | Yes | Remove a suppression (AC-07.6) → emits NOTIF_SUPPRESSION_REMOVED. |
/notif/webhook/{provider_id} |
POST | provider | Yes (AC-06.2) | Signed webhook (status updates + inbound STOP). |
Part C - Constraints & Boundaries¶
C.1 Regulatory Compliance¶
- CIMA Reg. 01-24: NOTIF carries the renewal-reminder message body but is not the regulated information surface itself - V1 PRD §16.2 mandatory information lives on the customer portal (CP) reached via the signed link. NOTIF-US-08 disclosure surface complements this. OQ-NOTIF-03 captures the residual lawyer question of whether the message body itself must carry additional regulated text.
- CI law n°2013-450 + ARTCI:
- Lawful basis (TV-01): legitimate interest of the broker for renewal-contract execution.
- Transparency (TV-02 + NOTIF-US-08): customer-portal disclosure block names Meta + BSP + SMS aggregator as sub-processors and discloses the cross-border transfer.
- Right of objection (TV-03 + NOTIF-US-07): STOP suppression mechanism, scoped per (tenant, customer, channel).
- Cross-border transfer (CRITICAL legal-audit gap #6): WhatsApp routes through Meta (US/EU). Mitigations: BSP DPA with sub-processor flow-down, standard contractual clauses with Meta. Whether ARTCI authorization filing is required is OQ-NOTIF-02 - lawyer-driven.
- Retention: 10 years aligned with AUDIT (NFR-06). Pseudonymization at AUDIT boundary (FR-13); cleartext PII at NOTIF row level under operator legitimate-interest (TV-07).
- CI fiscal / DGI-FNE: NOT a NOTIF concern. NOTIF does not emit invoices (BILL does).
- CIMA cybersecurity (Reg. 010-24 or current equivalent): legal-audit gap #4 - TLS in transit, secret rotation, signed webhooks (AC-06.1) are in scope as best-practice baseline; final article anchoring pending lawyer pin.
C.2 Localization¶
- All customer-facing copy in French.
- Phone format: E.164 with
+225prefix (CI). Validation upstream in ING; NOTIF re-validates as a defense-in-depth (FR-01). - Date format in template:
DD/MM/YYYY(e.g.15/06/2026). - Amount format in template: XOF integer with space thousands separator and
XOFsuffix (e.g.15 000 XOF). - Quiet hours:
Africa/Abidjanzone, derived from country profile (currently CI only). - Accents in templates: V1 ships with French accents (
é/è/à); SMS encoding will be UCS-2 (70 char/segment). OQ-NOTIF-06 captures the cost trade-off of stripping accents.
C.3 Security¶
- Tenant isolation: NFR-07. Every NOTIF row carries
tenant_id; suppression and cap are tenant-scoped. - Secret management: provider credentials (BSP API keys, SMS aggregator tokens, webhook-signing secrets) live in platform-managed secret storage; never in source, never in any log line, never in any audit payload. Rotation runbook is a deploy-pipeline concern.
- Webhook signature mandatory (AC-06.1, FR-09).
- Encryption in transit: all producer→NOTIF, NOTIF→provider, provider→NOTIF webhook traffic over TLS.
- Encryption at rest: PostgreSQL volume-level (deploy-default). The
signed_link_urlandcustomer_phone_e164columns are minimum-access; column-level encryption considered V2. - PII visibility: cleartext phone to operator role inside the application (TV-07, AC-05.2); masked in any export. NOT pseudonymized at the NOTIF level - only at the audit boundary (FR-13).
- Self-auditing: every operator manual retry (
NOTIF_MANUAL_RETRY), every webhook rejection (NOTIF_WEBHOOK_REJECTED), every suppression removal (NOTIF_SUPPRESSION_REMOVED), every kill-switch operation (NOTIF_KILL_SWITCH_*), every cap-rollover (NOTIF_CAP_EXCEEDED) produces an audit entry. - Honest scope: NOTIF cannot defend against a malicious BSP or a compromised provider account in V1. Provider sub-processor governance is a contractual matter (DPAs).
C.4 Connectivity¶
- NOTIF is a backend module; only the customer-portal disclosure surface (NOTIF-US-08) is customer-facing, and the bundle-weight rule (NFR-12) defers to V1 PRD §30 200 KB budget on CP.
- NOTIF MUST tolerate transient provider outages: the dispatch pipeline is asynchronous, retries on transient errors per AC-02.3, falls back per AC-02.2, and reconciles per AC-06.4. A 24-h provider outage produces
UNKNOWN_TIMEOUTrather than indefinite pending. - NOTIF MUST tolerate webhook losses: 5-min polling fallback up to 24 h (AC-06.4).
- NOTIF MUST be resilient to JVM crash: producer events are emitted via transactional outbox / Spring Modulith pattern (per audit-log NFR-01 contract); the same outbox carries the AUDIT producer events.
C.5 Out of Scope V1 (explicit boundaries)¶
- No tenant-side template editing UI; templates are platform-locked (§B.1).
- ~~No payment-confirmation push notification; PAY confirms in-portal only.~~ Lifted in v2 (D-11): NOTIF DOES dispatch a transactional payment-receipt push on
PaymentConfirmed(NOTIF-US-11). Still excluded: rich-media receipts (PDF attachment in the message body), receipt push to a third-party email address provided post-payment (V2), and any non-receipt transactional class (e.g. payment-failed push, refund push) - all V2. - No OTP send primitive (V1 PRD §15.3 Level 2 deferred; will be a NOTIF V2 message_type).
- No email channel; no voice; no in-app push.
- No customer-side per-channel preference toggle.
- No bulk retry; no provider-payload inspection in operator history; no tenant-admin suppression removal.
- No marketing-category templates; V1 strictly utility.
- No per-tenant provider account (BYO-provider).
- No per-tenant data residency for NOTIF rows (BYO-DB tenants' NOTIF data lives on platform DB - TV-05).
- No real-time pattern alerting (e.g. fallback-rate spike → page).
- No multi-language; FR only.
- No ARTCI authorization filing path until lawyer pins (OQ-NOTIF-02).
References¶
docs/prd/prd-v1.md§§10, 11.4, 11.7, 15.2, 16.3, 16.5, 18, 24, 27, 30 - design principles, notifications scope, reminder rules, data protection, proof obligation, audit events, dependencies, data model, connectivity.docs/prd/prd-vision-v2-v3.md- V2 §8.3 enhanced auth (OTP), V2 §8.6 audit, V3 §5.3 omnichannel.docs/prd/audit-log-prd.md- AUDIT producer contract; §B.1 catalog (extension required per OQ-NOTIF-04); §B.3 mandatory fields for NOTIF events; AUDIT TV-05 mirrored here as TV-05.docs/prd/identity-prd.md- AUTH signed-link issuance; the link payload that NOTIF carries.docs/prd/tenant-admin-prd.md- TENANT settings host:notif.daily_cap,notif.sms_sender_id,notif.wa_business_id; sender-ID provisioning workflow.docs/prd/customer-portal-prd.md- CP-OQ-04 + R-CP-024 + CP-07 + §B.7 row 'NOTIF': source of the v2 rework requirement; CP serves the per-transactionrecu_deep_linkconsumed byRENEWAL_PAYMENT_RECEIPT.docs/architecture/platform-saas-blueprint.md§5 - module layout (control plane, NOTIF placement).docs/legal/audit/Legal-Compliance-audit.md- CRITICAL gap #6 (cross-border WA), HIGH gap #8 (lawful basis), HIGH gap #11 (retention) - basis for §A.7 Temporarily Validated.