update subscription module
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
<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 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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 :**
|
||||
|
||||
<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 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.
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user