From 7b8f81336a6d19714fe70cef3ba7078815760fe9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 23:23:58 -0400 Subject: [PATCH] add subscription upgrade plan --- apps/admin/src/app/dashboard/billing/page.tsx | 9 +- .../modules/admin/admin.billing.service.ts | 1 + .../admin/admin.manual-payments.service.ts | 3 + apps/api/src/modules/admin/admin.routes.ts | 39 ++ apps/api/src/modules/admin/admin.schemas.ts | 39 ++ .../subscription.manual.service.ts | 16 + .../subscriptions/subscription.routes.ts | 36 + .../subscriptions/subscription.schemas.ts | 18 + .../subscription.upgrade.service.ts | 659 ++++++++++++++++++ .../src/app/(dashboard)/subscription/page.tsx | 372 +++++++--- apps/dashboard/src/lib/api.test.ts | 10 +- apps/dashboard/src/lib/api.ts | 27 +- ...ubscription-upgrade-implementation-plan.md | 508 ++++++++++++++ .../migration.sql | 179 +++++ packages/database/prisma/schema.prisma | 145 ++++ 15 files changed, 1957 insertions(+), 104 deletions(-) create mode 100644 apps/api/src/modules/subscriptions/subscription.upgrade.service.ts create mode 100644 docs/subscription-upgrade-implementation-plan.md create mode 100644 packages/database/prisma/migrations/20260810130000_subscription_upgrade_requests/migration.sql diff --git a/apps/admin/src/app/dashboard/billing/page.tsx b/apps/admin/src/app/dashboard/billing/page.tsx index 876e97d..836320e 100644 --- a/apps/admin/src/app/dashboard/billing/page.tsx +++ b/apps/admin/src/app/dashboard/billing/page.tsx @@ -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, diff --git a/apps/api/src/modules/admin/admin.billing.service.ts b/apps/api/src/modules/admin/admin.billing.service.ts index e4b692d..47495b1 100644 --- a/apps/api/src/modules/admin/admin.billing.service.ts +++ b/apps/api/src/modules/admin/admin.billing.service.ts @@ -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' } }, }, diff --git a/apps/api/src/modules/admin/admin.manual-payments.service.ts b/apps/api/src/modules/admin/admin.manual-payments.service.ts index 6054b5b..2ca1391 100644 --- a/apps/api/src/modules/admin/admin.manual-payments.service.ts +++ b/apps/api/src/modules/admin/admin.manual-payments.service.ts @@ -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 }, diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index af5a0ef..828bf75 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -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) } }) diff --git a/apps/api/src/modules/admin/admin.schemas.ts b/apps/api/src/modules/admin/admin.schemas.ts index 3c6f43e..be62a36 100644 --- a/apps/api/src/modules/admin/admin.schemas.ts +++ b/apps/api/src/modules/admin/admin.schemas.ts @@ -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(), diff --git a/apps/api/src/modules/subscriptions/subscription.manual.service.ts b/apps/api/src/modules/subscriptions/subscription.manual.service.ts index bfcdefd..25d3aea 100644 --- a/apps/api/src/modules/subscriptions/subscription.manual.service.ts +++ b/apps/api/src/modules/subscriptions/subscription.manual.service.ts @@ -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: { diff --git a/apps/api/src/modules/subscriptions/subscription.routes.ts b/apps/api/src/modules/subscriptions/subscription.routes.ts index 0356266..2d3d9ff 100644 --- a/apps/api/src/modules/subscriptions/subscription.routes.ts +++ b/apps/api/src/modules/subscriptions/subscription.routes.ts @@ -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) } }) diff --git a/apps/api/src/modules/subscriptions/subscription.schemas.ts b/apps/api/src/modules/subscriptions/subscription.schemas.ts index 7f5161a..67dcc30 100644 --- a/apps/api/src/modules/subscriptions/subscription.schemas.ts +++ b/apps/api/src/modules/subscriptions/subscription.schemas.ts @@ -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 } diff --git a/apps/api/src/modules/subscriptions/subscription.upgrade.service.ts b/apps/api/src/modules/subscriptions/subscription.upgrade.service.ts new file mode 100644 index 0000000..6640d94 --- /dev/null +++ b/apps/api/src/modules/subscriptions/subscription.upgrade.service.ts @@ -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 = { 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) +} diff --git a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx index 2274891..833e8a0 100644 --- a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx @@ -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 } +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 | null +} + type CommunicationLocale = 'ar' | 'en' | 'fr' interface CommunicationSettings { timezone: string @@ -124,6 +150,12 @@ const INVOICE_STATUS: Record = { } const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'] +const PLAN_RANK: Record = { + STARTER: 1, + GROWTH: 2, + PRO: 3, + ENTERPRISE: 4, +} const PLAN_LABELS: Record = { 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>> + 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 l’envoi 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 l’arrondi sont confirmés par le devis serveur.', 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.', @@ -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 (
-

{copy.accessUnavailable}

-

{verificationError}

+

{copy.accessUnavailable}

+

{verificationError}

@@ -881,10 +1027,16 @@ export default function SubscriptionPage() {
) : null}
-

- {subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe} +

+ {isActiveSubscription ? copy.planUpgrade : copy.subscribe}

-

{copy.selectPayment}

+

{copy.selectPayment}

+ {isInvalidActiveUpgradeSelection ? ( +

{copy.selectHigherPlan}

+ ) : null} + {activeUpgradeManualRequired ? ( +

{copy.upgradeManualOnly}

+ ) : null}
{/* Billing period toggle */} @@ -893,9 +1045,10 @@ export default function SubscriptionPage() { @@ -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' : ''}`} >
-

{PLAN_LABELS[plan]}

+

{PLAN_LABELS[plan]}

{isActive && {copy.active}}
-

+

{price ? formatCurrency(price, 'MAD') : '—'} - /{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort} + /{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}

    {features.length > 0 ? features.map((f) => ( -
  • +
  • {f.label}
  • )) : copy.planFeatures[plan].map((f) => ( -
  • +
  • {f}
  • ))} @@ -944,15 +1097,46 @@ export default function SubscriptionPage() { })}
+ {upgradeProration ? ( +
+
+
+

{copy.upgradeEstimateTitle}

+

{copy.upgradeEstimateHelp}

+
+ + {copy.remainingDays}: {upgradeProration.remainingDays} + +
+
+
+
{copy.targetRemainingValue}
+
{formatCurrency(upgradeProration.targetRemainingValue, currency)}
+
+
+
{copy.currentPlanCredit}
+
-{formatCurrency(upgradeProration.currentCredit, currency)}
+
+
+
{copy.upgradeSubtotal}
+
{formatCurrency(upgradeProration.subtotal, currency)}
+
+
+

{copy.finalQuoteNote}

+
+ ) : null} + {/* Provider selector */}
-

{copy.paymentProvider}

+

{copy.paymentProvider}

{paymentOptions.filter((option) => option.enabled).map((option) => ( @@ -975,12 +1159,14 @@ export default function SubscriptionPage() { {isManualMethod && !manualCheckout ? manualDetailsForm : null} {/* Checkout CTA */} -
+
-

{copy.total}

-

- {planPrice ? formatCurrency(planPrice, 'MAD') : '—'} - /{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear} +

{copy.total}

+

+ {payableAmount ? formatCurrency(payableAmount, 'MAD') : '—'} + {!upgradeProration ? ( + /{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear} + ) : null}

{paymentSubmission && paymentSubmission.status !== 'DRAFT' && !manualCheckout ? ( -
+

{copy.awaitingVerification}

{manualPaymentRequestNumber ? (

@@ -1015,33 +1203,33 @@ export default function SubscriptionPage() { ) : null} {manualCheckout ? ( -

+
-

{copy.awaitingVerification}

-

+

{copy.awaitingVerification}

+

{copy.invoiceNumber}: {manualCheckout.invoice.invoiceNumber ?? manualCheckout.invoice.id} · {formatCurrency(manualCheckout.invoice.amountDue || manualCheckout.invoice.amount, 'MAD')}

-

+

{copy.dueDate}: {manualCheckout.invoice.dueAt ? new Date(manualCheckout.invoice.dueAt).toLocaleDateString() : '—'}

- {manualCheckout.invoice.status} + {manualCheckout.invoice.status}
-
-

{copy.paymentInstructions}

-
+
+

{copy.paymentInstructions}

+
{Object.entries(manualCheckout.instructions).map(([key, value]) => ( -
{key}
{value}
+
{key}
{value}
))}
{paymentSubmission && paymentSubmission.status !== 'DRAFT' ? ( -
+

{copy.evidenceSubmitted}

{paymentSubmission.status} · {paymentSubmission.documents.length} file(s)

- {paymentSubmission.rejectionReason ?

{paymentSubmission.rejectionReason}

: null} + {paymentSubmission.rejectionReason ?

{paymentSubmission.rejectionReason}

: null}
) : (
@@ -1062,16 +1250,16 @@ export default function SubscriptionPage() { {communicationSettings ? (
-

{copy.communicationTitle}

-

{copy.communicationHelp}

+

{copy.communicationTitle}

+

{copy.communicationHelp}

-

{copy.enabledLanguages}

+

{copy.enabledLanguages}

{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => { const checked = communicationSettings.enabledCommunicationLocales.includes(locale) return ( -
-
{communicationSettings.contacts.map((contact, index) => ( -
+
-

{contact.email}

-

{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}

+

{contact.email}

+

{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}

-