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
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:
+21
-7
@@ -21,11 +21,11 @@ ADMIN_INTERNAL_URL=http://host.docker.internal:3002
|
||||
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
|
||||
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
|
||||
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
|
||||
JWT_SECRET=placeholder
|
||||
JWT_SECRET=JYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
|
||||
ADMIN_SEED_PASSWORD=placeholder
|
||||
ADMIN_SEED_PASSWORD=Qwerty00!@#$%
|
||||
ADMIN_SEED_FIRST_NAME=Platform
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
@@ -34,17 +34,17 @@ NODE_ENV=development
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000
|
||||
|
||||
|
||||
# Email — Resend (primary) with SMTP fallback
|
||||
# Get your API key at https://resend.com/api-keys
|
||||
# Email — Gmail SMTP for local Docker development
|
||||
EMAIL_PROVIDER=gmail
|
||||
RESEND_API_KEY=re_PLACEHOLDER
|
||||
EMAIL_FROM=noreply@rentaldrivego.ma
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||
# Use a Gmail app password, not your normal Google account password.
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=placeholder
|
||||
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
@@ -55,4 +55,18 @@ MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||
# STRIPE_API_KEY must be a Stripe secret/restricted key (sk_ or rk_).
|
||||
# STRIPE_WEBHOOK_SECRET must be a Stripe webhook signing secret (whsec_).
|
||||
STRIPE_API_KEY=sk_test_51TvTsb9SpDRZn9yJyBAlUSXcTp9zpwQfhJYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00OcYvj28K
|
||||
STRIPE_WEBHOOK_SECRET=whsec_c5e0a6b2dd5e2f6ac804b428fe46f04e3af9b55c562f4124de20c52866f3c211
|
||||
STRIPE_WEBHOOK_SECRET=whsec_c5e0a6b2dd5e2f6ac804b428fe46f04e3af9b55c562f4124de20c52866f3c211
|
||||
|
||||
# Manual subscription payments for local development
|
||||
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=true
|
||||
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=true
|
||||
PAYMENT_EVIDENCE_SCANNER_MODE=stub-clean
|
||||
BANK_TRANSFER_ENABLED=true
|
||||
BANK_TRANSFER_ACCOUNT_NAME=RentalDriveGo SARL
|
||||
BANK_TRANSFER_BANK_NAME=Local Development Bank
|
||||
BANK_TRANSFER_ACCOUNT_REFERENCE=DEV-MA64-0000-0000-0000
|
||||
BANK_TRANSFER_DUE_DAYS=7
|
||||
CHECK_PAYMENT_ENABLED=true
|
||||
CHECK_PAYMENT_PAYEE=RentalDriveGo SARL
|
||||
CHECK_PAYMENT_DELIVERY_ADDRESS=Local development billing desk
|
||||
CHECK_PAYMENT_DUE_DAYS=14
|
||||
|
||||
@@ -75,12 +75,13 @@ EMAIL_FROM_NAME=RentalDriveGo
|
||||
# Option A — Resend
|
||||
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||
# Option B — SMTP (Gmail)
|
||||
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||
# EMAIL_PROVIDER=gmail selects Gmail SMTP and skips Resend.
|
||||
EMAIL_PROVIDER=gmail
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=placeholder
|
||||
MAIL_PASSWORD=your-16-character-gmail-app-password
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
|
||||
+4
-2
@@ -110,12 +110,14 @@ ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
ADMIN_SEED_FIRST_NAME=Super
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
|
||||
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||
# Email provider: auto, resend, smtp, or gmail.
|
||||
# Use a Gmail app password for Gmail SMTP, not your normal Google account password.
|
||||
EMAIL_PROVIDER=gmail
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||
MAIL_PASSWORD=your-16-character-gmail-app-password
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Manual subscription payments
|
||||
|
||||
This project implements bank-transfer and check collection as first-class subscription billing paths. Evidence submission is explicitly **not** settlement: only a fresh-2FA finance confirmation of cleared funds creates a successful offline payment attempt, pays the canonical billing invoice, and activates or renews the subscription.
|
||||
|
||||
## Safe rollout
|
||||
|
||||
1. Apply the `20260809190000_manual_subscription_payments` migration and deploy with all new flags disabled.
|
||||
2. Configure a private persistent, encrypted-at-rest `FILE_STORAGE_ROOT`; set both private-storage confirmation flags only after verifying the mounted storage; configure a fail-closed scanner executable, bank/check payer instructions, billing contacts, valid IANA timezones, and finance collections owners.
|
||||
3. Enable `MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED`, then `MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED` and the desired manual method flags.
|
||||
4. Enable `SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED`; verify the AR/EN/FR snapshots, outbox delivery, 48-hour call task, and escalation queue.
|
||||
5. Enable `SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED` only after observing a full reminder/grace cycle. Suspension starts after 30 calendar grace days and rechecks payment, overrides, and configuration atomically.
|
||||
|
||||
Manual methods remain hidden unless their instructions and evidence-scanning pipeline are ready. The non-production `PAYMENT_EVIDENCE_SCANNER_MODE=stub-clean` option exists only for local tests; production requires `PAYMENT_EVIDENCE_SCANNER_PATH`.
|
||||
|
||||
## Operational invariants
|
||||
|
||||
- Subscription billing has one canonical `BillingInvoice`; legacy `SubscriptionInvoice` rows are compatibility links only.
|
||||
- A scheduled renewal is reused by Stripe checkout rather than duplicated.
|
||||
- Manual confirmation requires the full current MAD balance, matching collection method, clean same-invoice evidence, a unique normalized external reference, a unique idempotency key, a clearance timestamp, and a funds-verified attestation.
|
||||
- Initial purchases activate from confirmation time. Renewals extend from the original expiration, including late confirmations after suspension.
|
||||
- Evidence is stored privately with no public URL, quarantined before scanning, served only through authorized routes, and immutable after submission.
|
||||
- Collections reminders are recipient-specific and localized using contact preference, linked employee preference, then company default. Only Arabic, English, and French can be enabled.
|
||||
- Payment resolution cancels open call tasks and suppresses queued collections reminders.
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 l’abonnement 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
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 l’abonnement dû dans 14 jours', DUE_7D: 'Paiement de l’abonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de l’abonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' },
|
||||
due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période d’abonnement 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 n’est 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}, d’un montant de ${amount}. L’abonnement 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 l’accè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 l’abonnement mis à jour', created: (invoice, until) => `Une dérogation temporaire de recouvrement a été appliquée à la facture ${invoice} jusqu’au ${until}.`, revoked: (invoice) => `La dérogation temporaire concernant la facture ${invoice} a été révoquée. Le calendrier standard s’applique à 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'}` })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 } },
|
||||
},
|
||||
|
||||
@@ -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: 'S’abonner',
|
||||
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 d’un justificatif ne prouve pas le règlement et n’active pas l’abonnement. 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 l’envoyer à 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: 'S’abonner 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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -58,7 +58,7 @@ function collectBrowserApiSources() {
|
||||
return sources
|
||||
}
|
||||
|
||||
function buildSecurityHeaders({ assetSources = [], connectSources = collectBrowserApiSources() } = {}) {
|
||||
function buildSecurityHeaders({ assetSources = [], connectSources = collectBrowserApiSources(), frameSources = [] } = {}) {
|
||||
const assetOrigins = collectOrigins(assetSources)
|
||||
const connectOrigins = collectOrigins(connectSources)
|
||||
const websocketOrigins = [
|
||||
@@ -76,6 +76,7 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows
|
||||
const imgSrc = [...new Set(["'self'", 'data:', 'blob:', 'https:', ...assetOrigins, ...connectOrigins])]
|
||||
const fontSrc = ["'self'", 'data:', ...assetOrigins]
|
||||
const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])]
|
||||
const frameSrc = [...new Set(["'self'", ...frameSources])]
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
connectSrc.push('ws:')
|
||||
}
|
||||
@@ -95,6 +96,7 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows
|
||||
`img-src ${imgSrc.join(' ')}`,
|
||||
`font-src ${fontSrc.join(' ')}`,
|
||||
`connect-src ${connectSrc.join(' ')}`,
|
||||
`frame-src ${frameSrc.join(' ')}`,
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
|
||||
+3
-1
@@ -159,7 +159,9 @@ Open `.env.docker.production` and fill in every value. The minimum required secr
|
||||
| `POSTGRES_PASSWORD` | Strong random password |
|
||||
| `JWT_SECRET` | Long random string (e.g. `openssl rand -hex 64`) |
|
||||
| `ACME_EMAIL` | Your email for Let's Encrypt notifications |
|
||||
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
|
||||
| `EMAIL_PROVIDER` | Set to `gmail` to send through Gmail SMTP |
|
||||
| `MAIL_USERNAME` | Gmail address used to send transactional mail |
|
||||
| `MAIL_PASSWORD` | Gmail app password, not the normal Google account password |
|
||||
| `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` |
|
||||
| `STRIPE_API_KEY` | Live Stripe secret key or restricted key for production billing (`sk_live_` or `rk_live_`) |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret for `/api/v1/subscriptions/webhooks/stripe` (`whsec_`) |
|
||||
|
||||
@@ -0,0 +1,823 @@
|
||||
# Manual Subscription Payment Plan
|
||||
|
||||
**Methods:** Bank transfer and check
|
||||
**Existing online method:** Stripe remains available
|
||||
**Scope:** Platform subscription payments, not rental-reservation/customer payments
|
||||
**Status:** Implementation proposal based on the supplied code archive
|
||||
**Revision:** 1.3 — adds company-driven Arabic, English, and French communication policy
|
||||
|
||||
## 1. Target outcome
|
||||
|
||||
Add a manual subscription-payment path alongside Stripe:
|
||||
|
||||
1. A company owner selects a plan and chooses **Bank transfer** or **Check**.
|
||||
2. The server calculates the price and creates one pending subscription invoice.
|
||||
3. The dashboard shows the amount, invoice number, due date, and configured payment instructions.
|
||||
4. The owner pays outside the application, enters the transaction reference/check number, and uploads supporting evidence such as a transfer receipt or check copy.
|
||||
5. The evidence is stored privately, scanned, and submitted to a finance review queue. Uploading evidence does not mark the invoice paid.
|
||||
6. The finance admin reviews the evidence and independently verifies that the transfer settled or the check cleared.
|
||||
7. The admin confirms the authoritative method and reference against the correct invoice.
|
||||
8. In one database transaction, the system records the payment, accepts the evidence submission, marks the invoice paid, activates the purchased subscription period, and writes billing and audit events.
|
||||
|
||||
Stripe must continue to use webhook-confirmed payment. A reference entered by an admin must never be treated as independent proof that money was received.
|
||||
|
||||
For every renewal, the system must also run the collections schedule in section 9: targeted pre-expiration reminders, a required platform-admin call task, a 30-day active grace period, daily overdue notices, and suspension at the start of day 31 unless payment is confirmed or an authorized override is active.
|
||||
|
||||
## 2. Critical current-state findings
|
||||
|
||||
| Area | Current code | Consequence |
|
||||
| --- | --- | --- |
|
||||
| Customer subscription UI | `dashboard/src/app/(dashboard)/subscription/page.tsx` hard-codes `provider = 'STRIPE'` | There is no manual choice or payment-instruction screen. |
|
||||
| Subscription API contract | `api/src/modules/subscriptions/subscription.schemas.ts` accepts only `STRIPE` | Bank transfer/check requests fail validation. |
|
||||
| Subscription checkout | `api/src/modules/subscriptions/subscription.service.ts` always creates a Stripe Checkout session | A non-Stripe pending invoice cannot be created. |
|
||||
| Admin payment API | `api/src/modules/admin/admin.schemas.ts` already accepts an optional `providerPaymentId` | The backend has a partial manual-recording concept, but method/reference are optional and there is no idempotency contract. |
|
||||
| Admin payment service | `api/src/modules/admin/admin.billing.service.ts::payInvoice` immediately writes successful payment records | It lacks required manual-method validation, reference deduplication, concurrency protection, and a cleared-funds attestation. |
|
||||
| Admin UI | `admin/src/app/dashboard/billing/page.tsx` sends only an optional amount and labels it “Amount in cents” | The transaction reference is never sent even though the API can accept one. The money input is also error-prone. |
|
||||
| Invoice architecture | Stripe creates `SubscriptionInvoice`; the finance console operates on `BillingInvoice` | There are two invoice lifecycles. The existing legacy sync copies data in one direction only. An admin payment can leave the customer-visible invoice pending. |
|
||||
| Subscription activation | Admin payment calls `maybeRestoreSubscription`, which changes status to `ACTIVE` | It does not reliably apply the purchased plan or establish a new `currentPeriodStart`/`currentPeriodEnd`. |
|
||||
| Upload infrastructure | `api/src/http/upload/index.ts` validates images only; `api/src/lib/storage.ts::uploadImage` always writes `.jpg` files | It cannot safely represent PDF receipts or preserve document type. A dedicated private-document pipeline is required. |
|
||||
| Subscription scheduler | `api/src/index.ts` uses fixed server-time cron jobs and hard-coded 7-day `PAYMENT_PENDING`/`PAST_DUE` transitions | It cannot implement company-local 14-day/7-day/48-hour/24-hour milestones or the required 30-day full-access grace period. |
|
||||
| Company notification targeting | `notificationService.ts` can target one employee or every company employee; no billing-contact audience exists | Using the company-wide audience would notify unrelated users. Explicit billing contacts are required. |
|
||||
| Localization foundation | Dashboard/admin i18n providers and notification locale/template resolution already support Arabic, English, and French; company brand data has a default locale | The foundation can be reused, but billing-contact locale resolution, company-enabled language constraints, template parity, and audit snapshots still need to be added. |
|
||||
| Platform-admin notifications | The admin notifications page is a cross-company delivery audit, not a personal admin inbox | Platform-admin milestones and call tasks need assigned admin recipients and actionable task state. |
|
||||
| Timezone configuration | No company/billing timezone field is exposed in the supplied schemas | Deadline calculation cannot safely use local calendar days, especially across daylight-saving/offset changes. |
|
||||
| Existing manual payments | `api/src/modules/payments/*` and `api/src/modules/billing/*` record manual rental payments | These flows concern rental reservations and mark payments successful immediately; they should not be reused for platform subscriptions. |
|
||||
| Database source | The API imports `@rentaldrivego/database`, but that package/schema is not in the archive | The schema and migration changes below must be made in the full monorepo before implementation can compile. |
|
||||
|
||||
The main prerequisite is to stop treating `SubscriptionInvoice` and `BillingInvoice` as independent sources of truth. Adding fields to the admin form without resolving that split would create inconsistent financial state.
|
||||
|
||||
## 3. Product decisions for the MVP
|
||||
|
||||
- Keep Stripe and add two offline collection methods: `BANK_TRANSFER` and `CHECK`.
|
||||
- Only company owners may create a subscription-payment request.
|
||||
- Only finance admins with fresh 2FA may confirm a manual payment.
|
||||
- “Confirm payment” means the admin attests that the bank transfer settled or the check cleared. Merely receiving a check is not enough.
|
||||
- The customer submits the reference and evidence through the application. This replaces an unaudited email/WhatsApp handoff. The admin still confirms the authoritative reference after review.
|
||||
- Require at least one clean evidence document for a manual subscription-payment submission. Recommended MVP formats: PDF, JPEG, and PNG; maximum 10 MB per file, 3 files, and 20 MB total per submission.
|
||||
- Store evidence in private persistent storage, never in a public `/storage` path, database blob, source tree, or ephemeral container filesystem.
|
||||
- Evidence is supporting material, not proof of settlement. A convincing-looking receipt must not activate a subscription.
|
||||
- Manual subscription invoices must be paid in full for activation. Short, excess, or partial payments go to an exception workflow; they must not silently activate a plan.
|
||||
- The server, never the browser, calculates the plan price.
|
||||
- Store all amounts as integer minor units. The UI displays MAD values and must not ask admins to think in “cents.”
|
||||
- Make `BillingInvoice` the canonical invoice. Treat `SubscriptionInvoice` as a compatibility model during migration, then retire it.
|
||||
- A pending manual invoice has a method-specific due date. Generic Stripe timeout behavior must not suspend a customer while a check is still within its configured clearing window.
|
||||
- Payment notices go only to explicitly configured company billing contacts and company administrators. In this codebase, “company administrator” maps to the `OWNER` role; never use the `COMPANY_EMPLOYEES` audience for collections.
|
||||
- Each billing account has one assigned platform collections owner, with a monitored finance queue as fallback. Do not notify every platform admin.
|
||||
- Renewal invoices must be finalized early enough for the first 14-day reminder. Recommended: generate/finalize them at least 21 days before the subscription period ends.
|
||||
- Keep the subscription fully active throughout the 30-day grace period. Delinquency is a collections state, not restricted entitlement, until suspension begins on day 31.
|
||||
- A created or pending payment attempt does not stop follow-up. Only canonical, successfully confirmed payment does.
|
||||
- Classify required payment notices as transactional/mandatory. A company must always retain at least one valid billing recipient.
|
||||
- Support exactly three communication languages for this release: Arabic (`ar`), English (`en`), and French (`fr`). Each company selects a non-empty set of enabled languages and one default language from that set.
|
||||
- Resolve one language for each recipient and communication. Do not send the same notice in all enabled languages: that would create duplicate emails/in-app alerts when the company selected multiple languages.
|
||||
|
||||
### Communication language policy
|
||||
|
||||
Customer-facing dashboard copy, in-app notifications, emails, payment instructions, evidence-review messages, reminders, confirmations, dispute/extension notices, and suspension messages must use this deterministic order:
|
||||
|
||||
1. Use the billing contact's explicit `locale` when it is one of the company's enabled communication languages.
|
||||
2. Otherwise, for a linked employee, use that employee's preferred language when it is enabled for the company.
|
||||
3. Otherwise, use the company's `defaultCommunicationLocale`.
|
||||
4. Use English only as a controlled emergency fallback when the configured template is unavailable. Record a localization-fallback audit event, alert operations, and do not silently mix English fragments into Arabic or French messages.
|
||||
|
||||
An external email-only billing contact may set a locale from the company's enabled list; otherwise the company default applies. A company-language change affects future unsent communications only. It must not resend old notices or rewrite the locale/template snapshot of communications already created.
|
||||
|
||||
Arabic output must set `lang="ar"` and `dir="rtl"`; English and French use `dir="ltr"`. Dates, times, number grouping, and currency presentation use the resolved locale together with the company's IANA timezone. Canonical timestamps, currency codes, and integer minor-unit amounts remain unchanged in storage.
|
||||
|
||||
Internal platform-admin instructions may use the assigned admin's own preferred locale so they remain operationally clear. However, each call task and customer-contact workflow must display the company's default language, the individual contact's resolved language, and a customer-facing call script/message in that language. Any email or message sent to the customer from the admin workflow must use the customer's resolved company-approved language.
|
||||
|
||||
## 4. State model and invariants
|
||||
|
||||
### Invoice lifecycle
|
||||
|
||||
`DRAFT` → `OPEN` → `PAID`
|
||||
|
||||
Additional existing states such as `PAST_DUE`, `VOID`, and `UNCOLLECTIBLE` remain valid. A fully verified payment may move `OPEN` or `PAST_DUE` to `PAID`. It must not pay a `VOID`, `REFUNDED`, or `UNCOLLECTIBLE` invoice.
|
||||
|
||||
### Subscription lifecycle
|
||||
|
||||
- For an initial purchase, creating a manual invoice sets the subscription to `PAYMENT_PENDING` without granting paid access.
|
||||
- Creating a renewal invoice must not change an active subscription to `PAYMENT_PENDING` or reduce current access.
|
||||
- A full admin-confirmed initial payment activates at confirmation time. `receivedAt` remains the financial settlement/clearing time.
|
||||
- A confirmed renewal extends service from the immutable original expiration/current-period end, not from the early payment date. Payment during grace remains anchored to that original expiration so the grace service is not silently free.
|
||||
- A renewal plan change is scheduled for the period boundary; it must not discard remaining days from the current paid period.
|
||||
- Partial payment, duplicate confirmation, or failed validation must not change entitlement.
|
||||
|
||||
### Evidence-submission lifecycle
|
||||
|
||||
Payment submission: `DRAFT` → `SUBMITTED` → `UNDER_REVIEW` → `APPROVED | REJECTED`
|
||||
|
||||
Each document has an independent security state: `UPLOADED` → `SCANNING` → `CLEAN | QUARANTINED | SCAN_FAILED`.
|
||||
|
||||
- A submission cannot move to `SUBMITTED` until it has at least one `CLEAN` document.
|
||||
- Customers may replace/delete documents only while the submission is `DRAFT`.
|
||||
- Submitted evidence is immutable. Corrections are appended as a new submission/version; historical evidence is not overwritten.
|
||||
- Admin approval is allowed only after every attached document is clean and the funds have been independently verified.
|
||||
|
||||
### Collections lifecycle
|
||||
|
||||
Keep collections state separate from subscription entitlement:
|
||||
|
||||
`SCHEDULED` → `PRE_DUE` → `GRACE_PERIOD` → `RESOLVED | SUSPENDED`
|
||||
|
||||
An authorized dispute/extension creates an overlay state, `SUSPENSION_PAUSED`, without erasing the underlying invoice or schedule.
|
||||
|
||||
- `PRE_DUE` begins when the renewal invoice enters the 14-day reminder window.
|
||||
- At expiration, an unpaid case enters `GRACE_PERIOD`; the subscription remains `ACTIVE` with full access.
|
||||
- Confirmed payment moves the case to `RESOLVED`, cancels queued follow-up, and keeps/restores the subscription `ACTIVE`.
|
||||
- At the company-local time boundary that begins day 31—30 calendar days after the exact expiration time—an unpaid, unpaused case moves to `SUSPENDED` and the subscription becomes `SUSPENDED`.
|
||||
- A payment attempt, uploaded receipt, submitted evidence, payment promise, or call outcome does not resolve the case.
|
||||
|
||||
### Non-negotiable invariants
|
||||
|
||||
- One authoritative invoice balance.
|
||||
- One successful confirmation per external payment reference within its permitted uniqueness scope.
|
||||
- One result for repeated requests with the same idempotency key.
|
||||
- No invoice can be overpaid through this endpoint.
|
||||
- Invoice update, payment record, subscription activation, billing event, and audit event succeed or fail together.
|
||||
- No tenant can view or mutate another tenant’s invoice.
|
||||
- References are normalized for comparison but preserved in their original form for finance review.
|
||||
- Evidence bytes are private, content-validated, malware-scanned, encrypted at rest, and retrievable only through authorized invoice-scoped routes.
|
||||
- Payment confirmation and evidence approval refer to the same invoice and submission; a document cannot be attached across tenants or reused to approve another invoice.
|
||||
- Collections milestones are computed in the billing account’s valid IANA timezone, never a fixed numeric UTC offset.
|
||||
- Every scheduled milestone, recipient delivery, call task, call outcome, override, confirmation, and suspension has a stable idempotency key and append-only audit evidence.
|
||||
- Every customer-facing delivery stores its resolved locale, template key/version, company timezone, rendered-subject/body snapshot or immutable render inputs, and fallback status for exact audit reproduction.
|
||||
- Missing/invalid timezone or missing billing recipients fails safe: alert the assigned platform collections owner and do not auto-suspend until configuration is corrected.
|
||||
|
||||
## 5. Proposed data changes
|
||||
|
||||
Make these changes in the missing `@rentaldrivego/database` package.
|
||||
|
||||
### `BillingInvoice`
|
||||
|
||||
Add or formalize:
|
||||
|
||||
- `collectionMethod`: `STRIPE | BANK_TRANSFER | CHECK`
|
||||
- `requestedPlan`: nullable plan enum
|
||||
- `requestedBillingPeriod`: nullable billing-period enum
|
||||
- `manualPaymentDueAt`: nullable timestamp, or use the existing `dueAt` consistently
|
||||
- `legacySubscriptionInvoiceId`: nullable unique relation during migration, instead of relying only on JSON metadata
|
||||
|
||||
### `BillingPaymentAttempt` or a dedicated immutable payment record
|
||||
|
||||
Add first-class, queryable fields rather than hiding them only in metadata:
|
||||
|
||||
- `channel`: `ONLINE | OFFLINE`
|
||||
- `manualMethod`: nullable `BANK_TRANSFER | CHECK`
|
||||
- `externalReference`: nullable original reference
|
||||
- `normalizedExternalReference`: nullable normalized reference
|
||||
- `receivedAt`: nullable settlement/clearance time
|
||||
- `confirmedAt`: nullable admin-confirmation time
|
||||
- `confirmedByAdminId`: nullable admin relation
|
||||
- `idempotencyKey`: nullable UUID
|
||||
- `note`: nullable, bounded text
|
||||
|
||||
### `ManualPaymentSubmission`
|
||||
|
||||
Add a review entity separate from the successful payment record:
|
||||
|
||||
- `id`, `invoiceId`, `billingAccountId`, and `companyId`
|
||||
- `method`: `BANK_TRANSFER | CHECK`
|
||||
- `submittedReference` and `normalizedSubmittedReference`
|
||||
- `status`: `DRAFT | SUBMITTED | UNDER_REVIEW | APPROVED | REJECTED`
|
||||
- `submittedByEmployeeId` and `submittedAt`
|
||||
- `reviewedByAdminId`, `reviewedAt`, and bounded `rejectionReason`
|
||||
- `idempotencyKey`
|
||||
- relation to the final `BillingPaymentAttempt` after approval
|
||||
|
||||
The customer-supplied reference is evidence. The final payment record stores the admin-confirmed reference. If they differ, require an admin correction reason and preserve both values.
|
||||
|
||||
### `ManualPaymentDocument`
|
||||
|
||||
Store metadata and a private storage key, not file bytes:
|
||||
|
||||
- `id`, `submissionId`, `invoiceId`, `companyId`
|
||||
- `kind`: `BANK_TRANSFER_RECEIPT | CHECK_COPY | OTHER_SUPPORTING_EVIDENCE`
|
||||
- random `storageKey`; never use the original filename as a filesystem path
|
||||
- sanitized `originalFilename`, detected MIME type, detected extension, and byte size
|
||||
- SHA-256 digest for integrity and duplicate detection
|
||||
- `scanStatus`: `UPLOADED | SCANNING | CLEAN | QUARANTINED | SCAN_FAILED`
|
||||
- scanner result code without copying raw scanner output into user-visible errors
|
||||
- `uploadedByEmployeeId`, `uploadedAt`, and optional draft-only deletion metadata
|
||||
|
||||
### `BillingAccount` and `BillingContact`
|
||||
|
||||
Add to the billing account:
|
||||
|
||||
- required IANA `timezone` such as `Africa/Casablanca`
|
||||
- nullable `collectionsOwnerAdminId` identifying the internal owner
|
||||
- configurable reminder send time in local time; recommended default `09:00`
|
||||
- `enabledCommunicationLocales`: a non-empty set containing only `ar`, `en`, and/or `fr`
|
||||
- `defaultCommunicationLocale`: one of the enabled values
|
||||
|
||||
Add explicit billing recipients rather than broadcasting to the company:
|
||||
|
||||
- `BillingContact`: `billingAccountId`, nullable linked `employeeId`, email, `locale`, `isPrimary`, `receivePaymentNotices`, and active/verified state
|
||||
- linked employees receive in-app and email; an external billing email can receive email only
|
||||
- active `OWNER` recipients may be designated as company administrators for payment notices
|
||||
- require at least one primary active recipient; never silently expand to every employee
|
||||
- validate a contact/employee locale against the company's enabled languages; missing values inherit the company default
|
||||
|
||||
Database constraints must require a non-empty enabled-language set, allow only `ar | en | fr`, and require the default language to be a member of the enabled set. Backfill the company language from its existing brand/default locale when that value is supported; otherwise use `en` and flag the company for owner review.
|
||||
|
||||
### `CollectionsCase`
|
||||
|
||||
Create one case per canonical renewal invoice:
|
||||
|
||||
- `invoiceId`, `subscriptionId`, `billingAccountId`, `companyId`
|
||||
- `status`: `SCHEDULED | PRE_DUE | GRACE_PERIOD | RESOLVED | SUSPENDED`
|
||||
- immutable `originalExpirationAt`
|
||||
- `graceStartedAt`, `finalSuspensionAt`, `resolvedAt`, and `suspendedAt`
|
||||
- `nextActionAt` for efficient worker queries
|
||||
- assigned `collectionsOwnerAdminId`
|
||||
- `resolutionPaymentAttemptId` when paid
|
||||
- optimistic version or row-claim fields for concurrent workers
|
||||
|
||||
### `CollectionsCallTask`
|
||||
|
||||
- one unique `PRE_EXPIRY_48H_CALL` task per collections case
|
||||
- assigned platform admin, due time, status `OPEN | COMPLETED | CANCELLED`
|
||||
- outcome `CONTACTED | NO_ANSWER | PAYMENT_PROMISED | ISSUE_ESCALATED`
|
||||
- bounded notes, `promisedPaymentAt`, `nextFollowUpAt`, completed admin/time, and cancellation reason
|
||||
|
||||
The task remains open until an admin records an outcome. A payment confirmed before the call is completed cancels it with a system reason rather than fabricating a human outcome. `PAYMENT_PROMISED` closes the required 48-hour call but must create a dated follow-up action; it does not resolve the collections case.
|
||||
|
||||
### `CollectionsOverride`
|
||||
|
||||
- type `PAYMENT_DISPUTE | MANUAL_EXTENSION`
|
||||
- required reason, authorizing admin, created time, start/end time, and revoked time/admin
|
||||
- `pauseSuspension` and separately controlled `pauseNotifications`
|
||||
- extension-specific revised suspension date without changing `originalExpirationAt`
|
||||
- only `FINANCE`, `ADMIN`, or `SUPER_ADMIN` with fresh 2FA may create/revoke an override
|
||||
|
||||
### Platform-admin notification recipients
|
||||
|
||||
Extend the notification audience/recipient model to target a specific `AdminUser`. The current platform notification page remains the delivery audit, but collections admins also need a personal in-app inbox/badge. Do not emulate an admin notification by sending to a company employee.
|
||||
|
||||
Add or reuse `AdminUser.preferredLocale` for internal admin UI/email. Store the company's default and each contact's resolved locale on the call-task snapshot so a reassignment never loses the language required for customer contact.
|
||||
|
||||
Recommended database constraints:
|
||||
|
||||
- Unique `(billingAccountId, idempotencyKey)` when `idempotencyKey` is not null.
|
||||
- Unique `(billingAccountId, manualMethod, normalizedExternalReference)` for successful offline payments.
|
||||
- Unique submission idempotency key within the billing account and unique document storage keys.
|
||||
- Unique `CollectionsCase.invoiceId`, unique `(collectionsCaseId, taskType)`, and unique notification event `(companyId, idempotencyKey)` constraints.
|
||||
- Check constraints requiring manual method, reference, confirmation time, and confirming admin when `channel = OFFLINE` and status is `SUCCEEDED`.
|
||||
- Index pending invoices by `(collectionMethod, status, dueAt)` for finance queues and dunning jobs.
|
||||
- Index submissions by `(status, submittedAt)` and documents by `(scanStatus, uploadedAt)` for review and quarantine workers.
|
||||
- Index collections cases by `(status, nextActionAt)` and open call tasks by `(assignedAdminId, status, dueAt)`.
|
||||
|
||||
The application should also warn finance when the same normalized reference appears on another billing account. Do not make check numbers globally unique: different customers can legitimately use the same check number.
|
||||
|
||||
### Migration and backfill
|
||||
|
||||
1. Add nullable fields and indexes first.
|
||||
2. Backfill `BillingInvoice` rows from linked `SubscriptionInvoice` rows.
|
||||
3. Backfill collection method from the legacy provider.
|
||||
4. Verify counts, totals, currency, paid status, and links before changing reads.
|
||||
5. Switch customer and admin reads to `BillingInvoice`.
|
||||
6. Keep compatibility writes to `SubscriptionInvoice` only during one release window if rollback requires them.
|
||||
7. Backfill a verified IANA timezone and at least one explicit billing contact for every live billing account. Do not enable automatic suspension for incomplete accounts.
|
||||
8. Create collections cases only for canonical unpaid renewal invoices; do not infer them from unconfirmed payment attempts.
|
||||
9. Remove lazy, request-time legacy synchronization after the backfill is proven.
|
||||
|
||||
## 6. API design
|
||||
|
||||
### Customer: available methods and instructions
|
||||
|
||||
`GET /api/v1/subscriptions/payment-options`
|
||||
|
||||
Return only enabled methods and safe payer-facing instructions. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"methods": [
|
||||
{
|
||||
"method": "STRIPE",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"method": "BANK_TRANSFER",
|
||||
"enabled": true,
|
||||
"instructions": {
|
||||
"accountName": "RentalDriveGo",
|
||||
"bankName": "Configured bank",
|
||||
"accountReference": "Configured RIB/IBAN",
|
||||
"message": "Include the invoice number in the transfer description"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "CHECK",
|
||||
"enabled": true,
|
||||
"instructions": {
|
||||
"payee": "RentalDriveGo",
|
||||
"deliveryAddress": "Configured address",
|
||||
"message": "Write the invoice number on the check"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Payment instructions should come from validated platform configuration, not JSX constants. Bank credentials used to access the bank account are secrets and must never be returned; receiving-account details intentionally shown to payers are not application credentials.
|
||||
|
||||
### Customer: create a manual subscription invoice
|
||||
|
||||
`POST /api/v1/subscriptions/manual-checkout`
|
||||
|
||||
```json
|
||||
{
|
||||
"plan": "GROWTH",
|
||||
"billingPeriod": "ANNUAL",
|
||||
"currency": "MAD",
|
||||
"method": "BANK_TRANSFER",
|
||||
"idempotencyKey": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
The response returns the canonical invoice ID/number, exact amount, due date, selected method, and instructions. Do not accept a customer-supplied amount, paid status, transaction reference, or company ID.
|
||||
|
||||
Rules:
|
||||
|
||||
- Require authenticated company owner and tenant context.
|
||||
- Recalculate price from `PricingConfig`/shared plan prices.
|
||||
- Reuse the result for a repeated idempotency key.
|
||||
- Prevent multiple open manual invoices for the same subscription purchase. Require the owner to cancel/replace an existing open request when changing plan or method.
|
||||
- Set `PAYMENT_PENDING`; do not activate access.
|
||||
|
||||
Keep the current Stripe checkout endpoint, but migrate it to create the same canonical `BillingInvoice` before creating the Stripe Checkout session.
|
||||
|
||||
### Customer: create and submit payment evidence
|
||||
|
||||
Use a staged workflow so upload retries cannot create duplicate payment submissions.
|
||||
|
||||
1. `POST /api/v1/subscriptions/invoices/:invoiceId/manual-payment-submissions`
|
||||
|
||||
Creates or returns a draft submission using a UUID idempotency key. Accept `method` and `submittedReference`; derive company and invoice ownership from authentication and the route.
|
||||
|
||||
2. `POST /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents`
|
||||
|
||||
Accept one multipart field named `file` plus a document `kind`. Only an owner from the same company may upload while the submission is `DRAFT`.
|
||||
|
||||
3. `DELETE /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents/:documentId`
|
||||
|
||||
Allow only draft cleanup. After submission, documents are immutable and corrections must be appended through a replacement submission.
|
||||
|
||||
4. `POST /api/v1/subscriptions/manual-payment-submissions/:submissionId/submit`
|
||||
|
||||
Require the invoice to remain payable, the method to match, a reference to be present, and at least one clean document. Freeze the submission and notify the finance queue.
|
||||
|
||||
5. `GET /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents/:documentId`
|
||||
|
||||
Stream a clean document only after re-validating the authenticated owner, company, invoice, and submission relationships. Never redirect to a stable public URL.
|
||||
|
||||
Return document metadata and scan state, never a public storage URL. Customer document reads/downloads must re-check session, tenant, invoice, and submission ownership on every request.
|
||||
|
||||
### Private document pipeline
|
||||
|
||||
Do not reuse `imageUpload` or `uploadImage` unchanged. Add a dedicated payment-evidence upload path:
|
||||
|
||||
- Accept only PDF, JPEG, and PNG for the MVP.
|
||||
- Detect content from file signatures/structure. Do not trust `Content-Type`, filename, or extension supplied by the browser.
|
||||
- Reject mismatched MIME/extension, SVG, HTML, office documents, executables, archives, password-protected/unscannable files, polyglots, oversized images, and files over the configured limits.
|
||||
- Calculate SHA-256 while ingesting and assign a random storage key.
|
||||
- Write first to a private quarantine location, run malware scanning, and move/mark the object usable only after a clean result. If the scanner is unavailable or times out, fail closed and keep the file unavailable.
|
||||
- Use persistent private storage that works across API instances. The existing `FILE_STORAGE_ROOT` abstraction may be extended if it points to durable shared storage; object storage with private keys is preferable for multi-instance deployment.
|
||||
- Never expose the existing generic `/storage` static route. Stream through protected API handlers or issue very short-lived, invoice-scoped signed downloads.
|
||||
- For raw files, set a sanitized `Content-Disposition`, exact detected `Content-Type`, `X-Content-Type-Options: nosniff`, and restrictive private/no-store caching. Prefer generated image previews; do not render active or untrusted content as application HTML.
|
||||
- Audit upload, submit, view/download, reject, approve, quarantine, and deletion events without logging document bytes or full financial references.
|
||||
- Define retention and legal-hold behavior. After submission, deletion must follow the approved retention policy rather than a user-facing hard delete.
|
||||
|
||||
Add finance endpoints for the review queue and protected evidence access:
|
||||
|
||||
- `GET /api/v1/admin/billing/manual-payment-submissions?status=SUBMITTED`
|
||||
- `GET /api/v1/admin/billing/manual-payment-submissions/:submissionId`
|
||||
- `GET /api/v1/admin/billing/manual-payment-submissions/:submissionId/documents/:documentId`
|
||||
- `POST /api/v1/admin/billing/manual-payment-submissions/:submissionId/reject`
|
||||
|
||||
Rejection requires a bounded reason, preserves the evidence, notifies the owner, and leaves the invoice unpaid.
|
||||
|
||||
### Admin: confirm a cleared manual payment
|
||||
|
||||
Use an explicit endpoint instead of overloading the generic `/pay` action:
|
||||
|
||||
`POST /api/v1/admin/billing/invoices/:invoiceId/manual-payments`
|
||||
|
||||
```json
|
||||
{
|
||||
"submissionId": "manual_submission_id",
|
||||
"method": "BANK_TRANSFER",
|
||||
"externalReference": "BANK-TXN-123456",
|
||||
"amount": 120000,
|
||||
"receivedAt": "2026-08-09T14:30:00.000Z",
|
||||
"note": "Matched to the platform bank statement",
|
||||
"idempotencyKey": "uuid",
|
||||
"fundsVerified": true
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `method` must match the invoice collection method unless a finance override with a required reason is implemented.
|
||||
- `submissionId` must identify a submitted, clean, same-company evidence package for this invoice.
|
||||
- Reference is required, trimmed, normalized, length-bounded, and uses a conservative printable-character policy.
|
||||
- `amount` must equal the full current balance for subscription invoices.
|
||||
- `receivedAt` cannot be unreasonably far in the future.
|
||||
- `fundsVerified` must be explicitly true; the confirmation screen must explain that the receipt/check image alone is insufficient.
|
||||
- Require `FINANCE`, fresh 2FA, CSRF protection where applicable, and an idempotency key.
|
||||
- Return `409` for already-paid invoices, reference conflicts, stale balances, or repeated confirmation with a different payload.
|
||||
|
||||
### Customer reads
|
||||
|
||||
Rewrite `GET /api/v1/subscriptions/invoices` to read canonical billing invoices. Include:
|
||||
|
||||
- invoice number and amount
|
||||
- collection method
|
||||
- status and due date
|
||||
- paid/confirmed time
|
||||
- a masked reference after confirmation
|
||||
- requested plan and billing period
|
||||
- evidence-submission status and safe document metadata
|
||||
|
||||
Do not expose admin-only notes or other companies’ references.
|
||||
|
||||
### Billing recipients and collections operations
|
||||
|
||||
Company-owner endpoints:
|
||||
|
||||
- `GET /api/v1/subscriptions/billing-contacts`
|
||||
- `PUT /api/v1/subscriptions/billing-contacts`
|
||||
- `GET /api/v1/subscriptions/communication-settings`
|
||||
- `PUT /api/v1/subscriptions/communication-settings`
|
||||
|
||||
Require at least one active primary contact. Validate that an in-app recipient is an active employee of the authenticated company. An external email-only contact must be verified before replacing the last verified recipient.
|
||||
|
||||
The communication-settings write accepts only the supported base language codes and validates all contacts atomically:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabledCommunicationLocales": ["ar", "fr"],
|
||||
"defaultCommunicationLocale": "fr",
|
||||
"contacts": [
|
||||
{
|
||||
"email": "billing@example.ma",
|
||||
"locale": "ar",
|
||||
"isPrimary": true,
|
||||
"receivePaymentNotices": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Reject unsupported/empty locale sets, a default outside the enabled set, or a contact locale outside the enabled set. Return the effective locale for each contact. An optional authenticated preview endpoint may render subject/body samples for every enabled language without sending them, using synthetic non-sensitive values.
|
||||
|
||||
Platform collections endpoints:
|
||||
|
||||
- `GET /api/v1/admin/billing/collections?status=&assignedTo=&actionDueBefore=`
|
||||
- `GET /api/v1/admin/billing/collections/:caseId`
|
||||
- `PATCH /api/v1/admin/billing/collections/:caseId/assignee`
|
||||
- `POST /api/v1/admin/billing/collection-tasks/:taskId/outcomes`
|
||||
- `POST /api/v1/admin/billing/collections/:caseId/overrides`
|
||||
- `POST /api/v1/admin/billing/collections/:caseId/overrides/:overrideId/revoke`
|
||||
|
||||
Call-outcome payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"outcome": "PAYMENT_PROMISED",
|
||||
"note": "Customer expects the transfer to settle tomorrow",
|
||||
"promisedPaymentAt": "2026-09-15T12:00:00.000Z",
|
||||
"nextFollowUpAt": "2026-09-15T15:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Require `promisedPaymentAt`/`nextFollowUpAt` for `PAYMENT_PROMISED` and a note for `NO_ANSWER` or `ISSUE_ESCALATED`. Recording an outcome closes the required call task but never marks the invoice paid.
|
||||
|
||||
Override creation requires fresh 2FA, an authorized role, a bounded reason, and a finite expiry/revised suspension date. Indefinite overrides should be prohibited unless a `SUPER_ADMIN` uses a separately audited emergency path.
|
||||
|
||||
## 7. Atomic confirmation algorithm
|
||||
|
||||
The confirmation service must run as one database transaction:
|
||||
|
||||
1. Resolve the idempotency key. Return the original result if the same request already succeeded.
|
||||
2. Read the canonical invoice, billing account, evidence submission, and document metadata with a write lock, or use conditional updates that detect concurrent changes.
|
||||
3. Validate tenant/account linkage, payable state, collection method, currency, full balance, requested subscription data, submitted evidence state, and that every attached document is clean.
|
||||
4. Confirm that the submission belongs to this invoice and has not already been approved/rejected. Evidence from another invoice must never be reusable.
|
||||
5. Normalize the admin-confirmed reference, compare it with the submitted reference, require a reason for a correction, and enforce the database uniqueness constraint.
|
||||
6. Create an immutable successful offline payment attempt with submission link, method, original/normalized confirmed reference, settlement time, admin identity, and idempotency key.
|
||||
7. Mark the evidence submission `APPROVED` and link it to that payment attempt. Evidence bytes remain immutable.
|
||||
8. Conditionally update the invoice balance and status. The affected-row count must be exactly one.
|
||||
9. Apply the correct period rule: initial/lapsed purchase starts at confirmation; renewal starts at the case’s immutable `originalExpirationAt` and extends from that anchor. Schedule renewal plan changes for the boundary rather than truncating the current period.
|
||||
10. Clear payment-pending/past-due/suspension fields and retry counters.
|
||||
11. Mark the related collections case `RESOLVED`, clear `nextActionAt`, and cancel any open call/follow-up tasks with the system reason `PAYMENT_CONFIRMED`.
|
||||
12. Write `payment_evidence.approved`, `invoice.paid`, `subscription.activated`, `collections.resolved`, and admin audit records through the same transaction handle.
|
||||
13. During the compatibility window, update the linked legacy `SubscriptionInvoice` in the same transaction.
|
||||
14. Commit, suppress/cancel unsent collections deliveries, then send the payment receipt/resolution notification. Notification failure must not roll back a valid payment; it should enter a retry queue.
|
||||
|
||||
Do not keep the current pattern where the audit write happens after the billing transaction. That leaves a financial mutation without guaranteed audit evidence if the second write fails.
|
||||
|
||||
Stripe webhook success and admin-confirmed manual payment must call the same canonical payment-finalization routine so both stop collections identically. The notification dispatcher must re-check that the collections case is unresolved immediately before delivery; otherwise a reminder queued seconds before payment could still be sent afterward.
|
||||
|
||||
## 8. User experience changes
|
||||
|
||||
### Company dashboard
|
||||
|
||||
Update `dashboard/src/app/(dashboard)/subscription/page.tsx`:
|
||||
|
||||
- Replace the hard-coded Stripe provider with accessible Stripe, bank-transfer, and check choices returned by the API.
|
||||
- Keep plan and billing-period selection.
|
||||
- For Stripe, preserve the redirect flow.
|
||||
- For a manual method, create the pending invoice and show a confirmation panel with invoice number, amount, due date, copyable instructions, and “Awaiting verification” status.
|
||||
- Add a payment-evidence form for transaction reference/check number and one to three PDF/JPEG/PNG documents.
|
||||
- Show per-file size/type validation, upload/scan progress, clean/quarantined/failed status, retry behavior, and accessible removal controls while the submission remains a draft.
|
||||
- Require an explicit final **Submit for review** action. Explain that upload does not prove settlement or activate the subscription.
|
||||
- After submission, show immutable evidence metadata and review state rather than editable controls.
|
||||
- Display method and status in invoice history.
|
||||
- During pre-due/grace states, show a persistent billing banner only to billing contacts and `OWNER` users. Include amount due, original expiration date, local grace-day count, days remaining, final suspension date, and a stable link to Stripe checkout or manual instructions/evidence submission.
|
||||
- Add billing-contact management for owners and require one verified primary contact before the current contact can be removed.
|
||||
- Add company communication settings where an owner enables Arabic, English, and/or French and selects exactly one default. Contact-language choices must be limited to the enabled set and show the effective inherited value.
|
||||
- Disable repeated submission while a request is in flight and render idempotent retries safely.
|
||||
- Add complete English, French, and Arabic copy for subscription checkout, payment instructions, evidence upload/review, collections banners, validation, and receipts. Arabic pages and email previews must be verified right-to-left.
|
||||
|
||||
### Admin finance console
|
||||
|
||||
Update `admin/src/app/dashboard/billing/page.tsx`:
|
||||
|
||||
- Replace the current inline “Amount in cents” control with a focused **Confirm manual payment** dialog.
|
||||
- Show company, invoice number, plan, billing period, collection method, currency, total, balance due, and due date before confirmation.
|
||||
- Require method, reference/check number, settlement/clearance date, optional note, and the cleared-funds checkbox.
|
||||
- Show the submitted reference, scan status, document type, uploader, timestamp, and protected preview/download controls.
|
||||
- Never render customer filenames or document content as HTML. Clearly distinguish customer-supplied evidence from independently verified bank/check data.
|
||||
- Add **Reject evidence** with a required reason. Rejection must not change the invoice balance or subscription entitlement.
|
||||
- Default the amount to the full balance and display it as MAD; keep minor-unit conversion outside free-form admin input.
|
||||
- Require a final review step and handle fresh-2FA errors explicitly.
|
||||
- Disable the submit button while pending and reuse the same idempotency key on transport retry.
|
||||
- Show the masked reference and confirming admin in payment history. Show the full reference only in the authorized detail view if finance operations require it.
|
||||
- Keep generic partial payment controls separate for non-subscription invoices if that capability is still required.
|
||||
- Add an assigned collections queue ordered by overdue action, with case status, company-local deadline, amount due, contact details, latest notice, and open tasks.
|
||||
- Provide personal admin in-app notifications/badge for assigned milestones rather than treating the global delivery-audit page as an inbox.
|
||||
- The 48-hour call task must be visibly open until an outcome is saved. `PAYMENT_PROMISED` must show its promised date and generate the next follow-up action.
|
||||
- Show the company's default language and the selected contact's effective language on each call task, with the correct localized customer script/template. Admin-generated customer messages must not allow a language outside the company's enabled set.
|
||||
- Show active disputes/extensions prominently, including who authorized them, reason, expiry/revised suspension date, and whether notices are paused.
|
||||
|
||||
## 9. Subscription-payment follow-up, grace period, and suspension
|
||||
|
||||
### Roles and recipient resolution
|
||||
|
||||
- **Company billing contact/admin:** the customer responsible for payment. Resolve only explicit active billing contacts and designated active `OWNER` employees. Send in-app only to linked employees and email only to verified addresses.
|
||||
- **Platform admin:** the assigned internal collections owner. Use a finance queue fallback if the assignee is inactive or missing; never fan out to every admin.
|
||||
- Never use `COMPANY_EMPLOYEES` for payment notices. Resolve and snapshot recipients per event so the audit log shows exactly who was targeted.
|
||||
- For every customer recipient, resolve the communication locale using section 3 before rendering either channel. Email and in-app deliveries for the same event/recipient must use the same resolved locale and template version.
|
||||
- Internal platform-admin notices may use the admin's preferred locale, but the task must carry the company/contact language and localized customer script. Customer-facing content sent through the task always uses the customer recipient's resolved company-approved locale.
|
||||
|
||||
### Before expiration
|
||||
|
||||
| Relative time | Company billing contact/admin | Assigned platform admin | Required action |
|
||||
| --- | --- | --- | --- |
|
||||
| 14 days before | In-app + email | In-app | Payment reminder |
|
||||
| 7 days before | In-app + email | In-app | Second reminder |
|
||||
| 48 hours before | In-app + email | In-app + email | Create the unique required-call task |
|
||||
| 24 hours before | In-app + email | In-app + email | Final expiration warning |
|
||||
|
||||
At the 48-hour threshold:
|
||||
|
||||
- Create exactly one `PRE_EXPIRY_48H_CALL` task and assign it to the collections owner.
|
||||
- The task remains `OPEN` until the admin records `CONTACTED`, `NO_ANSWER`, `PAYMENT_PROMISED`, or `ISSUE_ESCALATED`.
|
||||
- Require notes for unsuccessful/escalated outcomes. Require a promised-payment date and next follow-up time for `PAYMENT_PROMISED`.
|
||||
- An overdue open call task escalates within the platform finance queue; it must not generate extra company notices.
|
||||
- If payment is confirmed before the call occurs, cancel the open task as `PAYMENT_CONFIRMED` and do not require a fictitious call outcome.
|
||||
|
||||
Each reminder includes the invoice number, amount due, subscription expiration time in the company timezone, and a stable dashboard link that presents Stripe payment or the configured bank/check instructions. Do not place expiring signed URLs in email.
|
||||
|
||||
### After expiration: 30-day active grace period
|
||||
|
||||
If canonical payment is not confirmed at expiration:
|
||||
|
||||
1. Move the collections case to `GRACE_PERIOD`, but keep the subscription `ACTIVE` with full access.
|
||||
2. Send exactly one in-app notification and one email per local calendar day to each current billing recipient.
|
||||
3. Include amount due, original expiration date, current grace day, days remaining, final suspension time, and payment link/instructions.
|
||||
4. Use the day-30 daily notice as the final warning; do not send a second duplicate “final” notice that day.
|
||||
5. At the local-time boundary that begins day 31—30 calendar days after expiration in the configured IANA timezone—atomically re-check payment and overrides, then suspend if still eligible.
|
||||
|
||||
The suspension transaction must conditionally change the collections case and subscription once, write audit/billing events, and queue the suspension notice. If a confirmed payment wins the race, suspension affects zero rows and does nothing.
|
||||
|
||||
### Stop condition
|
||||
|
||||
Reminders stop only when the canonical invoice is paid by a successfully confirmed payment. These do **not** stop follow-up:
|
||||
|
||||
- created/processing/failed payment attempt
|
||||
- Stripe Checkout session creation
|
||||
- manual reference entry by the customer
|
||||
- evidence upload or submission
|
||||
- call outcome or payment promise
|
||||
- email delivery/read status
|
||||
|
||||
On confirmation:
|
||||
|
||||
- resolve the collections case in the same transaction as invoice/subscription payment finalization
|
||||
- clear `nextActionAt` and cancel open call/follow-up tasks
|
||||
- suppress queued but unsent reminder deliveries/outbox work
|
||||
- require the delivery worker to re-check unresolved state immediately before sending
|
||||
- keep already-sent notification history; do not delete audit evidence
|
||||
- payment during grace leaves/restores the subscription `ACTIVE` without a suspension transition
|
||||
|
||||
### Timezone-safe, idempotent scheduling
|
||||
|
||||
- Require a valid IANA timezone per billing account. Use timezone-aware calendar arithmetic; never add fixed 24-hour milliseconds for local deadlines.
|
||||
- Calculate and persist `originalExpirationAt`, each milestone’s `scheduledFor`, and `finalSuspensionAt` when the collections case is created.
|
||||
- Run a frequent UTC worker, recommended every 15 minutes, that claims rows where `nextActionAt <= now`. Do not create one cron schedule per timezone.
|
||||
- Use database leases/conditional claims so multiple API instances cannot process the same action concurrently.
|
||||
- Notification keys: `collections:{invoiceId}:{milestone}:{recipientId}`. Daily-grace keys add the company-local date. Call-task uniqueness is enforced by `(collectionsCaseId, taskType)`.
|
||||
- Locale is snapshotted when the event is created and is not part of the idempotency key. A retry must reproduce the original localized notice; changing company language settings must not create a duplicate for the same milestone.
|
||||
- If the worker runs repeatedly, unique constraints return the original event/task instead of sending again.
|
||||
- After an outage, audit missed milestones and send only the most urgent currently applicable customer reminder per run; never dump several stale emails at once. The 48-hour call task must still be created if its threshold was crossed.
|
||||
|
||||
The existing notification event/outbox/delivery records should be reused for channel delivery evidence. Add `CollectionsEvent` records for scheduling decisions, call actions, overrides, resolution, and suspension.
|
||||
|
||||
Add dedicated localized notification types/templates rather than reusing generic payment failures: `SUBSCRIPTION_PAYMENT_DUE_14D`, `SUBSCRIPTION_PAYMENT_DUE_7D`, `SUBSCRIPTION_PAYMENT_DUE_48H`, `SUBSCRIPTION_PAYMENT_DUE_24H`, `SUBSCRIPTION_GRACE_DAILY`, `SUBSCRIPTION_GRACE_FINAL`, `COLLECTIONS_CALL_REQUIRED`, `SUBSCRIPTION_PAYMENT_CONFIRMED`, `MANUAL_PAYMENT_EVIDENCE_REJECTED`, `COLLECTIONS_OVERRIDE_CHANGED`, and `SUBSCRIPTION_SUSPENDED`. Every customer-facing template must exist in `ar`, `en`, and `fr` with an identical variable contract. Template variables must come from the canonical invoice/case, not caller-supplied display amounts or dates.
|
||||
|
||||
Retire the current hard-coded 7-day `runPaymentPendingTimeoutJob` and `runPastDueTimeoutJob` behavior for canonical subscription renewals before enabling this worker. Running both lifecycles would restrict or suspend accounts early.
|
||||
|
||||
### Disputes, extensions, and exceptions
|
||||
|
||||
- An authorized `PAYMENT_DISPUTE` or `MANUAL_EXTENSION` override pauses automatic suspension until its explicit expiry/revised suspension date. It must never mark the invoice paid.
|
||||
- `pauseNotifications` is a separate audited choice. A dispute may replace ordinary daily demands with dispute-status communication; an extension normally reschedules reminders against the revised date.
|
||||
- Expired/revoked overrides return the case to the calculated schedule. The next worker must re-check payment before any reminder or suspension.
|
||||
- A `PAST_DUE` manual invoice may still be paid if it has not been voided or replaced.
|
||||
- A changed plan/method must void or supersede the old open invoice; it must never leave two invoices capable of activating different plans.
|
||||
- Reference collision, overpayment, underpayment, wrong currency, bounced check, or unmatched transfer goes to a finance exception queue. Do not “fix” these by editing ledger rows.
|
||||
- Suspicious, infected, unscannable, unreadable, wrong-invoice, or mismatched evidence goes to quarantine/rejection and must never reach payment confirmation.
|
||||
- A bounced check after activation requires compensating records, reopens collections, and re-evaluates subscription access under an authorized reversal workflow. Never delete the original payment/evidence.
|
||||
|
||||
## 10. Files expected to change
|
||||
|
||||
| Layer | Files/modules |
|
||||
| --- | --- |
|
||||
| Database | Missing `@rentaldrivego/database` Prisma schema and a new migration |
|
||||
| Subscription contracts | `api/src/modules/subscriptions/subscription.schemas.ts` |
|
||||
| Subscription routes | `api/src/modules/subscriptions/subscription.routes.ts` |
|
||||
| Subscription orchestration | `api/src/modules/subscriptions/subscription.service.ts` |
|
||||
| Canonical invoice repository | `api/src/modules/subscriptions/subscription.repo.ts` and/or a shared billing repository |
|
||||
| Admin validation | `api/src/modules/admin/admin.schemas.ts` |
|
||||
| Admin route | `api/src/modules/admin/admin.routes.ts` |
|
||||
| Admin transaction logic | `api/src/modules/admin/admin.billing.service.ts` |
|
||||
| Evidence validation/upload | `api/src/http/upload/index.ts` or a new `api/src/http/upload/paymentEvidence.ts` |
|
||||
| Private document storage | `api/src/lib/storage.ts` plus the production persistent-storage adapter |
|
||||
| Evidence service/repository | New subscription billing evidence module under `api/src/modules/subscriptions/` or a shared billing module |
|
||||
| Collections policy/worker | `api/src/modules/subscriptions/subscription.policy.ts`, a new collections service/repository/worker, and `api/src/index.ts` scheduler wiring |
|
||||
| Notification targeting/outbox | `api/src/services/notificationService.ts`, localization templates, and notification repository/routes |
|
||||
| Locale resolution/templates | `api/src/services/notificationLocalizationService.ts`, all subscription-payment email/in-app templates, and template-variable parity checks |
|
||||
| Billing contacts/timezone/language | Database schema plus company/billing-account settings schemas, services, `api/src/modules/companies/company.schemas.ts`, and owner UI |
|
||||
| Admin task/override API | Admin schemas/routes/service plus collections policy authorization |
|
||||
| API documentation | `api/src/swagger/openapi.ts` |
|
||||
| Customer subscription UI | `dashboard/src/app/(dashboard)/subscription/page.tsx` |
|
||||
| Admin billing/collections UI | `admin/src/app/dashboard/billing/page.tsx`, new collections queue/task views, and a personal admin notification inbox/badge |
|
||||
| Shared types/i18n | Missing shared packages plus `dashboard/src/components/I18nProvider.tsx`, `admin/src/components/I18nProvider.tsx`, locale selectors, and Arabic RTL styles/email markup |
|
||||
| Tests | Subscription, admin billing, API, integration, and end-to-end test suites |
|
||||
|
||||
## 11. Test plan
|
||||
|
||||
### Unit and schema tests
|
||||
|
||||
- Accept only `BANK_TRANSFER`/`CHECK` on the manual endpoint.
|
||||
- Reject empty/oversized/control-character references, invalid dates, false/missing verification, floats, non-positive amounts, and wrong currency.
|
||||
- Normalize case/whitespace consistently without changing the stored display value.
|
||||
- Verify monthly/yearly period calculations at month-end and leap-year boundaries.
|
||||
- Accept valid PDF/JPEG/PNG signatures and reject spoofed MIME types/extensions, SVG/HTML, archives, executables, malformed/polyglot files, decompression bombs, oversized images, excessive counts, and size-limit violations.
|
||||
- Verify 14-day, 7-day, 48-hour, 24-hour, grace-day, and day-31 calculations in multiple IANA zones, including daylight-saving and Morocco offset changes.
|
||||
- Reject invalid/fixed-offset timezone identifiers and invalid/indefinite override windows.
|
||||
- Accept only `ar`, `en`, and `fr`; reject empty enabled sets, unsupported values, defaults outside the set, and contact locales outside the set.
|
||||
- Verify locale precedence: explicit billing-contact locale, enabled linked-employee preference, company default, then audited emergency English fallback.
|
||||
- Enforce template parity in CI: every customer-facing template key and interpolation variable must exist in all three languages, with no unresolved keys or silent mixed-language output.
|
||||
|
||||
### Service and database tests
|
||||
|
||||
- Manual checkout computes the server-side price and creates one open canonical invoice.
|
||||
- Repeated checkout with the same key returns the same invoice.
|
||||
- A second open invoice for the same purchase is rejected or explicitly replaces the first.
|
||||
- Confirming a settled payment atomically records payment, pays invoice, activates the requested plan, and writes events/audit.
|
||||
- A duplicate reference for the same account/method is rejected.
|
||||
- Two concurrent confirmations cannot both change the balance or create two successful attempts.
|
||||
- Underpayment, overpayment, wrong method, wrong invoice state, and stale amount do not activate the subscription.
|
||||
- Admin confirmation updates the customer-visible invoice during the compatibility period.
|
||||
- Past-due-but-payable succeeds; void/uncollectible/refunded fails.
|
||||
- A notification failure leaves the payment committed and schedules a retry.
|
||||
- Draft submission supports idempotent upload retry and submit; submitted documents cannot be replaced or deleted.
|
||||
- Submission is blocked until at least one document is clean and all documents have finished scanning.
|
||||
- Quarantined/scan-failed evidence cannot be viewed as clean evidence or used for approval.
|
||||
- File and database failures do not leave an approved orphan document or an untracked stored object; cleanup jobs reconcile abandoned drafts/quarantine objects.
|
||||
- SHA-256 digest remains stable through storage/retrieval and catches an accidental duplicate within a submission.
|
||||
- Collections case creation persists the original expiration, all milestone instants, and final suspension time once.
|
||||
- Each pre-due milestone reaches only explicit billing recipients and the assigned collections admin with the required channels.
|
||||
- Re-running the worker or running two workers concurrently creates one notification event per recipient/milestone and one 48-hour call task.
|
||||
- A company with all three languages enabled receives one notice per recipient/milestone in that recipient's resolved language, never three duplicate notices.
|
||||
- Each delivery persists the resolved locale, company timezone, template key/version, render snapshot/inputs, and localization-fallback status; retries reproduce the same localized content.
|
||||
- Changing a company/contact language does not resend completed milestones; the next unsent event uses the newly resolved language.
|
||||
- The assigned admin's internal notice uses the admin preference while the call task exposes the company/contact language and the correct localized customer script.
|
||||
- The call task remains open without an outcome; every outcome is audited; payment promise creates the next follow-up without resolving the invoice.
|
||||
- Grace days 1–29 send one daily notice; day 30 sends one final-warning variant; the start of day 31 suspends once.
|
||||
- The subscription retains full access throughout grace.
|
||||
- Confirmed Stripe/manual payment immediately resolves collections, cancels tasks/queued notices, and wins safely against concurrent suspension.
|
||||
- Pending/failed attempts, uploaded evidence, submitted evidence, and payment promises do not stop reminders.
|
||||
- Active dispute/extension overrides prevent suspension; expiry/revocation resumes the correct schedule without duplicate or stale notices.
|
||||
- Missing timezone/recipient/assignee fails safe, alerts finance, and does not auto-suspend.
|
||||
|
||||
### Authorization and boundary tests
|
||||
|
||||
- Non-owner company users cannot create manual subscription invoices.
|
||||
- Owners cannot confirm their own payments through admin endpoints.
|
||||
- Non-finance admins and stale/non-enrolled 2FA sessions cannot confirm payments.
|
||||
- Cross-tenant invoice access fails without leaking existence.
|
||||
- CSRF and rate-limit policies remain enforced.
|
||||
- Owners cannot attach a document to another company’s invoice/submission or guess a storage key to download it.
|
||||
- Only the uploading company and authorized finance admins can access evidence; each admin view/download is audited.
|
||||
- Private evidence is not reachable through `/storage`, search, static assets, invoice PDFs, logs, or public URLs.
|
||||
- Only authorized company owners can manage billing contacts; they cannot remove the last verified recipient.
|
||||
- Call outcomes are limited to the assigned/authorized platform admin; overrides require authorized roles and fresh 2FA.
|
||||
- The company-wide audience is never used for collections, and platform notices never leak into a company employee account.
|
||||
|
||||
### UI and end-to-end tests
|
||||
|
||||
- Stripe checkout still redirects and webhook success still activates exactly once.
|
||||
- Bank transfer/check show correct configured instructions and create a pending invoice without activation.
|
||||
- Customer can upload valid evidence, observe scanning, submit it for review, and cannot edit it afterward.
|
||||
- Invalid/quarantined evidence produces a safe error without exposing scanner internals.
|
||||
- Admin can securely inspect clean evidence, reject it with a reason, or proceed to confirmation.
|
||||
- Billing contacts see the exact amount, original expiration, days remaining, final suspension time, and current payment action in the dashboard/email templates.
|
||||
- Assigned platform admins receive the specified milestone channels, see the open 48-hour call task, and can record each required outcome.
|
||||
- Payment confirmation during grace removes future reminders immediately; an already-queued delivery is suppressed by the final state check.
|
||||
- Day-31 suspension and authorized dispute/extension behavior are covered end to end in a non-UTC company timezone.
|
||||
- Admin confirmation dialog sends method, reference, full amount, date, note, verification, and stable idempotency key.
|
||||
- Customer status changes from awaiting verification to paid/active after admin confirmation.
|
||||
- English, French, and Arabic labels, validation errors, payment instructions, notification/email bodies, call scripts, and layouts are verified.
|
||||
- Arabic screens and emails set the correct language/direction metadata and remain usable in RTL; English and French remain LTR.
|
||||
- Amounts, dates, grace-day counts, and suspension times render correctly for each language in the company's configured timezone without changing stored financial/time values.
|
||||
|
||||
## 12. Rollout sequence
|
||||
|
||||
1. **Schema and observability:** deploy nullable payment/evidence fields, collections/contact/task/override tables, indexes, structured audit fields, metrics, and reference-safe logging.
|
||||
2. **Recipient/timezone/language readiness:** collect and verify an IANA timezone, primary billing recipients, enabled/default communication languages, and assigned/fallback collections owner for every live account. Backfill from the existing supported brand/default locale, otherwise use English and flag for owner review. Do not enable automatic suspension for incomplete accounts.
|
||||
3. **Private storage and scanning:** provision durable private storage, quarantine, malware scanning, retention controls, protected downloads, and orphan cleanup before accepting any customer document.
|
||||
4. **Backfill and reconciliation:** create/verify canonical `BillingInvoice` links for all legacy subscription invoices; produce a reconciliation report before switching reads.
|
||||
5. **Canonical service:** make Stripe and customer invoice reads use the canonical lifecycle while retaining temporary compatibility writes.
|
||||
6. **Shadow collections worker:** calculate milestones and idempotency keys without sending or suspending; compare results against expected company-local dates and current invoices.
|
||||
7. **Notifications/tasks:** complete Arabic/English/French template parity and locale-resolution tests, then enable internal collections inbox/call tasks and pre-expiration company notices. Verify recipient scoping, language snapshots, RTL rendering, and email deliverability.
|
||||
8. **Evidence review and admin confirmation:** deploy private evidence endpoints, finance review queue, manual-payment confirmation, rejection, and atomic collections resolution behind feature flags.
|
||||
9. **Grace/suspension enforcement:** enable daily grace notices, overrides, and finally day-31 suspension after shadow data shows zero timing/recipient/payment-state mismatches.
|
||||
10. **Customer manual checkout:** expose bank/check and evidence upload only after instructions, storage/scanning, and finance operations are ready.
|
||||
11. **Monitor:** track milestone lag, duplicates suppressed, recipient/config gaps, open/overdue call tasks, override expiry, daily delivery failures, grace age, blocked suspensions, scan failures, confirmation conflicts, invoice/subscription mismatches, and Stripe regressions.
|
||||
12. **Retire compatibility:** remove lazy legacy sync and dual writes only after at least one billing cycle reconciles with zero mismatches.
|
||||
|
||||
Suggested flags:
|
||||
|
||||
- `manualSubscriptionPaymentsEnabled`
|
||||
- `bankTransferEnabled`
|
||||
- `checkPaymentEnabled`
|
||||
- `manualPaymentEvidenceUploadEnabled`
|
||||
- `subscriptionCollectionsNotificationsEnabled`
|
||||
- `subscriptionGracePeriodEnforcementEnabled`
|
||||
- `subscriptionAutomaticSuspensionEnabled`
|
||||
|
||||
## 13. Acceptance criteria
|
||||
|
||||
- An owner can choose Stripe, bank transfer, or check for a subscription purchase.
|
||||
- A manual choice creates exactly one pending canonical invoice with server-calculated price and safe payment instructions.
|
||||
- The owner can attach one to three valid private PDF/JPEG/PNG evidence documents and submit them to finance review.
|
||||
- Uploaded files are content-validated, malware-scanned, stored privately, tenant-protected, and audited; rejected or unscannable files cannot be approved.
|
||||
- The subscription is not activated when the invoice is created, when the customer reports a reference, or when evidence is uploaded/submitted.
|
||||
- A finance admin with fresh 2FA can confirm a cleared full payment using a required method and reference.
|
||||
- The admin can review clean evidence and reject it with a reason; approving a payment links the exact immutable submission and documents to the payment record.
|
||||
- Confirmation is idempotent, concurrency-safe, duplicate-resistant, fully audited, and atomic.
|
||||
- The paid invoice shown to the admin is the same invoice/status shown to the company owner.
|
||||
- Full initial confirmation activates the requested plan; renewal confirmation extends from the original expiration boundary without discarding remaining paid days.
|
||||
- Existing Stripe checkout/webhook behavior remains correct and does not double-activate.
|
||||
- Only explicit company billing contacts/owners and the assigned platform collections admin receive payment notices.
|
||||
- Every customer-facing subscription-payment communication is available in Arabic, English, and French and is sent once in the recipient's deterministic company-approved language.
|
||||
- A company can enable one or more supported languages and select one default; contact preferences are constrained to the enabled set and otherwise inherit the default.
|
||||
- Arabic customer UI/email uses RTL and correct `lang`/`dir` metadata; English and French use LTR. Dates, times, amounts, and currencies follow the resolved language and company timezone.
|
||||
- A language change affects future unsent communications without duplicating past milestones; every delivery records the locale and template version used.
|
||||
- Platform-admin tasks clearly identify the customer's language and provide the matching call script, even when the admin's internal interface uses a different preferred language.
|
||||
- Pre-expiration notifications follow the 14-day, 7-day, 48-hour, and 24-hour channel matrix without duplicates.
|
||||
- The 48-hour call task remains open until a valid outcome is recorded; payment promises create follow-up but do not resolve payment.
|
||||
- An unpaid subscription remains fully active for the complete 30-day grace period and receives one targeted in-app/email notice per local day, with the day-30 notice serving as the final warning.
|
||||
- At the start of local day 31, the system suspends exactly once only if payment remains unconfirmed and no authorized override is active.
|
||||
- Confirmed payment immediately stops future reminders/tasks and keeps/restores active service, including when confirmation races with suspension.
|
||||
- Timezone, idempotency, audit, disputes, extensions, and notification-delivery requirements are satisfied.
|
||||
- The audit trail identifies every in-app notice, email delivery, call task, call outcome, payment confirmation, override, collections resolution, and suspension with actor and timestamp.
|
||||
- All API, service, authorization, regression, and multilingual UI tests pass.
|
||||
|
||||
## 14. Required business inputs before implementation
|
||||
|
||||
These are configuration inputs, not reasons to weaken the design:
|
||||
|
||||
- Bank-transfer payer instructions: account name, bank name, RIB/IBAN or local account reference, and transfer memo rule.
|
||||
- Check instructions: exact payee name, delivery address, and whether activation waits for deposit or full clearance. Recommended: full clearance.
|
||||
- Manual-method payment/clearing lead times. The post-expiration subscription grace period is fixed at 30 days unless an authorized extension overrides it.
|
||||
- Finance escalation owner for unmatched, short, excess, duplicate, or bounced payments.
|
||||
- Collections assignment/fallback policy, overdue call-task escalation SLA, and which admin roles may act as collections owners.
|
||||
- Required local reminder send time and verified source of each billing account’s IANA timezone.
|
||||
- Whether the UI should expose generic base locales (`ar`, `en`, `fr`) while formatting for Morocco-specific conventions (`ar-MA`, `fr-MA`), and the regional convention to use for English. Recommended: store the three base communication choices and map them centrally to approved regional formatting locales.
|
||||
- Approved professional Arabic, English, and French wording for payment instructions, legal/collections notices, evidence rejection, dispute/extension, grace-period warnings, call scripts, and suspension. Product/finance must approve all three versions before notifications are enabled.
|
||||
- Override policy: maximum extension length, who may approve it, when normal daily notices pause, and dispute communication templates.
|
||||
- Whether manual methods are available to every plan/company. Recommended MVP: configurable globally, with the ability to disable each method independently.
|
||||
- Evidence retention/deletion period, legal-hold rules, and the person authorized to approve exceptional deletion. This must follow the applicable accounting/privacy jurisdiction.
|
||||
- Approved malware-scanning service and production private-storage backend. Do not enable uploads if scanning or durable private storage is unavailable.
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SubscriptionCollectionMethod" AS ENUM ('STRIPE', 'BANK_TRANSFER', 'CHECK');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BillingPaymentChannel" AS ENUM ('ONLINE', 'OFFLINE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ManualPaymentMethod" AS ENUM ('BANK_TRANSFER', 'CHECK');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ManualPaymentSubmissionStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ManualPaymentDocumentKind" AS ENUM ('BANK_TRANSFER_RECEIPT', 'CHECK_COPY', 'OTHER_SUPPORTING_EVIDENCE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ManualPaymentDocumentScanStatus" AS ENUM ('UPLOADED', 'SCANNING', 'CLEAN', 'QUARANTINED', 'SCAN_FAILED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CollectionsCaseStatus" AS ENUM ('SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CollectionsCallTaskType" AS ENUM ('PRE_EXPIRY_48H_CALL', 'PAYMENT_PROMISE_FOLLOW_UP');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CollectionsCallTaskStatus" AS ENUM ('OPEN', 'COMPLETED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CollectionsCallOutcome" AS ENUM ('CONTACTED', 'NO_ANSWER', 'PAYMENT_PROMISED', 'ISSUE_ESCALATED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CollectionsOverrideType" AS ENUM ('PAYMENT_DISPUTE', 'MANUAL_EXTENSION');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CollectionsOverrideStatus" AS ENUM ('ACTIVE', 'REVOKED', 'EXPIRED');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_DUE_14D';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_DUE_7D';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_DUE_48H';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_DUE_24H';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_GRACE_DAILY';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_GRACE_FINAL';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'COLLECTIONS_CALL_REQUIRED';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_PAYMENT_CONFIRMED';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'MANUAL_PAYMENT_EVIDENCE_REJECTED';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'COLLECTIONS_OVERRIDE_CHANGED';
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "NotificationRecipientType" ADD VALUE 'BILLING_CONTACT';
|
||||
ALTER TYPE "NotificationRecipientType" ADD VALUE 'ADMIN';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "billing_accounts" ADD COLUMN "collectionsOwnerAdminId" TEXT,
|
||||
ADD COLUMN "defaultCommunicationLocale" TEXT NOT NULL DEFAULT 'en',
|
||||
ADD COLUMN "enabledCommunicationLocales" TEXT[] DEFAULT ARRAY['en']::TEXT[],
|
||||
ADD COLUMN "reminderLocalTime" TEXT NOT NULL DEFAULT '09:00',
|
||||
ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'Africa/Casablanca';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "billing_invoices" ADD COLUMN "checkoutIdempotencyKey" TEXT,
|
||||
ADD COLUMN "collectionMethod" "SubscriptionCollectionMethod" NOT NULL DEFAULT 'STRIPE',
|
||||
ADD COLUMN "renewalKey" TEXT,
|
||||
ADD COLUMN "requestedBillingPeriod" "BillingPeriod",
|
||||
ADD COLUMN "requestedPlan" "Plan";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "billing_payment_attempts" ADD COLUMN "channel" "BillingPaymentChannel" NOT NULL DEFAULT 'ONLINE',
|
||||
ADD COLUMN "confirmedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "confirmedByAdminId" TEXT,
|
||||
ADD COLUMN "externalReference" TEXT,
|
||||
ADD COLUMN "idempotencyKey" TEXT,
|
||||
ADD COLUMN "manualMethod" "ManualPaymentMethod",
|
||||
ADD COLUMN "normalizedExternalReference" TEXT,
|
||||
ADD COLUMN "note" TEXT,
|
||||
ADD COLUMN "receivedAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "notification_recipients" ADD COLUMN "adminUserId" TEXT,
|
||||
ADD COLUMN "billingContactId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "admin_users" ADD COLUMN "preferredLocale" TEXT NOT NULL DEFAULT 'en';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "billing_contacts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"billingAccountId" TEXT NOT NULL,
|
||||
"companyId" TEXT NOT NULL,
|
||||
"employeeId" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"locale" TEXT,
|
||||
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
|
||||
"receivePaymentNotices" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"verifiedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "billing_contacts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "manual_payment_submissions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"billingAccountId" TEXT NOT NULL,
|
||||
"companyId" TEXT NOT NULL,
|
||||
"method" "ManualPaymentMethod" NOT NULL,
|
||||
"submittedReference" TEXT NOT NULL,
|
||||
"normalizedSubmittedReference" TEXT NOT NULL,
|
||||
"status" "ManualPaymentSubmissionStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"submittedByEmployeeId" TEXT NOT NULL,
|
||||
"submittedAt" TIMESTAMP(3),
|
||||
"reviewedByAdminId" TEXT,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"rejectionReason" TEXT,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"paymentAttemptId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "manual_payment_submissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "manual_payment_documents" (
|
||||
"id" TEXT NOT NULL,
|
||||
"submissionId" TEXT NOT NULL,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"companyId" TEXT NOT NULL,
|
||||
"kind" "ManualPaymentDocumentKind" NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"originalFilename" TEXT NOT NULL,
|
||||
"detectedMimeType" TEXT NOT NULL,
|
||||
"detectedExtension" TEXT NOT NULL,
|
||||
"byteSize" INTEGER NOT NULL,
|
||||
"sha256" TEXT NOT NULL,
|
||||
"scanStatus" "ManualPaymentDocumentScanStatus" NOT NULL DEFAULT 'UPLOADED',
|
||||
"scannerResultCode" TEXT,
|
||||
"uploadedByEmployeeId" TEXT NOT NULL,
|
||||
"uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "manual_payment_documents_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "collections_cases" (
|
||||
"id" TEXT NOT NULL,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"subscriptionId" TEXT NOT NULL,
|
||||
"billingAccountId" TEXT NOT NULL,
|
||||
"companyId" TEXT NOT NULL,
|
||||
"status" "CollectionsCaseStatus" NOT NULL DEFAULT 'SCHEDULED',
|
||||
"originalExpirationAt" TIMESTAMP(3) NOT NULL,
|
||||
"reminder14At" TIMESTAMP(3) NOT NULL,
|
||||
"reminder7At" TIMESTAMP(3) NOT NULL,
|
||||
"reminder48At" TIMESTAMP(3) NOT NULL,
|
||||
"reminder24At" TIMESTAMP(3) NOT NULL,
|
||||
"graceStartedAt" TIMESTAMP(3),
|
||||
"finalSuspensionAt" TIMESTAMP(3) NOT NULL,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"suspendedAt" TIMESTAMP(3),
|
||||
"nextActionAt" TIMESTAMP(3),
|
||||
"collectionsOwnerAdminId" TEXT,
|
||||
"resolutionPaymentAttemptId" TEXT,
|
||||
"processingLeaseUntil" TIMESTAMP(3),
|
||||
"processingBy" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "collections_cases_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "collections_call_tasks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"collectionsCaseId" TEXT NOT NULL,
|
||||
"taskType" "CollectionsCallTaskType" NOT NULL,
|
||||
"assignedAdminId" TEXT NOT NULL,
|
||||
"billingContactId" TEXT,
|
||||
"dueAt" TIMESTAMP(3) NOT NULL,
|
||||
"status" "CollectionsCallTaskStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"outcome" "CollectionsCallOutcome",
|
||||
"note" TEXT,
|
||||
"promisedPaymentAt" TIMESTAMP(3),
|
||||
"nextFollowUpAt" TIMESTAMP(3),
|
||||
"completedByAdminId" TEXT,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"cancellationReason" TEXT,
|
||||
"companyDefaultLocale" TEXT NOT NULL,
|
||||
"contactLocale" TEXT NOT NULL,
|
||||
"customerScript" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "collections_call_tasks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "collections_overrides" (
|
||||
"id" TEXT NOT NULL,
|
||||
"collectionsCaseId" TEXT NOT NULL,
|
||||
"type" "CollectionsOverrideType" NOT NULL,
|
||||
"status" "CollectionsOverrideStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"reason" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revisedSuspensionAt" TIMESTAMP(3),
|
||||
"pauseSuspension" BOOLEAN NOT NULL DEFAULT true,
|
||||
"pauseNotifications" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdByAdminId" TEXT NOT NULL,
|
||||
"revokedByAdminId" TEXT,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "collections_overrides_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "collections_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"collectionsCaseId" TEXT NOT NULL,
|
||||
"companyId" TEXT NOT NULL,
|
||||
"eventType" TEXT NOT NULL,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"scheduledFor" TIMESTAMP(3),
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"actorType" TEXT NOT NULL DEFAULT 'system',
|
||||
"actorId" TEXT,
|
||||
"payload" JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
CONSTRAINT "collections_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "billing_contacts_companyId_isActive_idx" ON "billing_contacts"("companyId", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "billing_contacts_employeeId_idx" ON "billing_contacts"("employeeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "billing_contacts_billingAccountId_email_key" ON "billing_contacts"("billingAccountId", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "manual_payment_submissions_paymentAttemptId_key" ON "manual_payment_submissions"("paymentAttemptId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "manual_payment_submissions_invoiceId_status_idx" ON "manual_payment_submissions"("invoiceId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "manual_payment_submissions_status_submittedAt_idx" ON "manual_payment_submissions"("status", "submittedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "manual_payment_submissions_billingAccountId_idempotencyKey_key" ON "manual_payment_submissions"("billingAccountId", "idempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "manual_payment_documents_storageKey_key" ON "manual_payment_documents"("storageKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "manual_payment_documents_invoiceId_idx" ON "manual_payment_documents"("invoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "manual_payment_documents_companyId_idx" ON "manual_payment_documents"("companyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "manual_payment_documents_scanStatus_uploadedAt_idx" ON "manual_payment_documents"("scanStatus", "uploadedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "manual_payment_documents_submissionId_sha256_key" ON "manual_payment_documents"("submissionId", "sha256");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "collections_cases_invoiceId_key" ON "collections_cases"("invoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "collections_cases_resolutionPaymentAttemptId_key" ON "collections_cases"("resolutionPaymentAttemptId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "collections_cases_status_nextActionAt_idx" ON "collections_cases"("status", "nextActionAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "collections_cases_subscriptionId_originalExpirationAt_key" ON "collections_cases"("subscriptionId", "originalExpirationAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "collections_call_tasks_assignedAdminId_status_dueAt_idx" ON "collections_call_tasks"("assignedAdminId", "status", "dueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "collections_call_tasks_collectionsCaseId_taskType_key" ON "collections_call_tasks"("collectionsCaseId", "taskType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "collections_overrides_collectionsCaseId_status_expiresAt_idx" ON "collections_overrides"("collectionsCaseId", "status", "expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "collections_events_companyId_occurredAt_idx" ON "collections_events"("companyId", "occurredAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "collections_events_collectionsCaseId_idempotencyKey_key" ON "collections_events"("collectionsCaseId", "idempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "billing_invoices_renewalKey_key" ON "billing_invoices"("renewalKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "billing_invoices_collectionMethod_status_dueAt_idx" ON "billing_invoices"("collectionMethod", "status", "dueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "billing_invoices_billingAccountId_checkoutIdempotencyKey_key" ON "billing_invoices"("billingAccountId", "checkoutIdempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "billing_payment_attempts_billingAccountId_idempotencyKey_key" ON "billing_payment_attempts"("billingAccountId", "idempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "billing_payment_attempts_billingAccountId_manualMethod_norm_key" ON "billing_payment_attempts"("billingAccountId", "manualMethod", "normalizedExternalReference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notification_recipients_billingContactId_readAt_createdAt_idx" ON "notification_recipients"("billingContactId", "readAt", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notification_recipients_adminUserId_readAt_createdAt_idx" ON "notification_recipients"("adminUserId", "readAt", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "billing_accounts" ADD CONSTRAINT "billing_accounts_collectionsOwnerAdminId_fkey" FOREIGN KEY ("collectionsOwnerAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "billing_contacts" ADD CONSTRAINT "billing_contacts_billingAccountId_fkey" FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "billing_contacts" ADD CONSTRAINT "billing_contacts_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "billing_contacts" ADD CONSTRAINT "billing_contacts_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "billing_payment_attempts" ADD CONSTRAINT "billing_payment_attempts_confirmedByAdminId_fkey" FOREIGN KEY ("confirmedByAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_billingAccountId_fkey" FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_submittedByEmployeeId_fkey" FOREIGN KEY ("submittedByEmployeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_reviewedByAdminId_fkey" FOREIGN KEY ("reviewedByAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "manual_payment_submissions" ADD CONSTRAINT "manual_payment_submissions_paymentAttemptId_fkey" FOREIGN KEY ("paymentAttemptId") REFERENCES "billing_payment_attempts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "manual_payment_documents" ADD CONSTRAINT "manual_payment_documents_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "manual_payment_submissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "manual_payment_documents" ADD CONSTRAINT "manual_payment_documents_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "manual_payment_documents" ADD CONSTRAINT "manual_payment_documents_uploadedByEmployeeId_fkey" FOREIGN KEY ("uploadedByEmployeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_billingAccountId_fkey" FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_collectionsOwnerAdminId_fkey" FOREIGN KEY ("collectionsOwnerAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_cases" ADD CONSTRAINT "collections_cases_resolutionPaymentAttemptId_fkey" FOREIGN KEY ("resolutionPaymentAttemptId") REFERENCES "billing_payment_attempts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_call_tasks" ADD CONSTRAINT "collections_call_tasks_collectionsCaseId_fkey" FOREIGN KEY ("collectionsCaseId") REFERENCES "collections_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_call_tasks" ADD CONSTRAINT "collections_call_tasks_assignedAdminId_fkey" FOREIGN KEY ("assignedAdminId") REFERENCES "admin_users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_call_tasks" ADD CONSTRAINT "collections_call_tasks_billingContactId_fkey" FOREIGN KEY ("billingContactId") REFERENCES "billing_contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_call_tasks" ADD CONSTRAINT "collections_call_tasks_completedByAdminId_fkey" FOREIGN KEY ("completedByAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_overrides" ADD CONSTRAINT "collections_overrides_collectionsCaseId_fkey" FOREIGN KEY ("collectionsCaseId") REFERENCES "collections_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_overrides" ADD CONSTRAINT "collections_overrides_createdByAdminId_fkey" FOREIGN KEY ("createdByAdminId") REFERENCES "admin_users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_overrides" ADD CONSTRAINT "collections_overrides_revokedByAdminId_fkey" FOREIGN KEY ("revokedByAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_events" ADD CONSTRAINT "collections_events_collectionsCaseId_fkey" FOREIGN KEY ("collectionsCaseId") REFERENCES "collections_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections_events" ADD CONSTRAINT "collections_events_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notification_recipients" ADD CONSTRAINT "notification_recipients_billingContactId_fkey" FOREIGN KEY ("billingContactId") REFERENCES "billing_contacts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notification_recipients" ADD CONSTRAINT "notification_recipients_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "admin_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill communication policy from existing supported brand language and
|
||||
-- create an explicit primary owner contact. Accounts remain visibly flagged
|
||||
-- for owner review because the legacy schema had no verified IANA timezone.
|
||||
UPDATE "billing_accounts" AS account
|
||||
SET "defaultCommunicationLocale" = CASE
|
||||
WHEN brand."defaultLocale" IN ('ar', 'en', 'fr') THEN brand."defaultLocale"
|
||||
ELSE 'en'
|
||||
END,
|
||||
"enabledCommunicationLocales" = ARRAY[CASE
|
||||
WHEN brand."defaultLocale" IN ('ar', 'en', 'fr') THEN brand."defaultLocale"
|
||||
ELSE 'en'
|
||||
END]::TEXT[],
|
||||
"metadata" = COALESCE(account."metadata", '{}'::jsonb) || '{"communicationSettingsRequireOwnerReview":true}'::jsonb
|
||||
FROM "brand_settings" AS brand
|
||||
WHERE brand."companyId" = account."companyId";
|
||||
|
||||
INSERT INTO "billing_contacts" (
|
||||
"id", "billingAccountId", "companyId", "employeeId", "email", "locale",
|
||||
"isPrimary", "receivePaymentNotices", "isActive", "verifiedAt", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'bc_' || md5(random()::text || clock_timestamp()::text || account."id"),
|
||||
account."id", account."companyId", owner."id", owner."email",
|
||||
CASE WHEN owner."preferredLanguage" IN ('ar', 'en', 'fr') THEN owner."preferredLanguage" ELSE account."defaultCommunicationLocale" END,
|
||||
true, true, true, COALESCE(owner."emailVerified", CURRENT_TIMESTAMP), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM "billing_accounts" AS account
|
||||
JOIN LATERAL (
|
||||
SELECT employee.*
|
||||
FROM "employees" AS employee
|
||||
WHERE employee."companyId" = account."companyId"
|
||||
AND employee."role" = 'OWNER'
|
||||
AND employee."isActive" = true
|
||||
ORDER BY employee."createdAt" ASC
|
||||
LIMIT 1
|
||||
) AS owner ON true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "billing_contacts" AS contact
|
||||
WHERE contact."billingAccountId" = account."id"
|
||||
);
|
||||
|
||||
UPDATE "billing_accounts" AS account
|
||||
SET "collectionsOwnerAdminId" = admin."id"
|
||||
FROM LATERAL (
|
||||
SELECT "id"
|
||||
FROM "admin_users"
|
||||
WHERE "isActive" = true AND "role" IN ('FINANCE', 'ADMIN', 'SUPER_ADMIN')
|
||||
ORDER BY "createdAt" ASC
|
||||
LIMIT 1
|
||||
) AS admin
|
||||
WHERE account."collectionsOwnerAdminId" IS NULL;
|
||||
|
||||
-- Database invariants that Prisma cannot express directly.
|
||||
ALTER TABLE "billing_accounts"
|
||||
ALTER COLUMN "enabledCommunicationLocales" SET NOT NULL,
|
||||
ALTER COLUMN "defaultCommunicationLocale" SET NOT NULL,
|
||||
ALTER COLUMN "timezone" SET NOT NULL,
|
||||
ALTER COLUMN "reminderLocalTime" SET NOT NULL;
|
||||
|
||||
ALTER TABLE "billing_accounts"
|
||||
ADD CONSTRAINT "billing_accounts_supported_locales_check"
|
||||
CHECK (
|
||||
cardinality("enabledCommunicationLocales") BETWEEN 1 AND 3
|
||||
AND "enabledCommunicationLocales" <@ ARRAY['ar', 'en', 'fr']::TEXT[]
|
||||
AND "defaultCommunicationLocale" = ANY("enabledCommunicationLocales")
|
||||
),
|
||||
ADD CONSTRAINT "billing_accounts_reminder_time_check"
|
||||
CHECK ("reminderLocalTime" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$');
|
||||
|
||||
ALTER TABLE "billing_contacts"
|
||||
ADD CONSTRAINT "billing_contacts_supported_locale_check"
|
||||
CHECK ("locale" IS NULL OR "locale" IN ('ar', 'en', 'fr'));
|
||||
|
||||
ALTER TABLE "admin_users"
|
||||
ADD CONSTRAINT "admin_users_supported_locale_check"
|
||||
CHECK ("preferredLocale" IN ('ar', 'en', 'fr'));
|
||||
|
||||
ALTER TABLE "billing_payment_attempts"
|
||||
ADD CONSTRAINT "billing_payment_attempts_offline_confirmation_check"
|
||||
CHECK (
|
||||
"channel" <> 'OFFLINE'
|
||||
OR "status" <> 'SUCCEEDED'
|
||||
OR (
|
||||
"manualMethod" IS NOT NULL
|
||||
AND length(trim(COALESCE("externalReference", ''))) >= 3
|
||||
AND length(trim(COALESCE("normalizedExternalReference", ''))) >= 3
|
||||
AND "receivedAt" IS NOT NULL
|
||||
AND "confirmedAt" IS NOT NULL
|
||||
AND "confirmedByAdminId" IS NOT NULL
|
||||
AND "idempotencyKey" IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE "manual_payment_documents"
|
||||
ADD CONSTRAINT "manual_payment_documents_size_check"
|
||||
CHECK ("byteSize" > 0 AND "byteSize" <= 10485760),
|
||||
ADD CONSTRAINT "manual_payment_documents_mime_check"
|
||||
CHECK ("detectedMimeType" IN ('application/pdf', 'image/jpeg', 'image/png'));
|
||||
|
||||
ALTER TABLE "manual_payment_submissions"
|
||||
ADD CONSTRAINT "manual_payment_submissions_reference_check"
|
||||
CHECK (length(trim("submittedReference")) >= 3 AND length(trim("normalizedSubmittedReference")) >= 3);
|
||||
|
||||
ALTER TABLE "collections_overrides"
|
||||
ADD CONSTRAINT "collections_overrides_finite_window_check"
|
||||
CHECK ("expiresAt" > "createdAt");
|
||||
|
||||
ALTER TABLE "notification_recipients"
|
||||
ADD CONSTRAINT "notification_recipients_exact_actor_check"
|
||||
CHECK (num_nonnulls("employeeId", "renterId", "billingContactId", "adminUserId") = 1);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_enum e
|
||||
JOIN pg_type t ON t.oid = e.enumtypid
|
||||
WHERE t.typname = 'NotificationType'
|
||||
AND e.enumlabel = 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED'
|
||||
) THEN
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED';
|
||||
END IF;
|
||||
END $$;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
-- Keep only the newest active manual payment submission per invoice before
|
||||
-- installing the invariant. Historical approved/rejected submissions remain.
|
||||
WITH ranked_active_submissions AS (
|
||||
SELECT
|
||||
"id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "invoiceId"
|
||||
ORDER BY "createdAt" DESC, "id" DESC
|
||||
) AS row_number
|
||||
FROM "manual_payment_submissions"
|
||||
WHERE "status" IN ('DRAFT', 'SUBMITTED', 'UNDER_REVIEW')
|
||||
)
|
||||
UPDATE "manual_payment_submissions" AS submission
|
||||
SET
|
||||
"status" = 'REJECTED',
|
||||
"rejectionReason" = COALESCE(
|
||||
submission."rejectionReason",
|
||||
'Superseded by a newer active manual payment submission.'
|
||||
),
|
||||
"reviewedAt" = COALESCE(submission."reviewedAt", CURRENT_TIMESTAMP)
|
||||
FROM ranked_active_submissions AS ranked
|
||||
WHERE submission."id" = ranked."id"
|
||||
AND ranked.row_number > 1;
|
||||
|
||||
CREATE UNIQUE INDEX "manual_payment_submissions_one_active_per_invoice"
|
||||
ON "manual_payment_submissions" ("invoiceId")
|
||||
WHERE "status" IN ('DRAFT', 'SUBMITTED', 'UNDER_REVIEW');
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
-- Keep a single primary billing account per company. Prefer the account with
|
||||
-- the highest open balance, then the most invoices, then the newest account.
|
||||
WITH account_scores AS (
|
||||
SELECT
|
||||
account."id",
|
||||
account."companyId",
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
WHEN invoice."status" IN ('OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID')
|
||||
THEN invoice."amountDue"
|
||||
ELSE 0
|
||||
END
|
||||
), 0) AS open_balance,
|
||||
COUNT(invoice."id") AS invoice_count,
|
||||
account."createdAt"
|
||||
FROM "billing_accounts" AS account
|
||||
LEFT JOIN "billing_invoices" AS invoice
|
||||
ON invoice."billingAccountId" = account."id"
|
||||
WHERE account."isPrimary" = true
|
||||
GROUP BY account."id", account."companyId", account."createdAt"
|
||||
),
|
||||
ranked_primary_accounts AS (
|
||||
SELECT
|
||||
"id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "companyId"
|
||||
ORDER BY open_balance DESC, invoice_count DESC, "createdAt" DESC, "id" DESC
|
||||
) AS row_number
|
||||
FROM account_scores
|
||||
)
|
||||
UPDATE "billing_accounts" AS account
|
||||
SET "isPrimary" = false
|
||||
FROM ranked_primary_accounts AS ranked
|
||||
WHERE account."id" = ranked."id"
|
||||
AND ranked.row_number > 1;
|
||||
|
||||
CREATE UNIQUE INDEX "billing_accounts_one_primary_per_company"
|
||||
ON "billing_accounts" ("companyId")
|
||||
WHERE "isPrimary" = true;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "platform_billing_settings" (
|
||||
"id" TEXT NOT NULL DEFAULT 'default',
|
||||
"taxRate" DOUBLE PRECISION NOT NULL DEFAULT 20,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"updatedBy" TEXT,
|
||||
|
||||
CONSTRAINT "platform_billing_settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
INSERT INTO "platform_billing_settings" ("id", "taxRate", "updatedAt")
|
||||
VALUES ('default', 20, NOW())
|
||||
ON CONFLICT ("id") DO NOTHING;
|
||||
@@ -134,6 +134,81 @@ enum BillingPaymentAttemptStatus {
|
||||
PARTIALLY_REFUNDED
|
||||
}
|
||||
|
||||
enum SubscriptionCollectionMethod {
|
||||
STRIPE
|
||||
BANK_TRANSFER
|
||||
CHECK
|
||||
}
|
||||
|
||||
enum BillingPaymentChannel {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
}
|
||||
|
||||
enum ManualPaymentMethod {
|
||||
BANK_TRANSFER
|
||||
CHECK
|
||||
}
|
||||
|
||||
enum ManualPaymentSubmissionStatus {
|
||||
DRAFT
|
||||
SUBMITTED
|
||||
UNDER_REVIEW
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ManualPaymentDocumentKind {
|
||||
BANK_TRANSFER_RECEIPT
|
||||
CHECK_COPY
|
||||
OTHER_SUPPORTING_EVIDENCE
|
||||
}
|
||||
|
||||
enum ManualPaymentDocumentScanStatus {
|
||||
UPLOADED
|
||||
SCANNING
|
||||
CLEAN
|
||||
QUARANTINED
|
||||
SCAN_FAILED
|
||||
}
|
||||
|
||||
enum CollectionsCaseStatus {
|
||||
SCHEDULED
|
||||
PRE_DUE
|
||||
GRACE_PERIOD
|
||||
RESOLVED
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
enum CollectionsCallTaskType {
|
||||
PRE_EXPIRY_48H_CALL
|
||||
PAYMENT_PROMISE_FOLLOW_UP
|
||||
}
|
||||
|
||||
enum CollectionsCallTaskStatus {
|
||||
OPEN
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum CollectionsCallOutcome {
|
||||
CONTACTED
|
||||
NO_ANSWER
|
||||
PAYMENT_PROMISED
|
||||
ISSUE_ESCALATED
|
||||
}
|
||||
|
||||
enum CollectionsOverrideType {
|
||||
PAYMENT_DISPUTE
|
||||
MANUAL_EXTENSION
|
||||
}
|
||||
|
||||
enum CollectionsOverrideStatus {
|
||||
ACTIVE
|
||||
REVOKED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum BillingRefundStatus {
|
||||
PENDING
|
||||
SUCCEEDED
|
||||
@@ -441,6 +516,17 @@ enum NotificationType {
|
||||
REFUND_PROCESSED
|
||||
NEW_OFFER_FROM_SAVED_COMPANY
|
||||
REVIEW_REQUEST
|
||||
SUBSCRIPTION_PAYMENT_DUE_14D
|
||||
SUBSCRIPTION_PAYMENT_DUE_7D
|
||||
SUBSCRIPTION_PAYMENT_DUE_48H
|
||||
SUBSCRIPTION_PAYMENT_DUE_24H
|
||||
SUBSCRIPTION_GRACE_DAILY
|
||||
SUBSCRIPTION_GRACE_FINAL
|
||||
COLLECTIONS_CALL_REQUIRED
|
||||
MANUAL_PAYMENT_EVIDENCE_SUBMITTED
|
||||
SUBSCRIPTION_PAYMENT_CONFIRMED
|
||||
MANUAL_PAYMENT_EVIDENCE_REJECTED
|
||||
COLLECTIONS_OVERRIDE_CHANGED
|
||||
}
|
||||
|
||||
enum FeedbackCategory {
|
||||
@@ -492,6 +578,8 @@ enum NotificationStatus {
|
||||
enum NotificationRecipientType {
|
||||
EMPLOYEE
|
||||
RENTER
|
||||
BILLING_CONTACT
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum NotificationDeliveryStatus {
|
||||
@@ -529,27 +617,32 @@ model Company {
|
||||
status CompanyStatus @default(PENDING)
|
||||
subscriptionPaymentRef String?
|
||||
|
||||
subscription Subscription?
|
||||
billingAccounts BillingAccount[]
|
||||
billingInvoices BillingInvoice[]
|
||||
brand BrandSettings?
|
||||
employees Employee[]
|
||||
vehicles Vehicle[]
|
||||
offers Offer[]
|
||||
reservations Reservation[]
|
||||
customers Customer[]
|
||||
rentalPayments RentalPayment[]
|
||||
subscriptionInvoices SubscriptionInvoice[]
|
||||
contractSettings ContractSettings?
|
||||
accountingSettings AccountingSettings?
|
||||
insurancePolicies InsurancePolicy[]
|
||||
pricingRules PricingRule[]
|
||||
notifications Notification[] @relation("CompanyNotifications")
|
||||
notificationEvents NotificationEvent[]
|
||||
notificationDefaults CompanyNotificationPreference[]
|
||||
complaints Complaint[]
|
||||
companyMenuItems CompanyMenuItem[]
|
||||
apiKeys CompanyApiKey[]
|
||||
subscription Subscription?
|
||||
billingAccounts BillingAccount[]
|
||||
billingInvoices BillingInvoice[]
|
||||
brand BrandSettings?
|
||||
employees Employee[]
|
||||
vehicles Vehicle[]
|
||||
offers Offer[]
|
||||
reservations Reservation[]
|
||||
customers Customer[]
|
||||
rentalPayments RentalPayment[]
|
||||
subscriptionInvoices SubscriptionInvoice[]
|
||||
contractSettings ContractSettings?
|
||||
accountingSettings AccountingSettings?
|
||||
insurancePolicies InsurancePolicy[]
|
||||
pricingRules PricingRule[]
|
||||
notifications Notification[] @relation("CompanyNotifications")
|
||||
notificationEvents NotificationEvent[]
|
||||
notificationDefaults CompanyNotificationPreference[]
|
||||
billingContacts BillingContact[]
|
||||
manualPaymentSubmissions ManualPaymentSubmission[]
|
||||
manualPaymentDocuments ManualPaymentDocument[]
|
||||
collectionsCases CollectionsCase[]
|
||||
collectionsEvents CollectionsEvent[]
|
||||
complaints Complaint[]
|
||||
companyMenuItems CompanyMenuItem[]
|
||||
apiKeys CompanyApiKey[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -597,6 +690,7 @@ model Subscription {
|
||||
maxRetryCount Int @default(5)
|
||||
invoices SubscriptionInvoice[]
|
||||
billingInvoices BillingInvoice[]
|
||||
collectionsCases CollectionsCase[]
|
||||
events SubscriptionEvent[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -623,28 +717,28 @@ model SubscriptionEvent {
|
||||
}
|
||||
|
||||
model SubscriptionInvoice {
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
subscriptionId String
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id])
|
||||
requestedPlan Plan?
|
||||
requestedBillingPeriod BillingPeriod?
|
||||
providerInvoiceId String?
|
||||
amount Int
|
||||
currency String @default("MAD")
|
||||
status InvoiceStatus
|
||||
amanpayTransactionId String? @unique
|
||||
paypalCaptureId String? @unique
|
||||
stripeCheckoutSessionId String? @unique
|
||||
paymentProvider PaymentProvider @default(AMANPAY)
|
||||
billingInvoiceId String? @unique
|
||||
billingInvoice BillingInvoice? @relation(fields: [billingInvoiceId], references: [id])
|
||||
dueAt DateTime?
|
||||
paidAt DateTime?
|
||||
failedAt DateTime?
|
||||
voidedAt DateTime?
|
||||
attempts PaymentAttempt[]
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
subscriptionId String
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id])
|
||||
requestedPlan Plan?
|
||||
requestedBillingPeriod BillingPeriod?
|
||||
providerInvoiceId String?
|
||||
amount Int
|
||||
currency String @default("MAD")
|
||||
status InvoiceStatus
|
||||
amanpayTransactionId String? @unique
|
||||
paypalCaptureId String? @unique
|
||||
stripeCheckoutSessionId String? @unique
|
||||
paymentProvider PaymentProvider @default(AMANPAY)
|
||||
billingInvoiceId String? @unique
|
||||
billingInvoice BillingInvoice? @relation(fields: [billingInvoiceId], references: [id])
|
||||
dueAt DateTime?
|
||||
paidAt DateTime?
|
||||
failedAt DateTime?
|
||||
voidedAt DateTime?
|
||||
attempts PaymentAttempt[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -653,35 +747,44 @@ model SubscriptionInvoice {
|
||||
}
|
||||
|
||||
model BillingAccount {
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
isPrimary Boolean @default(true)
|
||||
legalName String
|
||||
billingEmail String
|
||||
preferredLanguage String @default("en")
|
||||
billingAddress Json?
|
||||
taxId String?
|
||||
taxExempt Boolean @default(false)
|
||||
defaultCurrency String @default("MAD")
|
||||
defaultPaymentMethodId String?
|
||||
defaultPaymentMethod BillingPaymentMethod? @relation("BillingAccountDefaultPaymentMethod", fields: [defaultPaymentMethodId], references: [id])
|
||||
invoiceTerms BillingInvoiceTerms @default(DUE_ON_RECEIPT)
|
||||
netTermsDays Int @default(0)
|
||||
providerCustomerId String?
|
||||
dunningPaused Boolean @default(false)
|
||||
dunningPausedAt DateTime?
|
||||
dunningPausedBy String?
|
||||
metadata Json @default("{}")
|
||||
paymentMethods BillingPaymentMethod[] @relation("BillingAccountPaymentMethods")
|
||||
invoices BillingInvoice[]
|
||||
paymentIntents BillingPaymentIntent[]
|
||||
paymentAttempts BillingPaymentAttempt[]
|
||||
creditBalances BillingCreditBalance[]
|
||||
creditLedgerEntries BillingCreditLedgerEntry[]
|
||||
creditNotes BillingCreditNote[]
|
||||
refunds BillingRefund[]
|
||||
events BillingEvent[]
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
isPrimary Boolean @default(true)
|
||||
legalName String
|
||||
billingEmail String
|
||||
preferredLanguage String @default("en")
|
||||
timezone String @default("Africa/Casablanca")
|
||||
reminderLocalTime String @default("09:00")
|
||||
enabledCommunicationLocales String[] @default(["en"])
|
||||
defaultCommunicationLocale String @default("en")
|
||||
collectionsOwnerAdminId String?
|
||||
collectionsOwnerAdmin AdminUser? @relation("BillingAccountCollectionsOwner", fields: [collectionsOwnerAdminId], references: [id], onDelete: SetNull)
|
||||
billingAddress Json?
|
||||
taxId String?
|
||||
taxExempt Boolean @default(false)
|
||||
defaultCurrency String @default("MAD")
|
||||
defaultPaymentMethodId String?
|
||||
defaultPaymentMethod BillingPaymentMethod? @relation("BillingAccountDefaultPaymentMethod", fields: [defaultPaymentMethodId], references: [id])
|
||||
invoiceTerms BillingInvoiceTerms @default(DUE_ON_RECEIPT)
|
||||
netTermsDays Int @default(0)
|
||||
providerCustomerId String?
|
||||
dunningPaused Boolean @default(false)
|
||||
dunningPausedAt DateTime?
|
||||
dunningPausedBy String?
|
||||
metadata Json @default("{}")
|
||||
paymentMethods BillingPaymentMethod[] @relation("BillingAccountPaymentMethods")
|
||||
invoices BillingInvoice[]
|
||||
paymentIntents BillingPaymentIntent[]
|
||||
paymentAttempts BillingPaymentAttempt[]
|
||||
creditBalances BillingCreditBalance[]
|
||||
creditLedgerEntries BillingCreditLedgerEntry[]
|
||||
creditNotes BillingCreditNote[]
|
||||
refunds BillingRefund[]
|
||||
events BillingEvent[]
|
||||
billingContacts BillingContact[]
|
||||
manualPaymentSubmissions ManualPaymentSubmission[]
|
||||
collectionsCases CollectionsCase[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -691,6 +794,31 @@ model BillingAccount {
|
||||
@@map("billing_accounts")
|
||||
}
|
||||
|
||||
model BillingContact {
|
||||
id String @id @default(cuid())
|
||||
billingAccountId String
|
||||
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
employeeId String?
|
||||
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
|
||||
email String
|
||||
locale String?
|
||||
isPrimary Boolean @default(false)
|
||||
receivePaymentNotices Boolean @default(true)
|
||||
isActive Boolean @default(true)
|
||||
verifiedAt DateTime?
|
||||
notificationRecipients NotificationRecipient[]
|
||||
callTasks CollectionsCallTask[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([billingAccountId, email])
|
||||
@@index([companyId, isActive])
|
||||
@@index([employeeId])
|
||||
@@map("billing_contacts")
|
||||
}
|
||||
|
||||
model BillingPaymentMethod {
|
||||
id String @id @default(cuid())
|
||||
billingAccountId String
|
||||
@@ -717,26 +845,26 @@ model BillingPaymentMethod {
|
||||
}
|
||||
|
||||
model BillingInvoice {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
billingAccountId String
|
||||
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
|
||||
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
subscriptionId String?
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||
legacySubscriptionInvoice SubscriptionInvoice?
|
||||
invoiceNumber String? @unique
|
||||
invoiceSequence Int? @unique
|
||||
invoiceNumber String? @unique
|
||||
invoiceSequence Int? @unique
|
||||
invoiceType BillingInvoiceType
|
||||
status BillingInvoiceStatus @default(DRAFT)
|
||||
currency String @default("MAD")
|
||||
subtotalAmount Int @default(0)
|
||||
discountAmount Int @default(0)
|
||||
creditAmount Int @default(0)
|
||||
taxAmount Int @default(0)
|
||||
totalAmount Int @default(0)
|
||||
amountPaid Int @default(0)
|
||||
amountDue Int @default(0)
|
||||
status BillingInvoiceStatus @default(DRAFT)
|
||||
currency String @default("MAD")
|
||||
subtotalAmount Int @default(0)
|
||||
discountAmount Int @default(0)
|
||||
creditAmount Int @default(0)
|
||||
taxAmount Int @default(0)
|
||||
totalAmount Int @default(0)
|
||||
amountPaid Int @default(0)
|
||||
amountDue Int @default(0)
|
||||
invoiceDate DateTime?
|
||||
dueAt DateTime?
|
||||
finalizedAt DateTime?
|
||||
@@ -748,9 +876,14 @@ model BillingInvoice {
|
||||
billingAddress Json?
|
||||
providerInvoiceId String?
|
||||
paymentProvider PaymentProvider?
|
||||
isSubscriptionBlocking Boolean @default(false)
|
||||
collectionMethod SubscriptionCollectionMethod @default(STRIPE)
|
||||
requestedPlan Plan?
|
||||
requestedBillingPeriod BillingPeriod?
|
||||
checkoutIdempotencyKey String?
|
||||
renewalKey String? @unique
|
||||
isSubscriptionBlocking Boolean @default(false)
|
||||
adminReason String?
|
||||
metadata Json @default("{}")
|
||||
metadata Json @default("{}")
|
||||
createdByAdminId String?
|
||||
lineItems BillingInvoiceLineItem[]
|
||||
paymentIntents BillingPaymentIntent[]
|
||||
@@ -759,14 +892,18 @@ model BillingInvoice {
|
||||
creditNotes BillingCreditNote[]
|
||||
refunds BillingRefund[]
|
||||
events BillingEvent[]
|
||||
manualPaymentSubmissions ManualPaymentSubmission[]
|
||||
collectionsCase CollectionsCase?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
BillingCreditLedgerEntry BillingCreditLedgerEntry[]
|
||||
|
||||
@@unique([billingAccountId, checkoutIdempotencyKey])
|
||||
@@index([billingAccountId])
|
||||
@@index([companyId])
|
||||
@@index([subscriptionId])
|
||||
@@index([collectionMethod, status, dueAt])
|
||||
@@map("billing_invoices")
|
||||
}
|
||||
|
||||
@@ -816,32 +953,217 @@ model BillingPaymentIntent {
|
||||
}
|
||||
|
||||
model BillingPaymentAttempt {
|
||||
id String @id @default(cuid())
|
||||
invoiceId String
|
||||
invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||
billingAccountId String
|
||||
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
|
||||
paymentIntentId String?
|
||||
paymentIntent BillingPaymentIntent? @relation(fields: [paymentIntentId], references: [id])
|
||||
paymentMethodId String?
|
||||
paymentMethod BillingPaymentMethod? @relation(fields: [paymentMethodId], references: [id])
|
||||
providerPaymentId String?
|
||||
status BillingPaymentAttemptStatus
|
||||
amount Int
|
||||
currency String @default("MAD")
|
||||
failureCode String?
|
||||
failureMessage String?
|
||||
attemptedAt DateTime
|
||||
metadata Json @default("{}")
|
||||
refunds BillingRefund[]
|
||||
id String @id @default(cuid())
|
||||
invoiceId String
|
||||
invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||
billingAccountId String
|
||||
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
|
||||
paymentIntentId String?
|
||||
paymentIntent BillingPaymentIntent? @relation(fields: [paymentIntentId], references: [id])
|
||||
paymentMethodId String?
|
||||
paymentMethod BillingPaymentMethod? @relation(fields: [paymentMethodId], references: [id])
|
||||
providerPaymentId String?
|
||||
channel BillingPaymentChannel @default(ONLINE)
|
||||
manualMethod ManualPaymentMethod?
|
||||
externalReference String?
|
||||
normalizedExternalReference String?
|
||||
receivedAt DateTime?
|
||||
confirmedAt DateTime?
|
||||
confirmedByAdminId String?
|
||||
confirmedByAdmin AdminUser? @relation("ManualPaymentConfirmingAdmin", fields: [confirmedByAdminId], references: [id], onDelete: SetNull)
|
||||
idempotencyKey String?
|
||||
note String?
|
||||
status BillingPaymentAttemptStatus
|
||||
amount Int
|
||||
currency String @default("MAD")
|
||||
failureCode String?
|
||||
failureMessage String?
|
||||
attemptedAt DateTime
|
||||
metadata Json @default("{}")
|
||||
refunds BillingRefund[]
|
||||
manualPaymentSubmission ManualPaymentSubmission?
|
||||
resolvedCollectionsCase CollectionsCase? @relation("CollectionsResolutionPayment")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([billingAccountId, idempotencyKey])
|
||||
@@unique([billingAccountId, manualMethod, normalizedExternalReference])
|
||||
@@index([invoiceId])
|
||||
@@index([billingAccountId])
|
||||
@@map("billing_payment_attempts")
|
||||
}
|
||||
|
||||
model ManualPaymentSubmission {
|
||||
id String @id @default(cuid())
|
||||
invoiceId String
|
||||
invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||
billingAccountId String
|
||||
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
method ManualPaymentMethod
|
||||
submittedReference String
|
||||
normalizedSubmittedReference String
|
||||
status ManualPaymentSubmissionStatus @default(DRAFT)
|
||||
submittedByEmployeeId String
|
||||
submittedByEmployee Employee @relation(fields: [submittedByEmployeeId], references: [id])
|
||||
submittedAt DateTime?
|
||||
reviewedByAdminId String?
|
||||
reviewedByAdmin AdminUser? @relation("ManualPaymentReviewingAdmin", fields: [reviewedByAdminId], references: [id], onDelete: SetNull)
|
||||
reviewedAt DateTime?
|
||||
rejectionReason String?
|
||||
idempotencyKey String
|
||||
paymentAttemptId String? @unique
|
||||
paymentAttempt BillingPaymentAttempt? @relation(fields: [paymentAttemptId], references: [id], onDelete: SetNull)
|
||||
documents ManualPaymentDocument[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([billingAccountId, idempotencyKey])
|
||||
@@index([invoiceId, status])
|
||||
@@index([status, submittedAt])
|
||||
@@map("manual_payment_submissions")
|
||||
}
|
||||
|
||||
model ManualPaymentDocument {
|
||||
id String @id @default(cuid())
|
||||
submissionId String
|
||||
submission ManualPaymentSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
|
||||
invoiceId String
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
kind ManualPaymentDocumentKind
|
||||
storageKey String @unique
|
||||
originalFilename String
|
||||
detectedMimeType String
|
||||
detectedExtension String
|
||||
byteSize Int
|
||||
sha256 String
|
||||
scanStatus ManualPaymentDocumentScanStatus @default(UPLOADED)
|
||||
scannerResultCode String?
|
||||
uploadedByEmployeeId String
|
||||
uploadedByEmployee Employee @relation(fields: [uploadedByEmployeeId], references: [id])
|
||||
uploadedAt DateTime @default(now())
|
||||
deletedAt DateTime?
|
||||
|
||||
@@unique([submissionId, sha256])
|
||||
@@index([invoiceId])
|
||||
@@index([companyId])
|
||||
@@index([scanStatus, uploadedAt])
|
||||
@@map("manual_payment_documents")
|
||||
}
|
||||
|
||||
model CollectionsCase {
|
||||
id String @id @default(cuid())
|
||||
invoiceId String @unique
|
||||
invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||
subscriptionId String
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
billingAccountId String
|
||||
billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
status CollectionsCaseStatus @default(SCHEDULED)
|
||||
originalExpirationAt DateTime
|
||||
reminder14At DateTime
|
||||
reminder7At DateTime
|
||||
reminder48At DateTime
|
||||
reminder24At DateTime
|
||||
graceStartedAt DateTime?
|
||||
finalSuspensionAt DateTime
|
||||
resolvedAt DateTime?
|
||||
suspendedAt DateTime?
|
||||
nextActionAt DateTime?
|
||||
collectionsOwnerAdminId String?
|
||||
collectionsOwnerAdmin AdminUser? @relation("CollectionsCaseOwner", fields: [collectionsOwnerAdminId], references: [id], onDelete: SetNull)
|
||||
resolutionPaymentAttemptId String? @unique
|
||||
resolutionPaymentAttempt BillingPaymentAttempt? @relation("CollectionsResolutionPayment", fields: [resolutionPaymentAttemptId], references: [id], onDelete: SetNull)
|
||||
processingLeaseUntil DateTime?
|
||||
processingBy String?
|
||||
version Int @default(0)
|
||||
tasks CollectionsCallTask[]
|
||||
overrides CollectionsOverride[]
|
||||
events CollectionsEvent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([subscriptionId, originalExpirationAt])
|
||||
@@index([status, nextActionAt])
|
||||
@@map("collections_cases")
|
||||
}
|
||||
|
||||
model CollectionsCallTask {
|
||||
id String @id @default(cuid())
|
||||
collectionsCaseId String
|
||||
collectionsCase CollectionsCase @relation(fields: [collectionsCaseId], references: [id], onDelete: Cascade)
|
||||
taskType CollectionsCallTaskType
|
||||
assignedAdminId String
|
||||
assignedAdmin AdminUser @relation("CollectionsTaskAssignee", fields: [assignedAdminId], references: [id])
|
||||
billingContactId String?
|
||||
billingContact BillingContact? @relation(fields: [billingContactId], references: [id], onDelete: SetNull)
|
||||
dueAt DateTime
|
||||
status CollectionsCallTaskStatus @default(OPEN)
|
||||
outcome CollectionsCallOutcome?
|
||||
note String?
|
||||
promisedPaymentAt DateTime?
|
||||
nextFollowUpAt DateTime?
|
||||
completedByAdminId String?
|
||||
completedByAdmin AdminUser? @relation("CollectionsTaskCompleter", fields: [completedByAdminId], references: [id], onDelete: SetNull)
|
||||
completedAt DateTime?
|
||||
cancellationReason String?
|
||||
companyDefaultLocale String
|
||||
contactLocale String
|
||||
customerScript String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([collectionsCaseId, taskType])
|
||||
@@index([assignedAdminId, status, dueAt])
|
||||
@@map("collections_call_tasks")
|
||||
}
|
||||
|
||||
model CollectionsOverride {
|
||||
id String @id @default(cuid())
|
||||
collectionsCaseId String
|
||||
collectionsCase CollectionsCase @relation(fields: [collectionsCaseId], references: [id], onDelete: Cascade)
|
||||
type CollectionsOverrideType
|
||||
status CollectionsOverrideStatus @default(ACTIVE)
|
||||
reason String
|
||||
expiresAt DateTime
|
||||
revisedSuspensionAt DateTime?
|
||||
pauseSuspension Boolean @default(true)
|
||||
pauseNotifications Boolean @default(false)
|
||||
createdByAdminId String
|
||||
createdByAdmin AdminUser @relation("CollectionsOverrideCreator", fields: [createdByAdminId], references: [id])
|
||||
revokedByAdminId String?
|
||||
revokedByAdmin AdminUser? @relation("CollectionsOverrideRevoker", fields: [revokedByAdminId], references: [id], onDelete: SetNull)
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([collectionsCaseId, status, expiresAt])
|
||||
@@map("collections_overrides")
|
||||
}
|
||||
|
||||
model CollectionsEvent {
|
||||
id String @id @default(cuid())
|
||||
collectionsCaseId String
|
||||
collectionsCase CollectionsCase @relation(fields: [collectionsCaseId], references: [id], onDelete: Cascade)
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
eventType String
|
||||
idempotencyKey String
|
||||
scheduledFor DateTime?
|
||||
occurredAt DateTime @default(now())
|
||||
actorType String @default("system")
|
||||
actorId String?
|
||||
payload Json @default("{}")
|
||||
|
||||
@@unique([collectionsCaseId, idempotencyKey])
|
||||
@@index([companyId, occurredAt])
|
||||
@@map("collections_events")
|
||||
}
|
||||
|
||||
model BillingCreditBalance {
|
||||
id String @id @default(cuid())
|
||||
billingAccountId String
|
||||
@@ -1008,8 +1330,8 @@ model BrandSettings {
|
||||
paypalEmail String?
|
||||
paypalMerchantId String?
|
||||
paymentMethodsEnabled PaymentProvider[]
|
||||
isListedOnCarplace Boolean @default(true)
|
||||
carplaceRating Float?
|
||||
isListedOnCarplace Boolean @default(true)
|
||||
carplaceRating Float?
|
||||
homePageConfig Json? @map("home_page_config")
|
||||
menuConfig Json? @map("menu_config")
|
||||
|
||||
@@ -1036,10 +1358,13 @@ model Employee {
|
||||
preferredLanguage String @default("en")
|
||||
isActive Boolean @default(true)
|
||||
|
||||
notifications Notification[] @relation("EmployeeNotifications")
|
||||
notificationRecipients NotificationRecipient[]
|
||||
notificationPreferences NotificationPreference[]
|
||||
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
|
||||
notifications Notification[] @relation("EmployeeNotifications")
|
||||
notificationRecipients NotificationRecipient[]
|
||||
notificationPreferences NotificationPreference[]
|
||||
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
|
||||
billingContacts BillingContact[]
|
||||
manualPaymentSubmissions ManualPaymentSubmission[]
|
||||
manualPaymentDocuments ManualPaymentDocument[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -1073,10 +1398,10 @@ model Vehicle {
|
||||
allowDifferentDropoff Boolean @default(false)
|
||||
dropoffLocations String[] @default([])
|
||||
|
||||
reservations Reservation[]
|
||||
maintenance MaintenanceLog[]
|
||||
offerVehicles OfferVehicle[]
|
||||
calendarBlocks VehicleCalendarBlock[]
|
||||
reservations Reservation[]
|
||||
maintenance MaintenanceLog[]
|
||||
offerVehicles OfferVehicle[]
|
||||
calendarBlocks VehicleCalendarBlock[]
|
||||
pricingConfiguration VehiclePricingConfiguration?
|
||||
priceHistory VehiclePriceHistory[]
|
||||
|
||||
@@ -1116,11 +1441,11 @@ model VehiclePricingConfiguration {
|
||||
}
|
||||
|
||||
model VehiclePricingRule {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
configurationId String
|
||||
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
ruleType VehiclePricingRuleType @default(DATE_RANGE)
|
||||
ruleType VehiclePricingRuleType @default(DATE_RANGE)
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
dailyRate Int?
|
||||
@@ -1131,8 +1456,8 @@ model VehiclePricingRule {
|
||||
maxDailyRate Int?
|
||||
automaticAdjustmentPct Int?
|
||||
priceAdjustment Int?
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -1143,12 +1468,12 @@ model VehiclePricingRule {
|
||||
}
|
||||
|
||||
model VehiclePriceHistory {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
configurationId String
|
||||
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
|
||||
vehicleId String
|
||||
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||
source VehiclePriceChangeSource @default(CONFIG_UPDATE)
|
||||
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||
source VehiclePriceChangeSource @default(CONFIG_UPDATE)
|
||||
changedByEmployeeId String?
|
||||
previousDailyRate Int?
|
||||
nextDailyRate Int?
|
||||
@@ -1156,7 +1481,7 @@ model VehiclePriceHistory {
|
||||
nextWeeklyRate Int?
|
||||
note String?
|
||||
effectiveFrom DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([vehicleId, createdAt])
|
||||
@@index([configurationId, createdAt])
|
||||
@@ -1282,9 +1607,9 @@ model Customer {
|
||||
licenseApprovedAt DateTime?
|
||||
licenseApprovalNote String?
|
||||
|
||||
reservations Reservation[]
|
||||
complaints Complaint[]
|
||||
reviewOptOut Boolean @default(false)
|
||||
reservations Reservation[]
|
||||
complaints Complaint[]
|
||||
reviewOptOut Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -1309,7 +1634,7 @@ model Reservation {
|
||||
promoCodeUsed String?
|
||||
vehicleCategory VehicleCategory?
|
||||
source BookingSource @default(DASHBOARD)
|
||||
carplaceRef String?
|
||||
carplaceRef String?
|
||||
status ReservationStatus @default(DRAFT)
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
@@ -1342,11 +1667,11 @@ model Reservation {
|
||||
reviewPaused Boolean @default(false)
|
||||
|
||||
insurances ReservationInsurance[]
|
||||
insuranceTotal Int @default(0)
|
||||
insuranceTotal Int @default(0)
|
||||
additionalDrivers AdditionalDriver[]
|
||||
additionalDriverTotal Int @default(0)
|
||||
additionalDriverTotal Int @default(0)
|
||||
pricingRulesApplied Json?
|
||||
pricingRulesTotal Int @default(0)
|
||||
pricingRulesTotal Int @default(0)
|
||||
damageReports DamageReport[]
|
||||
rentalPayments RentalPayment[]
|
||||
inspections DamageInspection[]
|
||||
@@ -1402,7 +1727,6 @@ model CarplaceFunnelEvent {
|
||||
@@map("carplace_funnel_events")
|
||||
}
|
||||
|
||||
|
||||
model WebhookEvent {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
@@ -1420,50 +1744,50 @@ model WebhookEvent {
|
||||
}
|
||||
|
||||
model RentalPayment {
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
reservationId String
|
||||
reservation Reservation @relation(fields: [reservationId], references: [id])
|
||||
amount Int
|
||||
currency String @default("MAD")
|
||||
status PaymentStatus @default(PENDING)
|
||||
type PaymentType @default(CHARGE)
|
||||
paymentProvider PaymentProvider
|
||||
amanpayTransactionId String? @unique
|
||||
paypalCaptureId String? @unique
|
||||
stripeCheckoutSessionId String? @unique
|
||||
stripePaymentIntentId String? @unique
|
||||
paymentMethod String?
|
||||
reference String?
|
||||
note String?
|
||||
receivedAt DateTime?
|
||||
recordedByEmployeeId String?
|
||||
recordedByEmployee Employee? @relation("RecordedRentalPayments", fields: [recordedByEmployeeId], references: [id])
|
||||
idempotencyKey String?
|
||||
paidAt DateTime?
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
reservationId String
|
||||
reservation Reservation @relation(fields: [reservationId], references: [id])
|
||||
amount Int
|
||||
currency String @default("MAD")
|
||||
status PaymentStatus @default(PENDING)
|
||||
type PaymentType @default(CHARGE)
|
||||
paymentProvider PaymentProvider
|
||||
amanpayTransactionId String? @unique
|
||||
paypalCaptureId String? @unique
|
||||
stripeCheckoutSessionId String? @unique
|
||||
stripePaymentIntentId String? @unique
|
||||
paymentMethod String?
|
||||
reference String?
|
||||
note String?
|
||||
receivedAt DateTime?
|
||||
recordedByEmployeeId String?
|
||||
recordedByEmployee Employee? @relation("RecordedRentalPayments", fields: [recordedByEmployeeId], references: [id])
|
||||
idempotencyKey String?
|
||||
paidAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([companyId, idempotencyKey])
|
||||
@@index([companyId])
|
||||
@@index([reservationId])
|
||||
@@unique([companyId, idempotencyKey])
|
||||
@@map("rental_payments")
|
||||
}
|
||||
|
||||
model Review {
|
||||
id String @id @default(cuid())
|
||||
reservationId String @unique
|
||||
reservation Reservation @relation(fields: [reservationId], references: [id])
|
||||
id String @id @default(cuid())
|
||||
reservationId String @unique
|
||||
reservation Reservation @relation(fields: [reservationId], references: [id])
|
||||
renterId String?
|
||||
renter Renter? @relation(fields: [renterId], references: [id])
|
||||
renter Renter? @relation(fields: [renterId], references: [id])
|
||||
companyId String
|
||||
overallRating Int
|
||||
vehicleRating Int?
|
||||
serviceRating Int?
|
||||
comment String?
|
||||
isPublished Boolean @default(true)
|
||||
isPublished Boolean @default(true)
|
||||
companyReply String?
|
||||
companyRepliedAt DateTime?
|
||||
category FeedbackCategory?
|
||||
@@ -1476,29 +1800,29 @@ model Review {
|
||||
}
|
||||
|
||||
model Complaint {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
reservationId String?
|
||||
reservation Reservation? @relation(fields: [reservationId], references: [id])
|
||||
reservation Reservation? @relation(fields: [reservationId], references: [id])
|
||||
reviewId String?
|
||||
review Review? @relation(fields: [reviewId], references: [id])
|
||||
review Review? @relation(fields: [reviewId], references: [id])
|
||||
customerId String?
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
|
||||
severity ComplaintSeverity @default(LEVEL_1)
|
||||
status ComplaintStatus @default(OPEN)
|
||||
category FeedbackCategory
|
||||
subject String
|
||||
description String?
|
||||
resolution String?
|
||||
notes String?
|
||||
assignedTo String?
|
||||
resolvedAt DateTime?
|
||||
resolvedBy String?
|
||||
severity ComplaintSeverity @default(LEVEL_1)
|
||||
status ComplaintStatus @default(OPEN)
|
||||
category FeedbackCategory
|
||||
subject String
|
||||
description String?
|
||||
resolution String?
|
||||
notes String?
|
||||
assignedTo String?
|
||||
resolvedAt DateTime?
|
||||
resolvedBy String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([companyId])
|
||||
@@index([reservationId])
|
||||
@@ -1538,12 +1862,12 @@ model Notification {
|
||||
}
|
||||
|
||||
model NotificationEvent {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
type NotificationType
|
||||
templateKey String?
|
||||
locale String @default("en")
|
||||
locale String @default("en")
|
||||
title String
|
||||
body String
|
||||
data Json?
|
||||
@@ -1552,7 +1876,7 @@ model NotificationEvent {
|
||||
idempotencyKey String
|
||||
recipients NotificationRecipient[]
|
||||
outboxEntries NotificationOutbox[]
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([companyId, idempotencyKey])
|
||||
@@index([companyId, type, createdAt])
|
||||
@@ -1568,6 +1892,10 @@ model NotificationRecipient {
|
||||
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
renterId String?
|
||||
renter Renter? @relation(fields: [renterId], references: [id], onDelete: Cascade)
|
||||
billingContactId String?
|
||||
billingContact BillingContact? @relation(fields: [billingContactId], references: [id], onDelete: Cascade)
|
||||
adminUserId String?
|
||||
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
|
||||
readAt DateTime?
|
||||
archivedAt DateTime?
|
||||
deliveries NotificationDelivery[]
|
||||
@@ -1575,6 +1903,8 @@ model NotificationRecipient {
|
||||
|
||||
@@index([employeeId, readAt, createdAt])
|
||||
@@index([renterId, readAt, createdAt])
|
||||
@@index([billingContactId, readAt, createdAt])
|
||||
@@index([adminUserId, readAt, createdAt])
|
||||
@@index([notificationEventId])
|
||||
@@map("notification_recipients")
|
||||
}
|
||||
@@ -1953,9 +2283,19 @@ model AdminUser {
|
||||
passwordResetToken String? @unique
|
||||
passwordResetExpiresAt DateTime?
|
||||
|
||||
auditLogs AuditLog[]
|
||||
permissions AdminPermission[]
|
||||
recoveryCodes AdminRecoveryCode[]
|
||||
auditLogs AuditLog[]
|
||||
permissions AdminPermission[]
|
||||
recoveryCodes AdminRecoveryCode[]
|
||||
preferredLocale String @default("en")
|
||||
ownedBillingAccounts BillingAccount[] @relation("BillingAccountCollectionsOwner")
|
||||
confirmedManualPayments BillingPaymentAttempt[] @relation("ManualPaymentConfirmingAdmin")
|
||||
reviewedManualSubmissions ManualPaymentSubmission[] @relation("ManualPaymentReviewingAdmin")
|
||||
ownedCollectionsCases CollectionsCase[] @relation("CollectionsCaseOwner")
|
||||
assignedCollectionsTasks CollectionsCallTask[] @relation("CollectionsTaskAssignee")
|
||||
completedCollectionsTasks CollectionsCallTask[] @relation("CollectionsTaskCompleter")
|
||||
createdCollectionsOverrides CollectionsOverride[] @relation("CollectionsOverrideCreator")
|
||||
revokedCollectionsOverrides CollectionsOverride[] @relation("CollectionsOverrideRevoker")
|
||||
notificationRecipients NotificationRecipient[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -1974,7 +2314,6 @@ model AdminPermission {
|
||||
@@map("admin_permissions")
|
||||
}
|
||||
|
||||
|
||||
model AdminRecoveryCode {
|
||||
id String @id @default(cuid())
|
||||
adminUserId String
|
||||
@@ -2022,6 +2361,15 @@ model PricingConfig {
|
||||
@@map("pricing_configs")
|
||||
}
|
||||
|
||||
model PlatformBillingSettings {
|
||||
id String @id @default("default")
|
||||
taxRate Float @default(20)
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy String?
|
||||
|
||||
@@map("platform_billing_settings")
|
||||
}
|
||||
|
||||
model PlanFeature {
|
||||
id String @id @default(cuid())
|
||||
plan Plan
|
||||
|
||||
Vendored
+13
-1
@@ -25,10 +25,21 @@ export type NotificationType =
|
||||
| 'REFUND_PROCESSED'
|
||||
| 'NEW_OFFER_FROM_SAVED_COMPANY'
|
||||
| 'REVIEW_REQUEST'
|
||||
| 'SUBSCRIPTION_PAYMENT_DUE_14D'
|
||||
| 'SUBSCRIPTION_PAYMENT_DUE_7D'
|
||||
| 'SUBSCRIPTION_PAYMENT_DUE_48H'
|
||||
| 'SUBSCRIPTION_PAYMENT_DUE_24H'
|
||||
| 'SUBSCRIPTION_GRACE_DAILY'
|
||||
| 'SUBSCRIPTION_GRACE_FINAL'
|
||||
| 'COLLECTIONS_CALL_REQUIRED'
|
||||
| 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED'
|
||||
| 'SUBSCRIPTION_PAYMENT_CONFIRMED'
|
||||
| 'MANUAL_PAYMENT_EVIDENCE_REJECTED'
|
||||
| 'COLLECTIONS_OVERRIDE_CHANGED'
|
||||
|
||||
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
|
||||
export type NotificationDeliveryStatus = 'PENDING' | 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED' | 'SKIPPED' | 'DEAD_LETTER'
|
||||
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER'
|
||||
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER' | 'BILLING_CONTACT' | 'ADMIN'
|
||||
|
||||
export interface Company {
|
||||
id: string
|
||||
@@ -61,6 +72,7 @@ export interface AdminUser {
|
||||
isActive: boolean
|
||||
totpEnabled?: boolean
|
||||
totpSecret?: string | null
|
||||
preferredLocale?: string
|
||||
}
|
||||
|
||||
export interface InsurancePolicy {
|
||||
|
||||
@@ -25,10 +25,21 @@ export type NotificationType =
|
||||
| 'REFUND_PROCESSED'
|
||||
| 'NEW_OFFER_FROM_SAVED_COMPANY'
|
||||
| 'REVIEW_REQUEST'
|
||||
| 'SUBSCRIPTION_PAYMENT_DUE_14D'
|
||||
| 'SUBSCRIPTION_PAYMENT_DUE_7D'
|
||||
| 'SUBSCRIPTION_PAYMENT_DUE_48H'
|
||||
| 'SUBSCRIPTION_PAYMENT_DUE_24H'
|
||||
| 'SUBSCRIPTION_GRACE_DAILY'
|
||||
| 'SUBSCRIPTION_GRACE_FINAL'
|
||||
| 'COLLECTIONS_CALL_REQUIRED'
|
||||
| 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED'
|
||||
| 'SUBSCRIPTION_PAYMENT_CONFIRMED'
|
||||
| 'MANUAL_PAYMENT_EVIDENCE_REJECTED'
|
||||
| 'COLLECTIONS_OVERRIDE_CHANGED'
|
||||
|
||||
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
|
||||
export type NotificationDeliveryStatus = 'PENDING' | 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED' | 'SKIPPED' | 'DEAD_LETTER'
|
||||
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER'
|
||||
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER' | 'BILLING_CONTACT' | 'ADMIN'
|
||||
|
||||
export interface Company {
|
||||
id: string
|
||||
@@ -61,6 +72,7 @@ export interface AdminUser {
|
||||
isActive: boolean
|
||||
totpEnabled?: boolean
|
||||
totpSecret?: string | null
|
||||
preferredLocale?: string
|
||||
}
|
||||
|
||||
export interface InsurancePolicy {
|
||||
|
||||
@@ -75,11 +75,12 @@ PGMANAGE_DEFAULT_PASSWORD=change-me
|
||||
# Email
|
||||
EMAIL_FROM=rentaldrivego@gmail.com
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
EMAIL_PROVIDER=gmail
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=placeholder
|
||||
MAIL_PASSWORD=your-16-character-gmail-app-password
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
|
||||
+6
-13
@@ -1,21 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2022"],
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -2,6 +2,18 @@
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"globalPassThroughEnv": [
|
||||
"ADMIN_SEED_EMAIL",
|
||||
"ADMIN_SEED_FIRST_NAME",
|
||||
"ADMIN_SEED_LAST_NAME",
|
||||
"ADMIN_SEED_PASSWORD",
|
||||
"DATABASE_URL",
|
||||
"DATABASE_URL_FROM_POSTGRES",
|
||||
"POSTGRES_DB",
|
||||
"POSTGRES_HOST",
|
||||
"POSTGRES_HOST_LOCAL",
|
||||
"POSTGRES_PASSWORD",
|
||||
"POSTGRES_PORT",
|
||||
"POSTGRES_USER",
|
||||
"SITE_ORIGIN",
|
||||
"NEXT_PUBLIC_HOMEPAGE_URL",
|
||||
"NEXT_PUBLIC_CARPLACE_URL"
|
||||
|
||||
Reference in New Issue
Block a user