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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user