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

This commit is contained in:
root
2026-08-10 22:35:55 -04:00
parent 10ca76fc1e
commit 5f06256271
73 changed files with 8803 additions and 570 deletions
@@ -3,12 +3,13 @@
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, apiFetch, resolveApiBase } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { buildHomepageSignInPath } from '@/lib/dashboardPaths'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type CollectionMethod = 'STRIPE' | 'BANK_TRANSFER' | 'CHECK'
interface Subscription {
id: string
@@ -23,12 +24,66 @@ interface Subscription {
interface Invoice {
id: string
invoiceNumber: string | null
amount: number
amountDue: number
currency: string
status: string
paymentProvider: string
collectionMethod: CollectionMethod
dueAt: string | null
paidAt: string | null
createdAt: string
manualPaymentSubmission?: ManualPaymentSubmission | null
}
interface PaymentOption {
method: CollectionMethod
enabled: boolean
instructions?: Record<string, string>
}
interface ManualPaymentDocument {
id: string
kind: string
originalFilename: string
byteSize: number
scanStatus: string
uploadedAt: string
}
interface ManualPaymentSubmission {
id: string
method: CollectionMethod
submittedReference: string
status: string
submittedAt: string | null
rejectionReason?: string | null
documents: ManualPaymentDocument[]
}
interface ManualCheckoutResult {
invoice: Invoice
instructions: Record<string, string>
}
type CommunicationLocale = 'ar' | 'en' | 'fr'
interface CommunicationSettings {
timezone: string
reminderLocalTime: string
enabledCommunicationLocales: CommunicationLocale[]
defaultCommunicationLocale: CommunicationLocale
contacts: Array<{
id?: string
employeeId?: string | null
email: string
locale?: CommunicationLocale | null
effectiveLocale?: CommunicationLocale
isPrimary: boolean
receivePaymentNotices: boolean
isActive: boolean
verified?: boolean
}>
}
interface ProviderAvailability {
@@ -61,6 +116,9 @@ const STATUS_BADGE: Record<string, string> = {
const INVOICE_STATUS: Record<string, string> = {
PAID: 'bg-green-100 text-green-700',
PENDING: 'bg-orange-100 text-orange-700',
OPEN: 'bg-orange-100 text-orange-700',
PAYMENT_PENDING: 'bg-orange-100 text-orange-700',
PAST_DUE: 'bg-red-100 text-red-700',
FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600',
}
@@ -72,6 +130,30 @@ const PLAN_LABELS: Record<Plan, string> = {
PRO: 'Pro',
ENTERPRISE: 'Enterprise',
}
const PAYMENT_EVIDENCE_MAX_FILES = 3
const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
const PAYMENT_EVIDENCE_ACCEPT = 'application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png'
const PAYMENT_EVIDENCE_EXTENSIONS = new Set(['pdf', 'jpg', 'jpeg', 'png'])
const PAYMENT_EVIDENCE_MIME_TYPES = new Set(['application/pdf', 'application/x-pdf', 'application/octet-stream', 'image/jpeg', 'image/pjpeg', 'image/png', 'image/x-png'])
function validatePaymentEvidenceFiles(files: File[]) {
if (files.length > PAYMENT_EVIDENCE_MAX_FILES) return 'Upload at most three evidence files.'
for (const file of files) {
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!PAYMENT_EVIDENCE_EXTENSIONS.has(extension)) {
return 'Evidence files must be PDF, JPEG, or PNG.'
}
if (file.type && !PAYMENT_EVIDENCE_MIME_TYPES.has(file.type.toLowerCase())) {
return 'Evidence files must be PDF, JPEG, or PNG.'
}
if (file.size <= 0 || file.size > PAYMENT_EVIDENCE_MAX_FILE_SIZE) {
return 'Each evidence file must be between 1 byte and 10 MB.'
}
}
return null
}
export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n()
@@ -86,12 +168,23 @@ export default function SubscriptionPage() {
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const currency = 'MAD'
const provider = 'STRIPE'
const [selectedMethod, setSelectedMethod] = useState<CollectionMethod>('STRIPE')
const [paymentOptions, setPaymentOptions] = useState<PaymentOption[]>([])
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ stripe: false })
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [manualCheckout, setManualCheckout] = useState<ManualCheckoutResult | null>(null)
const [manualPaymentRequestNumber, setManualPaymentRequestNumber] = useState<string | null>(null)
const [paymentReference, setPaymentReference] = useState('')
const [evidenceFiles, setEvidenceFiles] = useState<File[]>([])
const [paymentSubmission, setPaymentSubmission] = useState<ManualPaymentSubmission | null>(null)
const [submittingEvidence, setSubmittingEvidence] = useState(false)
const [checkoutIdempotencyKey, setCheckoutIdempotencyKey] = useState<string | null>(null)
const [submissionIdempotencyKey, setSubmissionIdempotencyKey] = useState<string | null>(null)
const [communicationSettings, setCommunicationSettings] = useState<CommunicationSettings | null>(null)
const [savingCommunicationSettings, setSavingCommunicationSettings] = useState(false)
const copy = {
en: {
title: 'Subscription',
@@ -107,6 +200,39 @@ export default function SubscriptionPage() {
changePlan: 'Change plan',
subscribe: 'Subscribe',
selectPlan: 'Select a plan to continue to Stripe checkout.',
selectPayment: 'Choose Stripe, bank transfer, or check. The server calculates the final amount.',
bankTransfer: 'Bank transfer',
check: 'Check',
creatingInvoice: 'Creating invoice…',
createInvoice: 'Create payment invoice',
submitPaymentEvidence: 'Submit payment evidence',
awaitingVerification: 'Awaiting finance verification',
invoiceNumber: 'Invoice',
paymentRequestNumber: 'Payment request number',
paymentRequestHelp: 'Use this generated number when contacting support about this payment.',
dueDate: 'Due date',
paymentInstructions: 'Payment instructions',
reference: 'Transaction reference or check number',
bankTransferReference: 'Bank transfer reference number',
checkNumber: 'Check number',
evidence: 'Payment evidence (PDF, JPEG, or PNG; up to 3 files)',
bankTransferEvidence: 'Bank transfer receipt (PDF, JPEG, or PNG; up to 3 files)',
checkEvidence: 'Check copy (PDF, JPEG, or PNG; up to 3 files)',
submitReview: 'Submit for review',
submittingReview: 'Submitting…',
evidenceWarning: 'Uploading a receipt does not prove settlement or activate the subscription. Finance must independently confirm cleared funds.',
manualDetailsTitle: 'Payment details',
manualDetailsHelp: 'Enter the payment number and attach the supporting file before submitting it for finance review.',
evidenceSubmitted: 'Evidence submitted and locked for finance review.',
communicationTitle: 'Billing communication settings',
communicationHelp: 'Choose the languages your company permits for future payment notices. Each contact receives one notice in their effective language.',
enabledLanguages: 'Enabled languages',
defaultLanguage: 'Default language',
timezone: 'Billing timezone',
contactLanguage: 'Contact language',
inheritDefault: 'Inherit company default',
saveSettings: 'Save communication settings',
settingsSaved: 'Communication settings saved.',
monthly: 'Monthly',
annual: 'Annual (save 20%)',
active: 'Active',
@@ -119,6 +245,7 @@ export default function SubscriptionPage() {
redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history',
invoice: 'Invoice',
date: 'Date',
provider: 'Provider',
status: 'Status',
@@ -156,6 +283,39 @@ export default function SubscriptionPage() {
changePlan: 'Changer de plan',
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan pour continuer vers Stripe Checkout.',
selectPayment: 'Choisissez Stripe, virement bancaire ou chèque. Le serveur calcule le montant final.',
bankTransfer: 'Virement bancaire',
check: 'Chèque',
creatingInvoice: 'Création de la facture…',
createInvoice: 'Créer la facture de paiement',
submitPaymentEvidence: 'Envoyer le justificatif',
awaitingVerification: 'En attente de vérification financière',
invoiceNumber: 'Facture',
paymentRequestNumber: 'Numéro de demande de paiement',
paymentRequestHelp: 'Utilisez ce numéro généré pour contacter le support au sujet de ce paiement.',
dueDate: 'Échéance',
paymentInstructions: 'Instructions de paiement',
reference: 'Référence du virement ou numéro de chèque',
bankTransferReference: 'Référence du virement bancaire',
checkNumber: 'Numéro du chèque',
evidence: 'Justificatif (PDF, JPEG ou PNG ; 3 fichiers maximum)',
bankTransferEvidence: 'Reçu du virement (PDF, JPEG ou PNG ; 3 fichiers maximum)',
checkEvidence: 'Copie du chèque (PDF, JPEG ou PNG ; 3 fichiers maximum)',
submitReview: 'Soumettre pour vérification',
submittingReview: 'Envoi…',
evidenceWarning: 'Le dépôt dun justificatif ne prouve pas le règlement et nactive pas labonnement. La finance doit confirmer les fonds encaissés.',
manualDetailsTitle: 'Détails du paiement',
manualDetailsHelp: 'Saisissez le numéro de paiement et joignez le justificatif avant de lenvoyer à la finance.',
evidenceSubmitted: 'Justificatifs soumis et verrouillés pour vérification.',
communicationTitle: 'Paramètres de communication de facturation',
communicationHelp: 'Choisissez les langues autorisées pour les prochains avis de paiement. Chaque contact reçoit un seul avis dans sa langue effective.',
enabledLanguages: 'Langues activées',
defaultLanguage: 'Langue par défaut',
timezone: 'Fuseau horaire de facturation',
contactLanguage: 'Langue du contact',
inheritDefault: 'Hériter de la langue par défaut',
saveSettings: 'Enregistrer les paramètres',
settingsSaved: 'Paramètres de communication enregistrés.',
monthly: 'Mensuel',
annual: 'Annuel (économie 20%)',
active: 'Actif',
@@ -168,6 +328,7 @@ export default function SubscriptionPage() {
redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures',
invoice: 'Facture',
date: 'Date',
provider: 'Prestataire',
status: 'Statut',
@@ -205,6 +366,39 @@ export default function SubscriptionPage() {
changePlan: 'تغيير الخطة',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة للمتابعة إلى Stripe Checkout.',
selectPayment: 'اختر Stripe أو التحويل البنكي أو الشيك. يحسب الخادم المبلغ النهائي.',
bankTransfer: 'تحويل بنكي',
check: 'شيك',
creatingInvoice: 'جارٍ إنشاء الفاتورة…',
createInvoice: 'إنشاء فاتورة الدفع',
submitPaymentEvidence: 'إرسال إثبات الدفع',
awaitingVerification: 'في انتظار تحقق فريق المالية',
invoiceNumber: 'الفاتورة',
paymentRequestNumber: 'رقم طلب الدفع',
paymentRequestHelp: 'استخدم هذا الرقم المُنشأ عند التواصل مع الدعم بخصوص هذا الدفع.',
dueDate: 'تاريخ الاستحقاق',
paymentInstructions: 'تعليمات الدفع',
reference: 'مرجع التحويل أو رقم الشيك',
bankTransferReference: 'رقم مرجع التحويل البنكي',
checkNumber: 'رقم الشيك',
evidence: 'إثبات الدفع (PDF أو JPEG أو PNG، بحد أقصى 3 ملفات)',
bankTransferEvidence: 'إيصال التحويل البنكي (PDF أو JPEG أو PNG، بحد أقصى 3 ملفات)',
checkEvidence: 'نسخة الشيك (PDF أو JPEG أو PNG، بحد أقصى 3 ملفات)',
submitReview: 'إرسال للمراجعة',
submittingReview: 'جارٍ الإرسال…',
evidenceWarning: 'رفع الإيصال لا يثبت وصول الأموال ولا يفعّل الاشتراك. يجب أن يؤكد فريق المالية تحصيل المبلغ بشكل مستقل.',
manualDetailsTitle: 'تفاصيل الدفع',
manualDetailsHelp: 'أدخل رقم الدفع وأرفق المستند الداعم قبل إرساله إلى فريق المالية.',
evidenceSubmitted: 'تم إرسال المستندات وقفلها لمراجعة فريق المالية.',
communicationTitle: 'إعدادات اتصالات الفوترة',
communicationHelp: 'اختر اللغات التي تسمح بها الشركة لإشعارات الدفع المستقبلية. يتلقى كل مسؤول إشعاراً واحداً بلغته الفعلية.',
enabledLanguages: 'اللغات المفعّلة',
defaultLanguage: 'اللغة الافتراضية',
timezone: 'المنطقة الزمنية للفوترة',
contactLanguage: 'لغة جهة الاتصال',
inheritDefault: 'استخدام لغة الشركة الافتراضية',
saveSettings: 'حفظ إعدادات الاتصال',
settingsSaved: 'تم حفظ إعدادات الاتصال.',
monthly: 'شهري',
annual: 'سنوي (توفير 20%)',
active: 'نشط',
@@ -217,6 +411,7 @@ export default function SubscriptionPage() {
redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير',
invoice: 'الفاتورة',
date: 'التاريخ',
provider: 'المزوّد',
status: 'الحالة',
@@ -288,10 +483,16 @@ export default function SubscriptionPage() {
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
apiFetch<{ methods: PaymentOption[] }>('/subscriptions/payment-options'),
apiFetch<CommunicationSettings>('/subscriptions/communication-settings'),
fetchPlanData(),
])
.then(([sub, inv, availability]) => {
.then(([sub, inv, availability, options, settings]) => {
setProviderAvailability(availability)
setPaymentOptions(options.methods ?? [])
setCommunicationSettings(settings)
const firstEnabled = options.methods?.find((option) => option.enabled)
if (firstEnabled) setSelectedMethod(firstEnabled.method)
if (sub) {
setSubscription(sub)
setSelectedPlan(sub.plan)
@@ -299,6 +500,13 @@ export default function SubscriptionPage() {
// currency is always MAD
}
setInvoices(inv ?? [])
const latestManual = inv?.find((invoice) => invoice.collectionMethod !== 'STRIPE' && ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE'].includes(invoice.status))
if (latestManual) {
const option = options.methods?.find((item) => item.method === latestManual.collectionMethod)
setSelectedMethod(latestManual.collectionMethod)
setManualCheckout({ invoice: latestManual, instructions: option?.instructions ?? {} })
if (latestManual.manualPaymentSubmission) setPaymentSubmission(latestManual.manualPaymentSubmission)
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
@@ -359,6 +567,55 @@ export default function SubscriptionPage() {
setPaying(true)
setError(null)
try {
const selectedOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
if (!selectedOption) throw new Error(copy.providerUnavailable)
if (selectedMethod !== 'STRIPE') {
if (paymentReference.trim().length < 3) throw new Error(`${manualReferenceLabel} is required`)
if (evidenceFiles.length === 0) throw new Error(manualEvidenceLabel)
const fileError = validatePaymentEvidenceFiles(evidenceFiles)
if (fileError) throw new Error(fileError)
const idempotencyKey = checkoutIdempotencyKey ?? crypto.randomUUID()
setCheckoutIdempotencyKey(idempotencyKey)
const result = await apiFetch<ManualCheckoutResult>('/subscriptions/manual-checkout', {
method: 'POST',
body: JSON.stringify({ plan: selectedPlan, billingPeriod, currency, method: selectedMethod, idempotencyKey }),
})
setManualPaymentRequestNumber(result.invoice.invoiceNumber ?? result.invoice.id)
const key = submissionIdempotencyKey ?? crypto.randomUUID()
setSubmissionIdempotencyKey(key)
const submission = await apiFetch<ManualPaymentSubmission>(
`/subscriptions/invoices/${result.invoice.id}/manual-payment-submissions`,
{
method: 'POST',
body: JSON.stringify({ method: selectedMethod, submittedReference: paymentReference, idempotencyKey: key }),
},
)
let current = submission
for (const file of evidenceFiles) {
const form = new FormData()
form.append('kind', selectedMethod === 'CHECK' ? 'CHECK_COPY' : 'BANK_TRANSFER_RECEIPT')
form.append('file', file)
const document = await apiFetch<ManualPaymentDocument>(
`/subscriptions/manual-payment-submissions/${submission.id}/documents`,
{ method: 'POST', body: form },
)
current = { ...current, documents: [...current.documents.filter((item) => item.id !== document.id), document] }
setPaymentSubmission(current)
if (document.scanStatus !== 'CLEAN') throw new Error(`Evidence scan status: ${document.scanStatus}`)
}
const submitted = await apiFetch<ManualPaymentSubmission>(
`/subscriptions/manual-payment-submissions/${submission.id}/submit`,
{ method: 'POST' },
)
setPaymentSubmission(submitted)
setManualCheckout(null)
setEvidenceFiles([])
setCheckoutIdempotencyKey(null)
setSubmissionIdempotencyKey(null)
setPaying(false)
return
}
if (!providerAvailability.stripe) throw new Error(copy.providerUnavailable)
const currentUrl = new URL(window.location.href)
currentUrl.search = ''
@@ -369,7 +626,7 @@ export default function SubscriptionPage() {
plan: selectedPlan,
billingPeriod,
currency,
provider,
provider: 'STRIPE',
successUrl: `${currentUrl.toString()}?payment=success`,
failureUrl: `${currentUrl.toString()}?payment=failed`,
}),
@@ -381,6 +638,75 @@ export default function SubscriptionPage() {
}
}
async function handleEvidenceSubmit() {
if (!manualCheckout) return
setSubmittingEvidence(true)
setError(null)
try {
const key = submissionIdempotencyKey ?? crypto.randomUUID()
setSubmissionIdempotencyKey(key)
const fileError = validatePaymentEvidenceFiles(evidenceFiles)
if (fileError) throw new Error(fileError)
const reusableDraft = paymentSubmission?.status === 'DRAFT' ? paymentSubmission : null
const submission = reusableDraft ?? await apiFetch<ManualPaymentSubmission>(
`/subscriptions/invoices/${manualCheckout.invoice.id}/manual-payment-submissions`,
{
method: 'POST',
body: JSON.stringify({ method: selectedMethod, submittedReference: paymentReference, idempotencyKey: key }),
},
)
let current = submission
const pendingFiles = [...evidenceFiles]
for (const file of pendingFiles) {
const form = new FormData()
form.append('kind', selectedMethod === 'CHECK' ? 'CHECK_COPY' : 'BANK_TRANSFER_RECEIPT')
form.append('file', file)
const document = await apiFetch<ManualPaymentDocument>(
`/subscriptions/manual-payment-submissions/${submission.id}/documents`,
{ method: 'POST', body: form },
)
current = { ...current, documents: [...current.documents.filter((item) => item.id !== document.id), document] }
setPaymentSubmission(current)
setEvidenceFiles((files) => files.filter((item) => item !== file))
if (document.scanStatus !== 'CLEAN') throw new Error(`Evidence scan status: ${document.scanStatus}`)
}
const submitted = await apiFetch<ManualPaymentSubmission>(
`/subscriptions/manual-payment-submissions/${submission.id}/submit`,
{ method: 'POST' },
)
setPaymentSubmission(submitted)
setEvidenceFiles([])
setSubmissionIdempotencyKey(null)
} catch (err: any) {
setError(err.message)
} finally {
setSubmittingEvidence(false)
}
}
async function saveCommunicationSettings() {
if (!communicationSettings) return
setSavingCommunicationSettings(true)
setError(null)
try {
const saved = await apiFetch<CommunicationSettings>('/subscriptions/communication-settings', {
method: 'PUT',
body: JSON.stringify({
timezone: communicationSettings.timezone,
reminderLocalTime: communicationSettings.reminderLocalTime,
enabledCommunicationLocales: communicationSettings.enabledCommunicationLocales,
defaultCommunicationLocale: communicationSettings.defaultCommunicationLocale,
contacts: communicationSettings.contacts.map(({ effectiveLocale: _effectiveLocale, verified: _verified, ...contact }) => contact),
}),
})
setCommunicationSettings(saved)
} catch (err: any) {
setError(err.message)
} finally {
setSavingCommunicationSettings(false)
}
}
async function handleCancel() {
setCancelling(true)
setError(null)
@@ -394,6 +720,20 @@ export default function SubscriptionPage() {
}
}
function handleEvidenceFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.target.files
const selectedFiles = Array.from(files ?? [])
const fileError = validatePaymentEvidenceFiles(selectedFiles)
if (fileError) {
setEvidenceFiles([])
event.target.value = ''
setError(fileError)
return
}
setError(null)
setEvidenceFiles(selectedFiles)
}
async function handleResume() {
setCancelling(true)
setError(null)
@@ -411,6 +751,64 @@ export default function SubscriptionPage() {
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
const selectedPaymentOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
const isManualMethod = selectedMethod === 'BANK_TRANSFER' || selectedMethod === 'CHECK'
const manualReferenceLabel = selectedMethod === 'CHECK' ? copy.checkNumber : copy.bankTransferReference
const manualEvidenceLabel = selectedMethod === 'CHECK' ? copy.checkEvidence : copy.bankTransferEvidence
const manualEvidenceKindLabel = selectedMethod === 'CHECK' ? copy.check : copy.bankTransfer
const manualDetailsForm = isManualMethod ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
<div>
<p className="font-semibold text-slate-900">{copy.manualDetailsTitle}</p>
<p className="mt-1 text-sm text-slate-500">{copy.manualDetailsHelp}</p>
</div>
{selectedPaymentOption?.instructions && !manualCheckout ? (
<div className="mt-4 rounded-xl bg-white p-4">
<p className="text-sm font-semibold text-slate-900">{copy.paymentInstructions}</p>
<dl className="mt-2 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
{Object.entries(selectedPaymentOption.instructions).map(([key, value]) => (
<div key={key}><dt className="text-xs uppercase text-slate-500">{key}</dt><dd className="font-medium">{value}</dd></div>
))}
</dl>
</div>
) : null}
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<label className="block text-sm font-medium text-slate-800">
{manualReferenceLabel}
<input
value={paymentReference}
onChange={(event) => setPaymentReference(event.target.value)}
maxLength={120}
inputMode="text"
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2"
/>
</label>
<label className="block text-sm font-medium text-slate-800">
{manualEvidenceLabel}
<input
type="file"
accept={PAYMENT_EVIDENCE_ACCEPT}
multiple
onChange={handleEvidenceFileChange}
className="mt-1 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"
/>
</label>
</div>
{evidenceFiles.length > 0 ? (
<div className="mt-3 space-y-1">
{evidenceFiles.map((file) => (
<p key={`${file.name}-${file.size}-${file.lastModified}`} className="text-xs text-slate-600">
{manualEvidenceKindLabel}: {file.name}
</p>
))}
</div>
) : null}
{paymentSubmission?.documents.map((document) => (
<p key={document.id} className="mt-2 text-xs text-slate-600">{document.originalFilename} · {document.scanStatus}</p>
))}
<p className="mt-3 text-xs text-amber-900">{copy.evidenceWarning}</p>
</div>
) : null
return (
<div className="space-y-8">
@@ -470,7 +868,7 @@ export default function SubscriptionPage() {
{/* Plan selector + checkout */}
<div className="card p-6 space-y-6">
{!providerAvailability.stripe ? (
{paymentOptions.length > 0 && !paymentOptions.some((option) => option.enabled) ? (
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
{copy.noProviderConfigured}
{providerAvailability.stripeProblems && providerAvailability.stripeProblems.length > 0 ? (
@@ -486,7 +884,7 @@ export default function SubscriptionPage() {
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3>
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
<p className="mt-1 text-sm text-slate-500">{copy.selectPayment}</p>
</div>
{/* Billing period toggle */}
@@ -549,15 +947,33 @@ export default function SubscriptionPage() {
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3">
{providerAvailability.stripe ? (
<div className="flex items-center gap-2 rounded-xl border-2 border-blue-500 bg-blue-50 px-4 py-2.5 text-sm font-medium text-blue-700">
Stripe
</div>
) : null}
<div className="flex flex-wrap gap-3">
{paymentOptions.filter((option) => option.enabled).map((option) => (
<button
type="button"
key={option.method}
onClick={() => {
setSelectedMethod(option.method)
setManualCheckout(null)
setManualPaymentRequestNumber(null)
setPaymentSubmission(null)
setCheckoutIdempotencyKey(null)
setSubmissionIdempotencyKey(null)
setPaymentReference('')
setEvidenceFiles([])
}}
className={`flex items-center gap-2 rounded-xl border-2 px-4 py-2.5 text-sm font-medium ${
selectedMethod === option.method ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600'
}`}
>
{option.method === 'STRIPE' ? 'Stripe' : option.method === 'BANK_TRANSFER' ? copy.bankTransfer : copy.check}
</button>
))}
</div>
</div>
{isManualMethod && !manualCheckout ? manualDetailsForm : null}
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
@@ -569,14 +985,154 @@ export default function SubscriptionPage() {
</div>
<button
onClick={handleCheckout}
disabled={paying || loading || !providerAvailability.stripe}
disabled={
paying
|| loading
|| !paymentOptions.some((option) => option.method === selectedMethod && option.enabled)
|| (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0))
}
className="btn-primary px-8 py-3"
>
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
{paying
? (selectedMethod === 'STRIPE' ? copy.redirecting : copy.submittingReview)
: selectedMethod === 'STRIPE'
? (subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow)
: copy.submitPaymentEvidence}
</button>
</div>
{paymentSubmission && paymentSubmission.status !== 'DRAFT' && !manualCheckout ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-semibold">{copy.awaitingVerification}</p>
{manualPaymentRequestNumber ? (
<p className="mt-1">
{copy.paymentRequestNumber}: <span className="font-semibold">{manualPaymentRequestNumber}</span>
</p>
) : null}
{manualPaymentRequestNumber ? <p className="mt-1 text-xs">{copy.paymentRequestHelp}</p> : null}
<p className="mt-1">{paymentSubmission.status} · {paymentSubmission.documents.length} file(s)</p>
</div>
) : null}
{manualCheckout ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-5" aria-live="polite">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-semibold text-amber-900">{copy.awaitingVerification}</p>
<p className="mt-1 text-sm text-amber-800">
{copy.invoiceNumber}: {manualCheckout.invoice.invoiceNumber ?? manualCheckout.invoice.id} · {formatCurrency(manualCheckout.invoice.amountDue || manualCheckout.invoice.amount, 'MAD')}
</p>
<p className="text-sm text-amber-800">
{copy.dueDate}: {manualCheckout.invoice.dueAt ? new Date(manualCheckout.invoice.dueAt).toLocaleDateString() : '—'}
</p>
</div>
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">{manualCheckout.invoice.status}</span>
</div>
<div className="mt-4 rounded-xl bg-white/80 p-4">
<p className="text-sm font-semibold text-slate-900">{copy.paymentInstructions}</p>
<dl className="mt-2 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
{Object.entries(manualCheckout.instructions).map(([key, value]) => (
<div key={key}><dt className="text-xs uppercase text-slate-500">{key}</dt><dd className="font-medium">{value}</dd></div>
))}
</dl>
</div>
{paymentSubmission && paymentSubmission.status !== 'DRAFT' ? (
<div className="mt-4 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-800">
<p className="font-semibold">{copy.evidenceSubmitted}</p>
<p className="mt-1">{paymentSubmission.status} · {paymentSubmission.documents.length} file(s)</p>
{paymentSubmission.rejectionReason ? <p className="mt-2 text-red-700">{paymentSubmission.rejectionReason}</p> : null}
</div>
) : (
<div className="mt-4 space-y-3">
{manualDetailsForm}
<button
type="button"
onClick={handleEvidenceSubmit}
disabled={submittingEvidence || paymentReference.trim().length < 3 || (evidenceFiles.length === 0 && !paymentSubmission?.documents.length)}
className="btn-primary"
>
{submittingEvidence ? copy.submittingReview : copy.submitReview}
</button>
</div>
)}
</div>
) : null}
</div>
{communicationSettings ? (
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.communicationTitle}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.communicationHelp}</p>
<div className="mt-5 grid gap-5 lg:grid-cols-3">
<div>
<p className="text-sm font-medium text-slate-700">{copy.enabledLanguages}</p>
<div className="mt-2 flex flex-wrap gap-3">
{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => {
const checked = communicationSettings.enabledCommunicationLocales.includes(locale)
return (
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase">
<input
type="checkbox"
checked={checked}
onChange={() => setCommunicationSettings((current) => {
if (!current) return current
const next = checked
? current.enabledCommunicationLocales.filter((item) => item !== locale)
: [...current.enabledCommunicationLocales, locale]
if (next.length === 0) return current
return {
...current,
enabledCommunicationLocales: next,
defaultCommunicationLocale: next.includes(current.defaultCommunicationLocale) ? current.defaultCommunicationLocale : next[0],
contacts: current.contacts.map((contact) => contact.locale && !next.includes(contact.locale) ? { ...contact, locale: null } : contact),
}
})}
/>
{locale}
</label>
)
})}
</div>
</div>
<label className="block text-sm font-medium text-slate-700">
{copy.defaultLanguage}
<select value={communicationSettings.defaultCommunicationLocale} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, defaultCommunicationLocale: event.target.value as CommunicationLocale } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2">
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
{copy.timezone}
<input value={communicationSettings.timezone} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, timezone: event.target.value } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2" />
</label>
</div>
<div className="mt-5 space-y-3">
{communicationSettings.contacts.map((contact, index) => (
<div key={contact.id ?? contact.email} className="grid gap-3 rounded-xl border border-slate-200 p-4 sm:grid-cols-[1fr,220px] sm:items-center">
<div>
<p className="text-sm font-medium text-slate-900">{contact.email}</p>
<p className="text-xs text-slate-500">{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}</p>
</div>
<label className="text-xs font-medium text-slate-600">
{copy.contactLanguage}
<select
value={contact.locale ?? ''}
onChange={(event) => setCommunicationSettings((current) => current ? { ...current, contacts: current.contacts.map((item, itemIndex) => itemIndex === index ? { ...item, locale: (event.target.value || null) as CommunicationLocale | null } : item) } : current)}
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm"
>
<option value="">{copy.inheritDefault}</option>
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
</div>
))}
</div>
<button type="button" onClick={saveCommunicationSettings} disabled={savingCommunicationSettings} className="btn-primary mt-5">
{savingCommunicationSettings ? copy.loading : copy.saveSettings}
</button>
</div>
) : null}
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
@@ -586,6 +1142,7 @@ export default function SubscriptionPage() {
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.invoice}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
@@ -595,11 +1152,21 @@ export default function SubscriptionPage() {
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm font-medium text-blue-700">
<a
href={`${resolveApiBase()}/subscriptions/invoices/${inv.id}/pdf`}
target="_blank"
rel="noreferrer"
className="hover:underline"
>
{inv.invoiceNumber ?? copy.invoice}
</a>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4">