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
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@ const ADMIN_BASE_PATH = '/admin'
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH)
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] })
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix], frameSources: ['blob:'] })
const nextConfig = {
basePath: ADMIN_BASE_PATH,
@@ -9,6 +9,7 @@ interface AdminUser {
lastName: string
email: string
role: string
preferredLocale: 'ar' | 'en' | 'fr'
isActive: boolean
createdAt: string
permissions?: { id: string; resource: string; actions: string[] }[]
@@ -21,6 +22,7 @@ const EMPTY_FORM = {
email: '',
password: '',
role: 'SUPPORT',
preferredLocale: 'en',
isActive: true,
}
@@ -68,6 +70,7 @@ export default function AdminUsersPage() {
email: admin.email,
password: '',
role: admin.role,
preferredLocale: admin.preferredLocale,
isActive: admin.isActive,
})
setError(null)
@@ -92,6 +95,7 @@ export default function AdminUsersPage() {
lastName: form.lastName,
email: form.email,
role: form.role,
preferredLocale: form.preferredLocale,
isActive: form.isActive,
...(form.password ? { password: form.password } : {}),
}
@@ -273,6 +277,18 @@ export default function AdminUsersPage() {
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Notification language</label>
<select
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.preferredLocale}
onChange={(e) => setForm({ ...form, preferredLocale: e.target.value as 'ar' | 'en' | 'fr' })}
>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="ar">العربية</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
<select
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,6 @@ interface CompanyDetail {
terms: string
fuelPolicyType: string
lateFeePerHour: number | null
taxRate: number | null
signatureRequired: boolean
showTax: boolean
} | null
@@ -180,7 +179,6 @@ interface FormState {
terms: string
fuelPolicyType: string
lateFeePerHour: string
taxRate: string
signatureRequired: boolean
showTax: boolean
}
@@ -297,7 +295,6 @@ function createFormState(company: CompanyDetail): FormState {
terms: company.contractSettings?.terms ?? '',
fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL',
lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '',
taxRate: company.contractSettings?.taxRate?.toString() ?? '',
signatureRequired: company.contractSettings?.signatureRequired ?? true,
showTax: company.contractSettings?.showTax ?? false,
},
@@ -441,7 +438,6 @@ export default function AdminCompanyDetailPage() {
terms: form.contractSettings.terms,
fuelPolicyType: form.contractSettings.fuelPolicyType,
lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null,
taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null,
signatureRequired: form.contractSettings.signatureRequired,
showTax: form.contractSettings.showTax,
},
@@ -915,10 +911,6 @@ export default function AdminCompanyDetailPage() {
<span className={LABEL_CLASS}>Late fee per hour</span>
<input className={INPUT_CLASS} type="number" value={form.contractSettings.lateFeePerHour} onChange={(e) => updateSection('contractSettings', { lateFeePerHour: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Tax rate</span>
<input className={INPUT_CLASS} type="number" step="0.01" value={form.contractSettings.taxRate} onChange={(e) => updateSection('contractSettings', { taxRate: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.contractSettings.signatureRequired} onChange={(e) => updateSection('contractSettings', { signatureRequired: e.target.checked })} />
Signature required
+198 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState, type FormEvent } from 'react'
import {
AdminLanguageSwitcher,
AdminThemeSwitcher,
@@ -40,6 +40,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const pathname = usePathname()
const [ready, setReady] = useState(false)
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
const [unreadNotifications, setUnreadNotifications] = useState(0)
useEffect(() => {
let cancelled = false
@@ -58,6 +59,14 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
}
setAdmin(resolvedAdmin)
setReady(true)
if (resolvedAdmin.totpEnabled) {
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null)
.then((inbox) => {
if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0))
})
.catch(() => {})
}
} else {
window.location.replace(buildUnifiedLoginUrl(pathname))
}
@@ -84,6 +93,16 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
)
}
if (admin && !admin.totpEnabled) {
return (
<Admin2FAEnrollmentGate
admin={admin}
onEnrolled={setAdmin}
onLogout={handleLogout}
/>
)
}
return (
<AdminSessionProvider admin={admin as AdminSessionUser}>
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
@@ -107,6 +126,9 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg>
{dict.nav[link.key]}
{link.key === 'notifications' && unreadNotifications > 0 ? (
<span className="ms-auto rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold text-white">{unreadNotifications > 99 ? '99+' : unreadNotifications}</span>
) : null}
</Link>
)
})}
@@ -132,3 +154,178 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
</AdminSessionProvider>
)
}
function Admin2FAEnrollmentGate({
admin,
onEnrolled,
onLogout,
}: {
admin: AdminSessionUser
onEnrolled: (admin: AdminSessionUser) => void
onLogout: () => void
}) {
const { dict } = useAdminI18n()
const [secret, setSecret] = useState('')
const [qrCode, setQrCode] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [loadingSetup, setLoadingSetup] = useState(true)
const [verifying, setVerifying] = useState(false)
const [verifiedAdmin, setVerifiedAdmin] = useState<AdminSessionUser | null>(null)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const setupStarted = useRef(false)
useEffect(() => {
if (setupStarted.current) return
setupStarted.current = true
fetch(`${ADMIN_API_BASE}/admin/auth/2fa/setup`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
.then(async (response) => {
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.')
const data = json?.data ?? json
setSecret(data?.secret ?? '')
setQrCode(data?.qrCode ?? '')
})
.catch((err: any) => setError(err?.message ?? 'Failed to start 2FA setup.'))
.finally(() => setLoadingSetup(false))
}, [])
async function verifyCode(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedCode = code.trim()
if (!/^\d{6}$/.test(normalizedCode)) {
setError('Enter the 6-digit code from your authenticator app.')
return
}
setError(null)
setVerifying(true)
try {
const response = await fetch(`${ADMIN_API_BASE}/admin/auth/2fa/verify`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: normalizedCode }),
})
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Invalid 2FA code.')
const data = json?.data ?? json
setVerifiedAdmin((data?.admin ?? { ...admin, totpEnabled: true }) as AdminSessionUser)
setRecoveryCodes(Array.isArray(data?.recoveryCodes) ? data.recoveryCodes : [])
} catch (err: any) {
setError(err?.message ?? 'Invalid 2FA code.')
} finally {
setVerifying(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] p-6 text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
<section className="w-full max-w-2xl rounded-3xl border border-stone-200/80 bg-white/90 p-8 shadow-xl backdrop-blur dark:border-blue-900 dark:bg-[#07101e]/90">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Set up admin 2FA</h1>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
Admin 2FA enrollment is required before using privileged admin routes.
</p>
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{admin.email}</p>
</div>
<button
type="button"
onClick={onLogout}
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
>
{dict.logout}
</button>
</div>
{verifiedAdmin ? (
<div className="mt-8 space-y-5">
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
2FA is enabled. Save your recovery codes before continuing.
</div>
{recoveryCodes.length > 0 ? (
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-900 dark:bg-[#0d1b38]">
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Recovery codes</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{recoveryCodes.map((recoveryCode) => (
<code key={recoveryCode} className="rounded-lg bg-white px-3 py-2 text-sm text-stone-800 dark:bg-[#07101e] dark:text-slate-200">
{recoveryCode}
</code>
))}
</div>
</div>
) : null}
<button
type="button"
onClick={() => onEnrolled(verifiedAdmin)}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
Continue to admin dashboard
</button>
</div>
) : (
<form onSubmit={verifyCode} className="mt-8 space-y-6">
{loadingSetup ? (
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
Preparing authenticator setup...
</div>
) : (
<div className="grid gap-5 md:grid-cols-[180px,1fr]">
<div className="flex h-44 items-center justify-center rounded-2xl border border-stone-200 bg-white p-3 dark:border-blue-900 dark:bg-white">
{qrCode ? <img src={qrCode} alt="Admin 2FA QR code" className="h-full w-full object-contain" /> : <span className="text-sm text-stone-500">No QR code</span>}
</div>
<div>
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</p>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
Scan the QR code with your authenticator app, or enter the setup key manually.
</p>
{secret ? (
<code className="mt-3 block break-all rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-800 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200">
{secret}
</code>
) : null}
</div>
</div>
)}
<label className="block">
<span className="mb-2 block text-sm font-semibold text-blue-950 dark:text-stone-100">6-digit code</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric"
autoComplete="one-time-code"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-stone-900 outline-none transition focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100"
placeholder="000000"
disabled={loadingSetup || verifying}
/>
</label>
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<button
type="submit"
disabled={loadingSetup || verifying || code.length !== 6}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{verifying ? 'Verifying...' : 'Enable 2FA'}
</button>
</form>
)}
</section>
</div>
)
}
@@ -6,6 +6,14 @@ import {
formatNotificationDate,
type NotificationsPageResult,
} from '@/lib/adminNotifications'
import { ADMIN_API_BASE } from '@/lib/api'
interface AdminInboxItem {
id: string
readAt: string | null
createdAt: string
notificationEvent: { title: string; body: string; type: string; locale: string; data?: Record<string, unknown> }
}
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
@@ -49,6 +57,14 @@ export default function AdminNotificationsPage() {
const [filterStatus, setFilterStatus] = useState('')
const [filterCompany, setFilterCompany] = useState('')
const [page, setPage] = useState(1)
const [inbox, setInbox] = useState<{ data: AdminInboxItem[]; unread: number }>({ data: [], unread: 0 })
function loadInbox() {
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Failed to load personal inbox')))
.then((json) => setInbox(json.data))
.catch(() => {})
}
function load(p: number, signal?: AbortSignal) {
setLoading(true)
@@ -73,6 +89,7 @@ export default function AdminNotificationsPage() {
const controller = new AbortController()
setPage(1)
load(1, controller.signal)
loadInbox()
return () => controller.abort()
}, [filterChannel, filterStatus, filterCompany])
@@ -83,6 +100,11 @@ export default function AdminNotificationsPage() {
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
async function markRead(recipientId: string) {
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications/me/${recipientId}/read`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' })
if (response.ok) loadInbox()
}
return (
<div className="shell py-8 space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -100,6 +122,27 @@ export default function AdminNotificationsPage() {
)}
</div>
<section className="panel p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">My inbox</p>
<h2 className="mt-1 text-lg font-semibold text-zinc-100">Assigned operational notices</h2>
</div>
<span className="rounded-full bg-red-500/15 px-3 py-1 text-xs font-semibold text-red-300">{inbox.unread} unread</span>
</div>
{inbox.data.length === 0 ? <p className="mt-4 text-sm text-zinc-500">No assigned notices.</p> : (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{inbox.data.map((item) => (
<button key={item.id} type="button" onClick={() => markRead(item.id)} className={`rounded-xl border p-4 text-left ${item.readAt ? 'border-zinc-800 bg-zinc-950/50' : 'border-orange-500/40 bg-orange-500/5'}`}>
<p className="text-xs uppercase tracking-wide text-zinc-500">{item.notificationEvent.type.replaceAll('_', ' ')} · {item.notificationEvent.locale}</p>
<p className="mt-2 font-semibold text-zinc-100">{item.notificationEvent.title}</p>
<p className="mt-1 text-sm text-zinc-400">{item.notificationEvent.body}</p>
</button>
))}
</div>
)}
</section>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
+30
View File
@@ -154,6 +154,10 @@ html.dark .panel {
color: rgb(28 25 23);
}
.light .text-zinc-50 {
color: rgb(28 25 23);
}
.light .text-zinc-500 {
color: rgb(120 113 108);
}
@@ -171,4 +175,30 @@ html.dark .panel {
.light .hover\:text-zinc-200:hover {
color: rgb(28 25 23);
}
.light .text-amber-100,
.light .text-amber-100\/80,
.light .text-amber-200,
.light .text-amber-300 {
color: rgb(146 64 14);
}
.light .text-emerald-300 {
color: rgb(4 120 87);
}
.light .text-rose-200,
.light .text-rose-300,
.light .text-red-400 {
color: rgb(190 18 60);
}
.light .text-sky-300 {
color: rgb(3 105 161);
}
.light .text-red-200,
.light .text-red-300 {
color: rgb(185 28 28);
}
}
+24
View File
@@ -0,0 +1,24 @@
# Manual subscription payments are intentionally off until every dependency is ready.
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=false
BANK_TRANSFER_ENABLED=false
BANK_TRANSFER_ACCOUNT_NAME=
BANK_TRANSFER_BANK_NAME=
BANK_TRANSFER_ACCOUNT_REFERENCE=
BANK_TRANSFER_DUE_DAYS=7
CHECK_PAYMENT_ENABLED=false
CHECK_PAYMENT_PAYEE=
CHECK_PAYMENT_DELIVERY_ADDRESS=
CHECK_PAYMENT_DUE_DAYS=14
# Evidence is private, quarantined, content-validated and fail-closed scanned.
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=false
FILE_STORAGE_ROOT=/var/lib/rentaldrivego/uploads
PRIVATE_STORAGE_PERSISTENCE_CONFIRMED=false
PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED=false
PAYMENT_EVIDENCE_SCANNER_PATH=/usr/bin/clamscan
PAYMENT_EVIDENCE_SCAN_TIMEOUT_MS=60000
# Collections notifications can be proven before automatic suspension is enabled.
SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED=false
SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED=false
DEFAULT_BILLING_TIMEZONE=Africa/Casablanca
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { assertPaymentEvidenceFile, sanitizeEvidenceFilename } from './paymentEvidence'
function file(buffer: Buffer, originalname: string, mimetype: string): Express.Multer.File {
return { buffer, originalname, mimetype, size: buffer.length } as Express.Multer.File
}
function png(width = 16, height = 16) {
const head = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from([0, 0, 0, 13]),
Buffer.from('IHDR'),
])
const dimensions = Buffer.alloc(8)
dimensions.writeUInt32BE(width, 0)
dimensions.writeUInt32BE(height, 4)
const ihdrRest = Buffer.alloc(9)
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
return Buffer.concat([head, dimensions, ihdrRest, iend])
}
function jpeg(width = 16, height = 16) {
return Buffer.from([
0xff, 0xd8,
0xff, 0xc0, 0x00, 0x0b, 0x08,
(height >> 8) & 0xff, height & 0xff,
(width >> 8) & 0xff, width & 0xff,
0x01, 0x01, 0x11, 0x00,
0xff, 0xd9,
])
}
describe('payment evidence validation', () => {
it('accepts a structurally bounded PDF by content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs with trailing bytes after the EOF marker', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n\u0000\u0000')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs that contain common byte sequences inside document content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n(<html><svg>PK\u0003\u0004)</script>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid evidence files reported with compatible browser MIME aliases', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/octet-stream'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/x-pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid image evidence with trailing bytes and common JPEG extensions', () => {
expect(assertPaymentEvidenceFile(file(Buffer.concat([png(), Buffer.from('\n')]), 'receipt.png', 'image/x-png'))).toEqual({ mime: 'image/png', ext: '.png' })
expect(assertPaymentEvidenceFile(file(Buffer.concat([jpeg(), Buffer.from('\n')]), 'receipt.jfif', 'image/pjpeg'))).toEqual({ mime: 'image/jpeg', ext: '.jpg' })
})
it('rejects spoofed MIME types and active content', () => {
const html = Buffer.from('<!doctype html><script>alert(1)</script>')
expect(() => assertPaymentEvidenceFile(file(html, 'receipt.pdf', 'application/pdf'))).toThrow(/suspicious/i)
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)])
expect(() => assertPaymentEvidenceFile(file(png, 'receipt.pdf', 'application/pdf'))).toThrow(/valid PDF, JPEG, and PNG/i)
})
it('sanitizes filenames without allowing path traversal', () => {
expect(sanitizeEvidenceFilename('../../bank<receipt>.pdf')).toBe('bank_receipt_.pdf')
})
})
+128
View File
@@ -0,0 +1,128 @@
import path from 'path'
import multer from 'multer'
import { ValidationError } from '../errors'
export const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
export const PAYMENT_EVIDENCE_MAX_FILES = 3
export const PAYMENT_EVIDENCE_MAX_TOTAL_SIZE = 20 * 1024 * 1024
export const paymentEvidenceUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: PAYMENT_EVIDENCE_MAX_FILE_SIZE, files: 1, fields: 5, parts: 8 },
})
export type DetectedPaymentEvidence = {
mime: 'application/pdf' | 'image/jpeg' | 'image/png'
ext: '.pdf' | '.jpg' | '.png'
}
const allowedExtensions: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['.pdf'],
'image/jpeg': ['.jpg', '.jpeg', '.jpe', '.jfif'],
'image/png': ['.png'],
}
const allowedDeclaredMimes: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['application/pdf', 'application/x-pdf', 'application/octet-stream'],
'image/jpeg': ['image/jpeg', 'image/pjpeg', 'application/octet-stream'],
'image/png': ['image/png', 'image/x-png', 'application/octet-stream'],
}
function hasSpoofedLeadingContainerSignature(buffer: Buffer) {
const prefix = buffer.subarray(0, 512)
const text = prefix.toString('latin1').trimStart().toLowerCase()
return text.startsWith('<script')
|| text.startsWith('<!doctype html')
|| text.startsWith('<html')
|| text.startsWith('<svg')
|| prefix.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x4d, 0x5a, 0x90, 0x00]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
}
function detectType(buffer: Buffer): DetectedPaymentEvidence | null {
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
const iendIndex = buffer.lastIndexOf(iend)
if (buffer.length >= 33 && iendIndex >= 0 && buffer.length - iendIndex <= 2048) {
return { mime: 'image/png', ext: '.png' }
}
}
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
const eoiIndex = buffer.lastIndexOf(Buffer.from([0xff, 0xd9]))
if (eoiIndex >= 0 && buffer.length - eoiIndex <= 2048) {
return { mime: 'image/jpeg', ext: '.jpg' }
}
}
if (buffer.length >= 12 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
const content = buffer.toString('latin1')
// Real-world PDFs may include a newline or small binary marker after EOF.
// Require an EOF marker near the end instead of at the exact final byte.
const eofIndex = content.lastIndexOf('%%EOF')
if (eofIndex >= 0 && content.length - eofIndex <= 2048 && !/\/Encrypt\b/.test(content)) return { mime: 'application/pdf', ext: '.pdf' }
}
return null
}
function assertSafeJpegDimensions(buffer: Buffer) {
let offset = 2
while (offset + 8 < buffer.length) {
if (buffer[offset] !== 0xff) { offset += 1; continue }
const marker = buffer[offset + 1]
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2
continue
}
const segmentLength = buffer.readUInt16BE(offset + 2)
if (segmentLength < 2 || offset + 2 + segmentLength > buffer.length) break
const isStartOfFrame = marker !== undefined
&& ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf))
if (isStartOfFrame) {
const height = buffer.readUInt16BE(offset + 5)
const width = buffer.readUInt16BE(offset + 7)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
return
}
offset += 2 + segmentLength
}
throw new ValidationError('The JPEG file is malformed')
}
function assertSafePngDimensions(buffer: Buffer) {
if (buffer.length < 24) throw new ValidationError('The PNG file is malformed')
const width = buffer.readUInt32BE(16)
const height = buffer.readUInt32BE(20)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
}
export function assertPaymentEvidenceFile(file: Express.Multer.File | undefined): DetectedPaymentEvidence {
if (!file) throw new ValidationError('A payment evidence file is required')
if (file.size <= 0 || file.size > PAYMENT_EVIDENCE_MAX_FILE_SIZE) {
throw new ValidationError('Payment evidence files must be between 1 byte and 10 MB')
}
if (hasSpoofedLeadingContainerSignature(file.buffer)) throw new ValidationError('Unsupported or suspicious payment evidence file')
const detected = detectType(file.buffer)
if (!detected) throw new ValidationError('Only valid PDF, JPEG, and PNG evidence files are accepted')
const declaredMime = file.mimetype.toLowerCase()
if (!allowedDeclaredMimes[detected.mime].includes(declaredMime)) {
throw new ValidationError('The declared file type does not match its content')
}
const extension = path.extname(file.originalname).toLowerCase()
if (!allowedExtensions[detected.mime].includes(extension)) {
throw new ValidationError('The filename extension does not match the file content')
}
if (detected.mime === 'image/png') assertSafePngDimensions(file.buffer)
if (detected.mime === 'image/jpeg') assertSafeJpegDimensions(file.buffer)
return detected
}
export function sanitizeEvidenceFilename(value: string) {
const base = path.basename(value).normalize('NFKC').replace(/[\u0000-\u001f\u007f]/g, '').replace(/[^\p{L}\p{N}._ -]/gu, '_')
return (base || 'payment-evidence').slice(0, 180)
}
+15 -19
View File
@@ -8,14 +8,12 @@ import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies'
import { sendNotification } from './services/notificationService'
import { processNotificationOutbox, sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
runPaymentPendingTimeoutJob,
runPastDueTimeoutJob,
runSuspensionTimeoutJob,
runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service'
import { runCollectionsWorker } from './modules/subscriptions/subscription.collections.service'
const app = createApp()
const server = http.createServer(app)
@@ -108,26 +106,24 @@ cron.schedule('0 * * * *', async () => {
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
})
// Hourly: payment_pending → past_due after 7 days
cron.schedule('15 * * * *', async () => {
const n = await runPaymentPendingTimeoutJob()
if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`)
})
// Hourly: past_due → suspended after 7 days
cron.schedule('30 * * * *', async () => {
const n = await runPastDueTimeoutJob()
if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`)
})
// Daily: suspended → cancelled after 16 days; and period-end cancellations
// Daily: explicit period-end cancellations. Subscription collections use the
// timezone-aware 30-day grace worker below, so the old fixed 7-day chain is
// intentionally not scheduled.
cron.schedule('0 1 * * *', async () => {
const nSuspend = await runSuspensionTimeoutJob()
const nPeriod = await runPeriodEndCancellationJob()
if (nSuspend > 0) console.log(`[subscription] suspension_timeout: ${nSuspend} cancelled`)
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
})
cron.schedule('*/15 * * * *', async () => {
const n = await runCollectionsWorker()
if (n > 0) console.log(`[subscription] collections: ${n} cases processed`)
})
cron.schedule('* * * * *', async () => {
const n = await processNotificationOutbox()
if (n > 0) console.log(`[notifications] outbox: ${n} events completed`)
})
// Daily: send trial-ending reminders (3 days before trial end)
cron.schedule('0 9 * * *', async () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
+63
View File
@@ -50,6 +50,14 @@ export function assertStorageConfiguration(): string {
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
)
}
if (process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED === 'true') {
if (process.env.PRIVATE_STORAGE_PERSISTENCE_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed persistent private storage in production.')
}
if (process.env.PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed encryption at rest in production.')
}
}
}
return storageRoot
@@ -141,3 +149,58 @@ export async function deleteImage(imageUrl: string): Promise<void> {
fs.unlinkSync(filePath)
}
}
function normalizePrivateStorageKey(storageKey: string) {
const normalized = storageKey.replace(/\\/g, '/').replace(/^\/+/, '')
if (!normalized || normalized.includes('..') || path.isAbsolute(normalized)) {
throw new Error('Invalid private storage key')
}
return normalized
}
export function resolvePrivateDocumentPath(storageKey: string): string {
const root = ensureStorageRoot('private')
const filePath = path.join(root, normalizePrivateStorageKey(storageKey))
if (!isWithinPath(filePath, root)) throw new Error('Private document path escapes storage root')
return filePath
}
export async function storePaymentEvidenceInQuarantine(
buffer: Buffer,
companyId: string,
submissionId: string,
extension: string,
) {
const safeExtension = ['.pdf', '.jpg', '.png'].includes(extension) ? extension : ''
if (!safeExtension) throw new Error('Unsupported private document extension')
const storageKey = path.posix.join(
'payment-evidence',
'quarantine',
companyId,
submissionId,
`${crypto.randomBytes(24).toString('hex')}${safeExtension}`,
)
const filePath = resolvePrivateDocumentPath(storageKey)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, buffer, { mode: 0o600, flag: 'wx' })
return { storageKey, filePath }
}
export async function promotePaymentEvidence(storageKey: string) {
const sourcePath = resolvePrivateDocumentPath(storageKey)
const cleanKey = normalizePrivateStorageKey(storageKey).replace('/quarantine/', '/clean/')
if (cleanKey === storageKey) throw new Error('Only quarantined evidence can be promoted')
const targetPath = resolvePrivateDocumentPath(cleanKey)
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
fs.renameSync(sourcePath, targetPath)
return cleanKey
}
export function readPrivateDocument(storageKey: string): Buffer {
return fs.readFileSync(resolvePrivateDocumentPath(storageKey))
}
export async function deletePrivateDocument(storageKey: string): Promise<void> {
const filePath = resolvePrivateDocumentPath(storageKey)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
@@ -13,7 +13,7 @@ vi.mock('../lib/prisma', () => ({
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from './requireAdminAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
@@ -163,3 +163,35 @@ describe('requireAdminRole middleware', () => {
expect(res.status).not.toHaveBeenCalled()
})
})
describe('requireFreshAdmin2FA middleware', () => {
it('allows a 2FA-verified admin session until the session ends', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 24 * 60 * 60 * 1000,
} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose session has no 2FA verification proof', () => {
const req = { admin: { id: 'admin_1', totpEnabled: true } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'fresh_2fa_required',
message: 'Admin 2FA verification is required for this session',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
})
+2 -5
View File
@@ -20,8 +20,6 @@ const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
'/auth/2fa/verify',
])
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
function is2faEnrollmentExempt(req: Request) {
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
}
@@ -84,9 +82,8 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
}
const last2faAt = req.adminAuthLast2faAt
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
if (!req.adminAuthLast2faAt) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
}
next()
@@ -3,8 +3,11 @@ import {
billingAccountUpdateSchema,
billingCreditNoteSchema,
billingRefundSchema,
collectionsOverrideSchema,
confirmManualPaymentSchema,
createBillingInvoiceSchema,
payBillingInvoiceSchema,
platformBillingSettingsSchema,
} from './admin.schemas'
describe('admin billing schemas', () => {
@@ -44,10 +47,38 @@ describe('admin billing schemas', () => {
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
})
it('bounds platform billing tax settings', () => {
expect(platformBillingSettingsSchema.parse({ taxRate: 20 })).toEqual({ taxRate: 20 })
expect(platformBillingSettingsSchema.safeParse({ taxRate: -1 }).success).toBe(false)
expect(platformBillingSettingsSchema.safeParse({ taxRate: 101 }).success).toBe(false)
})
it('requires positive money movements for payments, credit notes, and refunds', () => {
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
})
it('requires cleared-funds attestation and an idempotency key for manual subscription settlement', () => {
const confirmation = {
submissionId: 'submission_1',
method: 'BANK_TRANSFER',
externalReference: 'BANK TXN 123',
amount: 19900,
receivedAt: '2026-08-09T12:00:00.000Z',
idempotencyKey: crypto.randomUUID(),
fundsVerified: true,
}
expect(confirmManualPaymentSchema.safeParse(confirmation).success).toBe(true)
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, fundsVerified: false }).success).toBe(false)
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, amount: 0 }).success).toBe(false)
})
it('keeps suspension and notification override controls separate', () => {
const base = { reason: 'Verified finance dispute', expiresAt: '2026-09-01T12:00:00.000Z' }
expect(collectionsOverrideSchema.parse({ ...base, type: 'PAYMENT_DISPUTE', pauseSuspension: true, pauseNotifications: false })).toMatchObject({ pauseSuspension: true, pauseNotifications: false })
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION' }).success).toBe(false)
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION', revisedSuspensionAt: '2026-08-25T12:00:00.000Z' }).success).toBe(true)
})
})
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listBillingAccounts } from './admin.billing.service'
import { prisma } from '../../lib/prisma'
vi.mock('../../lib/prisma', () => ({
prisma: {
subscriptionInvoice: { findMany: vi.fn() },
company: { findMany: vi.fn(), findUniqueOrThrow: vi.fn() },
billingAccount: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
billingInvoice: { groupBy: vi.fn(), findFirst: vi.fn(), create: vi.fn() },
billingCreditBalance: { create: vi.fn() },
billingEvent: { create: vi.fn() },
},
}))
vi.mock('../../services/invoicePdfService', () => ({
generateInvoicePdf: vi.fn(),
}))
describe('admin billing service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.subscriptionInvoice.findMany).mockResolvedValue([])
vi.mocked(prisma.company.findMany).mockResolvedValue([{ id: 'company_1' }] as never)
vi.mocked(prisma.billingAccount.count).mockResolvedValue(1 as never)
vi.mocked(prisma.billingInvoice.groupBy)
.mockResolvedValueOnce([
{ billingAccountId: 'billing_empty', _count: { _all: 0 }, _sum: { amountDue: 0 } },
{ billingAccountId: 'billing_due', _count: { _all: 1 }, _sum: { amountDue: 14900 } },
] as never)
.mockResolvedValueOnce([
{ status: 'OPEN', _count: { _all: 1 }, _sum: { amountDue: 14900, totalAmount: 14900 } },
] as never)
})
it('demotes duplicate primary billing accounts and lists only the canonical company row', async () => {
vi.mocked(prisma.billingAccount.findMany)
.mockResolvedValueOnce([
{ id: 'billing_empty', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-10T12:00:00.000Z'), creditBalances: [] },
{ id: 'billing_due', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-09T12:00:00.000Z'), creditBalances: [] },
] as never)
.mockResolvedValueOnce([
{
id: 'billing_due',
companyId: 'company_1',
isPrimary: true,
legalName: 'Atlas car',
billingEmail: 'moulay.elabidi@gmail.com',
createdAt: new Date('2026-08-09T12:00:00.000Z'),
company: { id: 'company_1', name: 'Atlas car', email: 'moulay.elabidi@gmail.com', slug: 'atlas-car', status: 'PENDING', subscription: { status: 'PAYMENT_PENDING' } },
creditBalances: [],
invoices: [{ id: 'invoice_1', status: 'OPEN', amountDue: 14900, amountPaid: 0, currency: 'MAD' }],
},
] as never)
const result = await listBillingAccounts({ page: 1, pageSize: 100 })
expect(prisma.billingAccount.updateMany).toHaveBeenCalledWith({
where: { companyId: 'company_1', id: { not: 'billing_due' }, isPrimary: true },
data: { isPrimary: false },
})
expect(prisma.billingAccount.findMany).toHaveBeenLastCalledWith(expect.objectContaining({
where: { isPrimary: true },
}))
expect(result.data).toHaveLength(1)
expect(result.data[0].id).toBe('billing_due')
expect(result.data[0].openBalance).toBe(14900)
})
})
@@ -1,6 +1,7 @@
import { prisma } from '../../lib/prisma'
import { NotFoundError, ValidationError } from '../../http/errors'
import { generateInvoicePdf } from '../../services/invoicePdfService'
import { calculateTaxAmount, getPlatformBillingSettings, updatePlatformBillingSettings } from '../subscriptions/billingTax'
const BLOCKING_INVOICE_TYPES = new Set([
'SUBSCRIPTION_INITIAL',
@@ -10,7 +11,6 @@ const BLOCKING_INVOICE_TYPES = new Set([
])
const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'])
function toSequenceNumber(value: unknown) {
if (typeof value === 'bigint') return Number(value)
if (typeof value === 'number') return value
@@ -85,6 +85,10 @@ function calculateLineAmounts(items: Array<{ type: string; amount: number }>) {
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }
}
function invoiceTaxRate(invoice: { taxRecords?: Array<{ taxRate?: number | null; taxExempt?: boolean }> }) {
return invoice.taxRecords?.find((record) => !record.taxExempt && typeof record.taxRate === 'number')?.taxRate ?? null
}
function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances?: any[] }>(account: T) {
const invoices = account.invoices ?? []
const creditBalances = account.creditBalances ?? []
@@ -104,6 +108,22 @@ function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances
}
}
function getOpenBalance(invoices: any[] = []) {
return invoices
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
}
function chooseCanonicalBillingAccount<T extends { id: string; createdAt?: Date; invoices?: any[] }>(accounts: T[]) {
return [...accounts].sort((a: any, b: any) => {
const openBalanceDelta = getOpenBalance(b.invoices) - getOpenBalance(a.invoices)
if (openBalanceDelta) return openBalanceDelta
const invoiceCountDelta = (b.invoices?.length ?? 0) - (a.invoices?.length ?? 0)
if (invoiceCountDelta) return invoiceCountDelta
return new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()
})[0]
}
async function createBillingEvent(tx: any, data: {
billingAccountId?: string | null
invoiceId?: string | null
@@ -142,11 +162,41 @@ async function createAuditLog(data: {
}
async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) {
const existing = await tx.billingAccount.findFirst({
const existingAccounts = await tx.billingAccount.findMany({
where: { companyId, isPrimary: true },
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true },
orderBy: { createdAt: 'desc' },
})
if (existing) return existing
if (existingAccounts.length) {
if (existingAccounts.length > 1) {
const accountIds = existingAccounts.map((account: any) => account.id)
const invoiceGroups = await tx.billingInvoice.groupBy({
by: ['billingAccountId'],
where: { billingAccountId: { in: accountIds } },
_count: { _all: true },
_sum: { amountDue: true },
})
const invoiceGroupByAccount = new Map<string, any>(invoiceGroups.map((group: any) => [group.billingAccountId, group]))
const canonical = chooseCanonicalBillingAccount(existingAccounts.map((account: any) => {
const group = invoiceGroupByAccount.get(account.id)
const invoiceCount = group?._count?._all ?? 0
const amountDue = group?._sum?.amountDue ?? 0
return {
...account,
invoices: Array.from({ length: invoiceCount }, () => ({
status: 'OPEN',
amountDue: Math.floor(amountDue / Math.max(invoiceCount, 1)),
})),
}
}))
await tx.billingAccount.updateMany({
where: { companyId, id: { not: canonical.id }, isPrimary: true },
data: { isPrimary: false },
})
return existingAccounts.find((account: any) => account.id === canonical.id) ?? canonical
}
return existingAccounts[0]
}
const company = await tx.company.findUniqueOrThrow({
where: { id: companyId },
@@ -346,7 +396,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
}
function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) {
const where: any = {}
const where: any = { isPrimary: true }
if (query.q) {
where.OR = [
{ legalName: { contains: query.q, mode: 'insensitive' } },
@@ -510,6 +560,13 @@ export async function getBillingAccountDetail(companyId: string) {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentIntents: { orderBy: { createdAt: 'desc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
manualPaymentSubmissions: {
orderBy: { createdAt: 'desc' },
include: {
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
},
},
taxRecords: true,
creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { orderBy: { createdAt: 'desc' } },
@@ -575,6 +632,25 @@ export async function updateBillingAccount(
return updated
}
export function getBillingPlatformSettings() {
return getPlatformBillingSettings()
}
export async function updateBillingPlatformSettings(data: { taxRate: number }, adminId: string, ip?: string) {
const before = await getPlatformBillingSettings()
const updated = await updatePlatformBillingSettings({ taxRate: data.taxRate, updatedBy: adminId })
await createAuditLog({
adminUserId: adminId,
action: 'UPDATE_PLATFORM_BILLING_SETTINGS',
resource: 'PlatformBillingSettings',
resourceId: updated.id,
before,
after: updated,
ipAddress: ip,
})
return updated
}
export async function setDunningPaused(
billingAccountId: string,
paused: boolean,
@@ -704,6 +780,7 @@ export async function createDraftInvoice(
}
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
const platformBillingSettings = await getPlatformBillingSettings()
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
@@ -771,9 +848,8 @@ export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: s
})
}
const taxRate = account.taxExempt ? 0 : Number(account.company.contractSettings?.taxRate ?? 0)
const taxBase = Math.max(preTaxBase - autoCreditToApply, 0)
const taxAmount = taxRate > 0 ? Math.round(taxBase * (taxRate / 100)) : 0
const { taxRate, taxAmount } = calculateTaxAmount(taxBase, account.taxExempt, platformBillingSettings.taxRate)
if (taxAmount > 0) {
await tx.billingInvoiceLineItem.create({
@@ -911,6 +987,9 @@ export async function payInvoice(
if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Invoice is not payable in its current state')
}
if (current.subscriptionId) {
throw new ValidationError('Subscription invoices require provider confirmation or the dedicated cleared-manual-payment workflow')
}
const amount = data.amount ?? current.amountDue
if (amount <= 0 || amount > current.amountDue) {
@@ -1389,6 +1468,7 @@ export async function getInvoicePdf(invoiceId: string) {
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description,
amount: item.amount,
currency: item.currency,
@@ -1401,6 +1481,7 @@ export async function getInvoicePdf(invoiceId: string) {
subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
@@ -0,0 +1,550 @@
import crypto from 'crypto'
import { prisma } from '../../lib/prisma'
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
import { readPrivateDocument } from '../../lib/storage'
import { sendNotification } from '../../services/notificationService'
import { coerceNotificationLocale, type NotificationLocale } from '../../services/notificationLocalizationService'
import { addBillingPeriod, normalizeExternalReference } from '../subscriptions/subscription.manual.service'
const PAYABLE_STATUSES = ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE']
const customerPaymentCopy: Record<NotificationLocale, {
confirmedTitle: string
confirmed: (details: PaymentConfirmationDetails) => string
rejectedTitle: string
rejected: (invoice: string, reason: string) => string
}> = {
en: {
confirmedTitle: 'Subscription payment confirmed',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'Payment evidence needs attention',
rejected: (invoice, reason) => `The evidence submitted for invoice ${invoice} was rejected: ${reason}. Upload corrected evidence from Subscription.`,
},
fr: {
confirmedTitle: 'Paiement de labonnement confirmé',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'Justificatif de paiement à corriger',
rejected: (invoice, reason) => `Le justificatif de la facture ${invoice} a été refusé : ${reason}. Téléversez un justificatif corrigé depuis Abonnement.`,
},
ar: {
confirmedTitle: 'تم تأكيد دفع الاشتراك',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'مستند الدفع يحتاج إلى تصحيح',
rejected: (invoice, reason) => `تم رفض مستند الفاتورة ${invoice}: ${reason}. حمّل مستنداً مصححاً من صفحة الاشتراك.`,
},
}
type PaymentConfirmationDetails = {
invoice: string
amountPaid: number
currency: string
paymentType?: string | null
paymentReference?: string | null
receivedAt?: Date | string | null
confirmedAt?: Date | string | null
plan?: string | null
billingPeriod?: string | null
periodStart?: Date | string | null
periodEnd?: Date | string | null
}
function fmtMoney(amount: number, currency: string) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format((amount ?? 0) / 100)
}
function fmtDate(value?: Date | string | null) {
if (!value) return 'Not set'
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
function paymentMethodLabel(method?: string | null) {
if (method === 'BANK_TRANSFER') return 'Bank transfer'
if (method === 'CHECK') return 'Check'
if (method === 'STRIPE') return 'Online card payment'
return method ?? 'Manual payment'
}
function buildConfirmedPaymentBody(details: PaymentConfirmationDetails) {
return [
'Dear customer,',
'',
'We confirm that your subscription payment has been verified and recorded. Your subscription is now active for the period shown below.',
'',
`Invoice: ${details.invoice}`,
`Amount paid: ${fmtMoney(details.amountPaid, details.currency)}`,
`Payment type: ${paymentMethodLabel(details.paymentType)}`,
details.paymentReference ? `Payment reference: ${details.paymentReference}` : null,
`Funds received/cleared on: ${fmtDate(details.receivedAt)}`,
`Payment confirmed on: ${fmtDate(details.confirmedAt)}`,
`Subscription plan: ${details.plan ?? 'Current plan'}`,
`Billing period: ${details.billingPeriod ?? 'Current billing period'}`,
`Subscription start: ${fmtDate(details.periodStart)}`,
`Subscription end: ${fmtDate(details.periodEnd)}`,
'',
'A PDF copy of the invoice is attached for your records.',
'',
'Regards,',
'RentalDriveGo Finance',
].filter((line): line is string => line !== null).join('\n')
}
async function notifyPaymentResult(data: {
billingAccountId: string
companyId: string
invoiceId: string
invoiceNumber?: string | null
kind: 'confirmed' | 'rejected'
sourceId: string
reason?: string
}) {
const account = await prisma.billingAccount.findUnique({
where: { id: data.billingAccountId },
include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } },
})
if (!account) return
const invoiceRecord = await prisma.billingInvoice.findUnique({
where: { id: data.invoiceId },
include: {
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' }, take: 1 },
},
})
const paymentAttempt = invoiceRecord?.paymentAttempts?.[0] ?? null
const periodStart = invoiceRecord?.subscription?.currentPeriodStart ?? invoiceRecord?.lineItems?.[0]?.periodStart ?? null
const periodEnd = invoiceRecord?.subscription?.currentPeriodEnd ?? invoiceRecord?.lineItems?.[0]?.periodEnd ?? null
for (const contact of account.billingContacts) {
const enabled = account.enabledCommunicationLocales as string[]
const locale = coerceNotificationLocale(
contact.locale && enabled.includes(contact.locale)
? contact.locale
: contact.employee?.preferredLanguage && enabled.includes(contact.employee.preferredLanguage)
? contact.employee.preferredLanguage
: account.defaultCommunicationLocale,
)
const copy = customerPaymentCopy[locale]
const invoice = data.invoiceNumber ?? data.invoiceId
const details: PaymentConfirmationDetails = {
invoice,
amountPaid: invoiceRecord?.amountPaid ?? paymentAttempt?.amount ?? 0,
currency: invoiceRecord?.currency ?? paymentAttempt?.currency ?? 'MAD',
paymentType: paymentAttempt?.manualMethod ?? invoiceRecord?.collectionMethod ?? invoiceRecord?.paymentProvider ?? 'MANUAL',
paymentReference: paymentAttempt?.externalReference ?? paymentAttempt?.providerPaymentId ?? null,
receivedAt: paymentAttempt?.receivedAt ?? invoiceRecord?.paidAt ?? null,
confirmedAt: paymentAttempt?.confirmedAt ?? invoiceRecord?.paidAt ?? null,
plan: invoiceRecord?.requestedPlan ?? invoiceRecord?.subscription?.plan ?? null,
billingPeriod: invoiceRecord?.requestedBillingPeriod ?? invoiceRecord?.subscription?.billingPeriod ?? null,
periodStart,
periodEnd,
}
await sendNotification({
type: data.kind === 'confirmed' ? 'SUBSCRIPTION_PAYMENT_CONFIRMED' : 'MANUAL_PAYMENT_EVIDENCE_REJECTED',
title: data.kind === 'confirmed' ? copy.confirmedTitle : copy.rejectedTitle,
body: data.kind === 'confirmed' ? copy.confirmed(details) : copy.rejected(invoice, data.reason ?? ''),
companyId: data.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: data.kind === 'confirmed' ? 'subscription.payment_confirmed.v1' : 'subscription.payment_evidence_rejected.v1',
idempotencyKey: `manual-payment:${data.kind}:${data.sourceId}:${contact.id}`,
sourceType: 'manual_payment',
sourceId: data.sourceId,
data: {
invoiceId: data.invoiceId,
amountPaid: details.amountPaid,
currency: details.currency,
paymentType: details.paymentType,
paymentReference: details.paymentReference,
subscriptionStart: details.periodStart,
subscriptionEnd: details.periodEnd,
timezone: account.timezone,
templateVersion: 1,
localizationFallback: false,
...(data.kind === 'confirmed' ? { emailAttachments: [{ type: 'invoice_pdf', invoiceId: data.invoiceId }] } : {}),
},
policy: { mandatory: true },
})
}
}
function requestHash(value: Record<string, unknown>) {
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
}
export async function listManualPaymentSubmissions(query: { status: string; page: number; pageSize: number }) {
const where = { status: query.status as any }
const [data, total] = await Promise.all([
prisma.manualPaymentSubmission.findMany({
where,
include: {
invoice: { include: { company: true, subscription: true } },
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
},
orderBy: { submittedAt: 'asc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma.manualPaymentSubmission.count({ where }),
])
return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.max(1, Math.ceil(total / query.pageSize)) }
}
export async function getManualPaymentSubmission(submissionId: string, adminId?: string) {
let submission = await prisma.manualPaymentSubmission.findUnique({
where: { id: submissionId },
include: {
invoice: {
include: {
company: { select: { id: true, name: true, email: true } },
subscription: true,
billingAccount: true,
lineItems: true,
},
},
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
reviewedByAdmin: { select: { id: true, firstName: true, lastName: true } },
},
})
if (!submission) throw new NotFoundError('Payment submission not found')
if (adminId && submission.status === 'SUBMITTED') {
await prisma.manualPaymentSubmission.updateMany({
where: { id: submission.id, status: 'SUBMITTED' },
data: { status: 'UNDER_REVIEW', reviewedByAdminId: adminId, reviewedAt: new Date() },
})
return getManualPaymentSubmission(submissionId)
}
return submission
}
export async function getAdminPaymentDocument(submissionId: string, documentId: string, adminId: string, ip?: string) {
const document = await prisma.manualPaymentDocument.findFirst({
where: { id: documentId, submissionId, deletedAt: null, scanStatus: 'CLEAN' },
include: { submission: { include: { invoice: true } } },
})
if (!document || document.submission.invoiceId !== document.invoiceId) throw new NotFoundError('Clean payment evidence document not found')
await prisma.auditLog.create({
data: {
adminUserId: adminId,
action: 'VIEW_MANUAL_PAYMENT_EVIDENCE',
resource: 'ManualPaymentDocument',
resourceId: document.id,
companyId: document.companyId,
ipAddress: ip,
after: { submissionId, invoiceId: document.invoiceId, sha256: document.sha256 },
},
})
return { document, bytes: readPrivateDocument(document.storageKey) }
}
export async function rejectManualPaymentSubmission(submissionId: string, reason: string, adminId: string, ip?: string) {
const updated = await prisma.$transaction(async (tx: any) => {
const current = await tx.manualPaymentSubmission.findUnique({
where: { id: submissionId },
include: { invoice: true },
})
if (!current) throw new NotFoundError('Payment submission not found')
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(current.status)) throw new ConflictError('Submission cannot be rejected in its current state')
const reviewedAt = new Date()
const updated = await tx.manualPaymentSubmission.update({
where: { id: current.id },
data: { status: 'REJECTED', rejectionReason: reason, reviewedByAdminId: adminId, reviewedAt },
include: { documents: { where: { deletedAt: null } } },
})
await tx.billingEvent.create({
data: {
billingAccountId: current.billingAccountId,
invoiceId: current.invoiceId,
subscriptionId: current.invoice.subscriptionId,
companyId: current.companyId,
eventType: 'payment_evidence.rejected',
source: 'admin',
payload: { submissionId, reason, adminId },
occurredAt: reviewedAt,
},
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'REJECT_MANUAL_PAYMENT_EVIDENCE',
resource: 'ManualPaymentSubmission',
resourceId: submissionId,
companyId: current.companyId,
before: { status: current.status },
after: { status: 'REJECTED', reason },
ipAddress: ip,
},
})
return { ...updated, invoice: current.invoice }
})
await notifyPaymentResult({
billingAccountId: updated.billingAccountId,
companyId: updated.companyId,
invoiceId: updated.invoiceId,
invoiceNumber: updated.invoice.invoiceNumber,
kind: 'rejected',
sourceId: updated.id,
reason,
})
return updated
}
export async function confirmManualPayment(invoiceId: string, data: {
submissionId: string
method: 'BANK_TRANSFER' | 'CHECK'
externalReference: string
amount: number
receivedAt: string
note?: string
correctionReason?: string
idempotencyKey: string
fundsVerified: true
}, adminId: string, ip?: string) {
const normalizedReference = normalizeExternalReference(data.externalReference)
const normalizedPayload = {
invoiceId,
submissionId: data.submissionId,
method: data.method,
normalizedReference,
amount: data.amount,
receivedAt: new Date(data.receivedAt).toISOString(),
note: data.note ?? null,
correctionReason: data.correctionReason ?? null,
fundsVerified: true,
}
const hash = requestHash(normalizedPayload)
const receivedAt = new Date(data.receivedAt)
if (receivedAt.getTime() > Date.now() + 5 * 60 * 1000) throw new ValidationError('Settlement time cannot be in the future')
let collectionsCaseId: string | null = null
try {
const result = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
billingAccount: true,
subscription: true,
legacySubscriptionInvoice: true,
collectionsCase: true,
manualPaymentSubmissions: {
where: { id: data.submissionId },
include: { documents: { where: { deletedAt: null } } },
},
},
})
if (!current) throw new NotFoundError('Invoice not found')
const duplicate = await tx.billingPaymentAttempt.findFirst({
where: { billingAccountId: current.billingAccountId, idempotencyKey: data.idempotencyKey },
include: { invoice: true },
})
if (duplicate) {
const metadata = duplicate.metadata as any
if (duplicate.invoiceId !== invoiceId || metadata?.confirmationRequestHash !== hash) {
throw new ConflictError('Idempotency key was already used with a different payment confirmation')
}
return { invoice: duplicate.invoice, paymentAttempt: duplicate, duplicate: true }
}
if (!PAYABLE_STATUSES.includes(current.status)) throw new ConflictError('Invoice is not payable in its current state')
if (current.collectionMethod !== data.method) throw new ValidationError('Payment method must match the invoice collection method')
if (current.currency !== 'MAD') throw new ValidationError('Manual subscription confirmation requires MAD currency')
if (data.amount !== current.amountDue || data.amount !== current.totalAmount - current.amountPaid) {
throw new ConflictError('The full current invoice balance must be confirmed')
}
const submission = current.manualPaymentSubmissions[0]
if (!submission || submission.invoiceId !== current.id || submission.companyId !== current.companyId) {
throw new ValidationError('Submission does not belong to this invoice')
}
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(submission.status)) throw new ConflictError('Evidence is not awaiting review')
if (submission.method !== data.method) throw new ValidationError('Submission method does not match the confirmation method')
if (!submission.documents.length || submission.documents.some((document: any) => document.scanStatus !== 'CLEAN')) {
throw new ValidationError('Every attached evidence document must be clean')
}
if (submission.normalizedSubmittedReference !== normalizedReference && !data.correctionReason) {
throw new ValidationError('A correction reason is required when the confirmed reference differs from the submitted reference')
}
const confirmedAt = new Date()
const intent = await tx.billingPaymentIntent.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.currency,
metadata: { source: 'admin_manual_subscription_confirmation', method: data.method },
},
})
const attempt = await tx.billingPaymentAttempt.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
paymentIntentId: intent.id,
channel: 'OFFLINE',
manualMethod: data.method,
externalReference: data.externalReference,
normalizedExternalReference: normalizedReference,
receivedAt,
confirmedAt,
confirmedByAdminId: adminId,
idempotencyKey: data.idempotencyKey,
note: data.note ?? null,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.currency,
attemptedAt: confirmedAt,
metadata: {
source: 'admin_manual_subscription_confirmation',
submissionId: submission.id,
confirmationRequestHash: hash,
correctionReason: data.correctionReason ?? null,
fundsVerified: true,
},
},
})
const paid = await tx.billingInvoice.updateMany({
where: { id: current.id, status: { in: PAYABLE_STATUSES }, amountDue: data.amount },
data: { status: 'PAID', amountPaid: { increment: data.amount }, amountDue: 0, paidAt: confirmedAt },
})
if (paid.count !== 1) throw new ConflictError('Invoice changed while payment was being confirmed')
await tx.manualPaymentSubmission.update({
where: { id: submission.id },
data: {
status: 'APPROVED',
reviewedByAdminId: adminId,
reviewedAt: confirmedAt,
paymentAttemptId: attempt.id,
},
})
if (current.legacySubscriptionInvoice) {
await tx.subscriptionInvoice.update({
where: { id: current.legacySubscriptionInvoice.id },
data: { status: 'PAID', paidAt: confirmedAt, failedAt: null },
})
}
if (!current.subscription) throw new ValidationError('Subscription invoice is missing its subscription')
const period = (current.requestedBillingPeriod ?? current.subscription.billingPeriod) as 'MONTHLY' | 'ANNUAL'
const isRenewal = current.invoiceType === 'SUBSCRIPTION_RENEWAL'
const periodStart = isRenewal
? (current.collectionsCase?.originalExpirationAt ?? current.subscription.currentPeriodEnd ?? confirmedAt)
: confirmedAt
await tx.subscription.update({
where: { id: current.subscription.id },
data: {
plan: current.requestedPlan ?? current.subscription.plan,
billingPeriod: period,
currency: current.currency,
status: 'ACTIVE',
currentPeriodStart: periodStart,
currentPeriodEnd: addBillingPeriod(periodStart, period),
paymentPendingSince: null,
paymentDueAt: null,
pastDueSince: null,
suspendedAt: null,
retryCount: 0,
},
})
if (current.collectionsCase) {
collectionsCaseId = current.collectionsCase.id
await tx.collectionsCase.update({
where: { id: current.collectionsCase.id },
data: {
status: 'RESOLVED',
resolvedAt: confirmedAt,
nextActionAt: null,
resolutionPaymentAttemptId: attempt.id,
},
})
await tx.collectionsCallTask.updateMany({
where: { collectionsCaseId: current.collectionsCase.id, status: 'OPEN' },
data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' },
})
await tx.collectionsEvent.create({
data: {
collectionsCaseId: current.collectionsCase.id,
companyId: current.companyId,
eventType: 'collections.resolved',
idempotencyKey: `payment:${attempt.id}`,
actorType: 'admin',
actorId: adminId,
payload: { paymentAttemptId: attempt.id },
},
})
}
await tx.billingEvent.create({
data: {
billingAccountId: current.billingAccountId,
invoiceId: current.id,
subscriptionId: current.subscription.id,
companyId: current.companyId,
eventType: 'invoice.paid',
source: 'admin',
payload: { paymentAttemptId: attempt.id, method: data.method, submissionId: submission.id },
occurredAt: confirmedAt,
},
})
await tx.subscriptionEvent.create({
data: {
subscriptionId: current.subscription.id,
companyId: current.companyId,
eventType: 'subscription.activated',
source: 'admin',
payload: { invoiceId: current.id, paymentAttemptId: attempt.id },
occurredAt: confirmedAt,
},
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'CONFIRM_MANUAL_SUBSCRIPTION_PAYMENT',
resource: 'BillingInvoice',
resourceId: current.id,
companyId: current.companyId,
before: { status: current.status, amountDue: current.amountDue },
after: { status: 'PAID', amountDue: 0, paymentAttemptId: attempt.id, method: data.method },
note: data.note,
ipAddress: ip,
},
})
const invoice = await tx.billingInvoice.findUniqueOrThrow({
where: { id: current.id },
include: { paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, manualPaymentSubmissions: { include: { documents: true } } },
})
return { invoice, paymentAttempt: attempt, duplicate: false }
}, { isolationLevel: 'Serializable' as any })
if (collectionsCaseId) {
await prisma.notificationOutbox.updateMany({
where: {
status: 'PENDING',
notificationEvent: { sourceType: 'collections_case', sourceId: collectionsCaseId },
},
data: { status: 'PUBLISHED', failureReason: 'Suppressed because payment was confirmed' },
})
}
await notifyPaymentResult({
billingAccountId: result.invoice.billingAccountId,
companyId: result.invoice.companyId,
invoiceId: result.invoice.id,
invoiceNumber: result.invoice.invoiceNumber,
kind: 'confirmed',
sourceId: result.paymentAttempt.id,
})
return result
} catch (error: any) {
if (error?.code === 'P2002' || error?.code === 'P2034') {
throw new ConflictError('The payment reference, idempotency key, or invoice was confirmed concurrently')
}
throw error
}
}
+4
View File
@@ -345,6 +345,7 @@ export function createAdmin(data: {
firstName: string
lastName: string
role: string
preferredLocale: string
passwordHash: string
permissions?: any[]
}) {
@@ -354,6 +355,7 @@ export function createAdmin(data: {
firstName: data.firstName,
lastName: data.lastName,
role: data.role as any,
preferredLocale: data.preferredLocale,
passwordHash: data.passwordHash,
permissions: data.permissions ? { create: data.permissions } : undefined,
},
@@ -372,6 +374,7 @@ export function updateAdmin(
firstName?: string
lastName?: string
role?: string
preferredLocale?: string
passwordHash?: string
isActive?: boolean
},
@@ -383,6 +386,7 @@ export function updateAdmin(
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
...(data.role !== undefined ? { role: data.role as any } : {}),
...(data.preferredLocale !== undefined ? { preferredLocale: data.preferredLocale } : {}),
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
},
+111
View File
@@ -6,6 +6,9 @@ import { setSessionCookie, clearSessionCookie } from '../../security/sessionCook
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.service'
import * as manualPaymentsService from './admin.manual-payments.service'
import * as collectionsService from '../subscriptions/subscription.collections.service'
import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
@@ -17,9 +20,14 @@ import {
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
platformBillingSettingsSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
} from './admin.schemas'
import { z } from 'zod'
@@ -279,6 +287,17 @@ router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), asyn
} catch (err) { next(err) }
})
router.get('/notifications/me', requireAdminAuth, async (req, res, next) => {
try { ok(res, await getAdminNotificationInbox(req.admin.id)) } catch (err) { next(err) }
})
router.post('/notifications/me/:id/read', requireAdminAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await markAdminNotificationRead(req.admin.id, id))
} catch (err) { next(err) }
})
// ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
@@ -344,6 +363,98 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol
} catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { ok(res, await manualPaymentsService.listManualPaymentSubmissions(parseQuery(manualPaymentSubmissionsQuerySchema, req))) } catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions/:submissionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
ok(res, await manualPaymentsService.getManualPaymentSubmission(submissionId, req.admin.id))
} catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions/:submissionId/documents/:documentId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(manualPaymentDocumentParamsSchema, req)
const { document, bytes } = await manualPaymentsService.getAdminPaymentDocument(submissionId, documentId, req.admin.id, req.ip)
const safeName = document.originalFilename.replace(/["\\\r\n]/g, '_')
res.setHeader('Content-Type', document.detectedMimeType)
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`)
res.setHeader('Content-Length', bytes.length)
res.setHeader('Cache-Control', 'private, no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.end(bytes)
} catch (err) { next(err) }
})
router.post('/billing/manual-payment-submissions/:submissionId/reject', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
const { reason } = parseBody(rejectManualPaymentSubmissionSchema, req)
ok(res, await manualPaymentsService.rejectManualPaymentSubmission(submissionId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/manual-payments', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await manualPaymentsService.confirmManualPayment(invoiceId, parseBody(confirmManualPaymentSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/collections', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { ok(res, await collectionsService.listCollectionsCases(parseQuery(collectionsQuerySchema, req))) } catch (err) { next(err) }
})
router.get('/billing/collections/:caseId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
ok(res, await collectionsService.getCollectionsCase(caseId))
} catch (err) { next(err) }
})
router.patch('/billing/collections/:caseId/assignee', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
const { adminId } = parseBody(collectionsAssigneeSchema, req)
ok(res, await collectionsService.assignCollectionsCase(caseId, adminId, req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collection-tasks/:taskId/outcomes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { taskId } = parseParams(collectionTaskIdParamSchema, req)
ok(res, await collectionsService.recordCallOutcome(taskId, parseBody(collectionTaskOutcomeSchema, req), req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collections/:caseId/overrides', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
ok(res, await collectionsService.createCollectionsOverride(caseId, parseBody(collectionsOverrideSchema, req), req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collections/:caseId/overrides/:overrideId/revoke', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { caseId, overrideId } = parseParams(collectionsOverrideParamsSchema, req)
ok(res, await collectionsService.revokeCollectionsOverride(caseId, overrideId, req.admin.id))
} catch (err) { next(err) }
})
router.get('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
try {
ok(res, await service.getBillingPlatformSettings())
} catch (err) { next(err) }
})
router.patch('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.updateBillingPlatformSettings(parseBody(platformBillingSettingsSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
+81 -1
View File
@@ -87,6 +87,7 @@ export const createAdminSchema = z.object({
firstName: z.string().min(1).max(100).trim(),
lastName: z.string().min(1).max(100).trim(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
preferredLocale: z.enum(['ar', 'en', 'fr']).default('en'),
password: z.string().min(8),
permissions: z.array(permissionSchema).optional(),
})
@@ -96,6 +97,7 @@ export const updateAdminSchema = z.object({
firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(100).trim().optional(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
preferredLocale: z.enum(['ar', 'en', 'fr']).optional(),
password: z.string().min(8).optional(),
isActive: z.boolean().optional(),
})
@@ -173,7 +175,7 @@ export const adminCompanyUpdateSchema = z.object({
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
terms: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
lateFeePerHour: z.number().int().nullable().optional(), taxRate: z.number().nullable().optional(),
lateFeePerHour: z.number().int().nullable().optional(),
signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
}).optional(),
accountingSettings: z.object({
@@ -243,6 +245,10 @@ export const billingAccountUpdateSchema = z.object({
netTermsDays: z.number().int().min(0).max(365).optional(),
})
export const platformBillingSettingsSchema = z.object({
taxRate: z.number().min(0).max(100),
})
export const billingLineItemInputSchema = z.object({
type: z.enum([
'SUBSCRIPTION_FEE',
@@ -292,6 +298,80 @@ export const payBillingInvoiceSchema = z.object({
providerPaymentId: z.union([z.string(), z.null()]).optional(),
})
const manualPaymentReferenceSchema = z.string()
.trim()
.min(3)
.max(120)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters')
export const manualPaymentSubmissionIdParamSchema = z.object({ submissionId: z.string().min(1) })
export const manualPaymentDocumentParamsSchema = z.object({
submissionId: z.string().min(1),
documentId: z.string().min(1),
})
export const manualPaymentSubmissionsQuerySchema = z.object({
status: z.enum(['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED']).default('SUBMITTED'),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const rejectManualPaymentSubmissionSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const confirmManualPaymentSchema = z.object({
submissionId: z.string().min(1),
method: z.enum(['BANK_TRANSFER', 'CHECK']),
externalReference: manualPaymentReferenceSchema,
amount: z.number().int().positive(),
receivedAt: z.string().datetime(),
note: z.string().trim().max(500).optional(),
correctionReason: z.string().trim().min(3).max(500).optional(),
idempotencyKey: z.string().uuid(),
fundsVerified: z.literal(true),
})
export const collectionsQuerySchema = z.object({
status: z.enum(['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED']).optional(),
assignedTo: z.string().optional(),
actionDueBefore: z.string().datetime().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const collectionsCaseIdParamSchema = z.object({ caseId: z.string().min(1) })
export const collectionTaskIdParamSchema = z.object({ taskId: z.string().min(1) })
export const collectionsOverrideParamsSchema = z.object({ caseId: z.string().min(1), overrideId: z.string().min(1) })
export const collectionsAssigneeSchema = z.object({ adminId: z.string().min(1) })
export const collectionTaskOutcomeSchema = z.object({
outcome: z.enum(['CONTACTED', 'NO_ANSWER', 'PAYMENT_PROMISED', 'ISSUE_ESCALATED']),
note: z.string().trim().max(500).optional(),
promisedPaymentAt: z.string().datetime().optional(),
nextFollowUpAt: z.string().datetime().optional(),
}).superRefine((data, ctx) => {
if (['NO_ANSWER', 'ISSUE_ESCALATED'].includes(data.outcome) && !data.note) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['note'], message: 'A note is required for this outcome' })
}
if (data.outcome === 'PAYMENT_PROMISED' && (!data.promisedPaymentAt || !data.nextFollowUpAt)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['promisedPaymentAt'], message: 'Promised payment and follow-up times are required' })
}
})
export const collectionsOverrideSchema = z.object({
type: z.enum(['PAYMENT_DISPUTE', 'MANUAL_EXTENSION']),
reason: z.string().trim().min(3).max(500),
expiresAt: z.string().datetime(),
revisedSuspensionAt: z.string().datetime().optional(),
pauseSuspension: z.boolean().default(true),
pauseNotifications: z.boolean().default(false),
}).superRefine((data, ctx) => {
if (data.type === 'MANUAL_EXTENSION' && !data.revisedSuspensionAt) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['revisedSuspensionAt'], message: 'A manual extension requires a revised suspension time' })
}
})
export const retryBillingInvoiceSchema = z.object({
paymentMethodId: z.union([z.string(), z.null()]).optional(),
})
@@ -3,8 +3,10 @@ import bcrypt from 'bcryptjs'
vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(),
findAdminByIdOrThrow: vi.fn(),
setAdminPasswordReset: vi.fn(),
updateAdminLastLogin: vi.fn(),
updateAdminTotpSecret: vi.fn(),
createAuditLog: vi.fn(),
}))
@@ -12,6 +14,10 @@ vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,test') },
}))
const redisStore = new Map<string, string>()
vi.mock('../../lib/redis', () => ({
@@ -33,7 +39,7 @@ vi.mock('../../lib/redis', () => ({
import * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService'
import { forgotPassword, login } from './admin.service'
import { forgotPassword, login, setupTotp } from './admin.service'
describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL
@@ -123,4 +129,20 @@ describe('admin.service forgotPassword', () => {
}))
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3')
})
it('reuses a pending TOTP setup secret so duplicate dev setup calls keep codes valid', async () => {
vi.mocked(repo.findAdminByIdOrThrow).mockResolvedValue({
id: 'admin_4',
email: 'admin4@example.test',
totpEnabled: false,
totpSecret: 'JBSWY3DPEHPK3PXP',
} as any)
const first = await setupTotp('admin_4', 'admin4@example.test')
const second = await setupTotp('admin_4', 'admin4@example.test')
expect(first.secret).toBe('JBSWY3DPEHPK3PXP')
expect(second.secret).toBe('JBSWY3DPEHPK3PXP')
expect(repo.updateAdminTotpSecret).not.toHaveBeenCalled()
})
})
+22 -3
View File
@@ -171,8 +171,13 @@ export async function login(email: string, password: string, totpCode?: string,
}
export async function setupTotp(adminId: string, email: string) {
const secret = authenticator.generateSecret()
await repo.updateAdminTotpSecret(adminId, secret)
const admin = await repo.findAdminByIdOrThrow(adminId)
const secret = admin.totpSecret && !admin.totpEnabled
? admin.totpSecret
: authenticator.generateSecret()
if (secret !== admin.totpSecret) {
await repo.updateAdminTotpSecret(adminId, secret)
}
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode }
@@ -335,7 +340,7 @@ export async function listAdmins() {
return admins.map((admin: any) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; preferredLocale: string; password: string; permissions?: any[] }) {
const admin = await repo.createAdmin({
...body,
passwordHash: await bcrypt.hash(body.password, 12),
@@ -350,6 +355,7 @@ export async function updateAdmin(
firstName?: string
lastName?: string
role?: string
preferredLocale?: string
password?: string
isActive?: boolean
},
@@ -359,6 +365,7 @@ export async function updateAdmin(
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
...(body.role !== undefined ? { role: body.role } : {}),
...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
})
@@ -406,6 +413,18 @@ export function updateBillingAccount(
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
}
export function getBillingPlatformSettings() {
return billingService.getBillingPlatformSettings()
}
export function updateBillingPlatformSettings(
data: Parameters<typeof billingService.updateBillingPlatformSettings>[0],
adminId: string,
ip?: string,
) {
return billingService.updateBillingPlatformSettings(data, adminId, ip)
}
export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
}
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas'
@@ -126,8 +126,7 @@ export async function startAccount(body: AccountStartInput) {
text: emailTexts[lang],
}).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message ?? String(err))
console.error('[AccountStart] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set', '| pass:', process.env.MAIL_PASSWORD ? '***' : 'not set')
console.error('[AccountStart] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
console.error('[AccountStart] Email provider config:', describeEmailProviderConfig())
})
return {
@@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken'
import { signActorToken } from '../../security/tokens'
import { AppError } from '../../http/errors'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
import { presentEmployeeSession } from './auth.presenter'
import * as repo from './auth.employee.repo'
@@ -137,7 +137,7 @@ export async function forgotPassword(email: string) {
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
}).catch((err) => {
console.error('[ForgotPassword] Email delivery failed:', err?.message)
console.error('[ForgotPassword] Provider config — resendKey present:', !!process.env.RESEND_API_KEY, '| smtpHost:', process.env.MAIL_HOST ?? 'not set')
console.error('[ForgotPassword] Email provider config:', describeEmailProviderConfig())
})
}
@@ -230,8 +230,7 @@ export async function resendVerification(email: string) {
text: `Verify your email by visiting:\n${verifyUrl}`,
}).catch((err) => {
console.error('[ResendVerification] Email delivery failed:', err?.message ?? String(err))
console.error('[ResendVerification] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set')
console.error('[ResendVerification] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
console.error('[ResendVerification] Email provider config:', describeEmailProviderConfig())
})
return { message: 'A new verification link has been sent to your email.' }
@@ -24,7 +24,6 @@ describe('company schemas edge cases', () => {
fuelPolicyType: 'FULL_TO_FULL',
additionalDriverPolicy: 'Additional drivers must be approved before pickup.',
additionalDriverCharge: 'PER_DAY',
taxRate: 20,
})).toMatchObject({
fuelPolicy: 'Return with the same fuel level.',
fuelPolicyType: 'FULL_TO_FULL',
@@ -111,7 +111,6 @@ export const contractSettingsSchema = z.object({
invoiceFooterNote: z.string().optional(),
signatureRequired: z.boolean().optional(),
showTax: z.boolean().optional(),
taxRate: z.number().optional(),
taxLabel: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
fuelPolicyNote: z.string().optional(),
@@ -1,6 +1,7 @@
import { prisma } from '../../lib/prisma'
import * as repo from './reservation.repo'
import { parseReservationExtras, serializeContractFields } from './reservation.presenter'
import { calculateTaxAmount, getPlatformBillingSettings } from '../subscriptions/billingTax'
export function formatDocumentNumber(prefix: string, sequence: number): string {
return `${prefix}-${String(sequence).padStart(6, '0')}`
@@ -134,8 +135,10 @@ export async function getContract(id: string, companyId: string) {
})
const subtotal = invoiceLineItems.reduce((s, i) => s + i.total, 0)
const taxes = contractSettings.showTax && contractSettings.taxRate
? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: contractSettings.taxRate, amount: Math.round(subtotal * contractSettings.taxRate / 100) }]
const platformBillingSettings = await getPlatformBillingSettings()
const tax = calculateTaxAmount(subtotal, false, platformBillingSettings.taxRate)
const taxes = contractSettings.showTax && tax.taxAmount > 0
? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: tax.taxRate, amount: tax.taxAmount }]
: []
const taxTotal = taxes.reduce((s, t) => s + t.amount, 0)
const grandTotal = subtotal + taxTotal
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from 'vitest'
import { calculateTaxAmount, getConfiguredInvoiceTaxRate } from './billingTax'
describe('platform billing tax', () => {
const originalSubscriptionTaxRate = process.env.SUBSCRIPTION_TAX_RATE
const originalInvoiceTaxRate = process.env.INVOICE_TAX_RATE
afterEach(() => {
if (originalSubscriptionTaxRate === undefined) delete process.env.SUBSCRIPTION_TAX_RATE
else process.env.SUBSCRIPTION_TAX_RATE = originalSubscriptionTaxRate
if (originalInvoiceTaxRate === undefined) delete process.env.INVOICE_TAX_RATE
else process.env.INVOICE_TAX_RATE = originalInvoiceTaxRate
})
it('uses one platform tax rate for all accounts', () => {
process.env.SUBSCRIPTION_TAX_RATE = '18'
process.env.INVOICE_TAX_RATE = '9'
expect(getConfiguredInvoiceTaxRate()).toBe(18)
expect(calculateTaxAmount(10_000, false, getConfiguredInvoiceTaxRate())).toEqual({
priceBeforeTax: 10_000,
taxRate: 18,
taxAmount: 1_800,
totalAmount: 11_800,
})
})
it('falls back to the default rate and respects tax exemptions', () => {
process.env.SUBSCRIPTION_TAX_RATE = 'not-a-rate'
delete process.env.INVOICE_TAX_RATE
expect(getConfiguredInvoiceTaxRate()).toBe(20)
expect(calculateTaxAmount(10_000, true)).toEqual({
priceBeforeTax: 10_000,
taxRate: 0,
taxAmount: 0,
totalAmount: 10_000,
})
})
})
@@ -0,0 +1,55 @@
import { prisma } from '../../lib/prisma'
const DEFAULT_INVOICE_TAX_RATE = 20
const PLATFORM_BILLING_SETTINGS_ID = 'default'
function normalizeTaxRate(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100 ? parsed : DEFAULT_INVOICE_TAX_RATE
}
export function getConfiguredInvoiceTaxRate() {
const raw = process.env.SUBSCRIPTION_TAX_RATE ?? process.env.INVOICE_TAX_RATE
if (raw === undefined || raw.trim() === '') return DEFAULT_INVOICE_TAX_RATE
return normalizeTaxRate(raw)
}
export async function getPlatformBillingSettings() {
const settings = await prisma.platformBillingSettings.findUnique({
where: { id: PLATFORM_BILLING_SETTINGS_ID },
})
return {
id: PLATFORM_BILLING_SETTINGS_ID,
taxRate: normalizeTaxRate(settings?.taxRate ?? getConfiguredInvoiceTaxRate()),
updatedAt: settings?.updatedAt ?? null,
updatedBy: settings?.updatedBy ?? null,
}
}
export async function updatePlatformBillingSettings(data: { taxRate: number; updatedBy?: string | null }) {
const taxRate = normalizeTaxRate(data.taxRate)
return prisma.platformBillingSettings.upsert({
where: { id: PLATFORM_BILLING_SETTINGS_ID },
create: {
id: PLATFORM_BILLING_SETTINGS_ID,
taxRate,
updatedBy: data.updatedBy ?? null,
},
update: {
taxRate,
updatedBy: data.updatedBy ?? null,
},
})
}
export function calculateTaxAmount(priceBeforeTax: number, taxExempt?: boolean, platformTaxRate = getConfiguredInvoiceTaxRate()) {
const taxRate = taxExempt ? 0 : normalizeTaxRate(platformTaxRate)
const taxAmount = taxRate > 0 ? Math.round(priceBeforeTax * (taxRate / 100)) : 0
return {
priceBeforeTax,
taxRate,
taxAmount,
totalAmount: priceBeforeTax + taxAmount,
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { calculateCollectionsSchedule } from './subscription.collections.service'
function localParts(date: Date, timeZone: string) {
return new Intl.DateTimeFormat('en-CA', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).format(date)
}
describe('timezone-safe subscription collections schedule', () => {
it('preserves the local expiration wall time across a DST transition', () => {
const timeZone = 'America/New_York'
const expiration = new Date('2026-03-01T15:30:00.000Z') // 10:30 local, before DST
const schedule = calculateCollectionsSchedule(expiration, timeZone, '09:00')
expect(localParts(schedule.finalSuspensionAt, timeZone)).toContain('2026-03-31, 10:30')
})
it('uses local reminder time for day milestones and exact time for 48/24 hours', () => {
const expiration = new Date('2026-09-30T17:00:00.000Z')
const schedule = calculateCollectionsSchedule(expiration, 'Africa/Casablanca', '09:00')
expect(localParts(schedule.reminder14At, 'Africa/Casablanca')).toContain('2026-09-16, 09:00')
expect(expiration.getTime() - schedule.reminder48At.getTime()).toBe(48 * 60 * 60 * 1000)
expect(expiration.getTime() - schedule.reminder24At.getTime()).toBe(24 * 60 * 60 * 1000)
})
it('rejects fixed offsets and invalid timezones', () => {
expect(() => calculateCollectionsSchedule(new Date(), 'UTC+01:00')).toThrow(/IANA timezone/i)
expect(() => calculateCollectionsSchedule(new Date(), 'Mars/Olympus')).toThrow(/IANA timezone/i)
})
})
@@ -0,0 +1,680 @@
import crypto from 'crypto'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezonePlugin from 'dayjs/plugin/timezone'
import { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../../lib/prisma'
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '../../http/errors'
import { sendNotification } from '../../services/notificationService'
import {
coerceNotificationLocale,
formatLocalizedCurrency,
formatLocalizedDate,
type NotificationLocale,
} from '../../services/notificationLocalizationService'
import {
addBillingPeriod,
calculateTaxForAccount,
ensurePrimaryBillingAccount,
isValidIanaTimezone,
taxLineItem,
taxRecordCreate,
} from './subscription.manual.service'
import { getPlatformBillingSettings } from './billingTax'
import { getPaymentOptions } from './subscription.payment-config'
dayjs.extend(utc)
dayjs.extend(timezonePlugin)
type CollectionsMilestone = 'DUE_14D' | 'DUE_7D' | 'DUE_48H' | 'DUE_24H' | 'GRACE_DAILY' | 'GRACE_FINAL'
const typeByMilestone: Record<CollectionsMilestone, any> = {
DUE_14D: 'SUBSCRIPTION_PAYMENT_DUE_14D',
DUE_7D: 'SUBSCRIPTION_PAYMENT_DUE_7D',
DUE_48H: 'SUBSCRIPTION_PAYMENT_DUE_48H',
DUE_24H: 'SUBSCRIPTION_PAYMENT_DUE_24H',
GRACE_DAILY: 'SUBSCRIPTION_GRACE_DAILY',
GRACE_FINAL: 'SUBSCRIPTION_GRACE_FINAL',
}
const templateByMilestone: Record<CollectionsMilestone, string> = {
DUE_14D: 'subscription.payment_due_14d.v1',
DUE_7D: 'subscription.payment_due_7d.v1',
DUE_48H: 'subscription.payment_due_48h.v1',
DUE_24H: 'subscription.payment_due_24h.v1',
GRACE_DAILY: 'subscription.grace_daily.v1',
GRACE_FINAL: 'subscription.grace_final.v1',
}
const labels: Record<NotificationLocale, {
titles: Record<CollectionsMilestone, string>
due: (invoice: string, amount: string, expiration: string) => string
grace: (invoice: string, amount: string, day: number, remaining: number, suspension: string) => string
action: string
callScript: (company: string, invoice: string, amount: string, expiration: string) => string
}> = {
en: {
titles: { DUE_14D: 'Subscription payment due in 14 days', DUE_7D: 'Subscription payment due in 7 days', DUE_48H: 'Payment due in 48 hours', DUE_24H: 'Final expiration warning', GRACE_DAILY: 'Subscription payment overdue', GRACE_FINAL: 'Final warning before suspension' },
due: (invoice, amount, expiration) => `Invoice ${invoice} for ${amount} remains unpaid. Your current subscription period expires on ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `Invoice ${invoice} for ${amount} is overdue. Grace day ${day}; ${remaining} day(s) remain. Service is scheduled for suspension on ${suspension} unless payment is confirmed.`,
action: 'Open Subscription in the dashboard to pay by Stripe or view the configured bank/check instructions.',
callScript: (company, invoice, amount, expiration) => `Hello, I am calling RentalDriveGo regarding ${company}'s invoice ${invoice} for ${amount}. The subscription expires on ${expiration}. Can I help confirm the payment plan?`,
},
fr: {
titles: { DUE_14D: 'Paiement de labonnement dû dans 14 jours', DUE_7D: 'Paiement de labonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de labonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' },
due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période dabonnement actuelle expire le ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `La facture ${invoice} de ${amount} est en retard. Jour de grâce ${day} ; il reste ${remaining} jour(s). Le service sera suspendu le ${suspension} si le paiement nest pas confirmé.`,
action: 'Ouvrez Abonnement dans le tableau de bord pour payer par Stripe ou consulter les instructions de virement/chèque.',
callScript: (company, invoice, amount, expiration) => `Bonjour, je vous appelle de RentalDriveGo au sujet de la facture ${invoice} de ${company}, dun montant de ${amount}. Labonnement expire le ${expiration}. Puis-je vous aider à confirmer le mode de règlement ?`,
},
ar: {
titles: { DUE_14D: 'استحقاق دفع الاشتراك خلال 14 يوماً', DUE_7D: 'استحقاق دفع الاشتراك خلال 7 أيام', DUE_48H: 'استحقاق الدفع خلال 48 ساعة', DUE_24H: 'التحذير الأخير قبل انتهاء الاشتراك', GRACE_DAILY: 'دفع الاشتراك متأخر', GRACE_FINAL: 'التحذير الأخير قبل تعليق الخدمة' },
due: (invoice, amount, expiration) => `لا تزال الفاتورة ${invoice} بمبلغ ${amount} غير مدفوعة. تنتهي فترة الاشتراك الحالية في ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `الفاتورة ${invoice} بمبلغ ${amount} متأخرة. يوم السماح ${day}، ويتبقى ${remaining} يوم. ستُعلّق الخدمة في ${suspension} ما لم يتم تأكيد الدفع.`,
action: 'افتح صفحة الاشتراك في لوحة التحكم للدفع عبر Stripe أو لعرض تعليمات التحويل البنكي أو الشيك.',
callScript: (company, invoice, amount, expiration) => `مرحباً، أتصل بكم من RentalDriveGo بخصوص فاتورة شركة ${company} رقم ${invoice} بمبلغ ${amount}. ينتهي الاشتراك في ${expiration}. هل يمكنني مساعدتكم في تأكيد خطة الدفع؟`,
},
}
const suspensionCopy: Record<NotificationLocale, { title: string; body: (invoice: string) => string }> = {
en: { title: 'Subscription suspended', body: (invoice) => `Your subscription was suspended because invoice ${invoice} remains unpaid after the grace period. Payment confirmation restores access.` },
fr: { title: 'Abonnement suspendu', body: (invoice) => `Votre abonnement a été suspendu, car la facture ${invoice} reste impayée après le délai de grâce. La confirmation du paiement rétablit laccès.` },
ar: { title: 'تم تعليق الاشتراك', body: (invoice) => `تم تعليق اشتراكك لأن الفاتورة ${invoice} ما زالت غير مدفوعة بعد فترة السماح. يؤدي تأكيد الدفع إلى استعادة الوصول.` },
}
const overrideCopy: Record<NotificationLocale, { title: string; created: (invoice: string, until: string) => string; revoked: (invoice: string) => string }> = {
en: { title: 'Subscription payment schedule updated', created: (invoice, until) => `A temporary collections override was applied to invoice ${invoice} through ${until}.`, revoked: (invoice) => `The temporary collections override for invoice ${invoice} was revoked. The standard schedule applies again.` },
fr: { title: 'Calendrier de paiement de labonnement mis à jour', created: (invoice, until) => `Une dérogation temporaire de recouvrement a été appliquée à la facture ${invoice} jusquau ${until}.`, revoked: (invoice) => `La dérogation temporaire concernant la facture ${invoice} a été révoquée. Le calendrier standard sapplique à nouveau.` },
ar: { title: 'تم تحديث جدول دفع الاشتراك', created: (invoice, until) => `تم تطبيق استثناء مؤقت للتحصيل على الفاتورة ${invoice} حتى ${until}.`, revoked: (invoice) => `تم إلغاء استثناء التحصيل المؤقت للفاتورة ${invoice}. عاد جدول المتابعة المعتاد للتطبيق.` },
}
const configurationCopy: Record<NotificationLocale, { title: string; body: (company: string) => string }> = {
en: { title: 'Collections configuration required', body: (company) => `${company} cannot be auto-suspended until its billing timezone, verified recipient, and collections owner configuration are valid.` },
fr: { title: 'Configuration du recouvrement requise', body: (company) => `${company} ne peut pas être suspendue automatiquement tant que le fuseau de facturation, le destinataire vérifié et le responsable du recouvrement ne sont pas valides.` },
ar: { title: 'إعدادات التحصيل مطلوبة', body: (company) => `لا يمكن تعليق ${company} تلقائياً حتى تصبح المنطقة الزمنية للفوترة وجهة الاتصال الموثقة ومسؤول التحصيل صالحة.` },
}
function flag(name: string) {
return process.env[name] === 'true'
}
function localDate(value: Date, timezone: string) {
return dayjs(value).tz(timezone).format('YYYY-MM-DD')
}
function atLocalTime(date: Date, timezone: string, localTime: string) {
const datePart = dayjs(date).tz(timezone).format('YYYY-MM-DD')
return dayjs.tz(`${datePart} ${localTime}`, timezone).toDate()
}
function addCalendarDays(value: Date, days: number, timezone: string) {
const local = dayjs(value).tz(timezone)
return dayjs.tz(local.add(days, 'day').format('YYYY-MM-DD HH:mm:ss.SSS'), timezone).toDate()
}
export function calculateCollectionsSchedule(expirationAt: Date, timezone: string, reminderLocalTime = '09:00') {
if (!isValidIanaTimezone(timezone)) throw new ValidationError('A valid IANA timezone is required')
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderLocalTime)) throw new ValidationError('Reminder local time must use HH:mm')
const expiry = dayjs(expirationAt).tz(timezone)
const reminderForDaysBefore = (days: number) => atLocalTime(expiry.subtract(days, 'day').toDate(), timezone, reminderLocalTime)
return {
originalExpirationAt: new Date(expirationAt),
reminder14At: reminderForDaysBefore(14),
reminder7At: reminderForDaysBefore(7),
reminder48At: new Date(expirationAt.getTime() - 48 * 60 * 60 * 1000),
reminder24At: new Date(expirationAt.getTime() - 24 * 60 * 60 * 1000),
finalSuspensionAt: addCalendarDays(expirationAt, 30, timezone),
}
}
function formatForCase(value: Date, locale: NotificationLocale, timezone: string) {
return formatLocalizedDate(value, locale, {
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: timezone,
})
}
function customerMessage(caseData: any, milestone: CollectionsMilestone, locale: NotificationLocale, graceDay?: number) {
const copy = labels[locale]
const amount = formatLocalizedCurrency(caseData.invoice.amountDue, caseData.invoice.currency, locale)
const expiry = formatForCase(caseData.originalExpirationAt, locale, caseData.billingAccount.timezone)
const suspension = formatForCase(caseData.finalSuspensionAt, locale, caseData.billingAccount.timezone)
const core = milestone === 'GRACE_DAILY' || milestone === 'GRACE_FINAL'
? copy.grace(caseData.invoice.invoiceNumber ?? caseData.invoice.id, amount, graceDay ?? 1, Math.max(0, 30 - (graceDay ?? 1)), suspension)
: copy.due(caseData.invoice.invoiceNumber ?? caseData.invoice.id, amount, expiry)
return { title: copy.titles[milestone], body: `${core}\n\n${copy.action}`, amount, expiry, suspension }
}
function effectiveContactLocale(contact: any, account: any): NotificationLocale {
const enabled = account.enabledCommunicationLocales as string[]
const employeeLocale = contact.employee?.preferredLanguage
return coerceNotificationLocale(
contact.locale && enabled.includes(contact.locale)
? contact.locale
: employeeLocale && enabled.includes(employeeLocale)
? employeeLocale
: account.defaultCommunicationLocale,
)
}
async function sendOverrideChangedNotification(caseId: string, overrideId: string, action: 'created' | 'revoked') {
const caseData = await prisma.collectionsCase.findUnique({
where: { id: caseId },
include: {
invoice: true,
billingAccount: { include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } } },
overrides: { where: { id: overrideId }, take: 1 },
},
}) as any
const override = caseData?.overrides?.[0]
if (!caseData || !override) return
for (const contact of caseData.billingAccount.billingContacts) {
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const copy = overrideCopy[locale]
const invoice = caseData.invoice.invoiceNumber ?? caseData.invoice.id
const until = formatForCase(override.expiresAt, locale, caseData.billingAccount.timezone)
await sendNotification({
type: 'COLLECTIONS_OVERRIDE_CHANGED',
title: copy.title,
body: action === 'created' ? copy.created(invoice, until) : copy.revoked(invoice),
companyId: caseData.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: `subscription.collections_override_${action}.v1`,
idempotencyKey: `collections:${caseData.invoiceId}:override:${overrideId}:${action}:${contact.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
data: { collectionsCaseId: caseData.id, invoiceId: caseData.invoiceId, overrideId, action, timezone: caseData.billingAccount.timezone, templateVersion: 1, localizationFallback: false },
policy: { mandatory: true },
})
}
}
async function sendMilestoneNotifications(caseData: any, milestone: CollectionsMilestone, eventKey: string, graceDay?: number) {
for (const contact of caseData.billingAccount.billingContacts) {
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const content = customerMessage(caseData, milestone, locale, graceDay)
await sendNotification({
type: typeByMilestone[milestone],
title: content.title,
body: content.body,
data: {
collectionsCaseId: caseData.id,
invoiceId: caseData.invoiceId,
invoiceNumber: caseData.invoice.invoiceNumber,
amountDue: caseData.invoice.amountDue,
currency: caseData.invoice.currency,
originalExpirationAt: caseData.originalExpirationAt,
finalSuspensionAt: caseData.finalSuspensionAt,
graceDay: graceDay ?? null,
timezone: caseData.billingAccount.timezone,
templateVersion: 1,
localizationFallback: false,
},
companyId: caseData.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: templateByMilestone[milestone],
idempotencyKey: `collections:${caseData.invoiceId}:${eventKey}:${contact.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
policy: { mandatory: true },
})
}
const admin = caseData.collectionsOwnerAdmin
if (admin) {
const emailMilestone = milestone === 'DUE_48H' || milestone === 'DUE_24H'
const internalLocale = coerceNotificationLocale(admin.preferredLocale)
const companyName = caseData.invoice.company.name
await sendNotification({
type: milestone === 'DUE_48H' ? 'COLLECTIONS_CALL_REQUIRED' : typeByMilestone[milestone],
title: `Collections: ${companyName}`,
body: `${caseData.invoice.invoiceNumber ?? caseData.invoice.id} · ${milestone} · ${caseData.billingAccount.defaultCommunicationLocale}`,
companyId: caseData.companyId,
adminUserId: admin.id,
channels: emailMilestone ? ['IN_APP', 'EMAIL'] : ['IN_APP'],
locale: internalLocale,
idempotencyKey: `collections:${caseData.invoiceId}:${eventKey}:admin:${admin.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
policy: { mandatory: true },
})
}
}
async function createRequiredCallTask(caseData: any) {
const contact = caseData.billingAccount.billingContacts.find((item: any) => item.isPrimary) ?? caseData.billingAccount.billingContacts[0]
if (!contact || !caseData.collectionsOwnerAdmin) return null
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const content = customerMessage(caseData, 'DUE_48H', locale)
return prisma.collectionsCallTask.upsert({
where: { collectionsCaseId_taskType: { collectionsCaseId: caseData.id, taskType: 'PRE_EXPIRY_48H_CALL' } },
update: {},
create: {
collectionsCaseId: caseData.id,
taskType: 'PRE_EXPIRY_48H_CALL',
assignedAdminId: caseData.collectionsOwnerAdmin.id,
billingContactId: contact.id,
dueAt: caseData.reminder48At,
companyDefaultLocale: caseData.billingAccount.defaultCommunicationLocale,
contactLocale: locale,
customerScript: labels[locale].callScript(caseData.invoice.company.name, caseData.invoice.invoiceNumber ?? caseData.invoice.id, content.amount, content.expiry),
},
})
}
async function getRenewalPrice(plan: string, billingPeriod: string) {
const configured = await prisma.pricingConfig.findUnique({ where: { plan_billingPeriod: { plan, billingPeriod } } })
return configured?.amount ?? (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
}
export async function ensureRenewalCollectionsCases(now = new Date()) {
if (!flag('SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED')) return 0
const horizon = new Date(now.getTime() + 21 * 24 * 60 * 60 * 1000)
const subscriptions = await prisma.subscription.findMany({
where: { status: 'ACTIVE', currentPeriodEnd: { not: null, lte: horizon, gt: new Date(now.getTime() - 31 * 24 * 60 * 60 * 1000) }, cancelAtPeriodEnd: false },
include: { company: { include: { employees: { where: { role: 'OWNER', isActive: true }, orderBy: { createdAt: 'asc' }, take: 1 } } } },
})
let createdCount = 0
for (const subscription of subscriptions as any[]) {
if (!subscription.currentPeriodEnd) continue
const owner = subscription.company.employees[0]
const account = await ensurePrimaryBillingAccount(subscription.companyId, owner?.id)
const schedule = calculateCollectionsSchedule(subscription.currentPeriodEnd, account.timezone, account.reminderLocalTime)
const renewalKey = `${subscription.id}:${subscription.currentPeriodEnd.toISOString()}`
const existing = await prisma.billingInvoice.findUnique({ where: { renewalKey }, include: { collectionsCase: true } })
if (existing?.collectionsCase) continue
const amount = await getRenewalPrice(subscription.plan, subscription.billingPeriod)
if (!Number.isInteger(amount) || amount <= 0) continue
const lastInvoice = await prisma.billingInvoice.findFirst({
where: { subscriptionId: subscription.id, status: 'PAID' },
orderBy: { paidAt: 'desc' },
select: { collectionMethod: true },
})
const enabledMethods = getPaymentOptions(coerceNotificationLocale(account.defaultCommunicationLocale)).methods.filter((item: any) => item.enabled).map((item: any) => item.method)
const collectionMethod = enabledMethods.includes(lastInvoice?.collectionMethod) ? lastInvoice!.collectionMethod : (enabledMethods[0] ?? 'STRIPE')
const assignee = account.collectionsOwnerAdminId
? await prisma.adminUser.findFirst({ where: { id: account.collectionsOwnerAdminId, isActive: true } })
: await prisma.adminUser.findFirst({ where: { isActive: true, role: { in: ['FINANCE', 'ADMIN', 'SUPER_ADMIN'] } }, orderBy: { createdAt: 'asc' } })
const ready = isValidIanaTimezone(account.timezone)
&& account.billingContacts.some((contact: any) => contact.isActive && contact.receivePaymentNotices && contact.verifiedAt)
&& Boolean(assignee)
const platformBillingSettings = await getPlatformBillingSettings()
await prisma.$transaction(async (tx: any) => {
let invoice = existing
if (!invoice) {
const tax = calculateTaxForAccount(amount, account, platformBillingSettings.taxRate)
const invoiceNumber = `INV-${now.getUTCFullYear()}-${crypto.randomUUID().slice(0, 8).toUpperCase()}`
invoice = await tx.billingInvoice.create({
data: {
billingAccountId: account.id,
companyId: subscription.companyId,
subscriptionId: subscription.id,
invoiceNumber,
invoiceType: 'SUBSCRIPTION_RENEWAL',
status: 'OPEN',
currency: subscription.currency,
subtotalAmount: amount,
taxAmount: tax.taxAmount,
totalAmount: tax.totalAmount,
amountDue: tax.totalAmount,
invoiceDate: now,
dueAt: subscription.currentPeriodEnd,
finalizedAt: now,
billingName: account.legalName,
billingEmail: account.billingEmail,
billingAddress: account.billingAddress ?? undefined,
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL',
collectionMethod,
requestedPlan: subscription.plan,
requestedBillingPeriod: subscription.billingPeriod,
renewalKey,
isSubscriptionBlocking: true,
metadata: { source: 'renewal_collections_worker' },
lineItems: {
create: [
{
subscriptionId: subscription.id,
plan: subscription.plan,
type: 'SUBSCRIPTION_FEE',
description: `${subscription.plan} subscription renewal — ${subscription.billingPeriod}`,
quantity: 1,
unitAmount: amount,
amount,
currency: subscription.currency,
periodStart: subscription.currentPeriodEnd,
periodEnd: addBillingPeriod(subscription.currentPeriodEnd, subscription.billingPeriod),
},
...taxLineItem(tax, subscription.currency),
],
},
...(taxRecordCreate(account, tax) ? { taxRecords: taxRecordCreate(account, tax) } : {}),
},
})
await tx.subscriptionInvoice.create({
data: {
companyId: subscription.companyId,
subscriptionId: subscription.id,
requestedPlan: subscription.plan,
requestedBillingPeriod: subscription.billingPeriod,
amount: tax.totalAmount,
currency: subscription.currency,
status: 'PENDING',
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL',
billingInvoiceId: invoice!.id,
dueAt: subscription.currentPeriodEnd,
},
})
}
if (!invoice) throw new Error('Renewal invoice creation failed')
await tx.collectionsCase.create({
data: {
invoiceId: invoice.id,
subscriptionId: subscription.id,
billingAccountId: account.id,
companyId: subscription.companyId,
status: now >= schedule.originalExpirationAt ? 'GRACE_PERIOD' : 'SCHEDULED',
...schedule,
graceStartedAt: now >= schedule.originalExpirationAt ? schedule.originalExpirationAt : null,
nextActionAt: ready ? schedule.reminder14At : null,
collectionsOwnerAdminId: assignee?.id ?? null,
},
})
})
createdCount += 1
}
return createdCount
}
function nextReminderAfter(caseData: any, now: Date) {
const candidates = [caseData.reminder14At, caseData.reminder7At, caseData.reminder48At, caseData.reminder24At, caseData.originalExpirationAt]
.map((value: Date | string) => new Date(value))
.filter((value: Date) => value > now)
.sort((a: Date, b: Date) => a.getTime() - b.getTime())
return candidates[0] ?? caseData.originalExpirationAt
}
async function recordCollectionsEvent(caseData: any, key: string, eventType: string, payload: Record<string, unknown> = {}) {
try {
await prisma.collectionsEvent.create({
data: { collectionsCaseId: caseData.id, companyId: caseData.companyId, eventType, idempotencyKey: key, payload: payload as any },
})
return true
} catch (error: any) {
if (error?.code === 'P2002') return false
throw error
}
}
async function processMilestone(caseData: any, milestone: CollectionsMilestone, key: string, graceDay?: number) {
const exists = await prisma.collectionsEvent.findUnique({ where: { collectionsCaseId_idempotencyKey: { collectionsCaseId: caseData.id, idempotencyKey: key } } })
if (exists) return false
await sendMilestoneNotifications(caseData, milestone, key, graceDay)
if (milestone === 'DUE_48H') await createRequiredCallTask(caseData)
return recordCollectionsEvent(caseData, key, `collections.${milestone.toLowerCase()}`, { graceDay: graceDay ?? null })
}
async function processCollectionsCase(caseId: string, now: Date) {
const caseData = await prisma.collectionsCase.findUnique({
where: { id: caseId },
include: {
invoice: { include: { company: true } },
subscription: true,
billingAccount: { include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] } } },
collectionsOwnerAdmin: true,
overrides: { where: { status: 'ACTIVE' }, orderBy: { expiresAt: 'desc' } },
},
}) as any
if (!caseData || ['RESOLVED', 'SUSPENDED'].includes(caseData.status)) return
if (caseData.invoice.status === 'PAID') {
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { status: 'RESOLVED', resolvedAt: now, nextActionAt: null } })
await prisma.collectionsCallTask.updateMany({ where: { collectionsCaseId: caseData.id, status: 'OPEN' }, data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' } })
return
}
const configured = isValidIanaTimezone(caseData.billingAccount.timezone)
&& caseData.billingAccount.billingContacts.length > 0
&& caseData.collectionsOwnerAdmin?.isActive
if (!configured) {
const safeTimezone = isValidIanaTimezone(caseData.billingAccount.timezone) ? caseData.billingAccount.timezone : 'UTC'
const eventKey = `configuration-blocked:${localDate(now, safeTimezone)}`
const firstOccurrenceToday = await recordCollectionsEvent(caseData, eventKey, 'collections.configuration_blocked')
if (firstOccurrenceToday && caseData.collectionsOwnerAdmin?.isActive) {
const locale = coerceNotificationLocale(caseData.collectionsOwnerAdmin.preferredLocale)
const copy = configurationCopy[locale]
await sendNotification({
type: 'COLLECTIONS_CALL_REQUIRED',
title: copy.title,
body: copy.body(caseData.invoice.company.name),
companyId: caseData.companyId,
adminUserId: caseData.collectionsOwnerAdmin.id,
channels: ['IN_APP', 'EMAIL'],
locale,
templateKey: 'subscription.collections_configuration_required.v1',
idempotencyKey: `collections:${caseData.invoiceId}:${eventKey}:admin:${caseData.collectionsOwnerAdmin.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
data: { collectionsCaseId: caseData.id, invoiceId: caseData.invoiceId, timezone: caseData.billingAccount.timezone, templateVersion: 1 },
policy: { mandatory: true },
})
}
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { nextActionAt: new Date(now.getTime() + 24 * 60 * 60 * 1000) } })
return
}
for (const override of caseData.overrides) {
if (override.expiresAt <= now) await prisma.collectionsOverride.update({ where: { id: override.id }, data: { status: 'EXPIRED' } })
}
const activeOverride = caseData.overrides.find((override: any) => override.expiresAt > now)
if (now < caseData.originalExpirationAt) {
const milestones: Array<[CollectionsMilestone, Date, string]> = [
['DUE_14D', caseData.reminder14At, 'due-14d'],
['DUE_7D', caseData.reminder7At, 'due-7d'],
['DUE_48H', caseData.reminder48At, 'due-48h'],
['DUE_24H', caseData.reminder24At, 'due-24h'],
]
const due = milestones.filter((item) => item[1] <= now).reverse()
if (due[0] && !activeOverride?.pauseNotifications) await processMilestone(caseData, due[0][0], due[0][2])
await prisma.collectionsCase.update({
where: { id: caseData.id },
data: { status: due.length ? 'PRE_DUE' : 'SCHEDULED', nextActionAt: nextReminderAfter(caseData, now), version: { increment: 1 } },
})
return
}
if (caseData.status !== 'GRACE_PERIOD') {
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { status: 'GRACE_PERIOD', graceStartedAt: caseData.originalExpirationAt } })
}
const suspensionAt = activeOverride?.revisedSuspensionAt ?? caseData.finalSuspensionAt
if (now >= suspensionAt) {
if (activeOverride?.pauseSuspension || !flag('SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED')) {
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { nextActionAt: activeOverride?.expiresAt ?? new Date(now.getTime() + 60 * 60 * 1000) } })
return
}
const suspended = await prisma.$transaction(async (tx: any) => {
const unpaid = await tx.billingInvoice.findFirst({ where: { id: caseData.invoiceId, status: { not: 'PAID' }, amountDue: { gt: 0 } } })
if (!unpaid) return false
const changed = await tx.collectionsCase.updateMany({ where: { id: caseData.id, status: { in: ['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD'] } }, data: { status: 'SUSPENDED', suspendedAt: now, nextActionAt: null } })
if (changed.count !== 1) return false
await tx.subscription.update({ where: { id: caseData.subscriptionId }, data: { status: 'SUSPENDED', suspendedAt: now } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseData.id, companyId: caseData.companyId, eventType: 'collections.suspended', idempotencyKey: 'suspended', payload: { finalSuspensionAt: suspensionAt } } })
await tx.subscriptionEvent.create({ data: { subscriptionId: caseData.subscriptionId, companyId: caseData.companyId, eventType: 'subscription.suspended', source: 'collections_worker', payload: { invoiceId: caseData.invoiceId }, occurredAt: now } })
await tx.billingEvent.create({ data: { billingAccountId: caseData.billingAccountId, invoiceId: caseData.invoiceId, subscriptionId: caseData.subscriptionId, companyId: caseData.companyId, eventType: 'collections.suspended', source: 'system', payload: {}, occurredAt: now } })
return true
})
if (suspended) {
for (const contact of caseData.billingAccount.billingContacts) {
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const copy = suspensionCopy[locale]
const invoice = caseData.invoice.invoiceNumber ?? caseData.invoiceId
await sendNotification({
type: 'SUBSCRIPTION_SUSPENDED',
title: copy.title,
body: copy.body(invoice),
companyId: caseData.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: 'subscription.suspended.v1',
idempotencyKey: `collections:${caseData.invoiceId}:suspended:${contact.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
data: { collectionsCaseId: caseData.id, invoiceId: caseData.invoiceId, timezone: caseData.billingAccount.timezone, templateVersion: 1, localizationFallback: false },
policy: { mandatory: true },
})
}
}
return
}
const expiryDay = dayjs(caseData.originalExpirationAt).tz(caseData.billingAccount.timezone).startOf('day')
const nowDay = dayjs(now).tz(caseData.billingAccount.timezone).startOf('day')
const graceDay = Math.max(1, Math.min(30, nowDay.diff(expiryDay, 'day') + 1))
const localKey = localDate(now, caseData.billingAccount.timezone)
if (!activeOverride?.pauseNotifications) {
await processMilestone(caseData, graceDay >= 30 ? 'GRACE_FINAL' : 'GRACE_DAILY', `grace:${localKey}`, graceDay)
}
const tomorrow = dayjs(now).tz(caseData.billingAccount.timezone).add(1, 'day').toDate()
const nextDaily = atLocalTime(tomorrow, caseData.billingAccount.timezone, caseData.billingAccount.reminderLocalTime)
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { nextActionAt: nextDaily < suspensionAt ? nextDaily : suspensionAt, version: { increment: 1 } } })
}
export async function runCollectionsWorker(now = new Date()) {
if (!flag('SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED')) return 0
await ensureRenewalCollectionsCases(now)
const cases = await prisma.collectionsCase.findMany({
where: { status: { in: ['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD'] }, nextActionAt: { lte: now }, OR: [{ processingLeaseUntil: null }, { processingLeaseUntil: { lt: now } }] },
select: { id: true, version: true },
orderBy: { nextActionAt: 'asc' },
take: 100,
})
let processed = 0
for (const item of cases) {
const workerId = crypto.randomUUID()
const claimed = await prisma.collectionsCase.updateMany({
where: { id: item.id, version: item.version, OR: [{ processingLeaseUntil: null }, { processingLeaseUntil: { lt: now } }] },
data: { processingLeaseUntil: new Date(now.getTime() + 5 * 60 * 1000), processingBy: workerId, version: { increment: 1 } },
})
if (claimed.count !== 1) continue
try {
await processCollectionsCase(item.id, now)
processed += 1
} finally {
await prisma.collectionsCase.updateMany({ where: { id: item.id, processingBy: workerId }, data: { processingLeaseUntil: null, processingBy: null } })
}
}
return processed
}
export async function listCollectionsCases(query: { status?: string; assignedTo?: string; actionDueBefore?: string; page: number; pageSize: number }) {
const where: any = {
...(query.status ? { status: query.status } : {}),
...(query.assignedTo ? { collectionsOwnerAdminId: query.assignedTo } : {}),
...(query.actionDueBefore ? { nextActionAt: { lte: new Date(query.actionDueBefore) } } : {}),
}
const [data, total] = await Promise.all([
prisma.collectionsCase.findMany({
where,
include: {
invoice: { include: { company: true } },
billingAccount: { include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true } } } },
collectionsOwnerAdmin: { select: { id: true, firstName: true, lastName: true, email: true } },
tasks: { where: { status: 'OPEN' }, orderBy: { dueAt: 'asc' } },
overrides: { where: { status: 'ACTIVE' }, orderBy: { expiresAt: 'desc' } },
},
orderBy: [{ nextActionAt: 'asc' }, { originalExpirationAt: 'asc' }],
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma.collectionsCase.count({ where }),
])
return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.max(1, Math.ceil(total / query.pageSize)) }
}
export async function getCollectionsCase(caseId: string) {
const value = await prisma.collectionsCase.findUnique({
where: { id: caseId },
include: { invoice: { include: { company: true, manualPaymentSubmissions: { include: { documents: true } } } }, billingAccount: { include: { billingContacts: true } }, collectionsOwnerAdmin: true, tasks: { orderBy: { createdAt: 'desc' } }, overrides: { orderBy: { createdAt: 'desc' } }, events: { orderBy: { occurredAt: 'desc' } } },
})
if (!value) throw new NotFoundError('Collections case not found')
return value
}
export async function assignCollectionsCase(caseId: string, adminId: string, actorId: string) {
const admin = await prisma.adminUser.findFirst({ where: { id: adminId, isActive: true, role: { in: ['FINANCE', 'ADMIN', 'SUPER_ADMIN'] } } })
if (!admin) throw new ValidationError('Assignee must be an active finance-capable admin')
return prisma.$transaction(async (tx: any) => {
const value = await tx.collectionsCase.update({ where: { id: caseId }, data: { collectionsOwnerAdminId: adminId } })
await tx.collectionsCallTask.updateMany({ where: { collectionsCaseId: caseId, status: 'OPEN' }, data: { assignedAdminId: adminId } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseId, companyId: value.companyId, eventType: 'collections.assigned', idempotencyKey: `assigned:${adminId}:${Date.now()}`, actorType: 'admin', actorId, payload: { assignedAdminId: adminId } } })
return value
})
}
async function assertTaskActor(task: any, actorId: string) {
if (task.assignedAdminId === actorId) return
const actor = await prisma.adminUser.findUnique({ where: { id: actorId } })
if (!actor || !['SUPER_ADMIN', 'ADMIN'].includes(actor.role)) throw new ForbiddenError('Only the assigned collections admin may complete this task')
}
export async function recordCallOutcome(taskId: string, data: { outcome: string; note?: string; promisedPaymentAt?: string; nextFollowUpAt?: string }, adminId: string) {
const task = await prisma.collectionsCallTask.findUnique({ where: { id: taskId }, include: { collectionsCase: true } })
if (!task) throw new NotFoundError('Collections task not found')
if (task.status !== 'OPEN') throw new ConflictError('Collections task is not open')
await assertTaskActor(task, adminId)
const completedAt = new Date()
return prisma.$transaction(async (tx: any) => {
const updated = await tx.collectionsCallTask.update({
where: { id: taskId },
data: { status: 'COMPLETED', outcome: data.outcome as any, note: data.note, promisedPaymentAt: data.promisedPaymentAt ? new Date(data.promisedPaymentAt) : null, nextFollowUpAt: data.nextFollowUpAt ? new Date(data.nextFollowUpAt) : null, completedByAdminId: adminId, completedAt },
})
if (data.outcome === 'PAYMENT_PROMISED' && data.nextFollowUpAt) {
await tx.collectionsCallTask.upsert({
where: { collectionsCaseId_taskType: { collectionsCaseId: task.collectionsCaseId, taskType: 'PAYMENT_PROMISE_FOLLOW_UP' } },
update: { status: 'OPEN', assignedAdminId: task.assignedAdminId, dueAt: new Date(data.nextFollowUpAt), outcome: null, completedAt: null, completedByAdminId: null },
create: { collectionsCaseId: task.collectionsCaseId, taskType: 'PAYMENT_PROMISE_FOLLOW_UP', assignedAdminId: task.assignedAdminId, billingContactId: task.billingContactId, dueAt: new Date(data.nextFollowUpAt), companyDefaultLocale: task.companyDefaultLocale, contactLocale: task.contactLocale, customerScript: task.customerScript },
})
}
await tx.collectionsEvent.create({ data: { collectionsCaseId: task.collectionsCaseId, companyId: task.collectionsCase.companyId, eventType: 'collections.call_outcome', idempotencyKey: `call-outcome:${task.id}`, actorType: 'admin', actorId: adminId, payload: { outcome: data.outcome, note: data.note ?? null, promisedPaymentAt: data.promisedPaymentAt ?? null } } })
return updated
})
}
export async function createCollectionsOverride(caseId: string, data: { type: string; reason: string; expiresAt: string; revisedSuspensionAt?: string; pauseSuspension: boolean; pauseNotifications: boolean }, adminId: string) {
const expiresAt = new Date(data.expiresAt)
if (expiresAt <= new Date() || expiresAt.getTime() > Date.now() + 90 * 24 * 60 * 60 * 1000) throw new ValidationError('Override expiry must be in the future and no more than 90 days away')
const value = await prisma.collectionsCase.findUnique({ where: { id: caseId } })
if (!value) throw new NotFoundError('Collections case not found')
const revisedSuspensionAt = data.revisedSuspensionAt ? new Date(data.revisedSuspensionAt) : null
if (revisedSuspensionAt && (revisedSuspensionAt <= value.originalExpirationAt || revisedSuspensionAt > expiresAt)) {
throw new ValidationError('Revised suspension time must be after the original expiration and no later than the override expiry')
}
const override = await prisma.$transaction(async (tx: any) => {
const active = await tx.collectionsOverride.findFirst({ where: { collectionsCaseId: caseId, status: 'ACTIVE', expiresAt: { gt: new Date() } } })
if (active) throw new ConflictError('Revoke the active collections override before creating another')
const override = await tx.collectionsOverride.create({ data: { collectionsCaseId: caseId, type: data.type as any, reason: data.reason, expiresAt, revisedSuspensionAt, pauseSuspension: data.pauseSuspension, pauseNotifications: data.pauseNotifications, createdByAdminId: adminId } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseId, companyId: value.companyId, eventType: 'collections.override_created', idempotencyKey: `override:${override.id}:created`, actorType: 'admin', actorId: adminId, payload: { overrideId: override.id, type: override.type, expiresAt } } })
return override
}, { isolationLevel: 'Serializable' as any })
await sendOverrideChangedNotification(caseId, override.id, 'created')
return override
}
export async function revokeCollectionsOverride(caseId: string, overrideId: string, adminId: string) {
const value = await prisma.collectionsOverride.findFirst({ where: { id: overrideId, collectionsCaseId: caseId, status: 'ACTIVE' }, include: { collectionsCase: true } })
if (!value) throw new NotFoundError('Active collections override not found')
const updated = await prisma.$transaction(async (tx: any) => {
const updated = await tx.collectionsOverride.update({ where: { id: overrideId }, data: { status: 'REVOKED', revokedByAdminId: adminId, revokedAt: new Date() } })
await tx.collectionsCase.update({ where: { id: caseId }, data: { nextActionAt: new Date() } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseId, companyId: value.collectionsCase.companyId, eventType: 'collections.override_revoked', idempotencyKey: `override:${overrideId}:revoked`, actorType: 'admin', actorId: adminId, payload: { overrideId } } })
return updated
})
await sendOverrideChangedNotification(caseId, overrideId, 'revoked')
return updated
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
communicationSettingsSchema,
createManualPaymentSubmissionSchema,
manualCheckoutSchema,
} from './subscription.schemas'
import { addBillingPeriod, normalizeExternalReference } from './subscription.manual.service'
describe('manual subscription payment contracts', () => {
it('accepts only bank transfer and check manual checkout', () => {
const base = { plan: 'GROWTH', billingPeriod: 'ANNUAL', currency: 'MAD', idempotencyKey: crypto.randomUUID() }
expect(manualCheckoutSchema.safeParse({ ...base, method: 'BANK_TRANSFER' }).success).toBe(true)
expect(manualCheckoutSchema.safeParse({ ...base, method: 'STRIPE' }).success).toBe(false)
})
it('normalizes references without discarding the display value', () => {
const submittedReference = ' bank txn-123 '
const parsed = createManualPaymentSubmissionSchema.parse({ method: 'BANK_TRANSFER', submittedReference, idempotencyKey: crypto.randomUUID() })
expect(parsed.submittedReference).toBe('bank txn-123')
expect(normalizeExternalReference(parsed.submittedReference)).toBe('BANK TXN-123')
})
it('enforces the company-enabled communication language set', () => {
const valid = {
timezone: 'Africa/Casablanca', reminderLocalTime: '09:00',
enabledCommunicationLocales: ['ar', 'fr'], defaultCommunicationLocale: 'fr',
contacts: [{ email: 'billing@example.ma', locale: 'ar', isPrimary: true, receivePaymentNotices: true, isActive: true }],
}
expect(communicationSettingsSchema.safeParse(valid).success).toBe(true)
expect(communicationSettingsSchema.safeParse({ ...valid, defaultCommunicationLocale: 'en' }).success).toBe(false)
expect(communicationSettingsSchema.safeParse({ ...valid, contacts: [{ ...valid.contacts[0], locale: 'en' }] }).success).toBe(false)
expect(communicationSettingsSchema.safeParse({ ...valid, enabledCommunicationLocales: [] }).success).toBe(false)
})
it('clamps month-end and leap-year billing periods', () => {
expect(addBillingPeriod(new Date('2028-01-31T12:00:00.000Z'), 'MONTHLY').toISOString()).toBe('2028-02-29T12:00:00.000Z')
expect(addBillingPeriod(new Date('2028-02-29T12:00:00.000Z'), 'ANNUAL').toISOString()).toBe('2029-02-28T12:00:00.000Z')
})
})
@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createManualPaymentSubmission, submitManualPaymentSubmission } from './subscription.manual.service'
import { prisma } from '../../lib/prisma'
import { sendNotification } from '../../services/notificationService'
vi.mock('../../lib/prisma', () => ({
prisma: {
$transaction: vi.fn(),
billingAccount: { findFirst: vi.fn(), findUnique: vi.fn(), findUniqueOrThrow: vi.fn() },
billingInvoice: { findFirst: vi.fn() },
manualPaymentSubmission: { findUnique: vi.fn(), findFirst: vi.fn(), upsert: vi.fn() },
adminUser: { findMany: vi.fn() },
},
}))
vi.mock('../../services/notificationService', () => ({
sendNotification: vi.fn(),
}))
vi.mock('../../lib/storage', () => ({
deletePrivateDocument: vi.fn(),
promotePaymentEvidence: vi.fn(),
readPrivateDocument: vi.fn(),
storePaymentEvidenceInQuarantine: vi.fn(),
}))
vi.mock('../../services/paymentEvidenceScanner', () => ({
scanPaymentEvidenceFile: vi.fn(),
}))
describe('manual subscription payment submissions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('treats an already submitted payment package as idempotent without sending another notice', async () => {
const submission = {
id: 'submission_1',
invoiceId: 'invoice_1',
billingAccountId: 'billing_1',
companyId: 'company_1',
method: 'BANK_TRANSFER',
submittedReference: 'bank ref 123',
status: 'SUBMITTED',
submittedAt: new Date('2026-08-10T12:00:00.000Z'),
reviewedAt: null,
rejectionReason: null,
documents: [{ id: 'doc_1', kind: 'BANK_TRANSFER_RECEIPT', originalFilename: 'receipt.pdf', detectedMimeType: 'application/pdf', byteSize: 1024, scanStatus: 'CLEAN', uploadedAt: new Date('2026-08-10T12:01:00.000Z') }],
invoice: { invoiceNumber: 'INV-2026-000001' },
}
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback({
manualPaymentSubmission: {
findFirst: vi.fn().mockResolvedValue(submission),
},
}))
await expect(submitManualPaymentSubmission('company_1', 'submission_1')).resolves.toMatchObject({
id: 'submission_1',
status: 'SUBMITTED',
documents: [{ id: 'doc_1', scanStatus: 'CLEAN' }],
})
expect(sendNotification).not.toHaveBeenCalled()
})
it('reuses the active submission created by a concurrent request after the invoice-level unique guard fires', async () => {
const concurrentSubmission = {
id: 'submission_2',
invoiceId: 'invoice_1',
billingAccountId: 'billing_1',
companyId: 'company_1',
method: 'BANK_TRANSFER',
submittedReference: 'bank ref 456',
normalizedSubmittedReference: 'BANK REF 456',
status: 'DRAFT',
submittedAt: null,
reviewedAt: null,
rejectionReason: null,
documents: [],
}
vi.mocked(prisma.billingAccount.findFirst).mockResolvedValue({
id: 'billing_1',
billingContacts: [{ id: 'contact_1' }],
} as never)
vi.mocked(prisma.billingAccount.findUniqueOrThrow).mockResolvedValue({
id: 'billing_1',
billingContacts: [{ id: 'contact_1' }],
} as never)
vi.mocked(prisma.billingInvoice.findFirst).mockResolvedValue({
id: 'invoice_1',
companyId: 'company_1',
billingAccountId: 'billing_1',
status: 'OPEN',
collectionMethod: 'BANK_TRANSFER',
} as never)
vi.mocked(prisma.manualPaymentSubmission.findUnique).mockResolvedValue(null as never)
vi.mocked(prisma.manualPaymentSubmission.findFirst)
.mockResolvedValueOnce(null as never)
.mockResolvedValueOnce(concurrentSubmission as never)
vi.mocked(prisma.manualPaymentSubmission.upsert).mockRejectedValueOnce({ code: 'P2002' })
await expect(createManualPaymentSubmission('company_1', 'employee_1', 'invoice_1', {
method: 'BANK_TRANSFER',
submittedReference: ' bank ref 456 ',
idempotencyKey: 'idem_1',
})).resolves.toMatchObject({
id: 'submission_2',
status: 'DRAFT',
submittedReference: 'bank ref 456',
})
})
it('sends detailed customer and admin notifications when payment evidence is submitted', async () => {
const submittedAt = new Date('2026-08-10T12:00:00.000Z')
const draftSubmission = {
id: 'submission_3',
invoiceId: 'invoice_3',
billingAccountId: 'billing_3',
companyId: 'company_3',
method: 'CHECK',
submittedReference: 'check-7788',
status: 'DRAFT',
submittedAt: null,
reviewedAt: null,
rejectionReason: null,
documents: [{ id: 'doc_3', kind: 'CHECK_COPY', originalFilename: 'check.jpg', detectedMimeType: 'image/jpeg', byteSize: 2048, scanStatus: 'CLEAN', uploadedAt: submittedAt }],
invoice: {
id: 'invoice_3',
invoiceNumber: 'INV-2026-000003',
status: 'OPEN',
subscriptionId: 'subscription_3',
},
}
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback({
manualPaymentSubmission: {
findFirst: vi.fn().mockResolvedValue(draftSubmission),
update: vi.fn().mockResolvedValue({ ...draftSubmission, status: 'SUBMITTED', submittedAt }),
},
billingEvent: { create: vi.fn().mockResolvedValue({ id: 'event_1' }) },
}))
vi.mocked(prisma.billingAccount.findUnique).mockResolvedValue({
id: 'billing_3',
defaultCommunicationLocale: 'en',
enabledCommunicationLocales: ['en'],
timezone: 'Africa/Casablanca',
billingContacts: [{ id: 'contact_3', employeeId: null, locale: 'en', employee: null }],
} as never)
vi.mocked(prisma.manualPaymentSubmission.findUnique).mockResolvedValue({
...draftSubmission,
status: 'SUBMITTED',
submittedAt,
submittedByEmployee: { email: 'owner@example.test' },
documents: draftSubmission.documents,
invoice: {
id: 'invoice_3',
invoiceNumber: 'INV-2026-000003',
amountDue: 14900,
currency: 'MAD',
requestedPlan: 'STARTER',
requestedBillingPeriod: 'MONTHLY',
dueAt: new Date('2026-08-24T00:00:00.000Z'),
company: { name: 'Atlas car' },
subscription: { currentPeriodStart: null, currentPeriodEnd: null },
lineItems: [{ periodStart: new Date('2026-08-10T00:00:00.000Z'), periodEnd: new Date('2026-09-10T00:00:00.000Z') }],
},
} as never)
vi.mocked(prisma.adminUser.findMany).mockResolvedValue([{ id: 'admin_finance', preferredLocale: 'en' }] as never)
await submitManualPaymentSubmission('company_3', 'submission_3')
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
billingContactId: 'contact_3',
body: expect.stringContaining('Payment type: Check'),
data: expect.objectContaining({ amountDue: 14900, paymentType: 'CHECK' }),
}))
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
adminUserId: 'admin_finance',
channels: ['IN_APP', 'EMAIL'],
title: 'Payment evidence submitted: INV-2026-000003',
body: expect.stringContaining('Action required: open Admin Billing'),
}))
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../services/stripeService', () => ({
getConfigurationStatus: vi.fn(),
}))
import { getConfigurationStatus } from '../../services/stripeService'
import { getPaymentOptions } from './subscription.payment-config'
const ENV_KEYS = [
'MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED',
'MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED',
'PAYMENT_EVIDENCE_SCANNER_MODE',
'PAYMENT_EVIDENCE_SCANNER_PATH',
'BANK_TRANSFER_ENABLED',
'BANK_TRANSFER_ACCOUNT_NAME',
'BANK_TRANSFER_BANK_NAME',
'BANK_TRANSFER_ACCOUNT_REFERENCE',
'CHECK_PAYMENT_ENABLED',
'CHECK_PAYMENT_PAYEE',
'CHECK_PAYMENT_DELIVERY_ADDRESS',
'NODE_ENV',
] as const
describe('subscription payment configuration', () => {
const originalEnv = new Map<string, string | undefined>()
beforeEach(() => {
vi.mocked(getConfigurationStatus).mockReturnValue({ configured: true, problems: [] })
for (const key of ENV_KEYS) originalEnv.set(key, process.env[key])
})
afterEach(() => {
for (const key of ENV_KEYS) {
const value = originalEnv.get(key)
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
originalEnv.clear()
vi.clearAllMocks()
})
it('exposes local manual payment methods when evidence scanning is ready', () => {
process.env.NODE_ENV = 'development'
expect(getPaymentOptions('en').methods).toEqual([
{ method: 'STRIPE', enabled: true },
{
method: 'BANK_TRANSFER',
enabled: true,
instructions: {
accountName: 'RentalDriveGo SARL',
bankName: 'Local Development Bank',
accountReference: 'DEV-MA64-0000-0000-0000',
message: 'Enter the bank transfer reference and upload the transfer receipt.',
},
},
{
method: 'CHECK',
enabled: true,
instructions: {
payee: 'RentalDriveGo SARL',
deliveryAddress: 'Local development billing desk',
message: 'Enter the check number and upload a check copy.',
},
},
])
})
it('hides manual methods in production when the evidence pipeline is not ready', () => {
process.env.NODE_ENV = 'production'
process.env.MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED = 'true'
process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED = 'true'
process.env.BANK_TRANSFER_ENABLED = 'true'
process.env.CHECK_PAYMENT_ENABLED = 'true'
expect(getPaymentOptions('en').methods).toEqual([{ method: 'STRIPE', enabled: true }])
})
})
@@ -0,0 +1,91 @@
import { ValidationError } from '../../http/errors'
import { getConfigurationStatus as getStripeConfigurationStatus } from '../../services/stripeService'
import type { NotificationLocale } from '../../services/notificationLocalizationService'
export type ManualCollectionMethod = 'BANK_TRANSFER' | 'CHECK'
const isProduction = () => process.env.NODE_ENV === 'production'
const useLocalDefaults = () => !isProduction()
const enabled = (value: string | undefined, defaultValue = false) => value === undefined ? defaultValue : value === 'true'
const localDefault = (value: string | undefined, fallback: string) => value?.trim() || (useLocalDefaults() ? fallback : '')
const messages: Record<NotificationLocale, Record<ManualCollectionMethod, string>> = {
en: {
BANK_TRANSFER: 'Enter the bank transfer reference and upload the transfer receipt.',
CHECK: 'Enter the check number and upload a check copy.',
},
fr: {
BANK_TRANSFER: 'Saisissez la référence du virement et téléversez le reçu.',
CHECK: 'Saisissez le numéro du chèque et téléversez une copie.',
},
ar: {
BANK_TRANSFER: 'أدخل مرجع التحويل البنكي وارفع إيصال التحويل.',
CHECK: 'أدخل رقم الشيك وارفع نسخة من الشيك.',
},
}
export function manualPaymentFeatureEnabled() {
return enabled(process.env.MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED, useLocalDefaults())
}
export function paymentEvidenceUploadEnabled() {
return manualPaymentFeatureEnabled() && enabled(process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED, useLocalDefaults())
}
export function paymentEvidencePipelineReady() {
if (!paymentEvidenceUploadEnabled()) return false
if (process.env.PAYMENT_EVIDENCE_SCANNER_PATH?.trim()) return true
return !isProduction() && (process.env.PAYMENT_EVIDENCE_SCANNER_MODE === undefined || process.env.PAYMENT_EVIDENCE_SCANNER_MODE === 'stub-clean')
}
export function getPaymentOptions(locale: NotificationLocale = 'en') {
const stripeStatus = getStripeConfigurationStatus()
const methods: Array<Record<string, unknown>> = [{ method: 'STRIPE', enabled: stripeStatus.configured }]
// A manual method is unusable unless evidence can be scanned and submitted.
// Keeping it hidden also makes the rollout fail closed when the scanner is
// accidentally omitted from a production deployment.
if (!manualPaymentFeatureEnabled() || !paymentEvidencePipelineReady()) return { methods }
if (enabled(process.env.BANK_TRANSFER_ENABLED, useLocalDefaults())) {
const accountName = localDefault(process.env.BANK_TRANSFER_ACCOUNT_NAME, 'RentalDriveGo SARL')
const bankName = localDefault(process.env.BANK_TRANSFER_BANK_NAME, 'Local Development Bank')
const accountReference = localDefault(process.env.BANK_TRANSFER_ACCOUNT_REFERENCE, 'DEV-MA64-0000-0000-0000')
if (accountName && bankName && accountReference) {
methods.push({
method: 'BANK_TRANSFER',
enabled: true,
instructions: { accountName, bankName, accountReference, message: messages[locale].BANK_TRANSFER },
})
}
}
if (enabled(process.env.CHECK_PAYMENT_ENABLED, useLocalDefaults())) {
const payee = localDefault(process.env.CHECK_PAYMENT_PAYEE, 'RentalDriveGo SARL')
const deliveryAddress = localDefault(process.env.CHECK_PAYMENT_DELIVERY_ADDRESS, 'Local development billing desk')
if (payee && deliveryAddress) {
methods.push({
method: 'CHECK',
enabled: true,
instructions: { payee, deliveryAddress, message: messages[locale].CHECK },
})
}
}
return { methods }
}
export function requireManualMethodEnabled(method: ManualCollectionMethod, locale: NotificationLocale = 'en') {
const option = getPaymentOptions(locale).methods.find((item) => item.method === method && item.enabled === true)
if (!option) throw new ValidationError(`${method === 'BANK_TRANSFER' ? 'Bank transfer' : 'Check'} payments are not available`)
return option
}
export function manualPaymentDueDays(method: ManualCollectionMethod) {
const raw = method === 'CHECK'
? process.env.CHECK_PAYMENT_DUE_DAYS
: process.env.BANK_TRANSFER_DUE_DAYS
const fallback = method === 'CHECK' ? 14 : 7
const value = Number(raw ?? fallback)
return Number.isInteger(value) && value >= 1 && value <= 60 ? value : fallback
}
@@ -3,8 +3,9 @@ import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { parseBody, parseParams } from '../../http/validate'
import { created, ok } from '../../http/respond'
import { paymentEvidenceUpload } from '../../http/upload/paymentEvidence'
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
@@ -17,7 +18,15 @@ import {
startTrialSchema,
cancelSchema,
reactivateSchema,
manualCheckoutSchema,
invoiceIdParamSchema,
submissionIdParamSchema,
submissionDocumentParamsSchema,
createManualPaymentSubmissionSchema,
manualPaymentDocumentFieldsSchema,
communicationSettingsSchema,
} from './subscription.schemas'
import * as manualService from './subscription.manual.service'
const publicRouter = Router()
const webhookRouter = Router()
@@ -83,7 +92,24 @@ router.get('/me', async (req, res, next) => {
})
router.get('/invoices', async (req, res, next) => {
try { ok(res, await service.getInvoices(req.companyId)) } catch (err) { next(err) }
try { ok(res, await manualService.getCanonicalInvoices(req.companyId)) } catch (err) { next(err) }
})
router.get('/invoices/:invoiceId/pdf', requireRole('OWNER'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { pdfBuffer, invoiceNumber } = await manualService.getPaidInvoicePdf(req.companyId, invoiceId)
const safeNumber = invoiceNumber.replace(/["\\\r\n]/g, '_')
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${safeNumber}.pdf"`)
res.setHeader('Content-Length', pdfBuffer.length)
res.setHeader('Cache-Control', 'private, no-store')
res.end(pdfBuffer)
} catch (err) { next(err) }
})
router.get('/payment-options', async (req, res, next) => {
try { ok(res, await manualService.getCompanyPaymentOptions(req.companyId, req.employee.id)) } catch (err) { next(err) }
})
router.get('/events', async (req, res, next) => {
@@ -108,6 +134,83 @@ router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/manual-checkout', requireRole('OWNER'), async (req, res, next) => {
try {
created(res, await manualService.createManualCheckout(
req.companyId,
req.employee.id,
parseBody(manualCheckoutSchema, req),
))
} catch (err) { next(err) }
})
router.post('/invoices/:invoiceId/manual-payment-submissions', requireRole('OWNER'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
created(res, await manualService.createManualPaymentSubmission(
req.companyId,
req.employee.id,
invoiceId,
parseBody(createManualPaymentSubmissionSchema, req),
))
} catch (err) { next(err) }
})
router.post(
'/manual-payment-submissions/:submissionId/documents',
requireRole('OWNER'),
paymentEvidenceUpload.single('file'),
async (req, res, next) => {
try {
const { submissionId } = parseParams(submissionIdParamSchema, req)
const { kind } = parseBody(manualPaymentDocumentFieldsSchema, req)
created(res, await manualService.uploadManualPaymentDocument(req.companyId, req.employee.id, submissionId, kind, req.file))
} catch (err) { next(err) }
},
)
router.delete('/manual-payment-submissions/:submissionId/documents/:documentId', requireRole('OWNER'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(submissionDocumentParamsSchema, req)
ok(res, await manualService.deleteManualPaymentDocument(req.companyId, submissionId, documentId))
} catch (err) { next(err) }
})
router.post('/manual-payment-submissions/:submissionId/submit', requireRole('OWNER'), async (req, res, next) => {
try {
const { submissionId } = parseParams(submissionIdParamSchema, req)
ok(res, await manualService.submitManualPaymentSubmission(req.companyId, submissionId))
} catch (err) { next(err) }
})
router.get('/manual-payment-submissions/:submissionId/documents/:documentId', requireRole('OWNER'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(submissionDocumentParamsSchema, req)
const { document, bytes } = await manualService.getCustomerPaymentDocument(req.companyId, submissionId, documentId)
const safeName = document.originalFilename.replace(/["\\\r\n]/g, '_')
res.setHeader('Content-Type', document.detectedMimeType)
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`)
res.setHeader('Content-Length', bytes.length)
res.setHeader('Cache-Control', 'private, no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.end(bytes)
} catch (err) { next(err) }
})
router.get('/communication-settings', requireRole('OWNER'), async (req, res, next) => {
try { ok(res, await manualService.getCommunicationSettings(req.companyId, req.employee.id)) } catch (err) { next(err) }
})
router.put('/communication-settings', requireRole('OWNER'), async (req, res, next) => {
try {
ok(res, await manualService.updateCommunicationSettings(
req.companyId,
req.employee.id,
parseBody(communicationSettingsSchema, req),
))
} catch (err) { next(err) }
})
router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(reactivateSchema, req)
@@ -4,6 +4,13 @@ const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
const providerEnum = z.enum(['STRIPE'])
const currencyEnum = z.enum(['MAD', 'EUR', 'USD'])
const manualMethodEnum = z.enum(['BANK_TRANSFER', 'CHECK'])
const localeEnum = z.enum(['ar', 'en', 'fr'])
const referenceSchema = z.string()
.trim()
.min(3)
.max(120)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters')
export const checkoutSchema = z.object({
plan: planEnum,
@@ -43,3 +50,60 @@ export const reactivateSchema = z.object({
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
export const manualCheckoutSchema = z.object({
plan: planEnum,
billingPeriod: billingPeriodEnum,
currency: z.literal('MAD'),
method: manualMethodEnum,
idempotencyKey: z.string().uuid(),
})
export const invoiceIdParamSchema = z.object({ invoiceId: z.string().min(1) })
export const submissionIdParamSchema = z.object({ submissionId: z.string().min(1) })
export const submissionDocumentParamsSchema = z.object({
submissionId: z.string().min(1),
documentId: z.string().min(1),
})
export const createManualPaymentSubmissionSchema = z.object({
method: manualMethodEnum,
submittedReference: referenceSchema,
idempotencyKey: z.string().uuid(),
})
export const manualPaymentDocumentFieldsSchema = z.object({
kind: z.enum(['BANK_TRANSFER_RECEIPT', 'CHECK_COPY', 'OTHER_SUPPORTING_EVIDENCE']),
})
export const billingContactInputSchema = z.object({
id: z.string().min(1).optional(),
employeeId: z.string().min(1).nullable().optional(),
email: z.string().email().max(255).trim().toLowerCase(),
locale: localeEnum.nullable().optional(),
isPrimary: z.boolean(),
receivePaymentNotices: z.boolean().default(true),
isActive: z.boolean().default(true),
})
export const communicationSettingsSchema = z.object({
timezone: z.string().min(1).max(100),
reminderLocalTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
enabledCommunicationLocales: z.array(localeEnum).min(1).max(3).transform((values) => Array.from(new Set(values))),
defaultCommunicationLocale: localeEnum,
contacts: z.array(billingContactInputSchema).min(1).max(20),
}).superRefine((data, ctx) => {
if (!data.enabledCommunicationLocales.includes(data.defaultCommunicationLocale)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultCommunicationLocale'], message: 'Default locale must be enabled' })
}
data.contacts.forEach((contact, index) => {
if (contact.locale && !data.enabledCommunicationLocales.includes(contact.locale)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contacts', index, 'locale'], message: 'Contact locale must be enabled' })
}
})
if (!data.contacts.some((contact) => contact.isActive && contact.isPrimary && contact.receivePaymentNotices)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contacts'], message: 'An active primary payment contact is required' })
}
})
export { manualMethodEnum, localeEnum, referenceSchema }
@@ -54,6 +54,12 @@ vi.mock('./subscription.repo', () => ({
setSuspended: vi.fn(),
}))
vi.mock('./subscription.manual.service', () => ({
createCanonicalStripeCheckoutInvoice: vi.fn(),
finalizeCanonicalOnlinePayment: vi.fn().mockResolvedValue(false),
recordCanonicalOnlinePaymentFailure: vi.fn().mockResolvedValue(false),
}))
vi.mock('../../security/webhookIdempotency', () => ({
getWebhookEventId: vi.fn((provider: string, event: any) => event.id ?? event.transaction_id ?? `${provider}_event`),
processWebhookOnce: vi.fn(async ({ handle }: { handle: () => Promise<unknown> }) => {
@@ -67,6 +73,7 @@ import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as stripe from '../../services/stripeService'
import * as repo from './subscription.repo'
import * as manualService from './subscription.manual.service'
import * as service from './subscription.service'
describe('subscription.service operational edges', () => {
@@ -131,7 +138,7 @@ describe('subscription.service operational edges', () => {
vi.mocked(repo.findOrCreateSubscription).mockResolvedValue({ id: 'sub_1' } as never)
vi.mocked(stripe.isConfigured).mockReturnValue(true)
vi.mocked(stripe.createCheckoutSession).mockResolvedValue({ checkoutUrl: 'https://checkout.stripe.test/session', sessionId: 'cs_test_123' } as never)
vi.mocked(repo.createInvoice).mockResolvedValue({ id: 'invoice_1' } as never)
vi.mocked(manualService.createCanonicalStripeCheckoutInvoice).mockResolvedValue({ id: 'invoice_1' } as never)
await expect(service.checkout('company_1', {
plan: 'GROWTH',
@@ -150,15 +157,12 @@ describe('subscription.service operational edges', () => {
subscriptionId: 'sub_1',
type: 'SUBSCRIPTION',
}))
expect(repo.createInvoice).toHaveBeenCalledWith(expect.objectContaining({
expect(manualService.createCanonicalStripeCheckoutInvoice).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1',
subscriptionId: 'sub_1',
requestedPlan: 'GROWTH',
requestedBillingPeriod: 'MONTHLY',
plan: 'GROWTH',
billingPeriod: 'MONTHLY',
amount: 19900,
paymentProvider: 'STRIPE',
amanpayTransactionId: null,
paypalCaptureId: null,
stripeCheckoutSessionId: 'cs_test_123',
dueAt: new Date('2026-06-08T00:00:00.000Z'),
}))
@@ -7,6 +7,11 @@ import * as stripe from '../../services/stripeService'
import * as repo from './subscription.repo'
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
import {
createCanonicalStripeCheckoutInvoice,
finalizeCanonicalOnlinePayment,
recordCanonicalOnlinePaymentFailure,
} from './subscription.manual.service'
// ─── Helpers ──────────────────────────────────────────────────
@@ -114,6 +119,7 @@ async function handlePaymentSuccess(subscriptionId: string, invoiceId: string, p
billingPeriod?: string | null
currency?: string | null
}) {
if (await finalizeCanonicalOnlinePayment(invoiceId)) return
const sub = await repo.findById(subscriptionId)
if (!sub) return
@@ -144,6 +150,7 @@ async function handlePaymentFailure(
failureCode?: string,
failureMessage?: string,
) {
if (await recordCanonicalOnlinePaymentFailure(invoiceId, failureCode, failureMessage)) return
const sub = await repo.findById(subscriptionId)
if (!sub) return
@@ -274,11 +281,6 @@ export async function checkout(companyId: string, body: {
const orderId = `sub-${companyId}-${Date.now()}`
const description = `${body.plan} plan — ${body.billingPeriod}`
let checkoutUrl: string
let amanpayTransactionId: string | null = null
let paypalCaptureId: string | null = null
let stripeCheckoutSessionId: string | null = null
if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured on this platform')
const result = await stripe.createCheckoutSession({
amount, currency: body.currency, orderId, description,
@@ -289,18 +291,19 @@ export async function checkout(companyId: string, body: {
subscriptionId: subscription.id,
type: 'SUBSCRIPTION',
})
checkoutUrl = result.checkoutUrl
stripeCheckoutSessionId = result.sessionId
const dueAt = new Date(Date.now() + SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000)
const invoice = await repo.createInvoice({
companyId, subscriptionId: subscription.id,
requestedPlan: body.plan,
requestedBillingPeriod: body.billingPeriod,
amount, currency: body.currency,
paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, stripeCheckoutSessionId, dueAt,
const invoice = await createCanonicalStripeCheckoutInvoice({
companyId,
subscriptionId: subscription.id,
plan: body.plan,
billingPeriod: body.billingPeriod,
amount,
currency: body.currency,
stripeCheckoutSessionId: result.sessionId,
dueAt,
})
return { invoice, checkoutUrl }
return { invoice, checkoutUrl: result.checkoutUrl }
}
export async function capturePaypal(companyId: string, paypalOrderId: string) {
@@ -72,14 +72,19 @@ describe('invoicePdfService', () => {
subtotalAmount: 80000,
discountAmount: 0,
creditAmount: 0,
taxAmount: 0,
totalAmount: 80000,
taxRate: 20,
taxAmount: 16000,
totalAmount: 96000,
amountPaid: 0,
amountDue: 80000,
amountDue: 96000,
},
})
expect(result.subarray(0, 8).toString()).toBe('%PDF-1.4')
expect(result.toString()).toContain('INV-2026-000002')
const pdf = result.toString()
expect(pdf).toContain('INV-2026-000002')
expect(pdf).toContain('Price before tax: 800.00 MAD')
expect(pdf).toContain('Tax \\(20%\\): 160.00 MAD')
expect(pdf).toContain('TTC: 960.00 MAD')
})
})
+24 -17
View File
@@ -25,6 +25,7 @@ interface InvoiceData {
transactionId?: string | null
paidAt?: string | null
lineItems?: Array<{
type?: string | null
description: string
amount: number
currency: string
@@ -37,6 +38,7 @@ interface InvoiceData {
subtotalAmount: number
discountAmount: number
creditAmount: number
taxRate?: number | null
taxAmount: number
totalAmount: number
amountPaid: number
@@ -340,13 +342,19 @@ function pdfText(value: unknown) {
.replace(/[^\x20-\x7E]/g, '')
}
function fmtTaxRate(value?: number | null) {
if (typeof value !== 'number' || !Number.isFinite(value)) return '0%'
return `${Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, '')}%`
}
function InvoiceDocument({ data }: { data: InvoiceData }) {
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
const addressStr = formatAddress(data.company.address)
const planLabel = data.subscription ? (PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan) : 'Manual'
const periodLabel = data.subscription ? (PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod) : 'Custom'
const lineItems = data.lineItems?.length
? data.lineItems
const invoiceLineItems = data.lineItems?.filter((item) => item.type !== 'TAX') ?? []
const lineItems = invoiceLineItems.length
? invoiceLineItems
: [{
description: `${planLabel} Plan — ${periodLabel} Subscription`,
amount: data.amount,
@@ -490,7 +498,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
React.createElement(Text, { style: s.totalLabel }, 'Price before tax'),
React.createElement(Text, { style: s.totalValue }, fmt(totals.subtotalAmount, data.currency)),
),
totals.discountAmount > 0
@@ -509,18 +517,16 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`),
)
: null,
totals.taxAmount > 0
? React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Tax'),
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
)
: null,
React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, `Tax (${fmtTaxRate(totals.taxRate)})`),
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
),
React.createElement(
View,
{ style: s.grandTotalRow },
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
React.createElement(Text, { style: s.grandTotalLabel }, 'TTC'),
React.createElement(Text, { style: s.grandTotalValue }, fmt(totals.totalAmount, data.currency)),
),
@@ -589,8 +595,9 @@ function pdfEscape(value: unknown) {
}
function buildSimpleInvoicePdf(data: InvoiceData) {
const lineItems = data.lineItems?.length
? data.lineItems
const invoiceLineItems = data.lineItems?.filter((item) => item.type !== 'TAX') ?? []
const lineItems = invoiceLineItems.length
? invoiceLineItems
: [{ description: 'Invoice charge', amount: data.amount, currency: data.currency }]
const totals = data.totals ?? {
subtotalAmount: data.amount,
@@ -616,11 +623,11 @@ function buildSimpleInvoicePdf(data: InvoiceData) {
'Line Items',
...lineItems.map((item) => `${item.description} - ${fmt(item.amount, item.currency)}`),
'',
`Subtotal: ${fmt(totals.subtotalAmount, data.currency)}`,
`Price before tax: ${fmt(totals.subtotalAmount, data.currency)}`,
totals.discountAmount ? `Discounts: -${fmt(totals.discountAmount, data.currency)}` : null,
totals.creditAmount ? `Credits: -${fmt(totals.creditAmount, data.currency)}` : null,
totals.taxAmount ? `Tax: ${fmt(totals.taxAmount, data.currency)}` : null,
`Total: ${fmt(totals.totalAmount, data.currency)}`,
`Tax (${fmtTaxRate(totals.taxRate)}): ${fmt(totals.taxAmount, data.currency)}`,
`TTC: ${fmt(totals.totalAmount, data.currency)}`,
`Amount Paid: ${fmt(totals.amountPaid, data.currency)}`,
`Amount Due: ${fmt(totals.amountDue, data.currency)}`,
'',
@@ -88,6 +88,8 @@ describe('notificationService command boundaries', () => {
recipientType: 'EMPLOYEE',
employeeId: 'employee_1',
renterId: null,
billingContactId: null,
adminUserId: null,
},
})
expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({
@@ -141,7 +143,87 @@ describe('notificationService command boundaries', () => {
channels: ['IN_APP'],
})
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'An explicit employee or renter recipient is required' }])
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'An explicit employee, renter, billing contact, or admin recipient is required' }])
expect(prismaMock.tx.notificationEvent.create).not.toHaveBeenCalled()
})
it('treats placeholder email provider secrets as not configured', async () => {
const previousEnv = {
RESEND_API_KEY: process.env.RESEND_API_KEY,
EMAIL_FROM: process.env.EMAIL_FROM,
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
MAIL_HOST: process.env.MAIL_HOST,
MAIL_PORT: process.env.MAIL_PORT,
MAIL_USERNAME: process.env.MAIL_USERNAME,
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
MAIL_FROM_ADDRESS: process.env.MAIL_FROM_ADDRESS,
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
}
const resendConstructor = vi.fn()
try {
process.env.EMAIL_PROVIDER = 'gmail'
process.env.RESEND_API_KEY = 're_PLACEHOLDER'
process.env.EMAIL_FROM = 'noreply@example.com'
process.env.EMAIL_FROM_NAME = 'Example App'
process.env.MAIL_HOST = 'smtp.gmail.com'
process.env.MAIL_PORT = '587'
process.env.MAIL_USERNAME = 'rentaldrivego@gmail.com'
process.env.MAIL_PASSWORD = 'placeholder'
process.env.MAIL_FROM_ADDRESS = 'rentaldrivego@gmail.com'
process.env.MAIL_FROM_NAME = 'RentalDriveGo'
vi.resetModules()
vi.doMock('resend', () => ({ Resend: resendConstructor }))
vi.doMock('../lib/prisma', () => ({ prisma: prismaMock.prisma }))
vi.doMock('./notificationLocalizationService', async () => {
const actual = await vi.importActual<typeof import('./notificationLocalizationService')>('./notificationLocalizationService')
return {
...actual,
resolveNotificationLocale: vi.fn().mockResolvedValue('en'),
resolveNotificationTemplate: vi.fn(),
}
})
const service = await import('./notificationService')
expect(resendConstructor).not.toHaveBeenCalled()
expect(service.describeEmailProviderConfig()).toEqual({
selectedProvider: 'smtp',
resend: {
apiKey: 'placeholder',
sender: 'not configured',
enabled: false,
},
smtp: {
host: 'configured',
port: 'configured',
username: 'configured',
password: 'placeholder',
sender: 'configured',
transport: 'not configured',
issues: [
'MAIL_PASSWORD is a placeholder; set a Gmail app password',
'SMTP transport is not configured',
],
},
})
await expect(service.sendTransactionalEmail({
to: 'owner@example.test',
subject: 'Verify',
html: '<p>Verify</p>',
text: 'Verify',
})).rejects.toThrow('SMTP email provider is selected but not configured: MAIL_PASSWORD is a placeholder; set a Gmail app password')
} finally {
Object.entries(previousEnv).forEach(([key, value]) => {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
})
vi.resetModules()
}
})
})
+399 -32
View File
@@ -3,47 +3,77 @@ import { prisma } from '../lib/prisma'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
import {
renderLocalizedEmailHtml,
coerceNotificationLocale,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
import type { NotificationTemplateVariables } from './notificationLocalizationService'
import { generateInvoicePdf } from './invoicePdfService'
const resendApiKey =
process.env.RESEND_API_KEY &&
process.env.RESEND_API_KEY !== 're_...' &&
process.env.RESEND_API_KEY.startsWith('re_')
? process.env.RESEND_API_KEY
: null
function envValue(name: string) {
const value = process.env[name]?.trim()
return value ? value : null
}
const resend = resendApiKey ? new Resend(resendApiKey) : null
function isPlaceholderEnvValue(value: string | null) {
if (!value) return true
const normalized = value.trim().toLowerCase()
return (
normalized.includes('${') ||
normalized.includes('placeholder') ||
normalized.includes('example') ||
normalized.startsWith('your-') ||
normalized === 'changeme' ||
normalized === 'change_me' ||
normalized === 're_...' ||
normalized === 're_placeholder'
)
}
function resolveEmailProvider() {
const value = envValue('EMAIL_PROVIDER')?.toLowerCase()
if (value === 'gmail') return 'smtp'
if (value === 'smtp' || value === 'resend') return value
return 'auto'
}
function resolveResendApiKey() {
const value = envValue('RESEND_API_KEY')
return value && value.startsWith('re_') && !isPlaceholderEnvValue(value) ? value : null
}
const emailProvider = resolveEmailProvider()
const resendApiKey = resolveResendApiKey()
const resend = emailProvider !== 'smtp' && resendApiKey ? new Resend(resendApiKey) : null
const emailFromAddress =
process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com'
? process.env.EMAIL_FROM
envValue('EMAIL_FROM') && !isPlaceholderEnvValue(envValue('EMAIL_FROM'))
? envValue('EMAIL_FROM')
: null
const emailFromName =
process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App'
? process.env.EMAIL_FROM_NAME
envValue('EMAIL_FROM_NAME') && !isPlaceholderEnvValue(envValue('EMAIL_FROM_NAME'))
? envValue('EMAIL_FROM_NAME')
: null
const smtpHost = process.env.MAIL_HOST
const smtpPort = Number(process.env.MAIL_PORT ?? 0)
const smtpUser = process.env.MAIL_USERNAME
const smtpPass = process.env.MAIL_PASSWORD
const smtpHost = envValue('MAIL_HOST')
const smtpPort = Number(envValue('MAIL_PORT') ?? 0)
const smtpUser = envValue('MAIL_USERNAME')
const smtpPass = envValue('MAIL_PASSWORD')
const smtpSecure =
process.env.MAIL_SCHEME === 'smtps' ||
process.env.MAIL_ENCRYPTION === 'ssl' ||
smtpPort === 465
const smtpFromAddress =
process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${')
? process.env.MAIL_FROM_ADDRESS
envValue('MAIL_FROM_ADDRESS') && !isPlaceholderEnvValue(envValue('MAIL_FROM_ADDRESS'))
? envValue('MAIL_FROM_ADDRESS')
: null
const smtpFromName =
process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${')
? process.env.MAIL_FROM_NAME
envValue('MAIL_FROM_NAME') && !isPlaceholderEnvValue(envValue('MAIL_FROM_NAME'))
? envValue('MAIL_FROM_NAME')
: null
type SmtpTransport = {
@@ -52,7 +82,7 @@ type SmtpTransport = {
let smtpTransport: SmtpTransport | null = null
if (smtpHost && smtpPort && smtpUser && smtpPass) {
if (smtpHost && smtpPort && smtpUser && smtpPass && !isPlaceholderEnvValue(smtpPass)) {
try {
const nodemailer = require('nodemailer') as {
createTransport(options: Record<string, unknown>): SmtpTransport
@@ -80,6 +110,8 @@ interface SendNotificationOptions {
companyId?: string
employeeId?: string
renterId?: string
billingContactId?: string
adminUserId?: string
billingAccountId?: string
email?: string
phone?: string
@@ -91,11 +123,14 @@ interface SendNotificationOptions {
idempotencyKey?: string
sourceType?: string
sourceId?: string
policy?: NotificationPolicy
}
type NotificationAudience =
| { type: 'EMPLOYEE'; employeeId: string }
| { type: 'RENTER'; renterId: string }
| { type: 'BILLING_CONTACT'; billingContactId: string }
| { type: 'ADMIN'; adminUserId: string }
| { type: 'COMPANY_EMPLOYEES' }
type NotificationPolicy = {
@@ -130,7 +165,15 @@ function uniqueChannels(channels: NotificationChannel[] | undefined): Notificati
}
function buildLegacyIdempotencyKey(opts: SendNotificationOptions) {
const target = opts.employeeId ? `employee:${opts.employeeId}` : opts.renterId ? `renter:${opts.renterId}` : `company:${opts.companyId ?? 'none'}`
const target = opts.employeeId
? `employee:${opts.employeeId}`
: opts.renterId
? `renter:${opts.renterId}`
: opts.billingContactId
? `billing-contact:${opts.billingContactId}`
: opts.adminUserId
? `admin:${opts.adminUserId}`
: `company:${opts.companyId ?? 'none'}`
const sourceId = opts.sourceId ?? String(opts.data?.id ?? opts.data?.reservationId ?? opts.data?.bookingId ?? target)
return [
'legacy-notification',
@@ -150,7 +193,7 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
where: { id: audience.employeeId, companyId, isActive: true },
select: { id: true, email: true, preferredLanguage: true },
})
return employee ? [{ recipientType: 'EMPLOYEE' as const, employeeId: employee.id, renterId: null, email: employee.email, locale: employee.preferredLanguage }] : []
return employee ? [{ recipientType: 'EMPLOYEE' as const, employeeId: employee.id, renterId: null, billingContactId: null, adminUserId: null, email: employee.email, locale: employee.preferredLanguage }] : []
}
if (audience.type === 'RENTER') {
@@ -158,7 +201,23 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
where: { id: audience.renterId },
select: { id: true, email: true, preferredLocale: true },
})
return renter ? [{ recipientType: 'RENTER' as const, employeeId: null, renterId: renter.id, email: renter.email, locale: renter.preferredLocale }] : []
return renter ? [{ recipientType: 'RENTER' as const, employeeId: null, renterId: renter.id, billingContactId: null, adminUserId: null, email: renter.email, locale: renter.preferredLocale }] : []
}
if (audience.type === 'BILLING_CONTACT') {
const contact = await prisma.billingContact.findFirst({
where: { id: audience.billingContactId, companyId, isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } },
select: { id: true, email: true, locale: true },
})
return contact ? [{ recipientType: 'BILLING_CONTACT' as const, employeeId: null, renterId: null, billingContactId: contact.id, adminUserId: null, email: contact.email, locale: contact.locale }] : []
}
if (audience.type === 'ADMIN') {
const admin = await prisma.adminUser.findFirst({
where: { id: audience.adminUserId, isActive: true },
select: { id: true, email: true, preferredLocale: true },
})
return admin ? [{ recipientType: 'ADMIN' as const, employeeId: null, renterId: null, billingContactId: null, adminUserId: admin.id, email: admin.email, locale: admin.preferredLocale }] : []
}
const employees = await prisma.employee.findMany({
@@ -169,6 +228,8 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
recipientType: 'EMPLOYEE' as const,
employeeId: employee.id,
renterId: null,
billingContactId: null,
adminUserId: null,
email: employee.email,
locale: employee.preferredLanguage,
}))
@@ -178,6 +239,8 @@ async function resolvePreferenceDecision(input: {
companyId: string
employeeId: string | null
renterId: string | null
billingContactId?: string | null
adminUserId?: string | null
type: NotificationType
channel: NotificationChannel
policy?: NotificationPolicy
@@ -194,9 +257,11 @@ async function resolvePreferenceDecision(input: {
const personalWhere = input.employeeId
? { employeeId: input.employeeId, notificationType: input.type, channel: input.channel }
: { renterId: input.renterId!, notificationType: input.type, channel: input.channel }
: input.renterId
? { renterId: input.renterId, notificationType: input.type, channel: input.channel }
: null
const personal = await prisma.notificationPreference.findFirst({ where: personalWhere as any })
const personal = personalWhere ? await prisma.notificationPreference.findFirst({ where: personalWhere as any }) : null
if (personal) {
return {
enabled: personal.enabled,
@@ -228,15 +293,61 @@ async function resolvePreferenceDecision(input: {
}
function resolveSmtpReplyTo() {
if (!process.env.MAIL_REPLY_TO_ADDRESS || process.env.MAIL_REPLY_TO_ADDRESS.includes('${')) {
const replyToAddress = envValue('MAIL_REPLY_TO_ADDRESS')
const replyToName = envValue('MAIL_REPLY_TO_NAME')
if (!replyToAddress || isPlaceholderEnvValue(replyToAddress)) {
return undefined
}
if (process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')) {
return `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>`
if (replyToName && !isPlaceholderEnvValue(replyToName)) {
return `${replyToName} <${replyToAddress}>`
}
return process.env.MAIL_REPLY_TO_ADDRESS
return replyToAddress
}
function envStatus(value: string | null, configured: boolean) {
if (!value) return 'not set'
if (isPlaceholderEnvValue(value)) return 'placeholder'
return configured ? 'configured' : 'invalid format'
}
function smtpConfigurationIssues() {
const issues: string[] = []
if (!smtpHost) issues.push('MAIL_HOST is not set')
if (!smtpPort) issues.push('MAIL_PORT is not set or invalid')
if (!smtpUser) issues.push('MAIL_USERNAME is not set')
if (!smtpPass) {
issues.push('MAIL_PASSWORD is not set')
} else if (isPlaceholderEnvValue(smtpPass)) {
issues.push('MAIL_PASSWORD is a placeholder; set a Gmail app password')
}
if (!smtpFromAddress) issues.push('MAIL_FROM_ADDRESS is not configured')
if (!smtpTransport) issues.push('SMTP transport is not configured')
return issues
}
export function describeEmailProviderConfig() {
const rawResendKey = envValue('RESEND_API_KEY')
const rawMailPassword = envValue('MAIL_PASSWORD')
return {
selectedProvider: emailProvider,
resend: {
apiKey: envStatus(rawResendKey, Boolean(resendApiKey)),
sender: emailFromAddress && emailFromName ? 'configured' : 'not configured',
enabled: emailProvider !== 'smtp' && Boolean(resend),
},
smtp: {
host: smtpHost ? 'configured' : 'not set',
port: smtpPort ? 'configured' : 'not set',
username: smtpUser ? 'configured' : 'not set',
password: envStatus(rawMailPassword, Boolean(smtpPass && !isPlaceholderEnvValue(smtpPass))),
sender: smtpFromAddress ? 'configured' : 'not configured',
transport: smtpTransport ? 'configured' : 'not configured',
issues: smtpConfigurationIssues(),
},
}
}
async function sendEmailWithProviders(opts: {
@@ -244,9 +355,14 @@ async function sendEmailWithProviders(opts: {
subject: string
html: string
text: string
attachments?: EmailAttachment[]
}) {
const errors: string[] = []
if (emailProvider === 'smtp' && !smtpTransport) {
throw new Error(`SMTP email provider is selected but not configured: ${smtpConfigurationIssues().join('; ')}`)
}
if (resend) {
try {
if (!emailFromAddress || !emailFromName) {
@@ -259,6 +375,10 @@ async function sendEmailWithProviders(opts: {
subject: opts.subject,
html: opts.html,
text: opts.text,
attachments: opts.attachments?.map((attachment) => ({
filename: attachment.filename,
content: attachment.content.toString('base64'),
})),
})
if (error) {
@@ -288,6 +408,7 @@ async function sendEmailWithProviders(opts: {
html: opts.html,
text: opts.text,
replyTo: resolveSmtpReplyTo(),
attachments: opts.attachments,
})
return {
@@ -306,6 +427,94 @@ async function sendEmailWithProviders(opts: {
throw new Error('No email provider is configured')
}
type EmailAttachment = {
filename: string
content: Buffer
contentType?: string
}
function invoiceTaxRate(invoice: { taxRecords?: Array<{ taxRate?: number | null; taxExempt?: boolean }> }) {
return invoice.taxRecords?.find((record) => !record.taxExempt && typeof record.taxRate === 'number')?.taxRate ?? null
}
async function buildInvoicePdfAttachment(invoiceId: string): Promise<EmailAttachment | null> {
const invoice = await prisma.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
company: true,
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
},
})
if (!invoice?.invoiceNumber) return null
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt: any) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
const content = await generateInvoicePdf({
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(),
dueDate: invoice.dueAt?.toISOString() ?? null,
company: {
name: invoice.billingName ?? invoice.company.name,
email: invoice.billingEmail ?? invoice.company.email,
phone: invoice.company.phone,
address: invoice.billingAddress ?? invoice.company.address,
},
subscription: invoice.subscription
? {
plan: invoice.subscription.plan,
billingPeriod: invoice.subscription.billingPeriod,
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
currency: invoice.subscription.currency,
}
: undefined,
amount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider ?? 'MANUAL',
transactionId: latestPaymentAttempt?.providerPaymentId ?? latestPaymentAttempt?.externalReference ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description,
amount: item.amount,
currency: item.currency,
quantity: item.quantity,
unitAmount: item.unitAmount,
periodStart: item.periodStart?.toISOString() ?? null,
periodEnd: item.periodEnd?.toISOString() ?? null,
})),
totals: {
subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
amountDue: invoice.amountDue,
},
})
return {
filename: `${invoice.invoiceNumber}.pdf`,
content,
contentType: 'application/pdf',
}
}
async function resolveEmailAttachments(event: any): Promise<EmailAttachment[]> {
const attachments = Array.isArray(event.data?.emailAttachments) ? event.data.emailAttachments : []
const resolved: EmailAttachment[] = []
for (const attachment of attachments) {
if (attachment?.type === 'invoice_pdf' && typeof attachment.invoiceId === 'string') {
const invoicePdf = await buildInvoicePdfAttachment(attachment.invoiceId)
if (invoicePdf) resolved.push(invoicePdf)
}
}
return resolved
}
export async function sendNotification(opts: SendNotificationOptions) {
if (!opts.companyId) {
return uniqueChannels(opts.channels).map((channel) => ({
@@ -319,13 +528,17 @@ export async function sendNotification(opts: SendNotificationOptions) {
? { type: 'EMPLOYEE', employeeId: opts.employeeId }
: opts.renterId
? { type: 'RENTER', renterId: opts.renterId }
: null
: opts.billingContactId
? { type: 'BILLING_CONTACT', billingContactId: opts.billingContactId }
: opts.adminUserId
? { type: 'ADMIN', adminUserId: opts.adminUserId }
: null
if (!audience) {
return uniqueChannels(opts.channels).map((channel) => ({
channel,
success: false,
error: 'An explicit employee or renter recipient is required',
error: 'An explicit employee, renter, billing contact, or admin recipient is required',
}))
}
@@ -346,6 +559,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
body: opts.body,
data: opts.data,
locale: opts.locale,
policy: opts.policy,
})
return command.deliveries.map((delivery) => ({
@@ -369,7 +583,7 @@ export async function createNotification(opts: CreateNotificationOptions) {
})
const channels = uniqueChannels(opts.channels)
const renderChannel: NotificationChannel = channels.includes('IN_APP') ? 'IN_APP' : channels[0]!
const rendered = opts.templateKey
const rendered = opts.templateKey && (!opts.title || !opts.body)
? await resolveNotificationTemplate({
templateKey: opts.templateKey,
channel: renderChannel,
@@ -415,6 +629,8 @@ export async function createNotification(opts: CreateNotificationOptions) {
recipientType: recipient.recipientType,
employeeId: recipient.employeeId,
renterId: recipient.renterId,
billingContactId: recipient.billingContactId,
adminUserId: recipient.adminUserId,
},
})
@@ -423,6 +639,8 @@ export async function createNotification(opts: CreateNotificationOptions) {
companyId: opts.companyId,
employeeId: recipient.employeeId,
renterId: recipient.renterId,
billingContactId: recipient.billingContactId,
adminUserId: recipient.adminUserId,
type: opts.type,
channel,
policy: opts.policy,
@@ -472,6 +690,155 @@ export async function createNotification(opts: CreateNotificationOptions) {
}
}
const COLLECTIONS_REMINDER_TYPES = new Set<NotificationType>([
'SUBSCRIPTION_PAYMENT_DUE_14D',
'SUBSCRIPTION_PAYMENT_DUE_7D',
'SUBSCRIPTION_PAYMENT_DUE_48H',
'SUBSCRIPTION_PAYMENT_DUE_24H',
'SUBSCRIPTION_GRACE_DAILY',
'SUBSCRIPTION_GRACE_FINAL',
'COLLECTIONS_CALL_REQUIRED',
])
async function deliveryEmail(recipient: any) {
return recipient.employee?.email
?? recipient.renter?.email
?? recipient.billingContact?.email
?? recipient.adminUser?.email
?? null
}
export async function processNotificationOutbox(limit = 50) {
const entries = await prisma.notificationOutbox.findMany({
where: { status: 'PENDING' },
include: {
notificationEvent: {
include: {
recipients: {
include: {
employee: { select: { email: true } },
renter: { select: { email: true } },
billingContact: { select: { email: true, isActive: true, verifiedAt: true } },
adminUser: { select: { email: true, isActive: true } },
deliveries: true,
},
},
},
},
},
orderBy: { createdAt: 'asc' },
take: Math.max(1, Math.min(limit, 200)),
})
let processed = 0
for (const entry of entries as any[]) {
const event = entry.notificationEvent
if (event.sourceType === 'collections_case' && COLLECTIONS_REMINDER_TYPES.has(event.type)) {
const collectionsCase = await prisma.collectionsCase.findUnique({ where: { id: event.sourceId }, select: { status: true } })
if (!collectionsCase || ['RESOLVED', 'SUSPENDED'].includes(collectionsCase.status)) {
await prisma.notificationDelivery.updateMany({
where: { notificationRecipient: { notificationEventId: event.id }, status: { in: ['PENDING', 'QUEUED', 'FAILED'] } },
data: { status: 'SKIPPED', failureCode: 'COLLECTIONS_CASE_CLOSED', failureReason: 'Suppressed because the collections case is closed.' },
})
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
processed += 1
continue
}
}
for (const recipient of event.recipients) {
for (const delivery of recipient.deliveries) {
if (!['PENDING', 'QUEUED', 'FAILED'].includes(delivery.status)) continue
if (delivery.nextAttemptAt && delivery.nextAttemptAt > new Date()) continue
try {
if (delivery.channel === 'IN_APP') {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: { status: 'SENT', sentAt: new Date(), attemptCount: { increment: 1 }, lastAttemptAt: new Date() },
})
} else if (delivery.channel === 'EMAIL') {
const to = await deliveryEmail(recipient)
const externalContactInvalid = recipient.billingContact && (!recipient.billingContact.isActive || !recipient.billingContact.verifiedAt)
const adminInvalid = recipient.adminUser && !recipient.adminUser.isActive
if (!to || externalContactInvalid || adminInvalid) throw new Error('Recipient email is unavailable')
const result = await sendEmailWithProviders({
to,
subject: event.title,
html: renderLocalizedEmailHtml(event.body, coerceNotificationLocale(event.locale)),
text: event.body,
attachments: await resolveEmailAttachments(event),
})
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: {
status: 'SENT',
sentAt: new Date(),
provider: result.provider,
providerMessageId: result.providerMessageId,
attemptCount: { increment: 1 },
lastAttemptAt: new Date(),
failureCode: null,
failureReason: null,
},
})
} else {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: { status: 'SKIPPED', failureCode: 'UNSUPPORTED_CHANNEL', failureReason: 'Channel is not implemented.' },
})
}
} catch (error: any) {
const attempts = delivery.attemptCount + 1
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: {
status: attempts >= 5 ? 'DEAD_LETTER' : 'FAILED',
attemptCount: attempts,
lastAttemptAt: new Date(),
nextAttemptAt: attempts >= 5 ? null : new Date(Date.now() + Math.min(60, 2 ** attempts) * 60_000),
failureCode: 'DELIVERY_FAILED',
failureReason: String(error?.message ?? 'Delivery failed').slice(0, 500),
},
})
}
}
}
const remaining = await prisma.notificationDelivery.count({
where: {
notificationRecipient: { notificationEventId: event.id },
status: { in: ['PENDING', 'QUEUED', 'FAILED'] },
},
})
if (remaining === 0) {
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
processed += 1
}
}
return processed
}
export async function getAdminNotificationInbox(adminUserId: string, page = 1, pageSize = 50) {
const where = { adminUserId, archivedAt: null }
const [data, total, unread] = await Promise.all([
prisma.notificationRecipient.findMany({
where,
include: { notificationEvent: true, deliveries: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.notificationRecipient.count({ where }),
prisma.notificationRecipient.count({ where: { ...where, readAt: null } }),
])
return { data, total, unread, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) }
}
export async function markAdminNotificationRead(adminUserId: string, recipientId: string) {
const recipient = await prisma.notificationRecipient.findFirst({ where: { id: recipientId, adminUserId } })
if (!recipient) throw new Error('Admin notification not found')
return prisma.notificationRecipient.update({ where: { id: recipient.id }, data: { readAt: recipient.readAt ?? new Date() } })
}
export async function sendTransactionalEmail(opts: {
to: string
subject: string
@@ -0,0 +1,42 @@
import { spawn } from 'child_process'
export type PaymentEvidenceScanResult = {
status: 'CLEAN' | 'QUARANTINED' | 'SCAN_FAILED'
code: string
}
const SCAN_TIMEOUT_MS = Number(process.env.PAYMENT_EVIDENCE_SCAN_TIMEOUT_MS ?? 60_000)
export async function scanPaymentEvidenceFile(filePath: string): Promise<PaymentEvidenceScanResult> {
if (process.env.NODE_ENV !== 'production' && process.env.PAYMENT_EVIDENCE_SCANNER_MODE === 'stub-clean') {
return { status: 'CLEAN', code: 'DEVELOPMENT_STUB_CLEAN' }
}
const scannerPath = process.env.PAYMENT_EVIDENCE_SCANNER_PATH?.trim()
if (!scannerPath) return { status: 'SCAN_FAILED', code: 'SCANNER_NOT_CONFIGURED' }
return new Promise((resolve) => {
const child = spawn(scannerPath, ['--no-summary', filePath], {
shell: false,
stdio: ['ignore', 'ignore', 'ignore'],
})
let settled = false
const finish = (result: PaymentEvidenceScanResult) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(result)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish({ status: 'SCAN_FAILED', code: 'SCANNER_TIMEOUT' })
}, Number.isFinite(SCAN_TIMEOUT_MS) && SCAN_TIMEOUT_MS > 0 ? SCAN_TIMEOUT_MS : 60_000)
child.once('error', () => finish({ status: 'SCAN_FAILED', code: 'SCANNER_EXECUTION_FAILED' }))
child.once('exit', (code) => {
if (code === 0) finish({ status: 'CLEAN', code: 'SCANNER_CLEAN' })
else if (code === 1) finish({ status: 'QUARANTINED', code: 'MALWARE_DETECTED' })
else finish({ status: 'SCAN_FAILED', code: `SCANNER_EXIT_${code ?? 'UNKNOWN'}` })
})
})
}
+32
View File
@@ -59,6 +59,11 @@ import {
employeeLanguageSchema,
} from '../modules/auth/auth.employee.schemas'
import { renterUpdateSchema, renterFcmTokenSchema } from '../modules/auth/auth.renter.schemas'
import {
manualCheckoutSchema,
createManualPaymentSubmissionSchema,
communicationSettingsSchema,
} from '../modules/subscriptions/subscription.schemas'
// ─────────────────────────────────────────────────────────────────────────────
@@ -167,6 +172,10 @@ export const openApiDocument: JsonObject = {
EmployeeLanguage: s(employeeLanguageSchema),
RenterUpdate: s(renterUpdateSchema),
RenterFcmToken: s(renterFcmTokenSchema),
// ── Subscription billing ────────────────────────────────────
ManualSubscriptionCheckout: s(manualCheckoutSchema),
ManualPaymentSubmissionCreate: s(createManualPaymentSubmissionSchema),
SubscriptionCommunicationSettings: s(communicationSettingsSchema),
},
},
paths: {
@@ -982,6 +991,29 @@ export const openApiDocument: JsonObject = {
'/subscriptions/checkout': {
post: { tags: ['Subscriptions'], summary: 'Checkout new subscription (Owner)', responses: { '200': ok } },
},
'/subscriptions/payment-options': {
get: { tags: ['Subscriptions'], summary: 'Available subscription collection methods and safe payer instructions', responses: { '200': ok } },
},
'/subscriptions/manual-checkout': {
post: { tags: ['Subscriptions'], summary: 'Create or reuse a bank transfer/check invoice (Owner)', requestBody: jsonBody('#/components/schemas/ManualSubscriptionCheckout'), responses: { '200': ok, '400': err4 } },
},
'/subscriptions/invoices/{invoiceId}/manual-payment-submissions': {
post: { tags: ['Subscriptions'], summary: 'Create an evidence draft for an invoice (Owner)', parameters: [idPath('invoiceId')], requestBody: jsonBody('#/components/schemas/ManualPaymentSubmissionCreate'), responses: { '200': ok, '400': err4 } },
},
'/subscriptions/manual-payment-submissions/{submissionId}/documents': {
post: { tags: ['Subscriptions'], summary: 'Upload and scan one private evidence document (Owner)', parameters: [idPath('submissionId')], responses: { '201': ok, '400': err4 } },
},
'/subscriptions/manual-payment-submissions/{submissionId}/submit': {
post: { tags: ['Subscriptions'], summary: 'Lock and submit clean evidence for finance review (Owner)', parameters: [idPath('submissionId')], responses: { '200': ok, '400': err4 } },
},
'/subscriptions/manual-payment-submissions/{submissionId}/documents/{documentId}': {
get: { tags: ['Subscriptions'], summary: 'Download owned clean evidence (Owner)', parameters: [idPath('submissionId'), idPath('documentId')], responses: { '200': ok, '404': err404 } },
delete: { tags: ['Subscriptions'], summary: 'Delete evidence while the submission is a draft (Owner)', parameters: [idPath('submissionId'), idPath('documentId')], responses: { '200': ok, '404': err404 } },
},
'/subscriptions/communication-settings': {
get: { tags: ['Subscriptions'], summary: 'Billing contacts, timezone, and AR/EN/FR policy (Owner)', responses: { '200': ok } },
put: { tags: ['Subscriptions'], summary: 'Update billing communication settings (Owner)', requestBody: jsonBody('#/components/schemas/SubscriptionCommunicationSettings'), responses: { '200': ok, '400': err4 } },
},
'/subscriptions/change-plan': {
post: { tags: ['Subscriptions'], summary: 'Change plan (Owner)', responses: { '200': ok } },
},
+2
View File
@@ -7,6 +7,8 @@ const delegates = [
'billingCreditNote',
'billingCreditLedgerEntry',
'billingCreditBalance',
'manualPaymentDocument',
'manualPaymentSubmission',
'billingPaymentAttempt',
'billingPaymentIntent',
'billingInvoiceLineItem',
@@ -3,12 +3,13 @@
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, apiFetch, resolveApiBase } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { buildHomepageSignInPath } from '@/lib/dashboardPaths'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type CollectionMethod = 'STRIPE' | 'BANK_TRANSFER' | 'CHECK'
interface Subscription {
id: string
@@ -23,12 +24,66 @@ interface Subscription {
interface Invoice {
id: string
invoiceNumber: string | null
amount: number
amountDue: number
currency: string
status: string
paymentProvider: string
collectionMethod: CollectionMethod
dueAt: string | null
paidAt: string | null
createdAt: string
manualPaymentSubmission?: ManualPaymentSubmission | null
}
interface PaymentOption {
method: CollectionMethod
enabled: boolean
instructions?: Record<string, string>
}
interface ManualPaymentDocument {
id: string
kind: string
originalFilename: string
byteSize: number
scanStatus: string
uploadedAt: string
}
interface ManualPaymentSubmission {
id: string
method: CollectionMethod
submittedReference: string
status: string
submittedAt: string | null
rejectionReason?: string | null
documents: ManualPaymentDocument[]
}
interface ManualCheckoutResult {
invoice: Invoice
instructions: Record<string, string>
}
type CommunicationLocale = 'ar' | 'en' | 'fr'
interface CommunicationSettings {
timezone: string
reminderLocalTime: string
enabledCommunicationLocales: CommunicationLocale[]
defaultCommunicationLocale: CommunicationLocale
contacts: Array<{
id?: string
employeeId?: string | null
email: string
locale?: CommunicationLocale | null
effectiveLocale?: CommunicationLocale
isPrimary: boolean
receivePaymentNotices: boolean
isActive: boolean
verified?: boolean
}>
}
interface ProviderAvailability {
@@ -61,6 +116,9 @@ const STATUS_BADGE: Record<string, string> = {
const INVOICE_STATUS: Record<string, string> = {
PAID: 'bg-green-100 text-green-700',
PENDING: 'bg-orange-100 text-orange-700',
OPEN: 'bg-orange-100 text-orange-700',
PAYMENT_PENDING: 'bg-orange-100 text-orange-700',
PAST_DUE: 'bg-red-100 text-red-700',
FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600',
}
@@ -72,6 +130,30 @@ const PLAN_LABELS: Record<Plan, string> = {
PRO: 'Pro',
ENTERPRISE: 'Enterprise',
}
const PAYMENT_EVIDENCE_MAX_FILES = 3
const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
const PAYMENT_EVIDENCE_ACCEPT = 'application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png'
const PAYMENT_EVIDENCE_EXTENSIONS = new Set(['pdf', 'jpg', 'jpeg', 'png'])
const PAYMENT_EVIDENCE_MIME_TYPES = new Set(['application/pdf', 'application/x-pdf', 'application/octet-stream', 'image/jpeg', 'image/pjpeg', 'image/png', 'image/x-png'])
function validatePaymentEvidenceFiles(files: File[]) {
if (files.length > PAYMENT_EVIDENCE_MAX_FILES) return 'Upload at most three evidence files.'
for (const file of files) {
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!PAYMENT_EVIDENCE_EXTENSIONS.has(extension)) {
return 'Evidence files must be PDF, JPEG, or PNG.'
}
if (file.type && !PAYMENT_EVIDENCE_MIME_TYPES.has(file.type.toLowerCase())) {
return 'Evidence files must be PDF, JPEG, or PNG.'
}
if (file.size <= 0 || file.size > PAYMENT_EVIDENCE_MAX_FILE_SIZE) {
return 'Each evidence file must be between 1 byte and 10 MB.'
}
}
return null
}
export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n()
@@ -86,12 +168,23 @@ export default function SubscriptionPage() {
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const currency = 'MAD'
const provider = 'STRIPE'
const [selectedMethod, setSelectedMethod] = useState<CollectionMethod>('STRIPE')
const [paymentOptions, setPaymentOptions] = useState<PaymentOption[]>([])
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ stripe: false })
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [manualCheckout, setManualCheckout] = useState<ManualCheckoutResult | null>(null)
const [manualPaymentRequestNumber, setManualPaymentRequestNumber] = useState<string | null>(null)
const [paymentReference, setPaymentReference] = useState('')
const [evidenceFiles, setEvidenceFiles] = useState<File[]>([])
const [paymentSubmission, setPaymentSubmission] = useState<ManualPaymentSubmission | null>(null)
const [submittingEvidence, setSubmittingEvidence] = useState(false)
const [checkoutIdempotencyKey, setCheckoutIdempotencyKey] = useState<string | null>(null)
const [submissionIdempotencyKey, setSubmissionIdempotencyKey] = useState<string | null>(null)
const [communicationSettings, setCommunicationSettings] = useState<CommunicationSettings | null>(null)
const [savingCommunicationSettings, setSavingCommunicationSettings] = useState(false)
const copy = {
en: {
title: 'Subscription',
@@ -107,6 +200,39 @@ export default function SubscriptionPage() {
changePlan: 'Change plan',
subscribe: 'Subscribe',
selectPlan: 'Select a plan to continue to Stripe checkout.',
selectPayment: 'Choose Stripe, bank transfer, or check. The server calculates the final amount.',
bankTransfer: 'Bank transfer',
check: 'Check',
creatingInvoice: 'Creating invoice…',
createInvoice: 'Create payment invoice',
submitPaymentEvidence: 'Submit payment evidence',
awaitingVerification: 'Awaiting finance verification',
invoiceNumber: 'Invoice',
paymentRequestNumber: 'Payment request number',
paymentRequestHelp: 'Use this generated number when contacting support about this payment.',
dueDate: 'Due date',
paymentInstructions: 'Payment instructions',
reference: 'Transaction reference or check number',
bankTransferReference: 'Bank transfer reference number',
checkNumber: 'Check number',
evidence: 'Payment evidence (PDF, JPEG, or PNG; up to 3 files)',
bankTransferEvidence: 'Bank transfer receipt (PDF, JPEG, or PNG; up to 3 files)',
checkEvidence: 'Check copy (PDF, JPEG, or PNG; up to 3 files)',
submitReview: 'Submit for review',
submittingReview: 'Submitting…',
evidenceWarning: 'Uploading a receipt does not prove settlement or activate the subscription. Finance must independently confirm cleared funds.',
manualDetailsTitle: 'Payment details',
manualDetailsHelp: 'Enter the payment number and attach the supporting file before submitting it for finance review.',
evidenceSubmitted: 'Evidence submitted and locked for finance review.',
communicationTitle: 'Billing communication settings',
communicationHelp: 'Choose the languages your company permits for future payment notices. Each contact receives one notice in their effective language.',
enabledLanguages: 'Enabled languages',
defaultLanguage: 'Default language',
timezone: 'Billing timezone',
contactLanguage: 'Contact language',
inheritDefault: 'Inherit company default',
saveSettings: 'Save communication settings',
settingsSaved: 'Communication settings saved.',
monthly: 'Monthly',
annual: 'Annual (save 20%)',
active: 'Active',
@@ -119,6 +245,7 @@ export default function SubscriptionPage() {
redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history',
invoice: 'Invoice',
date: 'Date',
provider: 'Provider',
status: 'Status',
@@ -156,6 +283,39 @@ export default function SubscriptionPage() {
changePlan: 'Changer de plan',
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan pour continuer vers Stripe Checkout.',
selectPayment: 'Choisissez Stripe, virement bancaire ou chèque. Le serveur calcule le montant final.',
bankTransfer: 'Virement bancaire',
check: 'Chèque',
creatingInvoice: 'Création de la facture…',
createInvoice: 'Créer la facture de paiement',
submitPaymentEvidence: 'Envoyer le justificatif',
awaitingVerification: 'En attente de vérification financière',
invoiceNumber: 'Facture',
paymentRequestNumber: 'Numéro de demande de paiement',
paymentRequestHelp: 'Utilisez ce numéro généré pour contacter le support au sujet de ce paiement.',
dueDate: 'Échéance',
paymentInstructions: 'Instructions de paiement',
reference: 'Référence du virement ou numéro de chèque',
bankTransferReference: 'Référence du virement bancaire',
checkNumber: 'Numéro du chèque',
evidence: 'Justificatif (PDF, JPEG ou PNG ; 3 fichiers maximum)',
bankTransferEvidence: 'Reçu du virement (PDF, JPEG ou PNG ; 3 fichiers maximum)',
checkEvidence: 'Copie du chèque (PDF, JPEG ou PNG ; 3 fichiers maximum)',
submitReview: 'Soumettre pour vérification',
submittingReview: 'Envoi…',
evidenceWarning: 'Le dépôt dun justificatif ne prouve pas le règlement et nactive pas labonnement. La finance doit confirmer les fonds encaissés.',
manualDetailsTitle: 'Détails du paiement',
manualDetailsHelp: 'Saisissez le numéro de paiement et joignez le justificatif avant de lenvoyer à la finance.',
evidenceSubmitted: 'Justificatifs soumis et verrouillés pour vérification.',
communicationTitle: 'Paramètres de communication de facturation',
communicationHelp: 'Choisissez les langues autorisées pour les prochains avis de paiement. Chaque contact reçoit un seul avis dans sa langue effective.',
enabledLanguages: 'Langues activées',
defaultLanguage: 'Langue par défaut',
timezone: 'Fuseau horaire de facturation',
contactLanguage: 'Langue du contact',
inheritDefault: 'Hériter de la langue par défaut',
saveSettings: 'Enregistrer les paramètres',
settingsSaved: 'Paramètres de communication enregistrés.',
monthly: 'Mensuel',
annual: 'Annuel (économie 20%)',
active: 'Actif',
@@ -168,6 +328,7 @@ export default function SubscriptionPage() {
redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures',
invoice: 'Facture',
date: 'Date',
provider: 'Prestataire',
status: 'Statut',
@@ -205,6 +366,39 @@ export default function SubscriptionPage() {
changePlan: 'تغيير الخطة',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة للمتابعة إلى Stripe Checkout.',
selectPayment: 'اختر Stripe أو التحويل البنكي أو الشيك. يحسب الخادم المبلغ النهائي.',
bankTransfer: 'تحويل بنكي',
check: 'شيك',
creatingInvoice: 'جارٍ إنشاء الفاتورة…',
createInvoice: 'إنشاء فاتورة الدفع',
submitPaymentEvidence: 'إرسال إثبات الدفع',
awaitingVerification: 'في انتظار تحقق فريق المالية',
invoiceNumber: 'الفاتورة',
paymentRequestNumber: 'رقم طلب الدفع',
paymentRequestHelp: 'استخدم هذا الرقم المُنشأ عند التواصل مع الدعم بخصوص هذا الدفع.',
dueDate: 'تاريخ الاستحقاق',
paymentInstructions: 'تعليمات الدفع',
reference: 'مرجع التحويل أو رقم الشيك',
bankTransferReference: 'رقم مرجع التحويل البنكي',
checkNumber: 'رقم الشيك',
evidence: 'إثبات الدفع (PDF أو JPEG أو PNG، بحد أقصى 3 ملفات)',
bankTransferEvidence: 'إيصال التحويل البنكي (PDF أو JPEG أو PNG، بحد أقصى 3 ملفات)',
checkEvidence: 'نسخة الشيك (PDF أو JPEG أو PNG، بحد أقصى 3 ملفات)',
submitReview: 'إرسال للمراجعة',
submittingReview: 'جارٍ الإرسال…',
evidenceWarning: 'رفع الإيصال لا يثبت وصول الأموال ولا يفعّل الاشتراك. يجب أن يؤكد فريق المالية تحصيل المبلغ بشكل مستقل.',
manualDetailsTitle: 'تفاصيل الدفع',
manualDetailsHelp: 'أدخل رقم الدفع وأرفق المستند الداعم قبل إرساله إلى فريق المالية.',
evidenceSubmitted: 'تم إرسال المستندات وقفلها لمراجعة فريق المالية.',
communicationTitle: 'إعدادات اتصالات الفوترة',
communicationHelp: 'اختر اللغات التي تسمح بها الشركة لإشعارات الدفع المستقبلية. يتلقى كل مسؤول إشعاراً واحداً بلغته الفعلية.',
enabledLanguages: 'اللغات المفعّلة',
defaultLanguage: 'اللغة الافتراضية',
timezone: 'المنطقة الزمنية للفوترة',
contactLanguage: 'لغة جهة الاتصال',
inheritDefault: 'استخدام لغة الشركة الافتراضية',
saveSettings: 'حفظ إعدادات الاتصال',
settingsSaved: 'تم حفظ إعدادات الاتصال.',
monthly: 'شهري',
annual: 'سنوي (توفير 20%)',
active: 'نشط',
@@ -217,6 +411,7 @@ export default function SubscriptionPage() {
redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير',
invoice: 'الفاتورة',
date: 'التاريخ',
provider: 'المزوّد',
status: 'الحالة',
@@ -288,10 +483,16 @@ export default function SubscriptionPage() {
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
apiFetch<{ methods: PaymentOption[] }>('/subscriptions/payment-options'),
apiFetch<CommunicationSettings>('/subscriptions/communication-settings'),
fetchPlanData(),
])
.then(([sub, inv, availability]) => {
.then(([sub, inv, availability, options, settings]) => {
setProviderAvailability(availability)
setPaymentOptions(options.methods ?? [])
setCommunicationSettings(settings)
const firstEnabled = options.methods?.find((option) => option.enabled)
if (firstEnabled) setSelectedMethod(firstEnabled.method)
if (sub) {
setSubscription(sub)
setSelectedPlan(sub.plan)
@@ -299,6 +500,13 @@ export default function SubscriptionPage() {
// currency is always MAD
}
setInvoices(inv ?? [])
const latestManual = inv?.find((invoice) => invoice.collectionMethod !== 'STRIPE' && ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE'].includes(invoice.status))
if (latestManual) {
const option = options.methods?.find((item) => item.method === latestManual.collectionMethod)
setSelectedMethod(latestManual.collectionMethod)
setManualCheckout({ invoice: latestManual, instructions: option?.instructions ?? {} })
if (latestManual.manualPaymentSubmission) setPaymentSubmission(latestManual.manualPaymentSubmission)
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
@@ -359,6 +567,55 @@ export default function SubscriptionPage() {
setPaying(true)
setError(null)
try {
const selectedOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
if (!selectedOption) throw new Error(copy.providerUnavailable)
if (selectedMethod !== 'STRIPE') {
if (paymentReference.trim().length < 3) throw new Error(`${manualReferenceLabel} is required`)
if (evidenceFiles.length === 0) throw new Error(manualEvidenceLabel)
const fileError = validatePaymentEvidenceFiles(evidenceFiles)
if (fileError) throw new Error(fileError)
const idempotencyKey = checkoutIdempotencyKey ?? crypto.randomUUID()
setCheckoutIdempotencyKey(idempotencyKey)
const result = await apiFetch<ManualCheckoutResult>('/subscriptions/manual-checkout', {
method: 'POST',
body: JSON.stringify({ plan: selectedPlan, billingPeriod, currency, method: selectedMethod, idempotencyKey }),
})
setManualPaymentRequestNumber(result.invoice.invoiceNumber ?? result.invoice.id)
const key = submissionIdempotencyKey ?? crypto.randomUUID()
setSubmissionIdempotencyKey(key)
const submission = await apiFetch<ManualPaymentSubmission>(
`/subscriptions/invoices/${result.invoice.id}/manual-payment-submissions`,
{
method: 'POST',
body: JSON.stringify({ method: selectedMethod, submittedReference: paymentReference, idempotencyKey: key }),
},
)
let current = submission
for (const file of evidenceFiles) {
const form = new FormData()
form.append('kind', selectedMethod === 'CHECK' ? 'CHECK_COPY' : 'BANK_TRANSFER_RECEIPT')
form.append('file', file)
const document = await apiFetch<ManualPaymentDocument>(
`/subscriptions/manual-payment-submissions/${submission.id}/documents`,
{ method: 'POST', body: form },
)
current = { ...current, documents: [...current.documents.filter((item) => item.id !== document.id), document] }
setPaymentSubmission(current)
if (document.scanStatus !== 'CLEAN') throw new Error(`Evidence scan status: ${document.scanStatus}`)
}
const submitted = await apiFetch<ManualPaymentSubmission>(
`/subscriptions/manual-payment-submissions/${submission.id}/submit`,
{ method: 'POST' },
)
setPaymentSubmission(submitted)
setManualCheckout(null)
setEvidenceFiles([])
setCheckoutIdempotencyKey(null)
setSubmissionIdempotencyKey(null)
setPaying(false)
return
}
if (!providerAvailability.stripe) throw new Error(copy.providerUnavailable)
const currentUrl = new URL(window.location.href)
currentUrl.search = ''
@@ -369,7 +626,7 @@ export default function SubscriptionPage() {
plan: selectedPlan,
billingPeriod,
currency,
provider,
provider: 'STRIPE',
successUrl: `${currentUrl.toString()}?payment=success`,
failureUrl: `${currentUrl.toString()}?payment=failed`,
}),
@@ -381,6 +638,75 @@ export default function SubscriptionPage() {
}
}
async function handleEvidenceSubmit() {
if (!manualCheckout) return
setSubmittingEvidence(true)
setError(null)
try {
const key = submissionIdempotencyKey ?? crypto.randomUUID()
setSubmissionIdempotencyKey(key)
const fileError = validatePaymentEvidenceFiles(evidenceFiles)
if (fileError) throw new Error(fileError)
const reusableDraft = paymentSubmission?.status === 'DRAFT' ? paymentSubmission : null
const submission = reusableDraft ?? await apiFetch<ManualPaymentSubmission>(
`/subscriptions/invoices/${manualCheckout.invoice.id}/manual-payment-submissions`,
{
method: 'POST',
body: JSON.stringify({ method: selectedMethod, submittedReference: paymentReference, idempotencyKey: key }),
},
)
let current = submission
const pendingFiles = [...evidenceFiles]
for (const file of pendingFiles) {
const form = new FormData()
form.append('kind', selectedMethod === 'CHECK' ? 'CHECK_COPY' : 'BANK_TRANSFER_RECEIPT')
form.append('file', file)
const document = await apiFetch<ManualPaymentDocument>(
`/subscriptions/manual-payment-submissions/${submission.id}/documents`,
{ method: 'POST', body: form },
)
current = { ...current, documents: [...current.documents.filter((item) => item.id !== document.id), document] }
setPaymentSubmission(current)
setEvidenceFiles((files) => files.filter((item) => item !== file))
if (document.scanStatus !== 'CLEAN') throw new Error(`Evidence scan status: ${document.scanStatus}`)
}
const submitted = await apiFetch<ManualPaymentSubmission>(
`/subscriptions/manual-payment-submissions/${submission.id}/submit`,
{ method: 'POST' },
)
setPaymentSubmission(submitted)
setEvidenceFiles([])
setSubmissionIdempotencyKey(null)
} catch (err: any) {
setError(err.message)
} finally {
setSubmittingEvidence(false)
}
}
async function saveCommunicationSettings() {
if (!communicationSettings) return
setSavingCommunicationSettings(true)
setError(null)
try {
const saved = await apiFetch<CommunicationSettings>('/subscriptions/communication-settings', {
method: 'PUT',
body: JSON.stringify({
timezone: communicationSettings.timezone,
reminderLocalTime: communicationSettings.reminderLocalTime,
enabledCommunicationLocales: communicationSettings.enabledCommunicationLocales,
defaultCommunicationLocale: communicationSettings.defaultCommunicationLocale,
contacts: communicationSettings.contacts.map(({ effectiveLocale: _effectiveLocale, verified: _verified, ...contact }) => contact),
}),
})
setCommunicationSettings(saved)
} catch (err: any) {
setError(err.message)
} finally {
setSavingCommunicationSettings(false)
}
}
async function handleCancel() {
setCancelling(true)
setError(null)
@@ -394,6 +720,20 @@ export default function SubscriptionPage() {
}
}
function handleEvidenceFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.target.files
const selectedFiles = Array.from(files ?? [])
const fileError = validatePaymentEvidenceFiles(selectedFiles)
if (fileError) {
setEvidenceFiles([])
event.target.value = ''
setError(fileError)
return
}
setError(null)
setEvidenceFiles(selectedFiles)
}
async function handleResume() {
setCancelling(true)
setError(null)
@@ -411,6 +751,64 @@ export default function SubscriptionPage() {
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
const selectedPaymentOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
const isManualMethod = selectedMethod === 'BANK_TRANSFER' || selectedMethod === 'CHECK'
const manualReferenceLabel = selectedMethod === 'CHECK' ? copy.checkNumber : copy.bankTransferReference
const manualEvidenceLabel = selectedMethod === 'CHECK' ? copy.checkEvidence : copy.bankTransferEvidence
const manualEvidenceKindLabel = selectedMethod === 'CHECK' ? copy.check : copy.bankTransfer
const manualDetailsForm = isManualMethod ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
<div>
<p className="font-semibold text-slate-900">{copy.manualDetailsTitle}</p>
<p className="mt-1 text-sm text-slate-500">{copy.manualDetailsHelp}</p>
</div>
{selectedPaymentOption?.instructions && !manualCheckout ? (
<div className="mt-4 rounded-xl bg-white p-4">
<p className="text-sm font-semibold text-slate-900">{copy.paymentInstructions}</p>
<dl className="mt-2 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
{Object.entries(selectedPaymentOption.instructions).map(([key, value]) => (
<div key={key}><dt className="text-xs uppercase text-slate-500">{key}</dt><dd className="font-medium">{value}</dd></div>
))}
</dl>
</div>
) : null}
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<label className="block text-sm font-medium text-slate-800">
{manualReferenceLabel}
<input
value={paymentReference}
onChange={(event) => setPaymentReference(event.target.value)}
maxLength={120}
inputMode="text"
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2"
/>
</label>
<label className="block text-sm font-medium text-slate-800">
{manualEvidenceLabel}
<input
type="file"
accept={PAYMENT_EVIDENCE_ACCEPT}
multiple
onChange={handleEvidenceFileChange}
className="mt-1 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"
/>
</label>
</div>
{evidenceFiles.length > 0 ? (
<div className="mt-3 space-y-1">
{evidenceFiles.map((file) => (
<p key={`${file.name}-${file.size}-${file.lastModified}`} className="text-xs text-slate-600">
{manualEvidenceKindLabel}: {file.name}
</p>
))}
</div>
) : null}
{paymentSubmission?.documents.map((document) => (
<p key={document.id} className="mt-2 text-xs text-slate-600">{document.originalFilename} · {document.scanStatus}</p>
))}
<p className="mt-3 text-xs text-amber-900">{copy.evidenceWarning}</p>
</div>
) : null
return (
<div className="space-y-8">
@@ -470,7 +868,7 @@ export default function SubscriptionPage() {
{/* Plan selector + checkout */}
<div className="card p-6 space-y-6">
{!providerAvailability.stripe ? (
{paymentOptions.length > 0 && !paymentOptions.some((option) => option.enabled) ? (
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
{copy.noProviderConfigured}
{providerAvailability.stripeProblems && providerAvailability.stripeProblems.length > 0 ? (
@@ -486,7 +884,7 @@ export default function SubscriptionPage() {
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3>
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
<p className="mt-1 text-sm text-slate-500">{copy.selectPayment}</p>
</div>
{/* Billing period toggle */}
@@ -549,15 +947,33 @@ export default function SubscriptionPage() {
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3">
{providerAvailability.stripe ? (
<div className="flex items-center gap-2 rounded-xl border-2 border-blue-500 bg-blue-50 px-4 py-2.5 text-sm font-medium text-blue-700">
Stripe
</div>
) : null}
<div className="flex flex-wrap gap-3">
{paymentOptions.filter((option) => option.enabled).map((option) => (
<button
type="button"
key={option.method}
onClick={() => {
setSelectedMethod(option.method)
setManualCheckout(null)
setManualPaymentRequestNumber(null)
setPaymentSubmission(null)
setCheckoutIdempotencyKey(null)
setSubmissionIdempotencyKey(null)
setPaymentReference('')
setEvidenceFiles([])
}}
className={`flex items-center gap-2 rounded-xl border-2 px-4 py-2.5 text-sm font-medium ${
selectedMethod === option.method ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600'
}`}
>
{option.method === 'STRIPE' ? 'Stripe' : option.method === 'BANK_TRANSFER' ? copy.bankTransfer : copy.check}
</button>
))}
</div>
</div>
{isManualMethod && !manualCheckout ? manualDetailsForm : null}
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
@@ -569,14 +985,154 @@ export default function SubscriptionPage() {
</div>
<button
onClick={handleCheckout}
disabled={paying || loading || !providerAvailability.stripe}
disabled={
paying
|| loading
|| !paymentOptions.some((option) => option.method === selectedMethod && option.enabled)
|| (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0))
}
className="btn-primary px-8 py-3"
>
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
{paying
? (selectedMethod === 'STRIPE' ? copy.redirecting : copy.submittingReview)
: selectedMethod === 'STRIPE'
? (subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow)
: copy.submitPaymentEvidence}
</button>
</div>
{paymentSubmission && paymentSubmission.status !== 'DRAFT' && !manualCheckout ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-semibold">{copy.awaitingVerification}</p>
{manualPaymentRequestNumber ? (
<p className="mt-1">
{copy.paymentRequestNumber}: <span className="font-semibold">{manualPaymentRequestNumber}</span>
</p>
) : null}
{manualPaymentRequestNumber ? <p className="mt-1 text-xs">{copy.paymentRequestHelp}</p> : null}
<p className="mt-1">{paymentSubmission.status} · {paymentSubmission.documents.length} file(s)</p>
</div>
) : null}
{manualCheckout ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-5" aria-live="polite">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-semibold text-amber-900">{copy.awaitingVerification}</p>
<p className="mt-1 text-sm text-amber-800">
{copy.invoiceNumber}: {manualCheckout.invoice.invoiceNumber ?? manualCheckout.invoice.id} · {formatCurrency(manualCheckout.invoice.amountDue || manualCheckout.invoice.amount, 'MAD')}
</p>
<p className="text-sm text-amber-800">
{copy.dueDate}: {manualCheckout.invoice.dueAt ? new Date(manualCheckout.invoice.dueAt).toLocaleDateString() : '—'}
</p>
</div>
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">{manualCheckout.invoice.status}</span>
</div>
<div className="mt-4 rounded-xl bg-white/80 p-4">
<p className="text-sm font-semibold text-slate-900">{copy.paymentInstructions}</p>
<dl className="mt-2 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
{Object.entries(manualCheckout.instructions).map(([key, value]) => (
<div key={key}><dt className="text-xs uppercase text-slate-500">{key}</dt><dd className="font-medium">{value}</dd></div>
))}
</dl>
</div>
{paymentSubmission && paymentSubmission.status !== 'DRAFT' ? (
<div className="mt-4 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-800">
<p className="font-semibold">{copy.evidenceSubmitted}</p>
<p className="mt-1">{paymentSubmission.status} · {paymentSubmission.documents.length} file(s)</p>
{paymentSubmission.rejectionReason ? <p className="mt-2 text-red-700">{paymentSubmission.rejectionReason}</p> : null}
</div>
) : (
<div className="mt-4 space-y-3">
{manualDetailsForm}
<button
type="button"
onClick={handleEvidenceSubmit}
disabled={submittingEvidence || paymentReference.trim().length < 3 || (evidenceFiles.length === 0 && !paymentSubmission?.documents.length)}
className="btn-primary"
>
{submittingEvidence ? copy.submittingReview : copy.submitReview}
</button>
</div>
)}
</div>
) : null}
</div>
{communicationSettings ? (
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.communicationTitle}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.communicationHelp}</p>
<div className="mt-5 grid gap-5 lg:grid-cols-3">
<div>
<p className="text-sm font-medium text-slate-700">{copy.enabledLanguages}</p>
<div className="mt-2 flex flex-wrap gap-3">
{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => {
const checked = communicationSettings.enabledCommunicationLocales.includes(locale)
return (
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase">
<input
type="checkbox"
checked={checked}
onChange={() => setCommunicationSettings((current) => {
if (!current) return current
const next = checked
? current.enabledCommunicationLocales.filter((item) => item !== locale)
: [...current.enabledCommunicationLocales, locale]
if (next.length === 0) return current
return {
...current,
enabledCommunicationLocales: next,
defaultCommunicationLocale: next.includes(current.defaultCommunicationLocale) ? current.defaultCommunicationLocale : next[0],
contacts: current.contacts.map((contact) => contact.locale && !next.includes(contact.locale) ? { ...contact, locale: null } : contact),
}
})}
/>
{locale}
</label>
)
})}
</div>
</div>
<label className="block text-sm font-medium text-slate-700">
{copy.defaultLanguage}
<select value={communicationSettings.defaultCommunicationLocale} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, defaultCommunicationLocale: event.target.value as CommunicationLocale } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2">
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
{copy.timezone}
<input value={communicationSettings.timezone} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, timezone: event.target.value } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2" />
</label>
</div>
<div className="mt-5 space-y-3">
{communicationSettings.contacts.map((contact, index) => (
<div key={contact.id ?? contact.email} className="grid gap-3 rounded-xl border border-slate-200 p-4 sm:grid-cols-[1fr,220px] sm:items-center">
<div>
<p className="text-sm font-medium text-slate-900">{contact.email}</p>
<p className="text-xs text-slate-500">{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}</p>
</div>
<label className="text-xs font-medium text-slate-600">
{copy.contactLanguage}
<select
value={contact.locale ?? ''}
onChange={(event) => setCommunicationSettings((current) => current ? { ...current, contacts: current.contacts.map((item, itemIndex) => itemIndex === index ? { ...item, locale: (event.target.value || null) as CommunicationLocale | null } : item) } : current)}
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm"
>
<option value="">{copy.inheritDefault}</option>
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
</div>
))}
</div>
<button type="button" onClick={saveCommunicationSettings} disabled={savingCommunicationSettings} className="btn-primary mt-5">
{savingCommunicationSettings ? copy.loading : copy.saveSettings}
</button>
</div>
) : null}
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
@@ -586,6 +1142,7 @@ export default function SubscriptionPage() {
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.invoice}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
@@ -595,11 +1152,21 @@ export default function SubscriptionPage() {
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm font-medium text-blue-700">
<a
href={`${resolveApiBase()}/subscriptions/invoices/${inv.id}/pdf`}
target="_blank"
rel="noreferrer"
className="hover:underline"
>
{inv.invoiceNumber ?? copy.invoice}
</a>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4">
+23 -1
View File
@@ -164,7 +164,29 @@ describe('dashboard apiFetch', () => {
process.env.NEXT_PUBLIC_API_URL = 'http://localhost:4000/api/v1'
api = await import('./api')
expect(api.resolveApiOrigin()).toBe('http://localhost:4000')
expect(api.resolveApiOrigin()).toBe('http://localhost')
})
it('uses the dashboard proxy for local browser requests to a different local API port', async () => {
installBrowser()
;(globalThis.window as any).location.origin = 'http://localhost:3000'
process.env.NEXT_PUBLIC_API_URL = 'http://localhost:4000/api/v1'
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { ok: true } }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch, resolveRealtimeSocketTarget } = await import('./api')
await apiFetch('/auth/employee/me')
expect(fetchMock).toHaveBeenCalledWith('/dashboard/api/v1/auth/employee/me', expect.objectContaining({
credentials: 'include',
}))
expect(resolveRealtimeSocketTarget()).toEqual({
origin: 'http://localhost:3000',
path: '/dashboard/socket.io',
})
})
it('routes proxied realtime connections through the dashboard socket path', async () => {
+25 -5
View File
@@ -15,11 +15,19 @@ function shouldUseDashboardProxy(configuredApiBase?: string): boolean {
if (!configuredApiBase) return true
if (configuredApiBase.startsWith('/')) return false
const currentHostname = window.location?.hostname
if (!currentHostname || isLocalBrowserHost(currentHostname)) return false
try {
return new URL(configuredApiBase).hostname !== currentHostname
const apiUrl = new URL(configuredApiBase)
const currentOrigin = window.location?.origin
const currentHostname = window.location?.hostname
if (!currentOrigin || !currentHostname) return false
if (isLocalBrowserHost(currentHostname) && isLocalBrowserHost(apiUrl.hostname)) {
return apiUrl.origin !== currentOrigin
}
if (isLocalBrowserHost(currentHostname)) return false
return apiUrl.origin !== currentOrigin
} catch {
return false
}
@@ -87,9 +95,21 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
}
if (!res.ok) {
const err = new Error(json?.message ?? json?.error ?? `Request failed with status ${res.status}`) as any
const issues = Array.isArray(json?.issues)
? json.issues
.map((issue: any) => {
const path = Array.isArray(issue?.path) && issue.path.length ? `${issue.path.join('.')}: ` : ''
return issue?.message ? `${path}${issue.message}` : null
})
.filter(Boolean)
: []
const message = issues.length
? `${json?.message ?? 'Invalid request'}: ${issues.join('; ')}`
: (json?.message ?? json?.error ?? `Request failed with status ${res.status}`)
const err = new Error(message) as any
err.code = json?.error
err.statusCode = res.status
err.issues = json?.issues
throw err
}
@@ -49,7 +49,7 @@ export default async function LocaleHomePage({ params }: LocalePageProps) {
const locale: Locale = localeValue;
const mode: ThemePreference = modeValue;
const messages = getMessages(locale);
const content = buildHomepageContent(messages.homepage, messages.shell, locale);
const content = buildHomepageContent(messages.homepage, messages.shell, locale, mode);
const integrationConfig = getPublicIntegrationConfig();
const homePath = localizedModePath('home', locale, mode);
+4 -2
View File
@@ -3,6 +3,7 @@ import type { PreviewRow } from '@/components/marketing/ProductPreview';
import type { HomepageMessages, ShellMessages } from '@/lib/localization/messages';
import type { Locale } from '@/lib/localization/config';
import { accountCreateUrl } from '@/lib/account-urls';
import type { ThemePreference } from '@/lib/theme/config';
import {
fleetBandIds,
pricingPlanIds,
@@ -267,6 +268,7 @@ export function buildHomepageContent(
homepage: HomepageMessages,
shell: ShellMessages,
locale: Locale = 'en',
mode: ThemePreference = 'dark',
): HomepageContent {
const pendingReason = shell.states.pendingBody;
@@ -275,7 +277,7 @@ export function buildHomepageContent(
eyebrow: homepage.hero.eyebrow,
title: homepage.hero.title,
body: homepage.hero.body,
primary: { label: homepage.hero.primary, href: accountCreateUrl(locale, 'dark') },
primary: { label: homepage.hero.primary, href: accountCreateUrl(locale, mode) },
secondary: { label: homepage.hero.secondary, disabledReason: pendingReason },
preview: {
title: homepage.preview.title,
@@ -438,7 +440,7 @@ export function buildHomepageContent(
eyebrow: homepage.final.eyebrow,
title: homepage.final.title,
body: homepage.final.body,
primary: { label: homepage.final.primary, href: accountCreateUrl(locale, 'dark') },
primary: { label: homepage.final.primary, href: accountCreateUrl(locale, mode) },
secondary: { label: homepage.final.secondary, disabledReason: pendingReason },
image: localizedImage(
'rentaldrivego',
@@ -37,6 +37,13 @@ describe('homepage content model', () => {
expect(content.final.secondary.disabledReason).toBe(enShell.states.pendingBody);
});
it('keeps create account links in the current homepage theme', () => {
const content = buildHomepageContent(enHomepage, enShell, 'en', 'light');
expect(content.hero.primary.href).toBe('/dashboard/sign-up?lang=en&theme=light');
expect(content.final.primary.href).toBe('/dashboard/sign-up?lang=en&theme=light');
});
it('includes explicit theme handoff in account creation URLs', () => {
expect(accountCreateUrl('en', 'light')).toBe('/dashboard/sign-up?lang=en&theme=light');
expect(accountCreateUrl('en', 'dark')).toBe('/dashboard/sign-up?lang=en&theme=dark');