Story CP-022: Email Verification + Trial/Payment Flow¶
Module: control-plane Slice: Slice 8b from architecture (split from CP-008) Brand context: Papillon HR Suite (warm, guided registration UX) Priority: 8 (after CP-008) Depends on: CP-008 Estimated complexity: M
Objective¶
Build the email verification flow that validates the registration token and transitions the tenant to either TRIAL or PAYMENT_REQUIRED status. Includes token expiry handling (24 hours), resend verification with rate limiting (max 3 per 24 hours), and the EmailVerificationPage frontend showing all 4 token states. On successful verification, triggers the provisioning pipeline (CP-024).
Backend Scope¶
Entities & Records¶
DTOs:
public record VerifyEmailRequest(String token) {}
public record VerifyEmailResponse(
String status, // "VERIFIED", "EXPIRED", "INVALID", "ALREADY_VERIFIED"
@Nullable String redirectUrl
) {}
public record ResendVerificationRequest(String email) {}
Repository Layer¶
EmailVerificationToken entity (platform DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| tenant_id | UUID | NOT NULL, FK to Tenant |
| token | String | NOT NULL, unique |
| String | NOT NULL | |
| expires_at | Instant | NOT NULL (24 hours after creation) |
| is_used | boolean | NOT NULL, default false |
| used_at | Instant | Nullable |
| created_date | Instant | NOT NULL |
EmailVerificationTokenRepository.java— Platform-scoped:findByToken(String) → Optional<EmailVerificationToken>findByEmailAndIsUsedFalse(String) → List<EmailVerificationToken>countByEmailAndCreatedDateAfter(String, Instant) → int— for resend rate limitingsave(EmailVerificationToken) → EmailVerificationToken
Service Layer¶
OnboardingService.java (extend from CP-008):
- verifyEmail(VerifyEmailRequest) → VerifyEmailResponse:
1. Validate token (single-use, expires 24 hours)
2. IF valid + freeTrial → transition to TRIAL, publish TenantStatusChanged event (triggers provisioning in CP-024)
3. IF valid + no freeTrial → transition to PAYMENT_REQUIRED, publish TenantStatusChanged event (triggers provisioning in CP-024)
4. IF expired → return "EXPIRED" with resend option
5. IF invalid → return "INVALID"
6. IF already verified → return "ALREADY_VERIFIED"
resendVerification(ResendVerificationRequest) → void:- Max 3 resends per 24 hours per email
- Generates new token, invalidates old one
- Sends new verification email
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| POST | /api/v1/onboarding/verify-email | VerifyEmailRequest | VerifyEmailResponse | Public |
| POST | /api/v1/onboarding/resend-verification | ResendVerificationRequest | 204 | Public |
Validation Rules¶
token: not blank, must match a pending verification recordemail(for resend): valid email, must match an existing PENDING_VERIFICATION tenant- Resend limit: max 3 per 24 hours per email address
Multi-Tenant Considerations¶
- Verification is a PRE-tenant operation — no TenantContext exists yet
- Token lookup is by token value (globally unique), not tenant-scoped
- Status transition triggers provisioning which creates the actual tenant context
Frontend Scope¶
Pages & Routes¶
/verification— Email verification landing page (receives?token=...query param from email link)
Components¶
EmailVerificationPage.tsx— Token validation result display with 4 states
UI States (ALL REQUIRED)¶
Email Verification:
| State | Behavior | French Copy |
|---|---|---|
| Loading | Spinner + checking message | "Verification en cours..." |
| Valid | Green checkmark + auto-redirect (2s) | "Adresse e-mail confirmee !" |
| Expired | Message + resend button | "Ce lien a expire." + "Renvoyer un e-mail de verification" |
| Invalid | Message + link to registration | "Ce lien n'est pas valide." + "Retour a l'inscription" |
| Already verified | Message + login link | "Votre adresse est deja confirmee." + "Se connecter" |
| Resend success | Toast | "E-mail de verification renvoye !" |
| Resend limit reached | Toast | "Nombre maximum de renvois atteint. Veuillez reessayer dans 24 heures." |
Responsive Behavior¶
- 360px (mobile): Centered card on warm bg (#FFFDF7). Icon + message + action button stacked vertically.
- 1024px+ (desktop): Same centered card layout. Max-width 480px.
Interactions¶
- Page auto-calls
POST /api/v1/onboarding/verify-emailwith token from URL on mount - Valid → 2 second delay → auto-redirect to password creation (Keycloak) or payment page
- Expired → "Renvoyer" button calls
POST /api/v1/onboarding/resend-verification - Invalid → "Retour a l'inscription" link navigates to
/inscription - Already verified → "Se connecter" link navigates to Keycloak login
French micro-copy:
| Key | French Text |
|---|---|
verification.loading |
"Verification en cours..." |
verification.success |
"Adresse e-mail confirmee !" |
verification.expired |
"Ce lien a expire." |
verification.resend |
"Renvoyer un e-mail de verification" |
verification.resend_success |
"E-mail de verification renvoye !" |
verification.resend_limit |
"Nombre maximum de renvois atteint. Veuillez reessayer dans 24 heures." |
verification.invalid |
"Ce lien n'est pas valide." |
verification.back_to_register |
"Retour a l'inscription" |
verification.already_done |
"Votre adresse est deja confirmee." |
verification.login |
"Se connecter" |
Acceptance Criteria¶
AC-062: Free trial activation
Given I checked "Demarrer un essai gratuit de 14 jours" during registration
When I click the email verification link
Then my tenant status is TRIAL
And all selected modules are active
And I am redirected to password creation -> Dashboard
AC-063: Registration without trial
Given I did NOT check free trial
When I click the email verification link
Then my tenant status is PAYMENT_REQUIRED
And modules are read-only
And I am redirected to payment choice page (Step 3)
AC-067: Email verification token expires
Given I received a verification email
When I click the link after 24 hours
Then I see "Ce lien a expire." with a "Renvoyer" button
AC-068: Resend verification limit
Given I have already resent the verification email 3 times in 24 hours
When I try to resend again
Then the request is rejected
AC-069: Email verification token single-use enforcement
Given I received a verification email with a valid token
When I click the link and verify my email successfully
Then the token is marked as used (used_at set to current time)
When I try to click the same link again
Then I receive status "ALREADY_VERIFIED"
And I am shown "Votre adresse est deja confirmee." with a login link
OHADA & Regulatory Rules¶
- Loi 2013-450: Email verification is part of the consent flow. User must verify email before accessing the platform.
- No specific OHADA requirements for email verification.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §Application Events (TenantStatusChanged), §Token managementdocs/standards/api-guidelines.md— §Public endpoints, §Error responsesdocs/standards/react-typescript-guidelines.md— §Public pages, §Auto-redirect pattern
Testing Requirements¶
Unit Tests¶
- OnboardingService.verifyEmail: valid token + free trial → TRIAL status
- OnboardingService.verifyEmail: valid token + no trial → PAYMENT_REQUIRED status
- OnboardingService.verifyEmail: expired token → EXPIRED response
- OnboardingService.verifyEmail: invalid token → INVALID response
- OnboardingService.verifyEmail: already verified → ALREADY_VERIFIED response
- OnboardingService.resendVerification: success within limit
- OnboardingService.resendVerification: rejected at limit (4th attempt)
Integration Tests¶
- Full flow: verify email with valid token → tenant status updated
- Token expiry: verify after 24 hours → EXPIRED
- Resend: 3 resends succeed, 4th rejected
- TenantStatusChanged event published on successful verification
What QA Will Validate¶
- Email verification page: all 4 states render correctly
- Auto-redirect after successful verification
- Resend button works and shows success/limit toast
- Mobile and desktop layouts
Out of Scope¶
- Registration form (Step 1) — CP-008
- Module selection (Step 2) — CP-023
- Provisioning pipeline — CP-024 (triggered by TenantStatusChanged event from this slice)
- Payment flow — CP-009
- Password creation — Keycloak handles this
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360px, 768px, 1024px
- [ ] All 5 UI states implemented (loading, empty, error, offline, success)
- [ ] French micro-copy matches design spec exactly
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified — N/A (pre-tenant operation)
- [ ] Offline write+sync works — N/A (verification always online)
- [ ] No TypeScript
any— all types explicit - [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format
- [ ] Audit trail entries created for every data mutation