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 apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
const apiUrl = new URL(apiOrigin) 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 = { const nextConfig = {
basePath: '/admin', basePath: '/admin',
...(assetPrefix ? { assetPrefix } : {}),
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
@@ -34,7 +34,7 @@ export default function AdminAuditLogsPage() {
cache: 'no-store', cache: 'no-store',
}) })
.then((r) => r.json()) .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)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
@@ -57,6 +57,10 @@ const SUB_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-400 bg-emerald-900/30', ACTIVE: 'text-emerald-400 bg-emerald-900/30',
TRIALING: 'text-sky-400 bg-sky-900/30', TRIALING: 'text-sky-400 bg-sky-900/30',
PAST_DUE: 'text-orange-400 bg-orange-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', CANCELLED: 'text-zinc-400 bg-zinc-800',
UNPAID: 'text-red-400 bg-red-900/30', UNPAID: 'text-red-400 bg-red-900/30',
} }
@@ -75,7 +79,7 @@ const PLAN_COLORS: Record<string, string> = {
} }
function fmt(amount: number, currency: string) { function fmt(amount: number, currency: string) {
return `${(amount / 100).toFixed(2)} ${currency}` return `${amount.toFixed(2)} ${currency}`
} }
function fmtDate(iso: string | null) { function fmtDate(iso: string | null) {
@@ -221,8 +225,8 @@ export default function AdminBillingPage() {
}) })
const json = await res.json() const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch') if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
setSubscriptions(json.data ?? []) setSubscriptions(json.data?.data ?? [])
setStats(json.stats ?? null) setStats(json.data?.stats ?? null)
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -263,7 +267,7 @@ export default function AdminBillingPage() {
}) })
const json = await res.json() const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`) if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`)
setInvoices(json.data ?? []) setInvoices(json.data?.data ?? [])
} catch (err: any) { } catch (err: any) {
setInvoicesError(err.message) setInvoicesError(err.message)
} finally { } finally {
@@ -290,7 +294,7 @@ export default function AdminBillingPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
<div className="col-span-2 sm:col-span-1 panel p-4"> <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="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>
<div className="panel p-4"> <div className="panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.active}</p> <p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.active}</p>
@@ -15,6 +15,7 @@ interface CompanyDetail {
subscriptionPaymentRef: string | null subscriptionPaymentRef: string | null
createdAt: string createdAt: string
subscription: { subscription: {
id: string
plan: string plan: string
billingPeriod: string billingPeriod: string
status: string status: string
@@ -25,6 +26,10 @@ interface CompanyDetail {
currentPeriodEnd: string | null currentPeriodEnd: string | null
cancelledAt: string | null cancelledAt: string | null
cancelAtPeriodEnd: boolean cancelAtPeriodEnd: boolean
paymentPendingSince: string | null
pastDueSince: string | null
suspendedAt: string | null
retryCount: number
} | null } | null
brand: { brand: {
displayName: string displayName: string
@@ -78,12 +83,21 @@ interface AuditLog {
adminUser: { email: string } | null adminUser: { email: string } | null
} }
interface SubEvent {
id: string
eventType: string
source: string
payload: Record<string, any>
occurredAt: string
}
interface FormState { interface FormState {
company: { company: {
name: string name: string
slug: string slug: string
email: string email: string
phone: string phone: string
status: string
subscriptionPaymentRef: string subscriptionPaymentRef: string
} }
subscription: { subscription: {
@@ -168,6 +182,7 @@ function createFormState(company: CompanyDetail): FormState {
slug: company.slug ?? '', slug: company.slug ?? '',
email: company.email ?? '', email: company.email ?? '',
phone: company.phone ?? '', phone: company.phone ?? '',
status: company.status ?? 'TRIALING',
subscriptionPaymentRef: company.subscriptionPaymentRef ?? '', subscriptionPaymentRef: company.subscriptionPaymentRef ?? '',
}, },
subscription: { subscription: {
@@ -227,6 +242,10 @@ export default function AdminCompanyDetailPage() {
const [company, setCompany] = useState<CompanyDetail | null>(null) const [company, setCompany] = useState<CompanyDetail | null>(null)
const [form, setForm] = useState<FormState | null>(null) const [form, setForm] = useState<FormState | null>(null)
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]) 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 [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [actioning, setActioning] = useState(false) const [actioning, setActioning] = useState(false)
@@ -246,7 +265,14 @@ export default function AdminCompanyDetailPage() {
if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found') if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found')
setCompany(cJson.data) setCompany(cJson.data)
setForm(createFormState(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) setError(null)
} catch (err: any) { } catch (err: any) {
setError(err.message) 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) { async function changeStatus(status: string) {
setActioning(true) setActioning(true)
setError(null) setError(null)
@@ -445,7 +494,11 @@ export default function AdminCompanyDetailPage() {
<span className={LABEL_CLASS}>Phone</span> <span className={LABEL_CLASS}>Phone</span>
<input className={INPUT_CLASS} value={form.company.phone} onChange={(e) => updateSection('company', { phone: e.target.value })} /> <input className={INPUT_CLASS} value={form.company.phone} onChange={(e) => updateSection('company', { phone: e.target.value })} />
</label> </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> <span className={LABEL_CLASS}>Subscription payment ref</span>
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} /> <input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
</label> </label>
@@ -470,16 +523,19 @@ export default function AdminCompanyDetailPage() {
<option value="ANNUAL">ANNUAL</option> <option value="ANNUAL">ANNUAL</option>
</select> </select>
</label> </label>
<label> <div>
<span className={LABEL_CLASS}>Status</span> <span className={LABEL_CLASS}>Status</span>
<select className={INPUT_CLASS} value={form.subscription.status} onChange={(e) => updateSection('subscription', { status: e.target.value })}> <p className={`mt-1 text-sm font-semibold ${STATUS_COLORS[form.subscription.status] ?? 'text-zinc-400'}`}>{form.subscription.status}</p>
<option value="TRIALING">TRIALING</option> {company.subscription?.paymentPendingSince && (
<option value="ACTIVE">ACTIVE</option> <p className="mt-0.5 text-xs text-zinc-500">Pending since {new Date(company.subscription.paymentPendingSince).toLocaleDateString()}</p>
<option value="PAST_DUE">PAST_DUE</option> )}
<option value="CANCELLED">CANCELLED</option> {company.subscription?.pastDueSince && (
<option value="UNPAID">UNPAID</option> <p className="mt-0.5 text-xs text-orange-500">Past due since {new Date(company.subscription.pastDueSince).toLocaleDateString()}</p>
</select> )}
</label> {company.subscription?.suspendedAt && (
<p className="mt-0.5 text-xs text-red-500">Suspended {new Date(company.subscription.suspendedAt).toLocaleDateString()}</p>
)}
</div>
<label> <label>
<span className={LABEL_CLASS}>Currency</span> <span className={LABEL_CLASS}>Currency</span>
<select className={INPUT_CLASS} value={form.subscription.currency} onChange={(e) => updateSection('subscription', { currency: e.target.value })}> <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>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-4">
<div className="panel p-6 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"> <div className="space-y-3">
{company.status === 'SUSPENDED' ? ( {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"> <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>
</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 && ( {auditLogs.length > 0 && (
<div className="panel overflow-hidden"> <div className="panel overflow-hidden">
<div className="border-b border-zinc-800 px-6 py-4"> <div className="border-b border-zinc-800 px-6 py-4">
@@ -729,5 +846,6 @@ export default function AdminCompanyDetailPage() {
)} )}
</div> </div>
</div> </div>
</div>
) )
} }
@@ -88,8 +88,9 @@ export default function AdminCompaniesPage() {
}) })
const json = await res.json() const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch') if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
setCompanies(json.data ?? []) const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
setFiltered(json.data ?? []) setCompanies(list)
setFiltered(list)
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } 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/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/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/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 }) { 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() const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed') if (!res.ok) throw new Error(json?.message ?? 'Failed')
setRenters(json.data ?? []) const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
setFiltered(json.data ?? []) setRenters(list)
setFiltered(list)
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -198,8 +198,8 @@ export default function AdminSiteConfigPage() {
const companiesJson = await companiesRes.json() const companiesJson = await companiesRes.json()
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies') if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
setCompanies(companiesJson.data ?? []) setCompanies(companiesJson.data?.data ?? [])
setFiltered(companiesJson.data ?? []) setFiltered(companiesJson.data?.data ?? [])
const homepageJson = await homepageRes.json() const homepageJson = await homepageRes.json()
if (homepageRes.ok && homepageJson?.data) { if (homepageRes.ok && homepageJson?.data) {
@@ -28,6 +28,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
auditLogs: 'Audit Logs', auditLogs: 'Audit Logs',
adminUsers: 'Admin Users', adminUsers: 'Admin Users',
billing: 'Billing', billing: 'Billing',
pricing: 'Pricing',
}, },
logout: 'Logout', logout: 'Logout',
language: 'Language', language: 'Language',
@@ -48,6 +49,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
auditLogs: "Journaux d'audit", auditLogs: "Journaux d'audit",
adminUsers: 'Utilisateurs admin', adminUsers: 'Utilisateurs admin',
billing: 'Facturation', billing: 'Facturation',
pricing: 'Tarification',
}, },
logout: 'Déconnexion', logout: 'Déconnexion',
language: 'Langue', language: 'Langue',
@@ -68,6 +70,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
auditLogs: 'سجلات التدقيق', auditLogs: 'سجلات التدقيق',
adminUsers: 'مستخدمو الإدارة', adminUsers: 'مستخدمو الإدارة',
billing: 'الفوترة', billing: 'الفوترة',
pricing: 'الأسعار',
}, },
logout: 'تسجيل الخروج', logout: 'تسجيل الخروج',
language: 'اللغة', language: 'اللغة',
+33
View File
@@ -7,6 +7,13 @@ import { redis } from './lib/redis'
import { prisma } from './lib/prisma' import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage' import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app' import { createApp, corsOrigins } from './app'
import {
runTrialExpirationJob,
runPaymentPendingTimeoutJob,
runPastDueTimeoutJob,
runSuspensionTimeoutJob,
runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service'
const app = createApp() const app = createApp()
const server = http.createServer(app) 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) // Daily: send trial-ending reminders (3 days before trial end)
cron.schedule('0 9 * * *', async () => { cron.schedule('0 9 * * *', async () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) 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 { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond' import { ok, created } from '../../http/respond'
import * as service from './admin.service' import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import { presentAdminUser } from './admin.presenter' import { presentAdminUser } from './admin.presenter'
import { import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
@@ -10,7 +11,18 @@ import {
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema, invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema, createAdminSchema, adminRoleSchema, adminPermissionsSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema, homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema,
pricingUpdateSchema,
} from './admin.schemas' } 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() const router = Router()
@@ -208,6 +220,70 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol
} catch (err) { next(err) } } 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 ─────────────────────────────────────────────── // ─── Site config ───────────────────────────────────────────────
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => { 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(), plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).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, trialStartAt: nullableDate, trialEndAt: nullableDate,
currentPeriodStart: nullableDate, currentPeriodEnd: nullableDate, currentPeriodStart: nullableDate, currentPeriodEnd: nullableDate,
cancelledAt: nullableDate, cancelAtPeriodEnd: z.boolean().optional(), cancelledAt: nullableDate, cancelAtPeriodEnd: z.boolean().optional(),
@@ -110,7 +110,7 @@ export const adminCompanyUpdateSchema = z.object({
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString, publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl, publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(), whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(), defaultCurrency: z.literal('MAD').optional(),
isListedOnMarketplace: z.boolean().optional(), isListedOnMarketplace: z.boolean().optional(),
homePageConfig: z.any().optional(), homePageConfig: z.any().optional(),
menuConfig: z.any().optional(), menuConfig: z.any().optional(),
@@ -125,7 +125,7 @@ export const adminCompanyUpdateSchema = z.object({
accountingSettings: z.object({ accountingSettings: z.object({
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(), reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
fiscalYearStart: z.number().int().min(1).max(12).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, accountantEmail: nullableEmail, accountantName: nullableString,
autoSendReport: z.boolean().optional(), autoSendReport: z.boolean().optional(),
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(), reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
@@ -174,3 +174,11 @@ export const invoiceIdParamSchema = z.object({
export const homepageUpdateSchema = z.object({ export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema, 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 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 managerName: string
fax?: string fax?: string
yearsActive: string yearsActive: string
currency: 'MAD' | 'USD' | 'EUR' currency: 'MAD'
registrationNumber: string registrationNumber: string
plan: 'STARTER' | 'GROWTH' | 'PRO' plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL' billingPeriod: 'MONTHLY' | 'ANNUAL'
@@ -18,6 +18,6 @@ export const companySignupSchema = z.object({
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'), preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']), plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']), currency: z.literal('MAD'),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']), paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
}) })
@@ -5,7 +5,7 @@ export const renterUpdateSchema = z.object({
lastName: z.string().min(1).max(100).trim().optional(), lastName: z.string().min(1).max(100).trim().optional(),
phone: z.string().max(30).trim().optional(), phone: z.string().max(30).trim().optional(),
preferredLocale: z.enum(['en', 'fr', 'ar']).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({ export const renterFcmTokenSchema = z.object({
@@ -20,7 +20,7 @@ export const brandSchema = z.object({
websiteUrl: z.string().url().optional(), websiteUrl: z.string().url().optional(),
whatsappNumber: z.string().optional(), whatsappNumber: z.string().optional(),
defaultLocale: z.string().optional(), defaultLocale: z.string().optional(),
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(), defaultCurrency: z.literal('MAD').optional(),
amanpayMerchantId: z.string().optional(), amanpayMerchantId: z.string().optional(),
amanpaySecretKey: z.string().optional(), amanpaySecretKey: z.string().optional(),
paypalEmail: z.string().email().optional(), paypalEmail: z.string().email().optional(),
@@ -146,7 +146,7 @@ export const pricingRuleSchema = z.object({
export const accountingSettingsSchema = z.object({ export const accountingSettingsSchema = z.object({
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(), reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
fiscalYearStart: z.number().int().min(1).max(12).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(), accountantEmail: z.string().email().optional(),
accountantName: z.string().optional(), accountantName: z.string().optional(),
autoSendReport: z.boolean().optional(), autoSendReport: z.boolean().optional(),
@@ -3,14 +3,14 @@ import { z } from 'zod'
export const chargeSchema = z.object({ export const chargeSchema = z.object({
provider: z.enum(['AMANPAY', 'PAYPAL']), provider: z.enum(['AMANPAY', 'PAYPAL']),
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), 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(), successUrl: z.string().url(),
failureUrl: z.string().url(), failureUrl: z.string().url(),
}) })
export const manualPaymentSchema = z.object({ export const manualPaymentSchema = z.object({
amount: z.number().int().positive(), 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'), type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']), 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: { export async function initCharge(reservationId: string, companyId: string, body: {
provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT' 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) const reservation = await repo.findReservationOrThrow(reservationId, companyId)
if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid') 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({ export const paySchema = z.object({
provider: z.enum(['AMANPAY', 'PAYPAL']), provider: z.enum(['AMANPAY', 'PAYPAL']),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'), currency: z.literal('MAD').default('MAD'),
successUrl: z.string().url(), successUrl: z.string().url(),
failureUrl: 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: { 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 company = await repo.findCompanyBySlug(slug)
const reservation = await repo.findReservationForPayment(reservationId, company.id) 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' import { prisma } from '../../lib/prisma'
// ─── Subscription queries ─────────────────────────────────────
export function findByCompany(companyId: string) { export function findByCompany(companyId: string) {
return prisma.subscription.findUnique({ return prisma.subscription.findUnique({
where: { companyId }, 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) { 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) { 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) { 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) { 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) { export function findInvoiceById(id: string) {
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() } }) 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) { export function activateSubscription(id: string, periodEnd: Date) {
return prisma.subscription.update({ return prisma.subscription.update({
where: { id }, 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) { export function startTrial(
const existing = await prisma.subscription.findUnique({ where: { companyId } }) companyId: string,
if (existing) return existing plan: string,
return prisma.subscription.create({ data: { companyId, plan: plan as any, billingPeriod: billingPeriod as any, currency, status: 'PENDING' as any } }) 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: { export function setPaymentPending(id: string) {
companyId: string; subscriptionId: string; amount: number; currency: string const now = new Date()
paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null const dueAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
}) { return prisma.subscription.update({
return prisma.subscriptionInvoice.create({ data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any } }) where: { id },
data: {
status: 'PAYMENT_PENDING',
paymentPendingSince: now,
paymentDueAt: dueAt,
},
})
} }
export function updateInvoicePaypal(id: string, captureId: string) { export function setPastDue(id: string) {
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId } }) 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 }) { export function updatePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
return prisma.subscription.update({ where: { companyId }, data }) return prisma.subscription.update({ where: { companyId }, data })
} }
export function setCancelAtPeriodEnd(companyId: string, value: boolean) { // ─── Invoice mutations ────────────────────────────────────────
return prisma.subscription.update({ where: { companyId }, data: { cancelAtPeriodEnd: value } })
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 amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService' import * as paypal from '../../services/paypalService'
import * as service from './subscription.service' 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 publicRouter = Router()
const webhookRouter = Router() const webhookRouter = Router()
@@ -15,8 +22,8 @@ const router = Router()
// ─── Public ──────────────────────────────────────────────────── // ─── Public ────────────────────────────────────────────────────
publicRouter.get('/plans', (_req, res) => { publicRouter.get('/plans', (_req, res, next) => {
ok(res, service.getPlans()) service.getPlans().then((d) => ok(res, d)).catch(next)
}) })
publicRouter.get('/providers', (_req, res) => { publicRouter.get('/providers', (_req, res) => {
@@ -56,19 +63,30 @@ router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, re
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
// ─── Authenticated ──────────────────────────────────────────── // ─── Authenticated ────────────────────────────────────────────
router.use(requireCompanyAuth, requireTenant) router.use(requireCompanyAuth, requireTenant)
router.get('/me', async (req, res, next) => { router.get('/me', async (req, res, next) => {
try { try { ok(res, await service.getSubscription(req.companyId)) } catch (err) { next(err) }
ok(res, await service.getSubscription(req.companyId))
} catch (err) { next(err) }
}) })
router.get('/invoices', async (req, res, next) => { 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 { 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) } } catch (err) { next(err) }
}) })
@@ -79,6 +97,13 @@ router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) } } 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) => { router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
try { try {
const body = parseBody(changePlanSchema, req) 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) => { router.post('/cancel', requireRole('OWNER'), async (req, res, next) => {
try { 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) } } catch (err) { next(err) }
}) })
router.post('/resume', requireRole('OWNER'), async (req, res, next) => { router.post('/resume', requireRole('OWNER'), async (req, res, next) => {
try { try { ok(res, await service.resume(req.companyId)) } catch (err) { next(err) }
ok(res, await service.resume(req.companyId))
} catch (err) { next(err) }
}) })
export default router export default router
@@ -1,20 +1,44 @@
import { z } from 'zod' 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({ export const checkoutSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']), plan: planEnum,
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), billingPeriod: billingPeriodEnum,
currency: z.enum(['MAD', 'USD', 'EUR']), currency: z.literal('MAD'),
provider: z.enum(['AMANPAY', 'PAYPAL']), provider: providerEnum,
successUrl: z.string().url(), successUrl: z.string().url(),
failureUrl: z.string().url(), failureUrl: z.string().url(),
}) })
export const changePlanSchema = z.object({ export const changePlanSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']), plan: planEnum,
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), billingPeriod: billingPeriodEnum,
currency: z.enum(['MAD', 'USD', 'EUR']), currency: z.literal('MAD'),
}) })
export const capturePaypalSchema = z.object({ export const capturePaypalSchema = z.object({
paypalOrderId: z.string(), 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 amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService' import * as paypal from '../../services/paypalService'
import * as repo from './subscription.repo' import * as repo from './subscription.repo'
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
// ─── Helpers ──────────────────────────────────────────────────
export function addPeriod(date: Date, period: string): Date { export function addPeriod(date: Date, period: string): Date {
const d = new Date(date) const d = new Date(date)
@@ -11,14 +14,25 @@ export function addPeriod(date: Date, period: string): Date {
return d return d
} }
export function getPlans() { // ─── Plans & providers ────────────────────────────────────────
return PLAN_PRICES
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() { export function getProviders() {
return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() } return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() }
} }
// ─── Reads ────────────────────────────────────────────────────
export function getSubscription(companyId: string) { export function getSubscription(companyId: string) {
return repo.findByCompany(companyId) return repo.findByCompany(companyId)
} }
@@ -27,15 +41,131 @@ export function getInvoices(companyId: string) {
return repo.findInvoices(companyId) 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) { export async function handleAmanpayWebhook(event: any) {
const transactionId = event.transaction_id ?? event.id const transactionId = event.transaction_id ?? event.id
const status = event.status?.toUpperCase() const status = event.status?.toUpperCase()
if (status === 'PAID' || status === 'SUCCEEDED') { if (status === 'PAID' || status === 'SUCCEEDED') {
const invoice = await repo.findInvoiceByAmanpay(transactionId) const invoice = await repo.findInvoiceByAmanpay(transactionId)
if (invoice) { if (!invoice || invoice.status === 'PAID') return // idempotent
await repo.markInvoicePaid(invoice.id) await handlePaymentSuccess(invoice.subscriptionId, invoice.id)
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod)) } 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') { if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
const captureId = event.resource?.id as string const captureId = event.resource?.id as string
const invoice = await repo.findInvoiceByPaypal(captureId) const invoice = await repo.findInvoiceByPaypal(captureId)
if (invoice) { if (!invoice || invoice.status === 'PAID') return // idempotent
await repo.markInvoicePaid(invoice.id) await handlePaymentSuccess(invoice.subscriptionId, invoice.id)
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod)) } 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: { export async function checkout(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL' plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'; provider: 'AMANPAY' | 'PAYPAL' currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL'
successUrl: string; failureUrl: string successUrl: string; failureUrl: string
}) { }) {
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod] const dbPrice = await prisma.pricingConfig.findUnique({
if (!prices) throw new ValidationError('Invalid plan or billing period') where: { plan_billingPeriod: { plan: body.plan, billingPeriod: body.billingPeriod } },
const amount = (prices as any)[body.currency] })
if (!amount) throw new ValidationError('Currency not supported for this plan') 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 company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
const subscription = await repo.findOrCreateSubscription(companyId, body.plan, body.billingPeriod, body.currency) 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 amanpayTransactionId = result.transactionId
} else { } else {
if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured on this platform') 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 checkoutUrl = result.approveUrl
paypalCaptureId = result.orderId 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 } return { invoice, checkoutUrl }
} }
export async function capturePaypal(companyId: string, paypalOrderId: string) { export async function capturePaypal(companyId: string, paypalOrderId: string) {
const invoice = await repo.findInvoiceByPaypalForCompany(paypalOrderId, companyId) 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 capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await repo.updateInvoicePaypal(invoice.id, captureId) 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 } return { success: true }
} }
// ─── Plan changes ─────────────────────────────────────────────
export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) { export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
return repo.updatePlan(companyId, data) return repo.updatePlan(companyId, data)
} }
export function cancel(companyId: string) { // ─── Cancellation ─────────────────────────────────────────────
return repo.setCancelAtPeriodEnd(companyId, true)
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) { export function resume(companyId: string) {
return repo.setCancelAtPeriodEnd(companyId, false) 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 id: string
reservationId: string reservationId: string
amount: number amount: number
currency: 'MAD' | 'USD' | 'EUR' currency: string
status: string status: string
type: 'CHARGE' | 'DEPOSIT' type: 'CHARGE' | 'DEPOSIT'
paymentProvider: 'AMANPAY' | 'PAYPAL' paymentProvider: 'AMANPAY' | 'PAYPAL'
@@ -71,7 +71,7 @@ export default function BillingPage() {
const [paymentModalRow, setPaymentModalRow] = useState<BillingRow | null>(null) const [paymentModalRow, setPaymentModalRow] = useState<BillingRow | null>(null)
const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('CASH') const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('CASH')
const [paymentType, setPaymentType] = useState<'CHARGE' | 'DEPOSIT'>('CHARGE') const [paymentType, setPaymentType] = useState<'CHARGE' | 'DEPOSIT'>('CHARGE')
const [paymentCurrency, setPaymentCurrency] = useState<'MAD' | 'USD' | 'EUR'>('MAD') const paymentCurrency = 'MAD'
const [paymentAmount, setPaymentAmount] = useState('') const [paymentAmount, setPaymentAmount] = useState('')
const [submittingPayment, setSubmittingPayment] = useState(false) const [submittingPayment, setSubmittingPayment] = useState(false)
const [paymentError, setPaymentError] = useState<string | null>(null) const [paymentError, setPaymentError] = useState<string | null>(null)
@@ -393,7 +393,6 @@ export default function BillingPage() {
setPaymentModalRow(row) setPaymentModalRow(row)
setPaymentMethod('CASH') setPaymentMethod('CASH')
setPaymentType('CHARGE') setPaymentType('CHARGE')
setPaymentCurrency('MAD')
setPaymentAmount(String((row.balanceDue / 100).toFixed(2))) setPaymentAmount(String((row.balanceDue / 100).toFixed(2)))
setPaymentError(null) 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> <p className="mt-2 text-xs text-slate-500">{paymentType === 'DEPOSIT' ? copy.depositHelp : copy.chargeHelp}</p>
</div> </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> <div>
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentAmount}</label> <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} /> <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 Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL' type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type Currency = 'MAD' | 'USD' | 'EUR'
interface Subscription { interface Subscription {
id: string id: string
plan: Plan plan: Plan
billingPeriod: BillingPeriod billingPeriod: BillingPeriod
status: string status: string
currency: Currency currency: string
trialEndAt: string | null trialEndAt: string | null
currentPeriodEnd: string | null currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean cancelAtPeriodEnd: boolean
@@ -24,7 +23,7 @@ interface Subscription {
interface Invoice { interface Invoice {
id: string id: string
amount: number amount: number
currency: Currency currency: string
status: string status: string
paymentProvider: string paymentProvider: string
paidAt: string | null paidAt: string | null
@@ -67,7 +66,7 @@ export default function SubscriptionPage() {
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER') const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY') const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const [currency, setCurrency] = useState<Currency>('MAD') const currency = 'MAD'
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY') const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false }) const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
const [paying, setPaying] = useState(false) const [paying, setPaying] = useState(false)
@@ -245,7 +244,7 @@ export default function SubscriptionPage() {
setSubscription(sub) setSubscription(sub)
setSelectedPlan(sub.plan) setSelectedPlan(sub.plan)
setBillingPeriod(sub.billingPeriod) setBillingPeriod(sub.billingPeriod)
setCurrency(sub.currency as Currency) // currency is always MAD
} }
setInvoices(inv ?? []) setInvoices(inv ?? [])
}) })
@@ -400,21 +399,6 @@ export default function SubscriptionPage() {
))} ))}
</div> </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 */} {/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => { {PLANS.map((plan) => {
@@ -435,7 +419,7 @@ export default function SubscriptionPage() {
{isActive && <span className="badge-green">{copy.active}</span>} {isActive && <span className="badge-green">{copy.active}</span>}
</div> </div>
<p className="mt-2 text-2xl font-black text-slate-900"> <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> <span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p> </p>
<ul className="mt-3 space-y-1"> <ul className="mt-3 space-y-1">
@@ -482,7 +466,7 @@ export default function SubscriptionPage() {
<div> <div>
<p className="text-sm text-slate-500">{copy.total}</p> <p className="text-sm text-slate-500">{copy.total}</p>
<p className="text-xl font-black text-slate-900"> <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> <span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
</p> </p>
</div> </div>
@@ -530,7 +514,7 @@ export default function SubscriptionPage() {
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'} {inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
</td> </td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900"> <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> </td>
</tr> </tr>
))} ))}
@@ -58,12 +58,20 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
setLoading(true) setLoading(true)
setError(null) setError(null)
try { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }), 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) setSent(true)
} catch { } catch {
setError(dict.error) setError(dict.error)
@@ -29,7 +29,7 @@ type SignupForm = {
yearsActive: string yearsActive: string
plan: 'STARTER' | 'GROWTH' | 'PRO' plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL' billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR' currency: 'MAD'
paymentProvider: 'AMANPAY' | 'PAYPAL' paymentProvider: 'AMANPAY' | 'PAYPAL'
} }
@@ -557,9 +557,8 @@ export default function SignUpPage() {
</button> </button>
))} ))}
</div> </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' ? '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'] }))} /> <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> </div>
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} /> <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} /> <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"> <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><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.reviewOwnerEmail}:</span> {form.email || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p> <p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
</div> </div>
@@ -741,7 +740,6 @@ function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod
return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY' return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
} }
function normalizeCurrency(value: string | null): SignupForm['currency'] { function normalizeCurrency(_value: string | null): SignupForm['currency'] {
if (value === 'USD' || value === 'EUR' || value === 'MAD') return value
return 'MAD' return 'MAD'
} }
@@ -5,29 +5,12 @@ import Link from 'next/link'
import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { resolveBrowserAppUrl } from '@/lib/appUrls' import { resolveBrowserAppUrl } from '@/lib/appUrls'
type Currency = 'MAD' | 'USD' | 'EUR'
type Billing = 'monthly' | 'annual' type Billing = 'monthly' | 'annual'
const SYMBOL: Record<Currency, string> = { MAD: 'MAD', USD: '$', EUR: '€' } const PRICES: Record<string, { monthly: number; annual: number }> = {
STARTER: { monthly: 299, annual: 2990 },
const LANGUAGE_CURRENCY: Record<string, Currency> = { ar: 'MAD', fr: 'EUR', en: 'USD' } GROWTH: { monthly: 399, annual: 3990 },
PRO: { monthly: 499, annual: 4990 },
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 PLANS = [ const PLANS = [
@@ -110,14 +93,8 @@ const copy = {
}, },
} as const } as const
function fmt(value: number, currency: Currency, billing: Billing): string { function annualSavingsPct(plan: string): number {
const sym = SYMBOL[currency] const { monthly, annual } = PRICES[plan]
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]
const wouldPay = monthly * 12 const wouldPay = monthly * 12
return Math.round(((wouldPay - annual) / wouldPay) * 100) return Math.round(((wouldPay - annual) / wouldPay) * 100)
} }
@@ -125,22 +102,16 @@ function annualSavingsPct(plan: string, currency: Currency): number {
export default function PricingClient() { export default function PricingClient() {
const { language } = useMarketplacePreferences() const { language } = useMarketplacePreferences()
const dict = copy[language] const dict = copy[language]
const [currency, setCurrency] = useState<Currency>(() => LANGUAGE_CURRENCY[language] ?? 'MAD')
const [billing, setBilling] = useState<Billing>('monthly') 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 dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const savings = annualSavingsPct('STARTER', currency) const savings = annualSavingsPct('STARTER')
return ( return (
<div className="space-y-10"> <div className="space-y-10">
{/* Toggles */}
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
{/* Billing toggle */} {/* 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) => ( {(['monthly', 'annual'] as Billing[]).map((b) => (
<button <button
key={b} key={b}
@@ -161,24 +132,6 @@ export default function PricingClient() {
))} ))}
</div> </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' && ( {billing === 'annual' && (
<p className="text-center text-sm font-medium text-orange-700 dark:text-orange-400"> <p className="text-center text-sm font-medium text-orange-700 dark:text-orange-400">
{dict.youSave} {savings}% {dict.withAnnual} {dict.youSave} {savings}% {dict.withAnnual}
@@ -188,11 +141,10 @@ export default function PricingClient() {
{/* Plan cards */} {/* Plan cards */}
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{PLANS.map((plan) => { {PLANS.map((plan) => {
const price = PRICES[plan.key][currency] const price = PRICES[plan.key]
const displayPrice = billing === 'annual' const displayPrice = billing === 'annual'
? Math.round(price.annual / 12) ? Math.round(price.annual / 12)
: price.monthly : price.monthly
const sym = SYMBOL[currency]
return ( return (
<div <div
@@ -218,20 +170,14 @@ export default function PricingClient() {
<div className="mt-6"> <div className="mt-6">
<div className="flex items-end gap-1"> <div className="flex items-end gap-1">
<span className="text-4xl font-black text-stone-900 dark:text-stone-100"> <span className="text-4xl font-black text-stone-900 dark:text-stone-100">
{currency === 'MAD' ? displayPrice : `${sym}${displayPrice}`} {displayPrice}
</span> </span>
{currency === 'MAD' && ( <span className="mb-1 text-lg font-semibold text-stone-500 dark:text-stone-400">MAD</span>
<span className="mb-1 text-lg font-semibold text-stone-500 dark:text-stone-400">{sym}</span>
)}
<span className="mb-1 text-sm text-stone-400 dark:text-stone-500">{dict.perMonth}</span> <span className="mb-1 text-sm text-stone-400 dark:text-stone-500">{dict.perMonth}</span>
</div> </div>
{billing === 'annual' && ( {billing === 'annual' && (
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500"> <p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
{dict.billedAs}{' '} {dict.billedAs} {price.annual} MAD {dict.yearly}
{currency === 'MAD'
? `${price.annual} ${sym}`
: `${sym}${price.annual}`}{' '}
{dict.yearly}
</p> </p>
)} )}
</div> </div>
@@ -254,7 +200,7 @@ export default function PricingClient() {
</ul> </ul>
<Link <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 ${ className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
plan.highlight plan.highlight
? 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-400 dark:text-stone-900 dark:hover:bg-orange-300' ? '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 <SelectField
label={dict.currency} label={dict.currency}
value={profile?.preferredCurrency || 'MAD'} value="MAD"
options={['MAD', 'USD', 'EUR']} options={['MAD']}
onChange={(value) => setProfile((current) => current ? { ...current, preferredCurrency: value } : current)} onChange={() => {}}
/> />
</div> </div>
<div className="mt-8 flex justify-end"> <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 - api
env_file: env_file:
- .env.docker.dev - .env.docker.dev
environment:
ADMIN_ASSET_PREFIX: "http://localhost:3002"
command: command:
[ [
"sh", "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 { enum SubscriptionStatus {
TRIALING TRIALING
ACTIVE ACTIVE
PAYMENT_PENDING
PAST_DUE PAST_DUE
SUSPENDED
CANCELLED CANCELLED
EXPIRED
PAUSED
UNPAID UNPAID
} }
@@ -45,6 +49,7 @@ enum InvoiceStatus {
PAID PAID
FAILED FAILED
REFUNDED REFUNDED
VOIDED
} }
enum PaymentProvider { enum PaymentProvider {
@@ -366,11 +371,20 @@ model Subscription {
currency String @default("MAD") currency String @default("MAD")
trialStartAt DateTime? trialStartAt DateTime?
trialEndAt DateTime? trialEndAt DateTime?
trialUsed Boolean @default(false)
currentPeriodStart DateTime? currentPeriodStart DateTime?
currentPeriodEnd DateTime? currentPeriodEnd DateTime?
paymentPendingSince DateTime?
paymentDueAt DateTime?
pastDueSince DateTime?
suspendedAt DateTime?
endedAt DateTime?
cancelledAt DateTime? cancelledAt DateTime?
cancelAtPeriodEnd Boolean @default(false) cancelAtPeriodEnd Boolean @default(false)
retryCount Int @default(0)
maxRetryCount Int @default(5)
invoices SubscriptionInvoice[] invoices SubscriptionInvoice[]
events SubscriptionEvent[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -378,19 +392,40 @@ model Subscription {
@@map("subscriptions") @@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 { model SubscriptionInvoice {
id String @id @default(cuid()) id String @id @default(cuid())
companyId String companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
subscriptionId String subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id]) subscription Subscription @relation(fields: [subscriptionId], references: [id])
providerInvoiceId String?
amount Int amount Int
currency String @default("MAD") currency String @default("MAD")
status InvoiceStatus status InvoiceStatus
amanpayTransactionId String? @unique amanpayTransactionId String? @unique
paypalCaptureId String? @unique paypalCaptureId String? @unique
paymentProvider PaymentProvider @default(AMANPAY) paymentProvider PaymentProvider @default(AMANPAY)
dueAt DateTime?
paidAt DateTime? paidAt DateTime?
failedAt DateTime?
voidedAt DateTime?
attempts PaymentAttempt[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -398,6 +433,22 @@ model SubscriptionInvoice {
@@map("subscription_invoices") @@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 { model BrandSettings {
id String @id @default(cuid()) id String @id @default(cuid())
companyId String @unique companyId String @unique
@@ -1115,3 +1166,16 @@ model AuditLog {
@@index([action]) @@index([action])
@@map("audit_logs") @@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 [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>>> = { export const PLAN_PRICES: Record<string, Record<string, Record<string, number>>> = {
STARTER: { STARTER: {
MONTHLY: { MAD: 2990, USD: 2900, EUR: 2700 }, MONTHLY: { MAD: 2990 },
ANNUAL: { MAD: 29900, USD: 29000, EUR: 27000 }, ANNUAL: { MAD: 29900 },
}, },
GROWTH: { GROWTH: {
MONTHLY: { MAD: 3990, USD: 5900, EUR: 5400 }, MONTHLY: { MAD: 3990 },
ANNUAL: { MAD: 39900, USD: 59000, EUR: 54000 }, ANNUAL: { MAD: 39900 },
}, },
PRO: { PRO: {
MONTHLY: { MAD: 99900, USD: 9900, EUR: 9000 }, MONTHLY: { MAD: 99900 },
ANNUAL: { MAD: 999000, USD: 99000, EUR: 90000 }, ANNUAL: { MAD: 999000 },
}, },
} }
export type Locale = 'en' | 'fr' | 'ar' 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 divisor = 100
const value = amount / divisor const value = amount / divisor
const localeMap: Record<Locale, string> = { en: 'en-US', fr: 'fr-FR', ar: 'ar-MA' } const localeMap: Record<Locale, string> = { en: 'en-US', fr: 'fr-FR', ar: 'ar-MA' }
return new Intl.NumberFormat(localeMap[locale], { return new Intl.NumberFormat(localeMap[locale], {
style: 'currency', style: 'currency',
currency, currency: 'MAD',
minimumFractionDigits: 2, minimumFractionDigits: 2,
}).format(value) }).format(value)
} }