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