update subscription algorithm

This commit is contained in:
root
2026-05-25 03:12:19 -04:00
parent 95376d3223
commit c9cbe479aa
42 changed files with 3098 additions and 307 deletions
File diff suppressed because it is too large Load Diff
+7
View File
@@ -2,8 +2,15 @@
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
const apiUrl = new URL(apiOrigin)
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
const assetPrefix = process.env.ADMIN_ASSET_PREFIX || undefined
const nextConfig = {
basePath: '/admin',
...(assetPrefix ? { assetPrefix } : {}),
images: {
remotePatterns: [
{
@@ -34,7 +34,7 @@ export default function AdminAuditLogsPage() {
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => setLogs(json.data ?? []))
.then((json) => setLogs(Array.isArray(json.data) ? json.data : (json.data?.data ?? [])))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
@@ -57,6 +57,10 @@ const SUB_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
TRIALING: 'text-sky-400 bg-sky-900/30',
PAST_DUE: 'text-orange-400 bg-orange-900/30',
PAYMENT_PENDING: 'text-yellow-400 bg-yellow-900/30',
SUSPENDED: 'text-red-400 bg-red-900/30',
EXPIRED: 'text-zinc-400 bg-zinc-800',
PAUSED: 'text-purple-400 bg-purple-900/30',
CANCELLED: 'text-zinc-400 bg-zinc-800',
UNPAID: 'text-red-400 bg-red-900/30',
}
@@ -75,7 +79,7 @@ const PLAN_COLORS: Record<string, string> = {
}
function fmt(amount: number, currency: string) {
return `${(amount / 100).toFixed(2)} ${currency}`
return `${amount.toFixed(2)} ${currency}`
}
function fmtDate(iso: string | null) {
@@ -221,8 +225,8 @@ export default function AdminBillingPage() {
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
setSubscriptions(json.data ?? [])
setStats(json.stats ?? null)
setSubscriptions(json.data?.data ?? [])
setStats(json.data?.stats ?? null)
} catch (err: any) {
setError(err.message)
} finally {
@@ -263,7 +267,7 @@ export default function AdminBillingPage() {
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`)
setInvoices(json.data ?? [])
setInvoices(json.data?.data ?? [])
} catch (err: any) {
setInvoicesError(err.message)
} finally {
@@ -290,7 +294,7 @@ export default function AdminBillingPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
<div className="col-span-2 sm:col-span-1 panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.mrr}</p>
<p className="mt-1 text-2xl font-bold text-emerald-400">{(stats.mrr / 100).toFixed(0)}</p>
<p className="mt-1 text-2xl font-bold text-emerald-400">{stats.mrr.toFixed(0)}</p>
</div>
<div className="panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.active}</p>
@@ -15,6 +15,7 @@ interface CompanyDetail {
subscriptionPaymentRef: string | null
createdAt: string
subscription: {
id: string
plan: string
billingPeriod: string
status: string
@@ -25,6 +26,10 @@ interface CompanyDetail {
currentPeriodEnd: string | null
cancelledAt: string | null
cancelAtPeriodEnd: boolean
paymentPendingSince: string | null
pastDueSince: string | null
suspendedAt: string | null
retryCount: number
} | null
brand: {
displayName: string
@@ -78,12 +83,21 @@ interface AuditLog {
adminUser: { email: string } | null
}
interface SubEvent {
id: string
eventType: string
source: string
payload: Record<string, any>
occurredAt: string
}
interface FormState {
company: {
name: string
slug: string
email: string
phone: string
status: string
subscriptionPaymentRef: string
}
subscription: {
@@ -168,6 +182,7 @@ function createFormState(company: CompanyDetail): FormState {
slug: company.slug ?? '',
email: company.email ?? '',
phone: company.phone ?? '',
status: company.status ?? 'TRIALING',
subscriptionPaymentRef: company.subscriptionPaymentRef ?? '',
},
subscription: {
@@ -227,6 +242,10 @@ export default function AdminCompanyDetailPage() {
const [company, setCompany] = useState<CompanyDetail | null>(null)
const [form, setForm] = useState<FormState | null>(null)
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
const [subEvents, setSubEvents] = useState<SubEvent[]>([])
const [subActioning, setSubActioning] = useState<string | null>(null)
const [subOverrideReason, setSubOverrideReason] = useState('')
const [showSubActions, setShowSubActions] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actioning, setActioning] = useState(false)
@@ -246,7 +265,14 @@ export default function AdminCompanyDetailPage() {
if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found')
setCompany(cJson.data)
setForm(createFormState(cJson.data))
setAuditLogs(aJson.data ?? [])
setAuditLogs(aJson.data?.data ?? [])
const subId = cJson.data?.subscription?.id
if (subId) {
const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { headers, cache: 'no-store' })
const eJson = await eRes.json()
setSubEvents(Array.isArray(eJson.data) ? eJson.data : [])
}
setError(null)
} catch (err: any) {
setError(err.message)
@@ -341,6 +367,29 @@ export default function AdminCompanyDetailPage() {
}
}
async function doSubAction(action: string) {
if (!company?.subscription?.id) return
if (!subOverrideReason.trim()) { setError('A reason is required for subscription overrides'); return }
setSubActioning(action)
setError(null)
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ reason: subOverrideReason }),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Action failed')
setSubOverrideReason('')
setShowSubActions(false)
await fetchData()
} catch (err: any) {
setError(err.message)
} finally {
setSubActioning(null)
}
}
async function changeStatus(status: string) {
setActioning(true)
setError(null)
@@ -445,7 +494,11 @@ export default function AdminCompanyDetailPage() {
<span className={LABEL_CLASS}>Phone</span>
<input className={INPUT_CLASS} value={form.company.phone} onChange={(e) => updateSection('company', { phone: e.target.value })} />
</label>
<label className="md:col-span-2">
<div>
<span className={LABEL_CLASS}>Company status</span>
<p className={`mt-1 text-sm font-semibold ${STATUS_COLORS[form.company.status] ?? 'text-zinc-400'}`}>{form.company.status}</p>
</div>
<label>
<span className={LABEL_CLASS}>Subscription payment ref</span>
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
</label>
@@ -470,16 +523,19 @@ export default function AdminCompanyDetailPage() {
<option value="ANNUAL">ANNUAL</option>
</select>
</label>
<label>
<div>
<span className={LABEL_CLASS}>Status</span>
<select className={INPUT_CLASS} value={form.subscription.status} onChange={(e) => updateSection('subscription', { status: e.target.value })}>
<option value="TRIALING">TRIALING</option>
<option value="ACTIVE">ACTIVE</option>
<option value="PAST_DUE">PAST_DUE</option>
<option value="CANCELLED">CANCELLED</option>
<option value="UNPAID">UNPAID</option>
</select>
</label>
<p className={`mt-1 text-sm font-semibold ${STATUS_COLORS[form.subscription.status] ?? 'text-zinc-400'}`}>{form.subscription.status}</p>
{company.subscription?.paymentPendingSince && (
<p className="mt-0.5 text-xs text-zinc-500">Pending since {new Date(company.subscription.paymentPendingSince).toLocaleDateString()}</p>
)}
{company.subscription?.pastDueSince && (
<p className="mt-0.5 text-xs text-orange-500">Past due since {new Date(company.subscription.pastDueSince).toLocaleDateString()}</p>
)}
{company.subscription?.suspendedAt && (
<p className="mt-0.5 text-xs text-red-500">Suspended {new Date(company.subscription.suspendedAt).toLocaleDateString()}</p>
)}
</div>
<label>
<span className={LABEL_CLASS}>Currency</span>
<select className={INPUT_CLASS} value={form.subscription.currency} onChange={(e) => updateSection('subscription', { currency: e.target.value })}>
@@ -680,8 +736,9 @@ export default function AdminCompanyDetailPage() {
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-4">
<div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Admin actions</h2>
<h2 className="text-base font-semibold">Company actions</h2>
<div className="space-y-3">
{company.status === 'SUSPENDED' ? (
<button onClick={() => changeStatus('ACTIVE')} disabled={actioning} className="w-full rounded-xl bg-emerald-900/40 py-2.5 text-sm font-semibold text-emerald-400 transition-colors hover:bg-emerald-900/60 disabled:opacity-50">
@@ -708,6 +765,66 @@ export default function AdminCompanyDetailPage() {
</div>
</div>
{company.subscription && (
<div className="panel p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Subscription overrides</h2>
<button onClick={() => setShowSubActions((v) => !v)} className="text-xs text-zinc-400 hover:text-zinc-200">
{showSubActions ? 'Hide' : 'Show'}
</button>
</div>
{showSubActions && (
<div className="space-y-3">
<div>
<label className="text-xs font-medium uppercase tracking-wide text-zinc-500">Reason (required)</label>
<input
className="mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500"
placeholder="e.g. Customer requested extension…"
value={subOverrideReason}
onChange={(e) => setSubOverrideReason(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => doSubAction('reactivate')} disabled={!!subActioning} className="rounded-lg bg-emerald-950/50 py-2 text-xs font-semibold text-emerald-400 hover:bg-emerald-900/50 disabled:opacity-50">
{subActioning === 'reactivate' ? '…' : 'Reactivate'}
</button>
<button onClick={() => doSubAction('extend-grace-period')} disabled={!!subActioning} className="rounded-lg bg-sky-950/50 py-2 text-xs font-semibold text-sky-400 hover:bg-sky-900/50 disabled:opacity-50">
{subActioning === 'extend-grace-period' ? '…' : '+7d grace'}
</button>
<button onClick={() => doSubAction('suspend')} disabled={!!subActioning} className="rounded-lg bg-orange-950/50 py-2 text-xs font-semibold text-orange-400 hover:bg-orange-900/50 disabled:opacity-50">
{subActioning === 'suspend' ? '…' : 'Suspend'}
</button>
<button onClick={() => doSubAction('cancel')} disabled={!!subActioning} className="rounded-lg bg-red-950/50 py-2 text-xs font-semibold text-red-400 hover:bg-red-900/50 disabled:opacity-50">
{subActioning === 'cancel' ? '…' : 'Cancel'}
</button>
</div>
<p className="text-xs text-zinc-600">Retry count: {company.subscription.retryCount ?? 0} / 5</p>
</div>
)}
</div>
)}
</div>
<div className="space-y-4">
{subEvents.length > 0 && (
<div className="panel overflow-hidden">
<div className="border-b border-zinc-800 px-6 py-4">
<h2 className="text-sm font-semibold">Subscription timeline</h2>
</div>
<div className="divide-y divide-zinc-800/60 max-h-72 overflow-y-auto">
{subEvents.map((ev) => (
<div key={ev.id} className="px-6 py-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-sky-400">{ev.eventType}</span>
<span className="text-xs text-zinc-600">{new Date(ev.occurredAt).toLocaleString()}</span>
</div>
<p className="mt-0.5 text-xs text-zinc-500">source: {ev.source}</p>
</div>
))}
</div>
</div>
)}
{auditLogs.length > 0 && (
<div className="panel overflow-hidden">
<div className="border-b border-zinc-800 px-6 py-4">
@@ -729,5 +846,6 @@ export default function AdminCompanyDetailPage() {
)}
</div>
</div>
</div>
)
}
@@ -88,8 +88,9 @@ export default function AdminCompaniesPage() {
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
setCompanies(json.data ?? [])
setFiltered(json.data ?? [])
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
setCompanies(list)
setFiltered(list)
} catch (err: any) {
setError(err.message)
} finally {
+1
View File
@@ -27,6 +27,7 @@ const navLinks = [
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
{ href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
]
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
@@ -0,0 +1,224 @@
'use client'
import { useEffect, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
interface PricingConfig {
id: string
plan: string
billingPeriod: string
amount: number
updatedAt: string
updatedBy: string | null
}
type Draft = Record<string, string>
const PLANS = ['STARTER', 'GROWTH', 'PRO']
const PERIODS = ['MONTHLY', 'ANNUAL']
const PLAN_COLORS: Record<string, string> = {
STARTER: 'text-zinc-400',
GROWTH: 'text-sky-400',
PRO: 'text-violet-400',
}
function key(plan: string, period: string) {
return `${plan}:${period}`
}
function fmt(amount: number) {
return (amount / 100).toFixed(2)
}
function parseCentimes(value: string): number | null {
const n = parseFloat(value)
if (isNaN(n) || n < 0) return null
return Math.round(n * 100)
}
export default function PricingPage() {
const [configs, setConfigs] = useState<PricingConfig[]>([])
const [draft, setDraft] = useState<Draft>({})
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [successMsg, setSuccessMsg] = useState('')
useEffect(() => {
const token = localStorage.getItem('admin_token')
fetch(`${ADMIN_API_BASE}/admin/pricing`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => {
if (!json?.data) throw new Error(json?.message ?? 'Failed to load')
const list: PricingConfig[] = json.data
setConfigs(list)
const initial: Draft = {}
for (const c of list) {
initial[key(c.plan, c.billingPeriod)] = fmt(c.amount)
}
setDraft(initial)
setStatus('ready')
})
.catch((err) => {
setError(err.message)
setStatus('error')
})
}, [])
async function handleSave() {
setError('')
setSuccessMsg('')
const entries: { plan: string; billingPeriod: string; amount: number }[] = []
for (const plan of PLANS) {
for (const period of PERIODS) {
const k = key(plan, period)
const amount = parseCentimes(draft[k] ?? '')
if (amount === null) {
setError(`Invalid value for ${plan} ${period}`)
return
}
entries.push({ plan, billingPeriod: period, amount })
}
}
setSaving(true)
try {
const token = localStorage.getItem('admin_token')
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ entries }),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Save failed')
const updated: PricingConfig[] = json.data
setConfigs(updated)
const refreshed: Draft = {}
for (const c of updated) {
refreshed[key(c.plan, c.billingPeriod)] = fmt(c.amount)
}
setDraft(refreshed)
setSuccessMsg('Prices saved successfully.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
if (status === 'loading') {
return (
<div className="flex h-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
</div>
)
}
if (status === 'error') {
return (
<div className="p-8">
<div className="rounded-2xl border border-red-800/40 bg-red-900/20 p-6 text-sm text-red-400">{error}</div>
</div>
)
}
const lastUpdated = configs
.filter((c) => c.updatedAt)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
return (
<div className="p-8 space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-black tracking-tight text-blue-950 dark:text-white">Subscription Pricing</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
Manage plan prices in MAD (values are entered in MAD, e.g. 29.90).
</p>
{lastUpdated && (
<p className="mt-1 text-xs text-stone-400 dark:text-slate-500">
Last updated {new Date(lastUpdated.updatedAt).toLocaleString()}
{lastUpdated.updatedBy ? ` by ${lastUpdated.updatedBy}` : ''}
</p>
)}
</div>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white disabled:opacity-60 hover:bg-orange-500 transition-colors"
>
{saving ? 'Saving…' : 'Save prices'}
</button>
</div>
{error && (
<div className="rounded-2xl border border-red-800/40 bg-red-900/20 px-4 py-3 text-sm text-red-400">{error}</div>
)}
{successMsg && (
<div className="rounded-2xl border border-emerald-800/40 bg-emerald-900/20 px-4 py-3 text-sm text-emerald-400">{successMsg}</div>
)}
<div className="rounded-2xl border border-stone-200/80 bg-white shadow-sm transition-colors dark:border-blue-900 dark:bg-[#0d1b38]">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-stone-200/80 dark:border-blue-900">
<th className="px-6 py-4 text-left text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Plan</th>
{PERIODS.map((period) => (
<th key={period} className="px-6 py-4 text-center text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">
{period}
</th>
))}
</tr>
</thead>
<tbody>
{PLANS.map((plan, i) => (
<tr
key={plan}
className={i < PLANS.length - 1 ? 'border-b border-stone-100 dark:border-blue-900/60' : ''}
>
<td className="px-6 py-5">
<span className={`text-sm font-bold ${PLAN_COLORS[plan]}`}>{plan}</span>
</td>
{PERIODS.map((period) => {
const k = key(plan, period)
const config = configs.find((c) => c.plan === plan && c.billingPeriod === period)
return (
<td key={period} className="px-6 py-5 text-center">
<div className="inline-flex items-center gap-1">
<input
type="number"
min="0"
step="0.01"
value={draft[k] ?? ''}
onChange={(e) => setDraft((prev) => ({ ...prev, [k]: e.target.value }))}
className="w-28 rounded-xl border border-stone-200 bg-white px-3 py-2 text-right text-sm font-medium text-blue-900 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white"
/>
<span className="text-xs font-medium text-stone-400 dark:text-slate-500">MAD</span>
</div>
{config && (
<p className="mt-1 text-xs text-stone-400 dark:text-slate-500">
Current: {fmt(config.amount)} MAD
</p>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="rounded-2xl border border-stone-200/80 bg-stone-50 p-5 text-xs text-stone-500 dark:border-blue-900 dark:bg-[#07101e] dark:text-slate-500">
<p className="font-semibold uppercase tracking-[0.14em]">Note</p>
<p className="mt-1">Prices are stored in centimes (1 MAD = 100 centimes) internally. Enter values in MAD (e.g. 29.90 for 2990 centimes). Changes take effect immediately for new checkout sessions.</p>
</div>
</div>
)
}
@@ -80,8 +80,9 @@ export default function AdminRentersPage() {
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed')
setRenters(json.data ?? [])
setFiltered(json.data ?? [])
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
setRenters(list)
setFiltered(list)
} catch (err: any) {
setError(err.message)
} finally {
@@ -198,8 +198,8 @@ export default function AdminSiteConfigPage() {
const companiesJson = await companiesRes.json()
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
setCompanies(companiesJson.data ?? [])
setFiltered(companiesJson.data ?? [])
setCompanies(companiesJson.data?.data ?? [])
setFiltered(companiesJson.data?.data ?? [])
const homepageJson = await homepageRes.json()
if (homepageRes.ok && homepageJson?.data) {
@@ -28,6 +28,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
auditLogs: 'Audit Logs',
adminUsers: 'Admin Users',
billing: 'Billing',
pricing: 'Pricing',
},
logout: 'Logout',
language: 'Language',
@@ -48,6 +49,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
auditLogs: "Journaux d'audit",
adminUsers: 'Utilisateurs admin',
billing: 'Facturation',
pricing: 'Tarification',
},
logout: 'Déconnexion',
language: 'Langue',
@@ -68,6 +70,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
auditLogs: 'سجلات التدقيق',
adminUsers: 'مستخدمو الإدارة',
billing: 'الفوترة',
pricing: 'الأسعار',
},
logout: 'تسجيل الخروج',
language: 'اللغة',
+33
View File
@@ -7,6 +7,13 @@ import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import {
runTrialExpirationJob,
runPaymentPendingTimeoutJob,
runPastDueTimeoutJob,
runSuspensionTimeoutJob,
runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service'
const app = createApp()
const server = http.createServer(app)
@@ -74,6 +81,32 @@ cron.schedule('0 8 * * *', async () => {
}
})
// Hourly: expire trials that ended without payment
cron.schedule('0 * * * *', async () => {
const n = await runTrialExpirationJob()
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
})
// Hourly: payment_pending → past_due after 7 days
cron.schedule('15 * * * *', async () => {
const n = await runPaymentPendingTimeoutJob()
if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`)
})
// Hourly: past_due → suspended after 7 days
cron.schedule('30 * * * *', async () => {
const n = await runPastDueTimeoutJob()
if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`)
})
// Daily: suspended → cancelled after 16 days; and period-end cancellations
cron.schedule('0 1 * * *', async () => {
const nSuspend = await runSuspensionTimeoutJob()
const nPeriod = await runPeriodEndCancellationJob()
if (nSuspend > 0) console.log(`[subscription] suspension_timeout: ${nSuspend} cancelled`)
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
})
// Daily: send trial-ending reminders (3 days before trial end)
cron.schedule('0 9 * * *', async () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
+12
View File
@@ -405,3 +405,15 @@ export function getInvoicePdfRecord(invoiceId: string) {
},
})
}
export function listPricingConfigs() {
return prisma.pricingConfig.findMany({ orderBy: [{ plan: 'asc' }, { billingPeriod: 'asc' }] })
}
export function upsertPricingConfig(plan: string, billingPeriod: string, amount: number, updatedBy?: string) {
return prisma.pricingConfig.upsert({
where: { plan_billingPeriod: { plan, billingPeriod } },
update: { amount, updatedBy },
create: { id: `prc_${plan.toLowerCase()}_${billingPeriod.toLowerCase()}`, plan, billingPeriod, amount, updatedBy },
})
}
@@ -3,6 +3,7 @@ import { requireAdminAuth, requireAdminRole } from '../../middleware/requireAdmi
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
@@ -10,7 +11,18 @@ import {
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema,
pricingUpdateSchema,
} from './admin.schemas'
import { z } from 'zod'
const adminSubOverrideSchema = z.object({
reason: z.string().min(1).max(500),
})
const adminExtendTrialSchema = z.object({
extraDays: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
const subIdParamSchema = z.object({ subscriptionId: z.string() })
const router = Router()
@@ -208,6 +220,70 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol
} catch (err) { next(err) }
})
// ─── Pricing config ────────────────────────────────────────────
router.get('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.getPricingConfigs())
} catch (err) { next(err) }
})
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { entries } = parseBody(pricingUpdateSchema, req)
ok(res, await service.updatePricingConfigs(entries, req.admin.id, req.ip))
} catch (err) { next(err) }
})
// ─── Subscription admin overrides ─────────────────────────────
router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
ok(res, await subService.getEventsBySubscriptionId(subscriptionId))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { extraDays, reason } = parseBody(adminExtendTrialSchema, req)
ok(res, await subService.adminExtendTrial(subscriptionId, extraDays, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminExtendGracePeriod(subscriptionId, 7, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminSuspendSubscription(subscriptionId, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminReactivateSubscription(subscriptionId, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminCancelSubscription(subscriptionId, req.admin.id, reason))
} catch (err) { next(err) }
})
// ─── Site config ───────────────────────────────────────────────
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
+11 -3
View File
@@ -99,7 +99,7 @@ export const adminCompanyUpdateSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
currency: z.literal('MAD').optional(),
trialStartAt: nullableDate, trialEndAt: nullableDate,
currentPeriodStart: nullableDate, currentPeriodEnd: nullableDate,
cancelledAt: nullableDate, cancelAtPeriodEnd: z.boolean().optional(),
@@ -110,7 +110,7 @@ export const adminCompanyUpdateSchema = z.object({
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
defaultCurrency: z.literal('MAD').optional(),
isListedOnMarketplace: z.boolean().optional(),
homePageConfig: z.any().optional(),
menuConfig: z.any().optional(),
@@ -125,7 +125,7 @@ export const adminCompanyUpdateSchema = z.object({
accountingSettings: z.object({
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
fiscalYearStart: z.number().int().min(1).max(12).optional(),
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
currency: z.literal('MAD').optional(),
accountantEmail: nullableEmail, accountantName: nullableString,
autoSendReport: z.boolean().optional(),
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
@@ -174,3 +174,11 @@ export const invoiceIdParamSchema = z.object({
export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema,
})
export const pricingUpdateSchema = z.object({
entries: z.array(z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
amount: z.number().int().positive(),
})).min(1),
})
@@ -296,3 +296,22 @@ export async function updateMarketplaceHomepage(homepage: any, adminId: string,
})
return saved
}
export function getPricingConfigs() {
return repo.listPricingConfigs()
}
export async function updatePricingConfigs(entries: { plan: string; billingPeriod: string; amount: number }[], adminId: string, ip?: string) {
const results = await Promise.all(
entries.map((e) => repo.upsertPricingConfig(e.plan, e.billingPeriod, e.amount, adminId)),
)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'PricingConfig',
after: toAuditJson(results),
ipAddress: ip,
userAgent: undefined,
})
return results
}
@@ -15,7 +15,7 @@ type CompanySignupInput = {
managerName: string
fax?: string
yearsActive: string
currency: 'MAD' | 'USD' | 'EUR'
currency: 'MAD'
registrationNumber: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
@@ -18,6 +18,6 @@ export const companySignupSchema = z.object({
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
currency: z.literal('MAD'),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
})
@@ -5,7 +5,7 @@ export const renterUpdateSchema = z.object({
lastName: z.string().min(1).max(100).trim().optional(),
phone: z.string().max(30).trim().optional(),
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
preferredCurrency: z.literal('MAD').optional(),
})
export const renterFcmTokenSchema = z.object({
@@ -20,7 +20,7 @@ export const brandSchema = z.object({
websiteUrl: z.string().url().optional(),
whatsappNumber: z.string().optional(),
defaultLocale: z.string().optional(),
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
defaultCurrency: z.literal('MAD').optional(),
amanpayMerchantId: z.string().optional(),
amanpaySecretKey: z.string().optional(),
paypalEmail: z.string().email().optional(),
@@ -146,7 +146,7 @@ export const pricingRuleSchema = z.object({
export const accountingSettingsSchema = z.object({
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
fiscalYearStart: z.number().int().min(1).max(12).optional(),
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
currency: z.literal('MAD').optional(),
accountantEmail: z.string().email().optional(),
accountantName: z.string().optional(),
autoSendReport: z.boolean().optional(),
@@ -3,14 +3,14 @@ import { z } from 'zod'
export const chargeSchema = z.object({
provider: z.enum(['AMANPAY', 'PAYPAL']),
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
currency: z.literal('MAD').default('MAD'),
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
export const manualPaymentSchema = z.object({
amount: z.number().int().positive(),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
currency: z.literal('MAD').default('MAD'),
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']),
})
@@ -41,7 +41,7 @@ export async function handlePaypalWebhook(event: any) {
export async function initCharge(reservationId: string, companyId: string, body: {
provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT'
currency: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
currency: 'MAD'; successUrl: string; failureUrl: string
}) {
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid')
+1 -1
View File
@@ -44,7 +44,7 @@ export const bookSchema = z.object({
export const paySchema = z.object({
provider: z.enum(['AMANPAY', 'PAYPAL']),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
currency: z.literal('MAD').default('MAD'),
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
+1 -1
View File
@@ -159,7 +159,7 @@ export async function getBooking(slug: string, reservationId: string) {
}
export async function initPayment(slug: string, reservationId: string, body: {
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string
}) {
const company = await repo.findCompanyBySlug(slug)
const reservation = await repo.findReservationForPayment(reservationId, company.id)
@@ -0,0 +1,47 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../../lib/prisma'
import { getAccessLevel, hasAnyAccess } from './subscription.policy'
export async function requireActiveSubscription(req: Request, res: Response, next: NextFunction) {
const companyId = req.companyId
if (!companyId) return res.status(401).json({ error: 'unauthenticated' })
const sub = await prisma.subscription.findUnique({ where: { companyId } })
const status = sub?.status ?? 'EXPIRED'
const accessLevel = getAccessLevel(status)
if (!hasAnyAccess(status)) {
return res.status(403).json({
error: 'subscription_required',
message: 'Your subscription has ended. Please reactivate to continue.',
subscriptionStatus: status,
accessLevel,
})
}
;(req as any).subscriptionStatus = status
;(req as any).accessLevel = accessLevel
next()
}
export async function requireFullAccess(req: Request, res: Response, next: NextFunction) {
const companyId = req.companyId
if (!companyId) return res.status(401).json({ error: 'unauthenticated' })
const sub = await prisma.subscription.findUnique({ where: { companyId } })
const status = sub?.status ?? 'EXPIRED'
const accessLevel = getAccessLevel(status)
if (accessLevel !== 'full') {
return res.status(403).json({
error: 'insufficient_access',
message: 'This action requires an active subscription.',
subscriptionStatus: status,
accessLevel,
})
}
;(req as any).subscriptionStatus = status
;(req as any).accessLevel = accessLevel
next()
}
@@ -0,0 +1,51 @@
export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none'
export const SUBSCRIPTION_POLICY = {
trial: {
durationDays: 14,
requiresPaymentMethod: false, // set true when payment capture is mandatory
oneTrialPerCompany: true,
},
payment: {
paymentPendingTimeoutDays: 7,
retryScheduleDays: [0, 3, 6, 10, 14],
maxRetryAttempts: 5,
},
pastDue: {
timeoutDays: 7,
},
suspension: {
cancelAfterDays: 16,
},
notifications: {
trialEndingDaysBefore: [7, 3, 1],
paymentFailureDaysAfter: [0, 3, 6],
pastDueDaysAfter: [7],
suspensionDaysAfter: [14],
cancellationDaysAfter: [30],
},
} as const
export const ACCESS_MAP: Record<string, AccessLevel> = {
TRIALING: 'full',
ACTIVE: 'full',
PAYMENT_PENDING: 'full',
UNPAID: 'full',
PAST_DUE: 'limited',
SUSPENDED: 'read_only',
CANCELLED: 'none',
EXPIRED: 'none',
PAUSED: 'read_only',
}
export function getAccessLevel(status: string): AccessLevel {
return ACCESS_MAP[status] ?? 'none'
}
export function hasFullAccess(status: string): boolean {
return getAccessLevel(status) === 'full'
}
export function hasAnyAccess(status: string): boolean {
return getAccessLevel(status) !== 'none'
}
@@ -1,60 +1,336 @@
import { prisma } from '../../lib/prisma'
// ─── Subscription queries ─────────────────────────────────────
export function findByCompany(companyId: string) {
return prisma.subscription.findUnique({
where: { companyId },
include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } },
include: {
invoices: { orderBy: { createdAt: 'desc' }, take: 12 },
events: { orderBy: { occurredAt: 'desc' }, take: 20 },
},
})
}
export function findById(id: string) {
return prisma.subscription.findUnique({
where: { id },
include: { company: true },
})
}
export function findInvoices(companyId: string) {
return prisma.subscriptionInvoice.findMany({ where: { companyId }, orderBy: { createdAt: 'desc' }, take: 50 })
return prisma.subscriptionInvoice.findMany({
where: { companyId },
orderBy: { createdAt: 'desc' },
take: 50,
})
}
export function findInvoiceByAmanpay(transactionId: string) {
return prisma.subscriptionInvoice.findFirst({ where: { amanpayTransactionId: transactionId }, include: { subscription: true } })
return prisma.subscriptionInvoice.findFirst({
where: { amanpayTransactionId: transactionId },
include: { subscription: true },
})
}
export function findInvoiceByPaypal(captureId: string) {
return prisma.subscriptionInvoice.findFirst({ where: { paypalCaptureId: captureId }, include: { subscription: true } })
return prisma.subscriptionInvoice.findFirst({
where: { paypalCaptureId: captureId },
include: { subscription: true },
})
}
export function findInvoiceByPaypalForCompany(paypalOrderId: string, companyId: string) {
return prisma.subscriptionInvoice.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId }, include: { subscription: true } })
return prisma.subscriptionInvoice.findFirstOrThrow({
where: { paypalCaptureId: paypalOrderId, companyId },
include: { subscription: true },
})
}
export function markInvoicePaid(id: string) {
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() } })
export function findInvoiceById(id: string) {
return prisma.subscriptionInvoice.findUniqueOrThrow({
where: { id },
include: { subscription: true },
})
}
// ─── Subscription mutations ───────────────────────────────────
export async function findOrCreateSubscription(
companyId: string,
plan: string,
billingPeriod: string,
currency: string,
) {
const existing = await prisma.subscription.findUnique({ where: { companyId } })
if (existing) return existing
return prisma.subscription.create({
data: { companyId, plan: plan as any, billingPeriod: billingPeriod as any, currency, status: 'PAYMENT_PENDING' as any },
})
}
export function activateSubscription(id: string, periodEnd: Date) {
return prisma.subscription.update({
where: { id },
data: { status: 'ACTIVE', currentPeriodStart: new Date(), currentPeriodEnd: periodEnd },
data: {
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: periodEnd,
paymentPendingSince: null,
paymentDueAt: null,
pastDueSince: null,
suspendedAt: null,
retryCount: 0,
},
})
}
export async function findOrCreateSubscription(companyId: string, plan: string, billingPeriod: string, currency: string) {
const existing = await prisma.subscription.findUnique({ where: { companyId } })
if (existing) return existing
return prisma.subscription.create({ data: { companyId, plan: plan as any, billingPeriod: billingPeriod as any, currency, status: 'PENDING' as any } })
export function startTrial(
companyId: string,
plan: string,
billingPeriod: string,
currency: string,
trialEndAt: Date,
) {
return prisma.subscription.upsert({
where: { companyId },
update: {
plan: plan as any,
billingPeriod: billingPeriod as any,
currency,
status: 'TRIALING',
trialStartAt: new Date(),
trialEndAt,
trialUsed: true,
},
create: {
companyId,
plan: plan as any,
billingPeriod: billingPeriod as any,
currency,
status: 'TRIALING',
trialStartAt: new Date(),
trialEndAt,
trialUsed: true,
},
})
}
export function createInvoice(data: {
companyId: string; subscriptionId: string; amount: number; currency: string
paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null
}) {
return prisma.subscriptionInvoice.create({ data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any } })
export function setPaymentPending(id: string) {
const now = new Date()
const dueAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
return prisma.subscription.update({
where: { id },
data: {
status: 'PAYMENT_PENDING',
paymentPendingSince: now,
paymentDueAt: dueAt,
},
})
}
export function updateInvoicePaypal(id: string, captureId: string) {
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId } })
export function setPastDue(id: string) {
return prisma.subscription.update({
where: { id },
data: {
status: 'PAST_DUE',
pastDueSince: new Date(),
},
})
}
export function setSuspended(id: string) {
return prisma.subscription.update({
where: { id },
data: {
status: 'SUSPENDED',
suspendedAt: new Date(),
},
})
}
export function setExpired(id: string) {
return prisma.subscription.update({
where: { id },
data: { status: 'EXPIRED', endedAt: new Date() },
})
}
export function setCancelled(id: string, immediate: boolean) {
if (immediate) {
return prisma.subscription.update({
where: { id },
data: { status: 'CANCELLED', cancelledAt: new Date(), endedAt: new Date() },
})
}
return prisma.subscription.update({
where: { id },
data: { cancelAtPeriodEnd: true },
})
}
export function setCancelAtPeriodEnd(companyId: string, value: boolean) {
return prisma.subscription.update({
where: { companyId },
data: { cancelAtPeriodEnd: value },
})
}
export function incrementRetryCount(id: string) {
return prisma.subscription.update({
where: { id },
data: { retryCount: { increment: 1 } },
})
}
export function updatePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
return prisma.subscription.update({ where: { companyId }, data })
}
export function setCancelAtPeriodEnd(companyId: string, value: boolean) {
return prisma.subscription.update({ where: { companyId }, data: { cancelAtPeriodEnd: value } })
// ─── Invoice mutations ────────────────────────────────────────
export function createInvoice(data: {
companyId: string
subscriptionId: string
amount: number
currency: string
paymentProvider: string
amanpayTransactionId?: string | null
paypalCaptureId?: string | null
dueAt?: Date | null
}) {
return prisma.subscriptionInvoice.create({
data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any },
})
}
export function markInvoicePaid(id: string) {
return prisma.subscriptionInvoice.update({
where: { id },
data: { status: 'PAID', paidAt: new Date(), failedAt: null },
})
}
export function markInvoiceFailed(id: string) {
return prisma.subscriptionInvoice.update({
where: { id },
data: { status: 'FAILED', failedAt: new Date() },
})
}
export function markInvoiceVoided(id: string) {
return prisma.subscriptionInvoice.update({
where: { id },
data: { status: 'VOIDED', voidedAt: new Date() },
})
}
export function updateInvoicePaypal(id: string, captureId: string) {
return prisma.subscriptionInvoice.update({
where: { id },
data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId, failedAt: null },
})
}
// ─── Payment attempts ─────────────────────────────────────────
export function createPaymentAttempt(data: {
invoiceId: string
subscriptionId: string
providerPaymentId?: string | null
status: string
failureCode?: string | null
failureMessage?: string | null
}) {
return prisma.paymentAttempt.create({
data: { ...data, attemptedAt: new Date() },
})
}
export function getPaymentAttempts(invoiceId: string) {
return prisma.paymentAttempt.findMany({
where: { invoiceId },
orderBy: { attemptedAt: 'desc' },
})
}
// ─── Subscription events ──────────────────────────────────────
export function createEvent(data: {
subscriptionId: string
companyId: string
eventType: string
source?: string
payload?: object
}) {
return prisma.subscriptionEvent.create({
data: {
...data,
source: data.source ?? 'system',
payload: (data.payload ?? {}) as any,
occurredAt: new Date(),
},
})
}
export function getEvents(subscriptionId: string) {
return prisma.subscriptionEvent.findMany({
where: { subscriptionId },
orderBy: { occurredAt: 'desc' },
take: 50,
})
}
// ─── Scheduled job helpers ────────────────────────────────────
export function findPaymentPendingTimedOut(cutoffDate: Date) {
return prisma.subscription.findMany({
where: {
status: 'PAYMENT_PENDING',
paymentPendingSince: { lte: cutoffDate },
},
include: { company: true },
})
}
export function findPastDueTimedOut(cutoffDate: Date) {
return prisma.subscription.findMany({
where: {
status: 'PAST_DUE',
pastDueSince: { lte: cutoffDate },
},
include: { company: true },
})
}
export function findSuspendedTimedOut(cutoffDate: Date) {
return prisma.subscription.findMany({
where: {
status: 'SUSPENDED',
suspendedAt: { lte: cutoffDate },
},
include: { company: true },
})
}
export function findTrialsEndedWithoutConversion() {
return prisma.subscription.findMany({
where: {
status: 'TRIALING',
trialEndAt: { lte: new Date() },
},
include: { company: { include: { employees: { where: { role: 'OWNER' }, take: 1 } } } },
})
}
export function findSubscriptionsToExpireAtPeriodEnd() {
return prisma.subscription.findMany({
where: {
status: 'ACTIVE',
cancelAtPeriodEnd: true,
currentPeriodEnd: { lte: new Date() },
},
include: { company: true },
})
}
@@ -7,7 +7,14 @@ import { ok } from '../../http/respond'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as service from './subscription.service'
import { checkoutSchema, changePlanSchema, capturePaypalSchema } from './subscription.schemas'
import {
checkoutSchema,
changePlanSchema,
capturePaypalSchema,
startTrialSchema,
cancelSchema,
reactivateSchema,
} from './subscription.schemas'
const publicRouter = Router()
const webhookRouter = Router()
@@ -15,8 +22,8 @@ const router = Router()
// ─── Public ────────────────────────────────────────────────────
publicRouter.get('/plans', (_req, res) => {
ok(res, service.getPlans())
publicRouter.get('/plans', (_req, res, next) => {
service.getPlans().then((d) => ok(res, d)).catch(next)
})
publicRouter.get('/providers', (_req, res) => {
@@ -56,19 +63,30 @@ router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, re
} catch (err) { next(err) }
})
// ─── Authenticated ────────────────────────────────────────────
// ─── Authenticated ────────────────────────────────────────────
router.use(requireCompanyAuth, requireTenant)
router.get('/me', async (req, res, next) => {
try {
ok(res, await service.getSubscription(req.companyId))
} catch (err) { next(err) }
try { ok(res, await service.getSubscription(req.companyId)) } catch (err) { next(err) }
})
router.get('/invoices', async (req, res, next) => {
try { ok(res, await service.getInvoices(req.companyId)) } catch (err) { next(err) }
})
router.get('/events', async (req, res, next) => {
try { ok(res, await service.getEvents(req.companyId)) } catch (err) { next(err) }
})
router.get('/entitlement', async (req, res, next) => {
try { ok(res, await service.getEntitlement(req.companyId)) } catch (err) { next(err) }
})
router.post('/trial', requireRole('OWNER'), async (req, res, next) => {
try {
ok(res, await service.getInvoices(req.companyId))
const body = parseBody(startTrialSchema, req)
ok(res, await service.startTrial(req.companyId, body.plan, body.billingPeriod, body.currency))
} catch (err) { next(err) }
})
@@ -79,6 +97,13 @@ router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(reactivateSchema, req)
ok(res, await service.reactivate(req.companyId, body))
} catch (err) { next(err) }
})
router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(changePlanSchema, req)
@@ -88,14 +113,13 @@ router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
router.post('/cancel', requireRole('OWNER'), async (req, res, next) => {
try {
ok(res, await service.cancel(req.companyId))
const { mode, reason } = parseBody(cancelSchema, req)
ok(res, await service.cancel(req.companyId, mode, reason))
} catch (err) { next(err) }
})
router.post('/resume', requireRole('OWNER'), async (req, res, next) => {
try {
ok(res, await service.resume(req.companyId))
} catch (err) { next(err) }
try { ok(res, await service.resume(req.companyId)) } catch (err) { next(err) }
})
export default router
@@ -1,20 +1,44 @@
import { z } from 'zod'
const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO'])
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
const providerEnum = z.enum(['AMANPAY', 'PAYPAL'])
export const checkoutSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
provider: z.enum(['AMANPAY', 'PAYPAL']),
plan: planEnum,
billingPeriod: billingPeriodEnum,
currency: z.literal('MAD'),
provider: providerEnum,
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
export const changePlanSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
plan: planEnum,
billingPeriod: billingPeriodEnum,
currency: z.literal('MAD'),
})
export const capturePaypalSchema = z.object({
paypalOrderId: z.string(),
})
export const startTrialSchema = z.object({
plan: planEnum,
billingPeriod: billingPeriodEnum,
currency: z.literal('MAD').default('MAD'),
})
export const cancelSchema = z.object({
mode: z.enum(['period_end', 'immediate']).default('period_end'),
reason: z.string().max(500).optional(),
})
export const reactivateSchema = z.object({
plan: planEnum,
billingPeriod: billingPeriodEnum,
currency: z.literal('MAD'),
provider: providerEnum,
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
@@ -4,6 +4,9 @@ import { ValidationError } from '../../http/errors'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './subscription.repo'
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
// ─── Helpers ──────────────────────────────────────────────────
export function addPeriod(date: Date, period: string): Date {
const d = new Date(date)
@@ -11,14 +14,25 @@ export function addPeriod(date: Date, period: string): Date {
return d
}
export function getPlans() {
return PLAN_PRICES
// ─── Plans & providers ────────────────────────────────────────
export async function getPlans() {
const configs = await prisma.pricingConfig.findMany()
if (configs.length === 0) return PLAN_PRICES
const result: Record<string, Record<string, Record<string, number>>> = {}
for (const c of configs) {
if (!result[c.plan]) result[c.plan] = {}
result[c.plan]![c.billingPeriod] = { MAD: c.amount }
}
return result
}
export function getProviders() {
return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() }
}
// ─── Reads ────────────────────────────────────────────────────
export function getSubscription(companyId: string) {
return repo.findByCompany(companyId)
}
@@ -27,15 +41,131 @@ export function getInvoices(companyId: string) {
return repo.findInvoices(companyId)
}
export function getEvents(companyId: string) {
return repo.findByCompany(companyId).then((sub) =>
sub ? repo.getEvents(sub.id) : [],
)
}
export function getEventsBySubscriptionId(subscriptionId: string) {
return repo.getEvents(subscriptionId)
}
// ─── Entitlements ─────────────────────────────────────────────
export async function getEntitlement(companyId: string) {
const sub = await repo.findByCompany(companyId)
const status = sub?.status ?? 'EXPIRED'
const accessLevel = getAccessLevel(status)
return {
companyId,
subscriptionId: sub?.id ?? null,
subscriptionStatus: status,
accessLevel,
plan: sub?.plan ?? null,
currentPeriodEnd: sub?.currentPeriodEnd ?? null,
}
}
// ─── Trial management ─────────────────────────────────────────
export async function startTrial(
companyId: string,
plan: string,
billingPeriod: string,
currency: string,
) {
if (SUBSCRIPTION_POLICY.trial.oneTrialPerCompany) {
const existing = await repo.findByCompany(companyId)
if (existing?.trialUsed) {
throw new ValidationError('This company has already used its free trial')
}
}
const trialEndAt = new Date(
Date.now() + SUBSCRIPTION_POLICY.trial.durationDays * 24 * 60 * 60 * 1000,
)
const sub = await repo.startTrial(companyId, plan, billingPeriod, currency, trialEndAt)
await repo.createEvent({
subscriptionId: sub.id,
companyId,
eventType: 'trial.started',
payload: { plan, billingPeriod, trialEndAt },
})
return sub
}
// ─── Payment success (shared by all providers) ───────────────
async function handlePaymentSuccess(subscriptionId: string, invoiceId: string) {
const sub = await repo.findById(subscriptionId)
if (!sub) return
await repo.markInvoicePaid(invoiceId)
await repo.createPaymentAttempt({
invoiceId,
subscriptionId,
status: 'succeeded',
})
const periodEnd = addPeriod(new Date(), sub.billingPeriod)
await repo.activateSubscription(subscriptionId, periodEnd)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: sub.status === 'TRIALING' ? 'trial.converted' : 'subscription.activated',
source: 'webhook',
payload: { invoiceId, periodEnd },
})
}
// ─── Payment failure ──────────────────────────────────────────
async function handlePaymentFailure(
subscriptionId: string,
invoiceId: string,
failureCode?: string,
failureMessage?: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) return
await repo.markInvoiceFailed(invoiceId)
await repo.createPaymentAttempt({
invoiceId,
subscriptionId,
status: 'failed',
failureCode,
failureMessage,
})
await repo.incrementRetryCount(sub.id)
if (sub.status !== 'PAYMENT_PENDING') {
await repo.setPaymentPending(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.payment_pending',
source: 'webhook',
payload: { invoiceId, failureCode },
})
}
}
// ─── Webhook handlers ─────────────────────────────────────────
export async function handleAmanpayWebhook(event: any) {
const transactionId = event.transaction_id ?? event.id
const status = event.status?.toUpperCase()
if (status === 'PAID' || status === 'SUCCEEDED') {
const invoice = await repo.findInvoiceByAmanpay(transactionId)
if (invoice) {
await repo.markInvoicePaid(invoice.id)
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
}
if (!invoice || invoice.status === 'PAID') return // idempotent
await handlePaymentSuccess(invoice.subscriptionId, invoice.id)
} else if (status === 'FAILED' || status === 'DECLINED') {
const invoice = await repo.findInvoiceByAmanpay(transactionId)
if (!invoice || invoice.status === 'PAID') return
await handlePaymentFailure(invoice.subscriptionId, invoice.id, status, event.failure_reason)
}
}
@@ -43,22 +173,28 @@ export async function handlePaypalWebhook(event: any) {
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
const captureId = event.resource?.id as string
const invoice = await repo.findInvoiceByPaypal(captureId)
if (invoice) {
await repo.markInvoicePaid(invoice.id)
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
}
if (!invoice || invoice.status === 'PAID') return // idempotent
await handlePaymentSuccess(invoice.subscriptionId, invoice.id)
} else if (event.event_type === 'PAYMENT.CAPTURE.DENIED') {
const captureId = event.resource?.id as string
const invoice = await repo.findInvoiceByPaypal(captureId)
if (!invoice || invoice.status === 'PAID') return
await handlePaymentFailure(invoice.subscriptionId, invoice.id, 'capture_denied')
}
}
// ─── Checkout ─────────────────────────────────────────────────
export async function checkout(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'; provider: 'AMANPAY' | 'PAYPAL'
currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL'
successUrl: string; failureUrl: string
}) {
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod]
if (!prices) throw new ValidationError('Invalid plan or billing period')
const amount = (prices as any)[body.currency]
if (!amount) throw new ValidationError('Currency not supported for this plan')
const dbPrice = await prisma.pricingConfig.findUnique({
where: { plan_billingPeriod: { plan: body.plan, billingPeriod: body.billingPeriod } },
})
const amount = dbPrice?.amount ?? (PLAN_PRICES[body.plan]?.[body.billingPeriod] as any)?.[body.currency]
if (!amount) throw new ValidationError('Invalid plan or billing period')
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
const subscription = await repo.findOrCreateSubscription(companyId, body.plan, body.billingPeriod, body.currency)
@@ -83,32 +219,277 @@ export async function checkout(companyId: string, body: {
amanpayTransactionId = result.transactionId
} else {
if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured on this platform')
const result = await paypal.createOrder({ amount, currency: body.currency, orderId, description, returnUrl: body.successUrl, cancelUrl: body.failureUrl })
const result = await paypal.createOrder({
amount, currency: body.currency, orderId, description,
returnUrl: body.successUrl, cancelUrl: body.failureUrl,
})
checkoutUrl = result.approveUrl
paypalCaptureId = result.orderId
}
const invoice = await repo.createInvoice({ companyId, subscriptionId: subscription.id, amount, currency: body.currency, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId })
const dueAt = new Date(Date.now() + SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000)
const invoice = await repo.createInvoice({
companyId, subscriptionId: subscription.id, amount, currency: body.currency,
paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, dueAt,
})
return { invoice, checkoutUrl }
}
export async function capturePaypal(companyId: string, paypalOrderId: string) {
const invoice = await repo.findInvoiceByPaypalForCompany(paypalOrderId, companyId)
if (invoice.status === 'PAID') return { success: true } // idempotent
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await repo.updateInvoicePaypal(invoice.id, captureId)
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
const periodEnd = addPeriod(new Date(), invoice.subscription.billingPeriod)
await repo.activateSubscription(invoice.subscriptionId, periodEnd)
await repo.createEvent({
subscriptionId: invoice.subscriptionId,
companyId,
eventType: 'subscription.activated',
source: 'user',
payload: { invoiceId: invoice.id },
})
return { success: true }
}
// ─── Plan changes ─────────────────────────────────────────────
export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
return repo.updatePlan(companyId, data)
}
export function cancel(companyId: string) {
return repo.setCancelAtPeriodEnd(companyId, true)
// ─── Cancellation ─────────────────────────────────────────────
export async function cancel(companyId: string, mode: 'period_end' | 'immediate' = 'period_end', reason?: string) {
const sub = await repo.findByCompany(companyId)
if (!sub) throw new ValidationError('No subscription found')
await repo.setCancelled(sub.id, mode === 'immediate')
await repo.createEvent({
subscriptionId: sub.id,
companyId,
eventType: 'subscription.canceled',
source: 'user',
payload: { mode, reason: reason ?? null },
})
return { success: true }
}
export function resume(companyId: string) {
return repo.setCancelAtPeriodEnd(companyId, false)
}
// ─── Reactivation ────────────────────────────────────────────
export async function reactivate(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL'
successUrl: string; failureUrl: string
}) {
const sub = await repo.findByCompany(companyId)
if (!sub) throw new ValidationError('No subscription found')
const allowedStatuses = ['CANCELLED', 'EXPIRED', 'SUSPENDED', 'PAST_DUE', 'PAYMENT_PENDING']
if (!allowedStatuses.includes(sub.status)) {
throw new ValidationError(`Cannot reactivate a subscription with status ${sub.status}`)
}
await repo.setPaymentPending(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId,
eventType: 'subscription.reactivated',
source: 'user',
payload: { plan: body.plan, billingPeriod: body.billingPeriod },
})
return checkout(companyId, body)
}
// ─── Scheduled job actions ────────────────────────────────────
export async function runTrialExpirationJob() {
const expired = await repo.findTrialsEndedWithoutConversion()
for (const sub of expired) {
await repo.setExpired(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'trial.expired',
payload: { trialEndAt: sub.trialEndAt },
})
}
return expired.length
}
export async function runPaymentPendingTimeoutJob() {
const cutoff = new Date(
Date.now() - SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000,
)
const subs = await repo.findPaymentPendingTimedOut(cutoff)
for (const sub of subs) {
await repo.setPastDue(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.past_due',
payload: { paymentPendingSince: sub.paymentPendingSince },
})
}
return subs.length
}
export async function runPastDueTimeoutJob() {
const cutoff = new Date(
Date.now() - SUBSCRIPTION_POLICY.pastDue.timeoutDays * 24 * 60 * 60 * 1000,
)
const subs = await repo.findPastDueTimedOut(cutoff)
for (const sub of subs) {
await repo.setSuspended(sub.id)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.suspended',
payload: { pastDueSince: sub.pastDueSince },
})
}
return subs.length
}
export async function runSuspensionTimeoutJob() {
const cutoff = new Date(
Date.now() - SUBSCRIPTION_POLICY.suspension.cancelAfterDays * 24 * 60 * 60 * 1000,
)
const subs = await repo.findSuspendedTimedOut(cutoff)
for (const sub of subs) {
await repo.setCancelled(sub.id, true)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.canceled',
payload: { reason: 'suspension_timeout', suspendedAt: sub.suspendedAt },
})
}
return subs.length
}
export async function runPeriodEndCancellationJob() {
const subs = await repo.findSubscriptionsToExpireAtPeriodEnd()
for (const sub of subs) {
await repo.setCancelled(sub.id, true)
await repo.createEvent({
subscriptionId: sub.id,
companyId: sub.companyId,
eventType: 'subscription.canceled',
payload: { reason: 'period_end', currentPeriodEnd: sub.currentPeriodEnd },
})
}
return subs.length
}
// ─── Admin overrides ──────────────────────────────────────────
export async function adminExtendTrial(
subscriptionId: string,
extraDays: number,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
const currentEnd = sub.trialEndAt ?? new Date()
const newEnd = new Date(currentEnd.getTime() + extraDays * 24 * 60 * 60 * 1000)
await prisma.subscription.update({
where: { id: subscriptionId },
data: { trialEndAt: newEnd },
})
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'admin.override_created',
source: 'admin',
payload: { action: 'extend_trial', extraDays, newEnd, adminId, reason },
})
return { trialEndAt: newEnd }
}
export async function adminExtendGracePeriod(
subscriptionId: string,
extraDays: number,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
await repo.setPaymentPending(sub.id)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'admin.override_created',
source: 'admin',
payload: { action: 'extend_grace_period', extraDays, adminId, reason },
})
return { success: true }
}
export async function adminCancelSubscription(
subscriptionId: string,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
await repo.setCancelled(sub.id, true)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'subscription.canceled',
source: 'admin',
payload: { adminId, reason },
})
return { success: true }
}
export async function adminReactivateSubscription(
subscriptionId: string,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
const periodEnd = addPeriod(new Date(), sub.billingPeriod)
await repo.activateSubscription(subscriptionId, periodEnd)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'subscription.reactivated',
source: 'admin',
payload: { adminId, reason, periodEnd },
})
return { success: true }
}
export async function adminSuspendSubscription(
subscriptionId: string,
adminId: string,
reason: string,
) {
const sub = await repo.findById(subscriptionId)
if (!sub) throw new ValidationError('Subscription not found')
await repo.setSuspended(sub.id)
await repo.createEvent({
subscriptionId,
companyId: sub.companyId,
eventType: 'subscription.suspended',
source: 'admin',
payload: { adminId, reason },
})
return { success: true }
}
@@ -25,7 +25,7 @@ type PaymentRow = {
id: string
reservationId: string
amount: number
currency: 'MAD' | 'USD' | 'EUR'
currency: string
status: string
type: 'CHARGE' | 'DEPOSIT'
paymentProvider: 'AMANPAY' | 'PAYPAL'
@@ -71,7 +71,7 @@ export default function BillingPage() {
const [paymentModalRow, setPaymentModalRow] = useState<BillingRow | null>(null)
const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('CASH')
const [paymentType, setPaymentType] = useState<'CHARGE' | 'DEPOSIT'>('CHARGE')
const [paymentCurrency, setPaymentCurrency] = useState<'MAD' | 'USD' | 'EUR'>('MAD')
const paymentCurrency = 'MAD'
const [paymentAmount, setPaymentAmount] = useState('')
const [submittingPayment, setSubmittingPayment] = useState(false)
const [paymentError, setPaymentError] = useState<string | null>(null)
@@ -393,7 +393,6 @@ export default function BillingPage() {
setPaymentModalRow(row)
setPaymentMethod('CASH')
setPaymentType('CHARGE')
setPaymentCurrency('MAD')
setPaymentAmount(String((row.balanceDue / 100).toFixed(2)))
setPaymentError(null)
}
@@ -614,15 +613,6 @@ export default function BillingPage() {
<p className="mt-2 text-xs text-slate-500">{paymentType === 'DEPOSIT' ? copy.depositHelp : copy.chargeHelp}</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentCurrency}</label>
<select className="input-field" value={paymentCurrency} onChange={(event) => setPaymentCurrency(event.target.value as 'MAD' | 'USD' | 'EUR')} disabled={submittingPayment}>
<option value="MAD">MAD</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentAmount}</label>
<input type="number" min="0" step="0.01" className="input-field" value={paymentAmount} onChange={(event) => setPaymentAmount(event.target.value)} disabled={submittingPayment} />
@@ -8,14 +8,13 @@ import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type Currency = 'MAD' | 'USD' | 'EUR'
interface Subscription {
id: string
plan: Plan
billingPeriod: BillingPeriod
status: string
currency: Currency
currency: string
trialEndAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
@@ -24,7 +23,7 @@ interface Subscription {
interface Invoice {
id: string
amount: number
currency: Currency
currency: string
status: string
paymentProvider: string
paidAt: string | null
@@ -67,7 +66,7 @@ export default function SubscriptionPage() {
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const [currency, setCurrency] = useState<Currency>('MAD')
const currency = 'MAD'
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
const [paying, setPaying] = useState(false)
@@ -245,7 +244,7 @@ export default function SubscriptionPage() {
setSubscription(sub)
setSelectedPlan(sub.plan)
setBillingPeriod(sub.billingPeriod)
setCurrency(sub.currency as Currency)
// currency is always MAD
}
setInvoices(inv ?? [])
})
@@ -400,21 +399,6 @@ export default function SubscriptionPage() {
))}
</div>
{/* Currency selector */}
<div className="flex items-center gap-2">
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
<button
key={c}
onClick={() => setCurrency(c)}
className={`px-3 py-1 rounded-lg text-sm font-medium border transition-colors ${
currency === c ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{c}
</button>
))}
</div>
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
@@ -435,7 +419,7 @@ export default function SubscriptionPage() {
{isActive && <span className="badge-green">{copy.active}</span>}
</div>
<p className="mt-2 text-2xl font-black text-slate-900">
{price ? formatCurrency(price, currency) : '—'}
{price ? formatCurrency(price, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
@@ -482,7 +466,7 @@ export default function SubscriptionPage() {
<div>
<p className="text-sm text-slate-500">{copy.total}</p>
<p className="text-xl font-black text-slate-900">
{planPrice ? formatCurrency(planPrice, currency) : '—'}
{planPrice ? formatCurrency(planPrice, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
</p>
</div>
@@ -530,7 +514,7 @@ export default function SubscriptionPage() {
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
{formatCurrency(inv.amount, inv.currency)}
{formatCurrency(inv.amount, 'MAD')}
</td>
</tr>
))}
@@ -58,12 +58,20 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, {
// Try employee first, then admin — mirrors the sign-in page which handles both
const [empRes, adminRes] = await Promise.all([
fetch(`${API_BASE}/auth/employee/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!res.ok) throw new Error()
}),
fetch(`${API_BASE}/admin/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}),
])
if (!empRes.ok && !adminRes.ok) throw new Error()
setSent(true)
} catch {
setError(dict.error)
@@ -29,7 +29,7 @@ type SignupForm = {
yearsActive: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'
currency: 'MAD'
paymentProvider: 'AMANPAY' | 'PAYPAL'
}
@@ -557,9 +557,8 @@ export default function SignUpPage() {
</button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2">
<LabeledSelect label={language === 'fr' ? 'Période de facturation' : language === 'ar' ? 'فترة الفوترة' : 'Billing period'} value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} labels={dict.billingPeriodOptions} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
<LabeledSelect label={language === 'fr' ? 'Devise' : language === 'ar' ? 'العملة' : 'Currency'} value={form.currency} options={['MAD', 'USD', 'EUR']} onChange={(value) => setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} />
<LabeledSelect label={language === 'fr' ? 'Prestataire principal' : language === 'ar' ? 'مزود الدفع الرئيسي' : 'Primary provider'} value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} labels={dict.paymentProviderOptions} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
</div>
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
@@ -571,7 +570,7 @@ export default function SignUpPage() {
<SectionIntro title={dict.reviewTitle} body={dict.reviewBody} />
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
<p><span className="font-semibold text-slate-900">{dict.reviewWorkspace}:</span> {form.companyName.fr || form.companyName.ar || dict.companyFallback}{form.companyName.fr && form.companyName.ar ? <span className="ml-2 text-slate-400">/ {form.companyName.ar}</span> : null}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · {form.currency}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · MAD</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewOwnerEmail}:</span> {form.email || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
</div>
@@ -741,7 +740,6 @@ function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod
return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
}
function normalizeCurrency(value: string | null): SignupForm['currency'] {
if (value === 'USD' || value === 'EUR' || value === 'MAD') return value
function normalizeCurrency(_value: string | null): SignupForm['currency'] {
return 'MAD'
}
@@ -5,29 +5,12 @@ import Link from 'next/link'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
type Currency = 'MAD' | 'USD' | 'EUR'
type Billing = 'monthly' | 'annual'
const SYMBOL: Record<Currency, string> = { MAD: 'MAD', USD: '$', EUR: '€' }
const LANGUAGE_CURRENCY: Record<string, Currency> = { ar: 'MAD', fr: 'EUR', en: 'USD' }
const PRICES: Record<string, Record<Currency, { monthly: number; annual: number }>> = {
STARTER: {
MAD: { monthly: 299, annual: 299 },
USD: { monthly: 29, annual: 290 },
EUR: { monthly: 27, annual: 270 },
},
GROWTH: {
MAD: { monthly: 599, annual: 399 },
USD: { monthly: 59, annual: 590 },
EUR: { monthly: 55, annual: 550 },
},
PRO: {
MAD: { monthly: 999, annual: 9990 },
USD: { monthly: 99, annual: 990 },
EUR: { monthly: 92, annual: 920 },
},
const PRICES: Record<string, { monthly: number; annual: number }> = {
STARTER: { monthly: 299, annual: 2990 },
GROWTH: { monthly: 399, annual: 3990 },
PRO: { monthly: 499, annual: 4990 },
}
const PLANS = [
@@ -110,14 +93,8 @@ const copy = {
},
} as const
function fmt(value: number, currency: Currency, billing: Billing): string {
const sym = SYMBOL[currency]
const amount = billing === 'annual' ? Math.round(value / 12) : value
return currency === 'MAD' ? `${amount} ${sym}` : `${sym}${amount}`
}
function annualSavingsPct(plan: string, currency: Currency): number {
const { monthly, annual } = PRICES[plan][currency]
function annualSavingsPct(plan: string): number {
const { monthly, annual } = PRICES[plan]
const wouldPay = monthly * 12
return Math.round(((wouldPay - annual) / wouldPay) * 100)
}
@@ -125,22 +102,16 @@ function annualSavingsPct(plan: string, currency: Currency): number {
export default function PricingClient() {
const { language } = useMarketplacePreferences()
const dict = copy[language]
const [currency, setCurrency] = useState<Currency>(() => LANGUAGE_CURRENCY[language] ?? 'MAD')
const [billing, setBilling] = useState<Billing>('monthly')
useEffect(() => {
setCurrency(LANGUAGE_CURRENCY[language] ?? 'MAD')
}, [language])
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const savings = annualSavingsPct('STARTER', currency)
const savings = annualSavingsPct('STARTER')
return (
<div className="space-y-10">
{/* Toggles */}
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
{/* Billing toggle */}
<div className="flex items-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm dark:border-blue-800 dark:bg-[#0d1b38]">
<div className="flex items-center justify-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm dark:border-blue-800 dark:bg-[#0d1b38]">
{(['monthly', 'annual'] as Billing[]).map((b) => (
<button
key={b}
@@ -161,24 +132,6 @@ export default function PricingClient() {
))}
</div>
{/* Currency toggle */}
<div className="flex items-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm dark:border-blue-800 dark:bg-[#0d1b38]">
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
<button
key={c}
onClick={() => setCurrency(c)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
currency === c
? 'bg-orange-500 text-white dark:bg-orange-400 dark:text-stone-900'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100'
}`}
>
{c}
</button>
))}
</div>
</div>
{billing === 'annual' && (
<p className="text-center text-sm font-medium text-orange-700 dark:text-orange-400">
{dict.youSave} {savings}% {dict.withAnnual}
@@ -188,11 +141,10 @@ export default function PricingClient() {
{/* Plan cards */}
<div className="grid gap-6 md:grid-cols-3">
{PLANS.map((plan) => {
const price = PRICES[plan.key][currency]
const price = PRICES[plan.key]
const displayPrice = billing === 'annual'
? Math.round(price.annual / 12)
: price.monthly
const sym = SYMBOL[currency]
return (
<div
@@ -218,20 +170,14 @@ export default function PricingClient() {
<div className="mt-6">
<div className="flex items-end gap-1">
<span className="text-4xl font-black text-stone-900 dark:text-stone-100">
{currency === 'MAD' ? displayPrice : `${sym}${displayPrice}`}
{displayPrice}
</span>
{currency === 'MAD' && (
<span className="mb-1 text-lg font-semibold text-stone-500 dark:text-stone-400">{sym}</span>
)}
<span className="mb-1 text-lg font-semibold text-stone-500 dark:text-stone-400">MAD</span>
<span className="mb-1 text-sm text-stone-400 dark:text-stone-500">{dict.perMonth}</span>
</div>
{billing === 'annual' && (
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
{dict.billedAs}{' '}
{currency === 'MAD'
? `${price.annual} ${sym}`
: `${sym}${price.annual}`}{' '}
{dict.yearly}
{dict.billedAs} {price.annual} MAD {dict.yearly}
</p>
)}
</div>
@@ -254,7 +200,7 @@ export default function PricingClient() {
</ul>
<Link
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}&currency=${currency}`}
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}&currency=MAD`}
className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
plan.highlight
? 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-400 dark:text-stone-900 dark:hover:bg-orange-300'
@@ -159,9 +159,9 @@ export default function RenterProfilePage() {
/>
<SelectField
label={dict.currency}
value={profile?.preferredCurrency || 'MAD'}
options={['MAD', 'USD', 'EUR']}
onChange={(value) => setProfile((current) => current ? { ...current, preferredCurrency: value } : current)}
value="MAD"
options={['MAD']}
onChange={() => {}}
/>
</div>
<div className="mt-8 flex justify-end">
+62
View File
@@ -0,0 +1,62 @@
The seed script skips creation if the email already exists. The
most flexible approach is a direct API call (if you have a token)
or a one-liner script. Here are both options:
Option 1 — via API (requires an existing admin to be logged in
first):
curl -s -X POST http://localhost:4000/api/v1/admin/admins \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your_admin_token>" \
-d '{
"email": "newadmin@example.com",
"firstName": "First",
"lastName": "Last",
"role": "ADMIN",
"password": "yourpassword123"
}'
Option 2 — directly in the database (no token needed, works any
time):
DATABASE_URL="postgresql://postgres:password@localhost:5432/renta
ldrivego" node -e "
const { PrismaClient } =
require('./packages/database/generated');
const bcrypt = require('./node_modules/bcryptjs');
const p = new PrismaClient();
const EMAIL = 'newadmin@example.com';
const PASSWORD = 'yourpassword123';
const FIRST = 'First';
const LAST = 'Last';
const ROLE = 'SUPER_ADMIN'; // SUPER_ADMIN | ADMIN | SUPPORT |
FINANCE | VIEWER
bcrypt.hash(PASSWORD, 12).then(hash =>
p.adminUser.create({ data: { email: EMAIL, firstName: FIRST,
lastName: LAST, passwordHash: hash, role: ROLE, isActive: true }
})
).then(a => { console.log('Created:', a.email, a.role);
p.\$disconnect(); })
.catch(e => { console.error('Error:', e.message);
p.\$disconnect(); });
"
From inside Docker:
docker exec rentaldrivego-dev-api-1 node -e "
const { PrismaClient } =
require('/app/packages/database/generated');
const bcrypt = require('/app/node_modules/bcryptjs');
const p = new PrismaClient();
bcrypt.hash('yourpassword123', 12).then(hash =>
p.adminUser.create({ data: { email: 'newadmin@example.com',
firstName: 'First', lastName: 'Last', passwordHash: hash, role:
'SUPER_ADMIN', isActive: true } })
).then(a => { console.log('Created:', a.email, a.role);
p.\$disconnect(); })
.catch(e => { console.error('Error:', e.message);
p.\$disconnect(); });
"
Replace email, password, firstName, lastName, and role as needed.
Available roles: SUPER_ADMIN, ADMIN, SUPPORT, FINANCE, VIEWER.
+2
View File
@@ -156,6 +156,8 @@ services:
- api
env_file:
- .env.docker.dev
environment:
ADMIN_ASSET_PREFIX: "http://localhost:3002"
command:
[
"sh",
@@ -0,0 +1,21 @@
CREATE TABLE "pricing_configs" (
"id" TEXT NOT NULL,
"plan" TEXT NOT NULL,
"billingPeriod" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT,
CONSTRAINT "pricing_configs_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "pricing_configs_plan_billingPeriod_key" ON "pricing_configs"("plan", "billingPeriod");
-- Seed default prices (amounts in centimes MAD)
INSERT INTO "pricing_configs" ("id", "plan", "billingPeriod", "amount", "updatedAt") VALUES
('prc_starter_monthly', 'STARTER', 'MONTHLY', 2990, NOW()),
('prc_starter_annual', 'STARTER', 'ANNUAL', 29900, NOW()),
('prc_growth_monthly', 'GROWTH', 'MONTHLY', 3990, NOW()),
('prc_growth_annual', 'GROWTH', 'ANNUAL', 39900, NOW()),
('prc_pro_monthly', 'PRO', 'MONTHLY', 99900, NOW()),
('prc_pro_annual', 'PRO', 'ANNUAL', 999000, NOW());
@@ -0,0 +1,66 @@
-- Add new SubscriptionStatus enum values
ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'PAYMENT_PENDING';
ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'SUSPENDED';
ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'EXPIRED';
ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'PAUSED';
-- Add new InvoiceStatus enum value
ALTER TYPE "InvoiceStatus" ADD VALUE IF NOT EXISTS 'VOIDED';
-- Extend subscriptions table (camelCase to match Prisma default)
ALTER TABLE "subscriptions"
ADD COLUMN IF NOT EXISTS "trialUsed" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "paymentPendingSince" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "paymentDueAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "pastDueSince" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "suspendedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "endedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "retryCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "maxRetryCount" INTEGER NOT NULL DEFAULT 5;
-- Extend subscription_invoices table
ALTER TABLE "subscription_invoices"
ADD COLUMN IF NOT EXISTS "providerInvoiceId" TEXT,
ADD COLUMN IF NOT EXISTS "dueAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "failedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "voidedAt" TIMESTAMP(3);
-- Create subscription_events table
CREATE TABLE IF NOT EXISTS "subscription_events" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'system',
"payload" JSONB NOT NULL DEFAULT '{}',
"occurredAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "subscription_events_subscriptionId_idx" ON "subscription_events"("subscriptionId");
CREATE INDEX IF NOT EXISTS "subscription_events_companyId_idx" ON "subscription_events"("companyId");
ALTER TABLE "subscription_events"
ADD CONSTRAINT "subscription_events_subscriptionId_fkey"
FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Create payment_attempts table
CREATE TABLE IF NOT EXISTS "payment_attempts" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"providerPaymentId" TEXT,
"status" TEXT NOT NULL,
"failureCode" TEXT,
"failureMessage" TEXT,
"attemptedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payment_attempts_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "payment_attempts_invoiceId_idx" ON "payment_attempts"("invoiceId");
ALTER TABLE "payment_attempts"
ADD CONSTRAINT "payment_attempts_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "subscription_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+64
View File
@@ -35,8 +35,12 @@ enum BillingPeriod {
enum SubscriptionStatus {
TRIALING
ACTIVE
PAYMENT_PENDING
PAST_DUE
SUSPENDED
CANCELLED
EXPIRED
PAUSED
UNPAID
}
@@ -45,6 +49,7 @@ enum InvoiceStatus {
PAID
FAILED
REFUNDED
VOIDED
}
enum PaymentProvider {
@@ -366,11 +371,20 @@ model Subscription {
currency String @default("MAD")
trialStartAt DateTime?
trialEndAt DateTime?
trialUsed Boolean @default(false)
currentPeriodStart DateTime?
currentPeriodEnd DateTime?
paymentPendingSince DateTime?
paymentDueAt DateTime?
pastDueSince DateTime?
suspendedAt DateTime?
endedAt DateTime?
cancelledAt DateTime?
cancelAtPeriodEnd Boolean @default(false)
retryCount Int @default(0)
maxRetryCount Int @default(5)
invoices SubscriptionInvoice[]
events SubscriptionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -378,19 +392,40 @@ model Subscription {
@@map("subscriptions")
}
model SubscriptionEvent {
id String @id @default(cuid())
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
companyId String
eventType String
source String @default("system")
payload Json @default("{}")
occurredAt DateTime
createdAt DateTime @default(now())
@@index([subscriptionId])
@@index([companyId])
@@map("subscription_events")
}
model SubscriptionInvoice {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id])
providerInvoiceId String?
amount Int
currency String @default("MAD")
status InvoiceStatus
amanpayTransactionId String? @unique
paypalCaptureId String? @unique
paymentProvider PaymentProvider @default(AMANPAY)
dueAt DateTime?
paidAt DateTime?
failedAt DateTime?
voidedAt DateTime?
attempts PaymentAttempt[]
createdAt DateTime @default(now())
@@ -398,6 +433,22 @@ model SubscriptionInvoice {
@@map("subscription_invoices")
}
model PaymentAttempt {
id String @id @default(cuid())
invoiceId String
invoice SubscriptionInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
subscriptionId String
providerPaymentId String?
status String
failureCode String?
failureMessage String?
attemptedAt DateTime
createdAt DateTime @default(now())
@@index([invoiceId])
@@map("payment_attempts")
}
model BrandSettings {
id String @id @default(cuid())
companyId String @unique
@@ -1115,3 +1166,16 @@ model AuditLog {
@@index([action])
@@map("audit_logs")
}
model PricingConfig {
id String @id @default(cuid())
plan String
billingPeriod String
amount Int
updatedAt DateTime @updatedAt
updatedBy String?
@@unique([plan, billingPeriod])
@@map("pricing_configs")
}
+10 -10
View File
@@ -19,32 +19,32 @@ export interface ApiError {
[key: string]: unknown
}
// Plan prices in smallest currency unit
// Plan prices in smallest currency unit (MAD, in centimes)
export const PLAN_PRICES: Record<string, Record<string, Record<string, number>>> = {
STARTER: {
MONTHLY: { MAD: 2990, USD: 2900, EUR: 2700 },
ANNUAL: { MAD: 29900, USD: 29000, EUR: 27000 },
MONTHLY: { MAD: 2990 },
ANNUAL: { MAD: 29900 },
},
GROWTH: {
MONTHLY: { MAD: 3990, USD: 5900, EUR: 5400 },
ANNUAL: { MAD: 39900, USD: 59000, EUR: 54000 },
MONTHLY: { MAD: 3990 },
ANNUAL: { MAD: 39900 },
},
PRO: {
MONTHLY: { MAD: 99900, USD: 9900, EUR: 9000 },
ANNUAL: { MAD: 999000, USD: 99000, EUR: 90000 },
MONTHLY: { MAD: 99900 },
ANNUAL: { MAD: 999000 },
},
}
export type Locale = 'en' | 'fr' | 'ar'
export type SupportedCurrency = 'MAD' | 'USD' | 'EUR'
export type SupportedCurrency = 'MAD'
export function formatCurrency(amount: number, currency: SupportedCurrency, locale: Locale = 'en'): string {
export function formatCurrency(amount: number, _currency: SupportedCurrency = 'MAD', locale: Locale = 'en'): string {
const divisor = 100
const value = amount / divisor
const localeMap: Record<Locale, string> = { en: 'en-US', fr: 'fr-FR', ar: 'ar-MA' }
return new Intl.NumberFormat(localeMap[locale], {
style: 'currency',
currency,
currency: 'MAD',
minimumFractionDigits: 2,
}).format(value)
}