update subscription module

This commit is contained in:
root
2026-05-25 04:58:56 -04:00
parent 1606ea0053
commit 8d0b7a5ceb
17 changed files with 4394 additions and 157 deletions
File diff suppressed because it is too large Load Diff
+739
View File
@@ -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 teams default language unless configured otherwise.
---
## 2. Source of Truth for Notification Language
The users 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 recipients 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 users 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
<html lang="ar" dir="rtl">
```
or for partial rendering:
```html
<div lang="ar" dir="rtl">
...
</div>
```
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
<html lang="fr">
```
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
<html lang="en">
```
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 users 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 recipients 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.
File diff suppressed because it is too large Load Diff
+71
View File
@@ -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 } })
}
+62 -1
View File
@@ -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) => {
@@ -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) })
@@ -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<typeof repo.createPlanFeature>[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<typeof repo.updatePlanFeature>[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<typeof repo.createPromotion>[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<typeof repo.updatePromotion>[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,
})
}
+12
View File
@@ -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)
+28
View File
@@ -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<Record<string, Record<string, Record<string, number>>>>((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)
@@ -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<string, { monthly: number; annual: number }> = {
STARTER: { monthly: 299, annual: 2990 },
GROWTH: { monthly: 399, annual: 3990 },
PRO: { monthly: 499, annual: 4990 },
type PricingMatrix = Record<string, Record<string, Record<string, number>>>
type PlanFeatureMap = Record<string, string[]>
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<Billing>('monthly')
const [pricing, setPricing] = useState<PlatformPricing>(initialPricing)
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const savings = annualSavingsPct('STARTER')
useEffect(() => {
marketplaceFetchOrDefault<PlatformPricing>('/site/platform/pricing', DEFAULT_PRICING)
.then(setPricing)
}, [])
const savings = annualSavingsPct(pricing.prices, 'STARTER')
return (
<div className="space-y-10">
@@ -141,10 +142,12 @@ export default function PricingClient() {
{/* Plan cards */}
<div className="grid gap-6 md:grid-cols-3">
{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 (
<div
@@ -177,13 +180,13 @@ export default function PricingClient() {
</div>
{billing === 'annual' && (
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
{dict.billedAs} {price.annual} MAD {dict.yearly}
{dict.billedAs} {annualPrice} MAD {dict.yearly}
</p>
)}
</div>
<ul className="mt-8 flex-1 space-y-3">
{plan.features.map((f) => (
{features.map((f) => (
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700 dark:text-stone-300">
<svg
className="mt-0.5 h-4 w-4 shrink-0 text-orange-500 dark:text-orange-400"
@@ -42,7 +42,7 @@ const copy = {
},
} as const
export default function PricingPageContent() {
export default function PricingPageContent({ initialPricing }: { initialPricing?: React.ComponentProps<typeof PricingClient>['initialPricing'] }) {
const { language } = useMarketplacePreferences()
const dict = copy[language]
@@ -56,7 +56,7 @@ export default function PricingPageContent() {
</div>
<div className="mt-16">
<PricingClient />
<PricingClient initialPricing={initialPricing} />
</div>
<div className="site-panel mx-auto mt-24 max-w-3xl divide-y divide-stone-200/80 dark:divide-stone-800">
@@ -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 <PricingPageContent />
const DEFAULT_PRICING = {
prices: PLAN_PRICES,
planFeatures: PLAN_FEATURES,
}
export default async function PricingPage() {
const initialPricing = await marketplaceFetchOrDefault('/site/platform/pricing', DEFAULT_PRICING)
return <PricingPageContent initialPricing={initialPricing} />
}
+558
View File
@@ -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 dun permis de conduire valide :
**Numéro du permis :** [●]
**Catégorie :** [B / autre]
**Date de délivrance :** [●]
**Date dexpiration :** [●]
**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 quil nest frappé daucune 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 daccident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume lentiè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 lassurance ;
- la franchise dassurance ;
- 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 dassurance :** [●]
**Numéro de police :** [●]
**Type de couverture :** [Responsabilité civile / Tous risques / autre]
**Franchise applicable :** [●] MAD
**Assistance :** [Oui / Non]
**Numéro dassistance :** [●]
Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables.
Sont notamment exclus de la couverture, sauf stipulation contraire de lassureur :
- conduite par une personne non autorisée ;
- conduite sans permis valide ;
- conduite sous lemprise dalcool, 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 daccident 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 sengage à 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 ;
- dutiliser 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 lagence linterdit ;
- 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, à lheure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de lusure normale.
Le véhicule doit être restitué avec :
- le même niveau de carburant quau 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 linté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 dassurance applicables.
---
## 13. Accident, panne, vol ou sinistre
En cas daccident, 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 daccident ;
- 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 lassureur.
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 lassureur 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 lassurance ;
- 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 lassurance ;
- de la franchise prévue au contrat dassurance.
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 lidentité 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 linfraction.
**Frais administratifs par infraction : [●] MAD TTC.**
---
## 16. Annulation, non-présentation et résiliation
En cas dannulation 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 dusage 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 à lexé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 :
- lexé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 sengage à 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 lattestation dassurance ;
- 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 sefforceront de trouver une solution amiable.
À défaut daccord amiable, le litige sera porté devant le tribunal compétent du ressort de :
**[Ville du siège de lagence / 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 dun permis valide ;
- sengager à 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 :**
<br><br>
### Signature du Locataire
**Nom :** [●]
**Signature :**
<br><br>
---
# 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 :**
<br><br>
**Signature du Locataire :**
<br><br>
---
# 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 :**
<br><br>
**Signature du Locataire :**
<br><br>
---
# 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 dassurance, 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é à lactivité réelle du Loueur, à ses conditions dassurance, à 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.
@@ -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");
@@ -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);
+34
View File
@@ -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")
}
+23
View File
@@ -35,6 +35,29 @@ export const PLAN_PRICES: Record<string, Record<string, Record<string, number>>>
},
}
export const PLAN_FEATURES: Record<string, string[]> = {
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'