add subscription upgrade plan
Build & Push / Pipeline Tests (push) Failing after 2m0s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Failing after 1m7s

This commit is contained in:
root
2026-08-10 23:23:58 -04:00
parent 5f06256271
commit 7b8f81336a
15 changed files with 1957 additions and 104 deletions
@@ -119,6 +119,7 @@ interface BillingInvoice {
creditNotes: CreditNote[]
refunds: Refund[]
taxRecords: TaxRecord[]
subscriptionUpgradeRequest?: { id: string; status: string } | null
manualPaymentSubmissions?: Array<{
id: string
method: 'BANK_TRANSFER' | 'CHECK'
@@ -980,7 +981,13 @@ export default function AdminBillingPage() {
setActionError(null)
setReauthRequired(false)
try {
await api(`/admin/billing/invoices/${selectedInvoice.id}/manual-payments`, {
const path = selectedInvoice.invoiceType === 'SUBSCRIPTION_UPGRADE'
? selectedInvoice.subscriptionUpgradeRequest?.id
? `/admin/billing/upgrade-requests/${selectedInvoice.subscriptionUpgradeRequest.id}/approve-payment`
: null
: `/admin/billing/invoices/${selectedInvoice.id}/manual-payments`
if (!path) throw new Error('Upgrade request link is missing for this subscription upgrade invoice.')
await api(path, {
method: 'POST',
body: JSON.stringify({
submissionId: submission.id,
@@ -568,6 +568,7 @@ export async function getBillingAccountDetail(companyId: string) {
},
},
taxRecords: true,
subscriptionUpgradeRequest: { select: { id: true, status: true } },
creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { orderBy: { createdAt: 'desc' } },
},
@@ -335,6 +335,9 @@ export async function confirmManualPayment(invoiceId: string, data: {
},
})
if (!current) throw new NotFoundError('Invoice not found')
if (current.invoiceType === 'SUBSCRIPTION_UPGRADE') {
throw new ValidationError('Subscription upgrade invoices must be approved through the upgrade review workflow')
}
const duplicate = await tx.billingPaymentAttempt.findFirst({
where: { billingAccountId: current.billingAccountId, idempotencyKey: data.idempotencyKey },
@@ -8,6 +8,7 @@ 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 * as upgradeService from '../subscriptions/subscription.upgrade.service'
import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService'
import { presentAdminUser } from './admin.presenter'
import {
@@ -26,6 +27,7 @@ import {
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
upgradeRequestsQuerySchema, upgradeRequestIdParamSchema, upgradeCorrectionSchema, upgradeRejectSchema, approveUpgradePaymentSchema,
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
} from './admin.schemas'
@@ -403,6 +405,43 @@ router.post('/billing/invoices/:invoiceId/manual-payments', requireAdminAuth, re
} catch (err) { next(err) }
})
router.get('/billing/upgrade-requests', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { status } = parseQuery(upgradeRequestsQuerySchema, req)
ok(res, await upgradeService.listAdminUpgradeRequests(status))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/request-correction', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
const { reason } = parseBody(upgradeCorrectionSchema, req)
ok(res, await upgradeService.requestUpgradeCorrection(requestId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/reject-payment', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
const { reason } = parseBody(upgradeRejectSchema, req)
ok(res, await upgradeService.rejectUpgradePayment(requestId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/approve-payment', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
ok(res, await upgradeService.approveUpgradePayment(requestId, parseBody(approveUpgradePaymentSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/retry-activation', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
ok(res, await upgradeService.retryUpgradeActivation(requestId, 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) }
})
@@ -332,6 +332,45 @@ export const confirmManualPaymentSchema = z.object({
fundsVerified: z.literal(true),
})
export const upgradeRequestsQuerySchema = z.object({
status: z.enum([
'DRAFT',
'QUOTED',
'PAYMENT_PENDING',
'PAYMENT_REVIEW',
'CORRECTION_REQUIRED',
'APPROVED',
'ACTIVATED',
'SCHEDULED',
'REJECTED',
'EXPIRED',
'CANCELLED',
'ACTIVATION_FAILED',
'SUPERSEDED',
]).optional(),
})
export const upgradeRequestIdParamSchema = z.object({ requestId: z.string().min(1) })
export const upgradeCorrectionSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const upgradeRejectSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const approveUpgradePaymentSchema = 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(),
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(),
@@ -1368,6 +1368,22 @@ export async function submitManualPaymentSubmission(companyId: string, submissio
source: 'customer',
payload: { submissionId: submission.id, documentCount: submission.documents.length },
})
const upgradeRequestId = (submission.invoice.metadata as any)?.subscriptionUpgradeRequestId
if (upgradeRequestId) {
await tx.subscriptionUpgradeRequest.updateMany({
where: { id: upgradeRequestId, companyId, status: { in: ['PAYMENT_PENDING', 'CORRECTION_REQUIRED'] } },
data: { status: 'PAYMENT_REVIEW', version: { increment: 1 } },
})
await createBillingEvent(tx, {
billingAccountId: submission.billingAccountId,
invoiceId: submission.invoiceId,
subscriptionId: submission.invoice.subscriptionId,
companyId,
eventType: 'subscription_upgrade.evidence_submitted',
source: 'customer',
payload: { requestId: upgradeRequestId, submissionId: submission.id, documentCount: submission.documents.length },
})
}
return {
response: safeSubmission(updated),
notification: {
@@ -25,8 +25,13 @@ import {
createManualPaymentSubmissionSchema,
manualPaymentDocumentFieldsSchema,
communicationSettingsSchema,
upgradeQuoteSchema,
upgradeRequestIdParamSchema,
acceptUpgradeQuoteSchema,
cancelUpgradeRequestSchema,
} from './subscription.schemas'
import * as manualService from './subscription.manual.service'
import * as upgradeService from './subscription.upgrade.service'
const publicRouter = Router()
const webhookRouter = Router()
@@ -112,6 +117,37 @@ 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('/upgrade-options', requireRole('OWNER'), async (req, res, next) => {
try { ok(res, await upgradeService.getUpgradeOptions(req.companyId)) } catch (err) { next(err) }
})
router.post('/upgrade-quotes', requireRole('OWNER'), async (req, res, next) => {
try {
created(res, await upgradeService.createUpgradeQuote(req.companyId, req.employee.id, parseBody(upgradeQuoteSchema, req)))
} catch (err) { next(err) }
})
router.get('/upgrade-requests/:requestId', requireRole('OWNER'), async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
ok(res, await upgradeService.getUpgradeRequest(req.companyId, requestId))
} catch (err) { next(err) }
})
router.post('/upgrade-requests/:requestId/accept', requireRole('OWNER'), async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
ok(res, await upgradeService.acceptUpgradeQuote(req.companyId, req.employee.id, requestId, parseBody(acceptUpgradeQuoteSchema, req)))
} catch (err) { next(err) }
})
router.post('/upgrade-requests/:requestId/cancel', requireRole('OWNER'), async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
ok(res, await upgradeService.cancelUpgradeRequest(req.companyId, req.employee.id, requestId, parseBody(cancelUpgradeRequestSchema, req).reason))
} catch (err) { next(err) }
})
router.get('/events', async (req, res, next) => {
try { ok(res, await service.getEvents(req.companyId)) } catch (err) { next(err) }
})
@@ -106,4 +106,22 @@ export const communicationSettingsSchema = z.object({
}
})
export const upgradeQuoteSchema = z.object({
targetPlan: planEnum,
requestType: z.enum(['IMMEDIATE_PRORATED', 'AT_RENEWAL']).default('IMMEDIATE_PRORATED'),
idempotencyKey: z.string().uuid(),
})
export const upgradeRequestIdParamSchema = z.object({ requestId: z.string().min(1) })
export const acceptUpgradeQuoteSchema = z.object({
method: manualMethodEnum,
acceptedTermsVersion: z.string().min(1).max(120).optional(),
idempotencyKey: z.string().uuid(),
})
export const cancelUpgradeRequestSchema = z.object({
reason: z.string().max(500).optional(),
})
export { manualMethodEnum, localeEnum, referenceSchema }
@@ -0,0 +1,659 @@
import crypto from 'crypto'
import { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../../lib/prisma'
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
import { calculateTaxAmount, getPlatformBillingSettings } from './billingTax'
import { ensurePrimaryBillingAccount, normalizeExternalReference } from './subscription.manual.service'
import { getPaymentOptions, manualPaymentDueDays, requireManualMethodEnabled, type ManualCollectionMethod } from './subscription.payment-config'
const PLAN_RANK: Record<string, number> = { STARTER: 1, GROWTH: 2, PRO: 3, ENTERPRISE: 4 }
const NON_TERMINAL_STATUSES = [
'DRAFT',
'QUOTED',
'PAYMENT_PENDING',
'PAYMENT_REVIEW',
'CORRECTION_REQUIRED',
'APPROVED',
'SCHEDULED',
'ACTIVATION_FAILED',
]
const CALCULATION_VERSION = 'subscription-upgrade-proration-v1'
const TERMS_VERSION = 'subscription-upgrade-terms-v1'
type PlanCode = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type BillingPeriodCode = 'MONTHLY' | 'ANNUAL'
function addDays(date: Date, days: number) {
const next = new Date(date)
next.setUTCDate(next.getUTCDate() + days)
return next
}
function utcDateOnly(date: Date) {
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}
function calendarDaysBetween(start: Date, end: Date) {
return Math.ceil((utcDateOnly(end) - utcDateOnly(start)) / 86_400_000)
}
function stableHash(input: unknown) {
return crypto.createHash('sha256').update(JSON.stringify(input)).digest('hex')
}
function quoteTaxRate(quote: any) {
const rate = quote?.inputSnapshot?.taxRate
return typeof rate === 'number' && Number.isFinite(rate) ? rate : 0
}
function nextInvoiceNumber(sequence: number, date: Date) {
return `INV-${date.getUTCFullYear()}-${String(sequence).padStart(6, '0')}`
}
async function getNextInvoiceSequence(tx: any) {
const latest = await tx.billingInvoice.findFirst({
where: { invoiceSequence: { not: null } },
select: { invoiceSequence: true },
orderBy: { invoiceSequence: 'desc' },
})
return (latest?.invoiceSequence ?? 0) + 1
}
async function resolvePrice(plan: PlanCode, billingPeriod: BillingPeriodCode) {
const configured = await prisma.pricingConfig.findUnique({ where: { plan_billingPeriod: { plan, billingPeriod } } })
const amount = configured?.amount ?? (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
if (!Number.isInteger(amount) || amount <= 0) throw new ValidationError('Invalid plan or billing period')
return amount
}
function assertUpgrade(fromPlan: string, toPlan: string) {
if ((PLAN_RANK[toPlan] ?? 0) <= (PLAN_RANK[fromPlan] ?? 0)) {
throw new ValidationError('Target plan must be a higher eligible plan')
}
}
async function getSubscriptionForUpgrade(companyId: string) {
const subscription = await prisma.subscription.findUnique({ where: { companyId } })
if (!subscription) throw new NotFoundError('Subscription not found')
if (!['ACTIVE', 'PAST_DUE'].includes(subscription.status)) {
throw new ValidationError('Only active or grace-period subscriptions can be upgraded')
}
if (!subscription.currentPeriodStart || !subscription.currentPeriodEnd) {
throw new ValidationError('Subscription term dates are required before an upgrade can be quoted')
}
return subscription
}
export async function getUpgradeOptions(companyId: string) {
const subscription = await getSubscriptionForUpgrade(companyId)
const activeRequest = await prisma.subscriptionUpgradeRequest.findFirst({
where: { subscriptionId: subscription.id, status: { in: NON_TERMINAL_STATUSES as any } },
include: { quote: true, billingInvoice: true },
})
const plans = (Object.keys(PLAN_RANK) as PlanCode[])
.filter((plan) => PLAN_RANK[plan] > PLAN_RANK[subscription.plan])
.map((plan) => ({ plan, billingPeriod: subscription.billingPeriod, requestTypes: ['IMMEDIATE_PRORATED', 'AT_RENEWAL'] }))
return {
subscription,
activeRequest,
options: activeRequest ? [] : plans,
}
}
export async function createUpgradeQuote(companyId: string, employeeId: string, data: {
targetPlan: PlanCode
requestType: 'IMMEDIATE_PRORATED' | 'AT_RENEWAL'
idempotencyKey: string
}) {
const subscription = await getSubscriptionForUpgrade(companyId)
assertUpgrade(subscription.plan, data.targetPlan)
const account = await ensurePrimaryBillingAccount(companyId, employeeId)
const currentPrice = await resolvePrice(subscription.plan as PlanCode, subscription.billingPeriod as BillingPeriodCode)
const targetPrice = await resolvePrice(data.targetPlan, subscription.billingPeriod as BillingPeriodCode)
const now = new Date()
const termStart = subscription.currentPeriodStart!
const renewalDate = subscription.currentPeriodEnd!
const termDays = calendarDaysBetween(termStart, renewalDate)
const remainingDays = Math.max(0, calendarDaysBetween(now, renewalDate))
if (termDays <= 0 || remainingDays <= 0) throw new ValidationError('There is not enough remaining term to quote an immediate upgrade')
const expiresAt = new Date(Math.min(addDays(now, 7).getTime(), renewalDate.getTime()))
const subtotal = data.requestType === 'AT_RENEWAL'
? 0
: Math.max(0, Math.round(((targetPrice - currentPrice) * remainingDays) / termDays))
const taxSettings = await getPlatformBillingSettings()
const tax = calculateTaxAmount(subtotal, account.taxExempt, taxSettings.taxRate)
const inputSnapshot = {
companyId,
subscriptionId: subscription.id,
fromPlan: subscription.plan,
toPlan: data.targetPlan,
billingPeriod: subscription.billingPeriod,
currency: subscription.currency,
currentPrice,
targetPrice,
termStart: termStart.toISOString(),
renewalDate: renewalDate.toISOString(),
pricingEffectiveAt: now.toISOString(),
termDays,
remainingDays,
taxRate: tax.taxRate,
requestType: data.requestType,
}
return prisma.$transaction(async (tx: any) => {
const duplicate = await tx.subscriptionUpgradeRequest.findFirst({
where: { companyId, metadata: { path: ['idempotencyKey'], equals: data.idempotencyKey } },
include: { quote: true },
})
if (duplicate) return { request: duplicate, quote: duplicate.quote, duplicate: true }
const existing = await tx.subscriptionUpgradeRequest.findFirst({
where: { subscriptionId: subscription.id, status: { in: NON_TERMINAL_STATUSES } },
})
if (existing) throw new ConflictError('A non-terminal upgrade request already exists for this subscription')
const request = await tx.subscriptionUpgradeRequest.create({
data: {
companyId,
subscriptionId: subscription.id,
requestType: data.requestType,
fromPlan: subscription.plan,
fromBillingPeriod: subscription.billingPeriod,
toPlan: data.targetPlan,
toBillingPeriod: subscription.billingPeriod,
status: 'QUOTED',
requestedByEmployeeId: employeeId,
scheduledFor: data.requestType === 'AT_RENEWAL' ? renewalDate : null,
companyLanguageSnapshot: account.defaultCommunicationLocale,
billingTimezoneSnapshot: account.timezone,
expiresAt,
metadata: { idempotencyKey: data.idempotencyKey },
},
})
const quote = await tx.subscriptionUpgradeQuote.create({
data: {
upgradeRequestId: request.id,
currency: subscription.currency,
currentEligibleNetTermPrice: currentPrice,
targetNetTermPrice: targetPrice,
termStart,
renewalDate,
pricingEffectiveAt: now,
termDays,
remainingDays,
prorationNumerator: remainingDays,
prorationDenominator: termDays,
currentCredit: data.requestType === 'AT_RENEWAL' ? 0 : Math.round((currentPrice * remainingDays) / termDays),
targetRemainingValue: data.requestType === 'AT_RENEWAL' ? 0 : Math.round((targetPrice * remainingDays) / termDays),
taxAmount: tax.taxAmount,
subtotalAmount: subtotal,
totalAmount: tax.totalAmount,
calculationVersion: CALCULATION_VERSION,
inputSnapshot,
integrityHash: stableHash(inputSnapshot),
expiresAt,
},
})
const updated = await tx.subscriptionUpgradeRequest.update({
where: { id: request.id },
data: { quoteId: quote.id },
include: { quote: true },
})
await tx.billingEvent.create({
data: {
billingAccountId: account.id,
subscriptionId: subscription.id,
companyId,
eventType: 'subscription_upgrade.quote_created',
source: 'customer',
payload: { requestId: request.id, quoteId: quote.id, requestType: data.requestType, totalAmount: tax.totalAmount },
occurredAt: now,
},
})
return { request: updated, quote, duplicate: false }
})
}
export async function acceptUpgradeQuote(companyId: string, employeeId: string, requestId: string, data: {
method: ManualCollectionMethod
acceptedTermsVersion?: string
idempotencyKey: string
}) {
const account = await ensurePrimaryBillingAccount(companyId, employeeId)
const option = requireManualMethodEnabled(data.method, account.defaultCommunicationLocale as any)
return prisma.$transaction(async (tx: any) => {
const request = await tx.subscriptionUpgradeRequest.findFirst({
where: { id: requestId, companyId },
include: { quote: true, subscription: true, billingInvoice: true },
})
if (!request) throw new NotFoundError('Upgrade request not found')
if (request.billingInvoice) return { request, invoice: request.billingInvoice, instructions: option.instructions, duplicate: true }
if (request.status !== 'QUOTED') throw new ConflictError('Upgrade request cannot be accepted in its current state')
if (!request.quote) throw new ValidationError('Upgrade request is missing its immutable quote')
if (request.expiresAt && request.expiresAt <= new Date()) throw new ConflictError('Upgrade quote has expired')
const acceptedAt = new Date()
if (request.requestType === 'AT_RENEWAL') {
const scheduled = await tx.subscriptionUpgradeRequest.update({
where: { id: request.id },
data: {
status: 'SCHEDULED',
acceptedAt,
acceptedTermsVersion: data.acceptedTermsVersion ?? TERMS_VERSION,
version: { increment: 1 },
},
include: { quote: true },
})
await tx.billingEvent.create({
data: {
billingAccountId: account.id,
subscriptionId: request.subscriptionId,
companyId,
eventType: 'subscription_upgrade.scheduled',
source: 'customer',
payload: { requestId: request.id, targetPlan: request.toPlan, scheduledFor: request.scheduledFor },
occurredAt: acceptedAt,
},
})
return { request: scheduled, invoice: null, instructions: null, duplicate: false }
}
const dueAt = addDays(acceptedAt, manualPaymentDueDays(data.method))
const sequence = await getNextInvoiceSequence(tx)
const invoice = await tx.billingInvoice.create({
data: {
billingAccountId: account.id,
companyId,
subscriptionId: request.subscriptionId,
invoiceNumber: nextInvoiceNumber(sequence, acceptedAt),
invoiceSequence: sequence,
invoiceType: 'SUBSCRIPTION_UPGRADE',
status: 'OPEN',
currency: request.quote.currency,
subtotalAmount: request.quote.subtotalAmount,
taxAmount: request.quote.taxAmount,
totalAmount: request.quote.totalAmount,
amountDue: request.quote.totalAmount,
invoiceDate: acceptedAt,
dueAt,
finalizedAt: acceptedAt,
billingName: account.legalName,
billingEmail: account.billingEmail,
billingAddress: account.billingAddress ?? undefined,
paymentProvider: 'MANUAL',
collectionMethod: data.method,
requestedPlan: request.toPlan,
requestedBillingPeriod: request.toBillingPeriod,
checkoutIdempotencyKey: data.idempotencyKey,
isSubscriptionBlocking: false,
metadata: { source: 'subscription_upgrade', subscriptionUpgradeRequestId: request.id, quoteId: request.quote.id },
lineItems: {
create: [
{
subscriptionId: request.subscriptionId,
plan: request.toPlan,
type: 'PRORATION',
description: `${request.fromPlan} to ${request.toPlan} prorated upgrade`,
quantity: 1,
unitAmount: request.quote.subtotalAmount,
amount: request.quote.subtotalAmount,
currency: request.quote.currency,
periodStart: acceptedAt,
periodEnd: request.quote.renewalDate,
metadata: { quoteId: request.quote.id },
},
...(request.quote.taxAmount > 0 ? [{
subscriptionId: request.subscriptionId,
plan: request.toPlan,
type: 'TAX',
description: `Tax (${quoteTaxRate(request.quote)}%)`,
quantity: 1,
unitAmount: request.quote.taxAmount,
amount: request.quote.taxAmount,
currency: request.quote.currency,
}] : []),
],
},
...(request.quote.taxAmount > 0 || account.taxExempt ? {
taxRecords: {
create: {
jurisdiction: 'platform',
taxRate: quoteTaxRate(request.quote),
taxAmount: request.quote.taxAmount,
taxType: 'SALES_TAX',
taxExempt: account.taxExempt,
exemptionReason: account.taxExempt ? 'Billing account tax exempt' : null,
metadata: { quoteId: request.quote.id },
},
},
} : {}),
},
include: { lineItems: true, manualPaymentSubmissions: { include: { documents: true } } },
})
const updated = await tx.subscriptionUpgradeRequest.update({
where: { id: request.id },
data: {
status: 'PAYMENT_PENDING',
acceptedAt,
acceptedTermsVersion: data.acceptedTermsVersion ?? TERMS_VERSION,
billingInvoiceId: invoice.id,
version: { increment: 1 },
},
include: { quote: true, billingInvoice: true },
})
await tx.billingEvent.create({
data: {
billingAccountId: account.id,
invoiceId: invoice.id,
subscriptionId: request.subscriptionId,
companyId,
eventType: 'subscription_upgrade.payment_pending',
source: 'customer',
payload: { requestId: request.id, quoteId: request.quote.id, method: data.method },
occurredAt: acceptedAt,
},
})
return { request: updated, invoice, instructions: option.instructions, duplicate: false }
})
}
export async function getUpgradeRequest(companyId: string, requestId: string) {
const request = await prisma.subscriptionUpgradeRequest.findFirst({
where: { id: requestId, companyId },
include: { quote: true, billingInvoice: { include: { manualPaymentSubmissions: { include: { documents: true } }, lineItems: true } } },
})
if (!request) throw new NotFoundError('Upgrade request not found')
return request
}
export async function cancelUpgradeRequest(companyId: string, employeeId: string, requestId: string, reason?: string) {
const updated = await prisma.subscriptionUpgradeRequest.updateMany({
where: { id: requestId, companyId, status: { in: ['DRAFT', 'QUOTED', 'PAYMENT_PENDING', 'CORRECTION_REQUIRED', 'SCHEDULED'] as any } },
data: { status: 'CANCELLED', cancelledAt: new Date(), cancelledByEmployeeId: employeeId, cancellationReason: reason ?? null, version: { increment: 1 } },
})
if (updated.count !== 1) throw new ConflictError('Upgrade request cannot be cancelled in its current state')
return getUpgradeRequest(companyId, requestId)
}
export async function listAdminUpgradeRequests(status?: string) {
return prisma.subscriptionUpgradeRequest.findMany({
where: status ? { status: status as any } : undefined,
include: {
company: { select: { id: true, name: true, email: true } },
subscription: true,
quote: true,
billingInvoice: { include: { manualPaymentSubmissions: { include: { documents: true } }, paymentAttempts: true } },
},
orderBy: [{ updatedAt: 'asc' }],
take: 100,
})
}
export async function requestUpgradeCorrection(requestId: string, reason: string, adminId: string, ip?: string) {
return prisma.$transaction(async (tx: any) => {
const current = await tx.subscriptionUpgradeRequest.findUnique({ where: { id: requestId }, include: { billingInvoice: true } })
if (!current) throw new NotFoundError('Upgrade request not found')
if (!['PAYMENT_PENDING', 'PAYMENT_REVIEW', 'CORRECTION_REQUIRED'].includes(current.status)) {
throw new ConflictError('Upgrade request cannot require correction in its current state')
}
const updated = await tx.subscriptionUpgradeRequest.update({
where: { id: current.id },
data: { status: 'CORRECTION_REQUIRED', correctionReason: reason, version: { increment: 1 } },
include: { quote: true, billingInvoice: true },
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'REQUEST_SUBSCRIPTION_UPGRADE_CORRECTION',
resource: 'SubscriptionUpgradeRequest',
resourceId: current.id,
companyId: current.companyId,
before: { status: current.status },
after: { status: 'CORRECTION_REQUIRED', reason },
ipAddress: ip,
},
})
return updated
})
}
export async function rejectUpgradePayment(requestId: string, reason: string, adminId: string, ip?: string) {
return prisma.$transaction(async (tx: any) => {
const current = await tx.subscriptionUpgradeRequest.findUnique({ where: { id: requestId } })
if (!current) throw new NotFoundError('Upgrade request not found')
if (!['PAYMENT_PENDING', 'PAYMENT_REVIEW', 'CORRECTION_REQUIRED'].includes(current.status)) {
throw new ConflictError('Upgrade request cannot be rejected in its current state')
}
const updated = await tx.subscriptionUpgradeRequest.update({
where: { id: current.id },
data: { status: 'REJECTED', rejectionReason: reason, version: { increment: 1 } },
include: { quote: true, billingInvoice: true },
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'REJECT_SUBSCRIPTION_UPGRADE_PAYMENT',
resource: 'SubscriptionUpgradeRequest',
resourceId: current.id,
companyId: current.companyId,
before: { status: current.status },
after: { status: 'REJECTED', reason },
ipAddress: ip,
},
})
return updated
})
}
export async function approveUpgradePayment(requestId: string, data: {
submissionId: string
method: ManualCollectionMethod
externalReference: string
amount: number
receivedAt: string
note?: string
idempotencyKey: string
fundsVerified: true
}, adminId: string, ip?: string) {
const normalizedReference = normalizeExternalReference(data.externalReference)
return prisma.$transaction(async (tx: any) => {
const current = await tx.subscriptionUpgradeRequest.findUnique({
where: { id: requestId },
include: {
quote: true,
subscription: true,
billingInvoice: {
include: { manualPaymentSubmissions: { where: { id: data.submissionId }, include: { documents: { where: { deletedAt: null } } } } },
},
},
})
if (!current) throw new NotFoundError('Upgrade request not found')
if (current.status === 'ACTIVATED') return current
if (!['PAYMENT_PENDING', 'PAYMENT_REVIEW', 'CORRECTION_REQUIRED', 'ACTIVATION_FAILED'].includes(current.status)) {
throw new ConflictError('Upgrade request cannot be approved in its current state')
}
if (!current.quote || !current.billingInvoice) throw new ValidationError('Upgrade payment invoice is missing')
if (current.billingInvoice.status !== 'OPEN' && current.billingInvoice.status !== 'PAYMENT_PENDING' && current.billingInvoice.status !== 'PAST_DUE') {
throw new ConflictError('Upgrade invoice is not payable')
}
if (data.amount !== current.quote.totalAmount || data.amount !== current.billingInvoice.amountDue) {
throw new ConflictError('Verified amount must match the immutable upgrade quote')
}
const submission = current.billingInvoice.manualPaymentSubmissions[0]
if (!submission || !['SUBMITTED', 'UNDER_REVIEW'].includes(submission.status)) {
throw new ValidationError('Submitted payment evidence is required before approval')
}
if (submission.method !== data.method || current.billingInvoice.collectionMethod !== data.method) {
throw new ValidationError('Payment method must match the upgrade invoice and evidence')
}
if (!submission.documents.length || submission.documents.some((document: any) => document.scanStatus !== 'CLEAN')) {
throw new ValidationError('Every attached evidence document must be clean')
}
const duplicate = await tx.billingPaymentAttempt.findFirst({
where: { billingAccountId: current.billingInvoice.billingAccountId, idempotencyKey: data.idempotencyKey },
})
if (duplicate) {
const activated = await tx.subscriptionUpgradeRequest.findUnique({ where: { id: current.id }, include: { quote: true, billingInvoice: true } })
if (activated?.status === 'ACTIVATED') return activated
throw new ConflictError('Idempotency key was already used')
}
const approvedAt = new Date()
const intent = await tx.billingPaymentIntent.create({
data: {
invoiceId: current.billingInvoice.id,
billingAccountId: current.billingInvoice.billingAccountId,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.quote.currency,
metadata: { source: 'subscription_upgrade_approval', requestId: current.id },
},
})
const attempt = await tx.billingPaymentAttempt.create({
data: {
invoiceId: current.billingInvoice.id,
billingAccountId: current.billingInvoice.billingAccountId,
paymentIntentId: intent.id,
channel: 'OFFLINE',
manualMethod: data.method,
externalReference: data.externalReference,
normalizedExternalReference: normalizedReference,
receivedAt: new Date(data.receivedAt),
confirmedAt: approvedAt,
confirmedByAdminId: adminId,
idempotencyKey: data.idempotencyKey,
note: data.note ?? null,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.quote.currency,
attemptedAt: approvedAt,
metadata: { source: 'subscription_upgrade_approval', requestId: current.id, submissionId: submission.id, fundsVerified: true },
},
})
await tx.billingInvoice.update({
where: { id: current.billingInvoice.id },
data: { status: 'PAID', amountPaid: { increment: data.amount }, amountDue: 0, paidAt: approvedAt },
})
await tx.manualPaymentSubmission.update({
where: { id: submission.id },
data: { status: 'APPROVED', reviewedByAdminId: adminId, reviewedAt: approvedAt, paymentAttemptId: attempt.id },
})
await tx.subscriptionUpgradeRequest.update({
where: { id: current.id },
data: {
status: 'APPROVED',
approvedPaymentAttemptId: attempt.id,
approvedByAdminId: adminId,
approvedAt,
activationKey: `subscription-upgrade:${current.id}`,
version: { increment: 1 },
},
})
return activateApprovedUpgradeInTransaction(tx, current.id, adminId, ip)
})
}
async function activateApprovedUpgradeInTransaction(tx: any, requestId: string, adminId: string, ip?: string) {
const current = await tx.subscriptionUpgradeRequest.findUnique({
where: { id: requestId },
include: { subscription: true, quote: true, billingInvoice: true },
})
if (!current) throw new NotFoundError('Upgrade request not found')
if (current.status === 'ACTIVATED') return current
if (current.status !== 'APPROVED') throw new ConflictError('Only approved upgrade requests can be activated')
const activatedAt = new Date()
const previousTermStart = current.subscription.currentPeriodStart
const previousTermEnd = current.subscription.currentPeriodEnd
const newTermStart = current.requestType === 'TERM_RESET' ? activatedAt : previousTermStart
const newTermEnd = current.requestType === 'TERM_RESET'
? (current.toBillingPeriod === 'ANNUAL'
? new Date(Date.UTC(activatedAt.getUTCFullYear() + 1, activatedAt.getUTCMonth(), activatedAt.getUTCDate()))
: new Date(Date.UTC(activatedAt.getUTCFullYear(), activatedAt.getUTCMonth() + 1, activatedAt.getUTCDate())))
: previousTermEnd
await tx.subscriptionPlanHistory.create({
data: {
subscriptionId: current.subscriptionId,
companyId: current.companyId,
previousPlan: current.subscription.plan,
previousBillingPeriod: current.subscription.billingPeriod,
newPlan: current.toPlan,
newBillingPeriod: current.toBillingPeriod,
changeType: 'upgrade',
sourceUpgradeRequestId: current.id,
approvedPaymentAttemptId: current.approvedPaymentAttemptId,
effectiveAt: activatedAt,
previousTermStart,
previousTermEnd,
newTermStart,
newTermEnd,
actorType: 'admin',
actorId: adminId,
approvingAdminId: adminId,
entitlementSnapshot: { plan: current.toPlan, billingPeriod: current.toBillingPeriod },
auditCorrelationId: current.activationKey ?? `subscription-upgrade:${current.id}`,
},
})
await tx.subscription.update({
where: { id: current.subscriptionId },
data: {
plan: current.toPlan,
billingPeriod: current.toBillingPeriod,
status: 'ACTIVE',
currentPeriodStart: newTermStart,
currentPeriodEnd: newTermEnd,
paymentPendingSince: null,
paymentDueAt: null,
pastDueSince: null,
suspendedAt: null,
retryCount: 0,
},
})
await tx.subscriptionUpgradeRequest.update({
where: { id: current.id },
data: { status: 'ACTIVATED', activatedAt, effectiveAt: activatedAt, version: { increment: 1 } },
})
await tx.billingEvent.create({
data: {
billingAccountId: current.billingInvoice?.billingAccountId ?? null,
invoiceId: current.billingInvoiceId,
subscriptionId: current.subscriptionId,
companyId: current.companyId,
eventType: 'subscription_upgrade.activated',
source: 'system',
payload: { requestId: current.id, fromPlan: current.fromPlan, toPlan: current.toPlan },
occurredAt: activatedAt,
},
})
await tx.subscriptionEvent.create({
data: {
subscriptionId: current.subscriptionId,
companyId: current.companyId,
eventType: 'subscription.upgraded',
source: 'system',
payload: { requestId: current.id, fromPlan: current.fromPlan, toPlan: current.toPlan },
occurredAt: activatedAt,
},
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'APPROVE_AND_ACTIVATE_SUBSCRIPTION_UPGRADE',
resource: 'SubscriptionUpgradeRequest',
resourceId: current.id,
companyId: current.companyId,
before: { status: 'APPROVED', plan: current.subscription.plan },
after: { status: 'ACTIVATED', plan: current.toPlan },
ipAddress: ip,
},
})
return tx.subscriptionUpgradeRequest.findUnique({ where: { id: current.id }, include: { quote: true, billingInvoice: true } })
}
export async function retryUpgradeActivation(requestId: string, adminId: string, ip?: string) {
return prisma.$transaction((tx: any) => activateApprovedUpgradeInTransaction(tx, requestId, adminId, ip))
}
export async function getUpgradePaymentOptions(companyId: string, employeeId: string) {
const account = await ensurePrimaryBillingAccount(companyId, employeeId)
return getPaymentOptions(account.defaultCommunicationLocale as any)
}
@@ -18,6 +18,7 @@ interface Subscription {
status: string
currency: string
trialEndAt: string | null
currentPeriodStart: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
}
@@ -67,6 +68,31 @@ interface ManualCheckoutResult {
instructions: Record<string, string>
}
interface UpgradeQuoteResult {
request: {
id: string
status: string
}
quote: {
id: string
subtotalAmount: number
taxAmount: number
totalAmount: number
currency: string
expiresAt: string
renewalDate: string
}
}
interface UpgradeAcceptResult {
request: {
id: string
status: string
}
invoice: Invoice | null
instructions: Record<string, string> | null
}
type CommunicationLocale = 'ar' | 'en' | 'fr'
interface CommunicationSettings {
timezone: string
@@ -124,6 +150,12 @@ const INVOICE_STATUS: Record<string, string> = {
}
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const PLAN_RANK: Record<Plan, number> = {
STARTER: 1,
GROWTH: 2,
PRO: 3,
ENTERPRISE: 4,
}
const PLAN_LABELS: Record<Plan, string> = {
STARTER: 'Launch',
GROWTH: 'Growth',
@@ -154,6 +186,53 @@ function validatePaymentEvidenceFiles(files: File[]) {
return null
}
function utcDateOnly(date: Date) {
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}
function calendarDaysBetween(start: Date, end: Date) {
return Math.ceil((utcDateOnly(end) - utcDateOnly(start)) / 86400000)
}
function buildUpgradeProrationPreview(input: {
subscription: Subscription | null
selectedPlan: Plan
billingPeriod: BillingPeriod
planPrices: Record<string, Record<string, Record<string, number>>>
currency: string
}) {
const { subscription, selectedPlan, billingPeriod, planPrices, currency } = input
if (!subscription || subscription.status !== 'ACTIVE') return null
if (PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan]) return null
if (billingPeriod !== subscription.billingPeriod) return null
if (!subscription.currentPeriodStart || !subscription.currentPeriodEnd) return null
const currentPrice = planPrices[subscription.plan]?.[billingPeriod]?.[currency]
const targetPrice = planPrices[selectedPlan]?.[billingPeriod]?.[currency]
if (!currentPrice || !targetPrice) return null
const termStart = new Date(subscription.currentPeriodStart)
const renewalDate = new Date(subscription.currentPeriodEnd)
const now = new Date()
const termDays = calendarDaysBetween(termStart, renewalDate)
const remainingDays = Math.max(0, calendarDaysBetween(now, renewalDate))
if (termDays <= 0 || remainingDays <= 0) return null
const currentCredit = Math.round((currentPrice * remainingDays) / termDays)
const targetRemainingValue = Math.round((targetPrice * remainingDays) / termDays)
const subtotal = Math.max(0, targetRemainingValue - currentCredit)
return {
currentPrice,
targetPrice,
termDays,
remainingDays,
currentCredit,
targetRemainingValue,
subtotal,
renewalDate,
}
}
export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n()
@@ -198,6 +277,16 @@ export default function SubscriptionPage() {
cancelling: 'Cancelling…',
cancelPlan: 'Cancel plan',
changePlan: 'Change plan',
planUpgrade: 'Plan upgrade',
selectHigherPlan: 'Choose a higher plan to request an upgrade.',
upgradeManualOnly: 'Plan upgrades require bank transfer or check payment evidence before finance approval.',
upgradeEstimateTitle: 'Remaining-term upgrade calculation',
upgradeEstimateHelp: 'Estimated amount due for the unused part of your current billing term. The final quote is frozen when you submit payment evidence.',
targetRemainingValue: 'Target plan value for remaining days',
currentPlanCredit: 'Current plan credit for remaining days',
upgradeSubtotal: 'Estimated upgrade due before tax',
remainingDays: 'Remaining days',
finalQuoteNote: 'Taxes and rounding are confirmed by the server quote.',
subscribe: 'Subscribe',
selectPlan: 'Select a plan to continue to Stripe checkout.',
selectPayment: 'Choose Stripe, bank transfer, or check. The server calculates the final amount.',
@@ -281,6 +370,16 @@ export default function SubscriptionPage() {
cancelling: 'Annulation…',
cancelPlan: 'Annuler le plan',
changePlan: 'Changer de plan',
planUpgrade: 'Mise à niveau du plan',
selectHigherPlan: 'Choisissez un plan supérieur pour demander une mise à niveau.',
upgradeManualOnly: 'Les mises à niveau de plan nécessitent un virement bancaire ou un chèque avec justificatif avant validation financière.',
upgradeEstimateTitle: 'Calcul de mise à niveau pour la période restante',
upgradeEstimateHelp: 'Montant estimé dû pour la partie non utilisée de la période de facturation actuelle. Le devis final est figé lors de lenvoi du justificatif.',
targetRemainingValue: 'Valeur du nouveau plan pour les jours restants',
currentPlanCredit: 'Crédit du plan actuel pour les jours restants',
upgradeSubtotal: 'Mise à niveau estimée hors taxe',
remainingDays: 'Jours restants',
finalQuoteNote: 'Les taxes et larrondi sont confirmés par le devis serveur.',
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.',
@@ -364,6 +463,16 @@ export default function SubscriptionPage() {
cancelling: 'جارٍ الإلغاء…',
cancelPlan: 'إلغاء الخطة',
changePlan: 'تغيير الخطة',
planUpgrade: 'ترقية الخطة',
selectHigherPlan: 'اختر خطة أعلى لطلب الترقية.',
upgradeManualOnly: 'تتطلب ترقيات الخطة تحويلاً بنكياً أو شيكاً مع إثبات دفع قبل موافقة المالية.',
upgradeEstimateTitle: 'حساب الترقية للفترة المتبقية',
upgradeEstimateHelp: 'مبلغ تقديري مستحق عن الجزء غير المستخدم من فترة الفوترة الحالية. يتم تثبيت العرض النهائي عند إرسال إثبات الدفع.',
targetRemainingValue: 'قيمة الخطة الجديدة للأيام المتبقية',
currentPlanCredit: 'رصيد الخطة الحالية للأيام المتبقية',
upgradeSubtotal: 'مبلغ الترقية التقديري قبل الضريبة',
remainingDays: 'الأيام المتبقية',
finalQuoteNote: 'يؤكد عرض الخادم الضرائب والتقريب.',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة للمتابعة إلى Stripe Checkout.',
selectPayment: 'اختر Stripe أو التحويل البنكي أو الشيك. يحسب الخادم المبلغ النهائي.',
@@ -526,12 +635,20 @@ export default function SubscriptionPage() {
}
}, [canViewPage, fetchPlanData])
useEffect(() => {
if (subscription?.status !== 'ACTIVE') return
if (PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan]) return
if (selectedMethod !== 'STRIPE') return
const manualOption = paymentOptions.find((option) => option.enabled && option.method !== 'STRIPE')
if (manualOption) setSelectedMethod(manualOption.method)
}, [paymentOptions, selectedMethod, selectedPlan, subscription])
if (verificationError) {
return (
<div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessUnavailable}</h2>
<p className="mt-2 text-sm text-slate-500">{verificationError}</p>
<h2 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.accessUnavailable}</h2>
<p className="mt-2 text-sm text-slate-500 dark:text-zinc-400">{verificationError}</p>
<button
type="button"
onClick={() => setVerificationAttempt((value) => value + 1)}
@@ -556,8 +673,8 @@ export default function SubscriptionPage() {
return (
<div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessDenied}</h2>
<p className="mt-2 text-sm text-slate-500">{copy.accessDeniedBody}</p>
<h2 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.accessDenied}</h2>
<p className="mt-2 text-sm text-slate-500 dark:text-zinc-400">{copy.accessDeniedBody}</p>
</div>
</div>
)
@@ -569,6 +686,16 @@ export default function SubscriptionPage() {
try {
const selectedOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
if (!selectedOption) throw new Error(copy.providerUnavailable)
const isPlanUpgrade = Boolean(subscription?.status === 'ACTIVE' && PLAN_RANK[selectedPlan] > PLAN_RANK[subscription.plan])
if (subscription?.status === 'ACTIVE' && PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan]) {
throw new Error(copy.selectHigherPlan)
}
if (isPlanUpgrade && billingPeriod !== subscription?.billingPeriod) {
throw new Error(copy.selectHigherPlan)
}
if (isPlanUpgrade && selectedMethod === 'STRIPE') {
throw new Error(copy.upgradeManualOnly)
}
if (selectedMethod !== 'STRIPE') {
if (paymentReference.trim().length < 3) throw new Error(`${manualReferenceLabel} is required`)
if (evidenceFiles.length === 0) throw new Error(manualEvidenceLabel)
@@ -576,10 +703,23 @@ export default function SubscriptionPage() {
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 }),
})
const result = isPlanUpgrade
? await (async () => {
const quoted = await apiFetch<UpgradeQuoteResult>('/subscriptions/upgrade-quotes', {
method: 'POST',
body: JSON.stringify({ targetPlan: selectedPlan, requestType: 'IMMEDIATE_PRORATED', idempotencyKey }),
})
const accepted = await apiFetch<UpgradeAcceptResult>(`/subscriptions/upgrade-requests/${quoted.request.id}/accept`, {
method: 'POST',
body: JSON.stringify({ method: selectedMethod, acceptedTermsVersion: 'subscription-upgrade-terms-v1', idempotencyKey }),
})
if (!accepted.invoice || !accepted.instructions) throw new Error('Upgrade payment invoice was not created.')
return { invoice: accepted.invoice, instructions: accepted.instructions }
})()
: 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()
@@ -753,68 +893,74 @@ export default function SubscriptionPage() {
: null
const selectedPaymentOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
const isManualMethod = selectedMethod === 'BANK_TRANSFER' || selectedMethod === 'CHECK'
const isActiveSubscription = subscription?.status === 'ACTIVE'
const isPlanUpgradeSelection = Boolean(isActiveSubscription && subscription && PLAN_RANK[selectedPlan] > PLAN_RANK[subscription.plan])
const isInvalidActiveUpgradeSelection = Boolean(isActiveSubscription && subscription && PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan])
const activeUpgradeManualRequired = Boolean(isPlanUpgradeSelection && selectedMethod === 'STRIPE')
const upgradeProration = buildUpgradeProrationPreview({ subscription, selectedPlan, billingPeriod, planPrices, currency })
const payableAmount = upgradeProration?.subtotal ?? planPrice
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 className="rounded-2xl border border-slate-200 bg-slate-50 p-5 dark:border-zinc-700 dark:bg-zinc-900">
<div>
<p className="font-semibold text-slate-900">{copy.manualDetailsTitle}</p>
<p className="mt-1 text-sm text-slate-500">{copy.manualDetailsHelp}</p>
<p className="font-semibold text-slate-900 dark:text-zinc-100">{copy.manualDetailsTitle}</p>
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">{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">
<div className="mt-4 rounded-xl bg-white p-4 dark:bg-zinc-950">
<p className="text-sm font-semibold text-slate-900 dark:text-zinc-100">{copy.paymentInstructions}</p>
<dl className="mt-2 grid gap-2 text-sm text-slate-700 dark:text-zinc-300 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>
<div key={key}><dt className="text-xs uppercase text-slate-500 dark:text-zinc-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">
<label className="block text-sm font-medium text-slate-800 dark:text-zinc-200">
{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"
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
/>
</label>
<label className="block text-sm font-medium text-slate-800">
<label className="block text-sm font-medium text-slate-800 dark:text-zinc-200">
{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"
className="mt-1 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
/>
</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">
<p key={`${file.name}-${file.size}-${file.lastModified}`} className="text-xs text-slate-600 dark:text-zinc-300">
{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 key={document.id} className="mt-2 text-xs text-slate-600 dark:text-zinc-300">{document.originalFilename} · {document.scanStatus}</p>
))}
<p className="mt-3 text-xs text-amber-900">{copy.evidenceWarning}</p>
<p className="mt-3 text-xs text-amber-900 dark:text-amber-200">{copy.evidenceWarning}</p>
</div>
) : null
return (
<div className="space-y-8">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
<h2 className="text-xl font-semibold text-slate-900 dark:text-zinc-100">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1 dark:text-zinc-400">{copy.subtitle}</p>
</div>
{error && (
@@ -835,19 +981,19 @@ export default function SubscriptionPage() {
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-500">{copy.currentPlan}</p>
<div className="mt-1 flex items-center gap-3">
<h3 className="text-2xl font-bold text-slate-900">{PLAN_LABELS[subscription.plan]}</h3>
<h3 className="text-2xl font-bold text-slate-900 dark:text-zinc-100">{PLAN_LABELS[subscription.plan]}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.statusLabels[subscription.status] ?? subscription.status}
</span>
</div>
<p className="mt-1 text-sm text-slate-500">
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">
{subscription.billingPeriod} · {subscription.currency}
{subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
</p>
{subscription.cancelAtPeriodEnd && (
<p className="mt-2 text-sm font-medium text-orange-700">
<p className="mt-2 text-sm font-medium text-orange-700 dark:text-orange-300">
{copy.cancelScheduled}{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
</p>
@@ -881,10 +1027,16 @@ export default function SubscriptionPage() {
</div>
) : null}
<div>
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
<h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">
{isActiveSubscription ? copy.planUpgrade : copy.subscribe}
</h3>
<p className="mt-1 text-sm text-slate-500">{copy.selectPayment}</p>
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">{copy.selectPayment}</p>
{isInvalidActiveUpgradeSelection ? (
<p className="mt-2 text-sm text-amber-700 dark:text-amber-300">{copy.selectHigherPlan}</p>
) : null}
{activeUpgradeManualRequired ? (
<p className="mt-2 text-sm text-amber-700 dark:text-amber-300">{copy.upgradeManualOnly}</p>
) : null}
</div>
{/* Billing period toggle */}
@@ -893,9 +1045,10 @@ export default function SubscriptionPage() {
<button
key={p}
onClick={() => setBillingPeriod(p)}
disabled={isActiveSubscription && subscription?.billingPeriod !== p}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
billingPeriod === p ? 'bg-blue-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
billingPeriod === p ? 'bg-blue-900 text-white dark:bg-blue-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700'
} ${isActiveSubscription && subscription?.billingPeriod !== p ? 'cursor-not-allowed opacity-50' : ''}`}
>
{p === 'MONTHLY' ? copy.monthly : copy.annual}
</button>
@@ -914,27 +1067,27 @@ export default function SubscriptionPage() {
onClick={() => setSelectedPlan(plan)}
className={`text-left p-5 rounded-xl border-2 transition-all ${
selectedPlan === plan
? 'border-blue-500 bg-blue-50/50'
: 'border-slate-200 hover:border-slate-300 bg-white'
? 'border-blue-500 bg-blue-50/50 dark:bg-blue-950/40'
: 'border-slate-200 hover:border-slate-300 bg-white dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-600'
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-slate-900">{PLAN_LABELS[plan]}</p>
<p className="font-semibold text-slate-900 dark:text-zinc-100">{PLAN_LABELS[plan]}</p>
{isActive && <span className="badge-green">{copy.active}</span>}
</div>
<p className="mt-2 text-2xl font-black text-slate-900">
<p className="mt-2 text-2xl font-black text-slate-900 dark:text-zinc-100">
{price ? formatCurrency(price, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
<span className="text-sm font-normal text-slate-500 dark:text-zinc-400">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{features.length > 0
? features.map((f) => (
<li key={f.id} className="text-xs text-slate-600 flex items-center gap-1.5">
<li key={f.id} className="text-xs text-slate-600 flex items-center gap-1.5 dark:text-zinc-300">
<span className="text-green-500"></span> {f.label}
</li>
))
: copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5 dark:text-zinc-300">
<span className="text-green-500"></span> {f}
</li>
))}
@@ -944,15 +1097,46 @@ export default function SubscriptionPage() {
})}
</div>
{upgradeProration ? (
<div className="rounded-2xl border border-blue-200 bg-blue-50 p-5 dark:border-blue-800 dark:bg-blue-950/40">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-semibold text-blue-950 dark:text-blue-100">{copy.upgradeEstimateTitle}</p>
<p className="mt-1 text-sm text-blue-800 dark:text-blue-200">{copy.upgradeEstimateHelp}</p>
</div>
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-blue-800 dark:bg-blue-900 dark:text-blue-100">
{copy.remainingDays}: {upgradeProration.remainingDays}
</span>
</div>
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-3">
<div className="rounded-xl bg-white p-3 dark:bg-zinc-950">
<dt className="text-xs uppercase text-slate-500 dark:text-zinc-500">{copy.targetRemainingValue}</dt>
<dd className="mt-1 font-semibold text-slate-900 dark:text-zinc-100">{formatCurrency(upgradeProration.targetRemainingValue, currency)}</dd>
</div>
<div className="rounded-xl bg-white p-3 dark:bg-zinc-950">
<dt className="text-xs uppercase text-slate-500 dark:text-zinc-500">{copy.currentPlanCredit}</dt>
<dd className="mt-1 font-semibold text-slate-900 dark:text-zinc-100">-{formatCurrency(upgradeProration.currentCredit, currency)}</dd>
</div>
<div className="rounded-xl bg-white p-3 dark:bg-zinc-950">
<dt className="text-xs uppercase text-slate-500 dark:text-zinc-500">{copy.upgradeSubtotal}</dt>
<dd className="mt-1 font-black text-slate-950 dark:text-zinc-50">{formatCurrency(upgradeProration.subtotal, currency)}</dd>
</div>
</dl>
<p className="mt-3 text-xs text-blue-800 dark:text-blue-200">{copy.finalQuoteNote}</p>
</div>
) : null}
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<p className="text-sm font-medium text-slate-700 mb-2 dark:text-zinc-300">{copy.paymentProvider}</p>
<div className="flex flex-wrap gap-3">
{paymentOptions.filter((option) => option.enabled).map((option) => (
<button
type="button"
key={option.method}
disabled={isPlanUpgradeSelection && option.method === 'STRIPE'}
onClick={() => {
if (isPlanUpgradeSelection && option.method === 'STRIPE') return
setSelectedMethod(option.method)
setManualCheckout(null)
setManualPaymentRequestNumber(null)
@@ -963,8 +1147,8 @@ export default function SubscriptionPage() {
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'
}`}
selectedMethod === option.method ? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200' : 'border-slate-200 text-slate-600 dark:border-zinc-700 dark:text-zinc-300'
} ${isPlanUpgradeSelection && option.method === 'STRIPE' ? 'cursor-not-allowed opacity-50' : ''}`}
>
{option.method === 'STRIPE' ? 'Stripe' : option.method === 'BANK_TRANSFER' ? copy.bankTransfer : copy.check}
</button>
@@ -975,12 +1159,14 @@ export default function SubscriptionPage() {
{isManualMethod && !manualCheckout ? manualDetailsForm : null}
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div className="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-zinc-800">
<div>
<p className="text-sm text-slate-500">{copy.total}</p>
<p className="text-xl font-black text-slate-900">
{planPrice ? formatCurrency(planPrice, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
<p className="text-sm text-slate-500 dark:text-zinc-400">{copy.total}</p>
<p className="text-xl font-black text-slate-900 dark:text-zinc-100">
{payableAmount ? formatCurrency(payableAmount, 'MAD') : '—'}
{!upgradeProration ? (
<span className="text-sm font-normal text-slate-500 ml-1 dark:text-zinc-400">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
) : null}
</p>
</div>
<button
@@ -989,6 +1175,8 @@ export default function SubscriptionPage() {
paying
|| loading
|| !paymentOptions.some((option) => option.method === selectedMethod && option.enabled)
|| isInvalidActiveUpgradeSelection
|| activeUpgradeManualRequired
|| (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0))
}
className="btn-primary px-8 py-3"
@@ -996,13 +1184,13 @@ export default function SubscriptionPage() {
{paying
? (selectedMethod === 'STRIPE' ? copy.redirecting : copy.submittingReview)
: selectedMethod === 'STRIPE'
? (subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow)
: copy.submitPaymentEvidence}
? (isActiveSubscription ? copy.planUpgrade : copy.subscribeNow)
: (isActiveSubscription ? copy.planUpgrade : 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">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{copy.awaitingVerification}</p>
{manualPaymentRequestNumber ? (
<p className="mt-1">
@@ -1015,33 +1203,33 @@ export default function SubscriptionPage() {
) : null}
{manualCheckout ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-5" aria-live="polite">
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-5 dark:border-amber-800 dark:bg-amber-950/40" 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">
<p className="font-semibold text-amber-900 dark:text-amber-100">{copy.awaitingVerification}</p>
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">
{copy.invoiceNumber}: {manualCheckout.invoice.invoiceNumber ?? manualCheckout.invoice.id} · {formatCurrency(manualCheckout.invoice.amountDue || manualCheckout.invoice.amount, 'MAD')}
</p>
<p className="text-sm text-amber-800">
<p className="text-sm text-amber-800 dark:text-amber-200">
{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>
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800 dark:bg-amber-900 dark:text-amber-100">{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">
<div className="mt-4 rounded-xl bg-white/80 p-4 dark:bg-zinc-950/80">
<p className="text-sm font-semibold text-slate-900 dark:text-zinc-100">{copy.paymentInstructions}</p>
<dl className="mt-2 grid gap-2 text-sm text-slate-700 dark:text-zinc-300 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>
<div key={key}><dt className="text-xs uppercase text-slate-500 dark:text-zinc-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">
<div className="mt-4 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/40 dark:text-green-200">
<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}
{paymentSubmission.rejectionReason ? <p className="mt-2 text-red-700 dark:text-red-300">{paymentSubmission.rejectionReason}</p> : null}
</div>
) : (
<div className="mt-4 space-y-3">
@@ -1062,16 +1250,16 @@ export default function SubscriptionPage() {
{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>
<h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.communicationTitle}</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">{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>
<p className="text-sm font-medium text-slate-700 dark:text-zinc-300">{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">
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase text-slate-700 dark:border-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
checked={checked}
@@ -1095,30 +1283,30 @@ export default function SubscriptionPage() {
})}
</div>
</div>
<label className="block text-sm font-medium text-slate-700">
<label className="block text-sm font-medium text-slate-700 dark:text-zinc-300">
{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">
<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 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
<label className="block text-sm font-medium text-slate-700 dark:text-zinc-300">
{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" />
<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 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
</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 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 dark:border-zinc-700">
<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>
<p className="text-sm font-medium text-slate-900 dark:text-zinc-100">{contact.email}</p>
<p className="text-xs text-slate-500 dark:text-zinc-400">{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">
<label className="text-xs font-medium text-slate-600 dark:text-zinc-300">
{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"
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
>
<option value="">{copy.inheritDefault}</option>
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
@@ -1135,29 +1323,29 @@ export default function SubscriptionPage() {
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3>
<div className="px-6 py-4 border-b border-slate-200 dark:border-zinc-800">
<h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.invoiceHistory}</h3>
</div>
<div className="overflow-x-auto">
<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>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paid}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
<tr className="bg-slate-50 border-b border-slate-200 dark:border-zinc-800 dark:bg-zinc-900">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.invoice}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.status}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.paid}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.amount}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tbody className="divide-y divide-slate-100 dark:divide-zinc-800">
{loading ? (
<tr><td colSpan={6} 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 dark:text-zinc-500">{copy.loading}</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={6} 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 dark:text-zinc-500">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm font-medium text-blue-700">
<td className="px-6 py-4 text-sm font-medium text-blue-700 dark:text-blue-300">
<a
href={`${resolveApiBase()}/subscriptions/invoices/${inv.id}/pdf`}
target="_blank"
@@ -1167,17 +1355,17 @@ export default function SubscriptionPage() {
{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 text-sm text-slate-700 dark:text-zinc-300">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700 dark:text-zinc-300">{inv.paymentProvider}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.invoiceStatusLabels[inv.status] ?? inv.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">
<td className="px-6 py-4 text-sm text-slate-500 dark:text-zinc-400">
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900 dark:text-zinc-100">
{formatCurrency(inv.amount, 'MAD')}
</td>
</tr>
+5 -5
View File
@@ -184,12 +184,12 @@ describe('dashboard apiFetch', () => {
credentials: 'include',
}))
expect(resolveRealtimeSocketTarget()).toEqual({
origin: 'http://localhost:3000',
path: '/dashboard/socket.io',
origin: 'http://localhost:4000',
path: '/socket.io',
})
})
it('routes proxied realtime connections through the dashboard socket path', async () => {
it('routes realtime connections to the configured API origin instead of the dashboard proxy path', async () => {
installBrowser()
setBrowserHostname('rentaldrivego.ma')
;(globalThis.window as any).location.origin = 'https://rentaldrivego.ma'
@@ -198,8 +198,8 @@ describe('dashboard apiFetch', () => {
const api = await import('./api')
expect(api.resolveRealtimeSocketTarget()).toEqual({
origin: 'https://rentaldrivego.ma',
path: '/dashboard/socket.io',
origin: 'https://api.rentaldrivego.ma',
path: '/socket.io',
})
})
+21 -6
View File
@@ -55,14 +55,29 @@ export function resolveApiOrigin(): string | null {
export function resolveRealtimeSocketTarget(): { origin: string; path: string } | null {
if (typeof window === 'undefined') return null
const apiBase = resolveApiBase()
const isDashboardProxy = apiBase === DASHBOARD_PROXY_API_BASE || apiBase.startsWith(`${DASHBOARD_PROXY_API_BASE}/`)
const origin = isDashboardProxy ? window.location.origin : resolveApiOrigin()
if (!origin) return null
const configuredApiBase = process.env.NEXT_PUBLIC_API_URL
if (configuredApiBase && !configuredApiBase.startsWith('/')) {
try {
return {
origin: new URL(normalizeApiBase(configuredApiBase)).origin,
path: '/socket.io',
}
} catch {
return null
}
}
const currentHostname = window.location?.hostname
if (currentHostname && isLocalBrowserHost(currentHostname)) {
return {
origin: 'http://localhost:4000',
path: '/socket.io',
}
}
return {
origin,
path: isDashboardProxy ? '/dashboard/socket.io' : '/socket.io',
origin: resolveApiOrigin() ?? window.location.origin,
path: '/socket.io',
}
}