Story CP-009: Invoice Generation + Pricing Engine¶
Module: control-plane Slice: Slice 9 from architecture Brand context: Papillon HR Suite Priority: 9 Depends on: CP-001, CP-002, CP-003, CP-004, CP-005 Estimated complexity: L
Objective¶
Build the core billing engine: invoice generation with FNE-ready fields, pricing segments (S1–S6), platform fee calculation for S4–S6, and the provider-agnostic payment abstraction (with manual payment for MVP). Subscription management UI and billing cycle options are handled in CP-025 and CP-026.
Backend Scope¶
Entities & Records¶
Invoice entity (tenant DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| tenant_id | UUID | NOT NULL |
| invoice_number | String | NOT NULL, unique per tenant, format FAC-{YYYY}-{sequence} |
| invoice_date | LocalDate | NOT NULL |
| seller_name | String | NOT NULL ("ALTARYS ENTERPRISE") |
| seller_rccm | String | NOT NULL |
| seller_nif | String | NOT NULL |
| seller_address | String | NOT NULL |
| buyer_name | String | NOT NULL |
| buyer_rccm | String | Nullable |
| buyer_nif | String | Nullable |
| buyer_address | String | Nullable |
| subtotal_ht | BigDecimal | NOT NULL (zero decimal) |
| tva_rate | BigDecimal | NOT NULL (18% for CI) |
| tva_amount | BigDecimal | NOT NULL |
| total_ttc | BigDecimal | NOT NULL |
| qr_code | String | Nullable (empty until FNE DGI integration V2) |
| fne_fiscal_number | String | Nullable (empty until V2) |
| payment_status | InvoicePaymentStatus enum | NOT NULL |
| is_proforma | boolean | NOT NULL, default false (set at generation time based on current NIF status; never changed retroactively) |
| billing_period_start | LocalDate | NOT NULL |
| billing_period_end | LocalDate | NOT NULL |
| created_date | Instant | NOT NULL |
InvoicePaymentStatus: DRAFT, SENT, PAID, OVERDUE, CANCELLED
InvoiceLine entity (tenant DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| invoice_id | UUID | FK to Invoice |
| module_code | String | NOT NULL |
| module_name | String | NOT NULL |
| quantity | int | NOT NULL (employee count or 1) |
| unit_price | BigDecimal | NOT NULL |
| line_total | BigDecimal | NOT NULL |
| pack_discount_applied | boolean | default false |
Payment entity (tenant DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| tenant_id | UUID | NOT NULL |
| invoice_id | UUID | FK to Invoice |
| provider | String | NOT NULL (MOBILE_MONEY, BANK_TRANSFER, MANUAL) |
| amount | BigDecimal | NOT NULL |
| currency | String(3) | NOT NULL, "XOF" |
| status | PaymentStatus enum | NOT NULL |
| provider_reference | String | Nullable |
| confirmed_by_user_id | UUID | Nullable (for manual confirmation) |
| created_date | Instant | NOT NULL |
| confirmed_date | Instant | Nullable |
PaymentStatus: PENDING, PROCESSING, CONFIRMED, FAILED, CANCELLED
PricingSegment (reference data, in code or config):
| Segment | Employee Count | Model |
|---|---|---|
| S1 | 2–12 | Flat fee/month |
| S2 | 13–29 | Flat fee/month |
| S3 | 30–49 | Flat fee/month |
| S4 | 50–99 | Per-user/month + Platform Fee |
| S5 | 100–199 | Per-user/month + Platform Fee |
| S6 | 200–350 | Per-user/month + Platform Fee |
Platform Fee formula (S4–S6): FP = [1 + (x - 1) × 0.15] × base_fee / 12
where x = number of active modules
| Segment | Base Fee (annual) |
|---|---|
| S4 | 360,000 FCFA |
| S5 | 540,000 FCFA |
| S6 | 660,000 FCFA |
Invoice counter table (tenant DB):
| Field | Type |
|---|---|
| tenant_id | UUID, PK |
| fiscal_year | int, PK |
| next_value | int, NOT NULL, default 1 |
Repository Layer¶
InvoiceRepository.java— Tenant-aware: findById, findByTenantId(Pageable), saveInvoiceLineRepository.java— Tenant-aware: findByInvoiceIdPaymentRepository.java— Tenant-aware: findByInvoiceId, saveInvoiceCounterRepository.java— Tenant-aware: findByTenantIdAndFiscalYearForUpdate
Service Layer¶
BillingService.java:
- generateInvoice(UUID tenantId) → Invoice — Monthly invoice generation:
1. Snapshot active employee count → determine segment
2. Get active modules → calculate per-module prices for segment
3. Detect packs → apply best discount
4. Calculate platform fee (S4–S6 only)
5. Calculate subtotal HT, TVA (18%), total TTC
6. Generate invoice number via InvoiceNumberGenerator
7. Set is_proforma if tenant has no NIF
8. All amounts BigDecimal, zero decimal places (XOF)
PricingEngine.java:
- calculatePriceForSegment(String moduleCode, String segment) → BigDecimal
- detectPack(Set<String> activeModules) → Optional<PackResult> — Returns pack name + discounted price if a pack matches. If multiple packs match, returns the one with greatest discount. Pack definitions hardcoded for MVP (5 packs: Contrôle, Finance, Essentiel, Standard, Complet — see PRD FR-CP-022). DB-driven in V2.
- calculatePlatformFee(String segment, int activeModuleCount) → BigDecimal
InvoiceNumberGenerator.java:
- nextNumber(UUID tenantId, int fiscalYear) → String — FOR UPDATE on counter row → format FAC-{YYYY}-{zeroPadded(5)}. Gap-free (FNE requirement).
PaymentProvider interface:
public interface PaymentProvider {
PaymentSession initiatePayment(UUID invoiceId, BigDecimal amount, String currency, String returnUrl);
PaymentConfirmation handleWebhook(String webhookPayload);
PaymentStatus getPaymentStatus(String paymentSessionId);
}
ManualPaymentProvider.java — MVP implementation: records payment as confirmed by admin. MobileMoneyPaymentProvider.java — Stub for MVP: throws "Not yet implemented". Provider partner TBD.
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| GET | /api/v1/billing/invoices | — | Page |
ROLE_DG |
| GET | /api/v1/billing/invoices/{id}/pdf | — | PDF byte stream | ROLE_DG |
| POST | /api/v1/billing/payments/initiate | {invoiceId, provider} |
PaymentSessionResponse | ROLE_DG |
| POST | /api/v1/billing/payments/webhook | Provider payload | 200 | Public (webhook secret) |
Validation Rules¶
- All monetary amounts: BigDecimal, zero decimal places (XOF)
- Invoice number: sequential per fiscal year, gap-free (FOR UPDATE + single transaction)
- TVA rate: 18% for CI
- Platform fee: only applies to S4–S6
- Pack detection: automatic, applies best discount
Multi-Tenant Considerations¶
- Invoice and Payment entities in tenant DB
- Invoice counter in tenant DB (per-tenant sequential numbering)
- Pricing config (segment prices, pack rules) in platform DB or application config
Flyway Migrations¶
V007__create_invoice_payment.sql— Tenant DB: invoices, invoice_lines, payments tablesV008__create_counters.sql— Tenant DB: invoice_counters table
Frontend Scope¶
None for this story. Subscription dashboard and billing UI are in CP-025.
Acceptance Criteria¶
AC-074: Invoice generation with correct segment
Given a tenant with 25 active employees (segment S2) and modules ABSMGT + QRCONTR
When the monthly billing job runs
Then an invoice is generated with correct per-module prices for S2
And the Pack Contrôle discount is applied (if applicable)
AC-075: Platform fee calculation
Given a tenant in segment S4 (55 employees) with 3 active modules
When the invoice is generated
Then the platform fee is calculated as: [1 + (3-1) × 0.15] × 360,000/12 = 30,000 × 1.30 = 39,000 FCFA
AC-076: FNE-compliant invoice numbering
Given tenant A has invoice FAC-2026-00003
When the next invoice is generated
Then it is numbered FAC-2026-00004 (no gaps)
AC-077: Proforma for incomplete tenant
Given a tenant with status ACTIVE_INCOMPLETE (no NIF)
When an invoice is generated
Then it is flagged is_proforma=true and buyer_nif is empty
OHADA & Regulatory Rules¶
- FNE (Arrêté n° 0337, 9 mai 2025): All B2B invoices must be FNE-compliant. Gap-free sequential numbering is mandatory. Buyer NIF required for full compliance. QR code + fiscal number fields reserved for DGI API integration (V2).
- TVA 18%: Standard CI TVA rate. Applied on all invoices.
- XOF zero decimal: XOF and XAF are zero-decimal currencies. 15 000 FCFA, never 15 000,00.
- Invoice immutability: Invoices cannot be modified. Corrections via credit notes (new negative invoice).
- AUDCIF Art. 24: Invoices retained 10 years minimum.
- BigDecimal ONLY: All financial calculations use BigDecimal. Never double/float.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §BigDecimal for financials, §Counter tables (FOR UPDATE)docs/standards/database-guidelines.md— §Immutable records (invoices), §Counter tablesdocs/standards/api-guidelines.md— §Financial API patterns, §PDF generation
Testing Requirements¶
Unit Tests¶
- PricingEngine: segment determination for all 6 segments
- PricingEngine: pack detection for all 5 packs (Contrôle, Finance, Essentiel, Standard, Complet)
- PricingEngine: best discount selection when multiple packs match
- PricingEngine: platform fee formula for S4, S5, S6
- InvoiceNumberGenerator: sequential, gap-free
- BillingService: proforma flag when NIF missing
Integration Tests¶
- Invoice generation end-to-end: employee count → segment → pricing → invoice
- Invoice numbering: concurrent requests produce sequential numbers
- Payment flow: initiate → webhook → confirm → invoice PAID → tenant status update
What QA Will Validate¶
- Invoice list with correct statuses
- PDF download for invoices
- All amounts display with XOF formatting (space as thousands separator)
Out of Scope¶
- Subscription dashboard UI — CP-025
- Module add/remove from subscription — CP-025
- Billing cycle (annual/monthly) — CP-026
- XOF compliance tests — CP-026
- Mobile Money provider integration — Stub for MVP. Real provider TBD.
- Automatic payment retry — V2
- Mid-cycle proration — V2 (new segment applies on next billing cycle)
- DGI FNE API integration (QR code, fiscal number) — V2
- Credit notes — V2
- Admin payment confirmation UI — CP-012
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified (no cross-tenant data leaks)
- [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format
- [ ] Audit trail entries created for every data mutation