diff --git a/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md b/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md
new file mode 100644
index 0000000..be33e78
--- /dev/null
+++ b/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md
@@ -0,0 +1,1670 @@
+# B2B Billing and Invoice Execution Plan
+
+## 1. Objective
+
+Build a billing and invoice system that manages the financial lifecycle behind B2B subscriptions.
+
+The system must support:
+
+- Subscription invoices
+- Trial conversion invoices
+- Renewal invoices
+- One-time invoices
+- Usage-based invoices
+- Manual invoices
+- Payment collection
+- Payment retries
+- Credits
+- Refunds
+- Taxes
+- Discounts
+- Invoice adjustments
+- Failed payments
+- Invoice reconciliation
+- Revenue auditability
+- Admin billing operations
+
+The billing system must integrate with the subscription policy system but remain logically separate from subscription status.
+
+---
+
+## 2. Core Principle
+
+Subscription state and invoice state are related, but they are not the same thing.
+
+Bad design:
+
+```text
+subscription.status = invoice.status
+```
+
+Better design:
+
+```text
+subscription.status = active
+invoice.status = open
+payment.status = failed
+entitlement.access_level = full
+```
+
+A customer can have an active subscription and an unpaid invoice during a grace period.
+
+A customer can have a canceled subscription and still owe an unpaid invoice.
+
+A customer can have a paid invoice and no active subscription if the invoice was for a one-time service.
+
+Do not merge these concepts. That is how billing systems become haunted.
+
+---
+
+## 3. Core Billing Entities
+
+The billing system should use these primary objects:
+
+```text
+billing_account
+subscription
+invoice
+invoice_line_item
+payment_intent
+payment_attempt
+payment_method
+credit_balance
+credit_note
+refund
+tax_record
+billing_event
+```
+
+| Entity | Purpose |
+|---|---|
+| `billing_account` | Represents the paying customer or company |
+| `subscription` | Represents recurring service agreement |
+| `invoice` | Represents money owed |
+| `invoice_line_item` | Represents individual charges or credits |
+| `payment_intent` | Represents attempt to collect invoice payment |
+| `payment_attempt` | Represents each actual payment try |
+| `payment_method` | Represents card, ACH, bank transfer, invoice terms, etc. |
+| `credit_balance` | Tracks customer prepaid or credited amount |
+| `credit_note` | Reduces or reverses invoice amount |
+| `refund` | Returns money after payment |
+| `tax_record` | Stores calculated tax details |
+| `billing_event` | Immutable audit trail |
+
+---
+
+## 4. Billing Account Model
+
+A billing account represents the organization or legal entity responsible for payment.
+
+```sql
+CREATE TABLE billing_accounts (
+ id UUID PRIMARY KEY,
+
+ workspace_id UUID NOT NULL,
+ customer_id UUID NOT NULL,
+
+ legal_name TEXT NOT NULL,
+ billing_email TEXT NOT NULL,
+
+ billing_address_line1 TEXT,
+ billing_address_line2 TEXT,
+ billing_city TEXT,
+ billing_state TEXT,
+ billing_postal_code TEXT,
+ billing_country TEXT,
+
+ tax_id TEXT,
+ tax_exempt BOOLEAN DEFAULT FALSE,
+
+ default_currency TEXT NOT NULL DEFAULT 'USD',
+ default_payment_method_id UUID,
+
+ invoice_terms TEXT NOT NULL DEFAULT 'due_on_receipt',
+ net_terms_days INTEGER DEFAULT 0,
+
+ provider_customer_id TEXT,
+
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+Billing account rules:
+
+- A workspace should have one primary billing account.
+- Enterprise customers may have multiple billing accounts if departmental billing is supported.
+- A billing account owns invoices, payment methods, and credit balance.
+- Billing contact information must be snapshotted on finalized invoices.
+- Invoices should preserve historical billing details and must not dynamically read current billing address after creation.
+
+---
+
+## 5. Invoice Lifecycle
+
+Invoices should have their own state machine.
+
+```text
+draft
+open
+paid
+partially_paid
+payment_pending
+past_due
+void
+uncollectible
+refunded
+partially_refunded
+```
+
+| Status | Meaning |
+|---|---|
+| `draft` | Invoice is being prepared and is not payable yet |
+| `open` | Invoice is finalized and awaiting payment |
+| `payment_pending` | Payment has been initiated but not completed |
+| `partially_paid` | Some amount has been paid, but balance remains |
+| `paid` | Invoice is fully paid |
+| `past_due` | Invoice due date has passed |
+| `void` | Invoice was canceled before payment |
+| `uncollectible` | Invoice is written off as bad debt |
+| `refunded` | Fully paid invoice was fully refunded |
+| `partially_refunded` | Paid invoice was partially refunded |
+
+---
+
+## 6. Invoice State Transitions
+
+Happy path:
+
+```text
+draft
+ ↓
+open
+ ↓
+payment_pending
+ ↓
+paid
+```
+
+Failure flow:
+
+```text
+open
+ ↓
+payment_pending
+ ↓
+open
+ ↓
+past_due
+ ↓
+uncollectible
+```
+
+Void flow:
+
+```text
+draft/open
+ ↓
+void
+```
+
+Refund flow:
+
+```text
+paid
+ ↓
+partially_refunded
+ ↓
+refunded
+```
+
+| Current Status | Event | New Status | Notes |
+|---|---|---|---|
+| `draft` | Invoice finalized | `open` | Invoice becomes payable |
+| `draft` | Invoice canceled | `void` | No payment expected |
+| `open` | Payment started | `payment_pending` | Payment is in progress |
+| `open` | Full payment received | `paid` | Invoice closed |
+| `open` | Partial payment received | `partially_paid` | Balance remains |
+| `open` | Due date passed | `past_due` | Trigger dunning |
+| `payment_pending` | Payment succeeded | `paid` | Invoice closed |
+| `payment_pending` | Payment failed | `open` | Retry may continue |
+| `partially_paid` | Remaining balance paid | `paid` | Invoice closed |
+| `partially_paid` | Due date passed | `past_due` | Balance overdue |
+| `past_due` | Payment received | `paid` | Restore account if needed |
+| `past_due` | Written off | `uncollectible` | Bad debt |
+| `paid` | Partial refund issued | `partially_refunded` | Access review may be needed |
+| `paid` | Full refund issued | `refunded` | Access review required |
+| `open` | Admin voids invoice | `void` | Audit required |
+
+---
+
+## 7. Invoice Types
+
+```text
+subscription_initial
+subscription_renewal
+trial_conversion
+subscription_upgrade
+subscription_downgrade_credit
+usage_based
+one_time
+manual
+correction
+cancellation_fee
+```
+
+| Invoice Type | Description |
+|---|---|
+| `subscription_initial` | First paid invoice for a new subscription |
+| `subscription_renewal` | Recurring invoice for next billing period |
+| `trial_conversion` | Invoice generated when trial converts to paid |
+| `subscription_upgrade` | Prorated invoice for plan upgrade |
+| `subscription_downgrade_credit` | Credit issued for downgrade |
+| `usage_based` | Usage charges for metered products |
+| `one_time` | Non-recurring charge |
+| `manual` | Admin-created invoice |
+| `correction` | Accounting correction |
+| `cancellation_fee` | Fee after contract cancellation, if applicable |
+
+---
+
+## 8. Invoice Creation Policy
+
+### 8.1 Trial Conversion Invoice
+
+```text
+trialing subscription
+ ↓
+generate trial_conversion invoice
+ ↓
+attempt payment
+```
+
+Rules:
+
+- Generate invoice at trial end.
+- Include subscription plan line item.
+- Include tax if applicable.
+- Apply credits if available.
+- Apply discounts if valid.
+- Attempt payment immediately if payment method exists.
+- If payment succeeds, mark invoice `paid` and subscription `active`.
+- If payment fails, mark invoice `open` or `payment_pending` and subscription `payment_pending`.
+
+### 8.2 Renewal Invoice
+
+```text
+active subscription
+ ↓
+generate renewal invoice
+ ↓
+finalize invoice
+ ↓
+attempt payment
+```
+
+| Billing Model | Invoice Timing |
+|---|---|
+| Credit card / auto-pay | Generate and charge at renewal time |
+| ACH / bank debit | Generate 3-5 days before due date |
+| Net terms | Generate at period start, due later |
+| Enterprise contract | Generate according to contract schedule |
+
+Default SaaS rule:
+
+```text
+Generate renewal invoice at current_period_end.
+Charge immediately.
+```
+
+Default B2B enterprise rule:
+
+```text
+Generate invoice at period start.
+Due date = invoice_date + net_terms_days.
+```
+
+### 8.3 Upgrade Invoice
+
+```text
+active subscription
+ ↓
+calculate prorated charge
+ ↓
+generate upgrade invoice
+ ↓
+attempt payment
+```
+
+Recommended policy:
+
+- Upgrade takes effect immediately.
+- Charge prorated amount immediately.
+- If payment succeeds, apply upgraded entitlements.
+- If payment fails, keep existing plan active.
+- Do not remove already-paid access because an upgrade payment failed.
+
+### 8.4 Downgrade Invoice or Credit
+
+Recommended policy:
+
+```text
+Downgrade takes effect at next billing cycle.
+No immediate refund by default.
+```
+
+Alternative policy:
+
+```text
+Immediate downgrade creates account credit.
+```
+
+Preferred default:
+
+| Action | Policy |
+|---|---|
+| Downgrade | Effective next billing period |
+| Refund | No automatic refund |
+| Credit | Optional, admin-controlled |
+| Entitlement reduction | At period end |
+
+### 8.5 Manual Invoice
+
+Admins may create manual invoices for:
+
+- Custom services
+- Enterprise onboarding
+- Extra seats
+- Support packages
+- Contracted fees
+- Usage corrections
+- Migration adjustments
+
+Manual invoices require:
+
+```text
+admin_id
+reason
+line_items
+approval if over threshold
+audit event
+```
+
+---
+
+## 9. Invoice Line Items
+
+Every invoice must be composed of line items.
+
+```text
+subscription_fee
+seat_fee
+usage_fee
+setup_fee
+discount
+tax
+credit
+proration
+refund_adjustment
+manual_adjustment
+```
+
+```sql
+CREATE TABLE invoice_line_items (
+ id UUID PRIMARY KEY,
+
+ invoice_id UUID NOT NULL,
+ subscription_id UUID,
+ plan_id UUID,
+
+ type TEXT NOT NULL,
+ description TEXT NOT NULL,
+
+ quantity NUMERIC NOT NULL DEFAULT 1,
+ unit_amount INTEGER NOT NULL,
+ amount INTEGER NOT NULL,
+
+ currency TEXT NOT NULL,
+
+ period_start TIMESTAMP,
+ period_end TIMESTAMP,
+
+ metadata JSONB,
+
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+Rules:
+
+- Invoice totals must be derived from line items.
+- Never manually mutate invoice total without a corresponding line item.
+- Negative line items are allowed for discounts, credits, and adjustments.
+- Tax should be represented as a separate line item or tax record.
+- Historical line item descriptions should not change after invoice finalization.
+
+---
+
+## 10. Invoice Amount Calculation
+
+```text
+subtotal = sum(charge line items)
+discount_total = sum(discount line items)
+credit_total = sum(credit line items)
+tax_total = calculated tax
+total = subtotal - discount_total - credit_total + tax_total
+amount_due = total - amount_paid
+```
+
+Constraints:
+
+```text
+amount_due cannot be less than 0
+paid invoice cannot have amount_due > 0
+void invoice cannot accept payments
+draft invoice cannot accept payments
+```
+
+If credits exceed invoice total:
+
+```text
+invoice amount_due = 0
+remaining credit stays on billing account
+invoice may be marked paid
+```
+
+---
+
+## 11. Payment Lifecycle
+
+Payments should be tracked separately from invoices.
+
+```text
+requires_payment_method
+requires_confirmation
+requires_action
+processing
+succeeded
+failed
+canceled
+refunded
+partially_refunded
+```
+
+Payment flow:
+
+```text
+invoice.open
+ ↓
+payment_intent.created
+ ↓
+payment.processing
+ ↓
+payment.succeeded
+ ↓
+invoice.paid
+```
+
+Failure flow:
+
+```text
+payment.failed
+ ↓
+invoice.open
+ ↓
+retry scheduled
+ ↓
+subscription payment_pending / past_due
+```
+
+---
+
+## 12. Payment Methods
+
+Supported payment methods:
+
+```text
+card
+ach_debit
+bank_transfer
+wire_transfer
+manual_invoice
+purchase_order
+```
+
+| Customer Type | Payment Method |
+|---|---|
+| Self-serve SaaS | Card |
+| Mid-market | Card or ACH |
+| Enterprise | Invoice terms / ACH / wire |
+| High-risk customer | Card or upfront payment only |
+
+Rules:
+
+- Each billing account may have one default payment method.
+- Payment methods must be tokenized through the provider.
+- Do not store raw card or bank details.
+- Expired cards should trigger notification before renewal.
+- Failed payment method should not be retried indefinitely.
+
+---
+
+## 13. Net Terms Policy
+
+Supported terms:
+
+```text
+due_on_receipt
+net_7
+net_15
+net_30
+net_45
+net_60
+```
+
+Default:
+
+```text
+Self-serve: due_on_receipt
+B2B standard: net_15 or net_30
+Enterprise: contract-specific
+```
+
+Rules:
+
+- Net terms require approval above a revenue threshold.
+- Net terms customers may remain `active` while invoice is `open`.
+- Once due date passes, invoice becomes `past_due`.
+- Subscription state should only degrade after the configured grace policy.
+
+Example:
+
+```json
+{
+ "invoice_date": "2026-06-01T00:00:00Z",
+ "net_terms_days": 30,
+ "due_at": "2026-07-01T00:00:00Z"
+}
+```
+
+---
+
+## 14. Dunning and Payment Recovery
+
+Dunning is triggered by unpaid invoices.
+
+| Day | Invoice Status | Subscription Status | Action |
+|---:|---|---|---|
+| 0 | `open` | `active` or `payment_pending` | Notify billing owner |
+| 3 | `open` | `payment_pending` | Retry payment |
+| 7 | `past_due` | `past_due` | Limit access |
+| 14 | `past_due` | `suspended` | Suspend account |
+| 30 | `uncollectible` or `void` | `canceled` | Cancel subscription |
+
+Rules:
+
+- Dunning is invoice-driven.
+- Subscription status reacts to unpaid invoice state.
+- A customer may have multiple unpaid invoices.
+- The oldest unpaid invoice should control escalation.
+- Payment of all blocking invoices should restore subscription.
+- Admins may pause dunning for enterprise customers.
+
+---
+
+## 15. Credits and Credit Balance
+
+Credits should reduce future invoice amounts.
+
+Credit sources:
+
+```text
+downgrade credit
+service credit
+SLA credit
+manual goodwill credit
+overpayment
+refund converted to credit
+```
+
+```sql
+CREATE TABLE credit_balances (
+ id UUID PRIMARY KEY,
+
+ billing_account_id UUID NOT NULL,
+ currency TEXT NOT NULL,
+
+ balance_amount INTEGER NOT NULL DEFAULT 0,
+
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+```sql
+CREATE TABLE credit_ledger_entries (
+ id UUID PRIMARY KEY,
+
+ billing_account_id UUID NOT NULL,
+ invoice_id UUID,
+
+ type TEXT NOT NULL,
+ amount INTEGER NOT NULL,
+ currency TEXT NOT NULL,
+
+ reason TEXT NOT NULL,
+ created_by TEXT,
+
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+Credit rules:
+
+- Credits must be ledgered.
+- Credit balance cannot change without a ledger entry.
+- Credits should be currency-specific.
+- Credits apply before payment collection.
+- Credits should not silently expire unless terms clearly say so.
+- Enterprise credits may require approval.
+
+---
+
+## 16. Discounts and Coupons
+
+Discount types:
+
+```text
+percentage
+fixed_amount
+trial_extension
+contractual
+promotional
+manual
+```
+
+Rules:
+
+- Discounts must have start and end dates.
+- Discounts must define scope: invoice, subscription, plan, or account.
+- Discounts should not apply to taxes unless legally permitted.
+- Expired discounts must not apply to new invoices.
+- Manual discounts require admin reason.
+
+Example:
+
+```json
+{
+ "discount_type": "percentage",
+ "percent_off": 20,
+ "applies_to": "subscription",
+ "starts_at": "2026-06-01T00:00:00Z",
+ "ends_at": "2026-12-01T00:00:00Z"
+}
+```
+
+---
+
+## 17. Tax Policy
+
+Tax calculation should happen at invoice finalization.
+
+Requirements:
+
+- Use billing address for tax jurisdiction.
+- Use tax exemption status if present.
+- Store tax calculation result.
+- Store tax rate and jurisdiction.
+- Do not recalculate finalized invoice taxes unless issuing correction.
+- Support reverse charge or VAT if applicable.
+
+```sql
+CREATE TABLE tax_records (
+ id UUID PRIMARY KEY,
+
+ invoice_id UUID NOT NULL,
+
+ jurisdiction TEXT,
+ tax_rate NUMERIC,
+ tax_amount INTEGER NOT NULL,
+ tax_type TEXT,
+
+ tax_exempt BOOLEAN DEFAULT FALSE,
+ exemption_reason TEXT,
+
+ provider_tax_id TEXT,
+ metadata JSONB,
+
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+Recommended: integrate tax calculation through a provider if selling across jurisdictions. Tax logic is not the place to be brave.
+
+---
+
+## 18. Refund Policy
+
+Refunds reverse payments, not invoices.
+
+Refund types:
+
+```text
+full
+partial
+goodwill
+duplicate_payment
+service_issue
+fraud
+chargeback
+```
+
+Rules:
+
+- Refunds require a paid invoice.
+- Refunds must reference the original payment.
+- Refunds must create a refund event.
+- Full refund may require subscription cancellation or entitlement review.
+- Partial refund may leave subscription active.
+- Refund does not delete the invoice.
+- Refund does not erase revenue history.
+
+```sql
+CREATE TABLE refunds (
+ id UUID PRIMARY KEY,
+
+ invoice_id UUID NOT NULL,
+ payment_attempt_id UUID NOT NULL,
+ billing_account_id UUID NOT NULL,
+
+ amount INTEGER NOT NULL,
+ currency TEXT NOT NULL,
+
+ reason TEXT NOT NULL,
+ status TEXT NOT NULL,
+
+ provider_refund_id TEXT,
+ created_by TEXT,
+
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+| Refund Event | Invoice Impact | Subscription Impact |
+|---|---|---|
+| Partial refund | `partially_refunded` | Usually no change |
+| Full refund current period | `refunded` | Review access |
+| Fraud refund | `refunded` | Suspend or cancel |
+| Duplicate payment refund | Keep `paid` | No change |
+| SLA credit as refund | `partially_refunded` | No change |
+
+---
+
+## 19. Credit Notes
+
+Credit notes reduce the amount owed on an invoice.
+
+Use credit notes when:
+
+- Invoice was wrong
+- Customer received a contractual credit
+- Customer downgraded and receives credit
+- Tax correction is needed
+- Admin adjusts invoice before collection
+
+Do not use refunds when money was never collected.
+
+```sql
+CREATE TABLE credit_notes (
+ id UUID PRIMARY KEY,
+
+ invoice_id UUID NOT NULL,
+ billing_account_id UUID NOT NULL,
+
+ amount INTEGER NOT NULL,
+ currency TEXT NOT NULL,
+
+ reason TEXT NOT NULL,
+ status TEXT NOT NULL,
+
+ created_by TEXT,
+
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+---
+
+## 20. Chargeback Policy
+
+Chargebacks are high-risk and should override normal happy-path billing.
+
+```text
+chargeback.created
+ ↓
+invoice disputed
+ ↓
+subscription suspended
+ ↓
+admin/risk review
+```
+
+Rules:
+
+- Chargeback should immediately flag billing account.
+- Current subscription should become `suspended` unless enterprise exception applies.
+- Disable automatic reactivation until resolved.
+- Require admin review to restore.
+- Track dispute outcome.
+
+| Outcome | Action |
+|---|---|
+| Won | Restore invoice/payment state |
+| Lost | Keep invoice unpaid or refunded |
+| Withdrawn | Restore access after review |
+| Under review | Keep account restricted |
+
+---
+
+## 21. Reconciliation Policy
+
+The billing system must reconcile local records with the payment provider.
+
+Daily reconciliation should compare:
+
+- Provider customer records
+- Provider subscriptions
+- Provider invoices
+- Provider payments
+- Provider refunds
+- Provider disputes
+
+| Mismatch | Action |
+|---|---|
+| Provider invoice paid, local invoice open | Mark local invoice paid |
+| Provider payment failed, local payment processing | Mark failed |
+| Provider subscription canceled, local active | Flag for review |
+| Local invoice exists, provider missing | Flag critical |
+| Provider refund exists, local missing | Import refund |
+
+Rules:
+
+- Do not blindly overwrite local admin overrides.
+- Create reconciliation events.
+- Alert on high-risk mismatches.
+- Store provider snapshot for audit.
+
+---
+
+## 22. Billing Events
+
+Every financial action must create an immutable event.
+
+```text
+billing_account.created
+billing_account.updated
+
+invoice.created
+invoice.finalized
+invoice.sent
+invoice.payment_started
+invoice.payment_failed
+invoice.payment_succeeded
+invoice.partially_paid
+invoice.paid
+invoice.past_due
+invoice.voided
+invoice.marked_uncollectible
+
+payment_intent.created
+payment_attempt.created
+payment_attempt.succeeded
+payment_attempt.failed
+payment_method.updated
+
+credit.created
+credit.applied
+credit.expired
+credit.reversed
+
+discount.applied
+discount.expired
+
+tax.calculated
+tax.exempt_applied
+
+refund.created
+refund.succeeded
+refund.failed
+
+chargeback.created
+chargeback.won
+chargeback.lost
+
+billing.reconciled
+billing.reconciliation_failed
+admin.billing_override_created
+```
+
+---
+
+## 23. Admin Billing Console
+
+Admins need operational tools, but every button should leave fingerprints.
+
+Capabilities:
+
+- View billing account
+- View invoices
+- View invoice line items
+- View payments
+- View payment attempts
+- View credit balance
+- View credit ledger
+- View refunds
+- View chargebacks
+- Retry payment
+- Send invoice
+- Void invoice
+- Mark invoice uncollectible
+- Issue credit note
+- Issue refund
+- Apply discount
+- Update billing email
+- Update tax ID
+- Update billing address
+- Change net terms
+- Pause dunning
+- Resume dunning
+
+Audit fields:
+
+```json
+{
+ "admin_id": "admin_123",
+ "action": "void_invoice",
+ "invoice_id": "inv_123",
+ "reason": "Duplicate invoice generated during migration",
+ "previous_status": "open",
+ "new_status": "void",
+ "created_at": "2026-06-15T00:00:00Z"
+}
+```
+
+Require approval for:
+
+- Refunds above threshold
+- Manual credits above threshold
+- Changing enterprise net terms
+- Marking invoice uncollectible
+- Voiding paid invoices
+- Removing tax
+- Applying large discounts
+
+---
+
+## 24. Customer Billing Portal
+
+Customers should be able to:
+
+- View current plan
+- View billing account details
+- View invoices
+- Download invoices as PDF
+- Update payment method
+- Pay open invoices
+- View payment history
+- View credit balance
+- Update billing email
+- Update tax ID
+- Update billing address
+- Cancel subscription if permitted
+- Request plan change
+
+Do not expose internal statuses like:
+
+```text
+uncollectible
+dunning_paused
+provider_mismatch
+```
+
+Map them to customer-friendly labels.
+
+---
+
+## 25. Customer-Facing Invoice Labels
+
+| Internal Status | Customer Label |
+|---|---|
+| `draft` | Preparing |
+| `open` | Due |
+| `payment_pending` | Payment processing |
+| `paid` | Paid |
+| `partially_paid` | Partially paid |
+| `past_due` | Past due |
+| `void` | Canceled |
+| `uncollectible` | Contact support |
+| `refunded` | Refunded |
+| `partially_refunded` | Partially refunded |
+
+---
+
+## 26. Billing Notifications
+
+| Event | Recipient | Notification |
+|---|---|---|
+| Invoice finalized | Billing owner | Invoice available |
+| Invoice due soon | Billing owner | Upcoming due date |
+| Payment succeeded | Billing owner | Receipt |
+| Payment failed | Billing owner + admins | Payment failed |
+| Invoice past due | Billing owner + admins | Payment overdue |
+| Account suspended | Billing owner + admins | Access restricted |
+| Invoice paid after failure | Billing owner + admins | Payment recovered |
+| Refund issued | Billing owner | Refund confirmation |
+| Credit applied | Billing owner | Credit applied |
+
+Rules:
+
+- Every notification must include invoice number.
+- Payment failure notifications must include update-payment link.
+- Past due notifications must include deadline.
+- Enterprise invoices should include PO number if available.
+- Receipts should include amount paid and payment method summary.
+
+---
+
+## 27. Invoice Numbering
+
+Invoice numbers must be sequential and immutable.
+
+Recommended format:
+
+```text
+INV-2026-000001
+```
+
+Rules:
+
+- Invoice number assigned at finalization.
+- Draft invoices may not need invoice numbers.
+- Invoice numbers must never be reused.
+- Voided invoices keep their invoice numbers.
+- Region-specific sequencing may be needed for tax compliance.
+
+Fields:
+
+```sql
+invoice_number TEXT UNIQUE
+invoice_sequence INTEGER UNIQUE
+```
+
+Do not generate invoice numbers using random UUIDs. Accounting teams will hate you, and this time they will be right.
+
+---
+
+## 28. Data Model Summary
+
+### Invoices Table
+
+```sql
+CREATE TABLE invoices (
+ id UUID PRIMARY KEY,
+
+ billing_account_id UUID NOT NULL,
+ subscription_id UUID,
+ workspace_id UUID NOT NULL,
+
+ invoice_number TEXT UNIQUE,
+ invoice_type TEXT NOT NULL,
+ status TEXT NOT NULL,
+
+ currency TEXT NOT NULL,
+
+ subtotal_amount INTEGER NOT NULL DEFAULT 0,
+ discount_amount INTEGER NOT NULL DEFAULT 0,
+ credit_amount INTEGER NOT NULL DEFAULT 0,
+ tax_amount INTEGER NOT NULL DEFAULT 0,
+ total_amount INTEGER NOT NULL DEFAULT 0,
+ amount_paid INTEGER NOT NULL DEFAULT 0,
+ amount_due INTEGER NOT NULL DEFAULT 0,
+
+ invoice_date TIMESTAMP,
+ due_at TIMESTAMP,
+ finalized_at TIMESTAMP,
+ paid_at TIMESTAMP,
+ voided_at TIMESTAMP,
+ marked_uncollectible_at TIMESTAMP,
+
+ billing_name TEXT,
+ billing_email TEXT,
+ billing_address JSONB,
+
+ provider_invoice_id TEXT,
+
+ metadata JSONB,
+
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+### Payment Attempts Table
+
+```sql
+CREATE TABLE payment_attempts (
+ id UUID PRIMARY KEY,
+
+ invoice_id UUID NOT NULL,
+ billing_account_id UUID NOT NULL,
+
+ provider_payment_id TEXT,
+ payment_method_id UUID,
+
+ status TEXT NOT NULL,
+ amount INTEGER NOT NULL,
+ currency TEXT NOT NULL,
+
+ failure_code TEXT,
+ failure_message TEXT,
+
+ attempted_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+### Billing Events Table
+
+```sql
+CREATE TABLE billing_events (
+ id UUID PRIMARY KEY,
+
+ billing_account_id UUID,
+ invoice_id UUID,
+ subscription_id UUID,
+ workspace_id UUID,
+
+ event_type TEXT NOT NULL,
+ source TEXT NOT NULL,
+
+ payload JSONB NOT NULL,
+
+ occurred_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+---
+
+## 29. API Requirements
+
+### Create Invoice
+
+```http
+POST /billing/invoices
+```
+
+```json
+{
+ "billing_account_id": "ba_123",
+ "invoice_type": "manual",
+ "line_items": [
+ {
+ "type": "setup_fee",
+ "description": "Enterprise onboarding",
+ "quantity": 1,
+ "unit_amount": 500000
+ }
+ ]
+}
+```
+
+### Finalize Invoice
+
+```http
+POST /billing/invoices/{invoice_id}/finalize
+```
+
+Behavior:
+
+- Assign invoice number.
+- Calculate totals.
+- Calculate tax.
+- Apply credits.
+- Set status to `open`.
+- Send invoice if configured.
+
+### Pay Invoice
+
+```http
+POST /billing/invoices/{invoice_id}/pay
+```
+
+```json
+{
+ "payment_method_id": "pm_123"
+}
+```
+
+### Retry Payment
+
+```http
+POST /billing/invoices/{invoice_id}/retry-payment
+```
+
+### Void Invoice
+
+```http
+POST /billing/invoices/{invoice_id}/void
+```
+
+```json
+{
+ "reason": "Duplicate invoice"
+}
+```
+
+### Issue Credit Note
+
+```http
+POST /billing/invoices/{invoice_id}/credit-notes
+```
+
+```json
+{
+ "amount": 10000,
+ "reason": "Service credit"
+}
+```
+
+### Issue Refund
+
+```http
+POST /billing/invoices/{invoice_id}/refunds
+```
+
+```json
+{
+ "amount": 10000,
+ "reason": "Customer requested partial refund"
+}
+```
+
+---
+
+## 30. Scheduled Jobs
+
+| Job | Frequency | Purpose |
+|---|---:|---|
+| Invoice generation job | Hourly/Daily | Generate renewal invoices |
+| Invoice finalization job | Hourly | Finalize pending invoices |
+| Payment collection job | Hourly | Charge open invoices |
+| Payment retry job | Hourly | Retry failed payments |
+| Invoice due reminder job | Daily | Notify customers before due date |
+| Past due job | Hourly | Mark overdue invoices as past due |
+| Dunning escalation job | Daily | Escalate unpaid invoices |
+| Credit application job | On invoice finalization | Apply available credits |
+| Reconciliation job | Daily | Match provider and local state |
+| Tax sync job | Daily/On demand | Validate tax records |
+| Invoice PDF generation job | On finalization | Generate downloadable invoices |
+
+---
+
+## 31. Subscription Integration Rules
+
+Billing should drive subscription changes only through defined events.
+
+| Billing Event | Subscription Reaction |
+|---|---|
+| `invoice.paid` for trial conversion | `trialing → active` |
+| `invoice.payment_failed` for renewal | `active → payment_pending` |
+| `invoice.past_due` | `payment_pending → past_due` |
+| `invoice.marked_uncollectible` | `suspended → canceled` or admin review |
+| `refund.succeeded` full current period | Entitlement review |
+| `chargeback.created` | `active → suspended` |
+
+Important: not every invoice should affect subscription status.
+
+A manual invoice for onboarding should not cancel the subscription if unpaid unless policy says it is blocking.
+
+Add this field:
+
+```text
+is_subscription_blocking
+```
+
+Example:
+
+```json
+{
+ "invoice_type": "manual",
+ "is_subscription_blocking": false
+}
+```
+
+---
+
+## 32. Blocking vs Non-Blocking Invoices
+
+Blocking invoices can affect subscription access:
+
+```text
+subscription_initial
+subscription_renewal
+trial_conversion
+subscription_upgrade
+```
+
+Non-blocking invoices should not automatically affect access:
+
+```text
+manual
+setup_fee
+professional_services
+correction
+one_time
+```
+
+| Invoice Type | Blocks Subscription? |
+|---|---|
+| `subscription_initial` | Yes |
+| `trial_conversion` | Yes |
+| `subscription_renewal` | Yes |
+| `subscription_upgrade` | Usually no |
+| `manual` | Configurable |
+| `one_time` | No by default |
+| `usage_based` | Configurable |
+| `setup_fee` | No by default |
+| `cancellation_fee` | No, subscription already ending |
+
+---
+
+## 33. Accounting Rules
+
+### Immutability
+
+Once finalized:
+
+- Invoice number cannot change.
+- Invoice date cannot change.
+- Line items cannot be edited directly.
+- Totals cannot be edited directly.
+- Billing address snapshot cannot be edited directly.
+
+Corrections require:
+
+```text
+credit note
+adjustment invoice
+refund
+void if unpaid
+```
+
+### Revenue Integrity
+
+- Paid invoices remain in history.
+- Refunded invoices remain in history.
+- Voided invoices remain in history.
+- Deleted invoices should not exist in production.
+- Admin changes must be audited.
+
+---
+
+## 34. Testing Plan
+
+### Unit Tests
+
+Test:
+
+- Invoice total calculation
+- Tax calculation hooks
+- Credit application
+- Discount application
+- Invoice state transitions
+- Payment state transitions
+- Refund rules
+- Credit note rules
+- Net terms due date logic
+- Blocking invoice logic
+
+### Integration Tests
+
+Test:
+
+- Create invoice
+- Finalize invoice
+- Pay invoice
+- Failed payment
+- Retry payment
+- Apply credit
+- Issue refund
+- Void invoice
+- Reconcile provider payment
+- Handle duplicate webhook
+- Handle out-of-order webhook
+
+### End-to-End Tests
+
+Test:
+
+```text
+Trial ends → invoice created → payment succeeds → subscription active
+Trial ends → invoice created → payment fails → subscription payment_pending
+Renewal invoice fails → dunning starts → subscription past_due
+Past due invoice paid → subscription restored
+Enterprise invoice created with net_30 → due date passes → past_due
+Upgrade invoice fails → existing subscription remains active
+Full refund current period → entitlement review triggered
+Chargeback created → subscription suspended
+```
+
+---
+
+## 35. Monitoring and Alerts
+
+Track:
+
+```text
+invoice_created_count
+invoice_finalized_count
+invoice_paid_count
+invoice_failed_count
+invoice_past_due_count
+invoice_voided_count
+invoice_uncollectible_count
+payment_attempt_count
+payment_success_rate
+payment_failure_rate
+refund_count
+credit_note_count
+reconciliation_mismatch_count
+average_days_to_payment
+accounts_receivable_total
+past_due_amount_total
+```
+
+Alert on:
+
+```text
+Payment failure spike
+Invoices stuck in payment_pending
+Invoices overdue beyond policy
+Provider/local invoice mismatch
+Provider/local payment mismatch
+Refund spike
+Credit abuse
+Chargeback created
+Invoice finalization failure
+Tax calculation failure
+```
+
+---
+
+## 36. Rollout Plan
+
+### Phase 1: Invoice Foundation
+
+Deliver:
+
+- Billing account model
+- Invoice model
+- Invoice line items
+- Invoice statuses
+- Invoice total calculation
+- Basic invoice admin view
+
+Acceptance criteria:
+
+- Admin can create draft invoice.
+- Admin can finalize invoice.
+- Finalized invoice gets immutable number.
+- Invoice totals are derived from line items.
+
+### Phase 2: Payment Collection
+
+Deliver:
+
+- Payment method support
+- Payment intent model
+- Payment attempt model
+- Payment success handling
+- Payment failure handling
+
+Acceptance criteria:
+
+- Open invoice can be paid.
+- Failed payment creates payment attempt.
+- Successful payment marks invoice paid.
+- Payment event is auditable.
+
+### Phase 3: Subscription Billing Integration
+
+Deliver:
+
+- Trial conversion invoices
+- Renewal invoices
+- Initial subscription invoices
+- Blocking invoice logic
+- Subscription reaction events
+
+Acceptance criteria:
+
+- Trial conversion payment activates subscription.
+- Renewal failure moves subscription to payment_pending.
+- Invoice payment restores subscription when applicable.
+- Non-blocking invoices do not affect subscription.
+
+### Phase 4: Dunning and Net Terms
+
+Deliver:
+
+- Due dates
+- Net terms
+- Past due logic
+- Payment retries
+- Dunning notifications
+- Subscription escalation rules
+
+Acceptance criteria:
+
+- Net terms invoices become past due after due date.
+- Failed payments retry on schedule.
+- Blocking unpaid invoices escalate subscription status.
+- Admin can pause dunning.
+
+### Phase 5: Credits, Refunds, Adjustments
+
+Deliver:
+
+- Credit balance ledger
+- Credit note support
+- Refund support
+- Discount support
+- Adjustment invoice support
+
+Acceptance criteria:
+
+- Credits apply to future invoices.
+- Credit balance is ledgered.
+- Refunds reference original payments.
+- Finalized invoices are corrected through credit notes or adjustments.
+
+### Phase 6: Tax and Compliance
+
+Deliver:
+
+- Tax calculation integration
+- Tax records
+- Tax exemption handling
+- Invoice PDF generation
+- Invoice address snapshots
+
+Acceptance criteria:
+
+- Tax calculated at finalization.
+- Tax result is stored.
+- Finalized invoice preserves billing details.
+- Customer can download invoice PDF.
+
+### Phase 7: Reconciliation and Hardening
+
+Deliver:
+
+- Provider reconciliation
+- Webhook replay
+- Dead-letter queue
+- Mismatch alerts
+- Admin correction workflows
+
+Acceptance criteria:
+
+- Provider and local states stay aligned.
+- Failed webhooks are replayable.
+- Mismatches create alerts.
+- Reconciliation does not overwrite valid admin decisions.
+
+---
+
+## 37. Final Default Billing Policy
+
+Use this as the baseline B2B billing policy:
+
+```text
+Invoices are generated from subscriptions, usage, or admin action.
+Invoices start as draft and become payable only after finalization.
+Invoice numbers are assigned only at finalization.
+Finalized invoices are immutable.
+Subscription invoices are blocking by default.
+Manual invoices are non-blocking by default.
+Credits are applied before payment collection.
+Tax is calculated during finalization.
+Payment failures create payment attempts and dunning events.
+Payment retries follow configured schedule.
+Open invoices become past_due after due date.
+Blocking past_due invoices affect subscription status.
+Refunds reverse payments, not invoices.
+Credit notes reduce invoice balances before or after finalization depending on accounting rules.
+All billing changes are event-driven and audited.
+Daily reconciliation compares local billing state with payment provider state.
+```
+
+---
+
+## 38. Definition of Done
+
+The billing and invoice system is complete when:
+
+- Billing accounts exist and own invoices.
+- Invoices have a clear lifecycle.
+- Invoice totals are line-item driven.
+- Finalized invoices are immutable.
+- Invoice numbers are sequential and permanent.
+- Payment attempts are tracked.
+- Failed payments trigger dunning.
+- Paid invoices update subscription status when appropriate.
+- Non-blocking invoices do not affect access.
+- Credits and refunds are ledgered.
+- Admin billing actions are audited.
+- Customers can view and pay invoices.
+- Provider reconciliation is operational.
+- Tests cover invoice, payment, refund, and subscription integration flows.
diff --git a/NOTIFICATION_LOCALIZATION_POLICY.md b/NOTIFICATION_LOCALIZATION_POLICY.md
new file mode 100644
index 0000000..41b8b24
--- /dev/null
+++ b/NOTIFICATION_LOCALIZATION_POLICY.md
@@ -0,0 +1,739 @@
+# Notification Language and Localization Policy
+
+## 1. Objective
+
+The notification system must send all customer-facing notifications in the language selected by the user during account creation.
+
+Supported initial languages:
+
+```text
+EN
+AR
+FR
+```
+
+Language mapping:
+
+| User Selection | Locale Code | Notification Language |
+|---|---|---|
+| `EN` | `en` | English |
+| `AR` | `ar` | Arabic |
+| `FR` | `fr` | French |
+
+This applies to account creation, workspace creation, invitations, trial notifications, subscription status notifications, billing notifications, invoice notifications, payment failure notifications, refunds, credits, in-app notifications, email notifications, SMS notifications if enabled, and customer-facing webhook labels if applicable.
+
+Internal admin alerts may use the internal team’s default language unless configured otherwise.
+
+---
+
+## 2. Source of Truth for Notification Language
+
+The user’s selected language at account creation must be stored and used as the primary language preference.
+
+### Required User Field
+
+```sql
+ALTER TABLE users
+ADD COLUMN preferred_language TEXT NOT NULL DEFAULT 'en';
+```
+
+Allowed values:
+
+```text
+en
+ar
+fr
+```
+
+### Account Creation Requirement
+
+During account creation, the user must select or confirm a language.
+
+Example payload:
+
+```json
+{
+ "email": "user@example.com",
+ "name": "Example User",
+ "preferred_language": "ar"
+}
+```
+
+Rules:
+
+- `preferred_language` is required at account creation.
+- If not provided, default to `en`.
+- The selected language must be saved on the user profile.
+- The selected language must be used for all customer-facing notifications.
+- Users may update their preferred language later from account settings.
+- Updating the language affects future notifications only, not historical notifications.
+
+---
+
+## 3. Language Resolution Policy
+
+When sending a notification, resolve the language in this order:
+
+```text
+recipient user preferred_language
+workspace default_language
+billing account preferred_language
+organization default_language
+system default_language
+```
+
+Default:
+
+```text
+en
+```
+
+Example:
+
+```text
+User preferred_language = ar
+Workspace default_language = fr
+System default_language = en
+
+Selected notification language = ar
+```
+
+The recipient’s own language wins. Not the workspace. Not the billing account. Not a hardcoded backend default.
+
+---
+
+## 4. Workspace and Billing Language Fields
+
+In addition to the user’s language, store default language on workspace and billing account.
+
+### Workspace Field
+
+```sql
+ALTER TABLE workspaces
+ADD COLUMN default_language TEXT NOT NULL DEFAULT 'en';
+```
+
+### Billing Account Field
+
+```sql
+ALTER TABLE billing_accounts
+ADD COLUMN preferred_language TEXT NOT NULL DEFAULT 'en';
+```
+
+Purpose:
+
+| Field | Purpose |
+|---|---|
+| `users.preferred_language` | Primary language for direct user notifications |
+| `workspaces.default_language` | Fallback for workspace-level notifications |
+| `billing_accounts.preferred_language` | Fallback for finance/billing notifications |
+| `system.default_language` | Final fallback |
+
+---
+
+## 5. Template Localization Policy
+
+Every customer-facing notification template must exist in all supported languages.
+
+Required locales:
+
+```text
+en
+ar
+fr
+```
+
+### Template Table Update
+
+```sql
+CREATE TABLE notification_templates (
+ id UUID PRIMARY KEY,
+
+ template_key TEXT NOT NULL,
+ category TEXT NOT NULL,
+ channel TEXT NOT NULL,
+
+ locale TEXT NOT NULL,
+
+ subject TEXT,
+ body TEXT NOT NULL,
+
+ required_variables JSONB,
+ optional_variables JSONB,
+
+ version INTEGER NOT NULL DEFAULT 1,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
+
+ UNIQUE(template_key, channel, locale, version)
+);
+```
+
+Preferred template structure:
+
+```text
+template_key = account.created.email
+locale = en
+
+template_key = account.created.email
+locale = ar
+
+template_key = account.created.email
+locale = fr
+```
+
+Do not encode the locale into the template key if the table already has a `locale` column. Duplicating state is how small mistakes become expensive folklore.
+
+---
+
+## 6. Template Lookup Policy
+
+When rendering a notification:
+
+```text
+1. Resolve recipient language.
+2. Find active template for template_key + channel + resolved locale.
+3. If unavailable, fall back to English.
+4. If English template is unavailable, fail notification creation and alert admins.
+```
+
+Lookup flow:
+
+```text
+event received
+ ↓
+resolve recipient
+ ↓
+resolve recipient language
+ ↓
+find localized template
+ ↓
+render template
+ ↓
+send notification
+```
+
+Fallback rules:
+
+| Condition | Action |
+|---|---|
+| `ar` template exists | Send Arabic notification |
+| `fr` template exists | Send French notification |
+| Requested locale missing | Fall back to English |
+| English fallback missing | Fail and alert internal admins |
+| Template variables missing | Fail and alert internal admins |
+
+Fallback to English is allowed only as a safety net. It should be monitored as a product defect.
+
+---
+
+## 7. Arabic Language Requirements
+
+Arabic notifications must support right-to-left rendering.
+
+For locale:
+
+```text
+ar
+```
+
+Apply:
+
+```html
+
+```
+
+or for partial rendering:
+
+```html
+
+ ...
+
+```
+
+Arabic requirements:
+
+- Use RTL layout for email and in-app notifications.
+- Align Arabic text to the right.
+- Use Arabic translations for all customer-facing labels.
+- Use localized date formatting where possible.
+- Keep invoice numbers, amounts, and technical IDs readable.
+- Avoid mixing English UI labels into Arabic notifications unless they are brand or product names.
+- Test Arabic templates separately.
+
+---
+
+## 8. French Language Requirements
+
+For locale:
+
+```text
+fr
+```
+
+Apply:
+
+```html
+
+```
+
+French requirements:
+
+- Use French templates for all customer-facing messages.
+- Use localized date formatting.
+- Use localized money formatting where appropriate.
+- Avoid partial English/French mixed messages.
+- Keep plan names and product names unchanged unless officially translated.
+
+---
+
+## 9. English Language Requirements
+
+For locale:
+
+```text
+en
+```
+
+Apply:
+
+```html
+
+```
+
+English is the default fallback language.
+
+---
+
+## 10. Localized Formatting Policy
+
+Notifications should localize:
+
+- Subject lines
+- Body text
+- Button labels
+- Status labels
+- Dates
+- Currency display
+- Invoice labels
+- Subscription status labels
+- Error messages
+- Call-to-action text
+
+Date formatting examples:
+
+| Locale | Display |
+|---|---|
+| `en` | June 15, 2026 |
+| `fr` | 15 juin 2026 |
+| `ar` | ١٥ يونيو ٢٠٢٦ |
+
+Currency formatting should respect both billing currency and locale.
+
+Example for USD:
+
+| Locale | Display |
+|---|---|
+| `en` | $1,200.00 |
+| `fr` | 1 200,00 $US |
+| `ar` | ١٬٢٠٠٫٠٠ US$ |
+
+Do not store localized amounts as the source of truth. Store money as integer minor units and format at render time.
+
+---
+
+## 11. Localized Subscription Status Labels
+
+### English
+
+| Internal Status | English Label |
+|---|---|
+| `trialing` | Trial active |
+| `active` | Active |
+| `payment_pending` | Payment pending |
+| `past_due` | Payment overdue |
+| `suspended` | Suspended |
+| `canceled` | Canceled |
+| `expired` | Expired |
+
+### French
+
+| Internal Status | French Label |
+|---|---|
+| `trialing` | Essai actif |
+| `active` | Actif |
+| `payment_pending` | Paiement en attente |
+| `past_due` | Paiement en retard |
+| `suspended` | Suspendu |
+| `canceled` | Annulé |
+| `expired` | Expiré |
+
+### Arabic
+
+| Internal Status | Arabic Label |
+|---|---|
+| `trialing` | الفترة التجريبية نشطة |
+| `active` | نشط |
+| `payment_pending` | الدفع قيد الانتظار |
+| `past_due` | الدفع متأخر |
+| `suspended` | معلّق |
+| `canceled` | ملغى |
+| `expired` | منتهي |
+
+---
+
+## 12. Localized Invoice Labels
+
+### English
+
+| Internal Invoice Status | English Label |
+|---|---|
+| `draft` | Preparing |
+| `open` | Due |
+| `payment_pending` | Payment processing |
+| `paid` | Paid |
+| `partially_paid` | Partially paid |
+| `past_due` | Past due |
+| `void` | Canceled |
+| `uncollectible` | Contact support |
+| `refunded` | Refunded |
+| `partially_refunded` | Partially refunded |
+
+### French
+
+| Internal Invoice Status | French Label |
+|---|---|
+| `draft` | En préparation |
+| `open` | À payer |
+| `payment_pending` | Paiement en cours |
+| `paid` | Payée |
+| `partially_paid` | Partiellement payée |
+| `past_due` | En retard |
+| `void` | Annulée |
+| `uncollectible` | Contacter le support |
+| `refunded` | Remboursée |
+| `partially_refunded` | Partiellement remboursée |
+
+### Arabic
+
+| Internal Invoice Status | Arabic Label |
+|---|---|
+| `draft` | قيد الإعداد |
+| `open` | مستحقة الدفع |
+| `payment_pending` | الدفع قيد المعالجة |
+| `paid` | مدفوعة |
+| `partially_paid` | مدفوعة جزئياً |
+| `past_due` | متأخرة |
+| `void` | ملغاة |
+| `uncollectible` | تواصل مع الدعم |
+| `refunded` | مستردة |
+| `partially_refunded` | مستردة جزئياً |
+
+---
+
+## 13. Notification Creation Logic
+
+When an event creates a notification, include the resolved locale.
+
+### Notification Record Update
+
+```sql
+ALTER TABLE notifications
+ADD COLUMN locale TEXT NOT NULL DEFAULT 'en';
+```
+
+Example notification record:
+
+```json
+{
+ "event_type": "invoice.finalized",
+ "recipient_email": "finance@example.com",
+ "recipient_role": "billing_owner",
+ "channel": "email",
+ "template_key": "invoice.finalized.email",
+ "locale": "fr",
+ "status": "pending"
+}
+```
+
+---
+
+## 14. Notification Rendering Pseudocode
+
+```ts
+type SupportedLocale = "en" | "ar" | "fr";
+
+function resolveNotificationLocale(recipient, workspace, billingAccount): SupportedLocale {
+ return (
+ recipient.preferredLanguage ||
+ workspace.defaultLanguage ||
+ billingAccount.preferredLanguage ||
+ "en"
+ );
+}
+
+async function createNotification(event, recipient) {
+ const locale = resolveNotificationLocale(
+ recipient.user,
+ event.workspace,
+ event.billingAccount
+ );
+
+ const template = await findTemplate({
+ templateKey: event.templateKey,
+ channel: event.channel,
+ locale
+ });
+
+ const fallbackTemplate = await findTemplate({
+ templateKey: event.templateKey,
+ channel: event.channel,
+ locale: "en"
+ });
+
+ const selectedTemplate = template || fallbackTemplate;
+
+ if (!selectedTemplate) {
+ throw new Error("Missing notification template");
+ }
+
+ return {
+ eventType: event.type,
+ recipientEmail: recipient.email,
+ channel: event.channel,
+ templateKey: event.templateKey,
+ locale: selectedTemplate.locale,
+ renderedContent: renderTemplate(selectedTemplate, event.payload)
+ };
+}
+```
+
+---
+
+## 15. Account Creation Flow Update
+
+During account creation:
+
+```text
+user selects language
+ ↓
+system stores preferred_language
+ ↓
+account.created event emitted
+ ↓
+notification system resolves preferred_language
+ ↓
+localized welcome notification sent
+```
+
+### Account Creation API Update
+
+```http
+POST /accounts
+```
+
+Request:
+
+```json
+{
+ "name": "Example User",
+ "email": "user@example.com",
+ "password": "secure_password",
+ "preferred_language": "ar"
+}
+```
+
+Response:
+
+```json
+{
+ "account_id": "acct_123",
+ "user_id": "user_123",
+ "preferred_language": "ar"
+}
+```
+
+Validation:
+
+```text
+preferred_language must be one of: en, ar, fr
+```
+
+---
+
+## 16. Notification Preference Update
+
+Language preference should be separate from notification category preferences.
+
+Example user settings:
+
+```text
+preferred_language = ar
+invoice.email.enabled = true
+billing.email.enabled = true
+onboarding.email.enabled = false
+```
+
+Rules:
+
+- Language controls the language of notifications.
+- Preferences control whether allowed notifications are sent.
+- Critical billing, invoice, payment, and security notifications still follow mandatory delivery rules.
+- Changing language does not unsubscribe the user from notifications.
+- Changing language affects future notifications only.
+
+---
+
+## 17. Testing Requirements
+
+### Unit Tests
+
+Test:
+
+```text
+User with preferred_language=en receives English template
+User with preferred_language=ar receives Arabic template
+User with preferred_language=fr receives French template
+Missing Arabic template falls back to English
+Missing French template falls back to English
+Missing English fallback fails notification creation
+Arabic email renders with dir="rtl"
+French email renders with lang="fr"
+English email renders with lang="en"
+```
+
+### Integration Tests
+
+Test:
+
+```text
+Account created with EN → welcome email sent in English
+Account created with AR → welcome email sent in Arabic
+Account created with FR → welcome email sent in French
+Invoice finalized for AR billing owner → Arabic invoice notification
+Payment failed for FR billing owner → French payment failure notification
+Subscription suspended for EN admin → English suspension notification
+```
+
+### End-to-End Tests
+
+Test:
+
+```text
+User creates account and selects Arabic
+ ↓
+Arabic welcome email is sent
+ ↓
+User starts trial
+ ↓
+Arabic trial notification is sent
+ ↓
+Invoice is generated
+ ↓
+Arabic invoice notification is sent
+ ↓
+Payment fails
+ ↓
+Arabic payment failure notification is sent
+```
+
+---
+
+## 18. Monitoring Requirements
+
+Track localization metrics:
+
+```text
+notifications_sent_by_locale
+notifications_failed_by_locale
+template_missing_by_locale
+fallback_to_english_count
+rtl_rendering_test_failures
+language_preference_update_count
+```
+
+Alert on:
+
+```text
+Missing Arabic template
+Missing French template
+Fallback-to-English spike
+Arabic rendering failure
+Localized template variable error
+```
+
+Fallback-to-English should be treated as a defect, not a harmless convenience.
+
+---
+
+## 19. Updated Notification Localization Section
+
+Replace the earlier notification localization section with this stricter version.
+
+### Notification Localization
+
+Notification templates must support the user’s selected account language.
+
+Supported locales:
+
+```text
+en
+ar
+fr
+```
+
+Template lookup order:
+
+```text
+recipient user preferred_language
+workspace default_language
+billing account preferred_language
+system default_language
+```
+
+The language selected during account creation is the primary source of truth for user-facing notifications.
+
+Arabic notifications must render in RTL mode.
+
+French and English notifications must use localized date, currency, and status labels.
+
+Fallback to English is allowed only when a localized template is missing, and every fallback must create an internal alert.
+
+---
+
+## 20. Final Localization Policy
+
+Use this as the baseline rule:
+
+```text
+The language selected by the user during account creation is the primary language for all customer-facing notifications.
+Supported languages are English, Arabic, and French.
+English uses locale en.
+Arabic uses locale ar and must render right-to-left.
+French uses locale fr.
+Each customer-facing notification template must exist in en, ar, and fr.
+If a localized template is missing, the system may fall back to English, but must log and alert the missing localization.
+Billing, invoice, subscription, and account notifications must use the recipient’s preferred language.
+Language preference is separate from notification opt-in preferences.
+Changing language affects future notifications only.
+Internal admin alerts may use the internal default language unless configured otherwise.
+```
+
+---
+
+## 21. Definition of Done
+
+The notification localization system is complete when:
+
+- Account creation stores `preferred_language`.
+- Supported languages are limited to `en`, `ar`, and `fr`.
+- Notification records store the resolved locale.
+- Email templates exist in English, Arabic, and French.
+- In-app notification templates exist in English, Arabic, and French.
+- Arabic templates render correctly in RTL.
+- French templates render with French labels and formatting.
+- Dates and currency are localized during rendering.
+- Missing localized templates fall back to English and create alerts.
+- Tests cover account, subscription, billing, invoice, and payment notifications for all supported languages.
diff --git a/apps/admin/src/app/dashboard/pricing/page.tsx b/apps/admin/src/app/dashboard/pricing/page.tsx
index 457ac34..b55b395 100644
--- a/apps/admin/src/app/dashboard/pricing/page.tsx
+++ b/apps/admin/src/app/dashboard/pricing/page.tsx
@@ -3,6 +3,8 @@
import { useEffect, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
+// ─── Types ─────────────────────────────────────────────────────────────────
+
interface PricingConfig {
id: string
plan: string
@@ -12,22 +14,78 @@ interface PricingConfig {
updatedBy: string | null
}
-type Draft = Record
+interface Promotion {
+ id: string
+ code: string
+ name: string
+ description: string | null
+ discountType: 'PERCENTAGE' | 'FIXED'
+ discountValue: number
+ plans: string[]
+ periods: string[]
+ maxUses: number | null
+ usedCount: number
+ validFrom: string
+ validUntil: string | null
+ isActive: boolean
+ createdAt: string
+}
+
+interface PlanFeature {
+ id: string
+ plan: string
+ label: string
+ sortOrder: number
+ createdAt: string
+ updatedAt: string
+}
+
+type PricingDraft = Record
+
+type PromoForm = {
+ code: string
+ name: string
+ description: string
+ discountType: 'PERCENTAGE' | 'FIXED'
+ discountValue: string
+ plans: string[]
+ periods: string[]
+ maxUses: string
+ validFrom: string
+ validUntil: string
+ isActive: boolean
+}
+
+type PlanFeatureForm = {
+ plan: string
+ label: string
+ sortOrder: string
+}
+
+// ─── Constants ─────────────────────────────────────────────────────────────
const PLANS = ['STARTER', 'GROWTH', 'PRO']
const PERIODS = ['MONTHLY', 'ANNUAL']
const PLAN_COLORS: Record = {
STARTER: 'text-zinc-400',
- GROWTH: 'text-sky-400',
- PRO: 'text-violet-400',
+ GROWTH: 'text-sky-400',
+ PRO: 'text-violet-400',
}
-function key(plan: string, period: string) {
+const PLAN_BADGE: Record = {
+ STARTER: 'bg-zinc-800 text-zinc-300',
+ GROWTH: 'bg-sky-900/40 text-sky-300',
+ PRO: 'bg-violet-900/40 text-violet-300',
+}
+
+// ─── Helpers ───────────────────────────────────────────────────────────────
+
+function pricingKey(plan: string, period: string) {
return `${plan}:${period}`
}
-function fmt(amount: number) {
+function fmtCentimes(amount: number) {
return (amount / 100).toFixed(2)
}
@@ -37,18 +95,916 @@ function parseCentimes(value: string): number | null {
return Math.round(n * 100)
}
+function fmtDate(iso: string | null) {
+ if (!iso) return '—'
+ return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
+}
+
+function toDateInputValue(iso: string | null) {
+ if (!iso) return ''
+ return iso.slice(0, 10)
+}
+
+function emptyPromoForm(): PromoForm {
+ return {
+ code: '', name: '', description: '',
+ discountType: 'PERCENTAGE', discountValue: '',
+ plans: [], periods: [],
+ maxUses: '', validFrom: toDateInputValue(new Date().toISOString()),
+ validUntil: '', isActive: true,
+ }
+}
+
+function emptyPlanFeatureForm(plan = PLANS[0]): PlanFeatureForm {
+ return {
+ plan,
+ label: '',
+ sortOrder: '0',
+ }
+}
+
+function authHeaders(): Record {
+ const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null
+ return token ? { Authorization: `Bearer ${token}` } : {}
+}
+
+function sortFeatures(list: PlanFeature[]) {
+ return [...list].sort((a, b) => {
+ const planDiff = PLANS.indexOf(a.plan) - PLANS.indexOf(b.plan)
+ if (planDiff !== 0) return planDiff
+ if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
+ return a.label.localeCompare(b.label)
+ })
+}
+
+// ─── Pricing table ──────────────────────────────────────────────────────────
+
+function PricingTable({
+ configs, draft, onChange, onSave, saving, error, successMsg,
+}: {
+ configs: PricingConfig[]
+ draft: PricingDraft
+ onChange: (key: string, val: string) => void
+ onSave: () => void
+ saving: boolean
+ error: string
+ successMsg: string
+}) {
+ const lastUpdated = configs
+ .filter((c) => c.updatedAt)
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
+
+ return (
+
+
+
+
Plan prices
+
+ Enter values in MAD — stored as centimes internally.
+
+ {lastUpdated && (
+
+ Last updated {new Date(lastUpdated.updatedAt).toLocaleString()}
+ {lastUpdated.updatedBy ? ` by ${lastUpdated.updatedBy}` : ''}
+
+ )}
+
+
+ {saving ? 'Saving…' : 'Save prices'}
+
+
+
+ {error && {error}
}
+ {successMsg && {successMsg}
}
+
+
+
+ )
+}
+
+// ─── Plan features ──────────────────────────────────────────────────────────
+
+function PlanFeatureFormPanel({
+ initial, onSave, onCancel, saving, error,
+}: {
+ initial: PlanFeatureForm
+ onSave: (form: PlanFeatureForm) => void
+ onCancel: () => void
+ saving: boolean
+ error: string
+}) {
+ const [form, setForm] = useState(initial)
+
+ useEffect(() => {
+ setForm(initial)
+ }, [initial])
+
+ const INPUT = 'w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-blue-900 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white'
+ const LABEL = 'mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400'
+
+ return (
+
+ {error &&
{error}
}
+
+
+
+
+
+ Cancel
+
+ onSave(form)} disabled={saving}
+ className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white transition hover:bg-orange-500 disabled:opacity-60"
+ >
+ {saving ? 'Saving…' : 'Save feature'}
+
+
+
+ )
+}
+
+function PlanFeaturesSection() {
+ const [features, setFeatures] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [formState, setFormState] = useState<'hidden' | 'create' | string>('hidden')
+ const [formInitial, setFormInitial] = useState(emptyPlanFeatureForm())
+ const [saving, setSaving] = useState(false)
+ const [formError, setFormError] = useState('')
+ const [deleteConfirm, setDeleteConfirm] = useState(null)
+
+ useEffect(() => {
+ fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders() })
+ .then(async (r) => {
+ const json = await r.json()
+ if (!r.ok) throw new Error(json?.message ?? 'Failed to load plan features')
+ setFeatures(sortFeatures(json.data ?? []))
+ })
+ .finally(() => setLoading(false))
+ }, [])
+
+ function openCreate(plan = PLANS[0]) {
+ const nextOrder = Math.max(
+ 0,
+ ...features.filter((feature) => feature.plan === plan).map((feature) => feature.sortOrder),
+ ) + 10
+ setFormInitial({ plan, label: '', sortOrder: String(nextOrder) })
+ setFormError('')
+ setFormState('create')
+ }
+
+ function openEdit(feature: PlanFeature) {
+ setFormInitial({
+ plan: feature.plan,
+ label: feature.label,
+ sortOrder: String(feature.sortOrder),
+ })
+ setFormError('')
+ setFormState(feature.id)
+ }
+
+ function buildPayload(form: PlanFeatureForm) {
+ const label = form.label.trim()
+ if (!label) return { error: 'Feature label is required' }
+
+ const sortOrder = form.sortOrder.trim() === '' ? 0 : Number.parseInt(form.sortOrder, 10)
+ if (Number.isNaN(sortOrder) || sortOrder < 0) return { error: 'Display order must be 0 or greater' }
+
+ return {
+ payload: {
+ plan: form.plan,
+ label,
+ sortOrder,
+ },
+ }
+ }
+
+ async function handleSave(form: PlanFeatureForm) {
+ setFormError('')
+ const result = buildPayload(form)
+ if ('error' in result) {
+ setFormError(result.error ?? 'Invalid plan feature')
+ return
+ }
+
+ setSaving(true)
+ try {
+ const isEdit = formState !== 'create'
+ const url = isEdit
+ ? `${ADMIN_API_BASE}/admin/pricing/features/${formState}`
+ : `${ADMIN_API_BASE}/admin/pricing/features`
+ const res = await fetch(url, {
+ method: isEdit ? 'PATCH' : 'POST',
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ body: JSON.stringify(result.payload),
+ })
+ const json = await res.json()
+ if (!res.ok) throw new Error(json?.message ?? 'Save failed')
+ const saved: PlanFeature = json.data
+ setFeatures((prev) => sortFeatures(
+ isEdit ? prev.map((feature) => (feature.id === saved.id ? saved : feature)) : [...prev, saved],
+ ))
+ setFormState('hidden')
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : 'Save failed')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function handleDelete(id: string) {
+ try {
+ const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/features/${id}`, {
+ method: 'DELETE',
+ headers: authHeaders(),
+ })
+ if (!res.ok) throw new Error('Delete failed')
+ setFeatures((prev) => prev.filter((feature) => feature.id !== id))
+ } catch {
+ /* silent */
+ } finally {
+ setDeleteConfirm(null)
+ }
+ }
+
+ return (
+
+
+
+
Plan features
+
+ Control the feature bullets shown on each public pricing card.
+
+
+ {formState === 'hidden' && (
+
openCreate()}
+ className="inline-flex items-center gap-1.5 rounded-full bg-orange-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-500"
+ >
+
+
+
+ New feature
+
+ )}
+
+
+ {formState !== 'hidden' && (
+ setFormState('hidden')}
+ saving={saving}
+ error={formError}
+ />
+ )}
+
+ {loading ? (
+
+ ) : features.length === 0 ? (
+
+
No plan features yet. Create one above.
+
+ ) : (
+
+ {PLANS.map((plan) => {
+ const planFeatures = features.filter((feature) => feature.plan === plan)
+ return (
+
+
+
+
{plan}
+
{planFeatures.length} feature{planFeatures.length === 1 ? '' : 's'}
+
+ {formState === 'hidden' && (
+
openCreate(plan)}
+ className="rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-transparent dark:text-slate-200 dark:hover:bg-[#162038]"
+ >
+ Add
+
+ )}
+
+
+
+ {planFeatures.length === 0 ? (
+
+ No features for this plan.
+
+ ) : planFeatures.map((feature) => (
+
+
+
+
{feature.label}
+
Order {feature.sortOrder}
+
+
+
openEdit(feature)}
+ className="rounded-lg p-1.5 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700 dark:hover:bg-[#162038] dark:hover:text-slate-200"
+ title="Edit"
+ >
+
+
+
+
+ {deleteConfirm === feature.id ? (
+ <>
+
handleDelete(feature.id)}
+ className="rounded-lg px-2 py-1 text-[11px] font-semibold text-red-400 transition hover:bg-red-900/20"
+ >Confirm
+
setDeleteConfirm(null)}
+ className="rounded-lg px-2 py-1 text-[11px] font-semibold text-stone-400 transition hover:bg-stone-100 dark:hover:bg-[#162038]"
+ >Cancel
+ >
+ ) : (
+
setDeleteConfirm(feature.id)}
+ className="rounded-lg p-1.5 text-stone-400 transition hover:bg-red-900/20 hover:text-red-400"
+ title="Delete"
+ >
+
+
+
+
+ )}
+
+
+
+ ))}
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+// ─── Promotion form ─────────────────────────────────────────────────────────
+
+function PromotionFormPanel({
+ initial, onSave, onCancel, saving, error,
+}: {
+ initial: PromoForm
+ onSave: (form: PromoForm) => void
+ onCancel: () => void
+ saving: boolean
+ error: string
+}) {
+ const [form, setForm] = useState(initial)
+
+ useEffect(() => {
+ setForm(initial)
+ }, [initial])
+
+ function set(k: K, v: PromoForm[K]) {
+ setForm((f) => ({ ...f, [k]: v }))
+ }
+
+ function toggleArr(field: 'plans' | 'periods', val: string) {
+ setForm((f) => {
+ const arr = f[field]
+ return { ...f, [field]: arr.includes(val) ? arr.filter((x) => x !== val) : [...arr, val] }
+ })
+ }
+
+ const INPUT = 'w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-blue-900 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white'
+ const LABEL = 'mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400'
+
+ return (
+
+ {error &&
{error}
}
+
+
+
+ Promo code *
+ set('code', e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ''))}
+ />
+ Uppercase letters, numbers, _ and - only
+
+
+
+ Name *
+ set('name', e.target.value)}
+ />
+
+
+
+ Description
+ set('description', e.target.value)}
+ />
+
+
+
+
Discount type *
+
+ {(['PERCENTAGE', 'FIXED'] as const).map((t) => (
+ set('discountType', t)}
+ className={`flex-1 rounded-xl border px-3 py-2 text-xs font-semibold transition ${
+ form.discountType === t
+ ? 'border-orange-500 bg-orange-500 text-white'
+ : 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300'
+ }`}
+ >
+ {t === 'PERCENTAGE' ? '% Percentage' : 'MAD Fixed'}
+
+ ))}
+
+
+
+
+
+ {form.discountType === 'PERCENTAGE' ? 'Discount % (1–100) *' : 'Discount amount (MAD) *'}
+
+
+ set('discountValue', e.target.value)}
+ />
+
+ {form.discountType === 'PERCENTAGE' ? '%' : 'MAD'}
+
+
+
+
+
+
Applicable plans
+
Leave empty to apply to all plans
+
+ {PLANS.map((p) => (
+ toggleArr('plans', p)}
+ className={`rounded-full border px-3 py-1 text-xs font-semibold transition ${
+ form.plans.includes(p)
+ ? 'border-orange-500 bg-orange-500 text-white'
+ : 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300'
+ }`}
+ >
+ {p}
+
+ ))}
+
+
+
+
+
Applicable periods
+
Leave empty to apply to all periods
+
+ {PERIODS.map((p) => (
+ toggleArr('periods', p)}
+ className={`rounded-full border px-3 py-1 text-xs font-semibold transition ${
+ form.periods.includes(p)
+ ? 'border-orange-500 bg-orange-500 text-white'
+ : 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300'
+ }`}
+ >
+ {p}
+
+ ))}
+
+
+
+
+ Valid from *
+ set('validFrom', e.target.value)}
+ />
+
+
+
+ Valid until
+ set('validUntil', e.target.value)}
+ />
+ Leave empty for no expiry
+
+
+
+ Max uses
+ set('maxUses', e.target.value)}
+ />
+
+
+
+ Active
+ set('isActive', !form.isActive)}
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${form.isActive ? 'bg-orange-500' : 'bg-stone-300 dark:bg-zinc-600'}`}
+ >
+
+
+
+
+
+
+
+ Cancel
+
+ onSave(form)} disabled={saving}
+ className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white transition hover:bg-orange-500 disabled:opacity-60"
+ >
+ {saving ? 'Saving…' : 'Save promotion'}
+
+
+
+ )
+}
+
+// ─── Promotions section ─────────────────────────────────────────────────────
+
+function PromotionsSection() {
+ const [promotions, setPromotions] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [formState, setFormState] = useState<'hidden' | 'create' | string>('hidden') // string = editing id
+ const [formInitial, setFormInitial] = useState(emptyPromoForm())
+ const [saving, setSaving] = useState(false)
+ const [formError, setFormError] = useState('')
+ const [deleteConfirm, setDeleteConfirm] = useState(null)
+
+ useEffect(() => {
+ fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders() })
+ .then(async (r) => {
+ const json = await r.json()
+ if (!r.ok) throw new Error(json?.message ?? 'Failed to load promotions')
+ setPromotions(json.data ?? [])
+ })
+ .finally(() => setLoading(false))
+ }, [])
+
+ function openCreate() {
+ setFormInitial(emptyPromoForm())
+ setFormError('')
+ setFormState('create')
+ }
+
+ function openEdit(promo: Promotion) {
+ setFormInitial({
+ code: promo.code,
+ name: promo.name,
+ description: promo.description ?? '',
+ discountType: promo.discountType,
+ discountValue: promo.discountType === 'FIXED'
+ ? fmtCentimes(promo.discountValue)
+ : String(promo.discountValue),
+ plans: promo.plans,
+ periods: promo.periods,
+ maxUses: promo.maxUses != null ? String(promo.maxUses) : '',
+ validFrom: toDateInputValue(promo.validFrom),
+ validUntil: toDateInputValue(promo.validUntil),
+ isActive: promo.isActive,
+ })
+ setFormError('')
+ setFormState(promo.id)
+ }
+
+ function buildPayload(form: PromoForm) {
+ if (!form.code) return { error: 'Code is required' }
+ if (!form.name) return { error: 'Name is required' }
+ if (!form.discountValue) return { error: 'Discount value is required' }
+ if (!form.validFrom) return { error: 'Valid from date is required' }
+
+ const rawVal = parseFloat(form.discountValue)
+ if (isNaN(rawVal) || rawVal <= 0) return { error: 'Discount value must be a positive number' }
+ if (form.discountType === 'PERCENTAGE' && rawVal > 100) return { error: 'Percentage cannot exceed 100' }
+
+ const discountValue = form.discountType === 'FIXED'
+ ? Math.round(rawVal * 100)
+ : Math.round(rawVal)
+
+ return {
+ payload: {
+ code: form.code,
+ name: form.name,
+ description: form.description || undefined,
+ discountType: form.discountType,
+ discountValue,
+ plans: form.plans,
+ periods: form.periods,
+ maxUses: form.maxUses ? parseInt(form.maxUses, 10) : null,
+ validFrom: new Date(form.validFrom).toISOString(),
+ validUntil: form.validUntil ? new Date(form.validUntil).toISOString() : null,
+ isActive: form.isActive,
+ },
+ }
+ }
+
+ async function handleSave(form: PromoForm) {
+ setFormError('')
+ const result = buildPayload(form)
+ if ('error' in result) {
+ setFormError(result.error ?? 'Invalid promotion form')
+ return
+ }
+
+ setSaving(true)
+ try {
+ const isEdit = formState !== 'create'
+ const url = isEdit
+ ? `${ADMIN_API_BASE}/admin/pricing/promotions/${formState}`
+ : `${ADMIN_API_BASE}/admin/pricing/promotions`
+ const res = await fetch(url, {
+ method: isEdit ? 'PATCH' : 'POST',
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ body: JSON.stringify(result.payload),
+ })
+ const json = await res.json()
+ if (!res.ok) throw new Error(json?.message ?? 'Save failed')
+ const saved: Promotion = json.data
+ setPromotions((prev) =>
+ isEdit ? prev.map((p) => (p.id === saved.id ? saved : p)) : [saved, ...prev],
+ )
+ setFormState('hidden')
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : 'Save failed')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function handleToggleActive(promo: Promotion) {
+ try {
+ const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${promo.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ body: JSON.stringify({ isActive: !promo.isActive }),
+ })
+ const json = await res.json()
+ if (!res.ok) throw new Error(json?.message ?? 'Failed')
+ setPromotions((prev) => prev.map((p) => (p.id === promo.id ? json.data : p)))
+ } catch { /* silent */ }
+ }
+
+ async function handleDelete(id: string) {
+ try {
+ const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${id}`, {
+ method: 'DELETE',
+ headers: authHeaders(),
+ })
+ if (!res.ok) throw new Error('Delete failed')
+ setPromotions((prev) => prev.filter((p) => p.id !== id))
+ } catch { /* silent */ } finally {
+ setDeleteConfirm(null)
+ }
+ }
+
+ return (
+
+
+
+
Promotions & discounts
+
+ Promo codes applied during checkout to reduce subscription prices.
+
+
+ {formState === 'hidden' && (
+
+
+
+
+ New promotion
+
+ )}
+
+
+ {formState !== 'hidden' && (
+ setFormState('hidden')}
+ saving={saving}
+ error={formError}
+ />
+ )}
+
+ {loading ? (
+
+ ) : promotions.length === 0 ? (
+
+
No promotions yet. Create one above.
+
+ ) : (
+
+
+
+
+ {['Code', 'Name', 'Discount', 'Plans', 'Periods', 'Validity', 'Uses', 'Status', ''].map((h) => (
+
+ {h}
+
+ ))}
+
+
+
+ {promotions.map((promo, i) => {
+ const isExpired = promo.validUntil && new Date(promo.validUntil) < new Date()
+ const isEditing = formState === promo.id
+ return (
+
+
+ {promo.code}
+
+
+ {promo.name}
+ {promo.description && {promo.description}
}
+
+
+
+ {promo.discountType === 'PERCENTAGE'
+ ? `${promo.discountValue}%`
+ : `${fmtCentimes(promo.discountValue)} MAD`}
+
+
+
+ {promo.plans.length === 0 ? (
+ All
+ ) : (
+
+ {promo.plans.map((p) => (
+ {p}
+ ))}
+
+ )}
+
+
+ {promo.periods.length === 0 ? (
+ All
+ ) : (
+
+ {promo.periods.map((p) => (
+ {p}
+ ))}
+
+ )}
+
+
+ {fmtDate(promo.validFrom)}
+ {promo.validUntil ? `→ ${fmtDate(promo.validUntil)}` : '→ No expiry'}
+
+
+ {promo.usedCount}{promo.maxUses != null ? ` / ${promo.maxUses}` : ''}
+
+
+ handleToggleActive(promo)}
+ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${promo.isActive && !isExpired ? 'bg-orange-500' : 'bg-stone-300 dark:bg-zinc-600'}`}
+ >
+
+
+
+
+
+
openEdit(promo)}
+ className="rounded-lg p-1.5 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700 dark:hover:bg-[#162038] dark:hover:text-slate-200"
+ title="Edit"
+ >
+
+
+
+
+ {deleteConfirm === promo.id ? (
+ <>
+
handleDelete(promo.id)}
+ className="rounded-lg px-2 py-1 text-[11px] font-semibold text-red-400 transition hover:bg-red-900/20"
+ >Confirm
+
setDeleteConfirm(null)}
+ className="rounded-lg px-2 py-1 text-[11px] font-semibold text-stone-400 transition hover:bg-stone-100 dark:hover:bg-[#162038]"
+ >Cancel
+ >
+ ) : (
+
setDeleteConfirm(promo.id)}
+ className="rounded-lg p-1.5 text-stone-400 transition hover:bg-red-900/20 hover:text-red-400"
+ title="Delete"
+ >
+
+
+
+
+ )}
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+ )
+}
+
+// ─── Page ───────────────────────────────────────────────────────────────────
+
export default function PricingPage() {
const [configs, setConfigs] = useState([])
- const [draft, setDraft] = useState({})
+ const [draft, setDraft] = useState({})
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [successMsg, setSuccessMsg] = useState('')
useEffect(() => {
- const token = localStorage.getItem('admin_token')
fetch(`${ADMIN_API_BASE}/admin/pricing`, {
- headers: { Authorization: `Bearer ${token}` },
+ headers: authHeaders(),
cache: 'no-store',
})
.then((r) => r.json())
@@ -56,53 +1012,39 @@ export default function PricingPage() {
if (!json?.data) throw new Error(json?.message ?? 'Failed to load')
const list: PricingConfig[] = json.data
setConfigs(list)
- const initial: Draft = {}
- for (const c of list) {
- initial[key(c.plan, c.billingPeriod)] = fmt(c.amount)
- }
+ const initial: PricingDraft = {}
+ for (const c of list) initial[pricingKey(c.plan, c.billingPeriod)] = fmtCentimes(c.amount)
setDraft(initial)
setStatus('ready')
})
- .catch((err) => {
- setError(err.message)
- setStatus('error')
- })
+ .catch((err) => { setError(err.message); setStatus('error') })
}, [])
- async function handleSave() {
+ async function handleSavePrices() {
setError('')
setSuccessMsg('')
const entries: { plan: string; billingPeriod: string; amount: number }[] = []
for (const plan of PLANS) {
for (const period of PERIODS) {
- const k = key(plan, period)
+ const k = pricingKey(plan, period)
const amount = parseCentimes(draft[k] ?? '')
- if (amount === null) {
- setError(`Invalid value for ${plan} ${period}`)
- return
- }
+ if (amount === null) { setError(`Invalid value for ${plan} ${period}`); return }
entries.push({ plan, billingPeriod: period, amount })
}
}
setSaving(true)
try {
- const token = localStorage.getItem('admin_token')
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, {
method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`,
- },
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ entries }),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Save failed')
const updated: PricingConfig[] = json.data
setConfigs(updated)
- const refreshed: Draft = {}
- for (const c of updated) {
- refreshed[key(c.plan, c.billingPeriod)] = fmt(c.amount)
- }
+ const refreshed: PricingDraft = {}
+ for (const c of updated) refreshed[pricingKey(c.plan, c.billingPeriod)] = fmtCentimes(c.amount)
setDraft(refreshed)
setSuccessMsg('Prices saved successfully.')
} catch (err) {
@@ -128,97 +1070,33 @@ export default function PricingPage() {
)
}
- const lastUpdated = configs
- .filter((c) => c.updatedAt)
- .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
-
return (
-
-
-
-
Subscription Pricing
-
- Manage plan prices in MAD (values are entered in MAD, e.g. 29.90).
-
- {lastUpdated && (
-
- Last updated {new Date(lastUpdated.updatedAt).toLocaleString()}
- {lastUpdated.updatedBy ? ` by ${lastUpdated.updatedBy}` : ''}
-
- )}
-
-
- {saving ? 'Saving…' : 'Save prices'}
-
+
+
+
Platform
+
Subscription Pricing
+
+ Manage plan prices, plan features, and promotional discount codes.
+
- {error && (
-
{error}
- )}
- {successMsg && (
-
{successMsg}
- )}
+
setDraft((prev) => ({ ...prev, [k]: v }))}
+ onSave={handleSavePrices}
+ saving={saving}
+ error={error}
+ successMsg={successMsg}
+ />
-
+
-
-
Note
-
Prices are stored in centimes (1 MAD = 100 centimes) internally. Enter values in MAD (e.g. 29.90 for 2990 centimes). Changes take effect immediately for new checkout sessions.
-
+
+
+
+
+
)
}
diff --git a/apps/api/src/modules/admin/admin.repo.ts b/apps/api/src/modules/admin/admin.repo.ts
index db0600f..7b27b38 100644
--- a/apps/api/src/modules/admin/admin.repo.ts
+++ b/apps/api/src/modules/admin/admin.repo.ts
@@ -417,3 +417,74 @@ export function upsertPricingConfig(plan: string, billingPeriod: string, amount:
create: { id: `prc_${plan.toLowerCase()}_${billingPeriod.toLowerCase()}`, plan, billingPeriod, amount, updatedBy },
})
}
+
+export function listPlanFeatures() {
+ return prisma.planFeature.findMany({
+ orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
+ })
+}
+
+export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) {
+ return prisma.planFeature.create({
+ data: {
+ plan: data.plan,
+ label: data.label,
+ sortOrder: data.sortOrder ?? 0,
+ },
+ })
+}
+
+export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) {
+ return prisma.planFeature.update({
+ where: { id },
+ data,
+ })
+}
+
+export function deletePlanFeature(id: string) {
+ return prisma.planFeature.delete({ where: { id } })
+}
+
+// ─── Pricing promotions ────────────────────────────────────────────────────
+
+export function listPromotions() {
+ return (prisma as any).pricingPromotion.findMany({ orderBy: { createdAt: 'desc' } })
+}
+
+export function createPromotion(data: {
+ code: string; name: string; description?: string | null
+ discountType: string; discountValue: number
+ plans: string[]; periods: string[]
+ maxUses?: number | null; validFrom: string; validUntil?: string | null
+ isActive: boolean; createdBy?: string | null
+}) {
+ return (prisma as any).pricingPromotion.create({
+ data: {
+ ...data,
+ validFrom: new Date(data.validFrom),
+ validUntil: data.validUntil ? new Date(data.validUntil) : null,
+ },
+ })
+}
+
+export function updatePromotion(id: string, data: Partial<{
+ code: string; name: string; description: string | null
+ discountType: string; discountValue: number
+ plans: string[]; periods: string[]
+ maxUses: number | null; validFrom: string; validUntil: string | null
+ isActive: boolean
+}>) {
+ const { validFrom, validUntil, ...rest } = data
+ return (prisma as any).pricingPromotion.update({
+ where: { id },
+ data: {
+ ...rest,
+ ...(validFrom ? { validFrom: new Date(validFrom) } : {}),
+ ...(validUntil !== undefined ? { validUntil: validUntil ? new Date(validUntil) : null } : {}),
+ },
+ })
+}
+
+export function deletePromotion(id: string) {
+ return (prisma as any).pricingPromotion.delete({ where: { id } })
+}
diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts
index 4dc35b7..25c8516 100644
--- a/apps/api/src/modules/admin/admin.routes.ts
+++ b/apps/api/src/modules/admin/admin.routes.ts
@@ -11,7 +11,8 @@ import {
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema,
- pricingUpdateSchema,
+ pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
+ promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
} from './admin.schemas'
import { z } from 'zod'
@@ -235,6 +236,66 @@ router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (r
} catch (err) { next(err) }
})
+router.get('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
+ try {
+ ok(res, await service.listPlanFeatures())
+ } catch (err) { next(err) }
+})
+
+router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
+ try {
+ const data = parseBody(planFeatureCreateSchema, req)
+ created(res, await service.createPlanFeature(data, req.admin.id, req.ip))
+ } catch (err) { next(err) }
+})
+
+router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
+ try {
+ const { featureId } = parseParams(planFeatureIdParamSchema, req)
+ const data = parseBody(planFeatureUpdateSchema, req)
+ ok(res, await service.updatePlanFeature(featureId, data, req.admin.id, req.ip))
+ } catch (err) { next(err) }
+})
+
+router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
+ try {
+ const { featureId } = parseParams(planFeatureIdParamSchema, req)
+ await service.deletePlanFeature(featureId, req.admin.id, req.ip)
+ ok(res, { success: true })
+ } catch (err) { next(err) }
+})
+
+// ─── Promotions ────────────────────────────────────────────────────────────
+
+router.get('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
+ try {
+ ok(res, await service.listPromotions())
+ } catch (err) { next(err) }
+})
+
+router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
+ try {
+ const data = parseBody(promotionCreateSchema, req)
+ created(res, await service.createPromotion(data as any, req.admin.id, req.ip))
+ } catch (err) { next(err) }
+})
+
+router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
+ try {
+ const { promotionId } = parseParams(promotionIdParamSchema, req)
+ const data = parseBody(promotionUpdateSchema, req)
+ ok(res, await service.updatePromotion(promotionId, data as any, req.admin.id, req.ip))
+ } catch (err) { next(err) }
+})
+
+router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
+ try {
+ const { promotionId } = parseParams(promotionIdParamSchema, req)
+ await service.deletePromotion(promotionId, req.admin.id, req.ip)
+ ok(res, { success: true })
+ } catch (err) { next(err) }
+})
+
// ─── Subscription admin overrides ─────────────────────────────
router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
diff --git a/apps/api/src/modules/admin/admin.schemas.ts b/apps/api/src/modules/admin/admin.schemas.ts
index 8b92d8c..6fdbca9 100644
--- a/apps/api/src/modules/admin/admin.schemas.ts
+++ b/apps/api/src/modules/admin/admin.schemas.ts
@@ -182,3 +182,30 @@ export const pricingUpdateSchema = z.object({
amount: z.number().int().positive(),
})).min(1),
})
+
+const planFeatureSchema = z.object({
+ plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
+ label: z.string().min(1).max(120),
+ sortOrder: z.number().int().min(0).default(0),
+})
+
+export const planFeatureCreateSchema = planFeatureSchema
+export const planFeatureUpdateSchema = planFeatureSchema.partial()
+export const promotionCreateSchema = z.object({
+ code: z.string().min(2).max(30).regex(/^[A-Z0-9_-]+$/, 'Code must be uppercase A-Z, 0-9, _ or -'),
+ name: z.string().min(1).max(100),
+ description: z.string().max(500).optional(),
+ discountType: z.enum(['PERCENTAGE', 'FIXED']),
+ discountValue: z.number().int().positive(),
+ plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO'])),
+ periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
+ maxUses: z.number().int().positive().nullable().optional(),
+ validFrom: z.string().datetime(),
+ validUntil: z.string().datetime().nullable().optional(),
+ isActive: z.boolean().default(true),
+})
+
+export const promotionUpdateSchema = promotionCreateSchema.partial()
+
+export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1) })
+export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
diff --git a/apps/api/src/modules/admin/admin.service.ts b/apps/api/src/modules/admin/admin.service.ts
index c28d12c..df1fc93 100644
--- a/apps/api/src/modules/admin/admin.service.ts
+++ b/apps/api/src/modules/admin/admin.service.ts
@@ -315,3 +315,78 @@ export async function updatePricingConfigs(entries: { plan: string; billingPerio
})
return results
}
+
+export function listPlanFeatures() {
+ return repo.listPlanFeatures()
+}
+
+export async function createPlanFeature(data: Parameters
[0], adminId: string, ip?: string) {
+ const feature = await repo.createPlanFeature(data)
+ await repo.createAuditLog({
+ adminUserId: adminId,
+ action: 'CREATE',
+ resource: 'PlanFeature',
+ after: toAuditJson(feature),
+ ipAddress: ip,
+ userAgent: undefined,
+ })
+ return feature
+}
+
+export async function updatePlanFeature(id: string, data: Parameters[1], adminId: string, ip?: string) {
+ const feature = await repo.updatePlanFeature(id, data)
+ await repo.createAuditLog({
+ adminUserId: adminId,
+ action: 'UPDATE',
+ resource: 'PlanFeature',
+ resourceId: id,
+ after: toAuditJson(feature),
+ ipAddress: ip,
+ userAgent: undefined,
+ })
+ return feature
+}
+
+export async function deletePlanFeature(id: string, adminId: string, ip?: string) {
+ await repo.deletePlanFeature(id)
+ await repo.createAuditLog({
+ adminUserId: adminId,
+ action: 'DELETE',
+ resource: 'PlanFeature',
+ resourceId: id,
+ ipAddress: ip,
+ userAgent: undefined,
+ })
+}
+
+// ─── Promotions ────────────────────────────────────────────────────────────
+
+export function listPromotions() {
+ return repo.listPromotions()
+}
+
+export async function createPromotion(data: Parameters[0], adminId: string, ip?: string) {
+ const promo = await repo.createPromotion({ ...data, createdBy: adminId })
+ await repo.createAuditLog({
+ adminUserId: adminId, action: 'CREATE', resource: 'PricingPromotion',
+ after: toAuditJson(promo), ipAddress: ip, userAgent: undefined,
+ })
+ return promo
+}
+
+export async function updatePromotion(id: string, data: Parameters[1], adminId: string, ip?: string) {
+ const promo = await repo.updatePromotion(id, data)
+ await repo.createAuditLog({
+ adminUserId: adminId, action: 'UPDATE', resource: 'PricingPromotion',
+ after: toAuditJson(promo), ipAddress: ip, userAgent: undefined,
+ })
+ return promo
+}
+
+export async function deletePromotion(id: string, adminId: string, ip?: string) {
+ await repo.deletePromotion(id)
+ await repo.createAuditLog({
+ adminUserId: adminId, action: 'DELETE', resource: 'PricingPromotion',
+ entityId: id, ipAddress: ip, userAgent: undefined,
+ })
+}
diff --git a/apps/api/src/modules/site/site.routes.ts b/apps/api/src/modules/site/site.routes.ts
index e4a3d21..0afa3a6 100644
--- a/apps/api/src/modules/site/site.routes.ts
+++ b/apps/api/src/modules/site/site.routes.ts
@@ -1,3 +1,4 @@
+import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { Router } from 'express'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
@@ -16,6 +17,17 @@ router.get('/platform/homepage', async (_req, res, next) => {
} catch (err) { next(err) }
})
+router.get('/platform/pricing', async (_req, res, next) => {
+ try {
+ ok(res, await service.getPlatformPricing())
+ } catch (err) {
+ if (isDatabaseUnavailableError(err)) {
+ return res.json({ data: { prices: PLAN_PRICES, planFeatures: PLAN_FEATURES } })
+ }
+ next(err)
+ }
+})
+
router.get('/:slug/brand', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
diff --git a/apps/api/src/modules/site/site.service.ts b/apps/api/src/modules/site/site.service.ts
index d1de105..17bb6c4 100644
--- a/apps/api/src/modules/site/site.service.ts
+++ b/apps/api/src/modules/site/site.service.ts
@@ -1,3 +1,4 @@
+import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyInsurancesToReservation } from '../../services/insuranceService'
@@ -5,6 +6,7 @@ import { applyAdditionalDriversToReservation } from '../../services/additionalDr
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
+import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './site.repo'
@@ -14,6 +16,32 @@ export async function getPlatformHomepage() {
return getMarketplaceHomepageContent()
}
+export async function getPlatformPricing() {
+ const [configs, features] = await Promise.all([
+ prisma.pricingConfig.findMany(),
+ prisma.planFeature.findMany({
+ orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
+ }),
+ ])
+
+ const prices = configs.length === 0
+ ? PLAN_PRICES
+ : configs.reduce>>>((acc, config) => {
+ if (!acc[config.plan]) acc[config.plan] = {}
+ acc[config.plan]![config.billingPeriod] = { MAD: config.amount }
+ return acc
+ }, {})
+
+ const planFeatures = Object.fromEntries(
+ Object.entries(PLAN_FEATURES).map(([plan, fallback]) => {
+ const labels = features.filter((feature) => feature.plan === plan).map((feature) => feature.label)
+ return [plan, labels.length > 0 ? labels : fallback]
+ }),
+ )
+
+ return { prices, planFeatures }
+}
+
export async function getBrand(slug: string) {
const company = await repo.findCompanyBySlug(slug)
return presentBrand(company)
diff --git a/apps/marketplace/src/app/(public)/pricing/PricingClient.tsx b/apps/marketplace/src/app/(public)/pricing/PricingClient.tsx
index 53e18ec..29f29bf 100644
--- a/apps/marketplace/src/app/(public)/pricing/PricingClient.tsx
+++ b/apps/marketplace/src/app/(public)/pricing/PricingClient.tsx
@@ -2,15 +2,24 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
+import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
+import { marketplaceFetchOrDefault } from '@/lib/api'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
type Billing = 'monthly' | 'annual'
-const PRICES: Record = {
- STARTER: { monthly: 299, annual: 2990 },
- GROWTH: { monthly: 399, annual: 3990 },
- PRO: { monthly: 499, annual: 4990 },
+type PricingMatrix = Record>>
+type PlanFeatureMap = Record
+
+type PlatformPricing = {
+ prices: PricingMatrix
+ planFeatures: PlanFeatureMap
+}
+
+export const DEFAULT_PRICING: PlatformPricing = {
+ prices: PLAN_PRICES,
+ planFeatures: PLAN_FEATURES,
}
const PLANS = [
@@ -18,38 +27,18 @@ const PLANS = [
key: 'STARTER',
name: 'Starter',
tagline: 'Launch your fleet online',
- features: [
- 'Up to 10 vehicles',
- '1 user account',
- 'Basic analytics',
- 'Marketplace listing',
- ],
highlight: false,
},
{
key: 'GROWTH',
name: 'Growth',
tagline: 'Scale with confidence',
- features: [
- 'Up to 50 vehicles',
- '5 user accounts',
- 'Full analytics',
- 'Priority marketplace placement',
- 'Custom branding',
- ],
highlight: true,
},
{
key: 'PRO',
name: 'Pro',
tagline: 'Enterprise-grade power',
- features: [
- 'Unlimited vehicles',
- 'Unlimited user accounts',
- 'Advanced reports',
- 'API access',
- 'Dedicated support',
- ],
highlight: false,
},
]
@@ -93,20 +82,32 @@ const copy = {
},
} as const
-function annualSavingsPct(plan: string): number {
- const { monthly, annual } = PRICES[plan]
- const wouldPay = monthly * 12
- return Math.round(((wouldPay - annual) / wouldPay) * 100)
+function getPlanPrice(prices: PricingMatrix, plan: string, billingPeriod: 'MONTHLY' | 'ANNUAL') {
+ return (prices[plan]?.[billingPeriod]?.MAD ?? PLAN_PRICES[plan]?.[billingPeriod]?.MAD ?? 0) / 100
}
-export default function PricingClient() {
+function annualSavingsPct(prices: PricingMatrix, plan: string): number {
+ const monthly = getPlanPrice(prices, plan, 'MONTHLY')
+ const annual = getPlanPrice(prices, plan, 'ANNUAL')
+ const wouldPay = monthly * 12
+ if (wouldPay <= 0) return 0
+ return Math.max(0, Math.round(((wouldPay - annual) / wouldPay) * 100))
+}
+
+export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { initialPricing?: PlatformPricing }) {
const { language } = useMarketplacePreferences()
const dict = copy[language]
const [billing, setBilling] = useState('monthly')
+ const [pricing, setPricing] = useState(initialPricing)
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
- const savings = annualSavingsPct('STARTER')
+ useEffect(() => {
+ marketplaceFetchOrDefault('/site/platform/pricing', DEFAULT_PRICING)
+ .then(setPricing)
+ }, [])
+
+ const savings = annualSavingsPct(pricing.prices, 'STARTER')
return (
@@ -141,10 +142,12 @@ export default function PricingClient() {
{/* Plan cards */}
{PLANS.map((plan) => {
- const price = PRICES[plan.key]
+ const monthlyPrice = getPlanPrice(pricing.prices, plan.key, 'MONTHLY')
+ const annualPrice = getPlanPrice(pricing.prices, plan.key, 'ANNUAL')
const displayPrice = billing === 'annual'
- ? Math.round(price.annual / 12)
- : price.monthly
+ ? Math.round(annualPrice / 12)
+ : monthlyPrice
+ const features = pricing.planFeatures[plan.key] ?? PLAN_FEATURES[plan.key] ?? []
return (
{billing === 'annual' && (
- {dict.billedAs} {price.annual} MAD {dict.yearly}
+ {dict.billedAs} {annualPrice} MAD {dict.yearly}
)}
- {plan.features.map((f) => (
+ {features.map((f) => (
['initialPricing'] }) {
const { language } = useMarketplacePreferences()
const dict = copy[language]
@@ -56,7 +56,7 @@ export default function PricingPageContent() {
diff --git a/apps/marketplace/src/app/(public)/pricing/page.tsx b/apps/marketplace/src/app/(public)/pricing/page.tsx
index bd0c4ff..9def7c1 100644
--- a/apps/marketplace/src/app/(public)/pricing/page.tsx
+++ b/apps/marketplace/src/app/(public)/pricing/page.tsx
@@ -1,4 +1,6 @@
import type { Metadata } from 'next'
+import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
+import { marketplaceFetchOrDefault } from '@/lib/api'
import PricingPageContent from './PricingPageContent'
export const metadata: Metadata = {
@@ -6,6 +8,12 @@ export const metadata: Metadata = {
description: 'Simple, transparent pricing for every fleet size.',
}
-export default function PricingPage() {
- return
+const DEFAULT_PRICING = {
+ prices: PLAN_PRICES,
+ planFeatures: PLAN_FEATURES,
+}
+
+export default async function PricingPage() {
+ const initialPricing = await marketplaceFetchOrDefault('/site/platform/pricing', DEFAULT_PRICING)
+ return
}
diff --git a/contrat_location_voiture_maroc.md b/contrat_location_voiture_maroc.md
new file mode 100644
index 0000000..f46a7e5
--- /dev/null
+++ b/contrat_location_voiture_maroc.md
@@ -0,0 +1,558 @@
+# Contrat de Location de Véhicule
+
+**Contrat n° :** [●]
+**Date de signature :** [●]
+**Lieu de signature :** [Ville, Maroc]
+
+---
+
+## 1. Parties au contrat
+
+Entre les soussignés :
+
+### Le Loueur / Agence
+
+**Nom commercial :** [●]
+**Raison sociale :** [●]
+**Forme juridique :** [●]
+**RC n° :** [●]
+**ICE n° :** [●]
+**IF n° :** [●]
+**Adresse :** [●]
+**Téléphone :** [●]
+**Email :** [●]
+**Représenté par :** [Nom, prénom, fonction]
+
+Ci-après désigné **« le Loueur »**,
+
+Et :
+
+### Le Locataire / Conducteur principal
+
+**Nom :** [●]
+**Prénom :** [●]
+**Date de naissance :** [●]
+**Nationalité :** [●]
+**CIN / Passeport n° :** [●]
+**Adresse complète :** [●]
+**Téléphone :** [●]
+**Email :** [●]
+
+Ci-après désigné **« le Locataire »**.
+
+Le Loueur et le Locataire sont ensemble désignés **« les Parties »**.
+
+---
+
+## 2. Permis de conduire
+
+Le Locataire déclare être titulaire d’un permis de conduire valide :
+
+**Numéro du permis :** [●]
+**Catégorie :** [B / autre]
+**Date de délivrance :** [●]
+**Date d’expiration :** [●]
+**Pays de délivrance :** [●]
+**Permis international, le cas échéant :** [Oui / Non, n° ●]
+
+Le Locataire garantit que son permis est valide pendant toute la durée de location et qu’il n’est frappé d’aucune suspension, annulation ou restriction incompatible avec la conduite du véhicule loué.
+
+---
+
+## 3. Conducteur(s) autorisé(s)
+
+Le véhicule ne peut être conduit que par le Locataire et, le cas échéant, par les conducteurs additionnels expressément mentionnés ci-dessous :
+
+### Conducteur additionnel 1
+
+**Nom et prénom :** [●]
+**CIN / Passeport :** [●]
+**Permis n° :** [●]
+**Catégorie :** [●]
+**Date de délivrance :** [●]
+**Téléphone :** [●]
+
+Tout conducteur non déclaré est interdit. En cas d’accident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume l’entière responsabilité des conséquences financières, civiles, pénales et assurantielles.
+
+---
+
+## 4. Véhicule loué
+
+Le Loueur met à disposition du Locataire le véhicule suivant :
+
+**Marque :** [●]
+**Modèle :** [●]
+**Année :** [●]
+**Couleur :** [●]
+**Immatriculation :** [●]
+**Numéro de châssis :** [●]
+**Kilométrage au départ :** [●] km
+**Niveau de carburant au départ :** [●]
+**Carte grise :** [Oui / Non]
+**Assurance :** [Oui / Non]
+**Visite technique :** [Oui / Non / Non applicable]
+
+Un état des lieux contradictoire au départ et au retour est annexé au présent contrat et en fait partie intégrante.
+
+---
+
+## 5. Durée de location
+
+La location commence le :
+
+**Date :** [●]
+**Heure :** [●]
+**Lieu de prise en charge :** [●]
+
+La location prend fin le :
+
+**Date :** [●]
+**Heure :** [●]
+**Lieu de restitution :** [●]
+
+Toute prolongation doit être acceptée préalablement par écrit par le Loueur. À défaut, le véhicule sera considéré comme conservé sans autorisation, et le Loueur pourra facturer les jours supplémentaires, pénalités de retard et frais éventuels.
+
+---
+
+## 6. Prix de location et modalités de paiement
+
+Le prix de location est fixé comme suit :
+
+| Désignation | Montant |
+|---|---:|
+| Tarif journalier HT | [●] MAD |
+| Nombre de jours | [●] |
+| Sous-total HT | [●] MAD |
+| TVA applicable | [●] % |
+| Montant TVA | [●] MAD |
+| Total TTC | [●] MAD |
+| Conducteur additionnel | [●] MAD |
+| Livraison / récupération | [●] MAD |
+| Siège enfant | [●] MAD |
+| GPS / accessoires | [●] MAD |
+| Autres frais | [●] MAD |
+
+**Montant total à payer TTC : [●] MAD**
+
+**Mode de paiement :** [Espèces / carte bancaire / virement / chèque / autre]
+**Date de paiement :** [●]
+**Référence de paiement :** [●]
+
+Le Locataire reconnaît avoir reçu une information claire sur le prix total TTC avant la signature du contrat.
+
+---
+
+## 7. Dépôt de garantie / caution
+
+Le Locataire verse au Loueur un dépôt de garantie de :
+
+**Montant : [●] MAD**
+
+**Forme de la caution :** [Préautorisation bancaire / espèces / chèque / autre]
+**Date de constitution :** [●]
+**Conditions de libération :** [●]
+
+Le dépôt de garantie garantit notamment :
+
+- les dommages non couverts par l’assurance ;
+- la franchise d’assurance ;
+- les kilomètres supplémentaires ;
+- le carburant manquant ;
+- les frais de nettoyage exceptionnel ;
+- les contraventions, amendes, péages ou frais administratifs ;
+- les retards de restitution ;
+- la perte de documents, clés, accessoires ou équipements.
+
+La caution sera restituée après vérification de l’état du véhicule, du kilométrage, du carburant, des éventuelles infractions et des sommes restant dues.
+
+---
+
+## 8. Assurance
+
+Le véhicule est assuré auprès de :
+
+**Compagnie d’assurance :** [●]
+**Numéro de police :** [●]
+**Type de couverture :** [Responsabilité civile / Tous risques / autre]
+**Franchise applicable :** [●] MAD
+**Assistance :** [Oui / Non]
+**Numéro d’assistance :** [●]
+
+Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables.
+
+Sont notamment exclus de la couverture, sauf stipulation contraire de l’assureur :
+
+- conduite par une personne non autorisée ;
+- conduite sans permis valide ;
+- conduite sous l’emprise d’alcool, stupéfiants ou substances interdites ;
+- usage du véhicule hors des voies autorisées ;
+- participation à des courses, essais ou compétitions ;
+- négligence grave ;
+- fausse déclaration ;
+- absence de déclaration d’accident dans les délais ;
+- vol avec clés laissées dans le véhicule ;
+- transport illicite de personnes ou de marchandises.
+
+---
+
+## 9. Kilométrage
+
+**Kilométrage inclus :** [●] km par jour / [●] km pour toute la durée de location.
+**Kilométrage au départ :** [●] km.
+**Kilométrage au retour :** [●] km.
+
+Tout kilomètre supplémentaire sera facturé au tarif de :
+
+**[●] MAD TTC par kilomètre supplémentaire.**
+
+Le kilométrage fait foi sur la base du compteur du véhicule, constaté dans l’état des lieux de départ et de retour.
+
+---
+
+## 10. Utilisation du véhicule
+
+Le Locataire s’engage à utiliser le véhicule avec prudence, conformément à sa destination normale et à la législation marocaine.
+
+Il est interdit notamment :
+
+- de sous-louer le véhicule ;
+- de transporter des marchandises dangereuses ou illicites ;
+- d’utiliser le véhicule pour des activités illégales ;
+- de participer à des compétitions ou essais sportifs ;
+- de tracter un autre véhicule sans autorisation écrite ;
+- de circuler hors du territoire marocain sans autorisation écrite du Loueur ;
+- de modifier le véhicule ou ses équipements ;
+- de fumer dans le véhicule si l’agence l’interdit ;
+- de transporter un nombre de passagers supérieur à celui autorisé.
+
+Le Locataire est responsable des infractions au Code de la route commises pendant la période de location.
+
+---
+
+## 11. Restitution du véhicule
+
+Le Locataire doit restituer le véhicule à la date, à l’heure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de l’usure normale.
+
+Le véhicule doit être restitué avec :
+
+- le même niveau de carburant qu’au départ ;
+- les papiers du véhicule ;
+- les clés ;
+- les accessoires et équipements remis ;
+- un état de propreté normal ;
+- aucun dommage nouveau non déclaré.
+
+En cas de retard, le Loueur pourra facturer :
+
+- [●] MAD par heure de retard ; ou
+- une journée supplémentaire au tarif contractuel au-delà de [●] heures de retard.
+
+En cas de carburant manquant, les frais seront facturés selon le coût réel augmenté de frais de service de [●] MAD.
+
+En cas de salissure excessive, le Loueur pourra facturer des frais de nettoyage de [●] MAD à [●] MAD selon l’état du véhicule.
+
+---
+
+## 12. État des lieux départ et retour
+
+Les Parties établissent un état des lieux au départ et au retour. Celui-ci peut être accompagné de photographies datées du véhicule, du compteur, du carburant, de la carrosserie, de l’intérieur, des pneus, des vitres et des équipements.
+
+Les éléments à contrôler comprennent notamment :
+
+- Kilométrage compteur ;
+- Niveau de carburant ;
+- Carrosserie ;
+- Pare-chocs ;
+- Rétroviseurs ;
+- Phares et feux ;
+- Pare-brise et vitres ;
+- Pneus et jantes ;
+- Intérieur et sièges ;
+- Tableau de bord ;
+- Roue de secours ;
+- Outillage ;
+- Documents du véhicule ;
+- Clés et accessoires.
+
+Tout dommage constaté au retour et non mentionné dans l’état des lieux de départ pourra être facturé au Locataire, sous réserve des garanties d’assurance applicables.
+
+---
+
+## 13. Accident, panne, vol ou sinistre
+
+En cas d’accident, panne, vol, tentative de vol, incendie ou tout autre sinistre, le Locataire doit immédiatement :
+
+- informer le Loueur par téléphone et par écrit ;
+- prévenir les autorités compétentes si nécessaire ;
+- établir un constat amiable en cas d’accident ;
+- obtenir un procès-verbal ou document officiel en cas de vol, blessure, délit ou dommage important ;
+- ne pas abandonner le véhicule sans autorisation du Loueur ;
+- ne pas reconnaître de responsabilité sans accord préalable du Loueur ou de l’assureur.
+
+Le Locataire doit transmettre au Loueur tous les documents utiles dans un délai maximum de **48 heures** à compter du sinistre.
+
+En cas de non-respect de cette procédure, le Locataire pourra être tenu responsable des conséquences financières, notamment si l’assureur refuse sa garantie.
+
+---
+
+## 14. Responsabilité du Locataire
+
+Le Locataire est responsable :
+
+- des dommages causés au véhicule pendant la période de location ;
+- des dommages causés aux tiers dans les limites prévues par la loi et l’assurance ;
+- des amendes, contraventions, frais de fourrière, péages et frais administratifs ;
+- des frais liés à une mauvaise utilisation du véhicule ;
+- des pertes de clés, documents ou accessoires ;
+- des dommages non couverts par l’assurance ;
+- de la franchise prévue au contrat d’assurance.
+
+Le Locataire reste responsable jusqu’à la restitution effective du véhicule au Loueur et la signature de l’état des lieux de retour.
+
+---
+
+## 15. Infractions, amendes et frais administratifs
+
+Toutes les infractions commises pendant la durée de location sont à la charge du Locataire.
+
+Le Loueur pourra communiquer l’identité du Locataire aux autorités compétentes et facturer au Locataire :
+
+- le montant des amendes ;
+- les frais de dossier ;
+- les frais de fourrière ;
+- les frais de notification ;
+- tout coût lié au traitement administratif de l’infraction.
+
+**Frais administratifs par infraction : [●] MAD TTC.**
+
+---
+
+## 16. Annulation, non-présentation et résiliation
+
+En cas d’annulation par le Locataire :
+
+- plus de [●] heures avant le départ : [conditions de remboursement] ;
+- moins de [●] heures avant le départ : [conditions] ;
+- non-présentation : [conditions].
+
+Le Loueur peut résilier immédiatement le contrat, sans indemnité pour le Locataire, en cas de :
+
+- fausse déclaration ;
+- permis invalide ;
+- défaut de paiement ;
+- utilisation interdite du véhicule ;
+- conduite dangereuse ;
+- non-respect grave du présent contrat ;
+- suspicion légitime de fraude ou d’usage illicite.
+
+Dans ce cas, le Locataire doit restituer immédiatement le véhicule.
+
+---
+
+## 17. Données personnelles
+
+Le Locataire autorise le Loueur à collecter et conserver les données nécessaires à l’exécution du présent contrat, notamment son identité, ses coordonnées, son permis de conduire, les informations de paiement, les documents liés au véhicule, les états des lieux et les données relatives aux éventuels incidents.
+
+Ces données sont utilisées pour :
+
+- l’exécution du contrat ;
+- la facturation ;
+- la gestion des sinistres ;
+- la gestion des infractions ;
+- les obligations comptables, fiscales et légales ;
+- la défense des droits du Loueur en cas de litige.
+
+Le Loueur s’engage à protéger les données personnelles du Locataire, à limiter leur accès aux personnes habilitées et à les conserver uniquement pendant la durée nécessaire aux finalités prévues et aux obligations légales applicables.
+
+---
+
+## 18. Documents annexés
+
+Les documents suivants sont annexés au présent contrat :
+
+- Copie CIN ou passeport du Locataire ;
+- Copie du permis de conduire ;
+- Copie permis international, le cas échéant ;
+- État des lieux de départ ;
+- État des lieux de retour ;
+- Photos du véhicule au départ ;
+- Photos du véhicule au retour ;
+- Copie de la carte grise ;
+- Copie de l’attestation d’assurance ;
+- Reçu de paiement ;
+- Justificatif de dépôt de garantie ;
+- Conditions générales de location, le cas échéant.
+
+Les annexes font partie intégrante du présent contrat.
+
+---
+
+## 19. Loi applicable et juridiction compétente
+
+Le présent contrat est soumis au droit marocain.
+
+En cas de litige relatif à sa validité, son interprétation, son exécution ou sa résiliation, les Parties s’efforceront de trouver une solution amiable.
+
+À défaut d’accord amiable, le litige sera porté devant le tribunal compétent du ressort de :
+
+**[Ville du siège de l’agence / tribunal compétent]**
+
+---
+
+## 20. Déclaration finale
+
+Le Locataire déclare :
+
+- avoir lu et compris le présent contrat ;
+- avoir reçu toutes les informations utiles avant signature ;
+- avoir vérifié l’état du véhicule au départ ;
+- être titulaire d’un permis valide ;
+- s’engager à respecter les conditions du présent contrat ;
+- accepter les prix, caution, franchises, pénalités et frais indiqués.
+
+**Fait à :** [●]
+**Le :** [●]
+**En deux exemplaires originaux.**
+
+Chaque page du présent contrat doit être paraphée par les deux Parties.
+
+### Signature du Loueur
+
+**Nom :** [●]
+**Signature et cachet :**
+
+
+
+### Signature du Locataire
+
+**Nom :** [●]
+**Signature :**
+
+
+
+---
+
+# Annexe 1 — État des lieux de départ
+
+**Contrat n° :** [●]
+**Date :** [●]
+**Heure :** [●]
+**Lieu :** [●]
+
+**Véhicule :** [Marque / Modèle]
+**Immatriculation :** [●]
+**Kilométrage départ :** [●] km
+**Carburant départ :** [●]
+
+| Élément | État au départ | Observations |
+|---|---|---|
+| Carrosserie avant | Bon / Rayé / Endommagé | [●] |
+| Carrosserie arrière | Bon / Rayé / Endommagé | [●] |
+| Côté droit | Bon / Rayé / Endommagé | [●] |
+| Côté gauche | Bon / Rayé / Endommagé | [●] |
+| Pare-brise | Bon / Impact / Fissure | [●] |
+| Vitres | Bon / Endommagé | [●] |
+| Pneus | Bon / Usé / Endommagé | [●] |
+| Jantes | Bon / Rayé / Endommagé | [●] |
+| Intérieur | Bon / Taché / Endommagé | [●] |
+| Sièges | Bon / Taché / Déchiré | [●] |
+| Tableau de bord | Bon / Endommagé | [●] |
+| Climatisation | Fonctionne / Ne fonctionne pas | [●] |
+| Feux | Fonctionnent / Défaut | [●] |
+| Roue de secours | Présente / Absente | [●] |
+| Outillage | Présent / Absent | [●] |
+| Carte grise | Présente / Absente | [●] |
+| Assurance | Présente / Absente | [●] |
+| Clés | [Nombre] | [●] |
+
+**Photos prises au départ :** Oui / Non
+**Nombre de photos :** [●]
+
+**Signature du Loueur :**
+
+
+
+**Signature du Locataire :**
+
+
+
+---
+
+# Annexe 2 — État des lieux de retour
+
+**Contrat n° :** [●]
+**Date :** [●]
+**Heure :** [●]
+**Lieu :** [●]
+
+**Kilométrage retour :** [●] km
+**Kilométrage parcouru :** [●] km
+**Kilométrage inclus :** [●] km
+**Kilométrage supplémentaire :** [●] km
+**Montant km supplémentaires :** [●] MAD
+
+**Carburant retour :** [●]
+**Carburant manquant :** Oui / Non
+**Montant carburant :** [●] MAD
+
+| Élément | État au retour | Nouveau dommage ? | Observations |
+|---|---|---|---|
+| Carrosserie avant | Bon / Rayé / Endommagé | Oui / Non | [●] |
+| Carrosserie arrière | Bon / Rayé / Endommagé | Oui / Non | [●] |
+| Côté droit | Bon / Rayé / Endommagé | Oui / Non | [●] |
+| Côté gauche | Bon / Rayé / Endommagé | Oui / Non | [●] |
+| Pare-brise | Bon / Impact / Fissure | Oui / Non | [●] |
+| Vitres | Bon / Endommagé | Oui / Non | [●] |
+| Pneus | Bon / Usé / Endommagé | Oui / Non | [●] |
+| Jantes | Bon / Rayé / Endommagé | Oui / Non | [●] |
+| Intérieur | Bon / Taché / Endommagé | Oui / Non | [●] |
+| Sièges | Bon / Taché / Déchiré | Oui / Non | [●] |
+| Documents | Complets / Manquants | Oui / Non | [●] |
+| Clés | Restituées / Manquantes | Oui / Non | [●] |
+
+## Frais constatés au retour
+
+| Frais | Montant |
+|---|---:|
+| Dommages | [●] MAD |
+| Franchise assurance | [●] MAD |
+| Kilométrage supplémentaire | [●] MAD |
+| Carburant | [●] MAD |
+| Nettoyage | [●] MAD |
+| Retard | [●] MAD |
+| Autres frais | [●] MAD |
+
+**Total à retenir sur la caution : [●] MAD**
+**Solde de caution à restituer : [●] MAD**
+
+**Photos prises au retour :** Oui / Non
+**Nombre de photos :** [●]
+
+**Signature du Loueur :**
+
+
+
+**Signature du Locataire :**
+
+
+
+---
+
+# Checklist pratique avant signature
+
+- Ne laisser aucun champ `[●]` vide dans la version signée.
+- Faire parapher chaque page par les deux Parties.
+- Joindre la copie CIN ou passeport du Locataire.
+- Joindre la copie du permis de conduire.
+- Photographier le compteur et le niveau de carburant au départ et au retour.
+- Photographier toutes les faces du véhicule au départ et au retour.
+- Écrire clairement le montant de la caution.
+- Mentionner la compagnie d’assurance, le numéro de police et la franchise.
+- Conserver une preuve de paiement.
+- Conserver les états des lieux signés.
+
+---
+
+# Note importante
+
+Ce modèle est un document pratique destiné à structurer une location de voiture au Maroc. Il doit être adapté à l’activité réelle du Loueur, à ses conditions d’assurance, à son statut fiscal et aux règles applicables. Pour un usage commercial régulier, une validation par un juriste ou avocat marocain est recommandée.
diff --git a/packages/database/prisma/migrations/20260525140000_pricing_promotions/migration.sql b/packages/database/prisma/migrations/20260525140000_pricing_promotions/migration.sql
new file mode 100644
index 0000000..c1b097c
--- /dev/null
+++ b/packages/database/prisma/migrations/20260525140000_pricing_promotions/migration.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS "pricing_promotions" (
+ "id" TEXT NOT NULL,
+ "code" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "discountType" TEXT NOT NULL,
+ "discountValue" INTEGER NOT NULL,
+ "plans" TEXT[] NOT NULL DEFAULT '{}',
+ "periods" TEXT[] NOT NULL DEFAULT '{}',
+ "maxUses" INTEGER,
+ "usedCount" INTEGER NOT NULL DEFAULT 0,
+ "validFrom" TIMESTAMP(3) NOT NULL,
+ "validUntil" TIMESTAMP(3),
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "createdBy" TEXT,
+ CONSTRAINT "pricing_promotions_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS "pricing_promotions_code_key" ON "pricing_promotions"("code");
+CREATE INDEX IF NOT EXISTS "pricing_promotions_isActive_idx" ON "pricing_promotions"("isActive");
diff --git a/packages/database/prisma/migrations/20260525153000_plan_features/migration.sql b/packages/database/prisma/migrations/20260525153000_plan_features/migration.sql
new file mode 100644
index 0000000..49c8de8
--- /dev/null
+++ b/packages/database/prisma/migrations/20260525153000_plan_features/migration.sql
@@ -0,0 +1,28 @@
+CREATE TABLE "plan_features" (
+ "id" TEXT NOT NULL,
+ "plan" "Plan" NOT NULL,
+ "label" TEXT NOT NULL,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "plan_features_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "plan_features_plan_sortOrder_idx" ON "plan_features"("plan", "sortOrder");
+
+INSERT INTO "plan_features" ("id", "plan", "label", "sortOrder") VALUES
+ ('plf_starter_1', 'STARTER', 'Up to 10 vehicles', 10),
+ ('plf_starter_2', 'STARTER', '1 user account', 20),
+ ('plf_starter_3', 'STARTER', 'Basic analytics', 30),
+ ('plf_starter_4', 'STARTER', 'Marketplace listing', 40),
+ ('plf_growth_1', 'GROWTH', 'Up to 50 vehicles', 10),
+ ('plf_growth_2', 'GROWTH', '5 user accounts', 20),
+ ('plf_growth_3', 'GROWTH', 'Full analytics', 30),
+ ('plf_growth_4', 'GROWTH', 'Priority marketplace placement', 40),
+ ('plf_growth_5', 'GROWTH', 'Custom branding', 50),
+ ('plf_pro_1', 'PRO', 'Unlimited vehicles', 10),
+ ('plf_pro_2', 'PRO', 'Unlimited user accounts', 20),
+ ('plf_pro_3', 'PRO', 'Advanced reports', 30),
+ ('plf_pro_4', 'PRO', 'API access', 40),
+ ('plf_pro_5', 'PRO', 'Dedicated support', 50);
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 45b05fd..8c3bac6 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -1179,3 +1179,37 @@ model PricingConfig {
@@unique([plan, billingPeriod])
@@map("pricing_configs")
}
+
+model PlanFeature {
+ id String @id @default(cuid())
+ plan Plan
+ label String
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([plan, sortOrder])
+ @@map("plan_features")
+}
+
+model PricingPromotion {
+ id String @id @default(cuid())
+ code String @unique
+ name String
+ description String?
+ discountType String // 'PERCENTAGE' | 'FIXED'
+ discountValue Int // percentage (1-100) or centimes for FIXED
+ plans String[] // empty array = all plans
+ periods String[] // empty array = all periods
+ maxUses Int?
+ usedCount Int @default(0)
+ validFrom DateTime
+ validUntil DateTime?
+ isActive Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ createdBy String?
+
+ @@index([isActive])
+ @@map("pricing_promotions")
+}
diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts
index f16e9c2..1d6dfcc 100644
--- a/packages/types/src/api.ts
+++ b/packages/types/src/api.ts
@@ -35,6 +35,29 @@ export const PLAN_PRICES: Record
>>
},
}
+export const PLAN_FEATURES: Record = {
+ STARTER: [
+ 'Up to 10 vehicles',
+ '1 user account',
+ 'Basic analytics',
+ 'Marketplace listing',
+ ],
+ GROWTH: [
+ 'Up to 50 vehicles',
+ '5 user accounts',
+ 'Full analytics',
+ 'Priority marketplace placement',
+ 'Custom branding',
+ ],
+ PRO: [
+ 'Unlimited vehicles',
+ 'Unlimited user accounts',
+ 'Advanced reports',
+ 'API access',
+ 'Dedicated support',
+ ],
+}
+
export type Locale = 'en' | 'fr' | 'ar'
export type SupportedCurrency = 'MAD'