fix billing and 2fa admin
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s

This commit is contained in:
root
2026-08-10 22:35:55 -04:00
parent 10ca76fc1e
commit 5f06256271
73 changed files with 8803 additions and 570 deletions
+21 -7
View File
@@ -21,11 +21,11 @@ ADMIN_INTERNAL_URL=http://host.docker.internal:3002
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
JWT_SECRET=placeholder
JWT_SECRET=JYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
ADMIN_SEED_PASSWORD=placeholder
ADMIN_SEED_PASSWORD=Qwerty00!@#$%
ADMIN_SEED_FIRST_NAME=Platform
ADMIN_SEED_LAST_NAME=Admin
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
@@ -34,17 +34,17 @@ NODE_ENV=development
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000
# Email — Resend (primary) with SMTP fallback
# Get your API key at https://resend.com/api-keys
# Email — Gmail SMTP for local Docker development
EMAIL_PROVIDER=gmail
RESEND_API_KEY=re_PLACEHOLDER
EMAIL_FROM=noreply@rentaldrivego.ma
EMAIL_FROM_NAME=RentalDriveGo
# SMTP fallback (only used if Resend fails or is unconfigured)
# Use a Gmail app password, not your normal Google account password.
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=placeholder
MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
@@ -55,4 +55,18 @@ MAIL_REPLY_TO_NAME=RentalDriveGo
# STRIPE_API_KEY must be a Stripe secret/restricted key (sk_ or rk_).
# STRIPE_WEBHOOK_SECRET must be a Stripe webhook signing secret (whsec_).
STRIPE_API_KEY=sk_test_51TvTsb9SpDRZn9yJyBAlUSXcTp9zpwQfhJYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00OcYvj28K
STRIPE_WEBHOOK_SECRET=whsec_c5e0a6b2dd5e2f6ac804b428fe46f04e3af9b55c562f4124de20c52866f3c211
STRIPE_WEBHOOK_SECRET=whsec_c5e0a6b2dd5e2f6ac804b428fe46f04e3af9b55c562f4124de20c52866f3c211
# Manual subscription payments for local development
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=true
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=true
PAYMENT_EVIDENCE_SCANNER_MODE=stub-clean
BANK_TRANSFER_ENABLED=true
BANK_TRANSFER_ACCOUNT_NAME=RentalDriveGo SARL
BANK_TRANSFER_BANK_NAME=Local Development Bank
BANK_TRANSFER_ACCOUNT_REFERENCE=DEV-MA64-0000-0000-0000
BANK_TRANSFER_DUE_DAYS=7
CHECK_PAYMENT_ENABLED=true
CHECK_PAYMENT_PAYEE=RentalDriveGo SARL
CHECK_PAYMENT_DELIVERY_ADDRESS=Local development billing desk
CHECK_PAYMENT_DUE_DAYS=14
+3 -2
View File
@@ -75,12 +75,13 @@ EMAIL_FROM_NAME=RentalDriveGo
# Option A — Resend
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
# Option B — SMTP (Gmail)
# SMTP fallback (only used if Resend fails or is unconfigured)
# EMAIL_PROVIDER=gmail selects Gmail SMTP and skips Resend.
EMAIL_PROVIDER=gmail
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=placeholder
MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
+4 -2
View File
@@ -110,12 +110,14 @@ ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
ADMIN_SEED_FIRST_NAME=Super
ADMIN_SEED_LAST_NAME=Admin
# SMTP fallback (only used if Resend fails or is unconfigured)
# Email provider: auto, resend, smtp, or gmail.
# Use a Gmail app password for Gmail SMTP, not your normal Google account password.
EMAIL_PROVIDER=gmail
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
+23
View File
@@ -0,0 +1,23 @@
# Manual subscription payments
This project implements bank-transfer and check collection as first-class subscription billing paths. Evidence submission is explicitly **not** settlement: only a fresh-2FA finance confirmation of cleared funds creates a successful offline payment attempt, pays the canonical billing invoice, and activates or renews the subscription.
## Safe rollout
1. Apply the `20260809190000_manual_subscription_payments` migration and deploy with all new flags disabled.
2. Configure a private persistent, encrypted-at-rest `FILE_STORAGE_ROOT`; set both private-storage confirmation flags only after verifying the mounted storage; configure a fail-closed scanner executable, bank/check payer instructions, billing contacts, valid IANA timezones, and finance collections owners.
3. Enable `MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED`, then `MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED` and the desired manual method flags.
4. Enable `SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED`; verify the AR/EN/FR snapshots, outbox delivery, 48-hour call task, and escalation queue.
5. Enable `SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED` only after observing a full reminder/grace cycle. Suspension starts after 30 calendar grace days and rechecks payment, overrides, and configuration atomically.
Manual methods remain hidden unless their instructions and evidence-scanning pipeline are ready. The non-production `PAYMENT_EVIDENCE_SCANNER_MODE=stub-clean` option exists only for local tests; production requires `PAYMENT_EVIDENCE_SCANNER_PATH`.
## Operational invariants
- Subscription billing has one canonical `BillingInvoice`; legacy `SubscriptionInvoice` rows are compatibility links only.
- A scheduled renewal is reused by Stripe checkout rather than duplicated.
- Manual confirmation requires the full current MAD balance, matching collection method, clean same-invoice evidence, a unique normalized external reference, a unique idempotency key, a clearance timestamp, and a funds-verified attestation.
- Initial purchases activate from confirmation time. Renewals extend from the original expiration, including late confirmations after suspension.
- Evidence is stored privately with no public URL, quarantined before scanning, served only through authorized routes, and immutable after submission.
- Collections reminders are recipient-specific and localized using contact preference, linked employee preference, then company default. Only Arabic, English, and French can be enabled.
- Payment resolution cancels open call tasks and suppresses queued collections reminders.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@ const ADMIN_BASE_PATH = '/admin'
// 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 = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH)
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] })
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix], frameSources: ['blob:'] })
const nextConfig = {
basePath: ADMIN_BASE_PATH,
@@ -9,6 +9,7 @@ interface AdminUser {
lastName: string
email: string
role: string
preferredLocale: 'ar' | 'en' | 'fr'
isActive: boolean
createdAt: string
permissions?: { id: string; resource: string; actions: string[] }[]
@@ -21,6 +22,7 @@ const EMPTY_FORM = {
email: '',
password: '',
role: 'SUPPORT',
preferredLocale: 'en',
isActive: true,
}
@@ -68,6 +70,7 @@ export default function AdminUsersPage() {
email: admin.email,
password: '',
role: admin.role,
preferredLocale: admin.preferredLocale,
isActive: admin.isActive,
})
setError(null)
@@ -92,6 +95,7 @@ export default function AdminUsersPage() {
lastName: form.lastName,
email: form.email,
role: form.role,
preferredLocale: form.preferredLocale,
isActive: form.isActive,
...(form.password ? { password: form.password } : {}),
}
@@ -273,6 +277,18 @@ export default function AdminUsersPage() {
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Notification language</label>
<select
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.preferredLocale}
onChange={(e) => setForm({ ...form, preferredLocale: e.target.value as 'ar' | 'en' | 'fr' })}
>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="ar">العربية</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
<select
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,6 @@ interface CompanyDetail {
terms: string
fuelPolicyType: string
lateFeePerHour: number | null
taxRate: number | null
signatureRequired: boolean
showTax: boolean
} | null
@@ -180,7 +179,6 @@ interface FormState {
terms: string
fuelPolicyType: string
lateFeePerHour: string
taxRate: string
signatureRequired: boolean
showTax: boolean
}
@@ -297,7 +295,6 @@ function createFormState(company: CompanyDetail): FormState {
terms: company.contractSettings?.terms ?? '',
fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL',
lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '',
taxRate: company.contractSettings?.taxRate?.toString() ?? '',
signatureRequired: company.contractSettings?.signatureRequired ?? true,
showTax: company.contractSettings?.showTax ?? false,
},
@@ -441,7 +438,6 @@ export default function AdminCompanyDetailPage() {
terms: form.contractSettings.terms,
fuelPolicyType: form.contractSettings.fuelPolicyType,
lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null,
taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null,
signatureRequired: form.contractSettings.signatureRequired,
showTax: form.contractSettings.showTax,
},
@@ -915,10 +911,6 @@ export default function AdminCompanyDetailPage() {
<span className={LABEL_CLASS}>Late fee per hour</span>
<input className={INPUT_CLASS} type="number" value={form.contractSettings.lateFeePerHour} onChange={(e) => updateSection('contractSettings', { lateFeePerHour: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Tax rate</span>
<input className={INPUT_CLASS} type="number" step="0.01" value={form.contractSettings.taxRate} onChange={(e) => updateSection('contractSettings', { taxRate: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.contractSettings.signatureRequired} onChange={(e) => updateSection('contractSettings', { signatureRequired: e.target.checked })} />
Signature required
+198 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState, type FormEvent } from 'react'
import {
AdminLanguageSwitcher,
AdminThemeSwitcher,
@@ -40,6 +40,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const pathname = usePathname()
const [ready, setReady] = useState(false)
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
const [unreadNotifications, setUnreadNotifications] = useState(0)
useEffect(() => {
let cancelled = false
@@ -58,6 +59,14 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
}
setAdmin(resolvedAdmin)
setReady(true)
if (resolvedAdmin.totpEnabled) {
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null)
.then((inbox) => {
if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0))
})
.catch(() => {})
}
} else {
window.location.replace(buildUnifiedLoginUrl(pathname))
}
@@ -84,6 +93,16 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
)
}
if (admin && !admin.totpEnabled) {
return (
<Admin2FAEnrollmentGate
admin={admin}
onEnrolled={setAdmin}
onLogout={handleLogout}
/>
)
}
return (
<AdminSessionProvider admin={admin as AdminSessionUser}>
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
@@ -107,6 +126,9 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg>
{dict.nav[link.key]}
{link.key === 'notifications' && unreadNotifications > 0 ? (
<span className="ms-auto rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold text-white">{unreadNotifications > 99 ? '99+' : unreadNotifications}</span>
) : null}
</Link>
)
})}
@@ -132,3 +154,178 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
</AdminSessionProvider>
)
}
function Admin2FAEnrollmentGate({
admin,
onEnrolled,
onLogout,
}: {
admin: AdminSessionUser
onEnrolled: (admin: AdminSessionUser) => void
onLogout: () => void
}) {
const { dict } = useAdminI18n()
const [secret, setSecret] = useState('')
const [qrCode, setQrCode] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [loadingSetup, setLoadingSetup] = useState(true)
const [verifying, setVerifying] = useState(false)
const [verifiedAdmin, setVerifiedAdmin] = useState<AdminSessionUser | null>(null)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const setupStarted = useRef(false)
useEffect(() => {
if (setupStarted.current) return
setupStarted.current = true
fetch(`${ADMIN_API_BASE}/admin/auth/2fa/setup`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
.then(async (response) => {
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.')
const data = json?.data ?? json
setSecret(data?.secret ?? '')
setQrCode(data?.qrCode ?? '')
})
.catch((err: any) => setError(err?.message ?? 'Failed to start 2FA setup.'))
.finally(() => setLoadingSetup(false))
}, [])
async function verifyCode(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedCode = code.trim()
if (!/^\d{6}$/.test(normalizedCode)) {
setError('Enter the 6-digit code from your authenticator app.')
return
}
setError(null)
setVerifying(true)
try {
const response = await fetch(`${ADMIN_API_BASE}/admin/auth/2fa/verify`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: normalizedCode }),
})
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Invalid 2FA code.')
const data = json?.data ?? json
setVerifiedAdmin((data?.admin ?? { ...admin, totpEnabled: true }) as AdminSessionUser)
setRecoveryCodes(Array.isArray(data?.recoveryCodes) ? data.recoveryCodes : [])
} catch (err: any) {
setError(err?.message ?? 'Invalid 2FA code.')
} finally {
setVerifying(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] p-6 text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
<section className="w-full max-w-2xl rounded-3xl border border-stone-200/80 bg-white/90 p-8 shadow-xl backdrop-blur dark:border-blue-900 dark:bg-[#07101e]/90">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Set up admin 2FA</h1>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
Admin 2FA enrollment is required before using privileged admin routes.
</p>
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{admin.email}</p>
</div>
<button
type="button"
onClick={onLogout}
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
>
{dict.logout}
</button>
</div>
{verifiedAdmin ? (
<div className="mt-8 space-y-5">
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
2FA is enabled. Save your recovery codes before continuing.
</div>
{recoveryCodes.length > 0 ? (
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-900 dark:bg-[#0d1b38]">
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Recovery codes</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{recoveryCodes.map((recoveryCode) => (
<code key={recoveryCode} className="rounded-lg bg-white px-3 py-2 text-sm text-stone-800 dark:bg-[#07101e] dark:text-slate-200">
{recoveryCode}
</code>
))}
</div>
</div>
) : null}
<button
type="button"
onClick={() => onEnrolled(verifiedAdmin)}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
Continue to admin dashboard
</button>
</div>
) : (
<form onSubmit={verifyCode} className="mt-8 space-y-6">
{loadingSetup ? (
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
Preparing authenticator setup...
</div>
) : (
<div className="grid gap-5 md:grid-cols-[180px,1fr]">
<div className="flex h-44 items-center justify-center rounded-2xl border border-stone-200 bg-white p-3 dark:border-blue-900 dark:bg-white">
{qrCode ? <img src={qrCode} alt="Admin 2FA QR code" className="h-full w-full object-contain" /> : <span className="text-sm text-stone-500">No QR code</span>}
</div>
<div>
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</p>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
Scan the QR code with your authenticator app, or enter the setup key manually.
</p>
{secret ? (
<code className="mt-3 block break-all rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-800 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200">
{secret}
</code>
) : null}
</div>
</div>
)}
<label className="block">
<span className="mb-2 block text-sm font-semibold text-blue-950 dark:text-stone-100">6-digit code</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric"
autoComplete="one-time-code"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-stone-900 outline-none transition focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100"
placeholder="000000"
disabled={loadingSetup || verifying}
/>
</label>
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<button
type="submit"
disabled={loadingSetup || verifying || code.length !== 6}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{verifying ? 'Verifying...' : 'Enable 2FA'}
</button>
</form>
)}
</section>
</div>
)
}
@@ -6,6 +6,14 @@ import {
formatNotificationDate,
type NotificationsPageResult,
} from '@/lib/adminNotifications'
import { ADMIN_API_BASE } from '@/lib/api'
interface AdminInboxItem {
id: string
readAt: string | null
createdAt: string
notificationEvent: { title: string; body: string; type: string; locale: string; data?: Record<string, unknown> }
}
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
@@ -49,6 +57,14 @@ export default function AdminNotificationsPage() {
const [filterStatus, setFilterStatus] = useState('')
const [filterCompany, setFilterCompany] = useState('')
const [page, setPage] = useState(1)
const [inbox, setInbox] = useState<{ data: AdminInboxItem[]; unread: number }>({ data: [], unread: 0 })
function loadInbox() {
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Failed to load personal inbox')))
.then((json) => setInbox(json.data))
.catch(() => {})
}
function load(p: number, signal?: AbortSignal) {
setLoading(true)
@@ -73,6 +89,7 @@ export default function AdminNotificationsPage() {
const controller = new AbortController()
setPage(1)
load(1, controller.signal)
loadInbox()
return () => controller.abort()
}, [filterChannel, filterStatus, filterCompany])
@@ -83,6 +100,11 @@ export default function AdminNotificationsPage() {
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
async function markRead(recipientId: string) {
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications/me/${recipientId}/read`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' })
if (response.ok) loadInbox()
}
return (
<div className="shell py-8 space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -100,6 +122,27 @@ export default function AdminNotificationsPage() {
)}
</div>
<section className="panel p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">My inbox</p>
<h2 className="mt-1 text-lg font-semibold text-zinc-100">Assigned operational notices</h2>
</div>
<span className="rounded-full bg-red-500/15 px-3 py-1 text-xs font-semibold text-red-300">{inbox.unread} unread</span>
</div>
{inbox.data.length === 0 ? <p className="mt-4 text-sm text-zinc-500">No assigned notices.</p> : (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{inbox.data.map((item) => (
<button key={item.id} type="button" onClick={() => markRead(item.id)} className={`rounded-xl border p-4 text-left ${item.readAt ? 'border-zinc-800 bg-zinc-950/50' : 'border-orange-500/40 bg-orange-500/5'}`}>
<p className="text-xs uppercase tracking-wide text-zinc-500">{item.notificationEvent.type.replaceAll('_', ' ')} · {item.notificationEvent.locale}</p>
<p className="mt-2 font-semibold text-zinc-100">{item.notificationEvent.title}</p>
<p className="mt-1 text-sm text-zinc-400">{item.notificationEvent.body}</p>
</button>
))}
</div>
)}
</section>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
+30
View File
@@ -154,6 +154,10 @@ html.dark .panel {
color: rgb(28 25 23);
}
.light .text-zinc-50 {
color: rgb(28 25 23);
}
.light .text-zinc-500 {
color: rgb(120 113 108);
}
@@ -171,4 +175,30 @@ html.dark .panel {
.light .hover\:text-zinc-200:hover {
color: rgb(28 25 23);
}
.light .text-amber-100,
.light .text-amber-100\/80,
.light .text-amber-200,
.light .text-amber-300 {
color: rgb(146 64 14);
}
.light .text-emerald-300 {
color: rgb(4 120 87);
}
.light .text-rose-200,
.light .text-rose-300,
.light .text-red-400 {
color: rgb(190 18 60);
}
.light .text-sky-300 {
color: rgb(3 105 161);
}
.light .text-red-200,
.light .text-red-300 {
color: rgb(185 28 28);
}
}
+24
View File
@@ -0,0 +1,24 @@
# Manual subscription payments are intentionally off until every dependency is ready.
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=false
BANK_TRANSFER_ENABLED=false
BANK_TRANSFER_ACCOUNT_NAME=
BANK_TRANSFER_BANK_NAME=
BANK_TRANSFER_ACCOUNT_REFERENCE=
BANK_TRANSFER_DUE_DAYS=7
CHECK_PAYMENT_ENABLED=false
CHECK_PAYMENT_PAYEE=
CHECK_PAYMENT_DELIVERY_ADDRESS=
CHECK_PAYMENT_DUE_DAYS=14
# Evidence is private, quarantined, content-validated and fail-closed scanned.
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=false
FILE_STORAGE_ROOT=/var/lib/rentaldrivego/uploads
PRIVATE_STORAGE_PERSISTENCE_CONFIRMED=false
PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED=false
PAYMENT_EVIDENCE_SCANNER_PATH=/usr/bin/clamscan
PAYMENT_EVIDENCE_SCAN_TIMEOUT_MS=60000
# Collections notifications can be proven before automatic suspension is enabled.
SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED=false
SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED=false
DEFAULT_BILLING_TIMEZONE=Africa/Casablanca
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { assertPaymentEvidenceFile, sanitizeEvidenceFilename } from './paymentEvidence'
function file(buffer: Buffer, originalname: string, mimetype: string): Express.Multer.File {
return { buffer, originalname, mimetype, size: buffer.length } as Express.Multer.File
}
function png(width = 16, height = 16) {
const head = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from([0, 0, 0, 13]),
Buffer.from('IHDR'),
])
const dimensions = Buffer.alloc(8)
dimensions.writeUInt32BE(width, 0)
dimensions.writeUInt32BE(height, 4)
const ihdrRest = Buffer.alloc(9)
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
return Buffer.concat([head, dimensions, ihdrRest, iend])
}
function jpeg(width = 16, height = 16) {
return Buffer.from([
0xff, 0xd8,
0xff, 0xc0, 0x00, 0x0b, 0x08,
(height >> 8) & 0xff, height & 0xff,
(width >> 8) & 0xff, width & 0xff,
0x01, 0x01, 0x11, 0x00,
0xff, 0xd9,
])
}
describe('payment evidence validation', () => {
it('accepts a structurally bounded PDF by content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs with trailing bytes after the EOF marker', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n\u0000\u0000')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs that contain common byte sequences inside document content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n(<html><svg>PK\u0003\u0004)</script>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid evidence files reported with compatible browser MIME aliases', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/octet-stream'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/x-pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid image evidence with trailing bytes and common JPEG extensions', () => {
expect(assertPaymentEvidenceFile(file(Buffer.concat([png(), Buffer.from('\n')]), 'receipt.png', 'image/x-png'))).toEqual({ mime: 'image/png', ext: '.png' })
expect(assertPaymentEvidenceFile(file(Buffer.concat([jpeg(), Buffer.from('\n')]), 'receipt.jfif', 'image/pjpeg'))).toEqual({ mime: 'image/jpeg', ext: '.jpg' })
})
it('rejects spoofed MIME types and active content', () => {
const html = Buffer.from('<!doctype html><script>alert(1)</script>')
expect(() => assertPaymentEvidenceFile(file(html, 'receipt.pdf', 'application/pdf'))).toThrow(/suspicious/i)
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)])
expect(() => assertPaymentEvidenceFile(file(png, 'receipt.pdf', 'application/pdf'))).toThrow(/valid PDF, JPEG, and PNG/i)
})
it('sanitizes filenames without allowing path traversal', () => {
expect(sanitizeEvidenceFilename('../../bank<receipt>.pdf')).toBe('bank_receipt_.pdf')
})
})
+128
View File
@@ -0,0 +1,128 @@
import path from 'path'
import multer from 'multer'
import { ValidationError } from '../errors'
export const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
export const PAYMENT_EVIDENCE_MAX_FILES = 3
export const PAYMENT_EVIDENCE_MAX_TOTAL_SIZE = 20 * 1024 * 1024
export const paymentEvidenceUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: PAYMENT_EVIDENCE_MAX_FILE_SIZE, files: 1, fields: 5, parts: 8 },
})
export type DetectedPaymentEvidence = {
mime: 'application/pdf' | 'image/jpeg' | 'image/png'
ext: '.pdf' | '.jpg' | '.png'
}
const allowedExtensions: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['.pdf'],
'image/jpeg': ['.jpg', '.jpeg', '.jpe', '.jfif'],
'image/png': ['.png'],
}
const allowedDeclaredMimes: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['application/pdf', 'application/x-pdf', 'application/octet-stream'],
'image/jpeg': ['image/jpeg', 'image/pjpeg', 'application/octet-stream'],
'image/png': ['image/png', 'image/x-png', 'application/octet-stream'],
}
function hasSpoofedLeadingContainerSignature(buffer: Buffer) {
const prefix = buffer.subarray(0, 512)
const text = prefix.toString('latin1').trimStart().toLowerCase()
return text.startsWith('<script')
|| text.startsWith('<!doctype html')
|| text.startsWith('<html')
|| text.startsWith('<svg')
|| prefix.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x4d, 0x5a, 0x90, 0x00]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
}
function detectType(buffer: Buffer): DetectedPaymentEvidence | null {
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
const iendIndex = buffer.lastIndexOf(iend)
if (buffer.length >= 33 && iendIndex >= 0 && buffer.length - iendIndex <= 2048) {
return { mime: 'image/png', ext: '.png' }
}
}
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
const eoiIndex = buffer.lastIndexOf(Buffer.from([0xff, 0xd9]))
if (eoiIndex >= 0 && buffer.length - eoiIndex <= 2048) {
return { mime: 'image/jpeg', ext: '.jpg' }
}
}
if (buffer.length >= 12 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
const content = buffer.toString('latin1')
// Real-world PDFs may include a newline or small binary marker after EOF.
// Require an EOF marker near the end instead of at the exact final byte.
const eofIndex = content.lastIndexOf('%%EOF')
if (eofIndex >= 0 && content.length - eofIndex <= 2048 && !/\/Encrypt\b/.test(content)) return { mime: 'application/pdf', ext: '.pdf' }
}
return null
}
function assertSafeJpegDimensions(buffer: Buffer) {
let offset = 2
while (offset + 8 < buffer.length) {
if (buffer[offset] !== 0xff) { offset += 1; continue }
const marker = buffer[offset + 1]
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2
continue
}
const segmentLength = buffer.readUInt16BE(offset + 2)
if (segmentLength < 2 || offset + 2 + segmentLength > buffer.length) break
const isStartOfFrame = marker !== undefined
&& ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf))
if (isStartOfFrame) {
const height = buffer.readUInt16BE(offset + 5)
const width = buffer.readUInt16BE(offset + 7)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
return
}
offset += 2 + segmentLength
}
throw new ValidationError('The JPEG file is malformed')
}
function assertSafePngDimensions(buffer: Buffer) {
if (buffer.length < 24) throw new ValidationError('The PNG file is malformed')
const width = buffer.readUInt32BE(16)
const height = buffer.readUInt32BE(20)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
}
export function assertPaymentEvidenceFile(file: Express.Multer.File | undefined): DetectedPaymentEvidence {
if (!file) throw new ValidationError('A payment evidence file is required')
if (file.size <= 0 || file.size > PAYMENT_EVIDENCE_MAX_FILE_SIZE) {
throw new ValidationError('Payment evidence files must be between 1 byte and 10 MB')
}
if (hasSpoofedLeadingContainerSignature(file.buffer)) throw new ValidationError('Unsupported or suspicious payment evidence file')
const detected = detectType(file.buffer)
if (!detected) throw new ValidationError('Only valid PDF, JPEG, and PNG evidence files are accepted')
const declaredMime = file.mimetype.toLowerCase()
if (!allowedDeclaredMimes[detected.mime].includes(declaredMime)) {
throw new ValidationError('The declared file type does not match its content')
}
const extension = path.extname(file.originalname).toLowerCase()
if (!allowedExtensions[detected.mime].includes(extension)) {
throw new ValidationError('The filename extension does not match the file content')
}
if (detected.mime === 'image/png') assertSafePngDimensions(file.buffer)
if (detected.mime === 'image/jpeg') assertSafeJpegDimensions(file.buffer)
return detected
}
export function sanitizeEvidenceFilename(value: string) {
const base = path.basename(value).normalize('NFKC').replace(/[\u0000-\u001f\u007f]/g, '').replace(/[^\p{L}\p{N}._ -]/gu, '_')
return (base || 'payment-evidence').slice(0, 180)
}
+15 -19
View File
@@ -8,14 +8,12 @@ import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies'
import { sendNotification } from './services/notificationService'
import { processNotificationOutbox, sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
runPaymentPendingTimeoutJob,
runPastDueTimeoutJob,
runSuspensionTimeoutJob,
runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service'
import { runCollectionsWorker } from './modules/subscriptions/subscription.collections.service'
const app = createApp()
const server = http.createServer(app)
@@ -108,26 +106,24 @@ cron.schedule('0 * * * *', async () => {
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
// Daily: explicit period-end cancellations. Subscription collections use the
// timezone-aware 30-day grace worker below, so the old fixed 7-day chain is
// intentionally not scheduled.
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`)
})
cron.schedule('*/15 * * * *', async () => {
const n = await runCollectionsWorker()
if (n > 0) console.log(`[subscription] collections: ${n} cases processed`)
})
cron.schedule('* * * * *', async () => {
const n = await processNotificationOutbox()
if (n > 0) console.log(`[notifications] outbox: ${n} events completed`)
})
// 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)
+63
View File
@@ -50,6 +50,14 @@ export function assertStorageConfiguration(): string {
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
)
}
if (process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED === 'true') {
if (process.env.PRIVATE_STORAGE_PERSISTENCE_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed persistent private storage in production.')
}
if (process.env.PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed encryption at rest in production.')
}
}
}
return storageRoot
@@ -141,3 +149,58 @@ export async function deleteImage(imageUrl: string): Promise<void> {
fs.unlinkSync(filePath)
}
}
function normalizePrivateStorageKey(storageKey: string) {
const normalized = storageKey.replace(/\\/g, '/').replace(/^\/+/, '')
if (!normalized || normalized.includes('..') || path.isAbsolute(normalized)) {
throw new Error('Invalid private storage key')
}
return normalized
}
export function resolvePrivateDocumentPath(storageKey: string): string {
const root = ensureStorageRoot('private')
const filePath = path.join(root, normalizePrivateStorageKey(storageKey))
if (!isWithinPath(filePath, root)) throw new Error('Private document path escapes storage root')
return filePath
}
export async function storePaymentEvidenceInQuarantine(
buffer: Buffer,
companyId: string,
submissionId: string,
extension: string,
) {
const safeExtension = ['.pdf', '.jpg', '.png'].includes(extension) ? extension : ''
if (!safeExtension) throw new Error('Unsupported private document extension')
const storageKey = path.posix.join(
'payment-evidence',
'quarantine',
companyId,
submissionId,
`${crypto.randomBytes(24).toString('hex')}${safeExtension}`,
)
const filePath = resolvePrivateDocumentPath(storageKey)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, buffer, { mode: 0o600, flag: 'wx' })
return { storageKey, filePath }
}
export async function promotePaymentEvidence(storageKey: string) {
const sourcePath = resolvePrivateDocumentPath(storageKey)
const cleanKey = normalizePrivateStorageKey(storageKey).replace('/quarantine/', '/clean/')
if (cleanKey === storageKey) throw new Error('Only quarantined evidence can be promoted')
const targetPath = resolvePrivateDocumentPath(cleanKey)
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
fs.renameSync(sourcePath, targetPath)
return cleanKey
}
export function readPrivateDocument(storageKey: string): Buffer {
return fs.readFileSync(resolvePrivateDocumentPath(storageKey))
}
export async function deletePrivateDocument(storageKey: string): Promise<void> {
const filePath = resolvePrivateDocumentPath(storageKey)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
@@ -13,7 +13,7 @@ vi.mock('../lib/prisma', () => ({
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from './requireAdminAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
@@ -163,3 +163,35 @@ describe('requireAdminRole middleware', () => {
expect(res.status).not.toHaveBeenCalled()
})
})
describe('requireFreshAdmin2FA middleware', () => {
it('allows a 2FA-verified admin session until the session ends', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 24 * 60 * 60 * 1000,
} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose session has no 2FA verification proof', () => {
const req = { admin: { id: 'admin_1', totpEnabled: true } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'fresh_2fa_required',
message: 'Admin 2FA verification is required for this session',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
})
+2 -5
View File
@@ -20,8 +20,6 @@ const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
'/auth/2fa/verify',
])
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
function is2faEnrollmentExempt(req: Request) {
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
}
@@ -84,9 +82,8 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
}
const last2faAt = req.adminAuthLast2faAt
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
if (!req.adminAuthLast2faAt) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
}
next()
@@ -3,8 +3,11 @@ import {
billingAccountUpdateSchema,
billingCreditNoteSchema,
billingRefundSchema,
collectionsOverrideSchema,
confirmManualPaymentSchema,
createBillingInvoiceSchema,
payBillingInvoiceSchema,
platformBillingSettingsSchema,
} from './admin.schemas'
describe('admin billing schemas', () => {
@@ -44,10 +47,38 @@ describe('admin billing schemas', () => {
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
})
it('bounds platform billing tax settings', () => {
expect(platformBillingSettingsSchema.parse({ taxRate: 20 })).toEqual({ taxRate: 20 })
expect(platformBillingSettingsSchema.safeParse({ taxRate: -1 }).success).toBe(false)
expect(platformBillingSettingsSchema.safeParse({ taxRate: 101 }).success).toBe(false)
})
it('requires positive money movements for payments, credit notes, and refunds', () => {
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
})
it('requires cleared-funds attestation and an idempotency key for manual subscription settlement', () => {
const confirmation = {
submissionId: 'submission_1',
method: 'BANK_TRANSFER',
externalReference: 'BANK TXN 123',
amount: 19900,
receivedAt: '2026-08-09T12:00:00.000Z',
idempotencyKey: crypto.randomUUID(),
fundsVerified: true,
}
expect(confirmManualPaymentSchema.safeParse(confirmation).success).toBe(true)
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, fundsVerified: false }).success).toBe(false)
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, amount: 0 }).success).toBe(false)
})
it('keeps suspension and notification override controls separate', () => {
const base = { reason: 'Verified finance dispute', expiresAt: '2026-09-01T12:00:00.000Z' }
expect(collectionsOverrideSchema.parse({ ...base, type: 'PAYMENT_DISPUTE', pauseSuspension: true, pauseNotifications: false })).toMatchObject({ pauseSuspension: true, pauseNotifications: false })
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION' }).success).toBe(false)
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION', revisedSuspensionAt: '2026-08-25T12:00:00.000Z' }).success).toBe(true)
})
})
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listBillingAccounts } from './admin.billing.service'
import { prisma } from '../../lib/prisma'
vi.mock('../../lib/prisma', () => ({
prisma: {
subscriptionInvoice: { findMany: vi.fn() },
company: { findMany: vi.fn(), findUniqueOrThrow: vi.fn() },
billingAccount: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
billingInvoice: { groupBy: vi.fn(), findFirst: vi.fn(), create: vi.fn() },
billingCreditBalance: { create: vi.fn() },
billingEvent: { create: vi.fn() },
},
}))
vi.mock('../../services/invoicePdfService', () => ({
generateInvoicePdf: vi.fn(),
}))
describe('admin billing service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.subscriptionInvoice.findMany).mockResolvedValue([])
vi.mocked(prisma.company.findMany).mockResolvedValue([{ id: 'company_1' }] as never)
vi.mocked(prisma.billingAccount.count).mockResolvedValue(1 as never)
vi.mocked(prisma.billingInvoice.groupBy)
.mockResolvedValueOnce([
{ billingAccountId: 'billing_empty', _count: { _all: 0 }, _sum: { amountDue: 0 } },
{ billingAccountId: 'billing_due', _count: { _all: 1 }, _sum: { amountDue: 14900 } },
] as never)
.mockResolvedValueOnce([
{ status: 'OPEN', _count: { _all: 1 }, _sum: { amountDue: 14900, totalAmount: 14900 } },
] as never)
})
it('demotes duplicate primary billing accounts and lists only the canonical company row', async () => {
vi.mocked(prisma.billingAccount.findMany)
.mockResolvedValueOnce([
{ id: 'billing_empty', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-10T12:00:00.000Z'), creditBalances: [] },
{ id: 'billing_due', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-09T12:00:00.000Z'), creditBalances: [] },
] as never)
.mockResolvedValueOnce([
{
id: 'billing_due',
companyId: 'company_1',
isPrimary: true,
legalName: 'Atlas car',
billingEmail: 'moulay.elabidi@gmail.com',
createdAt: new Date('2026-08-09T12:00:00.000Z'),
company: { id: 'company_1', name: 'Atlas car', email: 'moulay.elabidi@gmail.com', slug: 'atlas-car', status: 'PENDING', subscription: { status: 'PAYMENT_PENDING' } },
creditBalances: [],
invoices: [{ id: 'invoice_1', status: 'OPEN', amountDue: 14900, amountPaid: 0, currency: 'MAD' }],
},
] as never)
const result = await listBillingAccounts({ page: 1, pageSize: 100 })
expect(prisma.billingAccount.updateMany).toHaveBeenCalledWith({
where: { companyId: 'company_1', id: { not: 'billing_due' }, isPrimary: true },
data: { isPrimary: false },
})
expect(prisma.billingAccount.findMany).toHaveBeenLastCalledWith(expect.objectContaining({
where: { isPrimary: true },
}))
expect(result.data).toHaveLength(1)
expect(result.data[0].id).toBe('billing_due')
expect(result.data[0].openBalance).toBe(14900)
})
})
@@ -1,6 +1,7 @@
import { prisma } from '../../lib/prisma'
import { NotFoundError, ValidationError } from '../../http/errors'
import { generateInvoicePdf } from '../../services/invoicePdfService'
import { calculateTaxAmount, getPlatformBillingSettings, updatePlatformBillingSettings } from '../subscriptions/billingTax'
const BLOCKING_INVOICE_TYPES = new Set([
'SUBSCRIPTION_INITIAL',
@@ -10,7 +11,6 @@ const BLOCKING_INVOICE_TYPES = new Set([
])
const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'])
function toSequenceNumber(value: unknown) {
if (typeof value === 'bigint') return Number(value)
if (typeof value === 'number') return value
@@ -85,6 +85,10 @@ function calculateLineAmounts(items: Array<{ type: string; amount: number }>) {
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }
}
function invoiceTaxRate(invoice: { taxRecords?: Array<{ taxRate?: number | null; taxExempt?: boolean }> }) {
return invoice.taxRecords?.find((record) => !record.taxExempt && typeof record.taxRate === 'number')?.taxRate ?? null
}
function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances?: any[] }>(account: T) {
const invoices = account.invoices ?? []
const creditBalances = account.creditBalances ?? []
@@ -104,6 +108,22 @@ function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances
}
}
function getOpenBalance(invoices: any[] = []) {
return invoices
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
}
function chooseCanonicalBillingAccount<T extends { id: string; createdAt?: Date; invoices?: any[] }>(accounts: T[]) {
return [...accounts].sort((a: any, b: any) => {
const openBalanceDelta = getOpenBalance(b.invoices) - getOpenBalance(a.invoices)
if (openBalanceDelta) return openBalanceDelta
const invoiceCountDelta = (b.invoices?.length ?? 0) - (a.invoices?.length ?? 0)
if (invoiceCountDelta) return invoiceCountDelta
return new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()
})[0]
}
async function createBillingEvent(tx: any, data: {
billingAccountId?: string | null
invoiceId?: string | null
@@ -142,11 +162,41 @@ async function createAuditLog(data: {
}
async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) {
const existing = await tx.billingAccount.findFirst({
const existingAccounts = await tx.billingAccount.findMany({
where: { companyId, isPrimary: true },
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true },
orderBy: { createdAt: 'desc' },
})
if (existing) return existing
if (existingAccounts.length) {
if (existingAccounts.length > 1) {
const accountIds = existingAccounts.map((account: any) => account.id)
const invoiceGroups = await tx.billingInvoice.groupBy({
by: ['billingAccountId'],
where: { billingAccountId: { in: accountIds } },
_count: { _all: true },
_sum: { amountDue: true },
})
const invoiceGroupByAccount = new Map<string, any>(invoiceGroups.map((group: any) => [group.billingAccountId, group]))
const canonical = chooseCanonicalBillingAccount(existingAccounts.map((account: any) => {
const group = invoiceGroupByAccount.get(account.id)
const invoiceCount = group?._count?._all ?? 0
const amountDue = group?._sum?.amountDue ?? 0
return {
...account,
invoices: Array.from({ length: invoiceCount }, () => ({
status: 'OPEN',
amountDue: Math.floor(amountDue / Math.max(invoiceCount, 1)),
})),
}
}))
await tx.billingAccount.updateMany({
where: { companyId, id: { not: canonical.id }, isPrimary: true },
data: { isPrimary: false },
})
return existingAccounts.find((account: any) => account.id === canonical.id) ?? canonical
}
return existingAccounts[0]
}
const company = await tx.company.findUniqueOrThrow({
where: { id: companyId },
@@ -346,7 +396,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
}
function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) {
const where: any = {}
const where: any = { isPrimary: true }
if (query.q) {
where.OR = [
{ legalName: { contains: query.q, mode: 'insensitive' } },
@@ -510,6 +560,13 @@ export async function getBillingAccountDetail(companyId: string) {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentIntents: { orderBy: { createdAt: 'desc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
manualPaymentSubmissions: {
orderBy: { createdAt: 'desc' },
include: {
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
},
},
taxRecords: true,
creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { orderBy: { createdAt: 'desc' } },
@@ -575,6 +632,25 @@ export async function updateBillingAccount(
return updated
}
export function getBillingPlatformSettings() {
return getPlatformBillingSettings()
}
export async function updateBillingPlatformSettings(data: { taxRate: number }, adminId: string, ip?: string) {
const before = await getPlatformBillingSettings()
const updated = await updatePlatformBillingSettings({ taxRate: data.taxRate, updatedBy: adminId })
await createAuditLog({
adminUserId: adminId,
action: 'UPDATE_PLATFORM_BILLING_SETTINGS',
resource: 'PlatformBillingSettings',
resourceId: updated.id,
before,
after: updated,
ipAddress: ip,
})
return updated
}
export async function setDunningPaused(
billingAccountId: string,
paused: boolean,
@@ -704,6 +780,7 @@ export async function createDraftInvoice(
}
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
const platformBillingSettings = await getPlatformBillingSettings()
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
@@ -771,9 +848,8 @@ export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: s
})
}
const taxRate = account.taxExempt ? 0 : Number(account.company.contractSettings?.taxRate ?? 0)
const taxBase = Math.max(preTaxBase - autoCreditToApply, 0)
const taxAmount = taxRate > 0 ? Math.round(taxBase * (taxRate / 100)) : 0
const { taxRate, taxAmount } = calculateTaxAmount(taxBase, account.taxExempt, platformBillingSettings.taxRate)
if (taxAmount > 0) {
await tx.billingInvoiceLineItem.create({
@@ -911,6 +987,9 @@ export async function payInvoice(
if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Invoice is not payable in its current state')
}
if (current.subscriptionId) {
throw new ValidationError('Subscription invoices require provider confirmation or the dedicated cleared-manual-payment workflow')
}
const amount = data.amount ?? current.amountDue
if (amount <= 0 || amount > current.amountDue) {
@@ -1389,6 +1468,7 @@ export async function getInvoicePdf(invoiceId: string) {
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description,
amount: item.amount,
currency: item.currency,
@@ -1401,6 +1481,7 @@ export async function getInvoicePdf(invoiceId: string) {
subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
@@ -0,0 +1,550 @@
import crypto from 'crypto'
import { prisma } from '../../lib/prisma'
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
import { readPrivateDocument } from '../../lib/storage'
import { sendNotification } from '../../services/notificationService'
import { coerceNotificationLocale, type NotificationLocale } from '../../services/notificationLocalizationService'
import { addBillingPeriod, normalizeExternalReference } from '../subscriptions/subscription.manual.service'
const PAYABLE_STATUSES = ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE']
const customerPaymentCopy: Record<NotificationLocale, {
confirmedTitle: string
confirmed: (details: PaymentConfirmationDetails) => string
rejectedTitle: string
rejected: (invoice: string, reason: string) => string
}> = {
en: {
confirmedTitle: 'Subscription payment confirmed',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'Payment evidence needs attention',
rejected: (invoice, reason) => `The evidence submitted for invoice ${invoice} was rejected: ${reason}. Upload corrected evidence from Subscription.`,
},
fr: {
confirmedTitle: 'Paiement de labonnement confirmé',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'Justificatif de paiement à corriger',
rejected: (invoice, reason) => `Le justificatif de la facture ${invoice} a été refusé : ${reason}. Téléversez un justificatif corrigé depuis Abonnement.`,
},
ar: {
confirmedTitle: 'تم تأكيد دفع الاشتراك',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'مستند الدفع يحتاج إلى تصحيح',
rejected: (invoice, reason) => `تم رفض مستند الفاتورة ${invoice}: ${reason}. حمّل مستنداً مصححاً من صفحة الاشتراك.`,
},
}
type PaymentConfirmationDetails = {
invoice: string
amountPaid: number
currency: string
paymentType?: string | null
paymentReference?: string | null
receivedAt?: Date | string | null
confirmedAt?: Date | string | null
plan?: string | null
billingPeriod?: string | null
periodStart?: Date | string | null
periodEnd?: Date | string | null
}
function fmtMoney(amount: number, currency: string) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format((amount ?? 0) / 100)
}
function fmtDate(value?: Date | string | null) {
if (!value) return 'Not set'
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
function paymentMethodLabel(method?: string | null) {
if (method === 'BANK_TRANSFER') return 'Bank transfer'
if (method === 'CHECK') return 'Check'
if (method === 'STRIPE') return 'Online card payment'
return method ?? 'Manual payment'
}
function buildConfirmedPaymentBody(details: PaymentConfirmationDetails) {
return [
'Dear customer,',
'',
'We confirm that your subscription payment has been verified and recorded. Your subscription is now active for the period shown below.',
'',
`Invoice: ${details.invoice}`,
`Amount paid: ${fmtMoney(details.amountPaid, details.currency)}`,
`Payment type: ${paymentMethodLabel(details.paymentType)}`,
details.paymentReference ? `Payment reference: ${details.paymentReference}` : null,
`Funds received/cleared on: ${fmtDate(details.receivedAt)}`,
`Payment confirmed on: ${fmtDate(details.confirmedAt)}`,
`Subscription plan: ${details.plan ?? 'Current plan'}`,
`Billing period: ${details.billingPeriod ?? 'Current billing period'}`,
`Subscription start: ${fmtDate(details.periodStart)}`,
`Subscription end: ${fmtDate(details.periodEnd)}`,
'',
'A PDF copy of the invoice is attached for your records.',
'',
'Regards,',
'RentalDriveGo Finance',
].filter((line): line is string => line !== null).join('\n')
}
async function notifyPaymentResult(data: {
billingAccountId: string
companyId: string
invoiceId: string
invoiceNumber?: string | null
kind: 'confirmed' | 'rejected'
sourceId: string
reason?: string
}) {
const account = await prisma.billingAccount.findUnique({
where: { id: data.billingAccountId },
include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } },
})
if (!account) return
const invoiceRecord = await prisma.billingInvoice.findUnique({
where: { id: data.invoiceId },
include: {
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' }, take: 1 },
},
})
const paymentAttempt = invoiceRecord?.paymentAttempts?.[0] ?? null
const periodStart = invoiceRecord?.subscription?.currentPeriodStart ?? invoiceRecord?.lineItems?.[0]?.periodStart ?? null
const periodEnd = invoiceRecord?.subscription?.currentPeriodEnd ?? invoiceRecord?.lineItems?.[0]?.periodEnd ?? null
for (const contact of account.billingContacts) {
const enabled = account.enabledCommunicationLocales as string[]
const locale = coerceNotificationLocale(
contact.locale && enabled.includes(contact.locale)
? contact.locale
: contact.employee?.preferredLanguage && enabled.includes(contact.employee.preferredLanguage)
? contact.employee.preferredLanguage
: account.defaultCommunicationLocale,
)
const copy = customerPaymentCopy[locale]
const invoice = data.invoiceNumber ?? data.invoiceId
const details: PaymentConfirmationDetails = {
invoice,
amountPaid: invoiceRecord?.amountPaid ?? paymentAttempt?.amount ?? 0,
currency: invoiceRecord?.currency ?? paymentAttempt?.currency ?? 'MAD',
paymentType: paymentAttempt?.manualMethod ?? invoiceRecord?.collectionMethod ?? invoiceRecord?.paymentProvider ?? 'MANUAL',
paymentReference: paymentAttempt?.externalReference ?? paymentAttempt?.providerPaymentId ?? null,
receivedAt: paymentAttempt?.receivedAt ?? invoiceRecord?.paidAt ?? null,
confirmedAt: paymentAttempt?.confirmedAt ?? invoiceRecord?.paidAt ?? null,
plan: invoiceRecord?.requestedPlan ?? invoiceRecord?.subscription?.plan ?? null,
billingPeriod: invoiceRecord?.requestedBillingPeriod ?? invoiceRecord?.subscription?.billingPeriod ?? null,
periodStart,
periodEnd,
}
await sendNotification({
type: data.kind === 'confirmed' ? 'SUBSCRIPTION_PAYMENT_CONFIRMED' : 'MANUAL_PAYMENT_EVIDENCE_REJECTED',
title: data.kind === 'confirmed' ? copy.confirmedTitle : copy.rejectedTitle,
body: data.kind === 'confirmed' ? copy.confirmed(details) : copy.rejected(invoice, data.reason ?? ''),
companyId: data.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: data.kind === 'confirmed' ? 'subscription.payment_confirmed.v1' : 'subscription.payment_evidence_rejected.v1',
idempotencyKey: `manual-payment:${data.kind}:${data.sourceId}:${contact.id}`,
sourceType: 'manual_payment',
sourceId: data.sourceId,
data: {
invoiceId: data.invoiceId,
amountPaid: details.amountPaid,
currency: details.currency,
paymentType: details.paymentType,
paymentReference: details.paymentReference,
subscriptionStart: details.periodStart,
subscriptionEnd: details.periodEnd,
timezone: account.timezone,
templateVersion: 1,
localizationFallback: false,
...(data.kind === 'confirmed' ? { emailAttachments: [{ type: 'invoice_pdf', invoiceId: data.invoiceId }] } : {}),
},
policy: { mandatory: true },
})
}
}
function requestHash(value: Record<string, unknown>) {
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
}
export async function listManualPaymentSubmissions(query: { status: string; page: number; pageSize: number }) {
const where = { status: query.status as any }
const [data, total] = await Promise.all([
prisma.manualPaymentSubmission.findMany({
where,
include: {
invoice: { include: { company: true, subscription: true } },
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
},
orderBy: { submittedAt: 'asc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma.manualPaymentSubmission.count({ where }),
])
return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.max(1, Math.ceil(total / query.pageSize)) }
}
export async function getManualPaymentSubmission(submissionId: string, adminId?: string) {
let submission = await prisma.manualPaymentSubmission.findUnique({
where: { id: submissionId },
include: {
invoice: {
include: {
company: { select: { id: true, name: true, email: true } },
subscription: true,
billingAccount: true,
lineItems: true,
},
},
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
reviewedByAdmin: { select: { id: true, firstName: true, lastName: true } },
},
})
if (!submission) throw new NotFoundError('Payment submission not found')
if (adminId && submission.status === 'SUBMITTED') {
await prisma.manualPaymentSubmission.updateMany({
where: { id: submission.id, status: 'SUBMITTED' },
data: { status: 'UNDER_REVIEW', reviewedByAdminId: adminId, reviewedAt: new Date() },
})
return getManualPaymentSubmission(submissionId)
}
return submission
}
export async function getAdminPaymentDocument(submissionId: string, documentId: string, adminId: string, ip?: string) {
const document = await prisma.manualPaymentDocument.findFirst({
where: { id: documentId, submissionId, deletedAt: null, scanStatus: 'CLEAN' },
include: { submission: { include: { invoice: true } } },
})
if (!document || document.submission.invoiceId !== document.invoiceId) throw new NotFoundError('Clean payment evidence document not found')
await prisma.auditLog.create({
data: {
adminUserId: adminId,
action: 'VIEW_MANUAL_PAYMENT_EVIDENCE',
resource: 'ManualPaymentDocument',
resourceId: document.id,
companyId: document.companyId,
ipAddress: ip,
after: { submissionId, invoiceId: document.invoiceId, sha256: document.sha256 },
},
})
return { document, bytes: readPrivateDocument(document.storageKey) }
}
export async function rejectManualPaymentSubmission(submissionId: string, reason: string, adminId: string, ip?: string) {
const updated = await prisma.$transaction(async (tx: any) => {
const current = await tx.manualPaymentSubmission.findUnique({
where: { id: submissionId },
include: { invoice: true },
})
if (!current) throw new NotFoundError('Payment submission not found')
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(current.status)) throw new ConflictError('Submission cannot be rejected in its current state')
const reviewedAt = new Date()
const updated = await tx.manualPaymentSubmission.update({
where: { id: current.id },
data: { status: 'REJECTED', rejectionReason: reason, reviewedByAdminId: adminId, reviewedAt },
include: { documents: { where: { deletedAt: null } } },
})
await tx.billingEvent.create({
data: {
billingAccountId: current.billingAccountId,
invoiceId: current.invoiceId,
subscriptionId: current.invoice.subscriptionId,
companyId: current.companyId,
eventType: 'payment_evidence.rejected',
source: 'admin',
payload: { submissionId, reason, adminId },
occurredAt: reviewedAt,
},
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'REJECT_MANUAL_PAYMENT_EVIDENCE',
resource: 'ManualPaymentSubmission',
resourceId: submissionId,
companyId: current.companyId,
before: { status: current.status },
after: { status: 'REJECTED', reason },
ipAddress: ip,
},
})
return { ...updated, invoice: current.invoice }
})
await notifyPaymentResult({
billingAccountId: updated.billingAccountId,
companyId: updated.companyId,
invoiceId: updated.invoiceId,
invoiceNumber: updated.invoice.invoiceNumber,
kind: 'rejected',
sourceId: updated.id,
reason,
})
return updated
}
export async function confirmManualPayment(invoiceId: string, data: {
submissionId: string
method: 'BANK_TRANSFER' | 'CHECK'
externalReference: string
amount: number
receivedAt: string
note?: string
correctionReason?: string
idempotencyKey: string
fundsVerified: true
}, adminId: string, ip?: string) {
const normalizedReference = normalizeExternalReference(data.externalReference)
const normalizedPayload = {
invoiceId,
submissionId: data.submissionId,
method: data.method,
normalizedReference,
amount: data.amount,
receivedAt: new Date(data.receivedAt).toISOString(),
note: data.note ?? null,
correctionReason: data.correctionReason ?? null,
fundsVerified: true,
}
const hash = requestHash(normalizedPayload)
const receivedAt = new Date(data.receivedAt)
if (receivedAt.getTime() > Date.now() + 5 * 60 * 1000) throw new ValidationError('Settlement time cannot be in the future')
let collectionsCaseId: string | null = null
try {
const result = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
billingAccount: true,
subscription: true,
legacySubscriptionInvoice: true,
collectionsCase: true,
manualPaymentSubmissions: {
where: { id: data.submissionId },
include: { documents: { where: { deletedAt: null } } },
},
},
})
if (!current) throw new NotFoundError('Invoice not found')
const duplicate = await tx.billingPaymentAttempt.findFirst({
where: { billingAccountId: current.billingAccountId, idempotencyKey: data.idempotencyKey },
include: { invoice: true },
})
if (duplicate) {
const metadata = duplicate.metadata as any
if (duplicate.invoiceId !== invoiceId || metadata?.confirmationRequestHash !== hash) {
throw new ConflictError('Idempotency key was already used with a different payment confirmation')
}
return { invoice: duplicate.invoice, paymentAttempt: duplicate, duplicate: true }
}
if (!PAYABLE_STATUSES.includes(current.status)) throw new ConflictError('Invoice is not payable in its current state')
if (current.collectionMethod !== data.method) throw new ValidationError('Payment method must match the invoice collection method')
if (current.currency !== 'MAD') throw new ValidationError('Manual subscription confirmation requires MAD currency')
if (data.amount !== current.amountDue || data.amount !== current.totalAmount - current.amountPaid) {
throw new ConflictError('The full current invoice balance must be confirmed')
}
const submission = current.manualPaymentSubmissions[0]
if (!submission || submission.invoiceId !== current.id || submission.companyId !== current.companyId) {
throw new ValidationError('Submission does not belong to this invoice')
}
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(submission.status)) throw new ConflictError('Evidence is not awaiting review')
if (submission.method !== data.method) throw new ValidationError('Submission method does not match the confirmation method')
if (!submission.documents.length || submission.documents.some((document: any) => document.scanStatus !== 'CLEAN')) {
throw new ValidationError('Every attached evidence document must be clean')
}
if (submission.normalizedSubmittedReference !== normalizedReference && !data.correctionReason) {
throw new ValidationError('A correction reason is required when the confirmed reference differs from the submitted reference')
}
const confirmedAt = new Date()
const intent = await tx.billingPaymentIntent.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.currency,
metadata: { source: 'admin_manual_subscription_confirmation', method: data.method },
},
})
const attempt = await tx.billingPaymentAttempt.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
paymentIntentId: intent.id,
channel: 'OFFLINE',
manualMethod: data.method,
externalReference: data.externalReference,
normalizedExternalReference: normalizedReference,
receivedAt,
confirmedAt,
confirmedByAdminId: adminId,
idempotencyKey: data.idempotencyKey,
note: data.note ?? null,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.currency,
attemptedAt: confirmedAt,
metadata: {
source: 'admin_manual_subscription_confirmation',
submissionId: submission.id,
confirmationRequestHash: hash,
correctionReason: data.correctionReason ?? null,
fundsVerified: true,
},
},
})
const paid = await tx.billingInvoice.updateMany({
where: { id: current.id, status: { in: PAYABLE_STATUSES }, amountDue: data.amount },
data: { status: 'PAID', amountPaid: { increment: data.amount }, amountDue: 0, paidAt: confirmedAt },
})
if (paid.count !== 1) throw new ConflictError('Invoice changed while payment was being confirmed')
await tx.manualPaymentSubmission.update({
where: { id: submission.id },
data: {
status: 'APPROVED',
reviewedByAdminId: adminId,
reviewedAt: confirmedAt,
paymentAttemptId: attempt.id,
},
})
if (current.legacySubscriptionInvoice) {
await tx.subscriptionInvoice.update({
where: { id: current.legacySubscriptionInvoice.id },
data: { status: 'PAID', paidAt: confirmedAt, failedAt: null },
})
}
if (!current.subscription) throw new ValidationError('Subscription invoice is missing its subscription')
const period = (current.requestedBillingPeriod ?? current.subscription.billingPeriod) as 'MONTHLY' | 'ANNUAL'
const isRenewal = current.invoiceType === 'SUBSCRIPTION_RENEWAL'
const periodStart = isRenewal
? (current.collectionsCase?.originalExpirationAt ?? current.subscription.currentPeriodEnd ?? confirmedAt)
: confirmedAt
await tx.subscription.update({
where: { id: current.subscription.id },
data: {
plan: current.requestedPlan ?? current.subscription.plan,
billingPeriod: period,
currency: current.currency,
status: 'ACTIVE',
currentPeriodStart: periodStart,
currentPeriodEnd: addBillingPeriod(periodStart, period),
paymentPendingSince: null,
paymentDueAt: null,
pastDueSince: null,
suspendedAt: null,
retryCount: 0,
},
})
if (current.collectionsCase) {
collectionsCaseId = current.collectionsCase.id
await tx.collectionsCase.update({
where: { id: current.collectionsCase.id },
data: {
status: 'RESOLVED',
resolvedAt: confirmedAt,
nextActionAt: null,
resolutionPaymentAttemptId: attempt.id,
},
})
await tx.collectionsCallTask.updateMany({
where: { collectionsCaseId: current.collectionsCase.id, status: 'OPEN' },
data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' },
})
await tx.collectionsEvent.create({
data: {
collectionsCaseId: current.collectionsCase.id,
companyId: current.companyId,
eventType: 'collections.resolved',
idempotencyKey: `payment:${attempt.id}`,
actorType: 'admin',
actorId: adminId,
payload: { paymentAttemptId: attempt.id },
},
})
}
await tx.billingEvent.create({
data: {
billingAccountId: current.billingAccountId,
invoiceId: current.id,
subscriptionId: current.subscription.id,
companyId: current.companyId,
eventType: 'invoice.paid',
source: 'admin',
payload: { paymentAttemptId: attempt.id, method: data.method, submissionId: submission.id },
occurredAt: confirmedAt,
},
})
await tx.subscriptionEvent.create({
data: {
subscriptionId: current.subscription.id,
companyId: current.companyId,
eventType: 'subscription.activated',
source: 'admin',
payload: { invoiceId: current.id, paymentAttemptId: attempt.id },
occurredAt: confirmedAt,
},
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'CONFIRM_MANUAL_SUBSCRIPTION_PAYMENT',
resource: 'BillingInvoice',
resourceId: current.id,
companyId: current.companyId,
before: { status: current.status, amountDue: current.amountDue },
after: { status: 'PAID', amountDue: 0, paymentAttemptId: attempt.id, method: data.method },
note: data.note,
ipAddress: ip,
},
})
const invoice = await tx.billingInvoice.findUniqueOrThrow({
where: { id: current.id },
include: { paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, manualPaymentSubmissions: { include: { documents: true } } },
})
return { invoice, paymentAttempt: attempt, duplicate: false }
}, { isolationLevel: 'Serializable' as any })
if (collectionsCaseId) {
await prisma.notificationOutbox.updateMany({
where: {
status: 'PENDING',
notificationEvent: { sourceType: 'collections_case', sourceId: collectionsCaseId },
},
data: { status: 'PUBLISHED', failureReason: 'Suppressed because payment was confirmed' },
})
}
await notifyPaymentResult({
billingAccountId: result.invoice.billingAccountId,
companyId: result.invoice.companyId,
invoiceId: result.invoice.id,
invoiceNumber: result.invoice.invoiceNumber,
kind: 'confirmed',
sourceId: result.paymentAttempt.id,
})
return result
} catch (error: any) {
if (error?.code === 'P2002' || error?.code === 'P2034') {
throw new ConflictError('The payment reference, idempotency key, or invoice was confirmed concurrently')
}
throw error
}
}
+4
View File
@@ -345,6 +345,7 @@ export function createAdmin(data: {
firstName: string
lastName: string
role: string
preferredLocale: string
passwordHash: string
permissions?: any[]
}) {
@@ -354,6 +355,7 @@ export function createAdmin(data: {
firstName: data.firstName,
lastName: data.lastName,
role: data.role as any,
preferredLocale: data.preferredLocale,
passwordHash: data.passwordHash,
permissions: data.permissions ? { create: data.permissions } : undefined,
},
@@ -372,6 +374,7 @@ export function updateAdmin(
firstName?: string
lastName?: string
role?: string
preferredLocale?: string
passwordHash?: string
isActive?: boolean
},
@@ -383,6 +386,7 @@ export function updateAdmin(
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
...(data.role !== undefined ? { role: data.role as any } : {}),
...(data.preferredLocale !== undefined ? { preferredLocale: data.preferredLocale } : {}),
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
},
+111
View File
@@ -6,6 +6,9 @@ import { setSessionCookie, clearSessionCookie } from '../../security/sessionCook
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.service'
import * as manualPaymentsService from './admin.manual-payments.service'
import * as collectionsService from '../subscriptions/subscription.collections.service'
import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
@@ -17,9 +20,14 @@ import {
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
platformBillingSettingsSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
} from './admin.schemas'
import { z } from 'zod'
@@ -279,6 +287,17 @@ router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), asyn
} catch (err) { next(err) }
})
router.get('/notifications/me', requireAdminAuth, async (req, res, next) => {
try { ok(res, await getAdminNotificationInbox(req.admin.id)) } catch (err) { next(err) }
})
router.post('/notifications/me/:id/read', requireAdminAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await markAdminNotificationRead(req.admin.id, id))
} catch (err) { next(err) }
})
// ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
@@ -344,6 +363,98 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol
} catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { ok(res, await manualPaymentsService.listManualPaymentSubmissions(parseQuery(manualPaymentSubmissionsQuerySchema, req))) } catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions/:submissionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
ok(res, await manualPaymentsService.getManualPaymentSubmission(submissionId, req.admin.id))
} catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions/:submissionId/documents/:documentId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(manualPaymentDocumentParamsSchema, req)
const { document, bytes } = await manualPaymentsService.getAdminPaymentDocument(submissionId, documentId, req.admin.id, req.ip)
const safeName = document.originalFilename.replace(/["\\\r\n]/g, '_')
res.setHeader('Content-Type', document.detectedMimeType)
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`)
res.setHeader('Content-Length', bytes.length)
res.setHeader('Cache-Control', 'private, no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.end(bytes)
} catch (err) { next(err) }
})
router.post('/billing/manual-payment-submissions/:submissionId/reject', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
const { reason } = parseBody(rejectManualPaymentSubmissionSchema, req)
ok(res, await manualPaymentsService.rejectManualPaymentSubmission(submissionId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/manual-payments', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await manualPaymentsService.confirmManualPayment(invoiceId, parseBody(confirmManualPaymentSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/collections', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { ok(res, await collectionsService.listCollectionsCases(parseQuery(collectionsQuerySchema, req))) } catch (err) { next(err) }
})
router.get('/billing/collections/:caseId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
ok(res, await collectionsService.getCollectionsCase(caseId))
} catch (err) { next(err) }
})
router.patch('/billing/collections/:caseId/assignee', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
const { adminId } = parseBody(collectionsAssigneeSchema, req)
ok(res, await collectionsService.assignCollectionsCase(caseId, adminId, req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collection-tasks/:taskId/outcomes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { taskId } = parseParams(collectionTaskIdParamSchema, req)
ok(res, await collectionsService.recordCallOutcome(taskId, parseBody(collectionTaskOutcomeSchema, req), req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collections/:caseId/overrides', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
ok(res, await collectionsService.createCollectionsOverride(caseId, parseBody(collectionsOverrideSchema, req), req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collections/:caseId/overrides/:overrideId/revoke', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { caseId, overrideId } = parseParams(collectionsOverrideParamsSchema, req)
ok(res, await collectionsService.revokeCollectionsOverride(caseId, overrideId, req.admin.id))
} catch (err) { next(err) }
})
router.get('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
try {
ok(res, await service.getBillingPlatformSettings())
} catch (err) { next(err) }
})
router.patch('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.updateBillingPlatformSettings(parseBody(platformBillingSettingsSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
+81 -1
View File
@@ -87,6 +87,7 @@ export const createAdminSchema = z.object({
firstName: z.string().min(1).max(100).trim(),
lastName: z.string().min(1).max(100).trim(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
preferredLocale: z.enum(['ar', 'en', 'fr']).default('en'),
password: z.string().min(8),
permissions: z.array(permissionSchema).optional(),
})
@@ -96,6 +97,7 @@ export const updateAdminSchema = z.object({
firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(100).trim().optional(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
preferredLocale: z.enum(['ar', 'en', 'fr']).optional(),
password: z.string().min(8).optional(),
isActive: z.boolean().optional(),
})
@@ -173,7 +175,7 @@ export const adminCompanyUpdateSchema = z.object({
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
terms: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
lateFeePerHour: z.number().int().nullable().optional(), taxRate: z.number().nullable().optional(),
lateFeePerHour: z.number().int().nullable().optional(),
signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
}).optional(),
accountingSettings: z.object({
@@ -243,6 +245,10 @@ export const billingAccountUpdateSchema = z.object({
netTermsDays: z.number().int().min(0).max(365).optional(),
})
export const platformBillingSettingsSchema = z.object({
taxRate: z.number().min(0).max(100),
})
export const billingLineItemInputSchema = z.object({
type: z.enum([
'SUBSCRIPTION_FEE',
@@ -292,6 +298,80 @@ export const payBillingInvoiceSchema = z.object({
providerPaymentId: z.union([z.string(), z.null()]).optional(),
})
const manualPaymentReferenceSchema = z.string()
.trim()
.min(3)
.max(120)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters')
export const manualPaymentSubmissionIdParamSchema = z.object({ submissionId: z.string().min(1) })
export const manualPaymentDocumentParamsSchema = z.object({
submissionId: z.string().min(1),
documentId: z.string().min(1),
})
export const manualPaymentSubmissionsQuerySchema = z.object({
status: z.enum(['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED']).default('SUBMITTED'),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const rejectManualPaymentSubmissionSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const confirmManualPaymentSchema = z.object({
submissionId: z.string().min(1),
method: z.enum(['BANK_TRANSFER', 'CHECK']),
externalReference: manualPaymentReferenceSchema,
amount: z.number().int().positive(),
receivedAt: z.string().datetime(),
note: z.string().trim().max(500).optional(),
correctionReason: z.string().trim().min(3).max(500).optional(),
idempotencyKey: z.string().uuid(),
fundsVerified: z.literal(true),
})
export const collectionsQuerySchema = z.object({
status: z.enum(['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED']).optional(),
assignedTo: z.string().optional(),
actionDueBefore: z.string().datetime().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const collectionsCaseIdParamSchema = z.object({ caseId: z.string().min(1) })
export const collectionTaskIdParamSchema = z.object({ taskId: z.string().min(1) })
export const collectionsOverrideParamsSchema = z.object({ caseId: z.string().min(1), overrideId: z.string().min(1) })
export const collectionsAssigneeSchema = z.object({ adminId: z.string().min(1) })
export const collectionTaskOutcomeSchema = z.object({
outcome: z.enum(['CONTACTED', 'NO_ANSWER', 'PAYMENT_PROMISED', 'ISSUE_ESCALATED']),
note: z.string().trim().max(500).optional(),
promisedPaymentAt: z.string().datetime().optional(),
nextFollowUpAt: z.string().datetime().optional(),
}).superRefine((data, ctx) => {
if (['NO_ANSWER', 'ISSUE_ESCALATED'].includes(data.outcome) && !data.note) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['note'], message: 'A note is required for this outcome' })
}
if (data.outcome === 'PAYMENT_PROMISED' && (!data.promisedPaymentAt || !data.nextFollowUpAt)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['promisedPaymentAt'], message: 'Promised payment and follow-up times are required' })
}
})
export const collectionsOverrideSchema = z.object({
type: z.enum(['PAYMENT_DISPUTE', 'MANUAL_EXTENSION']),
reason: z.string().trim().min(3).max(500),
expiresAt: z.string().datetime(),
revisedSuspensionAt: z.string().datetime().optional(),
pauseSuspension: z.boolean().default(true),
pauseNotifications: z.boolean().default(false),
}).superRefine((data, ctx) => {
if (data.type === 'MANUAL_EXTENSION' && !data.revisedSuspensionAt) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['revisedSuspensionAt'], message: 'A manual extension requires a revised suspension time' })
}
})
export const retryBillingInvoiceSchema = z.object({
paymentMethodId: z.union([z.string(), z.null()]).optional(),
})
@@ -3,8 +3,10 @@ import bcrypt from 'bcryptjs'
vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(),
findAdminByIdOrThrow: vi.fn(),
setAdminPasswordReset: vi.fn(),
updateAdminLastLogin: vi.fn(),
updateAdminTotpSecret: vi.fn(),
createAuditLog: vi.fn(),
}))
@@ -12,6 +14,10 @@ vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,test') },
}))
const redisStore = new Map<string, string>()
vi.mock('../../lib/redis', () => ({
@@ -33,7 +39,7 @@ vi.mock('../../lib/redis', () => ({
import * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService'
import { forgotPassword, login } from './admin.service'
import { forgotPassword, login, setupTotp } from './admin.service'
describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL
@@ -123,4 +129,20 @@ describe('admin.service forgotPassword', () => {
}))
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3')
})
it('reuses a pending TOTP setup secret so duplicate dev setup calls keep codes valid', async () => {
vi.mocked(repo.findAdminByIdOrThrow).mockResolvedValue({
id: 'admin_4',
email: 'admin4@example.test',
totpEnabled: false,
totpSecret: 'JBSWY3DPEHPK3PXP',
} as any)
const first = await setupTotp('admin_4', 'admin4@example.test')
const second = await setupTotp('admin_4', 'admin4@example.test')
expect(first.secret).toBe('JBSWY3DPEHPK3PXP')
expect(second.secret).toBe('JBSWY3DPEHPK3PXP')
expect(repo.updateAdminTotpSecret).not.toHaveBeenCalled()
})
})
+22 -3
View File
@@ -171,8 +171,13 @@ export async function login(email: string, password: string, totpCode?: string,
}
export async function setupTotp(adminId: string, email: string) {
const secret = authenticator.generateSecret()
await repo.updateAdminTotpSecret(adminId, secret)
const admin = await repo.findAdminByIdOrThrow(adminId)
const secret = admin.totpSecret && !admin.totpEnabled
? admin.totpSecret
: authenticator.generateSecret()
if (secret !== admin.totpSecret) {
await repo.updateAdminTotpSecret(adminId, secret)
}
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode }
@@ -335,7 +340,7 @@ export async function listAdmins() {
return admins.map((admin: any) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; preferredLocale: string; password: string; permissions?: any[] }) {
const admin = await repo.createAdmin({
...body,
passwordHash: await bcrypt.hash(body.password, 12),
@@ -350,6 +355,7 @@ export async function updateAdmin(
firstName?: string
lastName?: string
role?: string
preferredLocale?: string
password?: string
isActive?: boolean
},
@@ -359,6 +365,7 @@ export async function updateAdmin(
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
...(body.role !== undefined ? { role: body.role } : {}),
...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
})
@@ -406,6 +413,18 @@ export function updateBillingAccount(
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
}
export function getBillingPlatformSettings() {
return billingService.getBillingPlatformSettings()
}
export function updateBillingPlatformSettings(
data: Parameters<typeof billingService.updateBillingPlatformSettings>[0],
adminId: string,
ip?: string,
) {
return billingService.updateBillingPlatformSettings(data, adminId, ip)
}
export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
}
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas'
@@ -126,8 +126,7 @@ export async function startAccount(body: AccountStartInput) {
text: emailTexts[lang],
}).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message ?? String(err))
console.error('[AccountStart] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set', '| pass:', process.env.MAIL_PASSWORD ? '***' : 'not set')
console.error('[AccountStart] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
console.error('[AccountStart] Email provider config:', describeEmailProviderConfig())
})
return {
@@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken'
import { signActorToken } from '../../security/tokens'
import { AppError } from '../../http/errors'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
import { presentEmployeeSession } from './auth.presenter'
import * as repo from './auth.employee.repo'
@@ -137,7 +137,7 @@ export async function forgotPassword(email: string) {
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
}).catch((err) => {
console.error('[ForgotPassword] Email delivery failed:', err?.message)
console.error('[ForgotPassword] Provider config — resendKey present:', !!process.env.RESEND_API_KEY, '| smtpHost:', process.env.MAIL_HOST ?? 'not set')
console.error('[ForgotPassword] Email provider config:', describeEmailProviderConfig())
})
}
@@ -230,8 +230,7 @@ export async function resendVerification(email: string) {
text: `Verify your email by visiting:\n${verifyUrl}`,
}).catch((err) => {
console.error('[ResendVerification] Email delivery failed:', err?.message ?? String(err))
console.error('[ResendVerification] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set')
console.error('[ResendVerification] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
console.error('[ResendVerification] Email provider config:', describeEmailProviderConfig())
})
return { message: 'A new verification link has been sent to your email.' }
@@ -24,7 +24,6 @@ describe('company schemas edge cases', () => {
fuelPolicyType: 'FULL_TO_FULL',
additionalDriverPolicy: 'Additional drivers must be approved before pickup.',
additionalDriverCharge: 'PER_DAY',
taxRate: 20,
})).toMatchObject({
fuelPolicy: 'Return with the same fuel level.',
fuelPolicyType: 'FULL_TO_FULL',
@@ -111,7 +111,6 @@ export const contractSettingsSchema = z.object({
invoiceFooterNote: z.string().optional(),
signatureRequired: z.boolean().optional(),
showTax: z.boolean().optional(),
taxRate: z.number().optional(),
taxLabel: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
fuelPolicyNote: z.string().optional(),
@@ -1,6 +1,7 @@
import { prisma } from '../../lib/prisma'
import * as repo from './reservation.repo'
import { parseReservationExtras, serializeContractFields } from './reservation.presenter'
import { calculateTaxAmount, getPlatformBillingSettings } from '../subscriptions/billingTax'
export function formatDocumentNumber(prefix: string, sequence: number): string {
return `${prefix}-${String(sequence).padStart(6, '0')}`
@@ -134,8 +135,10 @@ export async function getContract(id: string, companyId: string) {
})
const subtotal = invoiceLineItems.reduce((s, i) => s + i.total, 0)
const taxes = contractSettings.showTax && contractSettings.taxRate
? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: contractSettings.taxRate, amount: Math.round(subtotal * contractSettings.taxRate / 100) }]
const platformBillingSettings = await getPlatformBillingSettings()
const tax = calculateTaxAmount(subtotal, false, platformBillingSettings.taxRate)
const taxes = contractSettings.showTax && tax.taxAmount > 0
? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: tax.taxRate, amount: tax.taxAmount }]
: []
const taxTotal = taxes.reduce((s, t) => s + t.amount, 0)
const grandTotal = subtotal + taxTotal
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from 'vitest'
import { calculateTaxAmount, getConfiguredInvoiceTaxRate } from './billingTax'
describe('platform billing tax', () => {
const originalSubscriptionTaxRate = process.env.SUBSCRIPTION_TAX_RATE
const originalInvoiceTaxRate = process.env.INVOICE_TAX_RATE
afterEach(() => {
if (originalSubscriptionTaxRate === undefined) delete process.env.SUBSCRIPTION_TAX_RATE
else process.env.SUBSCRIPTION_TAX_RATE = originalSubscriptionTaxRate
if (originalInvoiceTaxRate === undefined) delete process.env.INVOICE_TAX_RATE
else process.env.INVOICE_TAX_RATE = originalInvoiceTaxRate
})
it('uses one platform tax rate for all accounts', () => {
process.env.SUBSCRIPTION_TAX_RATE = '18'
process.env.INVOICE_TAX_RATE = '9'
expect(getConfiguredInvoiceTaxRate()).toBe(18)
expect(calculateTaxAmount(10_000, false, getConfiguredInvoiceTaxRate())).toEqual({
priceBeforeTax: 10_000,
taxRate: 18,
taxAmount: 1_800,
totalAmount: 11_800,
})
})
it('falls back to the default rate and respects tax exemptions', () => {
process.env.SUBSCRIPTION_TAX_RATE = 'not-a-rate'
delete process.env.INVOICE_TAX_RATE
expect(getConfiguredInvoiceTaxRate()).toBe(20)
expect(calculateTaxAmount(10_000, true)).toEqual({
priceBeforeTax: 10_000,
taxRate: 0,
taxAmount: 0,
totalAmount: 10_000,
})
})
})
@@ -0,0 +1,55 @@
import { prisma } from '../../lib/prisma'
const DEFAULT_INVOICE_TAX_RATE = 20
const PLATFORM_BILLING_SETTINGS_ID = 'default'
function normalizeTaxRate(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100 ? parsed : DEFAULT_INVOICE_TAX_RATE
}
export function getConfiguredInvoiceTaxRate() {
const raw = process.env.SUBSCRIPTION_TAX_RATE ?? process.env.INVOICE_TAX_RATE
if (raw === undefined || raw.trim() === '') return DEFAULT_INVOICE_TAX_RATE
return normalizeTaxRate(raw)
}
export async function getPlatformBillingSettings() {
const settings = await prisma.platformBillingSettings.findUnique({
where: { id: PLATFORM_BILLING_SETTINGS_ID },
})
return {
id: PLATFORM_BILLING_SETTINGS_ID,
taxRate: normalizeTaxRate(settings?.taxRate ?? getConfiguredInvoiceTaxRate()),
updatedAt: settings?.updatedAt ?? null,
updatedBy: settings?.updatedBy ?? null,
}
}
export async function updatePlatformBillingSettings(data: { taxRate: number; updatedBy?: string | null }) {
const taxRate = normalizeTaxRate(data.taxRate)
return prisma.platformBillingSettings.upsert({
where: { id: PLATFORM_BILLING_SETTINGS_ID },
create: {
id: PLATFORM_BILLING_SETTINGS_ID,
taxRate,
updatedBy: data.updatedBy ?? null,
},
update: {
taxRate,
updatedBy: data.updatedBy ?? null,
},
})
}
export function calculateTaxAmount(priceBeforeTax: number, taxExempt?: boolean, platformTaxRate = getConfiguredInvoiceTaxRate()) {
const taxRate = taxExempt ? 0 : normalizeTaxRate(platformTaxRate)
const taxAmount = taxRate > 0 ? Math.round(priceBeforeTax * (taxRate / 100)) : 0
return {
priceBeforeTax,
taxRate,
taxAmount,
totalAmount: priceBeforeTax + taxAmount,
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { calculateCollectionsSchedule } from './subscription.collections.service'
function localParts(date: Date, timeZone: string) {
return new Intl.DateTimeFormat('en-CA', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).format(date)
}
describe('timezone-safe subscription collections schedule', () => {
it('preserves the local expiration wall time across a DST transition', () => {
const timeZone = 'America/New_York'
const expiration = new Date('2026-03-01T15:30:00.000Z') // 10:30 local, before DST
const schedule = calculateCollectionsSchedule(expiration, timeZone, '09:00')
expect(localParts(schedule.finalSuspensionAt, timeZone)).toContain('2026-03-31, 10:30')
})
it('uses local reminder time for day milestones and exact time for 48/24 hours', () => {
const expiration = new Date('2026-09-30T17:00:00.000Z')
const schedule = calculateCollectionsSchedule(expiration, 'Africa/Casablanca', '09:00')
expect(localParts(schedule.reminder14At, 'Africa/Casablanca')).toContain('2026-09-16, 09:00')
expect(expiration.getTime() - schedule.reminder48At.getTime()).toBe(48 * 60 * 60 * 1000)
expect(expiration.getTime() - schedule.reminder24At.getTime()).toBe(24 * 60 * 60 * 1000)
})
it('rejects fixed offsets and invalid timezones', () => {
expect(() => calculateCollectionsSchedule(new Date(), 'UTC+01:00')).toThrow(/IANA timezone/i)
expect(() => calculateCollectionsSchedule(new Date(), 'Mars/Olympus')).toThrow(/IANA timezone/i)
})
})
@@ -0,0 +1,680 @@
import crypto from 'crypto'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezonePlugin from 'dayjs/plugin/timezone'
import { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../../lib/prisma'
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '../../http/errors'
import { sendNotification } from '../../services/notificationService'
import {
coerceNotificationLocale,
formatLocalizedCurrency,
formatLocalizedDate,
type NotificationLocale,
} from '../../services/notificationLocalizationService'
import {
addBillingPeriod,
calculateTaxForAccount,
ensurePrimaryBillingAccount,
isValidIanaTimezone,
taxLineItem,
taxRecordCreate,
} from './subscription.manual.service'
import { getPlatformBillingSettings } from './billingTax'
import { getPaymentOptions } from './subscription.payment-config'
dayjs.extend(utc)
dayjs.extend(timezonePlugin)
type CollectionsMilestone = 'DUE_14D' | 'DUE_7D' | 'DUE_48H' | 'DUE_24H' | 'GRACE_DAILY' | 'GRACE_FINAL'
const typeByMilestone: Record<CollectionsMilestone, any> = {
DUE_14D: 'SUBSCRIPTION_PAYMENT_DUE_14D',
DUE_7D: 'SUBSCRIPTION_PAYMENT_DUE_7D',
DUE_48H: 'SUBSCRIPTION_PAYMENT_DUE_48H',
DUE_24H: 'SUBSCRIPTION_PAYMENT_DUE_24H',
GRACE_DAILY: 'SUBSCRIPTION_GRACE_DAILY',
GRACE_FINAL: 'SUBSCRIPTION_GRACE_FINAL',
}
const templateByMilestone: Record<CollectionsMilestone, string> = {
DUE_14D: 'subscription.payment_due_14d.v1',
DUE_7D: 'subscription.payment_due_7d.v1',
DUE_48H: 'subscription.payment_due_48h.v1',
DUE_24H: 'subscription.payment_due_24h.v1',
GRACE_DAILY: 'subscription.grace_daily.v1',
GRACE_FINAL: 'subscription.grace_final.v1',
}
const labels: Record<NotificationLocale, {
titles: Record<CollectionsMilestone, string>
due: (invoice: string, amount: string, expiration: string) => string
grace: (invoice: string, amount: string, day: number, remaining: number, suspension: string) => string
action: string
callScript: (company: string, invoice: string, amount: string, expiration: string) => string
}> = {
en: {
titles: { DUE_14D: 'Subscription payment due in 14 days', DUE_7D: 'Subscription payment due in 7 days', DUE_48H: 'Payment due in 48 hours', DUE_24H: 'Final expiration warning', GRACE_DAILY: 'Subscription payment overdue', GRACE_FINAL: 'Final warning before suspension' },
due: (invoice, amount, expiration) => `Invoice ${invoice} for ${amount} remains unpaid. Your current subscription period expires on ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `Invoice ${invoice} for ${amount} is overdue. Grace day ${day}; ${remaining} day(s) remain. Service is scheduled for suspension on ${suspension} unless payment is confirmed.`,
action: 'Open Subscription in the dashboard to pay by Stripe or view the configured bank/check instructions.',
callScript: (company, invoice, amount, expiration) => `Hello, I am calling RentalDriveGo regarding ${company}'s invoice ${invoice} for ${amount}. The subscription expires on ${expiration}. Can I help confirm the payment plan?`,
},
fr: {
titles: { DUE_14D: 'Paiement de labonnement dû dans 14 jours', DUE_7D: 'Paiement de labonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de labonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' },
due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période dabonnement actuelle expire le ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `La facture ${invoice} de ${amount} est en retard. Jour de grâce ${day} ; il reste ${remaining} jour(s). Le service sera suspendu le ${suspension} si le paiement nest pas confirmé.`,
action: 'Ouvrez Abonnement dans le tableau de bord pour payer par Stripe ou consulter les instructions de virement/chèque.',
callScript: (company, invoice, amount, expiration) => `Bonjour, je vous appelle de RentalDriveGo au sujet de la facture ${invoice} de ${company}, dun montant de ${amount}. Labonnement expire le ${expiration}. Puis-je vous aider à confirmer le mode de règlement ?`,
},
ar: {
titles: { DUE_14D: 'استحقاق دفع الاشتراك خلال 14 يوماً', DUE_7D: 'استحقاق دفع الاشتراك خلال 7 أيام', DUE_48H: 'استحقاق الدفع خلال 48 ساعة', DUE_24H: 'التحذير الأخير قبل انتهاء الاشتراك', GRACE_DAILY: 'دفع الاشتراك متأخر', GRACE_FINAL: 'التحذير الأخير قبل تعليق الخدمة' },
due: (invoice, amount, expiration) => `لا تزال الفاتورة ${invoice} بمبلغ ${amount} غير مدفوعة. تنتهي فترة الاشتراك الحالية في ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `الفاتورة ${invoice} بمبلغ ${amount} متأخرة. يوم السماح ${day}، ويتبقى ${remaining} يوم. ستُعلّق الخدمة في ${suspension} ما لم يتم تأكيد الدفع.`,
action: 'افتح صفحة الاشتراك في لوحة التحكم للدفع عبر Stripe أو لعرض تعليمات التحويل البنكي أو الشيك.',
callScript: (company, invoice, amount, expiration) => `مرحباً، أتصل بكم من RentalDriveGo بخصوص فاتورة شركة ${company} رقم ${invoice} بمبلغ ${amount}. ينتهي الاشتراك في ${expiration}. هل يمكنني مساعدتكم في تأكيد خطة الدفع؟`,
},
}
const suspensionCopy: Record<NotificationLocale, { title: string; body: (invoice: string) => string }> = {
en: { title: 'Subscription suspended', body: (invoice) => `Your subscription was suspended because invoice ${invoice} remains unpaid after the grace period. Payment confirmation restores access.` },
fr: { title: 'Abonnement suspendu', body: (invoice) => `Votre abonnement a été suspendu, car la facture ${invoice} reste impayée après le délai de grâce. La confirmation du paiement rétablit laccès.` },
ar: { title: 'تم تعليق الاشتراك', body: (invoice) => `تم تعليق اشتراكك لأن الفاتورة ${invoice} ما زالت غير مدفوعة بعد فترة السماح. يؤدي تأكيد الدفع إلى استعادة الوصول.` },
}
const overrideCopy: Record<NotificationLocale, { title: string; created: (invoice: string, until: string) => string; revoked: (invoice: string) => string }> = {
en: { title: 'Subscription payment schedule updated', created: (invoice, until) => `A temporary collections override was applied to invoice ${invoice} through ${until}.`, revoked: (invoice) => `The temporary collections override for invoice ${invoice} was revoked. The standard schedule applies again.` },
fr: { title: 'Calendrier de paiement de labonnement mis à jour', created: (invoice, until) => `Une dérogation temporaire de recouvrement a été appliquée à la facture ${invoice} jusquau ${until}.`, revoked: (invoice) => `La dérogation temporaire concernant la facture ${invoice} a été révoquée. Le calendrier standard sapplique à nouveau.` },
ar: { title: 'تم تحديث جدول دفع الاشتراك', created: (invoice, until) => `تم تطبيق استثناء مؤقت للتحصيل على الفاتورة ${invoice} حتى ${until}.`, revoked: (invoice) => `تم إلغاء استثناء التحصيل المؤقت للفاتورة ${invoice}. عاد جدول المتابعة المعتاد للتطبيق.` },
}
const configurationCopy: Record<NotificationLocale, { title: string; body: (company: string) => string }> = {
en: { title: 'Collections configuration required', body: (company) => `${company} cannot be auto-suspended until its billing timezone, verified recipient, and collections owner configuration are valid.` },
fr: { title: 'Configuration du recouvrement requise', body: (company) => `${company} ne peut pas être suspendue automatiquement tant que le fuseau de facturation, le destinataire vérifié et le responsable du recouvrement ne sont pas valides.` },
ar: { title: 'إعدادات التحصيل مطلوبة', body: (company) => `لا يمكن تعليق ${company} تلقائياً حتى تصبح المنطقة الزمنية للفوترة وجهة الاتصال الموثقة ومسؤول التحصيل صالحة.` },
}
function flag(name: string) {
return process.env[name] === 'true'
}
function localDate(value: Date, timezone: string) {
return dayjs(value).tz(timezone).format('YYYY-MM-DD')
}
function atLocalTime(date: Date, timezone: string, localTime: string) {
const datePart = dayjs(date).tz(timezone).format('YYYY-MM-DD')
return dayjs.tz(`${datePart} ${localTime}`, timezone).toDate()
}
function addCalendarDays(value: Date, days: number, timezone: string) {
const local = dayjs(value).tz(timezone)
return dayjs.tz(local.add(days, 'day').format('YYYY-MM-DD HH:mm:ss.SSS'), timezone).toDate()
}
export function calculateCollectionsSchedule(expirationAt: Date, timezone: string, reminderLocalTime = '09:00') {
if (!isValidIanaTimezone(timezone)) throw new ValidationError('A valid IANA timezone is required')
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderLocalTime)) throw new ValidationError('Reminder local time must use HH:mm')
const expiry = dayjs(expirationAt).tz(timezone)
const reminderForDaysBefore = (days: number) => atLocalTime(expiry.subtract(days, 'day').toDate(), timezone, reminderLocalTime)
return {
originalExpirationAt: new Date(expirationAt),
reminder14At: reminderForDaysBefore(14),
reminder7At: reminderForDaysBefore(7),
reminder48At: new Date(expirationAt.getTime() - 48 * 60 * 60 * 1000),
reminder24At: new Date(expirationAt.getTime() - 24 * 60 * 60 * 1000),
finalSuspensionAt: addCalendarDays(expirationAt, 30, timezone),
}
}
function formatForCase(value: Date, locale: NotificationLocale, timezone: string) {
return formatLocalizedDate(value, locale, {
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: timezone,
})
}
function customerMessage(caseData: any, milestone: CollectionsMilestone, locale: NotificationLocale, graceDay?: number) {
const copy = labels[locale]
const amount = formatLocalizedCurrency(caseData.invoice.amountDue, caseData.invoice.currency, locale)
const expiry = formatForCase(caseData.originalExpirationAt, locale, caseData.billingAccount.timezone)
const suspension = formatForCase(caseData.finalSuspensionAt, locale, caseData.billingAccount.timezone)
const core = milestone === 'GRACE_DAILY' || milestone === 'GRACE_FINAL'
? copy.grace(caseData.invoice.invoiceNumber ?? caseData.invoice.id, amount, graceDay ?? 1, Math.max(0, 30 - (graceDay ?? 1)), suspension)
: copy.due(caseData.invoice.invoiceNumber ?? caseData.invoice.id, amount, expiry)
return { title: copy.titles[milestone], body: `${core}\n\n${copy.action}`, amount, expiry, suspension }
}
function effectiveContactLocale(contact: any, account: any): NotificationLocale {
const enabled = account.enabledCommunicationLocales as string[]
const employeeLocale = contact.employee?.preferredLanguage
return coerceNotificationLocale(
contact.locale && enabled.includes(contact.locale)
? contact.locale
: employeeLocale && enabled.includes(employeeLocale)
? employeeLocale
: account.defaultCommunicationLocale,
)
}
async function sendOverrideChangedNotification(caseId: string, overrideId: string, action: 'created' | 'revoked') {
const caseData = await prisma.collectionsCase.findUnique({
where: { id: caseId },
include: {
invoice: true,
billingAccount: { include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } } },
overrides: { where: { id: overrideId }, take: 1 },
},
}) as any
const override = caseData?.overrides?.[0]
if (!caseData || !override) return
for (const contact of caseData.billingAccount.billingContacts) {
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const copy = overrideCopy[locale]
const invoice = caseData.invoice.invoiceNumber ?? caseData.invoice.id
const until = formatForCase(override.expiresAt, locale, caseData.billingAccount.timezone)
await sendNotification({
type: 'COLLECTIONS_OVERRIDE_CHANGED',
title: copy.title,
body: action === 'created' ? copy.created(invoice, until) : copy.revoked(invoice),
companyId: caseData.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: `subscription.collections_override_${action}.v1`,
idempotencyKey: `collections:${caseData.invoiceId}:override:${overrideId}:${action}:${contact.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
data: { collectionsCaseId: caseData.id, invoiceId: caseData.invoiceId, overrideId, action, timezone: caseData.billingAccount.timezone, templateVersion: 1, localizationFallback: false },
policy: { mandatory: true },
})
}
}
async function sendMilestoneNotifications(caseData: any, milestone: CollectionsMilestone, eventKey: string, graceDay?: number) {
for (const contact of caseData.billingAccount.billingContacts) {
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const content = customerMessage(caseData, milestone, locale, graceDay)
await sendNotification({
type: typeByMilestone[milestone],
title: content.title,
body: content.body,
data: {
collectionsCaseId: caseData.id,
invoiceId: caseData.invoiceId,
invoiceNumber: caseData.invoice.invoiceNumber,
amountDue: caseData.invoice.amountDue,
currency: caseData.invoice.currency,
originalExpirationAt: caseData.originalExpirationAt,
finalSuspensionAt: caseData.finalSuspensionAt,
graceDay: graceDay ?? null,
timezone: caseData.billingAccount.timezone,
templateVersion: 1,
localizationFallback: false,
},
companyId: caseData.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: templateByMilestone[milestone],
idempotencyKey: `collections:${caseData.invoiceId}:${eventKey}:${contact.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
policy: { mandatory: true },
})
}
const admin = caseData.collectionsOwnerAdmin
if (admin) {
const emailMilestone = milestone === 'DUE_48H' || milestone === 'DUE_24H'
const internalLocale = coerceNotificationLocale(admin.preferredLocale)
const companyName = caseData.invoice.company.name
await sendNotification({
type: milestone === 'DUE_48H' ? 'COLLECTIONS_CALL_REQUIRED' : typeByMilestone[milestone],
title: `Collections: ${companyName}`,
body: `${caseData.invoice.invoiceNumber ?? caseData.invoice.id} · ${milestone} · ${caseData.billingAccount.defaultCommunicationLocale}`,
companyId: caseData.companyId,
adminUserId: admin.id,
channels: emailMilestone ? ['IN_APP', 'EMAIL'] : ['IN_APP'],
locale: internalLocale,
idempotencyKey: `collections:${caseData.invoiceId}:${eventKey}:admin:${admin.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
policy: { mandatory: true },
})
}
}
async function createRequiredCallTask(caseData: any) {
const contact = caseData.billingAccount.billingContacts.find((item: any) => item.isPrimary) ?? caseData.billingAccount.billingContacts[0]
if (!contact || !caseData.collectionsOwnerAdmin) return null
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const content = customerMessage(caseData, 'DUE_48H', locale)
return prisma.collectionsCallTask.upsert({
where: { collectionsCaseId_taskType: { collectionsCaseId: caseData.id, taskType: 'PRE_EXPIRY_48H_CALL' } },
update: {},
create: {
collectionsCaseId: caseData.id,
taskType: 'PRE_EXPIRY_48H_CALL',
assignedAdminId: caseData.collectionsOwnerAdmin.id,
billingContactId: contact.id,
dueAt: caseData.reminder48At,
companyDefaultLocale: caseData.billingAccount.defaultCommunicationLocale,
contactLocale: locale,
customerScript: labels[locale].callScript(caseData.invoice.company.name, caseData.invoice.invoiceNumber ?? caseData.invoice.id, content.amount, content.expiry),
},
})
}
async function getRenewalPrice(plan: string, billingPeriod: string) {
const configured = await prisma.pricingConfig.findUnique({ where: { plan_billingPeriod: { plan, billingPeriod } } })
return configured?.amount ?? (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
}
export async function ensureRenewalCollectionsCases(now = new Date()) {
if (!flag('SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED')) return 0
const horizon = new Date(now.getTime() + 21 * 24 * 60 * 60 * 1000)
const subscriptions = await prisma.subscription.findMany({
where: { status: 'ACTIVE', currentPeriodEnd: { not: null, lte: horizon, gt: new Date(now.getTime() - 31 * 24 * 60 * 60 * 1000) }, cancelAtPeriodEnd: false },
include: { company: { include: { employees: { where: { role: 'OWNER', isActive: true }, orderBy: { createdAt: 'asc' }, take: 1 } } } },
})
let createdCount = 0
for (const subscription of subscriptions as any[]) {
if (!subscription.currentPeriodEnd) continue
const owner = subscription.company.employees[0]
const account = await ensurePrimaryBillingAccount(subscription.companyId, owner?.id)
const schedule = calculateCollectionsSchedule(subscription.currentPeriodEnd, account.timezone, account.reminderLocalTime)
const renewalKey = `${subscription.id}:${subscription.currentPeriodEnd.toISOString()}`
const existing = await prisma.billingInvoice.findUnique({ where: { renewalKey }, include: { collectionsCase: true } })
if (existing?.collectionsCase) continue
const amount = await getRenewalPrice(subscription.plan, subscription.billingPeriod)
if (!Number.isInteger(amount) || amount <= 0) continue
const lastInvoice = await prisma.billingInvoice.findFirst({
where: { subscriptionId: subscription.id, status: 'PAID' },
orderBy: { paidAt: 'desc' },
select: { collectionMethod: true },
})
const enabledMethods = getPaymentOptions(coerceNotificationLocale(account.defaultCommunicationLocale)).methods.filter((item: any) => item.enabled).map((item: any) => item.method)
const collectionMethod = enabledMethods.includes(lastInvoice?.collectionMethod) ? lastInvoice!.collectionMethod : (enabledMethods[0] ?? 'STRIPE')
const assignee = account.collectionsOwnerAdminId
? await prisma.adminUser.findFirst({ where: { id: account.collectionsOwnerAdminId, isActive: true } })
: await prisma.adminUser.findFirst({ where: { isActive: true, role: { in: ['FINANCE', 'ADMIN', 'SUPER_ADMIN'] } }, orderBy: { createdAt: 'asc' } })
const ready = isValidIanaTimezone(account.timezone)
&& account.billingContacts.some((contact: any) => contact.isActive && contact.receivePaymentNotices && contact.verifiedAt)
&& Boolean(assignee)
const platformBillingSettings = await getPlatformBillingSettings()
await prisma.$transaction(async (tx: any) => {
let invoice = existing
if (!invoice) {
const tax = calculateTaxForAccount(amount, account, platformBillingSettings.taxRate)
const invoiceNumber = `INV-${now.getUTCFullYear()}-${crypto.randomUUID().slice(0, 8).toUpperCase()}`
invoice = await tx.billingInvoice.create({
data: {
billingAccountId: account.id,
companyId: subscription.companyId,
subscriptionId: subscription.id,
invoiceNumber,
invoiceType: 'SUBSCRIPTION_RENEWAL',
status: 'OPEN',
currency: subscription.currency,
subtotalAmount: amount,
taxAmount: tax.taxAmount,
totalAmount: tax.totalAmount,
amountDue: tax.totalAmount,
invoiceDate: now,
dueAt: subscription.currentPeriodEnd,
finalizedAt: now,
billingName: account.legalName,
billingEmail: account.billingEmail,
billingAddress: account.billingAddress ?? undefined,
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL',
collectionMethod,
requestedPlan: subscription.plan,
requestedBillingPeriod: subscription.billingPeriod,
renewalKey,
isSubscriptionBlocking: true,
metadata: { source: 'renewal_collections_worker' },
lineItems: {
create: [
{
subscriptionId: subscription.id,
plan: subscription.plan,
type: 'SUBSCRIPTION_FEE',
description: `${subscription.plan} subscription renewal — ${subscription.billingPeriod}`,
quantity: 1,
unitAmount: amount,
amount,
currency: subscription.currency,
periodStart: subscription.currentPeriodEnd,
periodEnd: addBillingPeriod(subscription.currentPeriodEnd, subscription.billingPeriod),
},
...taxLineItem(tax, subscription.currency),
],
},
...(taxRecordCreate(account, tax) ? { taxRecords: taxRecordCreate(account, tax) } : {}),
},
})
await tx.subscriptionInvoice.create({
data: {
companyId: subscription.companyId,
subscriptionId: subscription.id,
requestedPlan: subscription.plan,
requestedBillingPeriod: subscription.billingPeriod,
amount: tax.totalAmount,
currency: subscription.currency,
status: 'PENDING',
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL',
billingInvoiceId: invoice!.id,
dueAt: subscription.currentPeriodEnd,
},
})
}
if (!invoice) throw new Error('Renewal invoice creation failed')
await tx.collectionsCase.create({
data: {
invoiceId: invoice.id,
subscriptionId: subscription.id,
billingAccountId: account.id,
companyId: subscription.companyId,
status: now >= schedule.originalExpirationAt ? 'GRACE_PERIOD' : 'SCHEDULED',
...schedule,
graceStartedAt: now >= schedule.originalExpirationAt ? schedule.originalExpirationAt : null,
nextActionAt: ready ? schedule.reminder14At : null,
collectionsOwnerAdminId: assignee?.id ?? null,
},
})
})
createdCount += 1
}
return createdCount
}
function nextReminderAfter(caseData: any, now: Date) {
const candidates = [caseData.reminder14At, caseData.reminder7At, caseData.reminder48At, caseData.reminder24At, caseData.originalExpirationAt]
.map((value: Date | string) => new Date(value))
.filter((value: Date) => value > now)
.sort((a: Date, b: Date) => a.getTime() - b.getTime())
return candidates[0] ?? caseData.originalExpirationAt
}
async function recordCollectionsEvent(caseData: any, key: string, eventType: string, payload: Record<string, unknown> = {}) {
try {
await prisma.collectionsEvent.create({
data: { collectionsCaseId: caseData.id, companyId: caseData.companyId, eventType, idempotencyKey: key, payload: payload as any },
})
return true
} catch (error: any) {
if (error?.code === 'P2002') return false
throw error
}
}
async function processMilestone(caseData: any, milestone: CollectionsMilestone, key: string, graceDay?: number) {
const exists = await prisma.collectionsEvent.findUnique({ where: { collectionsCaseId_idempotencyKey: { collectionsCaseId: caseData.id, idempotencyKey: key } } })
if (exists) return false
await sendMilestoneNotifications(caseData, milestone, key, graceDay)
if (milestone === 'DUE_48H') await createRequiredCallTask(caseData)
return recordCollectionsEvent(caseData, key, `collections.${milestone.toLowerCase()}`, { graceDay: graceDay ?? null })
}
async function processCollectionsCase(caseId: string, now: Date) {
const caseData = await prisma.collectionsCase.findUnique({
where: { id: caseId },
include: {
invoice: { include: { company: true } },
subscription: true,
billingAccount: { include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] } } },
collectionsOwnerAdmin: true,
overrides: { where: { status: 'ACTIVE' }, orderBy: { expiresAt: 'desc' } },
},
}) as any
if (!caseData || ['RESOLVED', 'SUSPENDED'].includes(caseData.status)) return
if (caseData.invoice.status === 'PAID') {
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { status: 'RESOLVED', resolvedAt: now, nextActionAt: null } })
await prisma.collectionsCallTask.updateMany({ where: { collectionsCaseId: caseData.id, status: 'OPEN' }, data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' } })
return
}
const configured = isValidIanaTimezone(caseData.billingAccount.timezone)
&& caseData.billingAccount.billingContacts.length > 0
&& caseData.collectionsOwnerAdmin?.isActive
if (!configured) {
const safeTimezone = isValidIanaTimezone(caseData.billingAccount.timezone) ? caseData.billingAccount.timezone : 'UTC'
const eventKey = `configuration-blocked:${localDate(now, safeTimezone)}`
const firstOccurrenceToday = await recordCollectionsEvent(caseData, eventKey, 'collections.configuration_blocked')
if (firstOccurrenceToday && caseData.collectionsOwnerAdmin?.isActive) {
const locale = coerceNotificationLocale(caseData.collectionsOwnerAdmin.preferredLocale)
const copy = configurationCopy[locale]
await sendNotification({
type: 'COLLECTIONS_CALL_REQUIRED',
title: copy.title,
body: copy.body(caseData.invoice.company.name),
companyId: caseData.companyId,
adminUserId: caseData.collectionsOwnerAdmin.id,
channels: ['IN_APP', 'EMAIL'],
locale,
templateKey: 'subscription.collections_configuration_required.v1',
idempotencyKey: `collections:${caseData.invoiceId}:${eventKey}:admin:${caseData.collectionsOwnerAdmin.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
data: { collectionsCaseId: caseData.id, invoiceId: caseData.invoiceId, timezone: caseData.billingAccount.timezone, templateVersion: 1 },
policy: { mandatory: true },
})
}
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { nextActionAt: new Date(now.getTime() + 24 * 60 * 60 * 1000) } })
return
}
for (const override of caseData.overrides) {
if (override.expiresAt <= now) await prisma.collectionsOverride.update({ where: { id: override.id }, data: { status: 'EXPIRED' } })
}
const activeOverride = caseData.overrides.find((override: any) => override.expiresAt > now)
if (now < caseData.originalExpirationAt) {
const milestones: Array<[CollectionsMilestone, Date, string]> = [
['DUE_14D', caseData.reminder14At, 'due-14d'],
['DUE_7D', caseData.reminder7At, 'due-7d'],
['DUE_48H', caseData.reminder48At, 'due-48h'],
['DUE_24H', caseData.reminder24At, 'due-24h'],
]
const due = milestones.filter((item) => item[1] <= now).reverse()
if (due[0] && !activeOverride?.pauseNotifications) await processMilestone(caseData, due[0][0], due[0][2])
await prisma.collectionsCase.update({
where: { id: caseData.id },
data: { status: due.length ? 'PRE_DUE' : 'SCHEDULED', nextActionAt: nextReminderAfter(caseData, now), version: { increment: 1 } },
})
return
}
if (caseData.status !== 'GRACE_PERIOD') {
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { status: 'GRACE_PERIOD', graceStartedAt: caseData.originalExpirationAt } })
}
const suspensionAt = activeOverride?.revisedSuspensionAt ?? caseData.finalSuspensionAt
if (now >= suspensionAt) {
if (activeOverride?.pauseSuspension || !flag('SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED')) {
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { nextActionAt: activeOverride?.expiresAt ?? new Date(now.getTime() + 60 * 60 * 1000) } })
return
}
const suspended = await prisma.$transaction(async (tx: any) => {
const unpaid = await tx.billingInvoice.findFirst({ where: { id: caseData.invoiceId, status: { not: 'PAID' }, amountDue: { gt: 0 } } })
if (!unpaid) return false
const changed = await tx.collectionsCase.updateMany({ where: { id: caseData.id, status: { in: ['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD'] } }, data: { status: 'SUSPENDED', suspendedAt: now, nextActionAt: null } })
if (changed.count !== 1) return false
await tx.subscription.update({ where: { id: caseData.subscriptionId }, data: { status: 'SUSPENDED', suspendedAt: now } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseData.id, companyId: caseData.companyId, eventType: 'collections.suspended', idempotencyKey: 'suspended', payload: { finalSuspensionAt: suspensionAt } } })
await tx.subscriptionEvent.create({ data: { subscriptionId: caseData.subscriptionId, companyId: caseData.companyId, eventType: 'subscription.suspended', source: 'collections_worker', payload: { invoiceId: caseData.invoiceId }, occurredAt: now } })
await tx.billingEvent.create({ data: { billingAccountId: caseData.billingAccountId, invoiceId: caseData.invoiceId, subscriptionId: caseData.subscriptionId, companyId: caseData.companyId, eventType: 'collections.suspended', source: 'system', payload: {}, occurredAt: now } })
return true
})
if (suspended) {
for (const contact of caseData.billingAccount.billingContacts) {
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const copy = suspensionCopy[locale]
const invoice = caseData.invoice.invoiceNumber ?? caseData.invoiceId
await sendNotification({
type: 'SUBSCRIPTION_SUSPENDED',
title: copy.title,
body: copy.body(invoice),
companyId: caseData.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: 'subscription.suspended.v1',
idempotencyKey: `collections:${caseData.invoiceId}:suspended:${contact.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
data: { collectionsCaseId: caseData.id, invoiceId: caseData.invoiceId, timezone: caseData.billingAccount.timezone, templateVersion: 1, localizationFallback: false },
policy: { mandatory: true },
})
}
}
return
}
const expiryDay = dayjs(caseData.originalExpirationAt).tz(caseData.billingAccount.timezone).startOf('day')
const nowDay = dayjs(now).tz(caseData.billingAccount.timezone).startOf('day')
const graceDay = Math.max(1, Math.min(30, nowDay.diff(expiryDay, 'day') + 1))
const localKey = localDate(now, caseData.billingAccount.timezone)
if (!activeOverride?.pauseNotifications) {
await processMilestone(caseData, graceDay >= 30 ? 'GRACE_FINAL' : 'GRACE_DAILY', `grace:${localKey}`, graceDay)
}
const tomorrow = dayjs(now).tz(caseData.billingAccount.timezone).add(1, 'day').toDate()
const nextDaily = atLocalTime(tomorrow, caseData.billingAccount.timezone, caseData.billingAccount.reminderLocalTime)
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { nextActionAt: nextDaily < suspensionAt ? nextDaily : suspensionAt, version: { increment: 1 } } })
}
export async function runCollectionsWorker(now = new Date()) {
if (!flag('SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED')) return 0
await ensureRenewalCollectionsCases(now)
const cases = await prisma.collectionsCase.findMany({
where: { status: { in: ['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD'] }, nextActionAt: { lte: now }, OR: [{ processingLeaseUntil: null }, { processingLeaseUntil: { lt: now } }] },
select: { id: true, version: true },
orderBy: { nextActionAt: 'asc' },
take: 100,
})
let processed = 0
for (const item of cases) {
const workerId = crypto.randomUUID()
const claimed = await prisma.collectionsCase.updateMany({
where: { id: item.id, version: item.version, OR: [{ processingLeaseUntil: null }, { processingLeaseUntil: { lt: now } }] },
data: { processingLeaseUntil: new Date(now.getTime() + 5 * 60 * 1000), processingBy: workerId, version: { increment: 1 } },
})
if (claimed.count !== 1) continue
try {
await processCollectionsCase(item.id, now)
processed += 1
} finally {
await prisma.collectionsCase.updateMany({ where: { id: item.id, processingBy: workerId }, data: { processingLeaseUntil: null, processingBy: null } })
}
}
return processed
}
export async function listCollectionsCases(query: { status?: string; assignedTo?: string; actionDueBefore?: string; page: number; pageSize: number }) {
const where: any = {
...(query.status ? { status: query.status } : {}),
...(query.assignedTo ? { collectionsOwnerAdminId: query.assignedTo } : {}),
...(query.actionDueBefore ? { nextActionAt: { lte: new Date(query.actionDueBefore) } } : {}),
}
const [data, total] = await Promise.all([
prisma.collectionsCase.findMany({
where,
include: {
invoice: { include: { company: true } },
billingAccount: { include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true } } } },
collectionsOwnerAdmin: { select: { id: true, firstName: true, lastName: true, email: true } },
tasks: { where: { status: 'OPEN' }, orderBy: { dueAt: 'asc' } },
overrides: { where: { status: 'ACTIVE' }, orderBy: { expiresAt: 'desc' } },
},
orderBy: [{ nextActionAt: 'asc' }, { originalExpirationAt: 'asc' }],
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma.collectionsCase.count({ where }),
])
return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.max(1, Math.ceil(total / query.pageSize)) }
}
export async function getCollectionsCase(caseId: string) {
const value = await prisma.collectionsCase.findUnique({
where: { id: caseId },
include: { invoice: { include: { company: true, manualPaymentSubmissions: { include: { documents: true } } } }, billingAccount: { include: { billingContacts: true } }, collectionsOwnerAdmin: true, tasks: { orderBy: { createdAt: 'desc' } }, overrides: { orderBy: { createdAt: 'desc' } }, events: { orderBy: { occurredAt: 'desc' } } },
})
if (!value) throw new NotFoundError('Collections case not found')
return value
}
export async function assignCollectionsCase(caseId: string, adminId: string, actorId: string) {
const admin = await prisma.adminUser.findFirst({ where: { id: adminId, isActive: true, role: { in: ['FINANCE', 'ADMIN', 'SUPER_ADMIN'] } } })
if (!admin) throw new ValidationError('Assignee must be an active finance-capable admin')
return prisma.$transaction(async (tx: any) => {
const value = await tx.collectionsCase.update({ where: { id: caseId }, data: { collectionsOwnerAdminId: adminId } })
await tx.collectionsCallTask.updateMany({ where: { collectionsCaseId: caseId, status: 'OPEN' }, data: { assignedAdminId: adminId } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseId, companyId: value.companyId, eventType: 'collections.assigned', idempotencyKey: `assigned:${adminId}:${Date.now()}`, actorType: 'admin', actorId, payload: { assignedAdminId: adminId } } })
return value
})
}
async function assertTaskActor(task: any, actorId: string) {
if (task.assignedAdminId === actorId) return
const actor = await prisma.adminUser.findUnique({ where: { id: actorId } })
if (!actor || !['SUPER_ADMIN', 'ADMIN'].includes(actor.role)) throw new ForbiddenError('Only the assigned collections admin may complete this task')
}
export async function recordCallOutcome(taskId: string, data: { outcome: string; note?: string; promisedPaymentAt?: string; nextFollowUpAt?: string }, adminId: string) {
const task = await prisma.collectionsCallTask.findUnique({ where: { id: taskId }, include: { collectionsCase: true } })
if (!task) throw new NotFoundError('Collections task not found')
if (task.status !== 'OPEN') throw new ConflictError('Collections task is not open')
await assertTaskActor(task, adminId)
const completedAt = new Date()
return prisma.$transaction(async (tx: any) => {
const updated = await tx.collectionsCallTask.update({
where: { id: taskId },
data: { status: 'COMPLETED', outcome: data.outcome as any, note: data.note, promisedPaymentAt: data.promisedPaymentAt ? new Date(data.promisedPaymentAt) : null, nextFollowUpAt: data.nextFollowUpAt ? new Date(data.nextFollowUpAt) : null, completedByAdminId: adminId, completedAt },
})
if (data.outcome === 'PAYMENT_PROMISED' && data.nextFollowUpAt) {
await tx.collectionsCallTask.upsert({
where: { collectionsCaseId_taskType: { collectionsCaseId: task.collectionsCaseId, taskType: 'PAYMENT_PROMISE_FOLLOW_UP' } },
update: { status: 'OPEN', assignedAdminId: task.assignedAdminId, dueAt: new Date(data.nextFollowUpAt), outcome: null, completedAt: null, completedByAdminId: null },
create: { collectionsCaseId: task.collectionsCaseId, taskType: 'PAYMENT_PROMISE_FOLLOW_UP', assignedAdminId: task.assignedAdminId, billingContactId: task.billingContactId, dueAt: new Date(data.nextFollowUpAt), companyDefaultLocale: task.companyDefaultLocale, contactLocale: task.contactLocale, customerScript: task.customerScript },
})
}
await tx.collectionsEvent.create({ data: { collectionsCaseId: task.collectionsCaseId, companyId: task.collectionsCase.companyId, eventType: 'collections.call_outcome', idempotencyKey: `call-outcome:${task.id}`, actorType: 'admin', actorId: adminId, payload: { outcome: data.outcome, note: data.note ?? null, promisedPaymentAt: data.promisedPaymentAt ?? null } } })
return updated
})
}
export async function createCollectionsOverride(caseId: string, data: { type: string; reason: string; expiresAt: string; revisedSuspensionAt?: string; pauseSuspension: boolean; pauseNotifications: boolean }, adminId: string) {
const expiresAt = new Date(data.expiresAt)
if (expiresAt <= new Date() || expiresAt.getTime() > Date.now() + 90 * 24 * 60 * 60 * 1000) throw new ValidationError('Override expiry must be in the future and no more than 90 days away')
const value = await prisma.collectionsCase.findUnique({ where: { id: caseId } })
if (!value) throw new NotFoundError('Collections case not found')
const revisedSuspensionAt = data.revisedSuspensionAt ? new Date(data.revisedSuspensionAt) : null
if (revisedSuspensionAt && (revisedSuspensionAt <= value.originalExpirationAt || revisedSuspensionAt > expiresAt)) {
throw new ValidationError('Revised suspension time must be after the original expiration and no later than the override expiry')
}
const override = await prisma.$transaction(async (tx: any) => {
const active = await tx.collectionsOverride.findFirst({ where: { collectionsCaseId: caseId, status: 'ACTIVE', expiresAt: { gt: new Date() } } })
if (active) throw new ConflictError('Revoke the active collections override before creating another')
const override = await tx.collectionsOverride.create({ data: { collectionsCaseId: caseId, type: data.type as any, reason: data.reason, expiresAt, revisedSuspensionAt, pauseSuspension: data.pauseSuspension, pauseNotifications: data.pauseNotifications, createdByAdminId: adminId } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseId, companyId: value.companyId, eventType: 'collections.override_created', idempotencyKey: `override:${override.id}:created`, actorType: 'admin', actorId: adminId, payload: { overrideId: override.id, type: override.type, expiresAt } } })
return override
}, { isolationLevel: 'Serializable' as any })
await sendOverrideChangedNotification(caseId, override.id, 'created')
return override
}
export async function revokeCollectionsOverride(caseId: string, overrideId: string, adminId: string) {
const value = await prisma.collectionsOverride.findFirst({ where: { id: overrideId, collectionsCaseId: caseId, status: 'ACTIVE' }, include: { collectionsCase: true } })
if (!value) throw new NotFoundError('Active collections override not found')
const updated = await prisma.$transaction(async (tx: any) => {
const updated = await tx.collectionsOverride.update({ where: { id: overrideId }, data: { status: 'REVOKED', revokedByAdminId: adminId, revokedAt: new Date() } })
await tx.collectionsCase.update({ where: { id: caseId }, data: { nextActionAt: new Date() } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseId, companyId: value.collectionsCase.companyId, eventType: 'collections.override_revoked', idempotencyKey: `override:${overrideId}:revoked`, actorType: 'admin', actorId: adminId, payload: { overrideId } } })
return updated
})
await sendOverrideChangedNotification(caseId, overrideId, 'revoked')
return updated
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
communicationSettingsSchema,
createManualPaymentSubmissionSchema,
manualCheckoutSchema,
} from './subscription.schemas'
import { addBillingPeriod, normalizeExternalReference } from './subscription.manual.service'
describe('manual subscription payment contracts', () => {
it('accepts only bank transfer and check manual checkout', () => {
const base = { plan: 'GROWTH', billingPeriod: 'ANNUAL', currency: 'MAD', idempotencyKey: crypto.randomUUID() }
expect(manualCheckoutSchema.safeParse({ ...base, method: 'BANK_TRANSFER' }).success).toBe(true)
expect(manualCheckoutSchema.safeParse({ ...base, method: 'STRIPE' }).success).toBe(false)
})
it('normalizes references without discarding the display value', () => {
const submittedReference = ' bank txn-123 '
const parsed = createManualPaymentSubmissionSchema.parse({ method: 'BANK_TRANSFER', submittedReference, idempotencyKey: crypto.randomUUID() })
expect(parsed.submittedReference).toBe('bank txn-123')
expect(normalizeExternalReference(parsed.submittedReference)).toBe('BANK TXN-123')
})
it('enforces the company-enabled communication language set', () => {
const valid = {
timezone: 'Africa/Casablanca', reminderLocalTime: '09:00',
enabledCommunicationLocales: ['ar', 'fr'], defaultCommunicationLocale: 'fr',
contacts: [{ email: 'billing@example.ma', locale: 'ar', isPrimary: true, receivePaymentNotices: true, isActive: true }],
}
expect(communicationSettingsSchema.safeParse(valid).success).toBe(true)
expect(communicationSettingsSchema.safeParse({ ...valid, defaultCommunicationLocale: 'en' }).success).toBe(false)
expect(communicationSettingsSchema.safeParse({ ...valid, contacts: [{ ...valid.contacts[0], locale: 'en' }] }).success).toBe(false)
expect(communicationSettingsSchema.safeParse({ ...valid, enabledCommunicationLocales: [] }).success).toBe(false)
})
it('clamps month-end and leap-year billing periods', () => {
expect(addBillingPeriod(new Date('2028-01-31T12:00:00.000Z'), 'MONTHLY').toISOString()).toBe('2028-02-29T12:00:00.000Z')
expect(addBillingPeriod(new Date('2028-02-29T12:00:00.000Z'), 'ANNUAL').toISOString()).toBe('2029-02-28T12:00:00.000Z')
})
})
@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createManualPaymentSubmission, submitManualPaymentSubmission } from './subscription.manual.service'
import { prisma } from '../../lib/prisma'
import { sendNotification } from '../../services/notificationService'
vi.mock('../../lib/prisma', () => ({
prisma: {
$transaction: vi.fn(),
billingAccount: { findFirst: vi.fn(), findUnique: vi.fn(), findUniqueOrThrow: vi.fn() },
billingInvoice: { findFirst: vi.fn() },
manualPaymentSubmission: { findUnique: vi.fn(), findFirst: vi.fn(), upsert: vi.fn() },
adminUser: { findMany: vi.fn() },
},
}))
vi.mock('../../services/notificationService', () => ({
sendNotification: vi.fn(),
}))
vi.mock('../../lib/storage', () => ({
deletePrivateDocument: vi.fn(),
promotePaymentEvidence: vi.fn(),
readPrivateDocument: vi.fn(),
storePaymentEvidenceInQuarantine: vi.fn(),
}))
vi.mock('../../services/paymentEvidenceScanner', () => ({
scanPaymentEvidenceFile: vi.fn(),
}))
describe('manual subscription payment submissions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('treats an already submitted payment package as idempotent without sending another notice', async () => {
const submission = {
id: 'submission_1',
invoiceId: 'invoice_1',
billingAccountId: 'billing_1',
companyId: 'company_1',
method: 'BANK_TRANSFER',
submittedReference: 'bank ref 123',
status: 'SUBMITTED',
submittedAt: new Date('2026-08-10T12:00:00.000Z'),
reviewedAt: null,
rejectionReason: null,
documents: [{ id: 'doc_1', kind: 'BANK_TRANSFER_RECEIPT', originalFilename: 'receipt.pdf', detectedMimeType: 'application/pdf', byteSize: 1024, scanStatus: 'CLEAN', uploadedAt: new Date('2026-08-10T12:01:00.000Z') }],
invoice: { invoiceNumber: 'INV-2026-000001' },
}
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback({
manualPaymentSubmission: {
findFirst: vi.fn().mockResolvedValue(submission),
},
}))
await expect(submitManualPaymentSubmission('company_1', 'submission_1')).resolves.toMatchObject({
id: 'submission_1',
status: 'SUBMITTED',
documents: [{ id: 'doc_1', scanStatus: 'CLEAN' }],
})
expect(sendNotification).not.toHaveBeenCalled()
})
it('reuses the active submission created by a concurrent request after the invoice-level unique guard fires', async () => {
const concurrentSubmission = {
id: 'submission_2',
invoiceId: 'invoice_1',
billingAccountId: 'billing_1',
companyId: 'company_1',
method: 'BANK_TRANSFER',
submittedReference: 'bank ref 456',
normalizedSubmittedReference: 'BANK REF 456',
status: 'DRAFT',
submittedAt: null,
reviewedAt: null,
rejectionReason: null,
documents: [],
}
vi.mocked(prisma.billingAccount.findFirst).mockResolvedValue({
id: 'billing_1',
billingContacts: [{ id: 'contact_1' }],
} as never)
vi.mocked(prisma.billingAccount.findUniqueOrThrow).mockResolvedValue({
id: 'billing_1',
billingContacts: [{ id: 'contact_1' }],
} as never)
vi.mocked(prisma.billingInvoice.findFirst).mockResolvedValue({
id: 'invoice_1',
companyId: 'company_1',
billingAccountId: 'billing_1',
status: 'OPEN',
collectionMethod: 'BANK_TRANSFER',
} as never)
vi.mocked(prisma.manualPaymentSubmission.findUnique).mockResolvedValue(null as never)
vi.mocked(prisma.manualPaymentSubmission.findFirst)
.mockResolvedValueOnce(null as never)
.mockResolvedValueOnce(concurrentSubmission as never)
vi.mocked(prisma.manualPaymentSubmission.upsert).mockRejectedValueOnce({ code: 'P2002' })
await expect(createManualPaymentSubmission('company_1', 'employee_1', 'invoice_1', {
method: 'BANK_TRANSFER',
submittedReference: ' bank ref 456 ',
idempotencyKey: 'idem_1',
})).resolves.toMatchObject({
id: 'submission_2',
status: 'DRAFT',
submittedReference: 'bank ref 456',
})
})
it('sends detailed customer and admin notifications when payment evidence is submitted', async () => {
const submittedAt = new Date('2026-08-10T12:00:00.000Z')
const draftSubmission = {
id: 'submission_3',
invoiceId: 'invoice_3',
billingAccountId: 'billing_3',
companyId: 'company_3',
method: 'CHECK',
submittedReference: 'check-7788',
status: 'DRAFT',
submittedAt: null,
reviewedAt: null,
rejectionReason: null,
documents: [{ id: 'doc_3', kind: 'CHECK_COPY', originalFilename: 'check.jpg', detectedMimeType: 'image/jpeg', byteSize: 2048, scanStatus: 'CLEAN', uploadedAt: submittedAt }],
invoice: {
id: 'invoice_3',
invoiceNumber: 'INV-2026-000003',
status: 'OPEN',
subscriptionId: 'subscription_3',
},
}
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback({
manualPaymentSubmission: {
findFirst: vi.fn().mockResolvedValue(draftSubmission),
update: vi.fn().mockResolvedValue({ ...draftSubmission, status: 'SUBMITTED', submittedAt }),
},
billingEvent: { create: vi.fn().mockResolvedValue({ id: 'event_1' }) },
}))
vi.mocked(prisma.billingAccount.findUnique).mockResolvedValue({
id: 'billing_3',
defaultCommunicationLocale: 'en',
enabledCommunicationLocales: ['en'],
timezone: 'Africa/Casablanca',
billingContacts: [{ id: 'contact_3', employeeId: null, locale: 'en', employee: null }],
} as never)
vi.mocked(prisma.manualPaymentSubmission.findUnique).mockResolvedValue({
...draftSubmission,
status: 'SUBMITTED',
submittedAt,
submittedByEmployee: { email: 'owner@example.test' },
documents: draftSubmission.documents,
invoice: {
id: 'invoice_3',
invoiceNumber: 'INV-2026-000003',
amountDue: 14900,
currency: 'MAD',
requestedPlan: 'STARTER',
requestedBillingPeriod: 'MONTHLY',
dueAt: new Date('2026-08-24T00:00:00.000Z'),
company: { name: 'Atlas car' },
subscription: { currentPeriodStart: null, currentPeriodEnd: null },
lineItems: [{ periodStart: new Date('2026-08-10T00:00:00.000Z'), periodEnd: new Date('2026-09-10T00:00:00.000Z') }],
},
} as never)
vi.mocked(prisma.adminUser.findMany).mockResolvedValue([{ id: 'admin_finance', preferredLocale: 'en' }] as never)
await submitManualPaymentSubmission('company_3', 'submission_3')
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
billingContactId: 'contact_3',
body: expect.stringContaining('Payment type: Check'),
data: expect.objectContaining({ amountDue: 14900, paymentType: 'CHECK' }),
}))
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
adminUserId: 'admin_finance',
channels: ['IN_APP', 'EMAIL'],
title: 'Payment evidence submitted: INV-2026-000003',
body: expect.stringContaining('Action required: open Admin Billing'),
}))
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../services/stripeService', () => ({
getConfigurationStatus: vi.fn(),
}))
import { getConfigurationStatus } from '../../services/stripeService'
import { getPaymentOptions } from './subscription.payment-config'
const ENV_KEYS = [
'MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED',
'MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED',
'PAYMENT_EVIDENCE_SCANNER_MODE',
'PAYMENT_EVIDENCE_SCANNER_PATH',
'BANK_TRANSFER_ENABLED',
'BANK_TRANSFER_ACCOUNT_NAME',
'BANK_TRANSFER_BANK_NAME',
'BANK_TRANSFER_ACCOUNT_REFERENCE',
'CHECK_PAYMENT_ENABLED',
'CHECK_PAYMENT_PAYEE',
'CHECK_PAYMENT_DELIVERY_ADDRESS',
'NODE_ENV',
] as const
describe('subscription payment configuration', () => {
const originalEnv = new Map<string, string | undefined>()
beforeEach(() => {
vi.mocked(getConfigurationStatus).mockReturnValue({ configured: true, problems: [] })
for (const key of ENV_KEYS) originalEnv.set(key, process.env[key])
})
afterEach(() => {
for (const key of ENV_KEYS) {
const value = originalEnv.get(key)
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
originalEnv.clear()
vi.clearAllMocks()
})
it('exposes local manual payment methods when evidence scanning is ready', () => {
process.env.NODE_ENV = 'development'
expect(getPaymentOptions('en').methods).toEqual([
{ method: 'STRIPE', enabled: true },
{
method: 'BANK_TRANSFER',
enabled: true,
instructions: {
accountName: 'RentalDriveGo SARL',
bankName: 'Local Development Bank',
accountReference: 'DEV-MA64-0000-0000-0000',
message: 'Enter the bank transfer reference and upload the transfer receipt.',
},
},
{
method: 'CHECK',
enabled: true,
instructions: {
payee: 'RentalDriveGo SARL',
deliveryAddress: 'Local development billing desk',
message: 'Enter the check number and upload a check copy.',
},
},
])
})
it('hides manual methods in production when the evidence pipeline is not ready', () => {
process.env.NODE_ENV = 'production'
process.env.MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED = 'true'
process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED = 'true'
process.env.BANK_TRANSFER_ENABLED = 'true'
process.env.CHECK_PAYMENT_ENABLED = 'true'
expect(getPaymentOptions('en').methods).toEqual([{ method: 'STRIPE', enabled: true }])
})
})
@@ -0,0 +1,91 @@
import { ValidationError } from '../../http/errors'
import { getConfigurationStatus as getStripeConfigurationStatus } from '../../services/stripeService'
import type { NotificationLocale } from '../../services/notificationLocalizationService'
export type ManualCollectionMethod = 'BANK_TRANSFER' | 'CHECK'
const isProduction = () => process.env.NODE_ENV === 'production'
const useLocalDefaults = () => !isProduction()
const enabled = (value: string | undefined, defaultValue = false) => value === undefined ? defaultValue : value === 'true'
const localDefault = (value: string | undefined, fallback: string) => value?.trim() || (useLocalDefaults() ? fallback : '')
const messages: Record<NotificationLocale, Record<ManualCollectionMethod, string>> = {
en: {
BANK_TRANSFER: 'Enter the bank transfer reference and upload the transfer receipt.',
CHECK: 'Enter the check number and upload a check copy.',
},
fr: {
BANK_TRANSFER: 'Saisissez la référence du virement et téléversez le reçu.',
CHECK: 'Saisissez le numéro du chèque et téléversez une copie.',
},
ar: {
BANK_TRANSFER: 'أدخل مرجع التحويل البنكي وارفع إيصال التحويل.',
CHECK: 'أدخل رقم الشيك وارفع نسخة من الشيك.',
},
}
export function manualPaymentFeatureEnabled() {
return enabled(process.env.MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED, useLocalDefaults())
}
export function paymentEvidenceUploadEnabled() {
return manualPaymentFeatureEnabled() && enabled(process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED, useLocalDefaults())
}
export function paymentEvidencePipelineReady() {
if (!paymentEvidenceUploadEnabled()) return false
if (process.env.PAYMENT_EVIDENCE_SCANNER_PATH?.trim()) return true
return !isProduction() && (process.env.PAYMENT_EVIDENCE_SCANNER_MODE === undefined || process.env.PAYMENT_EVIDENCE_SCANNER_MODE === 'stub-clean')
}
export function getPaymentOptions(locale: NotificationLocale = 'en') {
const stripeStatus = getStripeConfigurationStatus()
const methods: Array<Record<string, unknown>> = [{ method: 'STRIPE', enabled: stripeStatus.configured }]
// A manual method is unusable unless evidence can be scanned and submitted.
// Keeping it hidden also makes the rollout fail closed when the scanner is
// accidentally omitted from a production deployment.
if (!manualPaymentFeatureEnabled() || !paymentEvidencePipelineReady()) return { methods }
if (enabled(process.env.BANK_TRANSFER_ENABLED, useLocalDefaults())) {
const accountName = localDefault(process.env.BANK_TRANSFER_ACCOUNT_NAME, 'RentalDriveGo SARL')
const bankName = localDefault(process.env.BANK_TRANSFER_BANK_NAME, 'Local Development Bank')
const accountReference = localDefault(process.env.BANK_TRANSFER_ACCOUNT_REFERENCE, 'DEV-MA64-0000-0000-0000')
if (accountName && bankName && accountReference) {
methods.push({
method: 'BANK_TRANSFER',
enabled: true,
instructions: { accountName, bankName, accountReference, message: messages[locale].BANK_TRANSFER },
})
}
}
if (enabled(process.env.CHECK_PAYMENT_ENABLED, useLocalDefaults())) {
const payee = localDefault(process.env.CHECK_PAYMENT_PAYEE, 'RentalDriveGo SARL')
const deliveryAddress = localDefault(process.env.CHECK_PAYMENT_DELIVERY_ADDRESS, 'Local development billing desk')
if (payee && deliveryAddress) {
methods.push({
method: 'CHECK',
enabled: true,
instructions: { payee, deliveryAddress, message: messages[locale].CHECK },
})
}
}
return { methods }
}
export function requireManualMethodEnabled(method: ManualCollectionMethod, locale: NotificationLocale = 'en') {
const option = getPaymentOptions(locale).methods.find((item) => item.method === method && item.enabled === true)
if (!option) throw new ValidationError(`${method === 'BANK_TRANSFER' ? 'Bank transfer' : 'Check'} payments are not available`)
return option
}
export function manualPaymentDueDays(method: ManualCollectionMethod) {
const raw = method === 'CHECK'
? process.env.CHECK_PAYMENT_DUE_DAYS
: process.env.BANK_TRANSFER_DUE_DAYS
const fallback = method === 'CHECK' ? 14 : 7
const value = Number(raw ?? fallback)
return Number.isInteger(value) && value >= 1 && value <= 60 ? value : fallback
}
@@ -3,8 +3,9 @@ import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { parseBody, parseParams } from '../../http/validate'
import { created, ok } from '../../http/respond'
import { paymentEvidenceUpload } from '../../http/upload/paymentEvidence'
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
@@ -17,7 +18,15 @@ import {
startTrialSchema,
cancelSchema,
reactivateSchema,
manualCheckoutSchema,
invoiceIdParamSchema,
submissionIdParamSchema,
submissionDocumentParamsSchema,
createManualPaymentSubmissionSchema,
manualPaymentDocumentFieldsSchema,
communicationSettingsSchema,
} from './subscription.schemas'
import * as manualService from './subscription.manual.service'
const publicRouter = Router()
const webhookRouter = Router()
@@ -83,7 +92,24 @@ router.get('/me', async (req, res, next) => {
})
router.get('/invoices', async (req, res, next) => {
try { ok(res, await service.getInvoices(req.companyId)) } catch (err) { next(err) }
try { ok(res, await manualService.getCanonicalInvoices(req.companyId)) } catch (err) { next(err) }
})
router.get('/invoices/:invoiceId/pdf', requireRole('OWNER'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { pdfBuffer, invoiceNumber } = await manualService.getPaidInvoicePdf(req.companyId, invoiceId)
const safeNumber = invoiceNumber.replace(/["\\\r\n]/g, '_')
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${safeNumber}.pdf"`)
res.setHeader('Content-Length', pdfBuffer.length)
res.setHeader('Cache-Control', 'private, no-store')
res.end(pdfBuffer)
} catch (err) { next(err) }
})
router.get('/payment-options', async (req, res, next) => {
try { ok(res, await manualService.getCompanyPaymentOptions(req.companyId, req.employee.id)) } catch (err) { next(err) }
})
router.get('/events', async (req, res, next) => {
@@ -108,6 +134,83 @@ router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/manual-checkout', requireRole('OWNER'), async (req, res, next) => {
try {
created(res, await manualService.createManualCheckout(
req.companyId,
req.employee.id,
parseBody(manualCheckoutSchema, req),
))
} catch (err) { next(err) }
})
router.post('/invoices/:invoiceId/manual-payment-submissions', requireRole('OWNER'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
created(res, await manualService.createManualPaymentSubmission(
req.companyId,
req.employee.id,
invoiceId,
parseBody(createManualPaymentSubmissionSchema, req),
))
} catch (err) { next(err) }
})
router.post(
'/manual-payment-submissions/:submissionId/documents',
requireRole('OWNER'),
paymentEvidenceUpload.single('file'),
async (req, res, next) => {
try {
const { submissionId } = parseParams(submissionIdParamSchema, req)
const { kind } = parseBody(manualPaymentDocumentFieldsSchema, req)
created(res, await manualService.uploadManualPaymentDocument(req.companyId, req.employee.id, submissionId, kind, req.file))
} catch (err) { next(err) }
},
)
router.delete('/manual-payment-submissions/:submissionId/documents/:documentId', requireRole('OWNER'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(submissionDocumentParamsSchema, req)
ok(res, await manualService.deleteManualPaymentDocument(req.companyId, submissionId, documentId))
} catch (err) { next(err) }
})
router.post('/manual-payment-submissions/:submissionId/submit', requireRole('OWNER'), async (req, res, next) => {
try {
const { submissionId } = parseParams(submissionIdParamSchema, req)
ok(res, await manualService.submitManualPaymentSubmission(req.companyId, submissionId))
} catch (err) { next(err) }
})
router.get('/manual-payment-submissions/:submissionId/documents/:documentId', requireRole('OWNER'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(submissionDocumentParamsSchema, req)
const { document, bytes } = await manualService.getCustomerPaymentDocument(req.companyId, submissionId, documentId)
const safeName = document.originalFilename.replace(/["\\\r\n]/g, '_')
res.setHeader('Content-Type', document.detectedMimeType)
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`)
res.setHeader('Content-Length', bytes.length)
res.setHeader('Cache-Control', 'private, no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.end(bytes)
} catch (err) { next(err) }
})
router.get('/communication-settings', requireRole('OWNER'), async (req, res, next) => {
try { ok(res, await manualService.getCommunicationSettings(req.companyId, req.employee.id)) } catch (err) { next(err) }
})
router.put('/communication-settings', requireRole('OWNER'), async (req, res, next) => {
try {
ok(res, await manualService.updateCommunicationSettings(
req.companyId,
req.employee.id,
parseBody(communicationSettingsSchema, req),
))
} catch (err) { next(err) }
})
router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(reactivateSchema, req)
@@ -4,6 +4,13 @@ const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
const providerEnum = z.enum(['STRIPE'])
const currencyEnum = z.enum(['MAD', 'EUR', 'USD'])
const manualMethodEnum = z.enum(['BANK_TRANSFER', 'CHECK'])
const localeEnum = z.enum(['ar', 'en', 'fr'])
const referenceSchema = z.string()
.trim()
.min(3)
.max(120)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters')
export const checkoutSchema = z.object({
plan: planEnum,
@@ -43,3 +50,60 @@ export const reactivateSchema = z.object({
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
export const manualCheckoutSchema = z.object({
plan: planEnum,
billingPeriod: billingPeriodEnum,
currency: z.literal('MAD'),
method: manualMethodEnum,
idempotencyKey: z.string().uuid(),
})
export const invoiceIdParamSchema = z.object({ invoiceId: z.string().min(1) })
export const submissionIdParamSchema = z.object({ submissionId: z.string().min(1) })
export const submissionDocumentParamsSchema = z.object({
submissionId: z.string().min(1),
documentId: z.string().min(1),
})
export const createManualPaymentSubmissionSchema = z.object({
method: manualMethodEnum,
submittedReference: referenceSchema,
idempotencyKey: z.string().uuid(),
})
export const manualPaymentDocumentFieldsSchema = z.object({
kind: z.enum(['BANK_TRANSFER_RECEIPT', 'CHECK_COPY', 'OTHER_SUPPORTING_EVIDENCE']),
})
export const billingContactInputSchema = z.object({
id: z.string().min(1).optional(),
employeeId: z.string().min(1).nullable().optional(),
email: z.string().email().max(255).trim().toLowerCase(),
locale: localeEnum.nullable().optional(),
isPrimary: z.boolean(),
receivePaymentNotices: z.boolean().default(true),
isActive: z.boolean().default(true),
})
export const communicationSettingsSchema = z.object({
timezone: z.string().min(1).max(100),
reminderLocalTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
enabledCommunicationLocales: z.array(localeEnum).min(1).max(3).transform((values) => Array.from(new Set(values))),
defaultCommunicationLocale: localeEnum,
contacts: z.array(billingContactInputSchema).min(1).max(20),
}).superRefine((data, ctx) => {
if (!data.enabledCommunicationLocales.includes(data.defaultCommunicationLocale)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultCommunicationLocale'], message: 'Default locale must be enabled' })
}
data.contacts.forEach((contact, index) => {
if (contact.locale && !data.enabledCommunicationLocales.includes(contact.locale)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contacts', index, 'locale'], message: 'Contact locale must be enabled' })
}
})
if (!data.contacts.some((contact) => contact.isActive && contact.isPrimary && contact.receivePaymentNotices)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contacts'], message: 'An active primary payment contact is required' })
}
})
export { manualMethodEnum, localeEnum, referenceSchema }
@@ -54,6 +54,12 @@ vi.mock('./subscription.repo', () => ({
setSuspended: vi.fn(),
}))
vi.mock('./subscription.manual.service', () => ({
createCanonicalStripeCheckoutInvoice: vi.fn(),
finalizeCanonicalOnlinePayment: vi.fn().mockResolvedValue(false),
recordCanonicalOnlinePaymentFailure: vi.fn().mockResolvedValue(false),
}))
vi.mock('../../security/webhookIdempotency', () => ({
getWebhookEventId: vi.fn((provider: string, event: any) => event.id ?? event.transaction_id ?? `${provider}_event`),
processWebhookOnce: vi.fn(async ({ handle }: { handle: () => Promise<unknown> }) => {
@@ -67,6 +73,7 @@ import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as stripe from '../../services/stripeService'
import * as repo from './subscription.repo'
import * as manualService from './subscription.manual.service'
import * as service from './subscription.service'
describe('subscription.service operational edges', () => {
@@ -131,7 +138,7 @@ describe('subscription.service operational edges', () => {
vi.mocked(repo.findOrCreateSubscription).mockResolvedValue({ id: 'sub_1' } as never)
vi.mocked(stripe.isConfigured).mockReturnValue(true)
vi.mocked(stripe.createCheckoutSession).mockResolvedValue({ checkoutUrl: 'https://checkout.stripe.test/session', sessionId: 'cs_test_123' } as never)
vi.mocked(repo.createInvoice).mockResolvedValue({ id: 'invoice_1' } as never)
vi.mocked(manualService.createCanonicalStripeCheckoutInvoice).mockResolvedValue({ id: 'invoice_1' } as never)
await expect(service.checkout('company_1', {
plan: 'GROWTH',
@@ -150,15 +157,12 @@ describe('subscription.service operational edges', () => {
subscriptionId: 'sub_1',
type: 'SUBSCRIPTION',
}))
expect(repo.createInvoice).toHaveBeenCalledWith(expect.objectContaining({
expect(manualService.createCanonicalStripeCheckoutInvoice).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1',
subscriptionId: 'sub_1',
requestedPlan: 'GROWTH',
requestedBillingPeriod: 'MONTHLY',
plan: 'GROWTH',
billingPeriod: 'MONTHLY',
amount: 19900,
paymentProvider: 'STRIPE',
amanpayTransactionId: null,
paypalCaptureId: null,
stripeCheckoutSessionId: 'cs_test_123',
dueAt: new Date('2026-06-08T00:00:00.000Z'),
}))
@@ -7,6 +7,11 @@ import * as stripe from '../../services/stripeService'
import * as repo from './subscription.repo'
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
import {
createCanonicalStripeCheckoutInvoice,
finalizeCanonicalOnlinePayment,
recordCanonicalOnlinePaymentFailure,
} from './subscription.manual.service'
// ─── Helpers ──────────────────────────────────────────────────
@@ -114,6 +119,7 @@ async function handlePaymentSuccess(subscriptionId: string, invoiceId: string, p
billingPeriod?: string | null
currency?: string | null
}) {
if (await finalizeCanonicalOnlinePayment(invoiceId)) return
const sub = await repo.findById(subscriptionId)
if (!sub) return
@@ -144,6 +150,7 @@ async function handlePaymentFailure(
failureCode?: string,
failureMessage?: string,
) {
if (await recordCanonicalOnlinePaymentFailure(invoiceId, failureCode, failureMessage)) return
const sub = await repo.findById(subscriptionId)
if (!sub) return
@@ -274,11 +281,6 @@ export async function checkout(companyId: string, body: {
const orderId = `sub-${companyId}-${Date.now()}`
const description = `${body.plan} plan — ${body.billingPeriod}`
let checkoutUrl: string
let amanpayTransactionId: string | null = null
let paypalCaptureId: string | null = null
let stripeCheckoutSessionId: string | null = null
if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured on this platform')
const result = await stripe.createCheckoutSession({
amount, currency: body.currency, orderId, description,
@@ -289,18 +291,19 @@ export async function checkout(companyId: string, body: {
subscriptionId: subscription.id,
type: 'SUBSCRIPTION',
})
checkoutUrl = result.checkoutUrl
stripeCheckoutSessionId = result.sessionId
const dueAt = new Date(Date.now() + SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000)
const invoice = await repo.createInvoice({
companyId, subscriptionId: subscription.id,
requestedPlan: body.plan,
requestedBillingPeriod: body.billingPeriod,
amount, currency: body.currency,
paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, stripeCheckoutSessionId, dueAt,
const invoice = await createCanonicalStripeCheckoutInvoice({
companyId,
subscriptionId: subscription.id,
plan: body.plan,
billingPeriod: body.billingPeriod,
amount,
currency: body.currency,
stripeCheckoutSessionId: result.sessionId,
dueAt,
})
return { invoice, checkoutUrl }
return { invoice, checkoutUrl: result.checkoutUrl }
}
export async function capturePaypal(companyId: string, paypalOrderId: string) {
@@ -72,14 +72,19 @@ describe('invoicePdfService', () => {
subtotalAmount: 80000,
discountAmount: 0,
creditAmount: 0,
taxAmount: 0,
totalAmount: 80000,
taxRate: 20,
taxAmount: 16000,
totalAmount: 96000,
amountPaid: 0,
amountDue: 80000,
amountDue: 96000,
},
})
expect(result.subarray(0, 8).toString()).toBe('%PDF-1.4')
expect(result.toString()).toContain('INV-2026-000002')
const pdf = result.toString()
expect(pdf).toContain('INV-2026-000002')
expect(pdf).toContain('Price before tax: 800.00 MAD')
expect(pdf).toContain('Tax \\(20%\\): 160.00 MAD')
expect(pdf).toContain('TTC: 960.00 MAD')
})
})
+24 -17
View File
@@ -25,6 +25,7 @@ interface InvoiceData {
transactionId?: string | null
paidAt?: string | null
lineItems?: Array<{
type?: string | null
description: string
amount: number
currency: string
@@ -37,6 +38,7 @@ interface InvoiceData {
subtotalAmount: number
discountAmount: number
creditAmount: number
taxRate?: number | null
taxAmount: number
totalAmount: number
amountPaid: number
@@ -340,13 +342,19 @@ function pdfText(value: unknown) {
.replace(/[^\x20-\x7E]/g, '')
}
function fmtTaxRate(value?: number | null) {
if (typeof value !== 'number' || !Number.isFinite(value)) return '0%'
return `${Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, '')}%`
}
function InvoiceDocument({ data }: { data: InvoiceData }) {
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
const addressStr = formatAddress(data.company.address)
const planLabel = data.subscription ? (PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan) : 'Manual'
const periodLabel = data.subscription ? (PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod) : 'Custom'
const lineItems = data.lineItems?.length
? data.lineItems
const invoiceLineItems = data.lineItems?.filter((item) => item.type !== 'TAX') ?? []
const lineItems = invoiceLineItems.length
? invoiceLineItems
: [{
description: `${planLabel} Plan — ${periodLabel} Subscription`,
amount: data.amount,
@@ -490,7 +498,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
React.createElement(Text, { style: s.totalLabel }, 'Price before tax'),
React.createElement(Text, { style: s.totalValue }, fmt(totals.subtotalAmount, data.currency)),
),
totals.discountAmount > 0
@@ -509,18 +517,16 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`),
)
: null,
totals.taxAmount > 0
? React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Tax'),
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
)
: null,
React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, `Tax (${fmtTaxRate(totals.taxRate)})`),
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
),
React.createElement(
View,
{ style: s.grandTotalRow },
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
React.createElement(Text, { style: s.grandTotalLabel }, 'TTC'),
React.createElement(Text, { style: s.grandTotalValue }, fmt(totals.totalAmount, data.currency)),
),
@@ -589,8 +595,9 @@ function pdfEscape(value: unknown) {
}
function buildSimpleInvoicePdf(data: InvoiceData) {
const lineItems = data.lineItems?.length
? data.lineItems
const invoiceLineItems = data.lineItems?.filter((item) => item.type !== 'TAX') ?? []
const lineItems = invoiceLineItems.length
? invoiceLineItems
: [{ description: 'Invoice charge', amount: data.amount, currency: data.currency }]
const totals = data.totals ?? {
subtotalAmount: data.amount,
@@ -616,11 +623,11 @@ function buildSimpleInvoicePdf(data: InvoiceData) {
'Line Items',
...lineItems.map((item) => `${item.description} - ${fmt(item.amount, item.currency)}`),
'',
`Subtotal: ${fmt(totals.subtotalAmount, data.currency)}`,
`Price before tax: ${fmt(totals.subtotalAmount, data.currency)}`,
totals.discountAmount ? `Discounts: -${fmt(totals.discountAmount, data.currency)}` : null,
totals.creditAmount ? `Credits: -${fmt(totals.creditAmount, data.currency)}` : null,
totals.taxAmount ? `Tax: ${fmt(totals.taxAmount, data.currency)}` : null,
`Total: ${fmt(totals.totalAmount, data.currency)}`,
`Tax (${fmtTaxRate(totals.taxRate)}): ${fmt(totals.taxAmount, data.currency)}`,
`TTC: ${fmt(totals.totalAmount, data.currency)}`,
`Amount Paid: ${fmt(totals.amountPaid, data.currency)}`,
`Amount Due: ${fmt(totals.amountDue, data.currency)}`,
'',
@@ -88,6 +88,8 @@ describe('notificationService command boundaries', () => {
recipientType: 'EMPLOYEE',
employeeId: 'employee_1',
renterId: null,
billingContactId: null,
adminUserId: null,
},
})
expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({
@@ -141,7 +143,87 @@ describe('notificationService command boundaries', () => {
channels: ['IN_APP'],
})
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'An explicit employee or renter recipient is required' }])
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'An explicit employee, renter, billing contact, or admin recipient is required' }])
expect(prismaMock.tx.notificationEvent.create).not.toHaveBeenCalled()
})
it('treats placeholder email provider secrets as not configured', async () => {
const previousEnv = {
RESEND_API_KEY: process.env.RESEND_API_KEY,
EMAIL_FROM: process.env.EMAIL_FROM,
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
MAIL_HOST: process.env.MAIL_HOST,
MAIL_PORT: process.env.MAIL_PORT,
MAIL_USERNAME: process.env.MAIL_USERNAME,
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
MAIL_FROM_ADDRESS: process.env.MAIL_FROM_ADDRESS,
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
}
const resendConstructor = vi.fn()
try {
process.env.EMAIL_PROVIDER = 'gmail'
process.env.RESEND_API_KEY = 're_PLACEHOLDER'
process.env.EMAIL_FROM = 'noreply@example.com'
process.env.EMAIL_FROM_NAME = 'Example App'
process.env.MAIL_HOST = 'smtp.gmail.com'
process.env.MAIL_PORT = '587'
process.env.MAIL_USERNAME = 'rentaldrivego@gmail.com'
process.env.MAIL_PASSWORD = 'placeholder'
process.env.MAIL_FROM_ADDRESS = 'rentaldrivego@gmail.com'
process.env.MAIL_FROM_NAME = 'RentalDriveGo'
vi.resetModules()
vi.doMock('resend', () => ({ Resend: resendConstructor }))
vi.doMock('../lib/prisma', () => ({ prisma: prismaMock.prisma }))
vi.doMock('./notificationLocalizationService', async () => {
const actual = await vi.importActual<typeof import('./notificationLocalizationService')>('./notificationLocalizationService')
return {
...actual,
resolveNotificationLocale: vi.fn().mockResolvedValue('en'),
resolveNotificationTemplate: vi.fn(),
}
})
const service = await import('./notificationService')
expect(resendConstructor).not.toHaveBeenCalled()
expect(service.describeEmailProviderConfig()).toEqual({
selectedProvider: 'smtp',
resend: {
apiKey: 'placeholder',
sender: 'not configured',
enabled: false,
},
smtp: {
host: 'configured',
port: 'configured',
username: 'configured',
password: 'placeholder',
sender: 'configured',
transport: 'not configured',
issues: [
'MAIL_PASSWORD is a placeholder; set a Gmail app password',
'SMTP transport is not configured',
],
},
})
await expect(service.sendTransactionalEmail({
to: 'owner@example.test',
subject: 'Verify',
html: '<p>Verify</p>',
text: 'Verify',
})).rejects.toThrow('SMTP email provider is selected but not configured: MAIL_PASSWORD is a placeholder; set a Gmail app password')
} finally {
Object.entries(previousEnv).forEach(([key, value]) => {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
})
vi.resetModules()
}
})
})
+399 -32
View File
@@ -3,47 +3,77 @@ import { prisma } from '../lib/prisma'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
import {
renderLocalizedEmailHtml,
coerceNotificationLocale,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
import type { NotificationTemplateVariables } from './notificationLocalizationService'
import { generateInvoicePdf } from './invoicePdfService'
const resendApiKey =
process.env.RESEND_API_KEY &&
process.env.RESEND_API_KEY !== 're_...' &&
process.env.RESEND_API_KEY.startsWith('re_')
? process.env.RESEND_API_KEY
: null
function envValue(name: string) {
const value = process.env[name]?.trim()
return value ? value : null
}
const resend = resendApiKey ? new Resend(resendApiKey) : null
function isPlaceholderEnvValue(value: string | null) {
if (!value) return true
const normalized = value.trim().toLowerCase()
return (
normalized.includes('${') ||
normalized.includes('placeholder') ||
normalized.includes('example') ||
normalized.startsWith('your-') ||
normalized === 'changeme' ||
normalized === 'change_me' ||
normalized === 're_...' ||
normalized === 're_placeholder'
)
}
function resolveEmailProvider() {
const value = envValue('EMAIL_PROVIDER')?.toLowerCase()
if (value === 'gmail') return 'smtp'
if (value === 'smtp' || value === 'resend') return value
return 'auto'
}
function resolveResendApiKey() {
const value = envValue('RESEND_API_KEY')
return value && value.startsWith('re_') && !isPlaceholderEnvValue(value) ? value : null
}
const emailProvider = resolveEmailProvider()
const resendApiKey = resolveResendApiKey()
const resend = emailProvider !== 'smtp' && resendApiKey ? new Resend(resendApiKey) : null
const emailFromAddress =
process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com'
? process.env.EMAIL_FROM
envValue('EMAIL_FROM') && !isPlaceholderEnvValue(envValue('EMAIL_FROM'))
? envValue('EMAIL_FROM')
: null
const emailFromName =
process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App'
? process.env.EMAIL_FROM_NAME
envValue('EMAIL_FROM_NAME') && !isPlaceholderEnvValue(envValue('EMAIL_FROM_NAME'))
? envValue('EMAIL_FROM_NAME')
: null
const smtpHost = process.env.MAIL_HOST
const smtpPort = Number(process.env.MAIL_PORT ?? 0)
const smtpUser = process.env.MAIL_USERNAME
const smtpPass = process.env.MAIL_PASSWORD
const smtpHost = envValue('MAIL_HOST')
const smtpPort = Number(envValue('MAIL_PORT') ?? 0)
const smtpUser = envValue('MAIL_USERNAME')
const smtpPass = envValue('MAIL_PASSWORD')
const smtpSecure =
process.env.MAIL_SCHEME === 'smtps' ||
process.env.MAIL_ENCRYPTION === 'ssl' ||
smtpPort === 465
const smtpFromAddress =
process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${')
? process.env.MAIL_FROM_ADDRESS
envValue('MAIL_FROM_ADDRESS') && !isPlaceholderEnvValue(envValue('MAIL_FROM_ADDRESS'))
? envValue('MAIL_FROM_ADDRESS')
: null
const smtpFromName =
process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${')
? process.env.MAIL_FROM_NAME
envValue('MAIL_FROM_NAME') && !isPlaceholderEnvValue(envValue('MAIL_FROM_NAME'))
? envValue('MAIL_FROM_NAME')
: null
type SmtpTransport = {
@@ -52,7 +82,7 @@ type SmtpTransport = {
let smtpTransport: SmtpTransport | null = null
if (smtpHost && smtpPort && smtpUser && smtpPass) {
if (smtpHost && smtpPort && smtpUser && smtpPass && !isPlaceholderEnvValue(smtpPass)) {
try {
const nodemailer = require('nodemailer') as {
createTransport(options: Record<string, unknown>): SmtpTransport
@@ -80,6 +110,8 @@ interface SendNotificationOptions {
companyId?: string
employeeId?: string
renterId?: string
billingContactId?: string
adminUserId?: string
billingAccountId?: string
email?: string
phone?: string
@@ -91,11 +123,14 @@ interface SendNotificationOptions {
idempotencyKey?: string
sourceType?: string
sourceId?: string
policy?: NotificationPolicy
}
type NotificationAudience =
| { type: 'EMPLOYEE'; employeeId: string }
| { type: 'RENTER'; renterId: string }
| { type: 'BILLING_CONTACT'; billingContactId: string }
| { type: 'ADMIN'; adminUserId: string }
| { type: 'COMPANY_EMPLOYEES' }
type NotificationPolicy = {
@@ -130,7 +165,15 @@ function uniqueChannels(channels: NotificationChannel[] | undefined): Notificati
}
function buildLegacyIdempotencyKey(opts: SendNotificationOptions) {
const target = opts.employeeId ? `employee:${opts.employeeId}` : opts.renterId ? `renter:${opts.renterId}` : `company:${opts.companyId ?? 'none'}`
const target = opts.employeeId
? `employee:${opts.employeeId}`
: opts.renterId
? `renter:${opts.renterId}`
: opts.billingContactId
? `billing-contact:${opts.billingContactId}`
: opts.adminUserId
? `admin:${opts.adminUserId}`
: `company:${opts.companyId ?? 'none'}`
const sourceId = opts.sourceId ?? String(opts.data?.id ?? opts.data?.reservationId ?? opts.data?.bookingId ?? target)
return [
'legacy-notification',
@@ -150,7 +193,7 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
where: { id: audience.employeeId, companyId, isActive: true },
select: { id: true, email: true, preferredLanguage: true },
})
return employee ? [{ recipientType: 'EMPLOYEE' as const, employeeId: employee.id, renterId: null, email: employee.email, locale: employee.preferredLanguage }] : []
return employee ? [{ recipientType: 'EMPLOYEE' as const, employeeId: employee.id, renterId: null, billingContactId: null, adminUserId: null, email: employee.email, locale: employee.preferredLanguage }] : []
}
if (audience.type === 'RENTER') {
@@ -158,7 +201,23 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
where: { id: audience.renterId },
select: { id: true, email: true, preferredLocale: true },
})
return renter ? [{ recipientType: 'RENTER' as const, employeeId: null, renterId: renter.id, email: renter.email, locale: renter.preferredLocale }] : []
return renter ? [{ recipientType: 'RENTER' as const, employeeId: null, renterId: renter.id, billingContactId: null, adminUserId: null, email: renter.email, locale: renter.preferredLocale }] : []
}
if (audience.type === 'BILLING_CONTACT') {
const contact = await prisma.billingContact.findFirst({
where: { id: audience.billingContactId, companyId, isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } },
select: { id: true, email: true, locale: true },
})
return contact ? [{ recipientType: 'BILLING_CONTACT' as const, employeeId: null, renterId: null, billingContactId: contact.id, adminUserId: null, email: contact.email, locale: contact.locale }] : []
}
if (audience.type === 'ADMIN') {
const admin = await prisma.adminUser.findFirst({
where: { id: audience.adminUserId, isActive: true },
select: { id: true, email: true, preferredLocale: true },
})
return admin ? [{ recipientType: 'ADMIN' as const, employeeId: null, renterId: null, billingContactId: null, adminUserId: admin.id, email: admin.email, locale: admin.preferredLocale }] : []
}
const employees = await prisma.employee.findMany({
@@ -169,6 +228,8 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
recipientType: 'EMPLOYEE' as const,
employeeId: employee.id,
renterId: null,
billingContactId: null,
adminUserId: null,
email: employee.email,
locale: employee.preferredLanguage,
}))
@@ -178,6 +239,8 @@ async function resolvePreferenceDecision(input: {
companyId: string
employeeId: string | null
renterId: string | null
billingContactId?: string | null
adminUserId?: string | null
type: NotificationType
channel: NotificationChannel
policy?: NotificationPolicy
@@ -194,9 +257,11 @@ async function resolvePreferenceDecision(input: {
const personalWhere = input.employeeId
? { employeeId: input.employeeId, notificationType: input.type, channel: input.channel }
: { renterId: input.renterId!, notificationType: input.type, channel: input.channel }
: input.renterId
? { renterId: input.renterId, notificationType: input.type, channel: input.channel }
: null
const personal = await prisma.notificationPreference.findFirst({ where: personalWhere as any })
const personal = personalWhere ? await prisma.notificationPreference.findFirst({ where: personalWhere as any }) : null
if (personal) {
return {
enabled: personal.enabled,
@@ -228,15 +293,61 @@ async function resolvePreferenceDecision(input: {
}
function resolveSmtpReplyTo() {
if (!process.env.MAIL_REPLY_TO_ADDRESS || process.env.MAIL_REPLY_TO_ADDRESS.includes('${')) {
const replyToAddress = envValue('MAIL_REPLY_TO_ADDRESS')
const replyToName = envValue('MAIL_REPLY_TO_NAME')
if (!replyToAddress || isPlaceholderEnvValue(replyToAddress)) {
return undefined
}
if (process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')) {
return `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>`
if (replyToName && !isPlaceholderEnvValue(replyToName)) {
return `${replyToName} <${replyToAddress}>`
}
return process.env.MAIL_REPLY_TO_ADDRESS
return replyToAddress
}
function envStatus(value: string | null, configured: boolean) {
if (!value) return 'not set'
if (isPlaceholderEnvValue(value)) return 'placeholder'
return configured ? 'configured' : 'invalid format'
}
function smtpConfigurationIssues() {
const issues: string[] = []
if (!smtpHost) issues.push('MAIL_HOST is not set')
if (!smtpPort) issues.push('MAIL_PORT is not set or invalid')
if (!smtpUser) issues.push('MAIL_USERNAME is not set')
if (!smtpPass) {
issues.push('MAIL_PASSWORD is not set')
} else if (isPlaceholderEnvValue(smtpPass)) {
issues.push('MAIL_PASSWORD is a placeholder; set a Gmail app password')
}
if (!smtpFromAddress) issues.push('MAIL_FROM_ADDRESS is not configured')
if (!smtpTransport) issues.push('SMTP transport is not configured')
return issues
}
export function describeEmailProviderConfig() {
const rawResendKey = envValue('RESEND_API_KEY')
const rawMailPassword = envValue('MAIL_PASSWORD')
return {
selectedProvider: emailProvider,
resend: {
apiKey: envStatus(rawResendKey, Boolean(resendApiKey)),
sender: emailFromAddress && emailFromName ? 'configured' : 'not configured',
enabled: emailProvider !== 'smtp' && Boolean(resend),
},
smtp: {
host: smtpHost ? 'configured' : 'not set',
port: smtpPort ? 'configured' : 'not set',
username: smtpUser ? 'configured' : 'not set',
password: envStatus(rawMailPassword, Boolean(smtpPass && !isPlaceholderEnvValue(smtpPass))),
sender: smtpFromAddress ? 'configured' : 'not configured',
transport: smtpTransport ? 'configured' : 'not configured',
issues: smtpConfigurationIssues(),
},
}
}
async function sendEmailWithProviders(opts: {
@@ -244,9 +355,14 @@ async function sendEmailWithProviders(opts: {
subject: string
html: string
text: string
attachments?: EmailAttachment[]
}) {
const errors: string[] = []
if (emailProvider === 'smtp' && !smtpTransport) {
throw new Error(`SMTP email provider is selected but not configured: ${smtpConfigurationIssues().join('; ')}`)
}
if (resend) {
try {
if (!emailFromAddress || !emailFromName) {
@@ -259,6 +375,10 @@ async function sendEmailWithProviders(opts: {
subject: opts.subject,
html: opts.html,
text: opts.text,
attachments: opts.attachments?.map((attachment) => ({
filename: attachment.filename,
content: attachment.content.toString('base64'),
})),
})
if (error) {
@@ -288,6 +408,7 @@ async function sendEmailWithProviders(opts: {
html: opts.html,
text: opts.text,
replyTo: resolveSmtpReplyTo(),
attachments: opts.attachments,
})
return {
@@ -306,6 +427,94 @@ async function sendEmailWithProviders(opts: {
throw new Error('No email provider is configured')
}
type EmailAttachment = {
filename: string
content: Buffer
contentType?: string
}
function invoiceTaxRate(invoice: { taxRecords?: Array<{ taxRate?: number | null; taxExempt?: boolean }> }) {
return invoice.taxRecords?.find((record) => !record.taxExempt && typeof record.taxRate === 'number')?.taxRate ?? null
}
async function buildInvoicePdfAttachment(invoiceId: string): Promise<EmailAttachment | null> {
const invoice = await prisma.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
company: true,
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
},
})
if (!invoice?.invoiceNumber) return null
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt: any) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
const content = await generateInvoicePdf({
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(),
dueDate: invoice.dueAt?.toISOString() ?? null,
company: {
name: invoice.billingName ?? invoice.company.name,
email: invoice.billingEmail ?? invoice.company.email,
phone: invoice.company.phone,
address: invoice.billingAddress ?? invoice.company.address,
},
subscription: invoice.subscription
? {
plan: invoice.subscription.plan,
billingPeriod: invoice.subscription.billingPeriod,
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
currency: invoice.subscription.currency,
}
: undefined,
amount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider ?? 'MANUAL',
transactionId: latestPaymentAttempt?.providerPaymentId ?? latestPaymentAttempt?.externalReference ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description,
amount: item.amount,
currency: item.currency,
quantity: item.quantity,
unitAmount: item.unitAmount,
periodStart: item.periodStart?.toISOString() ?? null,
periodEnd: item.periodEnd?.toISOString() ?? null,
})),
totals: {
subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
amountDue: invoice.amountDue,
},
})
return {
filename: `${invoice.invoiceNumber}.pdf`,
content,
contentType: 'application/pdf',
}
}
async function resolveEmailAttachments(event: any): Promise<EmailAttachment[]> {
const attachments = Array.isArray(event.data?.emailAttachments) ? event.data.emailAttachments : []
const resolved: EmailAttachment[] = []
for (const attachment of attachments) {
if (attachment?.type === 'invoice_pdf' && typeof attachment.invoiceId === 'string') {
const invoicePdf = await buildInvoicePdfAttachment(attachment.invoiceId)
if (invoicePdf) resolved.push(invoicePdf)
}
}
return resolved
}
export async function sendNotification(opts: SendNotificationOptions) {
if (!opts.companyId) {
return uniqueChannels(opts.channels).map((channel) => ({
@@ -319,13 +528,17 @@ export async function sendNotification(opts: SendNotificationOptions) {
? { type: 'EMPLOYEE', employeeId: opts.employeeId }
: opts.renterId
? { type: 'RENTER', renterId: opts.renterId }
: null
: opts.billingContactId
? { type: 'BILLING_CONTACT', billingContactId: opts.billingContactId }
: opts.adminUserId
? { type: 'ADMIN', adminUserId: opts.adminUserId }
: null
if (!audience) {
return uniqueChannels(opts.channels).map((channel) => ({
channel,
success: false,
error: 'An explicit employee or renter recipient is required',
error: 'An explicit employee, renter, billing contact, or admin recipient is required',
}))
}
@@ -346,6 +559,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
body: opts.body,
data: opts.data,
locale: opts.locale,
policy: opts.policy,
})
return command.deliveries.map((delivery) => ({
@@ -369,7 +583,7 @@ export async function createNotification(opts: CreateNotificationOptions) {
})
const channels = uniqueChannels(opts.channels)
const renderChannel: NotificationChannel = channels.includes('IN_APP') ? 'IN_APP' : channels[0]!
const rendered = opts.templateKey
const rendered = opts.templateKey && (!opts.title || !opts.body)
? await resolveNotificationTemplate({
templateKey: opts.templateKey,
channel: renderChannel,
@@ -415,6 +629,8 @@ export async function createNotification(opts: CreateNotificationOptions) {
recipientType: recipient.recipientType,
employeeId: recipient.employeeId,
renterId: recipient.renterId,
billingContactId: recipient.billingContactId,
adminUserId: recipient.adminUserId,
},
})
@@ -423,6 +639,8 @@ export async function createNotification(opts: CreateNotificationOptions) {
companyId: opts.companyId,
employeeId: recipient.employeeId,
renterId: recipient.renterId,
billingContactId: recipient.billingContactId,
adminUserId: recipient.adminUserId,
type: opts.type,
channel,
policy: opts.policy,
@@ -472,6 +690,155 @@ export async function createNotification(opts: CreateNotificationOptions) {
}
}
const COLLECTIONS_REMINDER_TYPES = new Set<NotificationType>([
'SUBSCRIPTION_PAYMENT_DUE_14D',
'SUBSCRIPTION_PAYMENT_DUE_7D',
'SUBSCRIPTION_PAYMENT_DUE_48H',
'SUBSCRIPTION_PAYMENT_DUE_24H',
'SUBSCRIPTION_GRACE_DAILY',
'SUBSCRIPTION_GRACE_FINAL',
'COLLECTIONS_CALL_REQUIRED',
])
async function deliveryEmail(recipient: any) {
return recipient.employee?.email
?? recipient.renter?.email
?? recipient.billingContact?.email
?? recipient.adminUser?.email
?? null
}
export async function processNotificationOutbox(limit = 50) {
const entries = await prisma.notificationOutbox.findMany({
where: { status: 'PENDING' },
include: {
notificationEvent: {
include: {
recipients: {
include: {
employee: { select: { email: true } },
renter: { select: { email: true } },
billingContact: { select: { email: true, isActive: true, verifiedAt: true } },
adminUser: { select: { email: true, isActive: true } },
deliveries: true,
},
},
},
},
},
orderBy: { createdAt: 'asc' },
take: Math.max(1, Math.min(limit, 200)),
})
let processed = 0
for (const entry of entries as any[]) {
const event = entry.notificationEvent
if (event.sourceType === 'collections_case' && COLLECTIONS_REMINDER_TYPES.has(event.type)) {
const collectionsCase = await prisma.collectionsCase.findUnique({ where: { id: event.sourceId }, select: { status: true } })
if (!collectionsCase || ['RESOLVED', 'SUSPENDED'].includes(collectionsCase.status)) {
await prisma.notificationDelivery.updateMany({
where: { notificationRecipient: { notificationEventId: event.id }, status: { in: ['PENDING', 'QUEUED', 'FAILED'] } },
data: { status: 'SKIPPED', failureCode: 'COLLECTIONS_CASE_CLOSED', failureReason: 'Suppressed because the collections case is closed.' },
})
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
processed += 1
continue
}
}
for (const recipient of event.recipients) {
for (const delivery of recipient.deliveries) {
if (!['PENDING', 'QUEUED', 'FAILED'].includes(delivery.status)) continue
if (delivery.nextAttemptAt && delivery.nextAttemptAt > new Date()) continue
try {
if (delivery.channel === 'IN_APP') {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: { status: 'SENT', sentAt: new Date(), attemptCount: { increment: 1 }, lastAttemptAt: new Date() },
})
} else if (delivery.channel === 'EMAIL') {
const to = await deliveryEmail(recipient)
const externalContactInvalid = recipient.billingContact && (!recipient.billingContact.isActive || !recipient.billingContact.verifiedAt)
const adminInvalid = recipient.adminUser && !recipient.adminUser.isActive
if (!to || externalContactInvalid || adminInvalid) throw new Error('Recipient email is unavailable')
const result = await sendEmailWithProviders({
to,
subject: event.title,
html: renderLocalizedEmailHtml(event.body, coerceNotificationLocale(event.locale)),
text: event.body,
attachments: await resolveEmailAttachments(event),
})
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: {
status: 'SENT',
sentAt: new Date(),
provider: result.provider,
providerMessageId: result.providerMessageId,
attemptCount: { increment: 1 },
lastAttemptAt: new Date(),
failureCode: null,
failureReason: null,
},
})
} else {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: { status: 'SKIPPED', failureCode: 'UNSUPPORTED_CHANNEL', failureReason: 'Channel is not implemented.' },
})
}
} catch (error: any) {
const attempts = delivery.attemptCount + 1
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: {
status: attempts >= 5 ? 'DEAD_LETTER' : 'FAILED',
attemptCount: attempts,
lastAttemptAt: new Date(),
nextAttemptAt: attempts >= 5 ? null : new Date(Date.now() + Math.min(60, 2 ** attempts) * 60_000),
failureCode: 'DELIVERY_FAILED',
failureReason: String(error?.message ?? 'Delivery failed').slice(0, 500),
},
})
}
}
}
const remaining = await prisma.notificationDelivery.count({
where: {
notificationRecipient: { notificationEventId: event.id },
status: { in: ['PENDING', 'QUEUED', 'FAILED'] },
},
})
if (remaining === 0) {
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
processed += 1
}
}
return processed
}
export async function getAdminNotificationInbox(adminUserId: string, page = 1, pageSize = 50) {
const where = { adminUserId, archivedAt: null }
const [data, total, unread] = await Promise.all([
prisma.notificationRecipient.findMany({
where,
include: { notificationEvent: true, deliveries: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.notificationRecipient.count({ where }),
prisma.notificationRecipient.count({ where: { ...where, readAt: null } }),
])
return { data, total, unread, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) }
}
export async function markAdminNotificationRead(adminUserId: string, recipientId: string) {
const recipient = await prisma.notificationRecipient.findFirst({ where: { id: recipientId, adminUserId } })
if (!recipient) throw new Error('Admin notification not found')
return prisma.notificationRecipient.update({ where: { id: recipient.id }, data: { readAt: recipient.readAt ?? new Date() } })
}
export async function sendTransactionalEmail(opts: {
to: string
subject: string
@@ -0,0 +1,42 @@
import { spawn } from 'child_process'
export type PaymentEvidenceScanResult = {
status: 'CLEAN' | 'QUARANTINED' | 'SCAN_FAILED'
code: string
}
const SCAN_TIMEOUT_MS = Number(process.env.PAYMENT_EVIDENCE_SCAN_TIMEOUT_MS ?? 60_000)
export async function scanPaymentEvidenceFile(filePath: string): Promise<PaymentEvidenceScanResult> {
if (process.env.NODE_ENV !== 'production' && process.env.PAYMENT_EVIDENCE_SCANNER_MODE === 'stub-clean') {
return { status: 'CLEAN', code: 'DEVELOPMENT_STUB_CLEAN' }
}
const scannerPath = process.env.PAYMENT_EVIDENCE_SCANNER_PATH?.trim()
if (!scannerPath) return { status: 'SCAN_FAILED', code: 'SCANNER_NOT_CONFIGURED' }
return new Promise((resolve) => {
const child = spawn(scannerPath, ['--no-summary', filePath], {
shell: false,
stdio: ['ignore', 'ignore', 'ignore'],
})
let settled = false
const finish = (result: PaymentEvidenceScanResult) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(result)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish({ status: 'SCAN_FAILED', code: 'SCANNER_TIMEOUT' })
}, Number.isFinite(SCAN_TIMEOUT_MS) && SCAN_TIMEOUT_MS > 0 ? SCAN_TIMEOUT_MS : 60_000)
child.once('error', () => finish({ status: 'SCAN_FAILED', code: 'SCANNER_EXECUTION_FAILED' }))
child.once('exit', (code) => {
if (code === 0) finish({ status: 'CLEAN', code: 'SCANNER_CLEAN' })
else if (code === 1) finish({ status: 'QUARANTINED', code: 'MALWARE_DETECTED' })
else finish({ status: 'SCAN_FAILED', code: `SCANNER_EXIT_${code ?? 'UNKNOWN'}` })
})
})
}
+32
View File
@@ -59,6 +59,11 @@ import {
employeeLanguageSchema,
} from '../modules/auth/auth.employee.schemas'
import { renterUpdateSchema, renterFcmTokenSchema } from '../modules/auth/auth.renter.schemas'
import {
manualCheckoutSchema,
createManualPaymentSubmissionSchema,
communicationSettingsSchema,
} from '../modules/subscriptions/subscription.schemas'
// ─────────────────────────────────────────────────────────────────────────────
@@ -167,6 +172,10 @@ export const openApiDocument: JsonObject = {
EmployeeLanguage: s(employeeLanguageSchema),
RenterUpdate: s(renterUpdateSchema),
RenterFcmToken: s(renterFcmTokenSchema),
// ── Subscription billing ────────────────────────────────────
ManualSubscriptionCheckout: s(manualCheckoutSchema),
ManualPaymentSubmissionCreate: s(createManualPaymentSubmissionSchema),
SubscriptionCommunicationSettings: s(communicationSettingsSchema),
},
},
paths: {
@@ -982,6 +991,29 @@ export const openApiDocument: JsonObject = {
'/subscriptions/checkout': {
post: { tags: ['Subscriptions'], summary: 'Checkout new subscription (Owner)', responses: { '200': ok } },
},
'/subscriptions/payment-options': {
get: { tags: ['Subscriptions'], summary: 'Available subscription collection methods and safe payer instructions', responses: { '200': ok } },
},
'/subscriptions/manual-checkout': {
post: { tags: ['Subscriptions'], summary: 'Create or reuse a bank transfer/check invoice (Owner)', requestBody: jsonBody('#/components/schemas/ManualSubscriptionCheckout'), responses: { '200': ok, '400': err4 } },
},
'/subscriptions/invoices/{invoiceId}/manual-payment-submissions': {
post: { tags: ['Subscriptions'], summary: 'Create an evidence draft for an invoice (Owner)', parameters: [idPath('invoiceId')], requestBody: jsonBody('#/components/schemas/ManualPaymentSubmissionCreate'), responses: { '200': ok, '400': err4 } },
},
'/subscriptions/manual-payment-submissions/{submissionId}/documents': {
post: { tags: ['Subscriptions'], summary: 'Upload and scan one private evidence document (Owner)', parameters: [idPath('submissionId')], responses: { '201': ok, '400': err4 } },
},
'/subscriptions/manual-payment-submissions/{submissionId}/submit': {
post: { tags: ['Subscriptions'], summary: 'Lock and submit clean evidence for finance review (Owner)', parameters: [idPath('submissionId')], responses: { '200': ok, '400': err4 } },
},
'/subscriptions/manual-payment-submissions/{submissionId}/documents/{documentId}': {
get: { tags: ['Subscriptions'], summary: 'Download owned clean evidence (Owner)', parameters: [idPath('submissionId'), idPath('documentId')], responses: { '200': ok, '404': err404 } },
delete: { tags: ['Subscriptions'], summary: 'Delete evidence while the submission is a draft (Owner)', parameters: [idPath('submissionId'), idPath('documentId')], responses: { '200': ok, '404': err404 } },
},
'/subscriptions/communication-settings': {
get: { tags: ['Subscriptions'], summary: 'Billing contacts, timezone, and AR/EN/FR policy (Owner)', responses: { '200': ok } },
put: { tags: ['Subscriptions'], summary: 'Update billing communication settings (Owner)', requestBody: jsonBody('#/components/schemas/SubscriptionCommunicationSettings'), responses: { '200': ok, '400': err4 } },
},
'/subscriptions/change-plan': {
post: { tags: ['Subscriptions'], summary: 'Change plan (Owner)', responses: { '200': ok } },
},
+2
View File
@@ -7,6 +7,8 @@ const delegates = [
'billingCreditNote',
'billingCreditLedgerEntry',
'billingCreditBalance',
'manualPaymentDocument',
'manualPaymentSubmission',
'billingPaymentAttempt',
'billingPaymentIntent',
'billingInvoiceLineItem',
@@ -3,12 +3,13 @@
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, apiFetch, resolveApiBase } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { buildHomepageSignInPath } from '@/lib/dashboardPaths'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type CollectionMethod = 'STRIPE' | 'BANK_TRANSFER' | 'CHECK'
interface Subscription {
id: string
@@ -23,12 +24,66 @@ interface Subscription {
interface Invoice {
id: string
invoiceNumber: string | null
amount: number
amountDue: number
currency: string
status: string
paymentProvider: string
collectionMethod: CollectionMethod
dueAt: string | null
paidAt: string | null
createdAt: string
manualPaymentSubmission?: ManualPaymentSubmission | null
}
interface PaymentOption {
method: CollectionMethod
enabled: boolean
instructions?: Record<string, string>
}
interface ManualPaymentDocument {
id: string
kind: string
originalFilename: string
byteSize: number
scanStatus: string
uploadedAt: string
}
interface ManualPaymentSubmission {
id: string
method: CollectionMethod
submittedReference: string
status: string
submittedAt: string | null
rejectionReason?: string | null
documents: ManualPaymentDocument[]
}
interface ManualCheckoutResult {
invoice: Invoice
instructions: Record<string, string>
}
type CommunicationLocale = 'ar' | 'en' | 'fr'
interface CommunicationSettings {
timezone: string
reminderLocalTime: string
enabledCommunicationLocales: CommunicationLocale[]
defaultCommunicationLocale: CommunicationLocale
contacts: Array<{
id?: string
employeeId?: string | null
email: string
locale?: CommunicationLocale | null
effectiveLocale?: CommunicationLocale
isPrimary: boolean
receivePaymentNotices: boolean
isActive: boolean
verified?: boolean
}>
}
interface ProviderAvailability {
@@ -61,6 +116,9 @@ const STATUS_BADGE: Record<string, string> = {
const INVOICE_STATUS: Record<string, string> = {
PAID: 'bg-green-100 text-green-700',
PENDING: 'bg-orange-100 text-orange-700',
OPEN: 'bg-orange-100 text-orange-700',
PAYMENT_PENDING: 'bg-orange-100 text-orange-700',
PAST_DUE: 'bg-red-100 text-red-700',
FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600',
}
@@ -72,6 +130,30 @@ const PLAN_LABELS: Record<Plan, string> = {
PRO: 'Pro',
ENTERPRISE: 'Enterprise',
}
const PAYMENT_EVIDENCE_MAX_FILES = 3
const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
const PAYMENT_EVIDENCE_ACCEPT = 'application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png'
const PAYMENT_EVIDENCE_EXTENSIONS = new Set(['pdf', 'jpg', 'jpeg', 'png'])
const PAYMENT_EVIDENCE_MIME_TYPES = new Set(['application/pdf', 'application/x-pdf', 'application/octet-stream', 'image/jpeg', 'image/pjpeg', 'image/png', 'image/x-png'])
function validatePaymentEvidenceFiles(files: File[]) {
if (files.length > PAYMENT_EVIDENCE_MAX_FILES) return 'Upload at most three evidence files.'
for (const file of files) {
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!PAYMENT_EVIDENCE_EXTENSIONS.has(extension)) {
return 'Evidence files must be PDF, JPEG, or PNG.'
}
if (file.type && !PAYMENT_EVIDENCE_MIME_TYPES.has(file.type.toLowerCase())) {
return 'Evidence files must be PDF, JPEG, or PNG.'
}
if (file.size <= 0 || file.size > PAYMENT_EVIDENCE_MAX_FILE_SIZE) {
return 'Each evidence file must be between 1 byte and 10 MB.'
}
}
return null
}
export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n()
@@ -86,12 +168,23 @@ export default function SubscriptionPage() {
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const currency = 'MAD'
const provider = 'STRIPE'
const [selectedMethod, setSelectedMethod] = useState<CollectionMethod>('STRIPE')
const [paymentOptions, setPaymentOptions] = useState<PaymentOption[]>([])
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ stripe: false })
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [manualCheckout, setManualCheckout] = useState<ManualCheckoutResult | null>(null)
const [manualPaymentRequestNumber, setManualPaymentRequestNumber] = useState<string | null>(null)
const [paymentReference, setPaymentReference] = useState('')
const [evidenceFiles, setEvidenceFiles] = useState<File[]>([])
const [paymentSubmission, setPaymentSubmission] = useState<ManualPaymentSubmission | null>(null)
const [submittingEvidence, setSubmittingEvidence] = useState(false)
const [checkoutIdempotencyKey, setCheckoutIdempotencyKey] = useState<string | null>(null)
const [submissionIdempotencyKey, setSubmissionIdempotencyKey] = useState<string | null>(null)
const [communicationSettings, setCommunicationSettings] = useState<CommunicationSettings | null>(null)
const [savingCommunicationSettings, setSavingCommunicationSettings] = useState(false)
const copy = {
en: {
title: 'Subscription',
@@ -107,6 +200,39 @@ export default function SubscriptionPage() {
changePlan: 'Change plan',
subscribe: 'Subscribe',
selectPlan: 'Select a plan to continue to Stripe checkout.',
selectPayment: 'Choose Stripe, bank transfer, or check. The server calculates the final amount.',
bankTransfer: 'Bank transfer',
check: 'Check',
creatingInvoice: 'Creating invoice…',
createInvoice: 'Create payment invoice',
submitPaymentEvidence: 'Submit payment evidence',
awaitingVerification: 'Awaiting finance verification',
invoiceNumber: 'Invoice',
paymentRequestNumber: 'Payment request number',
paymentRequestHelp: 'Use this generated number when contacting support about this payment.',
dueDate: 'Due date',
paymentInstructions: 'Payment instructions',
reference: 'Transaction reference or check number',
bankTransferReference: 'Bank transfer reference number',
checkNumber: 'Check number',
evidence: 'Payment evidence (PDF, JPEG, or PNG; up to 3 files)',
bankTransferEvidence: 'Bank transfer receipt (PDF, JPEG, or PNG; up to 3 files)',
checkEvidence: 'Check copy (PDF, JPEG, or PNG; up to 3 files)',
submitReview: 'Submit for review',
submittingReview: 'Submitting…',
evidenceWarning: 'Uploading a receipt does not prove settlement or activate the subscription. Finance must independently confirm cleared funds.',
manualDetailsTitle: 'Payment details',
manualDetailsHelp: 'Enter the payment number and attach the supporting file before submitting it for finance review.',
evidenceSubmitted: 'Evidence submitted and locked for finance review.',
communicationTitle: 'Billing communication settings',
communicationHelp: 'Choose the languages your company permits for future payment notices. Each contact receives one notice in their effective language.',
enabledLanguages: 'Enabled languages',
defaultLanguage: 'Default language',
timezone: 'Billing timezone',
contactLanguage: 'Contact language',
inheritDefault: 'Inherit company default',
saveSettings: 'Save communication settings',
settingsSaved: 'Communication settings saved.',
monthly: 'Monthly',
annual: 'Annual (save 20%)',
active: 'Active',
@@ -119,6 +245,7 @@ export default function SubscriptionPage() {
redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history',
invoice: 'Invoice',
date: 'Date',
provider: 'Provider',
status: 'Status',
@@ -156,6 +283,39 @@ export default function SubscriptionPage() {
changePlan: 'Changer de plan',
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan pour continuer vers Stripe Checkout.',
selectPayment: 'Choisissez Stripe, virement bancaire ou chèque. Le serveur calcule le montant final.',
bankTransfer: 'Virement bancaire',
check: 'Chèque',
creatingInvoice: 'Création de la facture…',
createInvoice: 'Créer la facture de paiement',
submitPaymentEvidence: 'Envoyer le justificatif',
awaitingVerification: 'En attente de vérification financière',
invoiceNumber: 'Facture',
paymentRequestNumber: 'Numéro de demande de paiement',
paymentRequestHelp: 'Utilisez ce numéro généré pour contacter le support au sujet de ce paiement.',
dueDate: 'Échéance',
paymentInstructions: 'Instructions de paiement',
reference: 'Référence du virement ou numéro de chèque',
bankTransferReference: 'Référence du virement bancaire',
checkNumber: 'Numéro du chèque',
evidence: 'Justificatif (PDF, JPEG ou PNG ; 3 fichiers maximum)',
bankTransferEvidence: 'Reçu du virement (PDF, JPEG ou PNG ; 3 fichiers maximum)',
checkEvidence: 'Copie du chèque (PDF, JPEG ou PNG ; 3 fichiers maximum)',
submitReview: 'Soumettre pour vérification',
submittingReview: 'Envoi…',
evidenceWarning: 'Le dépôt dun justificatif ne prouve pas le règlement et nactive pas labonnement. La finance doit confirmer les fonds encaissés.',
manualDetailsTitle: 'Détails du paiement',
manualDetailsHelp: 'Saisissez le numéro de paiement et joignez le justificatif avant de lenvoyer à la finance.',
evidenceSubmitted: 'Justificatifs soumis et verrouillés pour vérification.',
communicationTitle: 'Paramètres de communication de facturation',
communicationHelp: 'Choisissez les langues autorisées pour les prochains avis de paiement. Chaque contact reçoit un seul avis dans sa langue effective.',
enabledLanguages: 'Langues activées',
defaultLanguage: 'Langue par défaut',
timezone: 'Fuseau horaire de facturation',
contactLanguage: 'Langue du contact',
inheritDefault: 'Hériter de la langue par défaut',
saveSettings: 'Enregistrer les paramètres',
settingsSaved: 'Paramètres de communication enregistrés.',
monthly: 'Mensuel',
annual: 'Annuel (économie 20%)',
active: 'Actif',
@@ -168,6 +328,7 @@ export default function SubscriptionPage() {
redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures',
invoice: 'Facture',
date: 'Date',
provider: 'Prestataire',
status: 'Statut',
@@ -205,6 +366,39 @@ export default function SubscriptionPage() {
changePlan: 'تغيير الخطة',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة للمتابعة إلى Stripe Checkout.',
selectPayment: 'اختر Stripe أو التحويل البنكي أو الشيك. يحسب الخادم المبلغ النهائي.',
bankTransfer: 'تحويل بنكي',
check: 'شيك',
creatingInvoice: 'جارٍ إنشاء الفاتورة…',
createInvoice: 'إنشاء فاتورة الدفع',
submitPaymentEvidence: 'إرسال إثبات الدفع',
awaitingVerification: 'في انتظار تحقق فريق المالية',
invoiceNumber: 'الفاتورة',
paymentRequestNumber: 'رقم طلب الدفع',
paymentRequestHelp: 'استخدم هذا الرقم المُنشأ عند التواصل مع الدعم بخصوص هذا الدفع.',
dueDate: 'تاريخ الاستحقاق',
paymentInstructions: 'تعليمات الدفع',
reference: 'مرجع التحويل أو رقم الشيك',
bankTransferReference: 'رقم مرجع التحويل البنكي',
checkNumber: 'رقم الشيك',
evidence: 'إثبات الدفع (PDF أو JPEG أو PNG، بحد أقصى 3 ملفات)',
bankTransferEvidence: 'إيصال التحويل البنكي (PDF أو JPEG أو PNG، بحد أقصى 3 ملفات)',
checkEvidence: 'نسخة الشيك (PDF أو JPEG أو PNG، بحد أقصى 3 ملفات)',
submitReview: 'إرسال للمراجعة',
submittingReview: 'جارٍ الإرسال…',
evidenceWarning: 'رفع الإيصال لا يثبت وصول الأموال ولا يفعّل الاشتراك. يجب أن يؤكد فريق المالية تحصيل المبلغ بشكل مستقل.',
manualDetailsTitle: 'تفاصيل الدفع',
manualDetailsHelp: 'أدخل رقم الدفع وأرفق المستند الداعم قبل إرساله إلى فريق المالية.',
evidenceSubmitted: 'تم إرسال المستندات وقفلها لمراجعة فريق المالية.',
communicationTitle: 'إعدادات اتصالات الفوترة',
communicationHelp: 'اختر اللغات التي تسمح بها الشركة لإشعارات الدفع المستقبلية. يتلقى كل مسؤول إشعاراً واحداً بلغته الفعلية.',
enabledLanguages: 'اللغات المفعّلة',
defaultLanguage: 'اللغة الافتراضية',
timezone: 'المنطقة الزمنية للفوترة',
contactLanguage: 'لغة جهة الاتصال',
inheritDefault: 'استخدام لغة الشركة الافتراضية',
saveSettings: 'حفظ إعدادات الاتصال',
settingsSaved: 'تم حفظ إعدادات الاتصال.',
monthly: 'شهري',
annual: 'سنوي (توفير 20%)',
active: 'نشط',
@@ -217,6 +411,7 @@ export default function SubscriptionPage() {
redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير',
invoice: 'الفاتورة',
date: 'التاريخ',
provider: 'المزوّد',
status: 'الحالة',
@@ -288,10 +483,16 @@ export default function SubscriptionPage() {
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
apiFetch<{ methods: PaymentOption[] }>('/subscriptions/payment-options'),
apiFetch<CommunicationSettings>('/subscriptions/communication-settings'),
fetchPlanData(),
])
.then(([sub, inv, availability]) => {
.then(([sub, inv, availability, options, settings]) => {
setProviderAvailability(availability)
setPaymentOptions(options.methods ?? [])
setCommunicationSettings(settings)
const firstEnabled = options.methods?.find((option) => option.enabled)
if (firstEnabled) setSelectedMethod(firstEnabled.method)
if (sub) {
setSubscription(sub)
setSelectedPlan(sub.plan)
@@ -299,6 +500,13 @@ export default function SubscriptionPage() {
// currency is always MAD
}
setInvoices(inv ?? [])
const latestManual = inv?.find((invoice) => invoice.collectionMethod !== 'STRIPE' && ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE'].includes(invoice.status))
if (latestManual) {
const option = options.methods?.find((item) => item.method === latestManual.collectionMethod)
setSelectedMethod(latestManual.collectionMethod)
setManualCheckout({ invoice: latestManual, instructions: option?.instructions ?? {} })
if (latestManual.manualPaymentSubmission) setPaymentSubmission(latestManual.manualPaymentSubmission)
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
@@ -359,6 +567,55 @@ export default function SubscriptionPage() {
setPaying(true)
setError(null)
try {
const selectedOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
if (!selectedOption) throw new Error(copy.providerUnavailable)
if (selectedMethod !== 'STRIPE') {
if (paymentReference.trim().length < 3) throw new Error(`${manualReferenceLabel} is required`)
if (evidenceFiles.length === 0) throw new Error(manualEvidenceLabel)
const fileError = validatePaymentEvidenceFiles(evidenceFiles)
if (fileError) throw new Error(fileError)
const idempotencyKey = checkoutIdempotencyKey ?? crypto.randomUUID()
setCheckoutIdempotencyKey(idempotencyKey)
const result = await apiFetch<ManualCheckoutResult>('/subscriptions/manual-checkout', {
method: 'POST',
body: JSON.stringify({ plan: selectedPlan, billingPeriod, currency, method: selectedMethod, idempotencyKey }),
})
setManualPaymentRequestNumber(result.invoice.invoiceNumber ?? result.invoice.id)
const key = submissionIdempotencyKey ?? crypto.randomUUID()
setSubmissionIdempotencyKey(key)
const submission = await apiFetch<ManualPaymentSubmission>(
`/subscriptions/invoices/${result.invoice.id}/manual-payment-submissions`,
{
method: 'POST',
body: JSON.stringify({ method: selectedMethod, submittedReference: paymentReference, idempotencyKey: key }),
},
)
let current = submission
for (const file of evidenceFiles) {
const form = new FormData()
form.append('kind', selectedMethod === 'CHECK' ? 'CHECK_COPY' : 'BANK_TRANSFER_RECEIPT')
form.append('file', file)
const document = await apiFetch<ManualPaymentDocument>(
`/subscriptions/manual-payment-submissions/${submission.id}/documents`,
{ method: 'POST', body: form },
)
current = { ...current, documents: [...current.documents.filter((item) => item.id !== document.id), document] }
setPaymentSubmission(current)
if (document.scanStatus !== 'CLEAN') throw new Error(`Evidence scan status: ${document.scanStatus}`)
}
const submitted = await apiFetch<ManualPaymentSubmission>(
`/subscriptions/manual-payment-submissions/${submission.id}/submit`,
{ method: 'POST' },
)
setPaymentSubmission(submitted)
setManualCheckout(null)
setEvidenceFiles([])
setCheckoutIdempotencyKey(null)
setSubmissionIdempotencyKey(null)
setPaying(false)
return
}
if (!providerAvailability.stripe) throw new Error(copy.providerUnavailable)
const currentUrl = new URL(window.location.href)
currentUrl.search = ''
@@ -369,7 +626,7 @@ export default function SubscriptionPage() {
plan: selectedPlan,
billingPeriod,
currency,
provider,
provider: 'STRIPE',
successUrl: `${currentUrl.toString()}?payment=success`,
failureUrl: `${currentUrl.toString()}?payment=failed`,
}),
@@ -381,6 +638,75 @@ export default function SubscriptionPage() {
}
}
async function handleEvidenceSubmit() {
if (!manualCheckout) return
setSubmittingEvidence(true)
setError(null)
try {
const key = submissionIdempotencyKey ?? crypto.randomUUID()
setSubmissionIdempotencyKey(key)
const fileError = validatePaymentEvidenceFiles(evidenceFiles)
if (fileError) throw new Error(fileError)
const reusableDraft = paymentSubmission?.status === 'DRAFT' ? paymentSubmission : null
const submission = reusableDraft ?? await apiFetch<ManualPaymentSubmission>(
`/subscriptions/invoices/${manualCheckout.invoice.id}/manual-payment-submissions`,
{
method: 'POST',
body: JSON.stringify({ method: selectedMethod, submittedReference: paymentReference, idempotencyKey: key }),
},
)
let current = submission
const pendingFiles = [...evidenceFiles]
for (const file of pendingFiles) {
const form = new FormData()
form.append('kind', selectedMethod === 'CHECK' ? 'CHECK_COPY' : 'BANK_TRANSFER_RECEIPT')
form.append('file', file)
const document = await apiFetch<ManualPaymentDocument>(
`/subscriptions/manual-payment-submissions/${submission.id}/documents`,
{ method: 'POST', body: form },
)
current = { ...current, documents: [...current.documents.filter((item) => item.id !== document.id), document] }
setPaymentSubmission(current)
setEvidenceFiles((files) => files.filter((item) => item !== file))
if (document.scanStatus !== 'CLEAN') throw new Error(`Evidence scan status: ${document.scanStatus}`)
}
const submitted = await apiFetch<ManualPaymentSubmission>(
`/subscriptions/manual-payment-submissions/${submission.id}/submit`,
{ method: 'POST' },
)
setPaymentSubmission(submitted)
setEvidenceFiles([])
setSubmissionIdempotencyKey(null)
} catch (err: any) {
setError(err.message)
} finally {
setSubmittingEvidence(false)
}
}
async function saveCommunicationSettings() {
if (!communicationSettings) return
setSavingCommunicationSettings(true)
setError(null)
try {
const saved = await apiFetch<CommunicationSettings>('/subscriptions/communication-settings', {
method: 'PUT',
body: JSON.stringify({
timezone: communicationSettings.timezone,
reminderLocalTime: communicationSettings.reminderLocalTime,
enabledCommunicationLocales: communicationSettings.enabledCommunicationLocales,
defaultCommunicationLocale: communicationSettings.defaultCommunicationLocale,
contacts: communicationSettings.contacts.map(({ effectiveLocale: _effectiveLocale, verified: _verified, ...contact }) => contact),
}),
})
setCommunicationSettings(saved)
} catch (err: any) {
setError(err.message)
} finally {
setSavingCommunicationSettings(false)
}
}
async function handleCancel() {
setCancelling(true)
setError(null)
@@ -394,6 +720,20 @@ export default function SubscriptionPage() {
}
}
function handleEvidenceFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.target.files
const selectedFiles = Array.from(files ?? [])
const fileError = validatePaymentEvidenceFiles(selectedFiles)
if (fileError) {
setEvidenceFiles([])
event.target.value = ''
setError(fileError)
return
}
setError(null)
setEvidenceFiles(selectedFiles)
}
async function handleResume() {
setCancelling(true)
setError(null)
@@ -411,6 +751,64 @@ export default function SubscriptionPage() {
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
const selectedPaymentOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
const isManualMethod = selectedMethod === 'BANK_TRANSFER' || selectedMethod === 'CHECK'
const manualReferenceLabel = selectedMethod === 'CHECK' ? copy.checkNumber : copy.bankTransferReference
const manualEvidenceLabel = selectedMethod === 'CHECK' ? copy.checkEvidence : copy.bankTransferEvidence
const manualEvidenceKindLabel = selectedMethod === 'CHECK' ? copy.check : copy.bankTransfer
const manualDetailsForm = isManualMethod ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
<div>
<p className="font-semibold text-slate-900">{copy.manualDetailsTitle}</p>
<p className="mt-1 text-sm text-slate-500">{copy.manualDetailsHelp}</p>
</div>
{selectedPaymentOption?.instructions && !manualCheckout ? (
<div className="mt-4 rounded-xl bg-white p-4">
<p className="text-sm font-semibold text-slate-900">{copy.paymentInstructions}</p>
<dl className="mt-2 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
{Object.entries(selectedPaymentOption.instructions).map(([key, value]) => (
<div key={key}><dt className="text-xs uppercase text-slate-500">{key}</dt><dd className="font-medium">{value}</dd></div>
))}
</dl>
</div>
) : null}
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<label className="block text-sm font-medium text-slate-800">
{manualReferenceLabel}
<input
value={paymentReference}
onChange={(event) => setPaymentReference(event.target.value)}
maxLength={120}
inputMode="text"
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2"
/>
</label>
<label className="block text-sm font-medium text-slate-800">
{manualEvidenceLabel}
<input
type="file"
accept={PAYMENT_EVIDENCE_ACCEPT}
multiple
onChange={handleEvidenceFileChange}
className="mt-1 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"
/>
</label>
</div>
{evidenceFiles.length > 0 ? (
<div className="mt-3 space-y-1">
{evidenceFiles.map((file) => (
<p key={`${file.name}-${file.size}-${file.lastModified}`} className="text-xs text-slate-600">
{manualEvidenceKindLabel}: {file.name}
</p>
))}
</div>
) : null}
{paymentSubmission?.documents.map((document) => (
<p key={document.id} className="mt-2 text-xs text-slate-600">{document.originalFilename} · {document.scanStatus}</p>
))}
<p className="mt-3 text-xs text-amber-900">{copy.evidenceWarning}</p>
</div>
) : null
return (
<div className="space-y-8">
@@ -470,7 +868,7 @@ export default function SubscriptionPage() {
{/* Plan selector + checkout */}
<div className="card p-6 space-y-6">
{!providerAvailability.stripe ? (
{paymentOptions.length > 0 && !paymentOptions.some((option) => option.enabled) ? (
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
{copy.noProviderConfigured}
{providerAvailability.stripeProblems && providerAvailability.stripeProblems.length > 0 ? (
@@ -486,7 +884,7 @@ export default function SubscriptionPage() {
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3>
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
<p className="mt-1 text-sm text-slate-500">{copy.selectPayment}</p>
</div>
{/* Billing period toggle */}
@@ -549,15 +947,33 @@ export default function SubscriptionPage() {
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3">
{providerAvailability.stripe ? (
<div className="flex items-center gap-2 rounded-xl border-2 border-blue-500 bg-blue-50 px-4 py-2.5 text-sm font-medium text-blue-700">
Stripe
</div>
) : null}
<div className="flex flex-wrap gap-3">
{paymentOptions.filter((option) => option.enabled).map((option) => (
<button
type="button"
key={option.method}
onClick={() => {
setSelectedMethod(option.method)
setManualCheckout(null)
setManualPaymentRequestNumber(null)
setPaymentSubmission(null)
setCheckoutIdempotencyKey(null)
setSubmissionIdempotencyKey(null)
setPaymentReference('')
setEvidenceFiles([])
}}
className={`flex items-center gap-2 rounded-xl border-2 px-4 py-2.5 text-sm font-medium ${
selectedMethod === option.method ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600'
}`}
>
{option.method === 'STRIPE' ? 'Stripe' : option.method === 'BANK_TRANSFER' ? copy.bankTransfer : copy.check}
</button>
))}
</div>
</div>
{isManualMethod && !manualCheckout ? manualDetailsForm : null}
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
@@ -569,14 +985,154 @@ export default function SubscriptionPage() {
</div>
<button
onClick={handleCheckout}
disabled={paying || loading || !providerAvailability.stripe}
disabled={
paying
|| loading
|| !paymentOptions.some((option) => option.method === selectedMethod && option.enabled)
|| (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0))
}
className="btn-primary px-8 py-3"
>
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
{paying
? (selectedMethod === 'STRIPE' ? copy.redirecting : copy.submittingReview)
: selectedMethod === 'STRIPE'
? (subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow)
: copy.submitPaymentEvidence}
</button>
</div>
{paymentSubmission && paymentSubmission.status !== 'DRAFT' && !manualCheckout ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-semibold">{copy.awaitingVerification}</p>
{manualPaymentRequestNumber ? (
<p className="mt-1">
{copy.paymentRequestNumber}: <span className="font-semibold">{manualPaymentRequestNumber}</span>
</p>
) : null}
{manualPaymentRequestNumber ? <p className="mt-1 text-xs">{copy.paymentRequestHelp}</p> : null}
<p className="mt-1">{paymentSubmission.status} · {paymentSubmission.documents.length} file(s)</p>
</div>
) : null}
{manualCheckout ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-5" aria-live="polite">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-semibold text-amber-900">{copy.awaitingVerification}</p>
<p className="mt-1 text-sm text-amber-800">
{copy.invoiceNumber}: {manualCheckout.invoice.invoiceNumber ?? manualCheckout.invoice.id} · {formatCurrency(manualCheckout.invoice.amountDue || manualCheckout.invoice.amount, 'MAD')}
</p>
<p className="text-sm text-amber-800">
{copy.dueDate}: {manualCheckout.invoice.dueAt ? new Date(manualCheckout.invoice.dueAt).toLocaleDateString() : '—'}
</p>
</div>
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">{manualCheckout.invoice.status}</span>
</div>
<div className="mt-4 rounded-xl bg-white/80 p-4">
<p className="text-sm font-semibold text-slate-900">{copy.paymentInstructions}</p>
<dl className="mt-2 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
{Object.entries(manualCheckout.instructions).map(([key, value]) => (
<div key={key}><dt className="text-xs uppercase text-slate-500">{key}</dt><dd className="font-medium">{value}</dd></div>
))}
</dl>
</div>
{paymentSubmission && paymentSubmission.status !== 'DRAFT' ? (
<div className="mt-4 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-800">
<p className="font-semibold">{copy.evidenceSubmitted}</p>
<p className="mt-1">{paymentSubmission.status} · {paymentSubmission.documents.length} file(s)</p>
{paymentSubmission.rejectionReason ? <p className="mt-2 text-red-700">{paymentSubmission.rejectionReason}</p> : null}
</div>
) : (
<div className="mt-4 space-y-3">
{manualDetailsForm}
<button
type="button"
onClick={handleEvidenceSubmit}
disabled={submittingEvidence || paymentReference.trim().length < 3 || (evidenceFiles.length === 0 && !paymentSubmission?.documents.length)}
className="btn-primary"
>
{submittingEvidence ? copy.submittingReview : copy.submitReview}
</button>
</div>
)}
</div>
) : null}
</div>
{communicationSettings ? (
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.communicationTitle}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.communicationHelp}</p>
<div className="mt-5 grid gap-5 lg:grid-cols-3">
<div>
<p className="text-sm font-medium text-slate-700">{copy.enabledLanguages}</p>
<div className="mt-2 flex flex-wrap gap-3">
{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => {
const checked = communicationSettings.enabledCommunicationLocales.includes(locale)
return (
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase">
<input
type="checkbox"
checked={checked}
onChange={() => setCommunicationSettings((current) => {
if (!current) return current
const next = checked
? current.enabledCommunicationLocales.filter((item) => item !== locale)
: [...current.enabledCommunicationLocales, locale]
if (next.length === 0) return current
return {
...current,
enabledCommunicationLocales: next,
defaultCommunicationLocale: next.includes(current.defaultCommunicationLocale) ? current.defaultCommunicationLocale : next[0],
contacts: current.contacts.map((contact) => contact.locale && !next.includes(contact.locale) ? { ...contact, locale: null } : contact),
}
})}
/>
{locale}
</label>
)
})}
</div>
</div>
<label className="block text-sm font-medium text-slate-700">
{copy.defaultLanguage}
<select value={communicationSettings.defaultCommunicationLocale} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, defaultCommunicationLocale: event.target.value as CommunicationLocale } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2">
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
{copy.timezone}
<input value={communicationSettings.timezone} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, timezone: event.target.value } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2" />
</label>
</div>
<div className="mt-5 space-y-3">
{communicationSettings.contacts.map((contact, index) => (
<div key={contact.id ?? contact.email} className="grid gap-3 rounded-xl border border-slate-200 p-4 sm:grid-cols-[1fr,220px] sm:items-center">
<div>
<p className="text-sm font-medium text-slate-900">{contact.email}</p>
<p className="text-xs text-slate-500">{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}</p>
</div>
<label className="text-xs font-medium text-slate-600">
{copy.contactLanguage}
<select
value={contact.locale ?? ''}
onChange={(event) => setCommunicationSettings((current) => current ? { ...current, contacts: current.contacts.map((item, itemIndex) => itemIndex === index ? { ...item, locale: (event.target.value || null) as CommunicationLocale | null } : item) } : current)}
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm"
>
<option value="">{copy.inheritDefault}</option>
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
</div>
))}
</div>
<button type="button" onClick={saveCommunicationSettings} disabled={savingCommunicationSettings} className="btn-primary mt-5">
{savingCommunicationSettings ? copy.loading : copy.saveSettings}
</button>
</div>
) : null}
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
@@ -586,6 +1142,7 @@ export default function SubscriptionPage() {
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.invoice}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
@@ -595,11 +1152,21 @@ export default function SubscriptionPage() {
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm font-medium text-blue-700">
<a
href={`${resolveApiBase()}/subscriptions/invoices/${inv.id}/pdf`}
target="_blank"
rel="noreferrer"
className="hover:underline"
>
{inv.invoiceNumber ?? copy.invoice}
</a>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4">
+23 -1
View File
@@ -164,7 +164,29 @@ describe('dashboard apiFetch', () => {
process.env.NEXT_PUBLIC_API_URL = 'http://localhost:4000/api/v1'
api = await import('./api')
expect(api.resolveApiOrigin()).toBe('http://localhost:4000')
expect(api.resolveApiOrigin()).toBe('http://localhost')
})
it('uses the dashboard proxy for local browser requests to a different local API port', async () => {
installBrowser()
;(globalThis.window as any).location.origin = 'http://localhost:3000'
process.env.NEXT_PUBLIC_API_URL = 'http://localhost:4000/api/v1'
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { ok: true } }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch, resolveRealtimeSocketTarget } = await import('./api')
await apiFetch('/auth/employee/me')
expect(fetchMock).toHaveBeenCalledWith('/dashboard/api/v1/auth/employee/me', expect.objectContaining({
credentials: 'include',
}))
expect(resolveRealtimeSocketTarget()).toEqual({
origin: 'http://localhost:3000',
path: '/dashboard/socket.io',
})
})
it('routes proxied realtime connections through the dashboard socket path', async () => {
+25 -5
View File
@@ -15,11 +15,19 @@ function shouldUseDashboardProxy(configuredApiBase?: string): boolean {
if (!configuredApiBase) return true
if (configuredApiBase.startsWith('/')) return false
const currentHostname = window.location?.hostname
if (!currentHostname || isLocalBrowserHost(currentHostname)) return false
try {
return new URL(configuredApiBase).hostname !== currentHostname
const apiUrl = new URL(configuredApiBase)
const currentOrigin = window.location?.origin
const currentHostname = window.location?.hostname
if (!currentOrigin || !currentHostname) return false
if (isLocalBrowserHost(currentHostname) && isLocalBrowserHost(apiUrl.hostname)) {
return apiUrl.origin !== currentOrigin
}
if (isLocalBrowserHost(currentHostname)) return false
return apiUrl.origin !== currentOrigin
} catch {
return false
}
@@ -87,9 +95,21 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
}
if (!res.ok) {
const err = new Error(json?.message ?? json?.error ?? `Request failed with status ${res.status}`) as any
const issues = Array.isArray(json?.issues)
? json.issues
.map((issue: any) => {
const path = Array.isArray(issue?.path) && issue.path.length ? `${issue.path.join('.')}: ` : ''
return issue?.message ? `${path}${issue.message}` : null
})
.filter(Boolean)
: []
const message = issues.length
? `${json?.message ?? 'Invalid request'}: ${issues.join('; ')}`
: (json?.message ?? json?.error ?? `Request failed with status ${res.status}`)
const err = new Error(message) as any
err.code = json?.error
err.statusCode = res.status
err.issues = json?.issues
throw err
}
@@ -49,7 +49,7 @@ export default async function LocaleHomePage({ params }: LocalePageProps) {
const locale: Locale = localeValue;
const mode: ThemePreference = modeValue;
const messages = getMessages(locale);
const content = buildHomepageContent(messages.homepage, messages.shell, locale);
const content = buildHomepageContent(messages.homepage, messages.shell, locale, mode);
const integrationConfig = getPublicIntegrationConfig();
const homePath = localizedModePath('home', locale, mode);
+4 -2
View File
@@ -3,6 +3,7 @@ import type { PreviewRow } from '@/components/marketing/ProductPreview';
import type { HomepageMessages, ShellMessages } from '@/lib/localization/messages';
import type { Locale } from '@/lib/localization/config';
import { accountCreateUrl } from '@/lib/account-urls';
import type { ThemePreference } from '@/lib/theme/config';
import {
fleetBandIds,
pricingPlanIds,
@@ -267,6 +268,7 @@ export function buildHomepageContent(
homepage: HomepageMessages,
shell: ShellMessages,
locale: Locale = 'en',
mode: ThemePreference = 'dark',
): HomepageContent {
const pendingReason = shell.states.pendingBody;
@@ -275,7 +277,7 @@ export function buildHomepageContent(
eyebrow: homepage.hero.eyebrow,
title: homepage.hero.title,
body: homepage.hero.body,
primary: { label: homepage.hero.primary, href: accountCreateUrl(locale, 'dark') },
primary: { label: homepage.hero.primary, href: accountCreateUrl(locale, mode) },
secondary: { label: homepage.hero.secondary, disabledReason: pendingReason },
preview: {
title: homepage.preview.title,
@@ -438,7 +440,7 @@ export function buildHomepageContent(
eyebrow: homepage.final.eyebrow,
title: homepage.final.title,
body: homepage.final.body,
primary: { label: homepage.final.primary, href: accountCreateUrl(locale, 'dark') },
primary: { label: homepage.final.primary, href: accountCreateUrl(locale, mode) },
secondary: { label: homepage.final.secondary, disabledReason: pendingReason },
image: localizedImage(
'rentaldrivego',
@@ -37,6 +37,13 @@ describe('homepage content model', () => {
expect(content.final.secondary.disabledReason).toBe(enShell.states.pendingBody);
});
it('keeps create account links in the current homepage theme', () => {
const content = buildHomepageContent(enHomepage, enShell, 'en', 'light');
expect(content.hero.primary.href).toBe('/dashboard/sign-up?lang=en&theme=light');
expect(content.final.primary.href).toBe('/dashboard/sign-up?lang=en&theme=light');
});
it('includes explicit theme handoff in account creation URLs', () => {
expect(accountCreateUrl('en', 'light')).toBe('/dashboard/sign-up?lang=en&theme=light');
expect(accountCreateUrl('en', 'dark')).toBe('/dashboard/sign-up?lang=en&theme=dark');
+3 -1
View File
@@ -58,7 +58,7 @@ function collectBrowserApiSources() {
return sources
}
function buildSecurityHeaders({ assetSources = [], connectSources = collectBrowserApiSources() } = {}) {
function buildSecurityHeaders({ assetSources = [], connectSources = collectBrowserApiSources(), frameSources = [] } = {}) {
const assetOrigins = collectOrigins(assetSources)
const connectOrigins = collectOrigins(connectSources)
const websocketOrigins = [
@@ -76,6 +76,7 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows
const imgSrc = [...new Set(["'self'", 'data:', 'blob:', 'https:', ...assetOrigins, ...connectOrigins])]
const fontSrc = ["'self'", 'data:', ...assetOrigins]
const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])]
const frameSrc = [...new Set(["'self'", ...frameSources])]
if (process.env.NODE_ENV !== 'production') {
connectSrc.push('ws:')
}
@@ -95,6 +96,7 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows
`img-src ${imgSrc.join(' ')}`,
`font-src ${fontSrc.join(' ')}`,
`connect-src ${connectSrc.join(' ')}`,
`frame-src ${frameSrc.join(' ')}`,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
+3 -1
View File
@@ -159,7 +159,9 @@ Open `.env.docker.production` and fill in every value. The minimum required secr
| `POSTGRES_PASSWORD` | Strong random password |
| `JWT_SECRET` | Long random string (e.g. `openssl rand -hex 64`) |
| `ACME_EMAIL` | Your email for Let's Encrypt notifications |
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
| `EMAIL_PROVIDER` | Set to `gmail` to send through Gmail SMTP |
| `MAIL_USERNAME` | Gmail address used to send transactional mail |
| `MAIL_PASSWORD` | Gmail app password, not the normal Google account password |
| `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` |
| `STRIPE_API_KEY` | Live Stripe secret key or restricted key for production billing (`sk_live_` or `rk_live_`) |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret for `/api/v1/subscriptions/webhooks/stripe` (`whsec_`) |
+823
View File
@@ -0,0 +1,823 @@
# Manual Subscription Payment Plan
**Methods:** Bank transfer and check
**Existing online method:** Stripe remains available
**Scope:** Platform subscription payments, not rental-reservation/customer payments
**Status:** Implementation proposal based on the supplied code archive
**Revision:** 1.3 — adds company-driven Arabic, English, and French communication policy
## 1. Target outcome
Add a manual subscription-payment path alongside Stripe:
1. A company owner selects a plan and chooses **Bank transfer** or **Check**.
2. The server calculates the price and creates one pending subscription invoice.
3. The dashboard shows the amount, invoice number, due date, and configured payment instructions.
4. The owner pays outside the application, enters the transaction reference/check number, and uploads supporting evidence such as a transfer receipt or check copy.
5. The evidence is stored privately, scanned, and submitted to a finance review queue. Uploading evidence does not mark the invoice paid.
6. The finance admin reviews the evidence and independently verifies that the transfer settled or the check cleared.
7. The admin confirms the authoritative method and reference against the correct invoice.
8. In one database transaction, the system records the payment, accepts the evidence submission, marks the invoice paid, activates the purchased subscription period, and writes billing and audit events.
Stripe must continue to use webhook-confirmed payment. A reference entered by an admin must never be treated as independent proof that money was received.
For every renewal, the system must also run the collections schedule in section 9: targeted pre-expiration reminders, a required platform-admin call task, a 30-day active grace period, daily overdue notices, and suspension at the start of day 31 unless payment is confirmed or an authorized override is active.
## 2. Critical current-state findings
| Area | Current code | Consequence |
| --- | --- | --- |
| Customer subscription UI | `dashboard/src/app/(dashboard)/subscription/page.tsx` hard-codes `provider = 'STRIPE'` | There is no manual choice or payment-instruction screen. |
| Subscription API contract | `api/src/modules/subscriptions/subscription.schemas.ts` accepts only `STRIPE` | Bank transfer/check requests fail validation. |
| Subscription checkout | `api/src/modules/subscriptions/subscription.service.ts` always creates a Stripe Checkout session | A non-Stripe pending invoice cannot be created. |
| Admin payment API | `api/src/modules/admin/admin.schemas.ts` already accepts an optional `providerPaymentId` | The backend has a partial manual-recording concept, but method/reference are optional and there is no idempotency contract. |
| Admin payment service | `api/src/modules/admin/admin.billing.service.ts::payInvoice` immediately writes successful payment records | It lacks required manual-method validation, reference deduplication, concurrency protection, and a cleared-funds attestation. |
| Admin UI | `admin/src/app/dashboard/billing/page.tsx` sends only an optional amount and labels it “Amount in cents” | The transaction reference is never sent even though the API can accept one. The money input is also error-prone. |
| Invoice architecture | Stripe creates `SubscriptionInvoice`; the finance console operates on `BillingInvoice` | There are two invoice lifecycles. The existing legacy sync copies data in one direction only. An admin payment can leave the customer-visible invoice pending. |
| Subscription activation | Admin payment calls `maybeRestoreSubscription`, which changes status to `ACTIVE` | It does not reliably apply the purchased plan or establish a new `currentPeriodStart`/`currentPeriodEnd`. |
| Upload infrastructure | `api/src/http/upload/index.ts` validates images only; `api/src/lib/storage.ts::uploadImage` always writes `.jpg` files | It cannot safely represent PDF receipts or preserve document type. A dedicated private-document pipeline is required. |
| Subscription scheduler | `api/src/index.ts` uses fixed server-time cron jobs and hard-coded 7-day `PAYMENT_PENDING`/`PAST_DUE` transitions | It cannot implement company-local 14-day/7-day/48-hour/24-hour milestones or the required 30-day full-access grace period. |
| Company notification targeting | `notificationService.ts` can target one employee or every company employee; no billing-contact audience exists | Using the company-wide audience would notify unrelated users. Explicit billing contacts are required. |
| Localization foundation | Dashboard/admin i18n providers and notification locale/template resolution already support Arabic, English, and French; company brand data has a default locale | The foundation can be reused, but billing-contact locale resolution, company-enabled language constraints, template parity, and audit snapshots still need to be added. |
| Platform-admin notifications | The admin notifications page is a cross-company delivery audit, not a personal admin inbox | Platform-admin milestones and call tasks need assigned admin recipients and actionable task state. |
| Timezone configuration | No company/billing timezone field is exposed in the supplied schemas | Deadline calculation cannot safely use local calendar days, especially across daylight-saving/offset changes. |
| Existing manual payments | `api/src/modules/payments/*` and `api/src/modules/billing/*` record manual rental payments | These flows concern rental reservations and mark payments successful immediately; they should not be reused for platform subscriptions. |
| Database source | The API imports `@rentaldrivego/database`, but that package/schema is not in the archive | The schema and migration changes below must be made in the full monorepo before implementation can compile. |
The main prerequisite is to stop treating `SubscriptionInvoice` and `BillingInvoice` as independent sources of truth. Adding fields to the admin form without resolving that split would create inconsistent financial state.
## 3. Product decisions for the MVP
- Keep Stripe and add two offline collection methods: `BANK_TRANSFER` and `CHECK`.
- Only company owners may create a subscription-payment request.
- Only finance admins with fresh 2FA may confirm a manual payment.
- “Confirm payment” means the admin attests that the bank transfer settled or the check cleared. Merely receiving a check is not enough.
- The customer submits the reference and evidence through the application. This replaces an unaudited email/WhatsApp handoff. The admin still confirms the authoritative reference after review.
- Require at least one clean evidence document for a manual subscription-payment submission. Recommended MVP formats: PDF, JPEG, and PNG; maximum 10 MB per file, 3 files, and 20 MB total per submission.
- Store evidence in private persistent storage, never in a public `/storage` path, database blob, source tree, or ephemeral container filesystem.
- Evidence is supporting material, not proof of settlement. A convincing-looking receipt must not activate a subscription.
- Manual subscription invoices must be paid in full for activation. Short, excess, or partial payments go to an exception workflow; they must not silently activate a plan.
- The server, never the browser, calculates the plan price.
- Store all amounts as integer minor units. The UI displays MAD values and must not ask admins to think in “cents.”
- Make `BillingInvoice` the canonical invoice. Treat `SubscriptionInvoice` as a compatibility model during migration, then retire it.
- A pending manual invoice has a method-specific due date. Generic Stripe timeout behavior must not suspend a customer while a check is still within its configured clearing window.
- Payment notices go only to explicitly configured company billing contacts and company administrators. In this codebase, “company administrator” maps to the `OWNER` role; never use the `COMPANY_EMPLOYEES` audience for collections.
- Each billing account has one assigned platform collections owner, with a monitored finance queue as fallback. Do not notify every platform admin.
- Renewal invoices must be finalized early enough for the first 14-day reminder. Recommended: generate/finalize them at least 21 days before the subscription period ends.
- Keep the subscription fully active throughout the 30-day grace period. Delinquency is a collections state, not restricted entitlement, until suspension begins on day 31.
- A created or pending payment attempt does not stop follow-up. Only canonical, successfully confirmed payment does.
- Classify required payment notices as transactional/mandatory. A company must always retain at least one valid billing recipient.
- Support exactly three communication languages for this release: Arabic (`ar`), English (`en`), and French (`fr`). Each company selects a non-empty set of enabled languages and one default language from that set.
- Resolve one language for each recipient and communication. Do not send the same notice in all enabled languages: that would create duplicate emails/in-app alerts when the company selected multiple languages.
### Communication language policy
Customer-facing dashboard copy, in-app notifications, emails, payment instructions, evidence-review messages, reminders, confirmations, dispute/extension notices, and suspension messages must use this deterministic order:
1. Use the billing contact's explicit `locale` when it is one of the company's enabled communication languages.
2. Otherwise, for a linked employee, use that employee's preferred language when it is enabled for the company.
3. Otherwise, use the company's `defaultCommunicationLocale`.
4. Use English only as a controlled emergency fallback when the configured template is unavailable. Record a localization-fallback audit event, alert operations, and do not silently mix English fragments into Arabic or French messages.
An external email-only billing contact may set a locale from the company's enabled list; otherwise the company default applies. A company-language change affects future unsent communications only. It must not resend old notices or rewrite the locale/template snapshot of communications already created.
Arabic output must set `lang="ar"` and `dir="rtl"`; English and French use `dir="ltr"`. Dates, times, number grouping, and currency presentation use the resolved locale together with the company's IANA timezone. Canonical timestamps, currency codes, and integer minor-unit amounts remain unchanged in storage.
Internal platform-admin instructions may use the assigned admin's own preferred locale so they remain operationally clear. However, each call task and customer-contact workflow must display the company's default language, the individual contact's resolved language, and a customer-facing call script/message in that language. Any email or message sent to the customer from the admin workflow must use the customer's resolved company-approved language.
## 4. State model and invariants
### Invoice lifecycle
`DRAFT``OPEN``PAID`
Additional existing states such as `PAST_DUE`, `VOID`, and `UNCOLLECTIBLE` remain valid. A fully verified payment may move `OPEN` or `PAST_DUE` to `PAID`. It must not pay a `VOID`, `REFUNDED`, or `UNCOLLECTIBLE` invoice.
### Subscription lifecycle
- For an initial purchase, creating a manual invoice sets the subscription to `PAYMENT_PENDING` without granting paid access.
- Creating a renewal invoice must not change an active subscription to `PAYMENT_PENDING` or reduce current access.
- A full admin-confirmed initial payment activates at confirmation time. `receivedAt` remains the financial settlement/clearing time.
- A confirmed renewal extends service from the immutable original expiration/current-period end, not from the early payment date. Payment during grace remains anchored to that original expiration so the grace service is not silently free.
- A renewal plan change is scheduled for the period boundary; it must not discard remaining days from the current paid period.
- Partial payment, duplicate confirmation, or failed validation must not change entitlement.
### Evidence-submission lifecycle
Payment submission: `DRAFT``SUBMITTED``UNDER_REVIEW``APPROVED | REJECTED`
Each document has an independent security state: `UPLOADED``SCANNING``CLEAN | QUARANTINED | SCAN_FAILED`.
- A submission cannot move to `SUBMITTED` until it has at least one `CLEAN` document.
- Customers may replace/delete documents only while the submission is `DRAFT`.
- Submitted evidence is immutable. Corrections are appended as a new submission/version; historical evidence is not overwritten.
- Admin approval is allowed only after every attached document is clean and the funds have been independently verified.
### Collections lifecycle
Keep collections state separate from subscription entitlement:
`SCHEDULED``PRE_DUE``GRACE_PERIOD``RESOLVED | SUSPENDED`
An authorized dispute/extension creates an overlay state, `SUSPENSION_PAUSED`, without erasing the underlying invoice or schedule.
- `PRE_DUE` begins when the renewal invoice enters the 14-day reminder window.
- At expiration, an unpaid case enters `GRACE_PERIOD`; the subscription remains `ACTIVE` with full access.
- Confirmed payment moves the case to `RESOLVED`, cancels queued follow-up, and keeps/restores the subscription `ACTIVE`.
- At the company-local time boundary that begins day 31—30 calendar days after the exact expiration time—an unpaid, unpaused case moves to `SUSPENDED` and the subscription becomes `SUSPENDED`.
- A payment attempt, uploaded receipt, submitted evidence, payment promise, or call outcome does not resolve the case.
### Non-negotiable invariants
- One authoritative invoice balance.
- One successful confirmation per external payment reference within its permitted uniqueness scope.
- One result for repeated requests with the same idempotency key.
- No invoice can be overpaid through this endpoint.
- Invoice update, payment record, subscription activation, billing event, and audit event succeed or fail together.
- No tenant can view or mutate another tenants invoice.
- References are normalized for comparison but preserved in their original form for finance review.
- Evidence bytes are private, content-validated, malware-scanned, encrypted at rest, and retrievable only through authorized invoice-scoped routes.
- Payment confirmation and evidence approval refer to the same invoice and submission; a document cannot be attached across tenants or reused to approve another invoice.
- Collections milestones are computed in the billing accounts valid IANA timezone, never a fixed numeric UTC offset.
- Every scheduled milestone, recipient delivery, call task, call outcome, override, confirmation, and suspension has a stable idempotency key and append-only audit evidence.
- Every customer-facing delivery stores its resolved locale, template key/version, company timezone, rendered-subject/body snapshot or immutable render inputs, and fallback status for exact audit reproduction.
- Missing/invalid timezone or missing billing recipients fails safe: alert the assigned platform collections owner and do not auto-suspend until configuration is corrected.
## 5. Proposed data changes
Make these changes in the missing `@rentaldrivego/database` package.
### `BillingInvoice`
Add or formalize:
- `collectionMethod`: `STRIPE | BANK_TRANSFER | CHECK`
- `requestedPlan`: nullable plan enum
- `requestedBillingPeriod`: nullable billing-period enum
- `manualPaymentDueAt`: nullable timestamp, or use the existing `dueAt` consistently
- `legacySubscriptionInvoiceId`: nullable unique relation during migration, instead of relying only on JSON metadata
### `BillingPaymentAttempt` or a dedicated immutable payment record
Add first-class, queryable fields rather than hiding them only in metadata:
- `channel`: `ONLINE | OFFLINE`
- `manualMethod`: nullable `BANK_TRANSFER | CHECK`
- `externalReference`: nullable original reference
- `normalizedExternalReference`: nullable normalized reference
- `receivedAt`: nullable settlement/clearance time
- `confirmedAt`: nullable admin-confirmation time
- `confirmedByAdminId`: nullable admin relation
- `idempotencyKey`: nullable UUID
- `note`: nullable, bounded text
### `ManualPaymentSubmission`
Add a review entity separate from the successful payment record:
- `id`, `invoiceId`, `billingAccountId`, and `companyId`
- `method`: `BANK_TRANSFER | CHECK`
- `submittedReference` and `normalizedSubmittedReference`
- `status`: `DRAFT | SUBMITTED | UNDER_REVIEW | APPROVED | REJECTED`
- `submittedByEmployeeId` and `submittedAt`
- `reviewedByAdminId`, `reviewedAt`, and bounded `rejectionReason`
- `idempotencyKey`
- relation to the final `BillingPaymentAttempt` after approval
The customer-supplied reference is evidence. The final payment record stores the admin-confirmed reference. If they differ, require an admin correction reason and preserve both values.
### `ManualPaymentDocument`
Store metadata and a private storage key, not file bytes:
- `id`, `submissionId`, `invoiceId`, `companyId`
- `kind`: `BANK_TRANSFER_RECEIPT | CHECK_COPY | OTHER_SUPPORTING_EVIDENCE`
- random `storageKey`; never use the original filename as a filesystem path
- sanitized `originalFilename`, detected MIME type, detected extension, and byte size
- SHA-256 digest for integrity and duplicate detection
- `scanStatus`: `UPLOADED | SCANNING | CLEAN | QUARANTINED | SCAN_FAILED`
- scanner result code without copying raw scanner output into user-visible errors
- `uploadedByEmployeeId`, `uploadedAt`, and optional draft-only deletion metadata
### `BillingAccount` and `BillingContact`
Add to the billing account:
- required IANA `timezone` such as `Africa/Casablanca`
- nullable `collectionsOwnerAdminId` identifying the internal owner
- configurable reminder send time in local time; recommended default `09:00`
- `enabledCommunicationLocales`: a non-empty set containing only `ar`, `en`, and/or `fr`
- `defaultCommunicationLocale`: one of the enabled values
Add explicit billing recipients rather than broadcasting to the company:
- `BillingContact`: `billingAccountId`, nullable linked `employeeId`, email, `locale`, `isPrimary`, `receivePaymentNotices`, and active/verified state
- linked employees receive in-app and email; an external billing email can receive email only
- active `OWNER` recipients may be designated as company administrators for payment notices
- require at least one primary active recipient; never silently expand to every employee
- validate a contact/employee locale against the company's enabled languages; missing values inherit the company default
Database constraints must require a non-empty enabled-language set, allow only `ar | en | fr`, and require the default language to be a member of the enabled set. Backfill the company language from its existing brand/default locale when that value is supported; otherwise use `en` and flag the company for owner review.
### `CollectionsCase`
Create one case per canonical renewal invoice:
- `invoiceId`, `subscriptionId`, `billingAccountId`, `companyId`
- `status`: `SCHEDULED | PRE_DUE | GRACE_PERIOD | RESOLVED | SUSPENDED`
- immutable `originalExpirationAt`
- `graceStartedAt`, `finalSuspensionAt`, `resolvedAt`, and `suspendedAt`
- `nextActionAt` for efficient worker queries
- assigned `collectionsOwnerAdminId`
- `resolutionPaymentAttemptId` when paid
- optimistic version or row-claim fields for concurrent workers
### `CollectionsCallTask`
- one unique `PRE_EXPIRY_48H_CALL` task per collections case
- assigned platform admin, due time, status `OPEN | COMPLETED | CANCELLED`
- outcome `CONTACTED | NO_ANSWER | PAYMENT_PROMISED | ISSUE_ESCALATED`
- bounded notes, `promisedPaymentAt`, `nextFollowUpAt`, completed admin/time, and cancellation reason
The task remains open until an admin records an outcome. A payment confirmed before the call is completed cancels it with a system reason rather than fabricating a human outcome. `PAYMENT_PROMISED` closes the required 48-hour call but must create a dated follow-up action; it does not resolve the collections case.
### `CollectionsOverride`
- type `PAYMENT_DISPUTE | MANUAL_EXTENSION`
- required reason, authorizing admin, created time, start/end time, and revoked time/admin
- `pauseSuspension` and separately controlled `pauseNotifications`
- extension-specific revised suspension date without changing `originalExpirationAt`
- only `FINANCE`, `ADMIN`, or `SUPER_ADMIN` with fresh 2FA may create/revoke an override
### Platform-admin notification recipients
Extend the notification audience/recipient model to target a specific `AdminUser`. The current platform notification page remains the delivery audit, but collections admins also need a personal in-app inbox/badge. Do not emulate an admin notification by sending to a company employee.
Add or reuse `AdminUser.preferredLocale` for internal admin UI/email. Store the company's default and each contact's resolved locale on the call-task snapshot so a reassignment never loses the language required for customer contact.
Recommended database constraints:
- Unique `(billingAccountId, idempotencyKey)` when `idempotencyKey` is not null.
- Unique `(billingAccountId, manualMethod, normalizedExternalReference)` for successful offline payments.
- Unique submission idempotency key within the billing account and unique document storage keys.
- Unique `CollectionsCase.invoiceId`, unique `(collectionsCaseId, taskType)`, and unique notification event `(companyId, idempotencyKey)` constraints.
- Check constraints requiring manual method, reference, confirmation time, and confirming admin when `channel = OFFLINE` and status is `SUCCEEDED`.
- Index pending invoices by `(collectionMethod, status, dueAt)` for finance queues and dunning jobs.
- Index submissions by `(status, submittedAt)` and documents by `(scanStatus, uploadedAt)` for review and quarantine workers.
- Index collections cases by `(status, nextActionAt)` and open call tasks by `(assignedAdminId, status, dueAt)`.
The application should also warn finance when the same normalized reference appears on another billing account. Do not make check numbers globally unique: different customers can legitimately use the same check number.
### Migration and backfill
1. Add nullable fields and indexes first.
2. Backfill `BillingInvoice` rows from linked `SubscriptionInvoice` rows.
3. Backfill collection method from the legacy provider.
4. Verify counts, totals, currency, paid status, and links before changing reads.
5. Switch customer and admin reads to `BillingInvoice`.
6. Keep compatibility writes to `SubscriptionInvoice` only during one release window if rollback requires them.
7. Backfill a verified IANA timezone and at least one explicit billing contact for every live billing account. Do not enable automatic suspension for incomplete accounts.
8. Create collections cases only for canonical unpaid renewal invoices; do not infer them from unconfirmed payment attempts.
9. Remove lazy, request-time legacy synchronization after the backfill is proven.
## 6. API design
### Customer: available methods and instructions
`GET /api/v1/subscriptions/payment-options`
Return only enabled methods and safe payer-facing instructions. Example:
```json
{
"methods": [
{
"method": "STRIPE",
"enabled": true
},
{
"method": "BANK_TRANSFER",
"enabled": true,
"instructions": {
"accountName": "RentalDriveGo",
"bankName": "Configured bank",
"accountReference": "Configured RIB/IBAN",
"message": "Include the invoice number in the transfer description"
}
},
{
"method": "CHECK",
"enabled": true,
"instructions": {
"payee": "RentalDriveGo",
"deliveryAddress": "Configured address",
"message": "Write the invoice number on the check"
}
}
]
}
```
Payment instructions should come from validated platform configuration, not JSX constants. Bank credentials used to access the bank account are secrets and must never be returned; receiving-account details intentionally shown to payers are not application credentials.
### Customer: create a manual subscription invoice
`POST /api/v1/subscriptions/manual-checkout`
```json
{
"plan": "GROWTH",
"billingPeriod": "ANNUAL",
"currency": "MAD",
"method": "BANK_TRANSFER",
"idempotencyKey": "uuid"
}
```
The response returns the canonical invoice ID/number, exact amount, due date, selected method, and instructions. Do not accept a customer-supplied amount, paid status, transaction reference, or company ID.
Rules:
- Require authenticated company owner and tenant context.
- Recalculate price from `PricingConfig`/shared plan prices.
- Reuse the result for a repeated idempotency key.
- Prevent multiple open manual invoices for the same subscription purchase. Require the owner to cancel/replace an existing open request when changing plan or method.
- Set `PAYMENT_PENDING`; do not activate access.
Keep the current Stripe checkout endpoint, but migrate it to create the same canonical `BillingInvoice` before creating the Stripe Checkout session.
### Customer: create and submit payment evidence
Use a staged workflow so upload retries cannot create duplicate payment submissions.
1. `POST /api/v1/subscriptions/invoices/:invoiceId/manual-payment-submissions`
Creates or returns a draft submission using a UUID idempotency key. Accept `method` and `submittedReference`; derive company and invoice ownership from authentication and the route.
2. `POST /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents`
Accept one multipart field named `file` plus a document `kind`. Only an owner from the same company may upload while the submission is `DRAFT`.
3. `DELETE /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents/:documentId`
Allow only draft cleanup. After submission, documents are immutable and corrections must be appended through a replacement submission.
4. `POST /api/v1/subscriptions/manual-payment-submissions/:submissionId/submit`
Require the invoice to remain payable, the method to match, a reference to be present, and at least one clean document. Freeze the submission and notify the finance queue.
5. `GET /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents/:documentId`
Stream a clean document only after re-validating the authenticated owner, company, invoice, and submission relationships. Never redirect to a stable public URL.
Return document metadata and scan state, never a public storage URL. Customer document reads/downloads must re-check session, tenant, invoice, and submission ownership on every request.
### Private document pipeline
Do not reuse `imageUpload` or `uploadImage` unchanged. Add a dedicated payment-evidence upload path:
- Accept only PDF, JPEG, and PNG for the MVP.
- Detect content from file signatures/structure. Do not trust `Content-Type`, filename, or extension supplied by the browser.
- Reject mismatched MIME/extension, SVG, HTML, office documents, executables, archives, password-protected/unscannable files, polyglots, oversized images, and files over the configured limits.
- Calculate SHA-256 while ingesting and assign a random storage key.
- Write first to a private quarantine location, run malware scanning, and move/mark the object usable only after a clean result. If the scanner is unavailable or times out, fail closed and keep the file unavailable.
- Use persistent private storage that works across API instances. The existing `FILE_STORAGE_ROOT` abstraction may be extended if it points to durable shared storage; object storage with private keys is preferable for multi-instance deployment.
- Never expose the existing generic `/storage` static route. Stream through protected API handlers or issue very short-lived, invoice-scoped signed downloads.
- For raw files, set a sanitized `Content-Disposition`, exact detected `Content-Type`, `X-Content-Type-Options: nosniff`, and restrictive private/no-store caching. Prefer generated image previews; do not render active or untrusted content as application HTML.
- Audit upload, submit, view/download, reject, approve, quarantine, and deletion events without logging document bytes or full financial references.
- Define retention and legal-hold behavior. After submission, deletion must follow the approved retention policy rather than a user-facing hard delete.
Add finance endpoints for the review queue and protected evidence access:
- `GET /api/v1/admin/billing/manual-payment-submissions?status=SUBMITTED`
- `GET /api/v1/admin/billing/manual-payment-submissions/:submissionId`
- `GET /api/v1/admin/billing/manual-payment-submissions/:submissionId/documents/:documentId`
- `POST /api/v1/admin/billing/manual-payment-submissions/:submissionId/reject`
Rejection requires a bounded reason, preserves the evidence, notifies the owner, and leaves the invoice unpaid.
### Admin: confirm a cleared manual payment
Use an explicit endpoint instead of overloading the generic `/pay` action:
`POST /api/v1/admin/billing/invoices/:invoiceId/manual-payments`
```json
{
"submissionId": "manual_submission_id",
"method": "BANK_TRANSFER",
"externalReference": "BANK-TXN-123456",
"amount": 120000,
"receivedAt": "2026-08-09T14:30:00.000Z",
"note": "Matched to the platform bank statement",
"idempotencyKey": "uuid",
"fundsVerified": true
}
```
Validation:
- `method` must match the invoice collection method unless a finance override with a required reason is implemented.
- `submissionId` must identify a submitted, clean, same-company evidence package for this invoice.
- Reference is required, trimmed, normalized, length-bounded, and uses a conservative printable-character policy.
- `amount` must equal the full current balance for subscription invoices.
- `receivedAt` cannot be unreasonably far in the future.
- `fundsVerified` must be explicitly true; the confirmation screen must explain that the receipt/check image alone is insufficient.
- Require `FINANCE`, fresh 2FA, CSRF protection where applicable, and an idempotency key.
- Return `409` for already-paid invoices, reference conflicts, stale balances, or repeated confirmation with a different payload.
### Customer reads
Rewrite `GET /api/v1/subscriptions/invoices` to read canonical billing invoices. Include:
- invoice number and amount
- collection method
- status and due date
- paid/confirmed time
- a masked reference after confirmation
- requested plan and billing period
- evidence-submission status and safe document metadata
Do not expose admin-only notes or other companies references.
### Billing recipients and collections operations
Company-owner endpoints:
- `GET /api/v1/subscriptions/billing-contacts`
- `PUT /api/v1/subscriptions/billing-contacts`
- `GET /api/v1/subscriptions/communication-settings`
- `PUT /api/v1/subscriptions/communication-settings`
Require at least one active primary contact. Validate that an in-app recipient is an active employee of the authenticated company. An external email-only contact must be verified before replacing the last verified recipient.
The communication-settings write accepts only the supported base language codes and validates all contacts atomically:
```json
{
"enabledCommunicationLocales": ["ar", "fr"],
"defaultCommunicationLocale": "fr",
"contacts": [
{
"email": "billing@example.ma",
"locale": "ar",
"isPrimary": true,
"receivePaymentNotices": true
}
]
}
```
Reject unsupported/empty locale sets, a default outside the enabled set, or a contact locale outside the enabled set. Return the effective locale for each contact. An optional authenticated preview endpoint may render subject/body samples for every enabled language without sending them, using synthetic non-sensitive values.
Platform collections endpoints:
- `GET /api/v1/admin/billing/collections?status=&assignedTo=&actionDueBefore=`
- `GET /api/v1/admin/billing/collections/:caseId`
- `PATCH /api/v1/admin/billing/collections/:caseId/assignee`
- `POST /api/v1/admin/billing/collection-tasks/:taskId/outcomes`
- `POST /api/v1/admin/billing/collections/:caseId/overrides`
- `POST /api/v1/admin/billing/collections/:caseId/overrides/:overrideId/revoke`
Call-outcome payload:
```json
{
"outcome": "PAYMENT_PROMISED",
"note": "Customer expects the transfer to settle tomorrow",
"promisedPaymentAt": "2026-09-15T12:00:00.000Z",
"nextFollowUpAt": "2026-09-15T15:00:00.000Z"
}
```
Require `promisedPaymentAt`/`nextFollowUpAt` for `PAYMENT_PROMISED` and a note for `NO_ANSWER` or `ISSUE_ESCALATED`. Recording an outcome closes the required call task but never marks the invoice paid.
Override creation requires fresh 2FA, an authorized role, a bounded reason, and a finite expiry/revised suspension date. Indefinite overrides should be prohibited unless a `SUPER_ADMIN` uses a separately audited emergency path.
## 7. Atomic confirmation algorithm
The confirmation service must run as one database transaction:
1. Resolve the idempotency key. Return the original result if the same request already succeeded.
2. Read the canonical invoice, billing account, evidence submission, and document metadata with a write lock, or use conditional updates that detect concurrent changes.
3. Validate tenant/account linkage, payable state, collection method, currency, full balance, requested subscription data, submitted evidence state, and that every attached document is clean.
4. Confirm that the submission belongs to this invoice and has not already been approved/rejected. Evidence from another invoice must never be reusable.
5. Normalize the admin-confirmed reference, compare it with the submitted reference, require a reason for a correction, and enforce the database uniqueness constraint.
6. Create an immutable successful offline payment attempt with submission link, method, original/normalized confirmed reference, settlement time, admin identity, and idempotency key.
7. Mark the evidence submission `APPROVED` and link it to that payment attempt. Evidence bytes remain immutable.
8. Conditionally update the invoice balance and status. The affected-row count must be exactly one.
9. Apply the correct period rule: initial/lapsed purchase starts at confirmation; renewal starts at the cases immutable `originalExpirationAt` and extends from that anchor. Schedule renewal plan changes for the boundary rather than truncating the current period.
10. Clear payment-pending/past-due/suspension fields and retry counters.
11. Mark the related collections case `RESOLVED`, clear `nextActionAt`, and cancel any open call/follow-up tasks with the system reason `PAYMENT_CONFIRMED`.
12. Write `payment_evidence.approved`, `invoice.paid`, `subscription.activated`, `collections.resolved`, and admin audit records through the same transaction handle.
13. During the compatibility window, update the linked legacy `SubscriptionInvoice` in the same transaction.
14. Commit, suppress/cancel unsent collections deliveries, then send the payment receipt/resolution notification. Notification failure must not roll back a valid payment; it should enter a retry queue.
Do not keep the current pattern where the audit write happens after the billing transaction. That leaves a financial mutation without guaranteed audit evidence if the second write fails.
Stripe webhook success and admin-confirmed manual payment must call the same canonical payment-finalization routine so both stop collections identically. The notification dispatcher must re-check that the collections case is unresolved immediately before delivery; otherwise a reminder queued seconds before payment could still be sent afterward.
## 8. User experience changes
### Company dashboard
Update `dashboard/src/app/(dashboard)/subscription/page.tsx`:
- Replace the hard-coded Stripe provider with accessible Stripe, bank-transfer, and check choices returned by the API.
- Keep plan and billing-period selection.
- For Stripe, preserve the redirect flow.
- For a manual method, create the pending invoice and show a confirmation panel with invoice number, amount, due date, copyable instructions, and “Awaiting verification” status.
- Add a payment-evidence form for transaction reference/check number and one to three PDF/JPEG/PNG documents.
- Show per-file size/type validation, upload/scan progress, clean/quarantined/failed status, retry behavior, and accessible removal controls while the submission remains a draft.
- Require an explicit final **Submit for review** action. Explain that upload does not prove settlement or activate the subscription.
- After submission, show immutable evidence metadata and review state rather than editable controls.
- Display method and status in invoice history.
- During pre-due/grace states, show a persistent billing banner only to billing contacts and `OWNER` users. Include amount due, original expiration date, local grace-day count, days remaining, final suspension date, and a stable link to Stripe checkout or manual instructions/evidence submission.
- Add billing-contact management for owners and require one verified primary contact before the current contact can be removed.
- Add company communication settings where an owner enables Arabic, English, and/or French and selects exactly one default. Contact-language choices must be limited to the enabled set and show the effective inherited value.
- Disable repeated submission while a request is in flight and render idempotent retries safely.
- Add complete English, French, and Arabic copy for subscription checkout, payment instructions, evidence upload/review, collections banners, validation, and receipts. Arabic pages and email previews must be verified right-to-left.
### Admin finance console
Update `admin/src/app/dashboard/billing/page.tsx`:
- Replace the current inline “Amount in cents” control with a focused **Confirm manual payment** dialog.
- Show company, invoice number, plan, billing period, collection method, currency, total, balance due, and due date before confirmation.
- Require method, reference/check number, settlement/clearance date, optional note, and the cleared-funds checkbox.
- Show the submitted reference, scan status, document type, uploader, timestamp, and protected preview/download controls.
- Never render customer filenames or document content as HTML. Clearly distinguish customer-supplied evidence from independently verified bank/check data.
- Add **Reject evidence** with a required reason. Rejection must not change the invoice balance or subscription entitlement.
- Default the amount to the full balance and display it as MAD; keep minor-unit conversion outside free-form admin input.
- Require a final review step and handle fresh-2FA errors explicitly.
- Disable the submit button while pending and reuse the same idempotency key on transport retry.
- Show the masked reference and confirming admin in payment history. Show the full reference only in the authorized detail view if finance operations require it.
- Keep generic partial payment controls separate for non-subscription invoices if that capability is still required.
- Add an assigned collections queue ordered by overdue action, with case status, company-local deadline, amount due, contact details, latest notice, and open tasks.
- Provide personal admin in-app notifications/badge for assigned milestones rather than treating the global delivery-audit page as an inbox.
- The 48-hour call task must be visibly open until an outcome is saved. `PAYMENT_PROMISED` must show its promised date and generate the next follow-up action.
- Show the company's default language and the selected contact's effective language on each call task, with the correct localized customer script/template. Admin-generated customer messages must not allow a language outside the company's enabled set.
- Show active disputes/extensions prominently, including who authorized them, reason, expiry/revised suspension date, and whether notices are paused.
## 9. Subscription-payment follow-up, grace period, and suspension
### Roles and recipient resolution
- **Company billing contact/admin:** the customer responsible for payment. Resolve only explicit active billing contacts and designated active `OWNER` employees. Send in-app only to linked employees and email only to verified addresses.
- **Platform admin:** the assigned internal collections owner. Use a finance queue fallback if the assignee is inactive or missing; never fan out to every admin.
- Never use `COMPANY_EMPLOYEES` for payment notices. Resolve and snapshot recipients per event so the audit log shows exactly who was targeted.
- For every customer recipient, resolve the communication locale using section 3 before rendering either channel. Email and in-app deliveries for the same event/recipient must use the same resolved locale and template version.
- Internal platform-admin notices may use the admin's preferred locale, but the task must carry the company/contact language and localized customer script. Customer-facing content sent through the task always uses the customer recipient's resolved company-approved locale.
### Before expiration
| Relative time | Company billing contact/admin | Assigned platform admin | Required action |
| --- | --- | --- | --- |
| 14 days before | In-app + email | In-app | Payment reminder |
| 7 days before | In-app + email | In-app | Second reminder |
| 48 hours before | In-app + email | In-app + email | Create the unique required-call task |
| 24 hours before | In-app + email | In-app + email | Final expiration warning |
At the 48-hour threshold:
- Create exactly one `PRE_EXPIRY_48H_CALL` task and assign it to the collections owner.
- The task remains `OPEN` until the admin records `CONTACTED`, `NO_ANSWER`, `PAYMENT_PROMISED`, or `ISSUE_ESCALATED`.
- Require notes for unsuccessful/escalated outcomes. Require a promised-payment date and next follow-up time for `PAYMENT_PROMISED`.
- An overdue open call task escalates within the platform finance queue; it must not generate extra company notices.
- If payment is confirmed before the call occurs, cancel the open task as `PAYMENT_CONFIRMED` and do not require a fictitious call outcome.
Each reminder includes the invoice number, amount due, subscription expiration time in the company timezone, and a stable dashboard link that presents Stripe payment or the configured bank/check instructions. Do not place expiring signed URLs in email.
### After expiration: 30-day active grace period
If canonical payment is not confirmed at expiration:
1. Move the collections case to `GRACE_PERIOD`, but keep the subscription `ACTIVE` with full access.
2. Send exactly one in-app notification and one email per local calendar day to each current billing recipient.
3. Include amount due, original expiration date, current grace day, days remaining, final suspension time, and payment link/instructions.
4. Use the day-30 daily notice as the final warning; do not send a second duplicate “final” notice that day.
5. At the local-time boundary that begins day 31—30 calendar days after expiration in the configured IANA timezone—atomically re-check payment and overrides, then suspend if still eligible.
The suspension transaction must conditionally change the collections case and subscription once, write audit/billing events, and queue the suspension notice. If a confirmed payment wins the race, suspension affects zero rows and does nothing.
### Stop condition
Reminders stop only when the canonical invoice is paid by a successfully confirmed payment. These do **not** stop follow-up:
- created/processing/failed payment attempt
- Stripe Checkout session creation
- manual reference entry by the customer
- evidence upload or submission
- call outcome or payment promise
- email delivery/read status
On confirmation:
- resolve the collections case in the same transaction as invoice/subscription payment finalization
- clear `nextActionAt` and cancel open call/follow-up tasks
- suppress queued but unsent reminder deliveries/outbox work
- require the delivery worker to re-check unresolved state immediately before sending
- keep already-sent notification history; do not delete audit evidence
- payment during grace leaves/restores the subscription `ACTIVE` without a suspension transition
### Timezone-safe, idempotent scheduling
- Require a valid IANA timezone per billing account. Use timezone-aware calendar arithmetic; never add fixed 24-hour milliseconds for local deadlines.
- Calculate and persist `originalExpirationAt`, each milestones `scheduledFor`, and `finalSuspensionAt` when the collections case is created.
- Run a frequent UTC worker, recommended every 15 minutes, that claims rows where `nextActionAt <= now`. Do not create one cron schedule per timezone.
- Use database leases/conditional claims so multiple API instances cannot process the same action concurrently.
- Notification keys: `collections:{invoiceId}:{milestone}:{recipientId}`. Daily-grace keys add the company-local date. Call-task uniqueness is enforced by `(collectionsCaseId, taskType)`.
- Locale is snapshotted when the event is created and is not part of the idempotency key. A retry must reproduce the original localized notice; changing company language settings must not create a duplicate for the same milestone.
- If the worker runs repeatedly, unique constraints return the original event/task instead of sending again.
- After an outage, audit missed milestones and send only the most urgent currently applicable customer reminder per run; never dump several stale emails at once. The 48-hour call task must still be created if its threshold was crossed.
The existing notification event/outbox/delivery records should be reused for channel delivery evidence. Add `CollectionsEvent` records for scheduling decisions, call actions, overrides, resolution, and suspension.
Add dedicated localized notification types/templates rather than reusing generic payment failures: `SUBSCRIPTION_PAYMENT_DUE_14D`, `SUBSCRIPTION_PAYMENT_DUE_7D`, `SUBSCRIPTION_PAYMENT_DUE_48H`, `SUBSCRIPTION_PAYMENT_DUE_24H`, `SUBSCRIPTION_GRACE_DAILY`, `SUBSCRIPTION_GRACE_FINAL`, `COLLECTIONS_CALL_REQUIRED`, `SUBSCRIPTION_PAYMENT_CONFIRMED`, `MANUAL_PAYMENT_EVIDENCE_REJECTED`, `COLLECTIONS_OVERRIDE_CHANGED`, and `SUBSCRIPTION_SUSPENDED`. Every customer-facing template must exist in `ar`, `en`, and `fr` with an identical variable contract. Template variables must come from the canonical invoice/case, not caller-supplied display amounts or dates.
Retire the current hard-coded 7-day `runPaymentPendingTimeoutJob` and `runPastDueTimeoutJob` behavior for canonical subscription renewals before enabling this worker. Running both lifecycles would restrict or suspend accounts early.
### Disputes, extensions, and exceptions
- An authorized `PAYMENT_DISPUTE` or `MANUAL_EXTENSION` override pauses automatic suspension until its explicit expiry/revised suspension date. It must never mark the invoice paid.
- `pauseNotifications` is a separate audited choice. A dispute may replace ordinary daily demands with dispute-status communication; an extension normally reschedules reminders against the revised date.
- Expired/revoked overrides return the case to the calculated schedule. The next worker must re-check payment before any reminder or suspension.
- A `PAST_DUE` manual invoice may still be paid if it has not been voided or replaced.
- A changed plan/method must void or supersede the old open invoice; it must never leave two invoices capable of activating different plans.
- Reference collision, overpayment, underpayment, wrong currency, bounced check, or unmatched transfer goes to a finance exception queue. Do not “fix” these by editing ledger rows.
- Suspicious, infected, unscannable, unreadable, wrong-invoice, or mismatched evidence goes to quarantine/rejection and must never reach payment confirmation.
- A bounced check after activation requires compensating records, reopens collections, and re-evaluates subscription access under an authorized reversal workflow. Never delete the original payment/evidence.
## 10. Files expected to change
| Layer | Files/modules |
| --- | --- |
| Database | Missing `@rentaldrivego/database` Prisma schema and a new migration |
| Subscription contracts | `api/src/modules/subscriptions/subscription.schemas.ts` |
| Subscription routes | `api/src/modules/subscriptions/subscription.routes.ts` |
| Subscription orchestration | `api/src/modules/subscriptions/subscription.service.ts` |
| Canonical invoice repository | `api/src/modules/subscriptions/subscription.repo.ts` and/or a shared billing repository |
| Admin validation | `api/src/modules/admin/admin.schemas.ts` |
| Admin route | `api/src/modules/admin/admin.routes.ts` |
| Admin transaction logic | `api/src/modules/admin/admin.billing.service.ts` |
| Evidence validation/upload | `api/src/http/upload/index.ts` or a new `api/src/http/upload/paymentEvidence.ts` |
| Private document storage | `api/src/lib/storage.ts` plus the production persistent-storage adapter |
| Evidence service/repository | New subscription billing evidence module under `api/src/modules/subscriptions/` or a shared billing module |
| Collections policy/worker | `api/src/modules/subscriptions/subscription.policy.ts`, a new collections service/repository/worker, and `api/src/index.ts` scheduler wiring |
| Notification targeting/outbox | `api/src/services/notificationService.ts`, localization templates, and notification repository/routes |
| Locale resolution/templates | `api/src/services/notificationLocalizationService.ts`, all subscription-payment email/in-app templates, and template-variable parity checks |
| Billing contacts/timezone/language | Database schema plus company/billing-account settings schemas, services, `api/src/modules/companies/company.schemas.ts`, and owner UI |
| Admin task/override API | Admin schemas/routes/service plus collections policy authorization |
| API documentation | `api/src/swagger/openapi.ts` |
| Customer subscription UI | `dashboard/src/app/(dashboard)/subscription/page.tsx` |
| Admin billing/collections UI | `admin/src/app/dashboard/billing/page.tsx`, new collections queue/task views, and a personal admin notification inbox/badge |
| Shared types/i18n | Missing shared packages plus `dashboard/src/components/I18nProvider.tsx`, `admin/src/components/I18nProvider.tsx`, locale selectors, and Arabic RTL styles/email markup |
| Tests | Subscription, admin billing, API, integration, and end-to-end test suites |
## 11. Test plan
### Unit and schema tests
- Accept only `BANK_TRANSFER`/`CHECK` on the manual endpoint.
- Reject empty/oversized/control-character references, invalid dates, false/missing verification, floats, non-positive amounts, and wrong currency.
- Normalize case/whitespace consistently without changing the stored display value.
- Verify monthly/yearly period calculations at month-end and leap-year boundaries.
- Accept valid PDF/JPEG/PNG signatures and reject spoofed MIME types/extensions, SVG/HTML, archives, executables, malformed/polyglot files, decompression bombs, oversized images, excessive counts, and size-limit violations.
- Verify 14-day, 7-day, 48-hour, 24-hour, grace-day, and day-31 calculations in multiple IANA zones, including daylight-saving and Morocco offset changes.
- Reject invalid/fixed-offset timezone identifiers and invalid/indefinite override windows.
- Accept only `ar`, `en`, and `fr`; reject empty enabled sets, unsupported values, defaults outside the set, and contact locales outside the set.
- Verify locale precedence: explicit billing-contact locale, enabled linked-employee preference, company default, then audited emergency English fallback.
- Enforce template parity in CI: every customer-facing template key and interpolation variable must exist in all three languages, with no unresolved keys or silent mixed-language output.
### Service and database tests
- Manual checkout computes the server-side price and creates one open canonical invoice.
- Repeated checkout with the same key returns the same invoice.
- A second open invoice for the same purchase is rejected or explicitly replaces the first.
- Confirming a settled payment atomically records payment, pays invoice, activates the requested plan, and writes events/audit.
- A duplicate reference for the same account/method is rejected.
- Two concurrent confirmations cannot both change the balance or create two successful attempts.
- Underpayment, overpayment, wrong method, wrong invoice state, and stale amount do not activate the subscription.
- Admin confirmation updates the customer-visible invoice during the compatibility period.
- Past-due-but-payable succeeds; void/uncollectible/refunded fails.
- A notification failure leaves the payment committed and schedules a retry.
- Draft submission supports idempotent upload retry and submit; submitted documents cannot be replaced or deleted.
- Submission is blocked until at least one document is clean and all documents have finished scanning.
- Quarantined/scan-failed evidence cannot be viewed as clean evidence or used for approval.
- File and database failures do not leave an approved orphan document or an untracked stored object; cleanup jobs reconcile abandoned drafts/quarantine objects.
- SHA-256 digest remains stable through storage/retrieval and catches an accidental duplicate within a submission.
- Collections case creation persists the original expiration, all milestone instants, and final suspension time once.
- Each pre-due milestone reaches only explicit billing recipients and the assigned collections admin with the required channels.
- Re-running the worker or running two workers concurrently creates one notification event per recipient/milestone and one 48-hour call task.
- A company with all three languages enabled receives one notice per recipient/milestone in that recipient's resolved language, never three duplicate notices.
- Each delivery persists the resolved locale, company timezone, template key/version, render snapshot/inputs, and localization-fallback status; retries reproduce the same localized content.
- Changing a company/contact language does not resend completed milestones; the next unsent event uses the newly resolved language.
- The assigned admin's internal notice uses the admin preference while the call task exposes the company/contact language and the correct localized customer script.
- The call task remains open without an outcome; every outcome is audited; payment promise creates the next follow-up without resolving the invoice.
- Grace days 129 send one daily notice; day 30 sends one final-warning variant; the start of day 31 suspends once.
- The subscription retains full access throughout grace.
- Confirmed Stripe/manual payment immediately resolves collections, cancels tasks/queued notices, and wins safely against concurrent suspension.
- Pending/failed attempts, uploaded evidence, submitted evidence, and payment promises do not stop reminders.
- Active dispute/extension overrides prevent suspension; expiry/revocation resumes the correct schedule without duplicate or stale notices.
- Missing timezone/recipient/assignee fails safe, alerts finance, and does not auto-suspend.
### Authorization and boundary tests
- Non-owner company users cannot create manual subscription invoices.
- Owners cannot confirm their own payments through admin endpoints.
- Non-finance admins and stale/non-enrolled 2FA sessions cannot confirm payments.
- Cross-tenant invoice access fails without leaking existence.
- CSRF and rate-limit policies remain enforced.
- Owners cannot attach a document to another companys invoice/submission or guess a storage key to download it.
- Only the uploading company and authorized finance admins can access evidence; each admin view/download is audited.
- Private evidence is not reachable through `/storage`, search, static assets, invoice PDFs, logs, or public URLs.
- Only authorized company owners can manage billing contacts; they cannot remove the last verified recipient.
- Call outcomes are limited to the assigned/authorized platform admin; overrides require authorized roles and fresh 2FA.
- The company-wide audience is never used for collections, and platform notices never leak into a company employee account.
### UI and end-to-end tests
- Stripe checkout still redirects and webhook success still activates exactly once.
- Bank transfer/check show correct configured instructions and create a pending invoice without activation.
- Customer can upload valid evidence, observe scanning, submit it for review, and cannot edit it afterward.
- Invalid/quarantined evidence produces a safe error without exposing scanner internals.
- Admin can securely inspect clean evidence, reject it with a reason, or proceed to confirmation.
- Billing contacts see the exact amount, original expiration, days remaining, final suspension time, and current payment action in the dashboard/email templates.
- Assigned platform admins receive the specified milestone channels, see the open 48-hour call task, and can record each required outcome.
- Payment confirmation during grace removes future reminders immediately; an already-queued delivery is suppressed by the final state check.
- Day-31 suspension and authorized dispute/extension behavior are covered end to end in a non-UTC company timezone.
- Admin confirmation dialog sends method, reference, full amount, date, note, verification, and stable idempotency key.
- Customer status changes from awaiting verification to paid/active after admin confirmation.
- English, French, and Arabic labels, validation errors, payment instructions, notification/email bodies, call scripts, and layouts are verified.
- Arabic screens and emails set the correct language/direction metadata and remain usable in RTL; English and French remain LTR.
- Amounts, dates, grace-day counts, and suspension times render correctly for each language in the company's configured timezone without changing stored financial/time values.
## 12. Rollout sequence
1. **Schema and observability:** deploy nullable payment/evidence fields, collections/contact/task/override tables, indexes, structured audit fields, metrics, and reference-safe logging.
2. **Recipient/timezone/language readiness:** collect and verify an IANA timezone, primary billing recipients, enabled/default communication languages, and assigned/fallback collections owner for every live account. Backfill from the existing supported brand/default locale, otherwise use English and flag for owner review. Do not enable automatic suspension for incomplete accounts.
3. **Private storage and scanning:** provision durable private storage, quarantine, malware scanning, retention controls, protected downloads, and orphan cleanup before accepting any customer document.
4. **Backfill and reconciliation:** create/verify canonical `BillingInvoice` links for all legacy subscription invoices; produce a reconciliation report before switching reads.
5. **Canonical service:** make Stripe and customer invoice reads use the canonical lifecycle while retaining temporary compatibility writes.
6. **Shadow collections worker:** calculate milestones and idempotency keys without sending or suspending; compare results against expected company-local dates and current invoices.
7. **Notifications/tasks:** complete Arabic/English/French template parity and locale-resolution tests, then enable internal collections inbox/call tasks and pre-expiration company notices. Verify recipient scoping, language snapshots, RTL rendering, and email deliverability.
8. **Evidence review and admin confirmation:** deploy private evidence endpoints, finance review queue, manual-payment confirmation, rejection, and atomic collections resolution behind feature flags.
9. **Grace/suspension enforcement:** enable daily grace notices, overrides, and finally day-31 suspension after shadow data shows zero timing/recipient/payment-state mismatches.
10. **Customer manual checkout:** expose bank/check and evidence upload only after instructions, storage/scanning, and finance operations are ready.
11. **Monitor:** track milestone lag, duplicates suppressed, recipient/config gaps, open/overdue call tasks, override expiry, daily delivery failures, grace age, blocked suspensions, scan failures, confirmation conflicts, invoice/subscription mismatches, and Stripe regressions.
12. **Retire compatibility:** remove lazy legacy sync and dual writes only after at least one billing cycle reconciles with zero mismatches.
Suggested flags:
- `manualSubscriptionPaymentsEnabled`
- `bankTransferEnabled`
- `checkPaymentEnabled`
- `manualPaymentEvidenceUploadEnabled`
- `subscriptionCollectionsNotificationsEnabled`
- `subscriptionGracePeriodEnforcementEnabled`
- `subscriptionAutomaticSuspensionEnabled`
## 13. Acceptance criteria
- An owner can choose Stripe, bank transfer, or check for a subscription purchase.
- A manual choice creates exactly one pending canonical invoice with server-calculated price and safe payment instructions.
- The owner can attach one to three valid private PDF/JPEG/PNG evidence documents and submit them to finance review.
- Uploaded files are content-validated, malware-scanned, stored privately, tenant-protected, and audited; rejected or unscannable files cannot be approved.
- The subscription is not activated when the invoice is created, when the customer reports a reference, or when evidence is uploaded/submitted.
- A finance admin with fresh 2FA can confirm a cleared full payment using a required method and reference.
- The admin can review clean evidence and reject it with a reason; approving a payment links the exact immutable submission and documents to the payment record.
- Confirmation is idempotent, concurrency-safe, duplicate-resistant, fully audited, and atomic.
- The paid invoice shown to the admin is the same invoice/status shown to the company owner.
- Full initial confirmation activates the requested plan; renewal confirmation extends from the original expiration boundary without discarding remaining paid days.
- Existing Stripe checkout/webhook behavior remains correct and does not double-activate.
- Only explicit company billing contacts/owners and the assigned platform collections admin receive payment notices.
- Every customer-facing subscription-payment communication is available in Arabic, English, and French and is sent once in the recipient's deterministic company-approved language.
- A company can enable one or more supported languages and select one default; contact preferences are constrained to the enabled set and otherwise inherit the default.
- Arabic customer UI/email uses RTL and correct `lang`/`dir` metadata; English and French use LTR. Dates, times, amounts, and currencies follow the resolved language and company timezone.
- A language change affects future unsent communications without duplicating past milestones; every delivery records the locale and template version used.
- Platform-admin tasks clearly identify the customer's language and provide the matching call script, even when the admin's internal interface uses a different preferred language.
- Pre-expiration notifications follow the 14-day, 7-day, 48-hour, and 24-hour channel matrix without duplicates.
- The 48-hour call task remains open until a valid outcome is recorded; payment promises create follow-up but do not resolve payment.
- An unpaid subscription remains fully active for the complete 30-day grace period and receives one targeted in-app/email notice per local day, with the day-30 notice serving as the final warning.
- At the start of local day 31, the system suspends exactly once only if payment remains unconfirmed and no authorized override is active.
- Confirmed payment immediately stops future reminders/tasks and keeps/restores active service, including when confirmation races with suspension.
- Timezone, idempotency, audit, disputes, extensions, and notification-delivery requirements are satisfied.
- The audit trail identifies every in-app notice, email delivery, call task, call outcome, payment confirmation, override, collections resolution, and suspension with actor and timestamp.
- All API, service, authorization, regression, and multilingual UI tests pass.
## 14. Required business inputs before implementation
These are configuration inputs, not reasons to weaken the design:
- Bank-transfer payer instructions: account name, bank name, RIB/IBAN or local account reference, and transfer memo rule.
- Check instructions: exact payee name, delivery address, and whether activation waits for deposit or full clearance. Recommended: full clearance.
- Manual-method payment/clearing lead times. The post-expiration subscription grace period is fixed at 30 days unless an authorized extension overrides it.
- Finance escalation owner for unmatched, short, excess, duplicate, or bounced payments.
- Collections assignment/fallback policy, overdue call-task escalation SLA, and which admin roles may act as collections owners.
- Required local reminder send time and verified source of each billing accounts IANA timezone.
- Whether the UI should expose generic base locales (`ar`, `en`, `fr`) while formatting for Morocco-specific conventions (`ar-MA`, `fr-MA`), and the regional convention to use for English. Recommended: store the three base communication choices and map them centrally to approved regional formatting locales.
- Approved professional Arabic, English, and French wording for payment instructions, legal/collections notices, evidence rejection, dispute/extension, grace-period warnings, call scripts, and suspension. Product/finance must approve all three versions before notifications are enabled.
- Override policy: maximum extension length, who may approve it, when normal daily notices pause, and dispute communication templates.
- Whether manual methods are available to every plan/company. Recommended MVP: configurable globally, with the ability to disable each method independently.
- Evidence retention/deletion period, legal-hold rules, and the person authorized to approve exceptional deletion. This must follow the applicable accounting/privacy jurisdiction.
- Approved malware-scanning service and production private-storage backend. Do not enable uploads if scanning or durable private storage is unavailable.
@@ -0,0 +1,538 @@
-- CreateEnum
CREATE TYPE "SubscriptionCollectionMethod" AS ENUM ('STRIPE', 'BANK_TRANSFER', 'CHECK');
-- CreateEnum
CREATE TYPE "BillingPaymentChannel" AS ENUM ('ONLINE', 'OFFLINE');
-- CreateEnum
CREATE TYPE "ManualPaymentMethod" AS ENUM ('BANK_TRANSFER', 'CHECK');
-- CreateEnum
CREATE TYPE "ManualPaymentSubmissionStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ManualPaymentDocumentKind" AS ENUM ('BANK_TRANSFER_RECEIPT', 'CHECK_COPY', 'OTHER_SUPPORTING_EVIDENCE');
-- CreateEnum
CREATE TYPE "ManualPaymentDocumentScanStatus" AS ENUM ('UPLOADED', 'SCANNING', 'CLEAN', 'QUARANTINED', 'SCAN_FAILED');
-- CreateEnum
CREATE TYPE "CollectionsCaseStatus" AS ENUM ('SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED');
-- CreateEnum
CREATE TYPE "CollectionsCallTaskType" AS ENUM ('PRE_EXPIRY_48H_CALL', 'PAYMENT_PROMISE_FOLLOW_UP');
-- CreateEnum
CREATE TYPE "CollectionsCallTaskStatus" AS ENUM ('OPEN', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "CollectionsCallOutcome" AS ENUM ('CONTACTED', 'NO_ANSWER', 'PAYMENT_PROMISED', 'ISSUE_ESCALATED');
-- CreateEnum
CREATE TYPE "CollectionsOverrideType" AS ENUM ('PAYMENT_DISPUTE', 'MANUAL_EXTENSION');
-- CreateEnum
CREATE TYPE "CollectionsOverrideStatus" AS ENUM ('ACTIVE', 'REVOKED', 'EXPIRED');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_DUE_14D';
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_DUE_7D';
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_DUE_48H';
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_DUE_24H';
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_GRACE_DAILY';
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_GRACE_FINAL';
ALTER TYPE "NotificationType" ADD VALUE 'COLLECTIONS_CALL_REQUIRED';
ALTER TYPE "NotificationType" ADD VALUE 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED';
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_CONFIRMED';
ALTER TYPE "NotificationType" ADD VALUE 'MANUAL_PAYMENT_EVIDENCE_REJECTED';
ALTER TYPE "NotificationType" ADD VALUE 'COLLECTIONS_OVERRIDE_CHANGED';
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationRecipientType" ADD VALUE 'BILLING_CONTACT';
ALTER TYPE "NotificationRecipientType" ADD VALUE 'ADMIN';
-- AlterTable
ALTER TABLE "billing_accounts" ADD COLUMN "collectionsOwnerAdminId" TEXT,
ADD COLUMN "defaultCommunicationLocale" TEXT NOT NULL DEFAULT 'en',
ADD COLUMN "enabledCommunicationLocales" TEXT[] DEFAULT ARRAY['en']::TEXT[],
ADD COLUMN "reminderLocalTime" TEXT NOT NULL DEFAULT '09:00',
ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'Africa/Casablanca';
-- AlterTable
ALTER TABLE "billing_invoices" ADD COLUMN "checkoutIdempotencyKey" TEXT,
ADD COLUMN "collectionMethod" "SubscriptionCollectionMethod" NOT NULL DEFAULT 'STRIPE',
ADD COLUMN "renewalKey" TEXT,
ADD COLUMN "requestedBillingPeriod" "BillingPeriod",
ADD COLUMN "requestedPlan" "Plan";
-- AlterTable
ALTER TABLE "billing_payment_attempts" ADD COLUMN "channel" "BillingPaymentChannel" NOT NULL DEFAULT 'ONLINE',
ADD COLUMN "confirmedAt" TIMESTAMP(3),
ADD COLUMN "confirmedByAdminId" TEXT,
ADD COLUMN "externalReference" TEXT,
ADD COLUMN "idempotencyKey" TEXT,
ADD COLUMN "manualMethod" "ManualPaymentMethod",
ADD COLUMN "normalizedExternalReference" TEXT,
ADD COLUMN "note" TEXT,
ADD COLUMN "receivedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "notification_recipients" ADD COLUMN "adminUserId" TEXT,
ADD COLUMN "billingContactId" TEXT;
-- AlterTable
ALTER TABLE "admin_users" ADD COLUMN "preferredLocale" TEXT NOT NULL DEFAULT 'en';
-- CreateTable
CREATE TABLE "billing_contacts" (
"id" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"employeeId" TEXT,
"email" TEXT NOT NULL,
"locale" TEXT,
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
"receivePaymentNotices" BOOLEAN NOT NULL DEFAULT true,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"verifiedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "billing_contacts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "manual_payment_submissions" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"method" "ManualPaymentMethod" NOT NULL,
"submittedReference" TEXT NOT NULL,
"normalizedSubmittedReference" TEXT NOT NULL,
"status" "ManualPaymentSubmissionStatus" NOT NULL DEFAULT 'DRAFT',
"submittedByEmployeeId" TEXT NOT NULL,
"submittedAt" TIMESTAMP(3),
"reviewedByAdminId" TEXT,
"reviewedAt" TIMESTAMP(3),
"rejectionReason" TEXT,
"idempotencyKey" TEXT NOT NULL,
"paymentAttemptId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "manual_payment_submissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "manual_payment_documents" (
"id" TEXT NOT NULL,
"submissionId" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"kind" "ManualPaymentDocumentKind" NOT NULL,
"storageKey" TEXT NOT NULL,
"originalFilename" TEXT NOT NULL,
"detectedMimeType" TEXT NOT NULL,
"detectedExtension" TEXT NOT NULL,
"byteSize" INTEGER NOT NULL,
"sha256" TEXT NOT NULL,
"scanStatus" "ManualPaymentDocumentScanStatus" NOT NULL DEFAULT 'UPLOADED',
"scannerResultCode" TEXT,
"uploadedByEmployeeId" TEXT NOT NULL,
"uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "manual_payment_documents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "collections_cases" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"status" "CollectionsCaseStatus" NOT NULL DEFAULT 'SCHEDULED',
"originalExpirationAt" TIMESTAMP(3) NOT NULL,
"reminder14At" TIMESTAMP(3) NOT NULL,
"reminder7At" TIMESTAMP(3) NOT NULL,
"reminder48At" TIMESTAMP(3) NOT NULL,
"reminder24At" TIMESTAMP(3) NOT NULL,
"graceStartedAt" TIMESTAMP(3),
"finalSuspensionAt" TIMESTAMP(3) NOT NULL,
"resolvedAt" TIMESTAMP(3),
"suspendedAt" TIMESTAMP(3),
"nextActionAt" TIMESTAMP(3),
"collectionsOwnerAdminId" TEXT,
"resolutionPaymentAttemptId" TEXT,
"processingLeaseUntil" TIMESTAMP(3),
"processingBy" TEXT,
"version" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "collections_cases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "collections_call_tasks" (
"id" TEXT NOT NULL,
"collectionsCaseId" TEXT NOT NULL,
"taskType" "CollectionsCallTaskType" NOT NULL,
"assignedAdminId" TEXT NOT NULL,
"billingContactId" TEXT,
"dueAt" TIMESTAMP(3) NOT NULL,
"status" "CollectionsCallTaskStatus" NOT NULL DEFAULT 'OPEN',
"outcome" "CollectionsCallOutcome",
"note" TEXT,
"promisedPaymentAt" TIMESTAMP(3),
"nextFollowUpAt" TIMESTAMP(3),
"completedByAdminId" TEXT,
"completedAt" TIMESTAMP(3),
"cancellationReason" TEXT,
"companyDefaultLocale" TEXT NOT NULL,
"contactLocale" TEXT NOT NULL,
"customerScript" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "collections_call_tasks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "collections_overrides" (
"id" TEXT NOT NULL,
"collectionsCaseId" TEXT NOT NULL,
"type" "CollectionsOverrideType" NOT NULL,
"status" "CollectionsOverrideStatus" NOT NULL DEFAULT 'ACTIVE',
"reason" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revisedSuspensionAt" TIMESTAMP(3),
"pauseSuspension" BOOLEAN NOT NULL DEFAULT true,
"pauseNotifications" BOOLEAN NOT NULL DEFAULT false,
"createdByAdminId" TEXT NOT NULL,
"revokedByAdminId" TEXT,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "collections_overrides_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "collections_events" (
"id" TEXT NOT NULL,
"collectionsCaseId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"scheduledFor" TIMESTAMP(3),
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actorType" TEXT NOT NULL DEFAULT 'system',
"actorId" TEXT,
"payload" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "collections_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "billing_contacts_companyId_isActive_idx" ON "billing_contacts"("companyId", "isActive");
-- CreateIndex
CREATE INDEX "billing_contacts_employeeId_idx" ON "billing_contacts"("employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "billing_contacts_billingAccountId_email_key" ON "billing_contacts"("billingAccountId", "email");
-- CreateIndex
CREATE UNIQUE INDEX "manual_payment_submissions_paymentAttemptId_key" ON "manual_payment_submissions"("paymentAttemptId");
-- CreateIndex
CREATE INDEX "manual_payment_submissions_invoiceId_status_idx" ON "manual_payment_submissions"("invoiceId", "status");
-- CreateIndex
CREATE INDEX "manual_payment_submissions_status_submittedAt_idx" ON "manual_payment_submissions"("status", "submittedAt");
-- CreateIndex
CREATE UNIQUE INDEX "manual_payment_submissions_billingAccountId_idempotencyKey_key" ON "manual_payment_submissions"("billingAccountId", "idempotencyKey");
-- CreateIndex
CREATE UNIQUE INDEX "manual_payment_documents_storageKey_key" ON "manual_payment_documents"("storageKey");
-- CreateIndex
CREATE INDEX "manual_payment_documents_invoiceId_idx" ON "manual_payment_documents"("invoiceId");
-- CreateIndex
CREATE INDEX "manual_payment_documents_companyId_idx" ON "manual_payment_documents"("companyId");
-- CreateIndex
CREATE INDEX "manual_payment_documents_scanStatus_uploadedAt_idx" ON "manual_payment_documents"("scanStatus", "uploadedAt");
-- CreateIndex
CREATE UNIQUE INDEX "manual_payment_documents_submissionId_sha256_key" ON "manual_payment_documents"("submissionId", "sha256");
-- CreateIndex
CREATE UNIQUE INDEX "collections_cases_invoiceId_key" ON "collections_cases"("invoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "collections_cases_resolutionPaymentAttemptId_key" ON "collections_cases"("resolutionPaymentAttemptId");
-- CreateIndex
CREATE INDEX "collections_cases_status_nextActionAt_idx" ON "collections_cases"("status", "nextActionAt");
-- CreateIndex
CREATE UNIQUE INDEX "collections_cases_subscriptionId_originalExpirationAt_key" ON "collections_cases"("subscriptionId", "originalExpirationAt");
-- CreateIndex
CREATE INDEX "collections_call_tasks_assignedAdminId_status_dueAt_idx" ON "collections_call_tasks"("assignedAdminId", "status", "dueAt");
-- CreateIndex
CREATE UNIQUE INDEX "collections_call_tasks_collectionsCaseId_taskType_key" ON "collections_call_tasks"("collectionsCaseId", "taskType");
-- CreateIndex
CREATE INDEX "collections_overrides_collectionsCaseId_status_expiresAt_idx" ON "collections_overrides"("collectionsCaseId", "status", "expiresAt");
-- CreateIndex
CREATE INDEX "collections_events_companyId_occurredAt_idx" ON "collections_events"("companyId", "occurredAt");
-- CreateIndex
CREATE UNIQUE INDEX "collections_events_collectionsCaseId_idempotencyKey_key" ON "collections_events"("collectionsCaseId", "idempotencyKey");
-- CreateIndex
CREATE UNIQUE INDEX "billing_invoices_renewalKey_key" ON "billing_invoices"("renewalKey");
-- CreateIndex
CREATE INDEX "billing_invoices_collectionMethod_status_dueAt_idx" ON "billing_invoices"("collectionMethod", "status", "dueAt");
-- CreateIndex
CREATE UNIQUE INDEX "billing_invoices_billingAccountId_checkoutIdempotencyKey_key" ON "billing_invoices"("billingAccountId", "checkoutIdempotencyKey");
-- CreateIndex
CREATE UNIQUE INDEX "billing_payment_attempts_billingAccountId_idempotencyKey_key" ON "billing_payment_attempts"("billingAccountId", "idempotencyKey");
-- CreateIndex
CREATE UNIQUE INDEX "billing_payment_attempts_billingAccountId_manualMethod_norm_key" ON "billing_payment_attempts"("billingAccountId", "manualMethod", "normalizedExternalReference");
-- CreateIndex
CREATE INDEX "notification_recipients_billingContactId_readAt_createdAt_idx" ON "notification_recipients"("billingContactId", "readAt", "createdAt");
-- CreateIndex
CREATE INDEX "notification_recipients_adminUserId_readAt_createdAt_idx" ON "notification_recipients"("adminUserId", "readAt", "createdAt");
-- AddForeignKey
ALTER TABLE "billing_accounts" ADD CONSTRAINT "billing_accounts_collectionsOwnerAdminId_fkey" FOREIGN KEY ("collectionsOwnerAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_contacts" ADD CONSTRAINT "billing_contacts_billingAccountId_fkey" FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_contacts" ADD CONSTRAINT "billing_contacts_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_contacts" ADD CONSTRAINT "billing_contacts_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_payment_attempts" ADD CONSTRAINT "billing_payment_attempts_confirmedByAdminId_fkey" FOREIGN KEY ("confirmedByAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_billingAccountId_fkey" FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_submittedByEmployeeId_fkey" FOREIGN KEY ("submittedByEmployeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_reviewedByAdminId_fkey" FOREIGN KEY ("reviewedByAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_paymentAttemptId_fkey" FOREIGN KEY ("paymentAttemptId") REFERENCES "billing_payment_attempts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "manual_payment_documents" ADD CONSTRAINT "manual_payment_documents_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "manual_payment_submissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "manual_payment_documents" ADD CONSTRAINT "manual_payment_documents_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "manual_payment_documents" ADD CONSTRAINT "manual_payment_documents_uploadedByEmployeeId_fkey" FOREIGN KEY ("uploadedByEmployeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_billingAccountId_fkey" FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_collectionsOwnerAdminId_fkey" FOREIGN KEY ("collectionsOwnerAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_resolutionPaymentAttemptId_fkey" FOREIGN KEY ("resolutionPaymentAttemptId") REFERENCES "billing_payment_attempts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_call_tasks" ADD CONSTRAINT "collections_call_tasks_collectionsCaseId_fkey" FOREIGN KEY ("collectionsCaseId") REFERENCES "collections_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_call_tasks" ADD CONSTRAINT "collections_call_tasks_assignedAdminId_fkey" FOREIGN KEY ("assignedAdminId") REFERENCES "admin_users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_call_tasks" ADD CONSTRAINT "collections_call_tasks_billingContactId_fkey" FOREIGN KEY ("billingContactId") REFERENCES "billing_contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_call_tasks" ADD CONSTRAINT "collections_call_tasks_completedByAdminId_fkey" FOREIGN KEY ("completedByAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_overrides" ADD CONSTRAINT "collections_overrides_collectionsCaseId_fkey" FOREIGN KEY ("collectionsCaseId") REFERENCES "collections_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_overrides" ADD CONSTRAINT "collections_overrides_createdByAdminId_fkey" FOREIGN KEY ("createdByAdminId") REFERENCES "admin_users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_overrides" ADD CONSTRAINT "collections_overrides_revokedByAdminId_fkey" FOREIGN KEY ("revokedByAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_events" ADD CONSTRAINT "collections_events_collectionsCaseId_fkey" FOREIGN KEY ("collectionsCaseId") REFERENCES "collections_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections_events" ADD CONSTRAINT "collections_events_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_recipients" ADD CONSTRAINT "notification_recipients_billingContactId_fkey" FOREIGN KEY ("billingContactId") REFERENCES "billing_contacts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_recipients" ADD CONSTRAINT "notification_recipients_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "admin_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Backfill communication policy from existing supported brand language and
-- create an explicit primary owner contact. Accounts remain visibly flagged
-- for owner review because the legacy schema had no verified IANA timezone.
UPDATE "billing_accounts" AS account
SET "defaultCommunicationLocale" = CASE
WHEN brand."defaultLocale" IN ('ar', 'en', 'fr') THEN brand."defaultLocale"
ELSE 'en'
END,
"enabledCommunicationLocales" = ARRAY[CASE
WHEN brand."defaultLocale" IN ('ar', 'en', 'fr') THEN brand."defaultLocale"
ELSE 'en'
END]::TEXT[],
"metadata" = COALESCE(account."metadata", '{}'::jsonb) || '{"communicationSettingsRequireOwnerReview":true}'::jsonb
FROM "brand_settings" AS brand
WHERE brand."companyId" = account."companyId";
INSERT INTO "billing_contacts" (
"id", "billingAccountId", "companyId", "employeeId", "email", "locale",
"isPrimary", "receivePaymentNotices", "isActive", "verifiedAt", "createdAt", "updatedAt"
)
SELECT
'bc_' || md5(random()::text || clock_timestamp()::text || account."id"),
account."id", account."companyId", owner."id", owner."email",
CASE WHEN owner."preferredLanguage" IN ('ar', 'en', 'fr') THEN owner."preferredLanguage" ELSE account."defaultCommunicationLocale" END,
true, true, true, COALESCE(owner."emailVerified", CURRENT_TIMESTAMP), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "billing_accounts" AS account
JOIN LATERAL (
SELECT employee.*
FROM "employees" AS employee
WHERE employee."companyId" = account."companyId"
AND employee."role" = 'OWNER'
AND employee."isActive" = true
ORDER BY employee."createdAt" ASC
LIMIT 1
) AS owner ON true
WHERE NOT EXISTS (
SELECT 1 FROM "billing_contacts" AS contact
WHERE contact."billingAccountId" = account."id"
);
UPDATE "billing_accounts" AS account
SET "collectionsOwnerAdminId" = admin."id"
FROM LATERAL (
SELECT "id"
FROM "admin_users"
WHERE "isActive" = true AND "role" IN ('FINANCE', 'ADMIN', 'SUPER_ADMIN')
ORDER BY "createdAt" ASC
LIMIT 1
) AS admin
WHERE account."collectionsOwnerAdminId" IS NULL;
-- Database invariants that Prisma cannot express directly.
ALTER TABLE "billing_accounts"
ALTER COLUMN "enabledCommunicationLocales" SET NOT NULL,
ALTER COLUMN "defaultCommunicationLocale" SET NOT NULL,
ALTER COLUMN "timezone" SET NOT NULL,
ALTER COLUMN "reminderLocalTime" SET NOT NULL;
ALTER TABLE "billing_accounts"
ADD CONSTRAINT "billing_accounts_supported_locales_check"
CHECK (
cardinality("enabledCommunicationLocales") BETWEEN 1 AND 3
AND "enabledCommunicationLocales" <@ ARRAY['ar', 'en', 'fr']::TEXT[]
AND "defaultCommunicationLocale" = ANY("enabledCommunicationLocales")
),
ADD CONSTRAINT "billing_accounts_reminder_time_check"
CHECK ("reminderLocalTime" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$');
ALTER TABLE "billing_contacts"
ADD CONSTRAINT "billing_contacts_supported_locale_check"
CHECK ("locale" IS NULL OR "locale" IN ('ar', 'en', 'fr'));
ALTER TABLE "admin_users"
ADD CONSTRAINT "admin_users_supported_locale_check"
CHECK ("preferredLocale" IN ('ar', 'en', 'fr'));
ALTER TABLE "billing_payment_attempts"
ADD CONSTRAINT "billing_payment_attempts_offline_confirmation_check"
CHECK (
"channel" <> 'OFFLINE'
OR "status" <> 'SUCCEEDED'
OR (
"manualMethod" IS NOT NULL
AND length(trim(COALESCE("externalReference", ''))) >= 3
AND length(trim(COALESCE("normalizedExternalReference", ''))) >= 3
AND "receivedAt" IS NOT NULL
AND "confirmedAt" IS NOT NULL
AND "confirmedByAdminId" IS NOT NULL
AND "idempotencyKey" IS NOT NULL
)
);
ALTER TABLE "manual_payment_documents"
ADD CONSTRAINT "manual_payment_documents_size_check"
CHECK ("byteSize" > 0 AND "byteSize" <= 10485760),
ADD CONSTRAINT "manual_payment_documents_mime_check"
CHECK ("detectedMimeType" IN ('application/pdf', 'image/jpeg', 'image/png'));
ALTER TABLE "manual_payment_submissions"
ADD CONSTRAINT "manual_payment_submissions_reference_check"
CHECK (length(trim("submittedReference")) >= 3 AND length(trim("normalizedSubmittedReference")) >= 3);
ALTER TABLE "collections_overrides"
ADD CONSTRAINT "collections_overrides_finite_window_check"
CHECK ("expiresAt" > "createdAt");
ALTER TABLE "notification_recipients"
ADD CONSTRAINT "notification_recipients_exact_actor_check"
CHECK (num_nonnulls("employeeId", "renterId", "billingContactId", "adminUserId") = 1);
@@ -0,0 +1,12 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'NotificationType'
AND e.enumlabel = 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED'
) THEN
ALTER TYPE "NotificationType" ADD VALUE 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED';
END IF;
END $$;
@@ -0,0 +1,27 @@
-- Keep only the newest active manual payment submission per invoice before
-- installing the invariant. Historical approved/rejected submissions remain.
WITH ranked_active_submissions AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "invoiceId"
ORDER BY "createdAt" DESC, "id" DESC
) AS row_number
FROM "manual_payment_submissions"
WHERE "status" IN ('DRAFT', 'SUBMITTED', 'UNDER_REVIEW')
)
UPDATE "manual_payment_submissions" AS submission
SET
"status" = 'REJECTED',
"rejectionReason" = COALESCE(
submission."rejectionReason",
'Superseded by a newer active manual payment submission.'
),
"reviewedAt" = COALESCE(submission."reviewedAt", CURRENT_TIMESTAMP)
FROM ranked_active_submissions AS ranked
WHERE submission."id" = ranked."id"
AND ranked.row_number > 1;
CREATE UNIQUE INDEX "manual_payment_submissions_one_active_per_invoice"
ON "manual_payment_submissions" ("invoiceId")
WHERE "status" IN ('DRAFT', 'SUBMITTED', 'UNDER_REVIEW');
@@ -0,0 +1,39 @@
-- Keep a single primary billing account per company. Prefer the account with
-- the highest open balance, then the most invoices, then the newest account.
WITH account_scores AS (
SELECT
account."id",
account."companyId",
COALESCE(SUM(
CASE
WHEN invoice."status" IN ('OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID')
THEN invoice."amountDue"
ELSE 0
END
), 0) AS open_balance,
COUNT(invoice."id") AS invoice_count,
account."createdAt"
FROM "billing_accounts" AS account
LEFT JOIN "billing_invoices" AS invoice
ON invoice."billingAccountId" = account."id"
WHERE account."isPrimary" = true
GROUP BY account."id", account."companyId", account."createdAt"
),
ranked_primary_accounts AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "companyId"
ORDER BY open_balance DESC, invoice_count DESC, "createdAt" DESC, "id" DESC
) AS row_number
FROM account_scores
)
UPDATE "billing_accounts" AS account
SET "isPrimary" = false
FROM ranked_primary_accounts AS ranked
WHERE account."id" = ranked."id"
AND ranked.row_number > 1;
CREATE UNIQUE INDEX "billing_accounts_one_primary_per_company"
ON "billing_accounts" ("companyId")
WHERE "isPrimary" = true;
@@ -0,0 +1,12 @@
CREATE TABLE "platform_billing_settings" (
"id" TEXT NOT NULL DEFAULT 'default',
"taxRate" DOUBLE PRECISION NOT NULL DEFAULT 20,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT,
CONSTRAINT "platform_billing_settings_pkey" PRIMARY KEY ("id")
);
INSERT INTO "platform_billing_settings" ("id", "taxRate", "updatedAt")
VALUES ('default', 20, NOW())
ON CONFLICT ("id") DO NOTHING;
+534 -186
View File
@@ -134,6 +134,81 @@ enum BillingPaymentAttemptStatus {
PARTIALLY_REFUNDED
}
enum SubscriptionCollectionMethod {
STRIPE
BANK_TRANSFER
CHECK
}
enum BillingPaymentChannel {
ONLINE
OFFLINE
}
enum ManualPaymentMethod {
BANK_TRANSFER
CHECK
}
enum ManualPaymentSubmissionStatus {
DRAFT
SUBMITTED
UNDER_REVIEW
APPROVED
REJECTED
}
enum ManualPaymentDocumentKind {
BANK_TRANSFER_RECEIPT
CHECK_COPY
OTHER_SUPPORTING_EVIDENCE
}
enum ManualPaymentDocumentScanStatus {
UPLOADED
SCANNING
CLEAN
QUARANTINED
SCAN_FAILED
}
enum CollectionsCaseStatus {
SCHEDULED
PRE_DUE
GRACE_PERIOD
RESOLVED
SUSPENDED
}
enum CollectionsCallTaskType {
PRE_EXPIRY_48H_CALL
PAYMENT_PROMISE_FOLLOW_UP
}
enum CollectionsCallTaskStatus {
OPEN
COMPLETED
CANCELLED
}
enum CollectionsCallOutcome {
CONTACTED
NO_ANSWER
PAYMENT_PROMISED
ISSUE_ESCALATED
}
enum CollectionsOverrideType {
PAYMENT_DISPUTE
MANUAL_EXTENSION
}
enum CollectionsOverrideStatus {
ACTIVE
REVOKED
EXPIRED
}
enum BillingRefundStatus {
PENDING
SUCCEEDED
@@ -441,6 +516,17 @@ enum NotificationType {
REFUND_PROCESSED
NEW_OFFER_FROM_SAVED_COMPANY
REVIEW_REQUEST
SUBSCRIPTION_PAYMENT_DUE_14D
SUBSCRIPTION_PAYMENT_DUE_7D
SUBSCRIPTION_PAYMENT_DUE_48H
SUBSCRIPTION_PAYMENT_DUE_24H
SUBSCRIPTION_GRACE_DAILY
SUBSCRIPTION_GRACE_FINAL
COLLECTIONS_CALL_REQUIRED
MANUAL_PAYMENT_EVIDENCE_SUBMITTED
SUBSCRIPTION_PAYMENT_CONFIRMED
MANUAL_PAYMENT_EVIDENCE_REJECTED
COLLECTIONS_OVERRIDE_CHANGED
}
enum FeedbackCategory {
@@ -492,6 +578,8 @@ enum NotificationStatus {
enum NotificationRecipientType {
EMPLOYEE
RENTER
BILLING_CONTACT
ADMIN
}
enum NotificationDeliveryStatus {
@@ -529,27 +617,32 @@ model Company {
status CompanyStatus @default(PENDING)
subscriptionPaymentRef String?
subscription Subscription?
billingAccounts BillingAccount[]
billingInvoices BillingInvoice[]
brand BrandSettings?
employees Employee[]
vehicles Vehicle[]
offers Offer[]
reservations Reservation[]
customers Customer[]
rentalPayments RentalPayment[]
subscriptionInvoices SubscriptionInvoice[]
contractSettings ContractSettings?
accountingSettings AccountingSettings?
insurancePolicies InsurancePolicy[]
pricingRules PricingRule[]
notifications Notification[] @relation("CompanyNotifications")
notificationEvents NotificationEvent[]
notificationDefaults CompanyNotificationPreference[]
complaints Complaint[]
companyMenuItems CompanyMenuItem[]
apiKeys CompanyApiKey[]
subscription Subscription?
billingAccounts BillingAccount[]
billingInvoices BillingInvoice[]
brand BrandSettings?
employees Employee[]
vehicles Vehicle[]
offers Offer[]
reservations Reservation[]
customers Customer[]
rentalPayments RentalPayment[]
subscriptionInvoices SubscriptionInvoice[]
contractSettings ContractSettings?
accountingSettings AccountingSettings?
insurancePolicies InsurancePolicy[]
pricingRules PricingRule[]
notifications Notification[] @relation("CompanyNotifications")
notificationEvents NotificationEvent[]
notificationDefaults CompanyNotificationPreference[]
billingContacts BillingContact[]
manualPaymentSubmissions ManualPaymentSubmission[]
manualPaymentDocuments ManualPaymentDocument[]
collectionsCases CollectionsCase[]
collectionsEvents CollectionsEvent[]
complaints Complaint[]
companyMenuItems CompanyMenuItem[]
apiKeys CompanyApiKey[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -597,6 +690,7 @@ model Subscription {
maxRetryCount Int @default(5)
invoices SubscriptionInvoice[]
billingInvoices BillingInvoice[]
collectionsCases CollectionsCase[]
events SubscriptionEvent[]
createdAt DateTime @default(now())
@@ -623,28 +717,28 @@ model SubscriptionEvent {
}
model SubscriptionInvoice {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id])
requestedPlan Plan?
requestedBillingPeriod BillingPeriod?
providerInvoiceId String?
amount Int
currency String @default("MAD")
status InvoiceStatus
amanpayTransactionId String? @unique
paypalCaptureId String? @unique
stripeCheckoutSessionId String? @unique
paymentProvider PaymentProvider @default(AMANPAY)
billingInvoiceId String? @unique
billingInvoice BillingInvoice? @relation(fields: [billingInvoiceId], references: [id])
dueAt DateTime?
paidAt DateTime?
failedAt DateTime?
voidedAt DateTime?
attempts PaymentAttempt[]
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id])
requestedPlan Plan?
requestedBillingPeriod BillingPeriod?
providerInvoiceId String?
amount Int
currency String @default("MAD")
status InvoiceStatus
amanpayTransactionId String? @unique
paypalCaptureId String? @unique
stripeCheckoutSessionId String? @unique
paymentProvider PaymentProvider @default(AMANPAY)
billingInvoiceId String? @unique
billingInvoice BillingInvoice? @relation(fields: [billingInvoiceId], references: [id])
dueAt DateTime?
paidAt DateTime?
failedAt DateTime?
voidedAt DateTime?
attempts PaymentAttempt[]
createdAt DateTime @default(now())
@@ -653,35 +747,44 @@ model SubscriptionInvoice {
}
model BillingAccount {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
isPrimary Boolean @default(true)
legalName String
billingEmail String
preferredLanguage String @default("en")
billingAddress Json?
taxId String?
taxExempt Boolean @default(false)
defaultCurrency String @default("MAD")
defaultPaymentMethodId String?
defaultPaymentMethod BillingPaymentMethod? @relation("BillingAccountDefaultPaymentMethod", fields: [defaultPaymentMethodId], references: [id])
invoiceTerms BillingInvoiceTerms @default(DUE_ON_RECEIPT)
netTermsDays Int @default(0)
providerCustomerId String?
dunningPaused Boolean @default(false)
dunningPausedAt DateTime?
dunningPausedBy String?
metadata Json @default("{}")
paymentMethods BillingPaymentMethod[] @relation("BillingAccountPaymentMethods")
invoices BillingInvoice[]
paymentIntents BillingPaymentIntent[]
paymentAttempts BillingPaymentAttempt[]
creditBalances BillingCreditBalance[]
creditLedgerEntries BillingCreditLedgerEntry[]
creditNotes BillingCreditNote[]
refunds BillingRefund[]
events BillingEvent[]
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
isPrimary Boolean @default(true)
legalName String
billingEmail String
preferredLanguage String @default("en")
timezone String @default("Africa/Casablanca")
reminderLocalTime String @default("09:00")
enabledCommunicationLocales String[] @default(["en"])
defaultCommunicationLocale String @default("en")
collectionsOwnerAdminId String?
collectionsOwnerAdmin AdminUser? @relation("BillingAccountCollectionsOwner", fields: [collectionsOwnerAdminId], references: [id], onDelete: SetNull)
billingAddress Json?
taxId String?
taxExempt Boolean @default(false)
defaultCurrency String @default("MAD")
defaultPaymentMethodId String?
defaultPaymentMethod BillingPaymentMethod? @relation("BillingAccountDefaultPaymentMethod", fields: [defaultPaymentMethodId], references: [id])
invoiceTerms BillingInvoiceTerms @default(DUE_ON_RECEIPT)
netTermsDays Int @default(0)
providerCustomerId String?
dunningPaused Boolean @default(false)
dunningPausedAt DateTime?
dunningPausedBy String?
metadata Json @default("{}")
paymentMethods BillingPaymentMethod[] @relation("BillingAccountPaymentMethods")
invoices BillingInvoice[]
paymentIntents BillingPaymentIntent[]
paymentAttempts BillingPaymentAttempt[]
creditBalances BillingCreditBalance[]
creditLedgerEntries BillingCreditLedgerEntry[]
creditNotes BillingCreditNote[]
refunds BillingRefund[]
events BillingEvent[]
billingContacts BillingContact[]
manualPaymentSubmissions ManualPaymentSubmission[]
collectionsCases CollectionsCase[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -691,6 +794,31 @@ model BillingAccount {
@@map("billing_accounts")
}
model BillingContact {
id String @id @default(cuid())
billingAccountId String
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
email String
locale String?
isPrimary Boolean @default(false)
receivePaymentNotices Boolean @default(true)
isActive Boolean @default(true)
verifiedAt DateTime?
notificationRecipients NotificationRecipient[]
callTasks CollectionsCallTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([billingAccountId, email])
@@index([companyId, isActive])
@@index([employeeId])
@@map("billing_contacts")
}
model BillingPaymentMethod {
id String @id @default(cuid())
billingAccountId String
@@ -717,26 +845,26 @@ model BillingPaymentMethod {
}
model BillingInvoice {
id String @id @default(cuid())
id String @id @default(cuid())
billingAccountId String
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
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])
legacySubscriptionInvoice SubscriptionInvoice?
invoiceNumber String? @unique
invoiceSequence Int? @unique
invoiceNumber String? @unique
invoiceSequence Int? @unique
invoiceType BillingInvoiceType
status BillingInvoiceStatus @default(DRAFT)
currency String @default("MAD")
subtotalAmount Int @default(0)
discountAmount Int @default(0)
creditAmount Int @default(0)
taxAmount Int @default(0)
totalAmount Int @default(0)
amountPaid Int @default(0)
amountDue Int @default(0)
status BillingInvoiceStatus @default(DRAFT)
currency String @default("MAD")
subtotalAmount Int @default(0)
discountAmount Int @default(0)
creditAmount Int @default(0)
taxAmount Int @default(0)
totalAmount Int @default(0)
amountPaid Int @default(0)
amountDue Int @default(0)
invoiceDate DateTime?
dueAt DateTime?
finalizedAt DateTime?
@@ -748,9 +876,14 @@ model BillingInvoice {
billingAddress Json?
providerInvoiceId String?
paymentProvider PaymentProvider?
isSubscriptionBlocking Boolean @default(false)
collectionMethod SubscriptionCollectionMethod @default(STRIPE)
requestedPlan Plan?
requestedBillingPeriod BillingPeriod?
checkoutIdempotencyKey String?
renewalKey String? @unique
isSubscriptionBlocking Boolean @default(false)
adminReason String?
metadata Json @default("{}")
metadata Json @default("{}")
createdByAdminId String?
lineItems BillingInvoiceLineItem[]
paymentIntents BillingPaymentIntent[]
@@ -759,14 +892,18 @@ model BillingInvoice {
creditNotes BillingCreditNote[]
refunds BillingRefund[]
events BillingEvent[]
manualPaymentSubmissions ManualPaymentSubmission[]
collectionsCase CollectionsCase?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
BillingCreditLedgerEntry BillingCreditLedgerEntry[]
@@unique([billingAccountId, checkoutIdempotencyKey])
@@index([billingAccountId])
@@index([companyId])
@@index([subscriptionId])
@@index([collectionMethod, status, dueAt])
@@map("billing_invoices")
}
@@ -816,32 +953,217 @@ model BillingPaymentIntent {
}
model BillingPaymentAttempt {
id String @id @default(cuid())
invoiceId String
invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
billingAccountId String
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
paymentIntentId String?
paymentIntent BillingPaymentIntent? @relation(fields: [paymentIntentId], references: [id])
paymentMethodId String?
paymentMethod BillingPaymentMethod? @relation(fields: [paymentMethodId], references: [id])
providerPaymentId String?
status BillingPaymentAttemptStatus
amount Int
currency String @default("MAD")
failureCode String?
failureMessage String?
attemptedAt DateTime
metadata Json @default("{}")
refunds BillingRefund[]
id String @id @default(cuid())
invoiceId String
invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
billingAccountId String
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
paymentIntentId String?
paymentIntent BillingPaymentIntent? @relation(fields: [paymentIntentId], references: [id])
paymentMethodId String?
paymentMethod BillingPaymentMethod? @relation(fields: [paymentMethodId], references: [id])
providerPaymentId String?
channel BillingPaymentChannel @default(ONLINE)
manualMethod ManualPaymentMethod?
externalReference String?
normalizedExternalReference String?
receivedAt DateTime?
confirmedAt DateTime?
confirmedByAdminId String?
confirmedByAdmin AdminUser? @relation("ManualPaymentConfirmingAdmin", fields: [confirmedByAdminId], references: [id], onDelete: SetNull)
idempotencyKey String?
note String?
status BillingPaymentAttemptStatus
amount Int
currency String @default("MAD")
failureCode String?
failureMessage String?
attemptedAt DateTime
metadata Json @default("{}")
refunds BillingRefund[]
manualPaymentSubmission ManualPaymentSubmission?
resolvedCollectionsCase CollectionsCase? @relation("CollectionsResolutionPayment")
createdAt DateTime @default(now())
@@unique([billingAccountId, idempotencyKey])
@@unique([billingAccountId, manualMethod, normalizedExternalReference])
@@index([invoiceId])
@@index([billingAccountId])
@@map("billing_payment_attempts")
}
model ManualPaymentSubmission {
id String @id @default(cuid())
invoiceId String
invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
billingAccountId String
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
method ManualPaymentMethod
submittedReference String
normalizedSubmittedReference String
status ManualPaymentSubmissionStatus @default(DRAFT)
submittedByEmployeeId String
submittedByEmployee Employee @relation(fields: [submittedByEmployeeId], references: [id])
submittedAt DateTime?
reviewedByAdminId String?
reviewedByAdmin AdminUser? @relation("ManualPaymentReviewingAdmin", fields: [reviewedByAdminId], references: [id], onDelete: SetNull)
reviewedAt DateTime?
rejectionReason String?
idempotencyKey String
paymentAttemptId String? @unique
paymentAttempt BillingPaymentAttempt? @relation(fields: [paymentAttemptId], references: [id], onDelete: SetNull)
documents ManualPaymentDocument[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([billingAccountId, idempotencyKey])
@@index([invoiceId, status])
@@index([status, submittedAt])
@@map("manual_payment_submissions")
}
model ManualPaymentDocument {
id String @id @default(cuid())
submissionId String
submission ManualPaymentSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
invoiceId String
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
kind ManualPaymentDocumentKind
storageKey String @unique
originalFilename String
detectedMimeType String
detectedExtension String
byteSize Int
sha256 String
scanStatus ManualPaymentDocumentScanStatus @default(UPLOADED)
scannerResultCode String?
uploadedByEmployeeId String
uploadedByEmployee Employee @relation(fields: [uploadedByEmployeeId], references: [id])
uploadedAt DateTime @default(now())
deletedAt DateTime?
@@unique([submissionId, sha256])
@@index([invoiceId])
@@index([companyId])
@@index([scanStatus, uploadedAt])
@@map("manual_payment_documents")
}
model CollectionsCase {
id String @id @default(cuid())
invoiceId String @unique
invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
billingAccountId String
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
status CollectionsCaseStatus @default(SCHEDULED)
originalExpirationAt DateTime
reminder14At DateTime
reminder7At DateTime
reminder48At DateTime
reminder24At DateTime
graceStartedAt DateTime?
finalSuspensionAt DateTime
resolvedAt DateTime?
suspendedAt DateTime?
nextActionAt DateTime?
collectionsOwnerAdminId String?
collectionsOwnerAdmin AdminUser? @relation("CollectionsCaseOwner", fields: [collectionsOwnerAdminId], references: [id], onDelete: SetNull)
resolutionPaymentAttemptId String? @unique
resolutionPaymentAttempt BillingPaymentAttempt? @relation("CollectionsResolutionPayment", fields: [resolutionPaymentAttemptId], references: [id], onDelete: SetNull)
processingLeaseUntil DateTime?
processingBy String?
version Int @default(0)
tasks CollectionsCallTask[]
overrides CollectionsOverride[]
events CollectionsEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([subscriptionId, originalExpirationAt])
@@index([status, nextActionAt])
@@map("collections_cases")
}
model CollectionsCallTask {
id String @id @default(cuid())
collectionsCaseId String
collectionsCase CollectionsCase @relation(fields: [collectionsCaseId], references: [id], onDelete: Cascade)
taskType CollectionsCallTaskType
assignedAdminId String
assignedAdmin AdminUser @relation("CollectionsTaskAssignee", fields: [assignedAdminId], references: [id])
billingContactId String?
billingContact BillingContact? @relation(fields: [billingContactId], references: [id], onDelete: SetNull)
dueAt DateTime
status CollectionsCallTaskStatus @default(OPEN)
outcome CollectionsCallOutcome?
note String?
promisedPaymentAt DateTime?
nextFollowUpAt DateTime?
completedByAdminId String?
completedByAdmin AdminUser? @relation("CollectionsTaskCompleter", fields: [completedByAdminId], references: [id], onDelete: SetNull)
completedAt DateTime?
cancellationReason String?
companyDefaultLocale String
contactLocale String
customerScript String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([collectionsCaseId, taskType])
@@index([assignedAdminId, status, dueAt])
@@map("collections_call_tasks")
}
model CollectionsOverride {
id String @id @default(cuid())
collectionsCaseId String
collectionsCase CollectionsCase @relation(fields: [collectionsCaseId], references: [id], onDelete: Cascade)
type CollectionsOverrideType
status CollectionsOverrideStatus @default(ACTIVE)
reason String
expiresAt DateTime
revisedSuspensionAt DateTime?
pauseSuspension Boolean @default(true)
pauseNotifications Boolean @default(false)
createdByAdminId String
createdByAdmin AdminUser @relation("CollectionsOverrideCreator", fields: [createdByAdminId], references: [id])
revokedByAdminId String?
revokedByAdmin AdminUser? @relation("CollectionsOverrideRevoker", fields: [revokedByAdminId], references: [id], onDelete: SetNull)
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([collectionsCaseId, status, expiresAt])
@@map("collections_overrides")
}
model CollectionsEvent {
id String @id @default(cuid())
collectionsCaseId String
collectionsCase CollectionsCase @relation(fields: [collectionsCaseId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
eventType String
idempotencyKey String
scheduledFor DateTime?
occurredAt DateTime @default(now())
actorType String @default("system")
actorId String?
payload Json @default("{}")
@@unique([collectionsCaseId, idempotencyKey])
@@index([companyId, occurredAt])
@@map("collections_events")
}
model BillingCreditBalance {
id String @id @default(cuid())
billingAccountId String
@@ -1008,8 +1330,8 @@ model BrandSettings {
paypalEmail String?
paypalMerchantId String?
paymentMethodsEnabled PaymentProvider[]
isListedOnCarplace Boolean @default(true)
carplaceRating Float?
isListedOnCarplace Boolean @default(true)
carplaceRating Float?
homePageConfig Json? @map("home_page_config")
menuConfig Json? @map("menu_config")
@@ -1036,10 +1358,13 @@ model Employee {
preferredLanguage String @default("en")
isActive Boolean @default(true)
notifications Notification[] @relation("EmployeeNotifications")
notificationRecipients NotificationRecipient[]
notificationPreferences NotificationPreference[]
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
notifications Notification[] @relation("EmployeeNotifications")
notificationRecipients NotificationRecipient[]
notificationPreferences NotificationPreference[]
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
billingContacts BillingContact[]
manualPaymentSubmissions ManualPaymentSubmission[]
manualPaymentDocuments ManualPaymentDocument[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1073,10 +1398,10 @@ model Vehicle {
allowDifferentDropoff Boolean @default(false)
dropoffLocations String[] @default([])
reservations Reservation[]
maintenance MaintenanceLog[]
offerVehicles OfferVehicle[]
calendarBlocks VehicleCalendarBlock[]
reservations Reservation[]
maintenance MaintenanceLog[]
offerVehicles OfferVehicle[]
calendarBlocks VehicleCalendarBlock[]
pricingConfiguration VehiclePricingConfiguration?
priceHistory VehiclePriceHistory[]
@@ -1116,11 +1441,11 @@ model VehiclePricingConfiguration {
}
model VehiclePricingRule {
id String @id @default(cuid())
id String @id @default(cuid())
configurationId String
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
name String
ruleType VehiclePricingRuleType @default(DATE_RANGE)
ruleType VehiclePricingRuleType @default(DATE_RANGE)
startDate DateTime
endDate DateTime
dailyRate Int?
@@ -1131,8 +1456,8 @@ model VehiclePricingRule {
maxDailyRate Int?
automaticAdjustmentPct Int?
priceAdjustment Int?
isActive Boolean @default(true)
sortOrder Int @default(0)
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1143,12 +1468,12 @@ model VehiclePricingRule {
}
model VehiclePriceHistory {
id String @id @default(cuid())
id String @id @default(cuid())
configurationId String
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
vehicleId String
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
source VehiclePriceChangeSource @default(CONFIG_UPDATE)
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
source VehiclePriceChangeSource @default(CONFIG_UPDATE)
changedByEmployeeId String?
previousDailyRate Int?
nextDailyRate Int?
@@ -1156,7 +1481,7 @@ model VehiclePriceHistory {
nextWeeklyRate Int?
note String?
effectiveFrom DateTime?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([vehicleId, createdAt])
@@index([configurationId, createdAt])
@@ -1282,9 +1607,9 @@ model Customer {
licenseApprovedAt DateTime?
licenseApprovalNote String?
reservations Reservation[]
complaints Complaint[]
reviewOptOut Boolean @default(false)
reservations Reservation[]
complaints Complaint[]
reviewOptOut Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1309,7 +1634,7 @@ model Reservation {
promoCodeUsed String?
vehicleCategory VehicleCategory?
source BookingSource @default(DASHBOARD)
carplaceRef String?
carplaceRef String?
status ReservationStatus @default(DRAFT)
startDate DateTime
endDate DateTime
@@ -1342,11 +1667,11 @@ model Reservation {
reviewPaused Boolean @default(false)
insurances ReservationInsurance[]
insuranceTotal Int @default(0)
insuranceTotal Int @default(0)
additionalDrivers AdditionalDriver[]
additionalDriverTotal Int @default(0)
additionalDriverTotal Int @default(0)
pricingRulesApplied Json?
pricingRulesTotal Int @default(0)
pricingRulesTotal Int @default(0)
damageReports DamageReport[]
rentalPayments RentalPayment[]
inspections DamageInspection[]
@@ -1402,7 +1727,6 @@ model CarplaceFunnelEvent {
@@map("carplace_funnel_events")
}
model WebhookEvent {
id String @id @default(cuid())
provider String
@@ -1420,50 +1744,50 @@ model WebhookEvent {
}
model RentalPayment {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id])
amount Int
currency String @default("MAD")
status PaymentStatus @default(PENDING)
type PaymentType @default(CHARGE)
paymentProvider PaymentProvider
amanpayTransactionId String? @unique
paypalCaptureId String? @unique
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique
paymentMethod String?
reference String?
note String?
receivedAt DateTime?
recordedByEmployeeId String?
recordedByEmployee Employee? @relation("RecordedRentalPayments", fields: [recordedByEmployeeId], references: [id])
idempotencyKey String?
paidAt DateTime?
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id])
amount Int
currency String @default("MAD")
status PaymentStatus @default(PENDING)
type PaymentType @default(CHARGE)
paymentProvider PaymentProvider
amanpayTransactionId String? @unique
paypalCaptureId String? @unique
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique
paymentMethod String?
reference String?
note String?
receivedAt DateTime?
recordedByEmployeeId String?
recordedByEmployee Employee? @relation("RecordedRentalPayments", fields: [recordedByEmployeeId], references: [id])
idempotencyKey String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([companyId, idempotencyKey])
@@index([companyId])
@@index([reservationId])
@@unique([companyId, idempotencyKey])
@@map("rental_payments")
}
model Review {
id String @id @default(cuid())
reservationId String @unique
reservation Reservation @relation(fields: [reservationId], references: [id])
id String @id @default(cuid())
reservationId String @unique
reservation Reservation @relation(fields: [reservationId], references: [id])
renterId String?
renter Renter? @relation(fields: [renterId], references: [id])
renter Renter? @relation(fields: [renterId], references: [id])
companyId String
overallRating Int
vehicleRating Int?
serviceRating Int?
comment String?
isPublished Boolean @default(true)
isPublished Boolean @default(true)
companyReply String?
companyRepliedAt DateTime?
category FeedbackCategory?
@@ -1476,29 +1800,29 @@ model Review {
}
model Complaint {
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)
reservationId String?
reservation Reservation? @relation(fields: [reservationId], references: [id])
reservation Reservation? @relation(fields: [reservationId], references: [id])
reviewId String?
review Review? @relation(fields: [reviewId], references: [id])
review Review? @relation(fields: [reviewId], references: [id])
customerId String?
customer Customer? @relation(fields: [customerId], references: [id])
customer Customer? @relation(fields: [customerId], references: [id])
severity ComplaintSeverity @default(LEVEL_1)
status ComplaintStatus @default(OPEN)
category FeedbackCategory
subject String
description String?
resolution String?
notes String?
assignedTo String?
resolvedAt DateTime?
resolvedBy String?
severity ComplaintSeverity @default(LEVEL_1)
status ComplaintStatus @default(OPEN)
category FeedbackCategory
subject String
description String?
resolution String?
notes String?
assignedTo String?
resolvedAt DateTime?
resolvedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([reservationId])
@@ -1538,12 +1862,12 @@ model Notification {
}
model NotificationEvent {
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)
type NotificationType
templateKey String?
locale String @default("en")
locale String @default("en")
title String
body String
data Json?
@@ -1552,7 +1876,7 @@ model NotificationEvent {
idempotencyKey String
recipients NotificationRecipient[]
outboxEntries NotificationOutbox[]
createdAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([companyId, idempotencyKey])
@@index([companyId, type, createdAt])
@@ -1568,6 +1892,10 @@ model NotificationRecipient {
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: Cascade)
renterId String?
renter Renter? @relation(fields: [renterId], references: [id], onDelete: Cascade)
billingContactId String?
billingContact BillingContact? @relation(fields: [billingContactId], references: [id], onDelete: Cascade)
adminUserId String?
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
readAt DateTime?
archivedAt DateTime?
deliveries NotificationDelivery[]
@@ -1575,6 +1903,8 @@ model NotificationRecipient {
@@index([employeeId, readAt, createdAt])
@@index([renterId, readAt, createdAt])
@@index([billingContactId, readAt, createdAt])
@@index([adminUserId, readAt, createdAt])
@@index([notificationEventId])
@@map("notification_recipients")
}
@@ -1953,9 +2283,19 @@ model AdminUser {
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
auditLogs AuditLog[]
permissions AdminPermission[]
recoveryCodes AdminRecoveryCode[]
auditLogs AuditLog[]
permissions AdminPermission[]
recoveryCodes AdminRecoveryCode[]
preferredLocale String @default("en")
ownedBillingAccounts BillingAccount[] @relation("BillingAccountCollectionsOwner")
confirmedManualPayments BillingPaymentAttempt[] @relation("ManualPaymentConfirmingAdmin")
reviewedManualSubmissions ManualPaymentSubmission[] @relation("ManualPaymentReviewingAdmin")
ownedCollectionsCases CollectionsCase[] @relation("CollectionsCaseOwner")
assignedCollectionsTasks CollectionsCallTask[] @relation("CollectionsTaskAssignee")
completedCollectionsTasks CollectionsCallTask[] @relation("CollectionsTaskCompleter")
createdCollectionsOverrides CollectionsOverride[] @relation("CollectionsOverrideCreator")
revokedCollectionsOverrides CollectionsOverride[] @relation("CollectionsOverrideRevoker")
notificationRecipients NotificationRecipient[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1974,7 +2314,6 @@ model AdminPermission {
@@map("admin_permissions")
}
model AdminRecoveryCode {
id String @id @default(cuid())
adminUserId String
@@ -2022,6 +2361,15 @@ model PricingConfig {
@@map("pricing_configs")
}
model PlatformBillingSettings {
id String @id @default("default")
taxRate Float @default(20)
updatedAt DateTime @updatedAt
updatedBy String?
@@map("platform_billing_settings")
}
model PlanFeature {
id String @id @default(cuid())
plan Plan
+13 -1
View File
@@ -25,10 +25,21 @@ export type NotificationType =
| 'REFUND_PROCESSED'
| 'NEW_OFFER_FROM_SAVED_COMPANY'
| 'REVIEW_REQUEST'
| 'SUBSCRIPTION_PAYMENT_DUE_14D'
| 'SUBSCRIPTION_PAYMENT_DUE_7D'
| 'SUBSCRIPTION_PAYMENT_DUE_48H'
| 'SUBSCRIPTION_PAYMENT_DUE_24H'
| 'SUBSCRIPTION_GRACE_DAILY'
| 'SUBSCRIPTION_GRACE_FINAL'
| 'COLLECTIONS_CALL_REQUIRED'
| 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED'
| 'SUBSCRIPTION_PAYMENT_CONFIRMED'
| 'MANUAL_PAYMENT_EVIDENCE_REJECTED'
| 'COLLECTIONS_OVERRIDE_CHANGED'
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
export type NotificationDeliveryStatus = 'PENDING' | 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED' | 'SKIPPED' | 'DEAD_LETTER'
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER'
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER' | 'BILLING_CONTACT' | 'ADMIN'
export interface Company {
id: string
@@ -61,6 +72,7 @@ export interface AdminUser {
isActive: boolean
totpEnabled?: boolean
totpSecret?: string | null
preferredLocale?: string
}
export interface InsurancePolicy {
+13 -1
View File
@@ -25,10 +25,21 @@ export type NotificationType =
| 'REFUND_PROCESSED'
| 'NEW_OFFER_FROM_SAVED_COMPANY'
| 'REVIEW_REQUEST'
| 'SUBSCRIPTION_PAYMENT_DUE_14D'
| 'SUBSCRIPTION_PAYMENT_DUE_7D'
| 'SUBSCRIPTION_PAYMENT_DUE_48H'
| 'SUBSCRIPTION_PAYMENT_DUE_24H'
| 'SUBSCRIPTION_GRACE_DAILY'
| 'SUBSCRIPTION_GRACE_FINAL'
| 'COLLECTIONS_CALL_REQUIRED'
| 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED'
| 'SUBSCRIPTION_PAYMENT_CONFIRMED'
| 'MANUAL_PAYMENT_EVIDENCE_REJECTED'
| 'COLLECTIONS_OVERRIDE_CHANGED'
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
export type NotificationDeliveryStatus = 'PENDING' | 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED' | 'SKIPPED' | 'DEAD_LETTER'
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER'
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER' | 'BILLING_CONTACT' | 'ADMIN'
export interface Company {
id: string
@@ -61,6 +72,7 @@ export interface AdminUser {
isActive: boolean
totpEnabled?: boolean
totpSecret?: string | null
preferredLocale?: string
}
export interface InsurancePolicy {
+2 -1
View File
@@ -75,11 +75,12 @@ PGMANAGE_DEFAULT_PASSWORD=change-me
# Email
EMAIL_FROM=rentaldrivego@gmail.com
EMAIL_FROM_NAME=RentalDriveGo
EMAIL_PROVIDER=gmail
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=placeholder
MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
+6 -13
View File
@@ -1,21 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"exclude": ["node_modules", "dist"]
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
+12
View File
@@ -2,6 +2,18 @@
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalPassThroughEnv": [
"ADMIN_SEED_EMAIL",
"ADMIN_SEED_FIRST_NAME",
"ADMIN_SEED_LAST_NAME",
"ADMIN_SEED_PASSWORD",
"DATABASE_URL",
"DATABASE_URL_FROM_POSTGRES",
"POSTGRES_DB",
"POSTGRES_HOST",
"POSTGRES_HOST_LOCAL",
"POSTGRES_PASSWORD",
"POSTGRES_PORT",
"POSTGRES_USER",
"SITE_ORIGIN",
"NEXT_PUBLIC_HOMEPAGE_URL",
"NEXT_PUBLIC_CARPLACE_URL"