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
+20 -6
View File
@@ -21,11 +21,11 @@ ADMIN_INTERNAL_URL=http://host.docker.internal:3002
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
ADMIN_ASSET_PREFIX=http://localhost:3002/admin ADMIN_ASSET_PREFIX=http://localhost:3002/admin
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
JWT_SECRET=placeholder JWT_SECRET=JYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00
JWT_EXPIRY=8h JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d RENTER_JWT_EXPIRY=7d
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
ADMIN_SEED_PASSWORD=placeholder ADMIN_SEED_PASSWORD=Qwerty00!@#$%
ADMIN_SEED_FIRST_NAME=Platform ADMIN_SEED_FIRST_NAME=Platform
ADMIN_SEED_LAST_NAME=Admin ADMIN_SEED_LAST_NAME=Admin
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 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 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 # Email — Gmail SMTP for local Docker development
# Get your API key at https://resend.com/api-keys EMAIL_PROVIDER=gmail
RESEND_API_KEY=re_PLACEHOLDER RESEND_API_KEY=re_PLACEHOLDER
EMAIL_FROM=noreply@rentaldrivego.ma EMAIL_FROM=noreply@rentaldrivego.ma
EMAIL_FROM_NAME=RentalDriveGo 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_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
MAIL_SCHEME=smtp MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=placeholder MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
@@ -56,3 +56,17 @@ MAIL_REPLY_TO_NAME=RentalDriveGo
# STRIPE_WEBHOOK_SECRET must be a Stripe webhook signing secret (whsec_). # STRIPE_WEBHOOK_SECRET must be a Stripe webhook signing secret (whsec_).
STRIPE_API_KEY=sk_test_51TvTsb9SpDRZn9yJyBAlUSXcTp9zpwQfhJYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00OcYvj28K 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 # Option A — Resend
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V #RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
# Option B — SMTP (Gmail) # 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_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
MAIL_SCHEME=smtp MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=placeholder MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com 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_FIRST_NAME=Super
ADMIN_SEED_LAST_NAME=Admin 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_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
MAIL_SCHEME=smtp MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=kfahihfzbcvkczew MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com 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 // 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). // port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH) 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 = { const nextConfig = {
basePath: ADMIN_BASE_PATH, basePath: ADMIN_BASE_PATH,
@@ -9,6 +9,7 @@ interface AdminUser {
lastName: string lastName: string
email: string email: string
role: string role: string
preferredLocale: 'ar' | 'en' | 'fr'
isActive: boolean isActive: boolean
createdAt: string createdAt: string
permissions?: { id: string; resource: string; actions: string[] }[] permissions?: { id: string; resource: string; actions: string[] }[]
@@ -21,6 +22,7 @@ const EMPTY_FORM = {
email: '', email: '',
password: '', password: '',
role: 'SUPPORT', role: 'SUPPORT',
preferredLocale: 'en',
isActive: true, isActive: true,
} }
@@ -68,6 +70,7 @@ export default function AdminUsersPage() {
email: admin.email, email: admin.email,
password: '', password: '',
role: admin.role, role: admin.role,
preferredLocale: admin.preferredLocale,
isActive: admin.isActive, isActive: admin.isActive,
}) })
setError(null) setError(null)
@@ -92,6 +95,7 @@ export default function AdminUsersPage() {
lastName: form.lastName, lastName: form.lastName,
email: form.email, email: form.email,
role: form.role, role: form.role,
preferredLocale: form.preferredLocale,
isActive: form.isActive, isActive: form.isActive,
...(form.password ? { password: form.password } : {}), ...(form.password ? { password: form.password } : {}),
} }
@@ -273,6 +277,18 @@ export default function AdminUsersPage() {
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)} {ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select> </select>
</div> </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> <div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label> <label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
<select <select
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,6 @@ interface CompanyDetail {
terms: string terms: string
fuelPolicyType: string fuelPolicyType: string
lateFeePerHour: number | null lateFeePerHour: number | null
taxRate: number | null
signatureRequired: boolean signatureRequired: boolean
showTax: boolean showTax: boolean
} | null } | null
@@ -180,7 +179,6 @@ interface FormState {
terms: string terms: string
fuelPolicyType: string fuelPolicyType: string
lateFeePerHour: string lateFeePerHour: string
taxRate: string
signatureRequired: boolean signatureRequired: boolean
showTax: boolean showTax: boolean
} }
@@ -297,7 +295,6 @@ function createFormState(company: CompanyDetail): FormState {
terms: company.contractSettings?.terms ?? '', terms: company.contractSettings?.terms ?? '',
fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL', fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL',
lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '', lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '',
taxRate: company.contractSettings?.taxRate?.toString() ?? '',
signatureRequired: company.contractSettings?.signatureRequired ?? true, signatureRequired: company.contractSettings?.signatureRequired ?? true,
showTax: company.contractSettings?.showTax ?? false, showTax: company.contractSettings?.showTax ?? false,
}, },
@@ -441,7 +438,6 @@ export default function AdminCompanyDetailPage() {
terms: form.contractSettings.terms, terms: form.contractSettings.terms,
fuelPolicyType: form.contractSettings.fuelPolicyType, fuelPolicyType: form.contractSettings.fuelPolicyType,
lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null, lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null,
taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null,
signatureRequired: form.contractSettings.signatureRequired, signatureRequired: form.contractSettings.signatureRequired,
showTax: form.contractSettings.showTax, showTax: form.contractSettings.showTax,
}, },
@@ -915,10 +911,6 @@ export default function AdminCompanyDetailPage() {
<span className={LABEL_CLASS}>Late fee per hour</span> <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 })} /> <input className={INPUT_CLASS} type="number" value={form.contractSettings.lateFeePerHour} onChange={(e) => updateSection('contractSettings', { lateFeePerHour: e.target.value })} />
</label> </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"> <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 })} /> <input type="checkbox" checked={form.contractSettings.signatureRequired} onChange={(e) => updateSection('contractSettings', { signatureRequired: e.target.checked })} />
Signature required Signature required
+198 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState, type FormEvent } from 'react'
import { import {
AdminLanguageSwitcher, AdminLanguageSwitcher,
AdminThemeSwitcher, AdminThemeSwitcher,
@@ -40,6 +40,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const pathname = usePathname() const pathname = usePathname()
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [admin, setAdmin] = useState<AdminSessionUser | null>(null) const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
const [unreadNotifications, setUnreadNotifications] = useState(0)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -58,6 +59,14 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
} }
setAdmin(resolvedAdmin) setAdmin(resolvedAdmin)
setReady(true) 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 { } else {
window.location.replace(buildUnifiedLoginUrl(pathname)) 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 ( return (
<AdminSessionProvider admin={admin as AdminSessionUser}> <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"> <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} /> <path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg> </svg>
{dict.nav[link.key]} {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> </Link>
) )
})} })}
@@ -132,3 +154,178 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
</AdminSessionProvider> </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, formatNotificationDate,
type NotificationsPageResult, type NotificationsPageResult,
} from '@/lib/adminNotifications' } 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 CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ'] const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
@@ -49,6 +57,14 @@ export default function AdminNotificationsPage() {
const [filterStatus, setFilterStatus] = useState('') const [filterStatus, setFilterStatus] = useState('')
const [filterCompany, setFilterCompany] = useState('') const [filterCompany, setFilterCompany] = useState('')
const [page, setPage] = useState(1) 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) { function load(p: number, signal?: AbortSignal) {
setLoading(true) setLoading(true)
@@ -73,6 +89,7 @@ export default function AdminNotificationsPage() {
const controller = new AbortController() const controller = new AbortController()
setPage(1) setPage(1)
load(1, controller.signal) load(1, controller.signal)
loadInbox()
return () => controller.abort() return () => controller.abort()
}, [filterChannel, filterStatus, filterCompany]) }, [filterChannel, filterStatus, filterCompany])
@@ -83,6 +100,11 @@ export default function AdminNotificationsPage() {
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0 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 ( return (
<div className="shell py-8 space-y-6"> <div className="shell py-8 space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
@@ -100,6 +122,27 @@ export default function AdminNotificationsPage() {
)} )}
</div> </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 */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<select <select
+30
View File
@@ -154,6 +154,10 @@ html.dark .panel {
color: rgb(28 25 23); color: rgb(28 25 23);
} }
.light .text-zinc-50 {
color: rgb(28 25 23);
}
.light .text-zinc-500 { .light .text-zinc-500 {
color: rgb(120 113 108); color: rgb(120 113 108);
} }
@@ -171,4 +175,30 @@ html.dark .panel {
.light .hover\:text-zinc-200:hover { .light .hover\:text-zinc-200:hover {
color: rgb(28 25 23); 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 { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens' import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies' import { getSessionCookieName } from './security/sessionCookies'
import { sendNotification } from './services/notificationService' import { processNotificationOutbox, sendNotification } from './services/notificationService'
import { import {
runTrialExpirationJob, runTrialExpirationJob,
runPaymentPendingTimeoutJob,
runPastDueTimeoutJob,
runSuspensionTimeoutJob,
runPeriodEndCancellationJob, runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service' } from './modules/subscriptions/subscription.service'
import { runCollectionsWorker } from './modules/subscriptions/subscription.collections.service'
const app = createApp() const app = createApp()
const server = http.createServer(app) const server = http.createServer(app)
@@ -108,26 +106,24 @@ cron.schedule('0 * * * *', async () => {
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`) if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
}) })
// Hourly: payment_pending → past_due after 7 days // Daily: explicit period-end cancellations. Subscription collections use the
cron.schedule('15 * * * *', async () => { // timezone-aware 30-day grace worker below, so the old fixed 7-day chain is
const n = await runPaymentPendingTimeoutJob() // intentionally not scheduled.
if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`)
})
// Hourly: past_due → suspended after 7 days
cron.schedule('30 * * * *', async () => {
const n = await runPastDueTimeoutJob()
if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`)
})
// Daily: suspended → cancelled after 16 days; and period-end cancellations
cron.schedule('0 1 * * *', async () => { cron.schedule('0 1 * * *', async () => {
const nSuspend = await runSuspensionTimeoutJob()
const nPeriod = await runPeriodEndCancellationJob() 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`) 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) // Daily: send trial-ending reminders (3 days before trial end)
cron.schedule('0 9 * * *', async () => { cron.schedule('0 9 * * *', async () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
+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}.` `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 return storageRoot
@@ -141,3 +149,58 @@ export async function deleteImage(imageUrl: string): Promise<void> {
fs.unlinkSync(filePath) 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 jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth' import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from './requireAdminAuth'
function responseStub() { function responseStub() {
const res = { status: vi.fn(), json: vi.fn() } const res = { status: vi.fn(), json: vi.fn() }
@@ -163,3 +163,35 @@ describe('requireAdminRole middleware', () => {
expect(res.status).not.toHaveBeenCalled() 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', '/auth/2fa/verify',
]) ])
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
function is2faEnrollmentExempt(req: Request) { function is2faEnrollmentExempt(req: Request) {
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path) 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') return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
} }
const last2faAt = req.adminAuthLast2faAt if (!req.adminAuthLast2faAt) {
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) { return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
} }
next() next()
@@ -3,8 +3,11 @@ import {
billingAccountUpdateSchema, billingAccountUpdateSchema,
billingCreditNoteSchema, billingCreditNoteSchema,
billingRefundSchema, billingRefundSchema,
collectionsOverrideSchema,
confirmManualPaymentSchema,
createBillingInvoiceSchema, createBillingInvoiceSchema,
payBillingInvoiceSchema, payBillingInvoiceSchema,
platformBillingSettingsSchema,
} from './admin.schemas' } from './admin.schemas'
describe('admin billing schemas', () => { describe('admin billing schemas', () => {
@@ -44,10 +47,38 @@ describe('admin billing schemas', () => {
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow() 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', () => { 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: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow() expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow() expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).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 { prisma } from '../../lib/prisma'
import { NotFoundError, ValidationError } from '../../http/errors' import { NotFoundError, ValidationError } from '../../http/errors'
import { generateInvoicePdf } from '../../services/invoicePdfService' import { generateInvoicePdf } from '../../services/invoicePdfService'
import { calculateTaxAmount, getPlatformBillingSettings, updatePlatformBillingSettings } from '../subscriptions/billingTax'
const BLOCKING_INVOICE_TYPES = new Set([ const BLOCKING_INVOICE_TYPES = new Set([
'SUBSCRIPTION_INITIAL', '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']) const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'])
function toSequenceNumber(value: unknown) { function toSequenceNumber(value: unknown) {
if (typeof value === 'bigint') return Number(value) if (typeof value === 'bigint') return Number(value)
if (typeof value === 'number') return 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 } 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) { function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances?: any[] }>(account: T) {
const invoices = account.invoices ?? [] const invoices = account.invoices ?? []
const creditBalances = account.creditBalances ?? [] 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: { async function createBillingEvent(tx: any, data: {
billingAccountId?: string | null billingAccountId?: string | null
invoiceId?: string | null invoiceId?: string | null
@@ -142,11 +162,41 @@ async function createAuditLog(data: {
} }
async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) { async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) {
const existing = await tx.billingAccount.findFirst({ const existingAccounts = await tx.billingAccount.findMany({
where: { companyId, isPrimary: true }, where: { companyId, isPrimary: true },
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: 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({ const company = await tx.company.findUniqueOrThrow({
where: { id: companyId }, where: { id: companyId },
@@ -346,7 +396,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
} }
function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) { function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) {
const where: any = {} const where: any = { isPrimary: true }
if (query.q) { if (query.q) {
where.OR = [ where.OR = [
{ legalName: { contains: query.q, mode: 'insensitive' } }, { legalName: { contains: query.q, mode: 'insensitive' } },
@@ -510,6 +560,13 @@ export async function getBillingAccountDetail(companyId: string) {
lineItems: { orderBy: { createdAt: 'asc' } }, lineItems: { orderBy: { createdAt: 'asc' } },
paymentIntents: { orderBy: { createdAt: 'desc' } }, paymentIntents: { orderBy: { createdAt: 'desc' } },
paymentAttempts: { orderBy: { attemptedAt: '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, taxRecords: true,
creditNotes: { orderBy: { createdAt: 'desc' } }, creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { orderBy: { createdAt: 'desc' } }, refunds: { orderBy: { createdAt: 'desc' } },
@@ -575,6 +632,25 @@ export async function updateBillingAccount(
return updated 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( export async function setDunningPaused(
billingAccountId: string, billingAccountId: string,
paused: boolean, paused: boolean,
@@ -704,6 +780,7 @@ export async function createDraftInvoice(
} }
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) { export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
const platformBillingSettings = await getPlatformBillingSettings()
const invoice = await prisma.$transaction(async (tx: any) => { const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({ const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId }, 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 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) { if (taxAmount > 0) {
await tx.billingInvoiceLineItem.create({ await tx.billingInvoiceLineItem.create({
@@ -911,6 +987,9 @@ export async function payInvoice(
if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) { if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Invoice is not payable in its current state') 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 const amount = data.amount ?? current.amountDue
if (amount <= 0 || amount > current.amountDue) { if (amount <= 0 || amount > current.amountDue) {
@@ -1389,6 +1468,7 @@ export async function getInvoicePdf(invoiceId: string) {
transactionId: latestPaymentAttempt?.providerPaymentId ?? null, transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(), paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({ lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description, description: item.description,
amount: item.amount, amount: item.amount,
currency: item.currency, currency: item.currency,
@@ -1401,6 +1481,7 @@ export async function getInvoicePdf(invoiceId: string) {
subtotalAmount: invoice.subtotalAmount, subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount, discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount, creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount, taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount, totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid, 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 firstName: string
lastName: string lastName: string
role: string role: string
preferredLocale: string
passwordHash: string passwordHash: string
permissions?: any[] permissions?: any[]
}) { }) {
@@ -354,6 +355,7 @@ export function createAdmin(data: {
firstName: data.firstName, firstName: data.firstName,
lastName: data.lastName, lastName: data.lastName,
role: data.role as any, role: data.role as any,
preferredLocale: data.preferredLocale,
passwordHash: data.passwordHash, passwordHash: data.passwordHash,
permissions: data.permissions ? { create: data.permissions } : undefined, permissions: data.permissions ? { create: data.permissions } : undefined,
}, },
@@ -372,6 +374,7 @@ export function updateAdmin(
firstName?: string firstName?: string
lastName?: string lastName?: string
role?: string role?: string
preferredLocale?: string
passwordHash?: string passwordHash?: string
isActive?: boolean isActive?: boolean
}, },
@@ -383,6 +386,7 @@ export function updateAdmin(
...(data.firstName !== undefined ? { firstName: data.firstName } : {}), ...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
...(data.lastName !== undefined ? { lastName: data.lastName } : {}), ...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
...(data.role !== undefined ? { role: data.role as any } : {}), ...(data.role !== undefined ? { role: data.role as any } : {}),
...(data.preferredLocale !== undefined ? { preferredLocale: data.preferredLocale } : {}),
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}), ...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}), ...(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 service from './admin.service'
import * as subService from '../subscriptions/subscription.service' import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.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 { presentAdminUser } from './admin.presenter'
import { import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
@@ -17,9 +20,14 @@ import {
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema, pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema, promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema, billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
platformBillingSettingsSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema, retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema, menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema, menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
} from './admin.schemas' } from './admin.schemas'
import { z } from 'zod' import { z } from 'zod'
@@ -279,6 +287,17 @@ router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), asyn
} catch (err) { next(err) } } 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 ──────────────────────────────────────────────── // ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { 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) } } 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) => { router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { try {
const { companyId } = parseParams(companyIdParamSchema, req) 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(), firstName: z.string().min(1).max(100).trim(),
lastName: z.string().min(1).max(100).trim(), lastName: z.string().min(1).max(100).trim(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']), role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
preferredLocale: z.enum(['ar', 'en', 'fr']).default('en'),
password: z.string().min(8), password: z.string().min(8),
permissions: z.array(permissionSchema).optional(), permissions: z.array(permissionSchema).optional(),
}) })
@@ -96,6 +97,7 @@ export const updateAdminSchema = z.object({
firstName: z.string().min(1).max(100).trim().optional(), firstName: z.string().min(1).max(100).trim().optional(),
lastName: 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(), role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
preferredLocale: z.enum(['ar', 'en', 'fr']).optional(),
password: z.string().min(8).optional(), password: z.string().min(8).optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
}) })
@@ -173,7 +175,7 @@ export const adminCompanyUpdateSchema = z.object({
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString, legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
terms: z.string().optional(), terms: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).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(), signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
}).optional(), }).optional(),
accountingSettings: z.object({ accountingSettings: z.object({
@@ -243,6 +245,10 @@ export const billingAccountUpdateSchema = z.object({
netTermsDays: z.number().int().min(0).max(365).optional(), 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({ export const billingLineItemInputSchema = z.object({
type: z.enum([ type: z.enum([
'SUBSCRIPTION_FEE', 'SUBSCRIPTION_FEE',
@@ -292,6 +298,80 @@ export const payBillingInvoiceSchema = z.object({
providerPaymentId: z.union([z.string(), z.null()]).optional(), 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({ export const retryBillingInvoiceSchema = z.object({
paymentMethodId: z.union([z.string(), z.null()]).optional(), paymentMethodId: z.union([z.string(), z.null()]).optional(),
}) })
@@ -3,8 +3,10 @@ import bcrypt from 'bcryptjs'
vi.mock('./admin.repo', () => ({ vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(), findAdminByEmail: vi.fn(),
findAdminByIdOrThrow: vi.fn(),
setAdminPasswordReset: vi.fn(), setAdminPasswordReset: vi.fn(),
updateAdminLastLogin: vi.fn(), updateAdminLastLogin: vi.fn(),
updateAdminTotpSecret: vi.fn(),
createAuditLog: vi.fn(), createAuditLog: vi.fn(),
})) }))
@@ -12,6 +14,10 @@ vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined), sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
})) }))
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,test') },
}))
const redisStore = new Map<string, string>() const redisStore = new Map<string, string>()
vi.mock('../../lib/redis', () => ({ vi.mock('../../lib/redis', () => ({
@@ -33,7 +39,7 @@ vi.mock('../../lib/redis', () => ({
import * as repo from './admin.repo' import * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService' import { sendTransactionalEmail } from '../../services/notificationService'
import { forgotPassword, login } from './admin.service' import { forgotPassword, login, setupTotp } from './admin.service'
describe('admin.service forgotPassword', () => { describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL const originalAdminUrl = process.env.ADMIN_URL
@@ -123,4 +129,20 @@ describe('admin.service forgotPassword', () => {
})) }))
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3') 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) { export async function setupTotp(adminId: string, email: string) {
const secret = authenticator.generateSecret() const admin = await repo.findAdminByIdOrThrow(adminId)
await repo.updateAdminTotpSecret(adminId, secret) 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 otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
const qrCode = await qrcode.toDataURL(otpauth) const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode } return { secret, qrCode }
@@ -335,7 +340,7 @@ export async function listAdmins() {
return admins.map((admin: any) => presenter.presentAdminUser(admin)) 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({ const admin = await repo.createAdmin({
...body, ...body,
passwordHash: await bcrypt.hash(body.password, 12), passwordHash: await bcrypt.hash(body.password, 12),
@@ -350,6 +355,7 @@ export async function updateAdmin(
firstName?: string firstName?: string
lastName?: string lastName?: string
role?: string role?: string
preferredLocale?: string
password?: string password?: string
isActive?: boolean isActive?: boolean
}, },
@@ -359,6 +365,7 @@ export async function updateAdmin(
...(body.firstName !== undefined ? { firstName: body.firstName } : {}), ...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
...(body.lastName !== undefined ? { lastName: body.lastName } : {}), ...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
...(body.role !== undefined ? { role: body.role } : {}), ...(body.role !== undefined ? { role: body.role } : {}),
...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}), ...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}), ...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
}) })
@@ -406,6 +413,18 @@ export function updateBillingAccount(
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip) 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) { export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip) return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
} }
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { AppError } from '../../http/errors' import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens' import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService' import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo' import * as repo from './auth.company.repo'
import type { output } from 'zod' import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas' import type { accountStartSchema } from './auth.account.schemas'
@@ -126,8 +126,7 @@ export async function startAccount(body: AccountStartInput) {
text: emailTexts[lang], text: emailTexts[lang],
}).catch((err) => { }).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message ?? String(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] Email provider config:', describeEmailProviderConfig())
console.error('[AccountStart] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
}) })
return { return {
@@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken'
import { signActorToken } from '../../security/tokens' import { signActorToken } from '../../security/tokens'
import { AppError } from '../../http/errors' import { AppError } from '../../http/errors'
import { hashPublicAccessToken } from '../../security/publicAccessTokens' import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService' import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations' import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
import { presentEmployeeSession } from './auth.presenter' import { presentEmployeeSession } from './auth.presenter'
import * as repo from './auth.employee.repo' 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), text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
}).catch((err) => { }).catch((err) => {
console.error('[ForgotPassword] Email delivery failed:', err?.message) 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}`, text: `Verify your email by visiting:\n${verifyUrl}`,
}).catch((err) => { }).catch((err) => {
console.error('[ResendVerification] Email delivery failed:', err?.message ?? String(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] Email provider config:', describeEmailProviderConfig())
console.error('[ResendVerification] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
}) })
return { message: 'A new verification link has been sent to your email.' } 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', fuelPolicyType: 'FULL_TO_FULL',
additionalDriverPolicy: 'Additional drivers must be approved before pickup.', additionalDriverPolicy: 'Additional drivers must be approved before pickup.',
additionalDriverCharge: 'PER_DAY', additionalDriverCharge: 'PER_DAY',
taxRate: 20,
})).toMatchObject({ })).toMatchObject({
fuelPolicy: 'Return with the same fuel level.', fuelPolicy: 'Return with the same fuel level.',
fuelPolicyType: 'FULL_TO_FULL', fuelPolicyType: 'FULL_TO_FULL',
@@ -111,7 +111,6 @@ export const contractSettingsSchema = z.object({
invoiceFooterNote: z.string().optional(), invoiceFooterNote: z.string().optional(),
signatureRequired: z.boolean().optional(), signatureRequired: z.boolean().optional(),
showTax: z.boolean().optional(), showTax: z.boolean().optional(),
taxRate: z.number().optional(),
taxLabel: z.string().optional(), taxLabel: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(), fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
fuelPolicyNote: z.string().optional(), fuelPolicyNote: z.string().optional(),
@@ -1,6 +1,7 @@
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import * as repo from './reservation.repo' import * as repo from './reservation.repo'
import { parseReservationExtras, serializeContractFields } from './reservation.presenter' import { parseReservationExtras, serializeContractFields } from './reservation.presenter'
import { calculateTaxAmount, getPlatformBillingSettings } from '../subscriptions/billingTax'
export function formatDocumentNumber(prefix: string, sequence: number): string { export function formatDocumentNumber(prefix: string, sequence: number): string {
return `${prefix}-${String(sequence).padStart(6, '0')}` 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 subtotal = invoiceLineItems.reduce((s, i) => s + i.total, 0)
const taxes = contractSettings.showTax && contractSettings.taxRate const platformBillingSettings = await getPlatformBillingSettings()
? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: contractSettings.taxRate, amount: Math.round(subtotal * contractSettings.taxRate / 100) }] 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 taxTotal = taxes.reduce((s, t) => s + t.amount, 0)
const grandTotal = subtotal + taxTotal 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 { requireTenant } from '../../middleware/requireTenant'
import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription' import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole' import { requireRole } from '../../middleware/requireRole'
import { parseBody } from '../../http/validate' import { parseBody, parseParams } from '../../http/validate'
import { ok } from '../../http/respond' import { created, ok } from '../../http/respond'
import { paymentEvidenceUpload } from '../../http/upload/paymentEvidence'
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks' import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
import * as amanpay from '../../services/amanpayService' import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService' import * as paypal from '../../services/paypalService'
@@ -17,7 +18,15 @@ import {
startTrialSchema, startTrialSchema,
cancelSchema, cancelSchema,
reactivateSchema, reactivateSchema,
manualCheckoutSchema,
invoiceIdParamSchema,
submissionIdParamSchema,
submissionDocumentParamsSchema,
createManualPaymentSubmissionSchema,
manualPaymentDocumentFieldsSchema,
communicationSettingsSchema,
} from './subscription.schemas' } from './subscription.schemas'
import * as manualService from './subscription.manual.service'
const publicRouter = Router() const publicRouter = Router()
const webhookRouter = Router() const webhookRouter = Router()
@@ -83,7 +92,24 @@ router.get('/me', async (req, res, next) => {
}) })
router.get('/invoices', async (req, res, next) => { router.get('/invoices', async (req, res, next) => {
try { ok(res, await service.getInvoices(req.companyId)) } catch (err) { next(err) } 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) => { router.get('/events', async (req, res, next) => {
@@ -108,6 +134,83 @@ router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) } } 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) => { router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => {
try { try {
const body = parseBody(reactivateSchema, req) const body = parseBody(reactivateSchema, req)
@@ -4,6 +4,13 @@ const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL']) const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
const providerEnum = z.enum(['STRIPE']) const providerEnum = z.enum(['STRIPE'])
const currencyEnum = z.enum(['MAD', 'EUR', 'USD']) 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({ export const checkoutSchema = z.object({
plan: planEnum, plan: planEnum,
@@ -43,3 +50,60 @@ export const reactivateSchema = z.object({
successUrl: z.string().url(), successUrl: z.string().url(),
failureUrl: 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(), 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', () => ({ vi.mock('../../security/webhookIdempotency', () => ({
getWebhookEventId: vi.fn((provider: string, event: any) => event.id ?? event.transaction_id ?? `${provider}_event`), getWebhookEventId: vi.fn((provider: string, event: any) => event.id ?? event.transaction_id ?? `${provider}_event`),
processWebhookOnce: vi.fn(async ({ handle }: { handle: () => Promise<unknown> }) => { 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 paypal from '../../services/paypalService'
import * as stripe from '../../services/stripeService' import * as stripe from '../../services/stripeService'
import * as repo from './subscription.repo' import * as repo from './subscription.repo'
import * as manualService from './subscription.manual.service'
import * as service from './subscription.service' import * as service from './subscription.service'
describe('subscription.service operational edges', () => { 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(repo.findOrCreateSubscription).mockResolvedValue({ id: 'sub_1' } as never)
vi.mocked(stripe.isConfigured).mockReturnValue(true) 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(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', { await expect(service.checkout('company_1', {
plan: 'GROWTH', plan: 'GROWTH',
@@ -150,15 +157,12 @@ describe('subscription.service operational edges', () => {
subscriptionId: 'sub_1', subscriptionId: 'sub_1',
type: 'SUBSCRIPTION', type: 'SUBSCRIPTION',
})) }))
expect(repo.createInvoice).toHaveBeenCalledWith(expect.objectContaining({ expect(manualService.createCanonicalStripeCheckoutInvoice).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1', companyId: 'company_1',
subscriptionId: 'sub_1', subscriptionId: 'sub_1',
requestedPlan: 'GROWTH', plan: 'GROWTH',
requestedBillingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
amount: 19900, amount: 19900,
paymentProvider: 'STRIPE',
amanpayTransactionId: null,
paypalCaptureId: null,
stripeCheckoutSessionId: 'cs_test_123', stripeCheckoutSessionId: 'cs_test_123',
dueAt: new Date('2026-06-08T00:00:00.000Z'), 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 * as repo from './subscription.repo'
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy' import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency' import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
import {
createCanonicalStripeCheckoutInvoice,
finalizeCanonicalOnlinePayment,
recordCanonicalOnlinePaymentFailure,
} from './subscription.manual.service'
// ─── Helpers ────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────
@@ -114,6 +119,7 @@ async function handlePaymentSuccess(subscriptionId: string, invoiceId: string, p
billingPeriod?: string | null billingPeriod?: string | null
currency?: string | null currency?: string | null
}) { }) {
if (await finalizeCanonicalOnlinePayment(invoiceId)) return
const sub = await repo.findById(subscriptionId) const sub = await repo.findById(subscriptionId)
if (!sub) return if (!sub) return
@@ -144,6 +150,7 @@ async function handlePaymentFailure(
failureCode?: string, failureCode?: string,
failureMessage?: string, failureMessage?: string,
) { ) {
if (await recordCanonicalOnlinePaymentFailure(invoiceId, failureCode, failureMessage)) return
const sub = await repo.findById(subscriptionId) const sub = await repo.findById(subscriptionId)
if (!sub) return if (!sub) return
@@ -274,11 +281,6 @@ export async function checkout(companyId: string, body: {
const orderId = `sub-${companyId}-${Date.now()}` const orderId = `sub-${companyId}-${Date.now()}`
const description = `${body.plan} plan — ${body.billingPeriod}` 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') if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured on this platform')
const result = await stripe.createCheckoutSession({ const result = await stripe.createCheckoutSession({
amount, currency: body.currency, orderId, description, amount, currency: body.currency, orderId, description,
@@ -289,18 +291,19 @@ export async function checkout(companyId: string, body: {
subscriptionId: subscription.id, subscriptionId: subscription.id,
type: 'SUBSCRIPTION', type: 'SUBSCRIPTION',
}) })
checkoutUrl = result.checkoutUrl
stripeCheckoutSessionId = result.sessionId
const dueAt = new Date(Date.now() + SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000) const dueAt = new Date(Date.now() + SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000)
const invoice = await repo.createInvoice({ const invoice = await createCanonicalStripeCheckoutInvoice({
companyId, subscriptionId: subscription.id, companyId,
requestedPlan: body.plan, subscriptionId: subscription.id,
requestedBillingPeriod: body.billingPeriod, plan: body.plan,
amount, currency: body.currency, billingPeriod: body.billingPeriod,
paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, stripeCheckoutSessionId, dueAt, amount,
currency: body.currency,
stripeCheckoutSessionId: result.sessionId,
dueAt,
}) })
return { invoice, checkoutUrl } return { invoice, checkoutUrl: result.checkoutUrl }
} }
export async function capturePaypal(companyId: string, paypalOrderId: string) { export async function capturePaypal(companyId: string, paypalOrderId: string) {
@@ -72,14 +72,19 @@ describe('invoicePdfService', () => {
subtotalAmount: 80000, subtotalAmount: 80000,
discountAmount: 0, discountAmount: 0,
creditAmount: 0, creditAmount: 0,
taxAmount: 0, taxRate: 20,
totalAmount: 80000, taxAmount: 16000,
totalAmount: 96000,
amountPaid: 0, amountPaid: 0,
amountDue: 80000, amountDue: 96000,
}, },
}) })
expect(result.subarray(0, 8).toString()).toBe('%PDF-1.4') 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 transactionId?: string | null
paidAt?: string | null paidAt?: string | null
lineItems?: Array<{ lineItems?: Array<{
type?: string | null
description: string description: string
amount: number amount: number
currency: string currency: string
@@ -37,6 +38,7 @@ interface InvoiceData {
subtotalAmount: number subtotalAmount: number
discountAmount: number discountAmount: number
creditAmount: number creditAmount: number
taxRate?: number | null
taxAmount: number taxAmount: number
totalAmount: number totalAmount: number
amountPaid: number amountPaid: number
@@ -340,13 +342,19 @@ function pdfText(value: unknown) {
.replace(/[^\x20-\x7E]/g, '') .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 }) { function InvoiceDocument({ data }: { data: InvoiceData }) {
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280' const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
const addressStr = formatAddress(data.company.address) const addressStr = formatAddress(data.company.address)
const planLabel = data.subscription ? (PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan) : 'Manual' 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 periodLabel = data.subscription ? (PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod) : 'Custom'
const lineItems = data.lineItems?.length const invoiceLineItems = data.lineItems?.filter((item) => item.type !== 'TAX') ?? []
? data.lineItems const lineItems = invoiceLineItems.length
? invoiceLineItems
: [{ : [{
description: `${planLabel} Plan — ${periodLabel} Subscription`, description: `${planLabel} Plan — ${periodLabel} Subscription`,
amount: data.amount, amount: data.amount,
@@ -490,7 +498,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement( React.createElement(
View, View,
{ style: s.totalRow }, { 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)), React.createElement(Text, { style: s.totalValue }, fmt(totals.subtotalAmount, data.currency)),
), ),
totals.discountAmount > 0 totals.discountAmount > 0
@@ -509,18 +517,16 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`), React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`),
) )
: null, : null,
totals.taxAmount > 0 React.createElement(
? React.createElement( View,
View, { style: s.totalRow },
{ style: s.totalRow }, React.createElement(Text, { style: s.totalLabel }, `Tax (${fmtTaxRate(totals.taxRate)})`),
React.createElement(Text, { style: s.totalLabel }, 'Tax'), React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)), ),
)
: null,
React.createElement( React.createElement(
View, View,
{ style: s.grandTotalRow }, { 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)), React.createElement(Text, { style: s.grandTotalValue }, fmt(totals.totalAmount, data.currency)),
), ),
@@ -589,8 +595,9 @@ function pdfEscape(value: unknown) {
} }
function buildSimpleInvoicePdf(data: InvoiceData) { function buildSimpleInvoicePdf(data: InvoiceData) {
const lineItems = data.lineItems?.length const invoiceLineItems = data.lineItems?.filter((item) => item.type !== 'TAX') ?? []
? data.lineItems const lineItems = invoiceLineItems.length
? invoiceLineItems
: [{ description: 'Invoice charge', amount: data.amount, currency: data.currency }] : [{ description: 'Invoice charge', amount: data.amount, currency: data.currency }]
const totals = data.totals ?? { const totals = data.totals ?? {
subtotalAmount: data.amount, subtotalAmount: data.amount,
@@ -616,11 +623,11 @@ function buildSimpleInvoicePdf(data: InvoiceData) {
'Line Items', 'Line Items',
...lineItems.map((item) => `${item.description} - ${fmt(item.amount, item.currency)}`), ...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.discountAmount ? `Discounts: -${fmt(totals.discountAmount, data.currency)}` : null,
totals.creditAmount ? `Credits: -${fmt(totals.creditAmount, data.currency)}` : null, totals.creditAmount ? `Credits: -${fmt(totals.creditAmount, data.currency)}` : null,
totals.taxAmount ? `Tax: ${fmt(totals.taxAmount, data.currency)}` : null, `Tax (${fmtTaxRate(totals.taxRate)}): ${fmt(totals.taxAmount, data.currency)}`,
`Total: ${fmt(totals.totalAmount, data.currency)}`, `TTC: ${fmt(totals.totalAmount, data.currency)}`,
`Amount Paid: ${fmt(totals.amountPaid, data.currency)}`, `Amount Paid: ${fmt(totals.amountPaid, data.currency)}`,
`Amount Due: ${fmt(totals.amountDue, data.currency)}`, `Amount Due: ${fmt(totals.amountDue, data.currency)}`,
'', '',
@@ -88,6 +88,8 @@ describe('notificationService command boundaries', () => {
recipientType: 'EMPLOYEE', recipientType: 'EMPLOYEE',
employeeId: 'employee_1', employeeId: 'employee_1',
renterId: null, renterId: null,
billingContactId: null,
adminUserId: null,
}, },
}) })
expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({ expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({
@@ -141,7 +143,87 @@ describe('notificationService command boundaries', () => {
channels: ['IN_APP'], 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() 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 { NotificationType, NotificationChannel } from '@rentaldrivego/database'
import { import {
renderLocalizedEmailHtml, renderLocalizedEmailHtml,
coerceNotificationLocale,
resolveNotificationLocale, resolveNotificationLocale,
resolveNotificationTemplate, resolveNotificationTemplate,
} from './notificationLocalizationService' } from './notificationLocalizationService'
import type { NotificationTemplateVariables } from './notificationLocalizationService' import type { NotificationTemplateVariables } from './notificationLocalizationService'
import { generateInvoicePdf } from './invoicePdfService'
const resendApiKey = function envValue(name: string) {
process.env.RESEND_API_KEY && const value = process.env[name]?.trim()
process.env.RESEND_API_KEY !== 're_...' && return value ? value : null
process.env.RESEND_API_KEY.startsWith('re_') }
? process.env.RESEND_API_KEY
: 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 = const emailFromAddress =
process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com' envValue('EMAIL_FROM') && !isPlaceholderEnvValue(envValue('EMAIL_FROM'))
? process.env.EMAIL_FROM ? envValue('EMAIL_FROM')
: null : null
const emailFromName = const emailFromName =
process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App' envValue('EMAIL_FROM_NAME') && !isPlaceholderEnvValue(envValue('EMAIL_FROM_NAME'))
? process.env.EMAIL_FROM_NAME ? envValue('EMAIL_FROM_NAME')
: null : null
const smtpHost = process.env.MAIL_HOST const smtpHost = envValue('MAIL_HOST')
const smtpPort = Number(process.env.MAIL_PORT ?? 0) const smtpPort = Number(envValue('MAIL_PORT') ?? 0)
const smtpUser = process.env.MAIL_USERNAME const smtpUser = envValue('MAIL_USERNAME')
const smtpPass = process.env.MAIL_PASSWORD const smtpPass = envValue('MAIL_PASSWORD')
const smtpSecure = const smtpSecure =
process.env.MAIL_SCHEME === 'smtps' || process.env.MAIL_SCHEME === 'smtps' ||
process.env.MAIL_ENCRYPTION === 'ssl' || process.env.MAIL_ENCRYPTION === 'ssl' ||
smtpPort === 465 smtpPort === 465
const smtpFromAddress = const smtpFromAddress =
process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${') envValue('MAIL_FROM_ADDRESS') && !isPlaceholderEnvValue(envValue('MAIL_FROM_ADDRESS'))
? process.env.MAIL_FROM_ADDRESS ? envValue('MAIL_FROM_ADDRESS')
: null : null
const smtpFromName = const smtpFromName =
process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${') envValue('MAIL_FROM_NAME') && !isPlaceholderEnvValue(envValue('MAIL_FROM_NAME'))
? process.env.MAIL_FROM_NAME ? envValue('MAIL_FROM_NAME')
: null : null
type SmtpTransport = { type SmtpTransport = {
@@ -52,7 +82,7 @@ type SmtpTransport = {
let smtpTransport: SmtpTransport | null = null let smtpTransport: SmtpTransport | null = null
if (smtpHost && smtpPort && smtpUser && smtpPass) { if (smtpHost && smtpPort && smtpUser && smtpPass && !isPlaceholderEnvValue(smtpPass)) {
try { try {
const nodemailer = require('nodemailer') as { const nodemailer = require('nodemailer') as {
createTransport(options: Record<string, unknown>): SmtpTransport createTransport(options: Record<string, unknown>): SmtpTransport
@@ -80,6 +110,8 @@ interface SendNotificationOptions {
companyId?: string companyId?: string
employeeId?: string employeeId?: string
renterId?: string renterId?: string
billingContactId?: string
adminUserId?: string
billingAccountId?: string billingAccountId?: string
email?: string email?: string
phone?: string phone?: string
@@ -91,11 +123,14 @@ interface SendNotificationOptions {
idempotencyKey?: string idempotencyKey?: string
sourceType?: string sourceType?: string
sourceId?: string sourceId?: string
policy?: NotificationPolicy
} }
type NotificationAudience = type NotificationAudience =
| { type: 'EMPLOYEE'; employeeId: string } | { type: 'EMPLOYEE'; employeeId: string }
| { type: 'RENTER'; renterId: string } | { type: 'RENTER'; renterId: string }
| { type: 'BILLING_CONTACT'; billingContactId: string }
| { type: 'ADMIN'; adminUserId: string }
| { type: 'COMPANY_EMPLOYEES' } | { type: 'COMPANY_EMPLOYEES' }
type NotificationPolicy = { type NotificationPolicy = {
@@ -130,7 +165,15 @@ function uniqueChannels(channels: NotificationChannel[] | undefined): Notificati
} }
function buildLegacyIdempotencyKey(opts: SendNotificationOptions) { 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) const sourceId = opts.sourceId ?? String(opts.data?.id ?? opts.data?.reservationId ?? opts.data?.bookingId ?? target)
return [ return [
'legacy-notification', 'legacy-notification',
@@ -150,7 +193,7 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
where: { id: audience.employeeId, companyId, isActive: true }, where: { id: audience.employeeId, companyId, isActive: true },
select: { id: true, email: true, preferredLanguage: 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') { if (audience.type === 'RENTER') {
@@ -158,7 +201,23 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
where: { id: audience.renterId }, where: { id: audience.renterId },
select: { id: true, email: true, preferredLocale: true }, 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({ const employees = await prisma.employee.findMany({
@@ -169,6 +228,8 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
recipientType: 'EMPLOYEE' as const, recipientType: 'EMPLOYEE' as const,
employeeId: employee.id, employeeId: employee.id,
renterId: null, renterId: null,
billingContactId: null,
adminUserId: null,
email: employee.email, email: employee.email,
locale: employee.preferredLanguage, locale: employee.preferredLanguage,
})) }))
@@ -178,6 +239,8 @@ async function resolvePreferenceDecision(input: {
companyId: string companyId: string
employeeId: string | null employeeId: string | null
renterId: string | null renterId: string | null
billingContactId?: string | null
adminUserId?: string | null
type: NotificationType type: NotificationType
channel: NotificationChannel channel: NotificationChannel
policy?: NotificationPolicy policy?: NotificationPolicy
@@ -194,9 +257,11 @@ async function resolvePreferenceDecision(input: {
const personalWhere = input.employeeId const personalWhere = input.employeeId
? { employeeId: input.employeeId, notificationType: input.type, channel: input.channel } ? { 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) { if (personal) {
return { return {
enabled: personal.enabled, enabled: personal.enabled,
@@ -228,15 +293,61 @@ async function resolvePreferenceDecision(input: {
} }
function resolveSmtpReplyTo() { 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 return undefined
} }
if (process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')) { if (replyToName && !isPlaceholderEnvValue(replyToName)) {
return `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>` 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: { async function sendEmailWithProviders(opts: {
@@ -244,9 +355,14 @@ async function sendEmailWithProviders(opts: {
subject: string subject: string
html: string html: string
text: string text: string
attachments?: EmailAttachment[]
}) { }) {
const errors: string[] = [] const errors: string[] = []
if (emailProvider === 'smtp' && !smtpTransport) {
throw new Error(`SMTP email provider is selected but not configured: ${smtpConfigurationIssues().join('; ')}`)
}
if (resend) { if (resend) {
try { try {
if (!emailFromAddress || !emailFromName) { if (!emailFromAddress || !emailFromName) {
@@ -259,6 +375,10 @@ async function sendEmailWithProviders(opts: {
subject: opts.subject, subject: opts.subject,
html: opts.html, html: opts.html,
text: opts.text, text: opts.text,
attachments: opts.attachments?.map((attachment) => ({
filename: attachment.filename,
content: attachment.content.toString('base64'),
})),
}) })
if (error) { if (error) {
@@ -288,6 +408,7 @@ async function sendEmailWithProviders(opts: {
html: opts.html, html: opts.html,
text: opts.text, text: opts.text,
replyTo: resolveSmtpReplyTo(), replyTo: resolveSmtpReplyTo(),
attachments: opts.attachments,
}) })
return { return {
@@ -306,6 +427,94 @@ async function sendEmailWithProviders(opts: {
throw new Error('No email provider is configured') 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) { export async function sendNotification(opts: SendNotificationOptions) {
if (!opts.companyId) { if (!opts.companyId) {
return uniqueChannels(opts.channels).map((channel) => ({ return uniqueChannels(opts.channels).map((channel) => ({
@@ -319,13 +528,17 @@ export async function sendNotification(opts: SendNotificationOptions) {
? { type: 'EMPLOYEE', employeeId: opts.employeeId } ? { type: 'EMPLOYEE', employeeId: opts.employeeId }
: opts.renterId : opts.renterId
? { type: 'RENTER', renterId: 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) { if (!audience) {
return uniqueChannels(opts.channels).map((channel) => ({ return uniqueChannels(opts.channels).map((channel) => ({
channel, channel,
success: false, 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, body: opts.body,
data: opts.data, data: opts.data,
locale: opts.locale, locale: opts.locale,
policy: opts.policy,
}) })
return command.deliveries.map((delivery) => ({ return command.deliveries.map((delivery) => ({
@@ -369,7 +583,7 @@ export async function createNotification(opts: CreateNotificationOptions) {
}) })
const channels = uniqueChannels(opts.channels) const channels = uniqueChannels(opts.channels)
const renderChannel: NotificationChannel = channels.includes('IN_APP') ? 'IN_APP' : channels[0]! 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({ ? await resolveNotificationTemplate({
templateKey: opts.templateKey, templateKey: opts.templateKey,
channel: renderChannel, channel: renderChannel,
@@ -415,6 +629,8 @@ export async function createNotification(opts: CreateNotificationOptions) {
recipientType: recipient.recipientType, recipientType: recipient.recipientType,
employeeId: recipient.employeeId, employeeId: recipient.employeeId,
renterId: recipient.renterId, renterId: recipient.renterId,
billingContactId: recipient.billingContactId,
adminUserId: recipient.adminUserId,
}, },
}) })
@@ -423,6 +639,8 @@ export async function createNotification(opts: CreateNotificationOptions) {
companyId: opts.companyId, companyId: opts.companyId,
employeeId: recipient.employeeId, employeeId: recipient.employeeId,
renterId: recipient.renterId, renterId: recipient.renterId,
billingContactId: recipient.billingContactId,
adminUserId: recipient.adminUserId,
type: opts.type, type: opts.type,
channel, channel,
policy: opts.policy, 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: { export async function sendTransactionalEmail(opts: {
to: string to: string
subject: 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, employeeLanguageSchema,
} from '../modules/auth/auth.employee.schemas' } from '../modules/auth/auth.employee.schemas'
import { renterUpdateSchema, renterFcmTokenSchema } from '../modules/auth/auth.renter.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), EmployeeLanguage: s(employeeLanguageSchema),
RenterUpdate: s(renterUpdateSchema), RenterUpdate: s(renterUpdateSchema),
RenterFcmToken: s(renterFcmTokenSchema), RenterFcmToken: s(renterFcmTokenSchema),
// ── Subscription billing ────────────────────────────────────
ManualSubscriptionCheckout: s(manualCheckoutSchema),
ManualPaymentSubmissionCreate: s(createManualPaymentSubmissionSchema),
SubscriptionCommunicationSettings: s(communicationSettingsSchema),
}, },
}, },
paths: { paths: {
@@ -982,6 +991,29 @@ export const openApiDocument: JsonObject = {
'/subscriptions/checkout': { '/subscriptions/checkout': {
post: { tags: ['Subscriptions'], summary: 'Checkout new subscription (Owner)', responses: { '200': ok } }, 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': { '/subscriptions/change-plan': {
post: { tags: ['Subscriptions'], summary: 'Change plan (Owner)', responses: { '200': ok } }, post: { tags: ['Subscriptions'], summary: 'Change plan (Owner)', responses: { '200': ok } },
}, },
+2
View File
@@ -7,6 +7,8 @@ const delegates = [
'billingCreditNote', 'billingCreditNote',
'billingCreditLedgerEntry', 'billingCreditLedgerEntry',
'billingCreditBalance', 'billingCreditBalance',
'manualPaymentDocument',
'manualPaymentSubmission',
'billingPaymentAttempt', 'billingPaymentAttempt',
'billingPaymentIntent', 'billingPaymentIntent',
'billingInvoiceLineItem', 'billingInvoiceLineItem',
@@ -3,12 +3,13 @@
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types' 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 { useDashboardI18n } from '@/components/I18nProvider'
import { buildHomepageSignInPath } from '@/lib/dashboardPaths' import { buildHomepageSignInPath } from '@/lib/dashboardPaths'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE' type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type BillingPeriod = 'MONTHLY' | 'ANNUAL' type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type CollectionMethod = 'STRIPE' | 'BANK_TRANSFER' | 'CHECK'
interface Subscription { interface Subscription {
id: string id: string
@@ -23,12 +24,66 @@ interface Subscription {
interface Invoice { interface Invoice {
id: string id: string
invoiceNumber: string | null
amount: number amount: number
amountDue: number
currency: string currency: string
status: string status: string
paymentProvider: string paymentProvider: string
collectionMethod: CollectionMethod
dueAt: string | null
paidAt: string | null paidAt: string | null
createdAt: string 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 { interface ProviderAvailability {
@@ -61,6 +116,9 @@ const STATUS_BADGE: Record<string, string> = {
const INVOICE_STATUS: Record<string, string> = { const INVOICE_STATUS: Record<string, string> = {
PAID: 'bg-green-100 text-green-700', PAID: 'bg-green-100 text-green-700',
PENDING: 'bg-orange-100 text-orange-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', FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600', REFUNDED: 'bg-slate-100 text-slate-600',
} }
@@ -72,6 +130,30 @@ const PLAN_LABELS: Record<Plan, string> = {
PRO: 'Pro', PRO: 'Pro',
ENTERPRISE: 'Enterprise', 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() { export default function SubscriptionPage() {
const router = useRouter() const router = useRouter()
const { language } = useDashboardI18n() const { language } = useDashboardI18n()
@@ -86,12 +168,23 @@ export default function SubscriptionPage() {
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER') const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY') const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const currency = 'MAD' 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 [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ stripe: false })
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES) const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([]) const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
const [paying, setPaying] = useState(false) const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = 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 = { const copy = {
en: { en: {
title: 'Subscription', title: 'Subscription',
@@ -107,6 +200,39 @@ export default function SubscriptionPage() {
changePlan: 'Change plan', changePlan: 'Change plan',
subscribe: 'Subscribe', subscribe: 'Subscribe',
selectPlan: 'Select a plan to continue to Stripe checkout.', 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', monthly: 'Monthly',
annual: 'Annual (save 20%)', annual: 'Annual (save 20%)',
active: 'Active', active: 'Active',
@@ -119,6 +245,7 @@ export default function SubscriptionPage() {
redirecting: 'Redirecting…', redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now', subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history', invoiceHistory: 'Invoice history',
invoice: 'Invoice',
date: 'Date', date: 'Date',
provider: 'Provider', provider: 'Provider',
status: 'Status', status: 'Status',
@@ -156,6 +283,39 @@ export default function SubscriptionPage() {
changePlan: 'Changer de plan', changePlan: 'Changer de plan',
subscribe: 'Sabonner', subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan pour continuer vers Stripe Checkout.', 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', monthly: 'Mensuel',
annual: 'Annuel (économie 20%)', annual: 'Annuel (économie 20%)',
active: 'Actif', active: 'Actif',
@@ -168,6 +328,7 @@ export default function SubscriptionPage() {
redirecting: 'Redirection…', redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant', subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures', invoiceHistory: 'Historique des factures',
invoice: 'Facture',
date: 'Date', date: 'Date',
provider: 'Prestataire', provider: 'Prestataire',
status: 'Statut', status: 'Statut',
@@ -205,6 +366,39 @@ export default function SubscriptionPage() {
changePlan: 'تغيير الخطة', changePlan: 'تغيير الخطة',
subscribe: 'اشتراك', subscribe: 'اشتراك',
selectPlan: 'اختر خطة للمتابعة إلى Stripe Checkout.', 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: 'شهري', monthly: 'شهري',
annual: 'سنوي (توفير 20%)', annual: 'سنوي (توفير 20%)',
active: 'نشط', active: 'نشط',
@@ -217,6 +411,7 @@ export default function SubscriptionPage() {
redirecting: 'جارٍ التحويل…', redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن', subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير', invoiceHistory: 'سجل الفواتير',
invoice: 'الفاتورة',
date: 'التاريخ', date: 'التاريخ',
provider: 'المزوّد', provider: 'المزوّد',
status: 'الحالة', status: 'الحالة',
@@ -288,10 +483,16 @@ export default function SubscriptionPage() {
apiFetch<Subscription | null>('/subscriptions/me'), apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'), apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'), apiFetch<ProviderAvailability>('/subscriptions/providers'),
apiFetch<{ methods: PaymentOption[] }>('/subscriptions/payment-options'),
apiFetch<CommunicationSettings>('/subscriptions/communication-settings'),
fetchPlanData(), fetchPlanData(),
]) ])
.then(([sub, inv, availability]) => { .then(([sub, inv, availability, options, settings]) => {
setProviderAvailability(availability) setProviderAvailability(availability)
setPaymentOptions(options.methods ?? [])
setCommunicationSettings(settings)
const firstEnabled = options.methods?.find((option) => option.enabled)
if (firstEnabled) setSelectedMethod(firstEnabled.method)
if (sub) { if (sub) {
setSubscription(sub) setSubscription(sub)
setSelectedPlan(sub.plan) setSelectedPlan(sub.plan)
@@ -299,6 +500,13 @@ export default function SubscriptionPage() {
// currency is always MAD // currency is always MAD
} }
setInvoices(inv ?? []) 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)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@@ -359,6 +567,55 @@ export default function SubscriptionPage() {
setPaying(true) setPaying(true)
setError(null) setError(null)
try { 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) if (!providerAvailability.stripe) throw new Error(copy.providerUnavailable)
const currentUrl = new URL(window.location.href) const currentUrl = new URL(window.location.href)
currentUrl.search = '' currentUrl.search = ''
@@ -369,7 +626,7 @@ export default function SubscriptionPage() {
plan: selectedPlan, plan: selectedPlan,
billingPeriod, billingPeriod,
currency, currency,
provider, provider: 'STRIPE',
successUrl: `${currentUrl.toString()}?payment=success`, successUrl: `${currentUrl.toString()}?payment=success`,
failureUrl: `${currentUrl.toString()}?payment=failed`, 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() { async function handleCancel() {
setCancelling(true) setCancelling(true)
setError(null) 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() { async function handleResume() {
setCancelling(true) setCancelling(true)
setError(null) setError(null)
@@ -411,6 +751,64 @@ export default function SubscriptionPage() {
const daysLeft = subscription?.trialEndAt const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000) ? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null : 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -470,7 +868,7 @@ export default function SubscriptionPage() {
{/* Plan selector + checkout */} {/* Plan selector + checkout */}
<div className="card p-6 space-y-6"> <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"> <div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
{copy.noProviderConfigured} {copy.noProviderConfigured}
{providerAvailability.stripeProblems && providerAvailability.stripeProblems.length > 0 ? ( {providerAvailability.stripeProblems && providerAvailability.stripeProblems.length > 0 ? (
@@ -486,7 +884,7 @@ export default function SubscriptionPage() {
<h3 className="text-base font-semibold text-slate-900"> <h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe} {subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3> </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> </div>
{/* Billing period toggle */} {/* Billing period toggle */}
@@ -549,15 +947,33 @@ export default function SubscriptionPage() {
{/* Provider selector */} {/* Provider selector */}
<div> <div>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p> <p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3"> <div className="flex flex-wrap gap-3">
{providerAvailability.stripe ? ( {paymentOptions.filter((option) => option.enabled).map((option) => (
<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"> <button
Stripe type="button"
</div> key={option.method}
) : null} 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>
</div> </div>
{isManualMethod && !manualCheckout ? manualDetailsForm : null}
{/* Checkout CTA */} {/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100"> <div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div> <div>
@@ -569,14 +985,154 @@ export default function SubscriptionPage() {
</div> </div>
<button <button
onClick={handleCheckout} 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" 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> </button>
</div> </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> </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 */} {/* Invoice history */}
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200"> <div className="px-6 py-4 border-b border-slate-200">
@@ -586,6 +1142,7 @@ export default function SubscriptionPage() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-slate-50 border-b border-slate-200"> <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.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.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</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> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{loading ? ( {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 ? ( ) : 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) => ( ) : invoices.map((inv) => (
<tr key={inv.id}> <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">{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 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4"> <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' process.env.NEXT_PUBLIC_API_URL = 'http://localhost:4000/api/v1'
api = await import('./api') 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 () => { 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) return true
if (configuredApiBase.startsWith('/')) return false if (configuredApiBase.startsWith('/')) return false
const currentHostname = window.location?.hostname
if (!currentHostname || isLocalBrowserHost(currentHostname)) return false
try { 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 { } catch {
return false return false
} }
@@ -87,9 +95,21 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
} }
if (!res.ok) { 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.code = json?.error
err.statusCode = res.status err.statusCode = res.status
err.issues = json?.issues
throw err throw err
} }
@@ -49,7 +49,7 @@ export default async function LocaleHomePage({ params }: LocalePageProps) {
const locale: Locale = localeValue; const locale: Locale = localeValue;
const mode: ThemePreference = modeValue; const mode: ThemePreference = modeValue;
const messages = getMessages(locale); 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 integrationConfig = getPublicIntegrationConfig();
const homePath = localizedModePath('home', locale, mode); 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 { HomepageMessages, ShellMessages } from '@/lib/localization/messages';
import type { Locale } from '@/lib/localization/config'; import type { Locale } from '@/lib/localization/config';
import { accountCreateUrl } from '@/lib/account-urls'; import { accountCreateUrl } from '@/lib/account-urls';
import type { ThemePreference } from '@/lib/theme/config';
import { import {
fleetBandIds, fleetBandIds,
pricingPlanIds, pricingPlanIds,
@@ -267,6 +268,7 @@ export function buildHomepageContent(
homepage: HomepageMessages, homepage: HomepageMessages,
shell: ShellMessages, shell: ShellMessages,
locale: Locale = 'en', locale: Locale = 'en',
mode: ThemePreference = 'dark',
): HomepageContent { ): HomepageContent {
const pendingReason = shell.states.pendingBody; const pendingReason = shell.states.pendingBody;
@@ -275,7 +277,7 @@ export function buildHomepageContent(
eyebrow: homepage.hero.eyebrow, eyebrow: homepage.hero.eyebrow,
title: homepage.hero.title, title: homepage.hero.title,
body: homepage.hero.body, 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 }, secondary: { label: homepage.hero.secondary, disabledReason: pendingReason },
preview: { preview: {
title: homepage.preview.title, title: homepage.preview.title,
@@ -438,7 +440,7 @@ export function buildHomepageContent(
eyebrow: homepage.final.eyebrow, eyebrow: homepage.final.eyebrow,
title: homepage.final.title, title: homepage.final.title,
body: homepage.final.body, 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 }, secondary: { label: homepage.final.secondary, disabledReason: pendingReason },
image: localizedImage( image: localizedImage(
'rentaldrivego', 'rentaldrivego',
@@ -37,6 +37,13 @@ describe('homepage content model', () => {
expect(content.final.secondary.disabledReason).toBe(enShell.states.pendingBody); 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', () => { it('includes explicit theme handoff in account creation URLs', () => {
expect(accountCreateUrl('en', 'light')).toBe('/dashboard/sign-up?lang=en&theme=light'); expect(accountCreateUrl('en', 'light')).toBe('/dashboard/sign-up?lang=en&theme=light');
expect(accountCreateUrl('en', 'dark')).toBe('/dashboard/sign-up?lang=en&theme=dark'); expect(accountCreateUrl('en', 'dark')).toBe('/dashboard/sign-up?lang=en&theme=dark');
+3 -1
View File
@@ -58,7 +58,7 @@ function collectBrowserApiSources() {
return sources return sources
} }
function buildSecurityHeaders({ assetSources = [], connectSources = collectBrowserApiSources() } = {}) { function buildSecurityHeaders({ assetSources = [], connectSources = collectBrowserApiSources(), frameSources = [] } = {}) {
const assetOrigins = collectOrigins(assetSources) const assetOrigins = collectOrigins(assetSources)
const connectOrigins = collectOrigins(connectSources) const connectOrigins = collectOrigins(connectSources)
const websocketOrigins = [ const websocketOrigins = [
@@ -76,6 +76,7 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows
const imgSrc = [...new Set(["'self'", 'data:', 'blob:', 'https:', ...assetOrigins, ...connectOrigins])] const imgSrc = [...new Set(["'self'", 'data:', 'blob:', 'https:', ...assetOrigins, ...connectOrigins])]
const fontSrc = ["'self'", 'data:', ...assetOrigins] const fontSrc = ["'self'", 'data:', ...assetOrigins]
const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])] const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])]
const frameSrc = [...new Set(["'self'", ...frameSources])]
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
connectSrc.push('ws:') connectSrc.push('ws:')
} }
@@ -95,6 +96,7 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows
`img-src ${imgSrc.join(' ')}`, `img-src ${imgSrc.join(' ')}`,
`font-src ${fontSrc.join(' ')}`, `font-src ${fontSrc.join(' ')}`,
`connect-src ${connectSrc.join(' ')}`, `connect-src ${connectSrc.join(' ')}`,
`frame-src ${frameSrc.join(' ')}`,
"frame-ancestors 'none'", "frame-ancestors 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action '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 | | `POSTGRES_PASSWORD` | Strong random password |
| `JWT_SECRET` | Long random string (e.g. `openssl rand -hex 64`) | | `JWT_SECRET` | Long random string (e.g. `openssl rand -hex 64`) |
| `ACME_EMAIL` | Your email for Let's Encrypt notifications | | `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` | | `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_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_`) | | `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 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 { enum BillingRefundStatus {
PENDING PENDING
SUCCEEDED SUCCEEDED
@@ -441,6 +516,17 @@ enum NotificationType {
REFUND_PROCESSED REFUND_PROCESSED
NEW_OFFER_FROM_SAVED_COMPANY NEW_OFFER_FROM_SAVED_COMPANY
REVIEW_REQUEST 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 { enum FeedbackCategory {
@@ -492,6 +578,8 @@ enum NotificationStatus {
enum NotificationRecipientType { enum NotificationRecipientType {
EMPLOYEE EMPLOYEE
RENTER RENTER
BILLING_CONTACT
ADMIN
} }
enum NotificationDeliveryStatus { enum NotificationDeliveryStatus {
@@ -529,27 +617,32 @@ model Company {
status CompanyStatus @default(PENDING) status CompanyStatus @default(PENDING)
subscriptionPaymentRef String? subscriptionPaymentRef String?
subscription Subscription? subscription Subscription?
billingAccounts BillingAccount[] billingAccounts BillingAccount[]
billingInvoices BillingInvoice[] billingInvoices BillingInvoice[]
brand BrandSettings? brand BrandSettings?
employees Employee[] employees Employee[]
vehicles Vehicle[] vehicles Vehicle[]
offers Offer[] offers Offer[]
reservations Reservation[] reservations Reservation[]
customers Customer[] customers Customer[]
rentalPayments RentalPayment[] rentalPayments RentalPayment[]
subscriptionInvoices SubscriptionInvoice[] subscriptionInvoices SubscriptionInvoice[]
contractSettings ContractSettings? contractSettings ContractSettings?
accountingSettings AccountingSettings? accountingSettings AccountingSettings?
insurancePolicies InsurancePolicy[] insurancePolicies InsurancePolicy[]
pricingRules PricingRule[] pricingRules PricingRule[]
notifications Notification[] @relation("CompanyNotifications") notifications Notification[] @relation("CompanyNotifications")
notificationEvents NotificationEvent[] notificationEvents NotificationEvent[]
notificationDefaults CompanyNotificationPreference[] notificationDefaults CompanyNotificationPreference[]
complaints Complaint[] billingContacts BillingContact[]
companyMenuItems CompanyMenuItem[] manualPaymentSubmissions ManualPaymentSubmission[]
apiKeys CompanyApiKey[] manualPaymentDocuments ManualPaymentDocument[]
collectionsCases CollectionsCase[]
collectionsEvents CollectionsEvent[]
complaints Complaint[]
companyMenuItems CompanyMenuItem[]
apiKeys CompanyApiKey[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -597,6 +690,7 @@ model Subscription {
maxRetryCount Int @default(5) maxRetryCount Int @default(5)
invoices SubscriptionInvoice[] invoices SubscriptionInvoice[]
billingInvoices BillingInvoice[] billingInvoices BillingInvoice[]
collectionsCases CollectionsCase[]
events SubscriptionEvent[] events SubscriptionEvent[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -623,28 +717,28 @@ model SubscriptionEvent {
} }
model SubscriptionInvoice { model SubscriptionInvoice {
id String @id @default(cuid()) id String @id @default(cuid())
companyId String companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
subscriptionId String subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id]) subscription Subscription @relation(fields: [subscriptionId], references: [id])
requestedPlan Plan? requestedPlan Plan?
requestedBillingPeriod BillingPeriod? requestedBillingPeriod BillingPeriod?
providerInvoiceId String? providerInvoiceId String?
amount Int amount Int
currency String @default("MAD") currency String @default("MAD")
status InvoiceStatus status InvoiceStatus
amanpayTransactionId String? @unique amanpayTransactionId String? @unique
paypalCaptureId String? @unique paypalCaptureId String? @unique
stripeCheckoutSessionId String? @unique stripeCheckoutSessionId String? @unique
paymentProvider PaymentProvider @default(AMANPAY) paymentProvider PaymentProvider @default(AMANPAY)
billingInvoiceId String? @unique billingInvoiceId String? @unique
billingInvoice BillingInvoice? @relation(fields: [billingInvoiceId], references: [id]) billingInvoice BillingInvoice? @relation(fields: [billingInvoiceId], references: [id])
dueAt DateTime? dueAt DateTime?
paidAt DateTime? paidAt DateTime?
failedAt DateTime? failedAt DateTime?
voidedAt DateTime? voidedAt DateTime?
attempts PaymentAttempt[] attempts PaymentAttempt[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -653,35 +747,44 @@ model SubscriptionInvoice {
} }
model BillingAccount { model BillingAccount {
id String @id @default(cuid()) id String @id @default(cuid())
companyId String companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
isPrimary Boolean @default(true) isPrimary Boolean @default(true)
legalName String legalName String
billingEmail String billingEmail String
preferredLanguage String @default("en") preferredLanguage String @default("en")
billingAddress Json? timezone String @default("Africa/Casablanca")
taxId String? reminderLocalTime String @default("09:00")
taxExempt Boolean @default(false) enabledCommunicationLocales String[] @default(["en"])
defaultCurrency String @default("MAD") defaultCommunicationLocale String @default("en")
defaultPaymentMethodId String? collectionsOwnerAdminId String?
defaultPaymentMethod BillingPaymentMethod? @relation("BillingAccountDefaultPaymentMethod", fields: [defaultPaymentMethodId], references: [id]) collectionsOwnerAdmin AdminUser? @relation("BillingAccountCollectionsOwner", fields: [collectionsOwnerAdminId], references: [id], onDelete: SetNull)
invoiceTerms BillingInvoiceTerms @default(DUE_ON_RECEIPT) billingAddress Json?
netTermsDays Int @default(0) taxId String?
providerCustomerId String? taxExempt Boolean @default(false)
dunningPaused Boolean @default(false) defaultCurrency String @default("MAD")
dunningPausedAt DateTime? defaultPaymentMethodId String?
dunningPausedBy String? defaultPaymentMethod BillingPaymentMethod? @relation("BillingAccountDefaultPaymentMethod", fields: [defaultPaymentMethodId], references: [id])
metadata Json @default("{}") invoiceTerms BillingInvoiceTerms @default(DUE_ON_RECEIPT)
paymentMethods BillingPaymentMethod[] @relation("BillingAccountPaymentMethods") netTermsDays Int @default(0)
invoices BillingInvoice[] providerCustomerId String?
paymentIntents BillingPaymentIntent[] dunningPaused Boolean @default(false)
paymentAttempts BillingPaymentAttempt[] dunningPausedAt DateTime?
creditBalances BillingCreditBalance[] dunningPausedBy String?
creditLedgerEntries BillingCreditLedgerEntry[] metadata Json @default("{}")
creditNotes BillingCreditNote[] paymentMethods BillingPaymentMethod[] @relation("BillingAccountPaymentMethods")
refunds BillingRefund[] invoices BillingInvoice[]
events BillingEvent[] paymentIntents BillingPaymentIntent[]
paymentAttempts BillingPaymentAttempt[]
creditBalances BillingCreditBalance[]
creditLedgerEntries BillingCreditLedgerEntry[]
creditNotes BillingCreditNote[]
refunds BillingRefund[]
events BillingEvent[]
billingContacts BillingContact[]
manualPaymentSubmissions ManualPaymentSubmission[]
collectionsCases CollectionsCase[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -691,6 +794,31 @@ model BillingAccount {
@@map("billing_accounts") @@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 { model BillingPaymentMethod {
id String @id @default(cuid()) id String @id @default(cuid())
billingAccountId String billingAccountId String
@@ -717,26 +845,26 @@ model BillingPaymentMethod {
} }
model BillingInvoice { model BillingInvoice {
id String @id @default(cuid()) id String @id @default(cuid())
billingAccountId String billingAccountId String
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade) billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
companyId String companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
subscriptionId String? subscriptionId String?
subscription Subscription? @relation(fields: [subscriptionId], references: [id]) subscription Subscription? @relation(fields: [subscriptionId], references: [id])
legacySubscriptionInvoice SubscriptionInvoice? legacySubscriptionInvoice SubscriptionInvoice?
invoiceNumber String? @unique invoiceNumber String? @unique
invoiceSequence Int? @unique invoiceSequence Int? @unique
invoiceType BillingInvoiceType invoiceType BillingInvoiceType
status BillingInvoiceStatus @default(DRAFT) status BillingInvoiceStatus @default(DRAFT)
currency String @default("MAD") currency String @default("MAD")
subtotalAmount Int @default(0) subtotalAmount Int @default(0)
discountAmount Int @default(0) discountAmount Int @default(0)
creditAmount Int @default(0) creditAmount Int @default(0)
taxAmount Int @default(0) taxAmount Int @default(0)
totalAmount Int @default(0) totalAmount Int @default(0)
amountPaid Int @default(0) amountPaid Int @default(0)
amountDue Int @default(0) amountDue Int @default(0)
invoiceDate DateTime? invoiceDate DateTime?
dueAt DateTime? dueAt DateTime?
finalizedAt DateTime? finalizedAt DateTime?
@@ -748,9 +876,14 @@ model BillingInvoice {
billingAddress Json? billingAddress Json?
providerInvoiceId String? providerInvoiceId String?
paymentProvider PaymentProvider? 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? adminReason String?
metadata Json @default("{}") metadata Json @default("{}")
createdByAdminId String? createdByAdminId String?
lineItems BillingInvoiceLineItem[] lineItems BillingInvoiceLineItem[]
paymentIntents BillingPaymentIntent[] paymentIntents BillingPaymentIntent[]
@@ -759,14 +892,18 @@ model BillingInvoice {
creditNotes BillingCreditNote[] creditNotes BillingCreditNote[]
refunds BillingRefund[] refunds BillingRefund[]
events BillingEvent[] events BillingEvent[]
manualPaymentSubmissions ManualPaymentSubmission[]
collectionsCase CollectionsCase?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
BillingCreditLedgerEntry BillingCreditLedgerEntry[] BillingCreditLedgerEntry BillingCreditLedgerEntry[]
@@unique([billingAccountId, checkoutIdempotencyKey])
@@index([billingAccountId]) @@index([billingAccountId])
@@index([companyId]) @@index([companyId])
@@index([subscriptionId]) @@index([subscriptionId])
@@index([collectionMethod, status, dueAt])
@@map("billing_invoices") @@map("billing_invoices")
} }
@@ -816,32 +953,217 @@ model BillingPaymentIntent {
} }
model BillingPaymentAttempt { model BillingPaymentAttempt {
id String @id @default(cuid()) id String @id @default(cuid())
invoiceId String invoiceId String
invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
billingAccountId String billingAccountId String
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade) billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
paymentIntentId String? paymentIntentId String?
paymentIntent BillingPaymentIntent? @relation(fields: [paymentIntentId], references: [id]) paymentIntent BillingPaymentIntent? @relation(fields: [paymentIntentId], references: [id])
paymentMethodId String? paymentMethodId String?
paymentMethod BillingPaymentMethod? @relation(fields: [paymentMethodId], references: [id]) paymentMethod BillingPaymentMethod? @relation(fields: [paymentMethodId], references: [id])
providerPaymentId String? providerPaymentId String?
status BillingPaymentAttemptStatus channel BillingPaymentChannel @default(ONLINE)
amount Int manualMethod ManualPaymentMethod?
currency String @default("MAD") externalReference String?
failureCode String? normalizedExternalReference String?
failureMessage String? receivedAt DateTime?
attemptedAt DateTime confirmedAt DateTime?
metadata Json @default("{}") confirmedByAdminId String?
refunds BillingRefund[] 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()) createdAt DateTime @default(now())
@@unique([billingAccountId, idempotencyKey])
@@unique([billingAccountId, manualMethod, normalizedExternalReference])
@@index([invoiceId]) @@index([invoiceId])
@@index([billingAccountId]) @@index([billingAccountId])
@@map("billing_payment_attempts") @@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 { model BillingCreditBalance {
id String @id @default(cuid()) id String @id @default(cuid())
billingAccountId String billingAccountId String
@@ -1008,8 +1330,8 @@ model BrandSettings {
paypalEmail String? paypalEmail String?
paypalMerchantId String? paypalMerchantId String?
paymentMethodsEnabled PaymentProvider[] paymentMethodsEnabled PaymentProvider[]
isListedOnCarplace Boolean @default(true) isListedOnCarplace Boolean @default(true)
carplaceRating Float? carplaceRating Float?
homePageConfig Json? @map("home_page_config") homePageConfig Json? @map("home_page_config")
menuConfig Json? @map("menu_config") menuConfig Json? @map("menu_config")
@@ -1036,10 +1358,13 @@ model Employee {
preferredLanguage String @default("en") preferredLanguage String @default("en")
isActive Boolean @default(true) isActive Boolean @default(true)
notifications Notification[] @relation("EmployeeNotifications") notifications Notification[] @relation("EmployeeNotifications")
notificationRecipients NotificationRecipient[] notificationRecipients NotificationRecipient[]
notificationPreferences NotificationPreference[] notificationPreferences NotificationPreference[]
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments") recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
billingContacts BillingContact[]
manualPaymentSubmissions ManualPaymentSubmission[]
manualPaymentDocuments ManualPaymentDocument[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1073,10 +1398,10 @@ model Vehicle {
allowDifferentDropoff Boolean @default(false) allowDifferentDropoff Boolean @default(false)
dropoffLocations String[] @default([]) dropoffLocations String[] @default([])
reservations Reservation[] reservations Reservation[]
maintenance MaintenanceLog[] maintenance MaintenanceLog[]
offerVehicles OfferVehicle[] offerVehicles OfferVehicle[]
calendarBlocks VehicleCalendarBlock[] calendarBlocks VehicleCalendarBlock[]
pricingConfiguration VehiclePricingConfiguration? pricingConfiguration VehiclePricingConfiguration?
priceHistory VehiclePriceHistory[] priceHistory VehiclePriceHistory[]
@@ -1116,11 +1441,11 @@ model VehiclePricingConfiguration {
} }
model VehiclePricingRule { model VehiclePricingRule {
id String @id @default(cuid()) id String @id @default(cuid())
configurationId String configurationId String
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade) configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
name String name String
ruleType VehiclePricingRuleType @default(DATE_RANGE) ruleType VehiclePricingRuleType @default(DATE_RANGE)
startDate DateTime startDate DateTime
endDate DateTime endDate DateTime
dailyRate Int? dailyRate Int?
@@ -1131,8 +1456,8 @@ model VehiclePricingRule {
maxDailyRate Int? maxDailyRate Int?
automaticAdjustmentPct Int? automaticAdjustmentPct Int?
priceAdjustment Int? priceAdjustment Int?
isActive Boolean @default(true) isActive Boolean @default(true)
sortOrder Int @default(0) sortOrder Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1143,12 +1468,12 @@ model VehiclePricingRule {
} }
model VehiclePriceHistory { model VehiclePriceHistory {
id String @id @default(cuid()) id String @id @default(cuid())
configurationId String configurationId String
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade) configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
vehicleId String vehicleId String
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade) vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
source VehiclePriceChangeSource @default(CONFIG_UPDATE) source VehiclePriceChangeSource @default(CONFIG_UPDATE)
changedByEmployeeId String? changedByEmployeeId String?
previousDailyRate Int? previousDailyRate Int?
nextDailyRate Int? nextDailyRate Int?
@@ -1156,7 +1481,7 @@ model VehiclePriceHistory {
nextWeeklyRate Int? nextWeeklyRate Int?
note String? note String?
effectiveFrom DateTime? effectiveFrom DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([vehicleId, createdAt]) @@index([vehicleId, createdAt])
@@index([configurationId, createdAt]) @@index([configurationId, createdAt])
@@ -1282,9 +1607,9 @@ model Customer {
licenseApprovedAt DateTime? licenseApprovedAt DateTime?
licenseApprovalNote String? licenseApprovalNote String?
reservations Reservation[] reservations Reservation[]
complaints Complaint[] complaints Complaint[]
reviewOptOut Boolean @default(false) reviewOptOut Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1309,7 +1634,7 @@ model Reservation {
promoCodeUsed String? promoCodeUsed String?
vehicleCategory VehicleCategory? vehicleCategory VehicleCategory?
source BookingSource @default(DASHBOARD) source BookingSource @default(DASHBOARD)
carplaceRef String? carplaceRef String?
status ReservationStatus @default(DRAFT) status ReservationStatus @default(DRAFT)
startDate DateTime startDate DateTime
endDate DateTime endDate DateTime
@@ -1342,11 +1667,11 @@ model Reservation {
reviewPaused Boolean @default(false) reviewPaused Boolean @default(false)
insurances ReservationInsurance[] insurances ReservationInsurance[]
insuranceTotal Int @default(0) insuranceTotal Int @default(0)
additionalDrivers AdditionalDriver[] additionalDrivers AdditionalDriver[]
additionalDriverTotal Int @default(0) additionalDriverTotal Int @default(0)
pricingRulesApplied Json? pricingRulesApplied Json?
pricingRulesTotal Int @default(0) pricingRulesTotal Int @default(0)
damageReports DamageReport[] damageReports DamageReport[]
rentalPayments RentalPayment[] rentalPayments RentalPayment[]
inspections DamageInspection[] inspections DamageInspection[]
@@ -1402,7 +1727,6 @@ model CarplaceFunnelEvent {
@@map("carplace_funnel_events") @@map("carplace_funnel_events")
} }
model WebhookEvent { model WebhookEvent {
id String @id @default(cuid()) id String @id @default(cuid())
provider String provider String
@@ -1420,50 +1744,50 @@ model WebhookEvent {
} }
model RentalPayment { model RentalPayment {
id String @id @default(cuid()) id String @id @default(cuid())
companyId String companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
reservationId String reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id]) reservation Reservation @relation(fields: [reservationId], references: [id])
amount Int amount Int
currency String @default("MAD") currency String @default("MAD")
status PaymentStatus @default(PENDING) status PaymentStatus @default(PENDING)
type PaymentType @default(CHARGE) type PaymentType @default(CHARGE)
paymentProvider PaymentProvider paymentProvider PaymentProvider
amanpayTransactionId String? @unique amanpayTransactionId String? @unique
paypalCaptureId String? @unique paypalCaptureId String? @unique
stripeCheckoutSessionId String? @unique stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique stripePaymentIntentId String? @unique
paymentMethod String? paymentMethod String?
reference String? reference String?
note String? note String?
receivedAt DateTime? receivedAt DateTime?
recordedByEmployeeId String? recordedByEmployeeId String?
recordedByEmployee Employee? @relation("RecordedRentalPayments", fields: [recordedByEmployeeId], references: [id]) recordedByEmployee Employee? @relation("RecordedRentalPayments", fields: [recordedByEmployeeId], references: [id])
idempotencyKey String? idempotencyKey String?
paidAt DateTime? paidAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([companyId, idempotencyKey])
@@index([companyId]) @@index([companyId])
@@index([reservationId]) @@index([reservationId])
@@unique([companyId, idempotencyKey])
@@map("rental_payments") @@map("rental_payments")
} }
model Review { model Review {
id String @id @default(cuid()) id String @id @default(cuid())
reservationId String @unique reservationId String @unique
reservation Reservation @relation(fields: [reservationId], references: [id]) reservation Reservation @relation(fields: [reservationId], references: [id])
renterId String? renterId String?
renter Renter? @relation(fields: [renterId], references: [id]) renter Renter? @relation(fields: [renterId], references: [id])
companyId String companyId String
overallRating Int overallRating Int
vehicleRating Int? vehicleRating Int?
serviceRating Int? serviceRating Int?
comment String? comment String?
isPublished Boolean @default(true) isPublished Boolean @default(true)
companyReply String? companyReply String?
companyRepliedAt DateTime? companyRepliedAt DateTime?
category FeedbackCategory? category FeedbackCategory?
@@ -1476,29 +1800,29 @@ model Review {
} }
model Complaint { model Complaint {
id String @id @default(cuid()) id String @id @default(cuid())
companyId String companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
reservationId String? reservationId String?
reservation Reservation? @relation(fields: [reservationId], references: [id]) reservation Reservation? @relation(fields: [reservationId], references: [id])
reviewId String? reviewId String?
review Review? @relation(fields: [reviewId], references: [id]) review Review? @relation(fields: [reviewId], references: [id])
customerId String? customerId String?
customer Customer? @relation(fields: [customerId], references: [id]) customer Customer? @relation(fields: [customerId], references: [id])
severity ComplaintSeverity @default(LEVEL_1) severity ComplaintSeverity @default(LEVEL_1)
status ComplaintStatus @default(OPEN) status ComplaintStatus @default(OPEN)
category FeedbackCategory category FeedbackCategory
subject String subject String
description String? description String?
resolution String? resolution String?
notes String? notes String?
assignedTo String? assignedTo String?
resolvedAt DateTime? resolvedAt DateTime?
resolvedBy String? resolvedBy String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([companyId]) @@index([companyId])
@@index([reservationId]) @@index([reservationId])
@@ -1538,12 +1862,12 @@ model Notification {
} }
model NotificationEvent { model NotificationEvent {
id String @id @default(cuid()) id String @id @default(cuid())
companyId String companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
type NotificationType type NotificationType
templateKey String? templateKey String?
locale String @default("en") locale String @default("en")
title String title String
body String body String
data Json? data Json?
@@ -1552,7 +1876,7 @@ model NotificationEvent {
idempotencyKey String idempotencyKey String
recipients NotificationRecipient[] recipients NotificationRecipient[]
outboxEntries NotificationOutbox[] outboxEntries NotificationOutbox[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([companyId, idempotencyKey]) @@unique([companyId, idempotencyKey])
@@index([companyId, type, createdAt]) @@index([companyId, type, createdAt])
@@ -1568,6 +1892,10 @@ model NotificationRecipient {
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: Cascade) employee Employee? @relation(fields: [employeeId], references: [id], onDelete: Cascade)
renterId String? renterId String?
renter Renter? @relation(fields: [renterId], references: [id], onDelete: Cascade) 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? readAt DateTime?
archivedAt DateTime? archivedAt DateTime?
deliveries NotificationDelivery[] deliveries NotificationDelivery[]
@@ -1575,6 +1903,8 @@ model NotificationRecipient {
@@index([employeeId, readAt, createdAt]) @@index([employeeId, readAt, createdAt])
@@index([renterId, readAt, createdAt]) @@index([renterId, readAt, createdAt])
@@index([billingContactId, readAt, createdAt])
@@index([adminUserId, readAt, createdAt])
@@index([notificationEventId]) @@index([notificationEventId])
@@map("notification_recipients") @@map("notification_recipients")
} }
@@ -1953,9 +2283,19 @@ model AdminUser {
passwordResetToken String? @unique passwordResetToken String? @unique
passwordResetExpiresAt DateTime? passwordResetExpiresAt DateTime?
auditLogs AuditLog[] auditLogs AuditLog[]
permissions AdminPermission[] permissions AdminPermission[]
recoveryCodes AdminRecoveryCode[] 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1974,7 +2314,6 @@ model AdminPermission {
@@map("admin_permissions") @@map("admin_permissions")
} }
model AdminRecoveryCode { model AdminRecoveryCode {
id String @id @default(cuid()) id String @id @default(cuid())
adminUserId String adminUserId String
@@ -2022,6 +2361,15 @@ model PricingConfig {
@@map("pricing_configs") @@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 { model PlanFeature {
id String @id @default(cuid()) id String @id @default(cuid())
plan Plan plan Plan
+13 -1
View File
@@ -25,10 +25,21 @@ export type NotificationType =
| 'REFUND_PROCESSED' | 'REFUND_PROCESSED'
| 'NEW_OFFER_FROM_SAVED_COMPANY' | 'NEW_OFFER_FROM_SAVED_COMPANY'
| 'REVIEW_REQUEST' | '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 NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
export type NotificationDeliveryStatus = 'PENDING' | 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED' | 'SKIPPED' | 'DEAD_LETTER' 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 { export interface Company {
id: string id: string
@@ -61,6 +72,7 @@ export interface AdminUser {
isActive: boolean isActive: boolean
totpEnabled?: boolean totpEnabled?: boolean
totpSecret?: string | null totpSecret?: string | null
preferredLocale?: string
} }
export interface InsurancePolicy { export interface InsurancePolicy {
+13 -1
View File
@@ -25,10 +25,21 @@ export type NotificationType =
| 'REFUND_PROCESSED' | 'REFUND_PROCESSED'
| 'NEW_OFFER_FROM_SAVED_COMPANY' | 'NEW_OFFER_FROM_SAVED_COMPANY'
| 'REVIEW_REQUEST' | '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 NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
export type NotificationDeliveryStatus = 'PENDING' | 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED' | 'SKIPPED' | 'DEAD_LETTER' 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 { export interface Company {
id: string id: string
@@ -61,6 +72,7 @@ export interface AdminUser {
isActive: boolean isActive: boolean
totpEnabled?: boolean totpEnabled?: boolean
totpSecret?: string | null totpSecret?: string | null
preferredLocale?: string
} }
export interface InsurancePolicy { export interface InsurancePolicy {
+2 -1
View File
@@ -75,11 +75,12 @@ PGMANAGE_DEFAULT_PASSWORD=change-me
# Email # Email
EMAIL_FROM=rentaldrivego@gmail.com EMAIL_FROM=rentaldrivego@gmail.com
EMAIL_FROM_NAME=RentalDriveGo EMAIL_FROM_NAME=RentalDriveGo
EMAIL_PROVIDER=gmail
MAIL_HOST=smtp.gmail.com MAIL_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
MAIL_SCHEME=smtp MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=placeholder MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
+6 -13
View File
@@ -1,21 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"], "lib": ["ES2022"],
"module": "commonjs", "strict": true,
"moduleResolution": "node",
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"declaration": true, "skipLibCheck": true,
"declarationMap": true, "forceConsistentCasingInFileNames": true
"sourceMap": true, }
"outDir": "dist",
"rootDir": "src"
},
"exclude": ["node_modules", "dist"]
} }
+12
View File
@@ -2,6 +2,18 @@
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"], "globalDependencies": ["**/.env.*local"],
"globalPassThroughEnv": [ "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", "SITE_ORIGIN",
"NEXT_PUBLIC_HOMEPAGE_URL", "NEXT_PUBLIC_HOMEPAGE_URL",
"NEXT_PUBLIC_CARPLACE_URL" "NEXT_PUBLIC_CARPLACE_URL"