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[] creditNotes: CreditNote[]
refunds: Refund[] refunds: Refund[]
taxRecords: TaxRecord[] taxRecords: TaxRecord[]
subscriptionUpgradeRequest?: { id: string; status: string } | null
manualPaymentSubmissions?: Array<{ manualPaymentSubmissions?: Array<{
id: string id: string
method: 'BANK_TRANSFER' | 'CHECK' method: 'BANK_TRANSFER' | 'CHECK'
@@ -980,7 +981,13 @@ export default function AdminBillingPage() {
setActionError(null) setActionError(null)
setReauthRequired(false) setReauthRequired(false)
try { 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', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submissionId: submission.id, submissionId: submission.id,
@@ -568,6 +568,7 @@ export async function getBillingAccountDetail(companyId: string) {
}, },
}, },
taxRecords: true, taxRecords: true,
subscriptionUpgradeRequest: { select: { id: true, status: true } },
creditNotes: { orderBy: { createdAt: 'desc' } }, creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { 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) 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({ const duplicate = await tx.billingPaymentAttempt.findFirst({
where: { billingAccountId: current.billingAccountId, idempotencyKey: data.idempotencyKey }, 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 menuService from '../menu/menu.service'
import * as manualPaymentsService from './admin.manual-payments.service' import * as manualPaymentsService from './admin.manual-payments.service'
import * as collectionsService from '../subscriptions/subscription.collections.service' import * as collectionsService from '../subscriptions/subscription.collections.service'
import * as upgradeService from '../subscriptions/subscription.upgrade.service'
import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService' import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService'
import { presentAdminUser } from './admin.presenter' import { presentAdminUser } from './admin.presenter'
import { import {
@@ -26,6 +27,7 @@ import {
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema, menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema, manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema, manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
upgradeRequestsQuerySchema, upgradeRequestIdParamSchema, upgradeCorrectionSchema, upgradeRejectSchema, approveUpgradePaymentSchema,
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema, collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema, collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
} from './admin.schemas' } from './admin.schemas'
@@ -403,6 +405,43 @@ router.post('/billing/invoices/:invoiceId/manual-payments', requireAdminAuth, re
} catch (err) { next(err) } } 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) => { router.get('/billing/collections', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { ok(res, await collectionsService.listCollectionsCases(parseQuery(collectionsQuerySchema, req))) } catch (err) { next(err) } 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), 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({ export const collectionsQuerySchema = z.object({
status: z.enum(['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED']).optional(), status: z.enum(['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED']).optional(),
assignedTo: z.string().optional(), assignedTo: z.string().optional(),
@@ -1368,6 +1368,22 @@ export async function submitManualPaymentSubmission(companyId: string, submissio
source: 'customer', source: 'customer',
payload: { submissionId: submission.id, documentCount: submission.documents.length }, 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 { return {
response: safeSubmission(updated), response: safeSubmission(updated),
notification: { notification: {
@@ -25,8 +25,13 @@ import {
createManualPaymentSubmissionSchema, createManualPaymentSubmissionSchema,
manualPaymentDocumentFieldsSchema, manualPaymentDocumentFieldsSchema,
communicationSettingsSchema, communicationSettingsSchema,
upgradeQuoteSchema,
upgradeRequestIdParamSchema,
acceptUpgradeQuoteSchema,
cancelUpgradeRequestSchema,
} from './subscription.schemas' } from './subscription.schemas'
import * as manualService from './subscription.manual.service' import * as manualService from './subscription.manual.service'
import * as upgradeService from './subscription.upgrade.service'
const publicRouter = Router() const publicRouter = Router()
const webhookRouter = 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) } 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) => { router.get('/events', async (req, res, next) => {
try { ok(res, await service.getEvents(req.companyId)) } catch (err) { next(err) } 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 } 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 status: string
currency: string currency: string
trialEndAt: string | null trialEndAt: string | null
currentPeriodStart: string | null
currentPeriodEnd: string | null currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean cancelAtPeriodEnd: boolean
} }
@@ -67,6 +68,31 @@ interface ManualCheckoutResult {
instructions: Record<string, string> 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' type CommunicationLocale = 'ar' | 'en' | 'fr'
interface CommunicationSettings { interface CommunicationSettings {
timezone: string timezone: string
@@ -124,6 +150,12 @@ const INVOICE_STATUS: Record<string, string> = {
} }
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'] 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> = { const PLAN_LABELS: Record<Plan, string> = {
STARTER: 'Launch', STARTER: 'Launch',
GROWTH: 'Growth', GROWTH: 'Growth',
@@ -154,6 +186,53 @@ function validatePaymentEvidenceFiles(files: File[]) {
return null 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() { export default function SubscriptionPage() {
const router = useRouter() const router = useRouter()
const { language } = useDashboardI18n() const { language } = useDashboardI18n()
@@ -198,6 +277,16 @@ export default function SubscriptionPage() {
cancelling: 'Cancelling…', cancelling: 'Cancelling…',
cancelPlan: 'Cancel plan', cancelPlan: 'Cancel plan',
changePlan: 'Change 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', subscribe: 'Subscribe',
selectPlan: 'Select a plan to continue to Stripe checkout.', selectPlan: 'Select a plan to continue to Stripe checkout.',
selectPayment: 'Choose Stripe, bank transfer, or check. The server calculates the final amount.', selectPayment: 'Choose Stripe, bank transfer, or check. The server calculates the final amount.',
@@ -281,6 +370,16 @@ export default function SubscriptionPage() {
cancelling: 'Annulation…', cancelling: 'Annulation…',
cancelPlan: 'Annuler le plan', cancelPlan: 'Annuler le plan',
changePlan: 'Changer de 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', subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan pour continuer vers Stripe Checkout.', selectPlan: 'Sélectionnez un plan pour continuer vers Stripe Checkout.',
selectPayment: 'Choisissez Stripe, virement bancaire ou chèque. Le serveur calcule le montant final.', selectPayment: 'Choisissez Stripe, virement bancaire ou chèque. Le serveur calcule le montant final.',
@@ -364,6 +463,16 @@ export default function SubscriptionPage() {
cancelling: 'جارٍ الإلغاء…', cancelling: 'جارٍ الإلغاء…',
cancelPlan: 'إلغاء الخطة', cancelPlan: 'إلغاء الخطة',
changePlan: 'تغيير الخطة', changePlan: 'تغيير الخطة',
planUpgrade: 'ترقية الخطة',
selectHigherPlan: 'اختر خطة أعلى لطلب الترقية.',
upgradeManualOnly: 'تتطلب ترقيات الخطة تحويلاً بنكياً أو شيكاً مع إثبات دفع قبل موافقة المالية.',
upgradeEstimateTitle: 'حساب الترقية للفترة المتبقية',
upgradeEstimateHelp: 'مبلغ تقديري مستحق عن الجزء غير المستخدم من فترة الفوترة الحالية. يتم تثبيت العرض النهائي عند إرسال إثبات الدفع.',
targetRemainingValue: 'قيمة الخطة الجديدة للأيام المتبقية',
currentPlanCredit: 'رصيد الخطة الحالية للأيام المتبقية',
upgradeSubtotal: 'مبلغ الترقية التقديري قبل الضريبة',
remainingDays: 'الأيام المتبقية',
finalQuoteNote: 'يؤكد عرض الخادم الضرائب والتقريب.',
subscribe: 'اشتراك', subscribe: 'اشتراك',
selectPlan: 'اختر خطة للمتابعة إلى Stripe Checkout.', selectPlan: 'اختر خطة للمتابعة إلى Stripe Checkout.',
selectPayment: 'اختر Stripe أو التحويل البنكي أو الشيك. يحسب الخادم المبلغ النهائي.', selectPayment: 'اختر Stripe أو التحويل البنكي أو الشيك. يحسب الخادم المبلغ النهائي.',
@@ -526,12 +635,20 @@ export default function SubscriptionPage() {
} }
}, [canViewPage, fetchPlanData]) }, [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) { if (verificationError) {
return ( return (
<div className="flex min-h-[40vh] items-center justify-center px-6"> <div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center"> <div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessUnavailable}</h2> <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">{verificationError}</p> <p className="mt-2 text-sm text-slate-500 dark:text-zinc-400">{verificationError}</p>
<button <button
type="button" type="button"
onClick={() => setVerificationAttempt((value) => value + 1)} onClick={() => setVerificationAttempt((value) => value + 1)}
@@ -556,8 +673,8 @@ export default function SubscriptionPage() {
return ( return (
<div className="flex min-h-[40vh] items-center justify-center px-6"> <div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center"> <div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessDenied}</h2> <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">{copy.accessDeniedBody}</p> <p className="mt-2 text-sm text-slate-500 dark:text-zinc-400">{copy.accessDeniedBody}</p>
</div> </div>
</div> </div>
) )
@@ -569,6 +686,16 @@ export default function SubscriptionPage() {
try { try {
const selectedOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled) const selectedOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
if (!selectedOption) throw new Error(copy.providerUnavailable) 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 (selectedMethod !== 'STRIPE') {
if (paymentReference.trim().length < 3) throw new Error(`${manualReferenceLabel} is required`) if (paymentReference.trim().length < 3) throw new Error(`${manualReferenceLabel} is required`)
if (evidenceFiles.length === 0) throw new Error(manualEvidenceLabel) if (evidenceFiles.length === 0) throw new Error(manualEvidenceLabel)
@@ -576,10 +703,23 @@ export default function SubscriptionPage() {
if (fileError) throw new Error(fileError) if (fileError) throw new Error(fileError)
const idempotencyKey = checkoutIdempotencyKey ?? crypto.randomUUID() const idempotencyKey = checkoutIdempotencyKey ?? crypto.randomUUID()
setCheckoutIdempotencyKey(idempotencyKey) setCheckoutIdempotencyKey(idempotencyKey)
const result = await apiFetch<ManualCheckoutResult>('/subscriptions/manual-checkout', { const result = isPlanUpgrade
method: 'POST', ? await (async () => {
body: JSON.stringify({ plan: selectedPlan, billingPeriod, currency, method: selectedMethod, idempotencyKey }), 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) setManualPaymentRequestNumber(result.invoice.invoiceNumber ?? result.invoice.id)
const key = submissionIdempotencyKey ?? crypto.randomUUID() const key = submissionIdempotencyKey ?? crypto.randomUUID()
@@ -753,68 +893,74 @@ export default function SubscriptionPage() {
: null : null
const selectedPaymentOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled) const selectedPaymentOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
const isManualMethod = selectedMethod === 'BANK_TRANSFER' || selectedMethod === 'CHECK' 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 manualReferenceLabel = selectedMethod === 'CHECK' ? copy.checkNumber : copy.bankTransferReference
const manualEvidenceLabel = selectedMethod === 'CHECK' ? copy.checkEvidence : copy.bankTransferEvidence const manualEvidenceLabel = selectedMethod === 'CHECK' ? copy.checkEvidence : copy.bankTransferEvidence
const manualEvidenceKindLabel = selectedMethod === 'CHECK' ? copy.check : copy.bankTransfer const manualEvidenceKindLabel = selectedMethod === 'CHECK' ? copy.check : copy.bankTransfer
const manualDetailsForm = isManualMethod ? ( 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> <div>
<p className="font-semibold text-slate-900">{copy.manualDetailsTitle}</p> <p className="font-semibold text-slate-900 dark:text-zinc-100">{copy.manualDetailsTitle}</p>
<p className="mt-1 text-sm text-slate-500">{copy.manualDetailsHelp}</p> <p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">{copy.manualDetailsHelp}</p>
</div> </div>
{selectedPaymentOption?.instructions && !manualCheckout ? ( {selectedPaymentOption?.instructions && !manualCheckout ? (
<div className="mt-4 rounded-xl bg-white p-4"> <div className="mt-4 rounded-xl bg-white p-4 dark:bg-zinc-950">
<p className="text-sm font-semibold text-slate-900">{copy.paymentInstructions}</p> <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 sm:grid-cols-2"> <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]) => ( {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> </dl>
</div> </div>
) : null} ) : null}
<div className="mt-4 grid gap-4 lg:grid-cols-2"> <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} {manualReferenceLabel}
<input <input
value={paymentReference} value={paymentReference}
onChange={(event) => setPaymentReference(event.target.value)} onChange={(event) => setPaymentReference(event.target.value)}
maxLength={120} maxLength={120}
inputMode="text" 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>
<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} {manualEvidenceLabel}
<input <input
type="file" type="file"
accept={PAYMENT_EVIDENCE_ACCEPT} accept={PAYMENT_EVIDENCE_ACCEPT}
multiple multiple
onChange={handleEvidenceFileChange} 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> </label>
</div> </div>
{evidenceFiles.length > 0 ? ( {evidenceFiles.length > 0 ? (
<div className="mt-3 space-y-1"> <div className="mt-3 space-y-1">
{evidenceFiles.map((file) => ( {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} {manualEvidenceKindLabel}: {file.name}
</p> </p>
))} ))}
</div> </div>
) : null} ) : null}
{paymentSubmission?.documents.map((document) => ( {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> </div>
) : null ) : null
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2> <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">{copy.subtitle}</p> <p className="text-sm text-slate-500 mt-1 dark:text-zinc-400">{copy.subtitle}</p>
</div> </div>
{error && ( {error && (
@@ -835,19 +981,19 @@ export default function SubscriptionPage() {
<div className="card p-6"> <div className="card p-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <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"> <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'}`}> <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} {copy.statusLabels[subscription.status] ?? subscription.status}
</span> </span>
</div> </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.billingPeriod} · {subscription.currency}
{subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`} {subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
</p> </p>
{subscription.cancelAtPeriodEnd && ( {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}{' '} {copy.cancelScheduled}{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button> <button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
</p> </p>
@@ -881,10 +1027,16 @@ export default function SubscriptionPage() {
</div> </div>
) : null} ) : null}
<div> <div>
<h3 className="text-base font-semibold text-slate-900"> <h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe} {isActiveSubscription ? copy.planUpgrade : copy.subscribe}
</h3> </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> </div>
{/* Billing period toggle */} {/* Billing period toggle */}
@@ -893,9 +1045,10 @@ export default function SubscriptionPage() {
<button <button
key={p} key={p}
onClick={() => setBillingPeriod(p)} onClick={() => setBillingPeriod(p)}
disabled={isActiveSubscription && subscription?.billingPeriod !== p}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${ 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} {p === 'MONTHLY' ? copy.monthly : copy.annual}
</button> </button>
@@ -914,27 +1067,27 @@ export default function SubscriptionPage() {
onClick={() => setSelectedPlan(plan)} onClick={() => setSelectedPlan(plan)}
className={`text-left p-5 rounded-xl border-2 transition-all ${ className={`text-left p-5 rounded-xl border-2 transition-all ${
selectedPlan === plan selectedPlan === plan
? 'border-blue-500 bg-blue-50/50' ? 'border-blue-500 bg-blue-50/50 dark:bg-blue-950/40'
: 'border-slate-200 hover:border-slate-300 bg-white' : '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' : ''}`} } ${isActive ? 'ring-2 ring-green-200' : ''}`}
> >
<div className="flex items-center justify-between"> <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>} {isActive && <span className="badge-green">{copy.active}</span>}
</div> </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') : '—'} {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> </p>
<ul className="mt-3 space-y-1"> <ul className="mt-3 space-y-1">
{features.length > 0 {features.length > 0
? features.map((f) => ( ? 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} <span className="text-green-500"></span> {f.label}
</li> </li>
)) ))
: copy.planFeatures[plan].map((f) => ( : 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} <span className="text-green-500"></span> {f}
</li> </li>
))} ))}
@@ -944,15 +1097,46 @@ export default function SubscriptionPage() {
})} })}
</div> </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 */} {/* Provider selector */}
<div> <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"> <div className="flex flex-wrap gap-3">
{paymentOptions.filter((option) => option.enabled).map((option) => ( {paymentOptions.filter((option) => option.enabled).map((option) => (
<button <button
type="button" type="button"
key={option.method} key={option.method}
disabled={isPlanUpgradeSelection && option.method === 'STRIPE'}
onClick={() => { onClick={() => {
if (isPlanUpgradeSelection && option.method === 'STRIPE') return
setSelectedMethod(option.method) setSelectedMethod(option.method)
setManualCheckout(null) setManualCheckout(null)
setManualPaymentRequestNumber(null) setManualPaymentRequestNumber(null)
@@ -963,8 +1147,8 @@ export default function SubscriptionPage() {
setEvidenceFiles([]) setEvidenceFiles([])
}} }}
className={`flex items-center gap-2 rounded-xl border-2 px-4 py-2.5 text-sm font-medium ${ 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} {option.method === 'STRIPE' ? 'Stripe' : option.method === 'BANK_TRANSFER' ? copy.bankTransfer : copy.check}
</button> </button>
@@ -975,12 +1159,14 @@ export default function SubscriptionPage() {
{isManualMethod && !manualCheckout ? manualDetailsForm : null} {isManualMethod && !manualCheckout ? manualDetailsForm : null}
{/* Checkout CTA */} {/* 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> <div>
<p className="text-sm text-slate-500">{copy.total}</p> <p className="text-sm text-slate-500 dark:text-zinc-400">{copy.total}</p>
<p className="text-xl font-black text-slate-900"> <p className="text-xl font-black text-slate-900 dark:text-zinc-100">
{planPrice ? formatCurrency(planPrice, 'MAD') : '—'} {payableAmount ? formatCurrency(payableAmount, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span> {!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> </p>
</div> </div>
<button <button
@@ -989,6 +1175,8 @@ export default function SubscriptionPage() {
paying paying
|| loading || loading
|| !paymentOptions.some((option) => option.method === selectedMethod && option.enabled) || !paymentOptions.some((option) => option.method === selectedMethod && option.enabled)
|| isInvalidActiveUpgradeSelection
|| activeUpgradeManualRequired
|| (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0)) || (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0))
} }
className="btn-primary px-8 py-3" className="btn-primary px-8 py-3"
@@ -996,13 +1184,13 @@ export default function SubscriptionPage() {
{paying {paying
? (selectedMethod === 'STRIPE' ? copy.redirecting : copy.submittingReview) ? (selectedMethod === 'STRIPE' ? copy.redirecting : copy.submittingReview)
: selectedMethod === 'STRIPE' : selectedMethod === 'STRIPE'
? (subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow) ? (isActiveSubscription ? copy.planUpgrade : copy.subscribeNow)
: copy.submitPaymentEvidence} : (isActiveSubscription ? copy.planUpgrade : copy.submitPaymentEvidence)}
</button> </button>
</div> </div>
{paymentSubmission && paymentSubmission.status !== 'DRAFT' && !manualCheckout ? ( {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> <p className="font-semibold">{copy.awaitingVerification}</p>
{manualPaymentRequestNumber ? ( {manualPaymentRequestNumber ? (
<p className="mt-1"> <p className="mt-1">
@@ -1015,33 +1203,33 @@ export default function SubscriptionPage() {
) : null} ) : null}
{manualCheckout ? ( {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 className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<p className="font-semibold text-amber-900">{copy.awaitingVerification}</p> <p className="font-semibold text-amber-900 dark:text-amber-100">{copy.awaitingVerification}</p>
<p className="mt-1 text-sm text-amber-800"> <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')} {copy.invoiceNumber}: {manualCheckout.invoice.invoiceNumber ?? manualCheckout.invoice.id} · {formatCurrency(manualCheckout.invoice.amountDue || manualCheckout.invoice.amount, 'MAD')}
</p> </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() : '—'} {copy.dueDate}: {manualCheckout.invoice.dueAt ? new Date(manualCheckout.invoice.dueAt).toLocaleDateString() : '—'}
</p> </p>
</div> </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>
<div className="mt-4 rounded-xl bg-white/80 p-4"> <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">{copy.paymentInstructions}</p> <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 sm:grid-cols-2"> <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]) => ( {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> </dl>
</div> </div>
{paymentSubmission && paymentSubmission.status !== 'DRAFT' ? ( {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="font-semibold">{copy.evidenceSubmitted}</p>
<p className="mt-1">{paymentSubmission.status} · {paymentSubmission.documents.length} file(s)</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>
) : ( ) : (
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
@@ -1062,16 +1250,16 @@ export default function SubscriptionPage() {
{communicationSettings ? ( {communicationSettings ? (
<div className="card p-6"> <div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.communicationTitle}</h3> <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">{copy.communicationHelp}</p> <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 className="mt-5 grid gap-5 lg:grid-cols-3">
<div> <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"> <div className="mt-2 flex flex-wrap gap-3">
{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => { {(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => {
const checked = communicationSettings.enabledCommunicationLocales.includes(locale) const checked = communicationSettings.enabledCommunicationLocales.includes(locale)
return ( 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 <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
@@ -1095,30 +1283,30 @@ export default function SubscriptionPage() {
})} })}
</div> </div>
</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} {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>)} {communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select> </select>
</label> </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} {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> </label>
</div> </div>
<div className="mt-5 space-y-3"> <div className="mt-5 space-y-3">
{communicationSettings.contacts.map((contact, index) => ( {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> <div>
<p className="text-sm font-medium text-slate-900">{contact.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">{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : '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> </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} {copy.contactLanguage}
<select <select
value={contact.locale ?? ''} 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)} 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> <option value="">{copy.inheritDefault}</option>
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)} {communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
@@ -1135,29 +1323,29 @@ export default function SubscriptionPage() {
{/* Invoice history */} {/* Invoice history */}
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200"> <div className="px-6 py-4 border-b border-slate-200 dark:border-zinc-800">
<h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3> <h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.invoiceHistory}</h3>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-slate-50 border-b border-slate-200"> <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">{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.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 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">{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.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 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">{copy.paid}</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">{copy.amount}</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> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100 dark:divide-zinc-800">
{loading ? ( {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 ? ( ) : 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) => ( ) : invoices.map((inv) => (
<tr key={inv.id}> <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 <a
href={`${resolveApiBase()}/subscriptions/invoices/${inv.id}/pdf`} href={`${resolveApiBase()}/subscriptions/invoices/${inv.id}/pdf`}
target="_blank" target="_blank"
@@ -1167,17 +1355,17 @@ export default function SubscriptionPage() {
{inv.invoiceNumber ?? copy.invoice} {inv.invoiceNumber ?? copy.invoice}
</a> </a>
</td> </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 dark:text-zinc-300">{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">{inv.paymentProvider}</td>
<td className="px-6 py-4"> <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'}`}> <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} {copy.invoiceStatusLabels[inv.status] ?? inv.status}
</span> </span>
</td> </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() : '—'} {inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
</td> </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')} {formatCurrency(inv.amount, 'MAD')}
</td> </td>
</tr> </tr>
+5 -5
View File
@@ -184,12 +184,12 @@ describe('dashboard apiFetch', () => {
credentials: 'include', credentials: 'include',
})) }))
expect(resolveRealtimeSocketTarget()).toEqual({ expect(resolveRealtimeSocketTarget()).toEqual({
origin: 'http://localhost:3000', origin: 'http://localhost:4000',
path: '/dashboard/socket.io', 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() installBrowser()
setBrowserHostname('rentaldrivego.ma') setBrowserHostname('rentaldrivego.ma')
;(globalThis.window as any).location.origin = 'https://rentaldrivego.ma' ;(globalThis.window as any).location.origin = 'https://rentaldrivego.ma'
@@ -198,8 +198,8 @@ describe('dashboard apiFetch', () => {
const api = await import('./api') const api = await import('./api')
expect(api.resolveRealtimeSocketTarget()).toEqual({ expect(api.resolveRealtimeSocketTarget()).toEqual({
origin: 'https://rentaldrivego.ma', origin: 'https://api.rentaldrivego.ma',
path: '/dashboard/socket.io', path: '/socket.io',
}) })
}) })
+21 -6
View File
@@ -55,14 +55,29 @@ export function resolveApiOrigin(): string | null {
export function resolveRealtimeSocketTarget(): { origin: string; path: string } | null { export function resolveRealtimeSocketTarget(): { origin: string; path: string } | null {
if (typeof window === 'undefined') return null if (typeof window === 'undefined') return null
const apiBase = resolveApiBase() const configuredApiBase = process.env.NEXT_PUBLIC_API_URL
const isDashboardProxy = apiBase === DASHBOARD_PROXY_API_BASE || apiBase.startsWith(`${DASHBOARD_PROXY_API_BASE}/`) if (configuredApiBase && !configuredApiBase.startsWith('/')) {
const origin = isDashboardProxy ? window.location.origin : resolveApiOrigin() try {
if (!origin) return null 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 { return {
origin, origin: resolveApiOrigin() ?? window.location.origin,
path: isDashboardProxy ? '/dashboard/socket.io' : '/socket.io', path: '/socket.io',
} }
} }
@@ -0,0 +1,508 @@
# Subscription Upgrade Implementation Plan
## 1. Purpose
Implement a safe subscription-upgrade workflow for companies that already have an active plan and pay manually. The workflow must calculate a defensible upgrade amount, collect payment evidence, require platform-admin verification, activate the new plan without losing history, and communicate in the company's selected language: English (`en`), French (`fr`), or Arabic (`ar`).
## 2. Product Policy
### 2.1 Supported upgrade options
| Option | Billing treatment | Activation | Renewal date | Availability |
|---|---|---|---|---|
| Upgrade now — prorated | Charge the net price difference for the unused part of the current term | After payment verification | Unchanged | Primary self-service option |
| Upgrade at renewal | No immediate charge; schedule the new plan for the next term | At renewal | Existing renewal date | Secondary self-service option |
| Upgrade now — reset term | Credit eligible unused current-plan value and begin a full new term | After payment verification | Reset to activation date | Platform-admin assisted |
| Temporary add-on | Keep the current plan and charge for a defined feature or limit | After payment verification | Unchanged | Out of scope until add-on rules exist |
### 2.2 Mandatory rules
1. A company may upgrade only from an active or grace-period subscription to a higher eligible plan.
2. A submitted receipt is evidence, not proof of cleared payment.
3. Upgraded access is granted only after a platform admin verifies payment.
4. The existing subscription record and plan history must never be overwritten or deleted.
5. Only one non-terminal upgrade request may exist per subscription.
6. An issued quote is immutable. If it expires, create a new quote using current inputs.
7. Upgrades, renewals, downgrades, add-ons, and plan changes made by platform admins must use distinct actions and audit events.
8. A term reset requires explicit company confirmation because it changes the renewal date.
9. Only company billing contacts and company admins receive customer payment communications. Do not notify every company user.
10. All customer-facing communication uses the company's selected supported language. Arabic content must render in true RTL.
## 3. Scope
### In scope
- Immediate prorated upgrade.
- Upgrade scheduled for renewal.
- Manual payment instructions and receipt upload.
- Platform-admin review, approval, rejection, and request-for-correction actions.
- Plan-entitlement activation after verified payment.
- EN, FR, and AR user interfaces and notifications.
- Quote expiry, cancellation, audit history, idempotency, and reporting.
- Migration-safe data changes and automated/manual testing.
### Out of scope for the first release
- Automatic card or bank collection.
- Automatic refunds or cash credits.
- Downgrades.
- Temporary add-ons unless pricing, duration, renewal, and removal behavior are separately defined.
- Mid-upgrade currency changes.
- Self-service term resets.
- Changing the billing cadence during a standard prorated upgrade.
## 4. Roles and Permissions
| Action | Company user | Billing contact | Company admin | Platform admin |
|---|---:|---:|---:|---:|
| View current plan and available upgrades | Optional | Yes | Yes | Yes |
| Request an upgrade | No | Yes | Yes | Yes, on behalf of company |
| View quote and payment instructions | No | Yes | Yes | Yes |
| Upload or replace payment evidence | No | Yes | Yes | Yes |
| Cancel an unpaid request | No | Yes | Yes | Yes |
| Verify or reject payment | No | No | No | Yes |
| Activate an upgrade | No | No | No | System after verification |
| Override price, eligibility, or effective date | No | No | No | Separate privileged permission plus reason |
| View internal review notes | No | No | No | Yes |
Enforce permissions on the server. Hiding a control in the UI is not authorization.
## 5. Upgrade Eligibility
The eligibility service must check all of the following before showing or quoting a target plan:
- The subscription exists and belongs to the requesting company.
- Subscription status is `active` or an explicitly supported `grace_period` state.
- The target plan is active, sellable, and ranked above the current plan.
- Current and target plans use the same currency and billing cadence for the standard prorated path.
- The target plan supports the company's region and contractual conditions.
- No non-terminal upgrade request already exists.
- The subscription is not suspended for fraud, legal, or administrative reasons.
- There is enough time remaining to justify an immediate upgrade under the configured minimum-charge policy.
- Any company-specific contract or negotiated pricing is supported by the quote engine; otherwise route to platform-admin assistance.
Never infer that a more expensive plan is necessarily an upgrade. Use an explicit plan transition matrix or plan rank plus compatibility rules.
## 6. Pricing and Proration
### 6.1 Standard formula
Use day-based proration for a same-cadence immediate upgrade:
```text
term_days = renewal_date - term_start_date
remaining_days = renewal_date - effective_date
remaining_new_plan_value = target_net_term_price × remaining_days ÷ term_days
remaining_current_plan_credit = eligible_current_net_term_price × remaining_days ÷ term_days
upgrade_subtotal = max(0, remaining_new_plan_value - remaining_current_plan_credit)
upgrade_total = upgrade_subtotal + taxes + explicit_fees
```
For a current annual price of $1,200, a target annual price of $2,400, and exactly half the term remaining:
```text
($2,400 - $1,200) × 0.5 = $600
```
### 6.2 Pricing rules
- Store money as integer minor units and use a fixed decimal/rounding policy. Never use binary floating point for billing.
- Use the subscription's actual eligible net price, not merely the current public catalog price.
- Decide explicitly whether current-plan discounts carry to the new plan. Default: they do not carry unless contractually marked transferable.
- Calculate tax using the legally required point and method. Do not assume the simple price difference is tax-complete.
- Define whether the effective date is the quote date, payment-received date, or approval date. Recommended: approval timestamp, with the quote calculated through a clearly stated valid-until date and recalculated if expired.
- If partial-day precision is unnecessary, use the company's billing timezone and calendar dates consistently. Do not mix UTC timestamps with local-date proration.
- Set a minimum charge and rounding rule per currency. If the calculated charge is below the minimum, offer upgrade at renewal or allow a documented admin waiver.
- Store every pricing input and output in the quote snapshot so later catalog or tax changes cannot rewrite history.
### 6.3 Quote contents
Each quote must show and store:
- Current plan and target plan.
- Current term start and renewal date.
- Proposed activation method.
- Proration dates and fraction.
- Current-plan eligible credit.
- Target-plan remaining value.
- Discounts, taxes, fees, subtotal, and total.
- Currency and rounding result.
- Quote creation and expiry timestamps.
- Price-list, tax-rule, and calculation-version identifiers.
- Terms accepted by the requesting user.
## 7. State Model
### 7.1 Upgrade request states
| State | Meaning | Allowed next states |
|---|---|---|
| `draft` | Target plan selected; quote not submitted | `quoted`, `cancelled` |
| `quoted` | Immutable quote issued | `payment_pending`, `expired`, `cancelled` |
| `payment_pending` | Company accepted quote and received payment instructions | `payment_review`, `expired`, `cancelled` |
| `payment_review` | Evidence submitted for platform review | `correction_required`, `approved`, `rejected`, `expired` |
| `correction_required` | Evidence or payment details need correction | `payment_review`, `expired`, `cancelled` |
| `approved` | Payment verified and activation transaction authorized | `activated`, `activation_failed` |
| `activated` | New plan and entitlements are active | Terminal |
| `scheduled` | Upgrade is scheduled for renewal | `activated`, `cancelled`, `superseded` |
| `rejected` | Payment or request rejected | Terminal |
| `expired` | Quote/request validity elapsed | Terminal; requote creates a new request |
| `cancelled` | Cancelled before activation | Terminal |
| `activation_failed` | Payment approved but activation did not complete | `activated` through idempotent retry or manual incident resolution |
| `superseded` | Scheduled request replaced by a newer valid request | Terminal |
Do not encode upgrade progress as the primary subscription status. Keep the subscription active on its existing plan while the upgrade request is pending.
### 7.2 Transition controls
- Every transition must validate the current state to prevent stale or duplicate actions.
- Approval and activation must be idempotent.
- Only the activation transaction may change the active plan and entitlements.
- `approved` must not become `rejected`; use a reversal/incident process if an approval was mistaken.
- Scheduled upgrades must be revalidated before renewal activation.
## 8. Data Model
### 8.1 `subscription_upgrade_requests`
- `id`
- `company_id`
- `subscription_id`
- `request_type` (`immediate_prorated`, `at_renewal`, `term_reset`)
- `from_plan_id`, `from_plan_version_id`
- `to_plan_id`, `to_plan_version_id`
- `status`
- `requested_by_user_id`
- `requested_at`
- `effective_at`
- `scheduled_for`
- `quote_id`
- `company_language_snapshot`
- `billing_timezone_snapshot`
- `accepted_terms_version`
- `accepted_at`
- `cancelled_at`, `cancelled_by`, `cancellation_reason`
- `expires_at`
- optimistic-lock/version column
- created/updated timestamps
### 8.2 `subscription_upgrade_quotes`
- `id`, `upgrade_request_id`
- currency
- current eligible net term price
- target net term price
- term start, renewal date, pricing effective date
- term days, remaining days, proration numerator/denominator
- current credit, target remaining value
- discounts, taxes, fees, subtotal, total in minor units
- calculation version and serialized input snapshot
- created/expired timestamps
- immutable hash or integrity field if supported
### 8.3 Payment evidence and review
Reuse the existing manual-payment model when possible. It must relate evidence to the exact upgrade request and quote, and record:
- Evidence file metadata and secure storage reference.
- Amount claimed, currency, payment method, reference number, and claimed payment date.
- Uploader and upload time.
- Review status, reviewer, review time, internal notes, and rejection/correction reason.
- Verified amount and currency.
- Malware-scan status and access controls.
Do not store sensitive full bank-account or payment-card data in notes or uploaded filenames.
### 8.4 `subscription_plan_history`
- subscription, company, previous plan/version, new plan/version
- change type (`upgrade`)
- source upgrade request and approved payment references
- effective timestamp
- previous and new term dates
- actor and approving platform admin
- entitlement snapshot or version references
- audit correlation ID
### 8.5 Constraints and indexes
- Partial unique constraint: one non-terminal upgrade request per subscription.
- Unique activation key per upgrade request.
- Foreign keys to company, subscription, immutable plan versions, quote, and payment review.
- Indexes for company/status, subscription/status, expiry, scheduled activation, and admin review queue.
## 9. Backend Services and API
### 9.1 Services
- Eligibility service: returns permitted plan transitions and reasons for ineligibility.
- Quote service: calculates and freezes price snapshots.
- Upgrade workflow service: validates transitions and authorization.
- Manual-payment service: issues instructions and manages evidence/review.
- Activation service: atomically changes plan history, active plan/version, entitlements, and audit events.
- Notification service: selects recipients, language, template, and channel.
- Expiry/scheduling worker: expires quotes and activates renewal-scheduled upgrades safely.
### 9.2 Suggested endpoints
```text
GET /api/subscriptions/{id}/upgrade-options
POST /api/subscriptions/{id}/upgrade-quotes
POST /api/upgrade-requests/{id}/accept
POST /api/upgrade-requests/{id}/payment-evidence
POST /api/upgrade-requests/{id}/cancel
GET /api/upgrade-requests/{id}
GET /api/admin/upgrade-requests?status=payment_review
POST /api/admin/upgrade-requests/{id}/request-correction
POST /api/admin/upgrade-requests/{id}/approve-payment
POST /api/admin/upgrade-requests/{id}/reject-payment
POST /api/admin/upgrade-requests/{id}/retry-activation
```
Mutation endpoints must accept an idempotency key and reject stale version numbers.
### 9.3 Activation transaction
In one database transaction or equivalent consistency boundary:
1. Lock the upgrade request and subscription.
2. Confirm request state is `approved` and not already activated.
3. Recheck company/subscription ownership and target-plan validity.
4. Write the subscription plan-history record.
5. Set the active immutable plan version.
6. Apply new entitlements and limits.
7. Preserve or reset term dates according to the accepted request type.
8. Mark the request `activated`.
9. Write audit/outbox events.
Send notifications after commit through an outbox/queue. A failed email must not roll back an activated subscription.
## 10. User Experience
### 10.1 Company flow
1. Billing contact/admin opens Billing > Current plan.
2. System displays eligible higher plans and a comparison of entitlements and limits.
3. User selects `Upgrade now` or `Upgrade at renewal`.
4. System displays a transparent quote, effective-date policy, renewal-date effect, and expiry.
5. User accepts the upgrade terms.
6. For immediate upgrade, system displays localized manual-payment instructions.
7. User uploads payment evidence and can see review status.
8. Platform admin reviews payment.
9. After approval, the system activates the plan and displays confirmation, effective date, next renewal date, and new limits.
The confirmation screen must not imply immediate access before payment verification.
### 10.2 Platform-admin flow
- Review queue sorted by aging and quote expiry risk.
- Company, current/target plan, quote breakdown, claimed and expected payment, evidence, and previous attempts visible in one view.
- Actions: approve, request correction, reject, or open company/contact record.
- Approval requires confirmation of amount, currency, cleared-payment reference, and reviewer identity.
- Overrides require elevated permission and a mandatory reason.
- Approved-but-failed activations appear in a separate incident queue and must not invite a second approval.
### 10.3 Accessibility and responsiveness
- Keyboard-accessible comparison, forms, uploads, dialogs, and admin actions.
- Visible focus, descriptive validation, status announcements, and sufficient contrast.
- Do not encode status by color alone.
- Responsive layouts for company and admin flows.
- Currency, dates, and numbers formatted for the selected locale while stored canonically.
## 11. Localization and Notifications
### 11.1 Language selection
- Resolve the company's selected communication language at request creation and store a snapshot.
- Use only `en`, `fr`, or `ar`; define a deliberate fallback, recommended `en`, and log missing translations.
- Use company language for shared company communications. If future requirements demand per-recipient language, make that a separate policy decision.
- Arabic templates, PDFs, email layouts, and in-app screens must use RTL layout, not merely translated strings.
### 11.2 Notification events
| Event | Company billing contact/admin | Platform admin |
|---|---|---|
| Quote created | In-app + email | Optional in-app |
| Payment instructions issued | In-app + email | Optional in-app |
| Evidence submitted | In-app confirmation | In-app queue notification |
| Correction required | In-app + email | In-app status |
| Payment rejected | In-app + email | In-app status |
| Upgrade activated | In-app + email | In-app confirmation |
| Quote/request expiring soon | In-app + email | In-app for aging requests |
| Quote/request expired | In-app + email | In-app status |
| Activation failed after approval | Generic processing message only | Urgent in-app + operational alert |
Templates must include company name, current and target plans, amount/currency where appropriate, quote expiry, effective-date rule, renewal-date effect, and a safe deep link. Never attach raw payment evidence to email.
## 12. Limits and Entitlements
Define behavior for every entitlement before implementation:
- Higher quantitative limits become available only at activation.
- Feature flags follow the immutable target plan version.
- Existing usage is preserved.
- No destructive data migration may occur during an upgrade.
- Cache invalidation must occur immediately after commit.
- Active sessions must receive updated authorization without requiring an unsafe manual workaround.
- If entitlement propagation fails, mark activation as failed or partially failed according to a defined recovery policy; do not silently claim success.
## 13. Expiry, Cancellation, and Recovery
- Recommended quote validity: configurable, initially 7 calendar days, capped by renewal date.
- Send an expiry warning 48 hours before expiry when the request is still actionable.
- Expiry workers must be idempotent and must not expire an already approved request.
- Company cancellation is allowed only before approval/activation and does not delete history or evidence.
- Requoting creates a new request or quote version linked to the expired one; it never mutates the expired quote.
- A rejected payment cannot be reopened silently. Start a new review attempt or request according to the audit policy.
- Activation retry uses the same activation key and cannot create duplicate plan history.
- Define an operational reversal procedure for mistaken approval; do not improvise a downgrade through the upgrade endpoint.
## 14. Security and Audit
- Validate company tenancy on every company endpoint.
- Apply least-privilege permissions to payment evidence and internal notes.
- Restrict file type and size; scan uploads before review/download.
- Use signed, short-lived file access rather than public URLs.
- Rate-limit quote generation, evidence uploads, and admin mutations.
- Record actor, timestamp, previous state, next state, reason, IP/session metadata where permitted, and correlation ID for every material action.
- Make financial/audit records append-only where practical.
- Redact sensitive data from logs, analytics, and notification payloads.
- Define retention rules for evidence and audit data according to applicable law and contract requirements.
## 15. Observability and Reporting
Track at minimum:
- Upgrade options viewed.
- Quote creation, acceptance, expiry, and cancellation rates.
- Payment-review turnaround time.
- Correction and rejection reasons.
- Approval-to-activation latency.
- Activation failures and retries.
- Upgrade conversion by current plan, target plan, cadence, language, and company segment.
- Incremental recurring/contract value, calculated from plan history rather than UI analytics.
Alert on approved requests stuck before activation, repeated worker failures, duplicate-transition errors, unusual override volume, and notification failure spikes.
## 16. Testing Strategy
### 16.1 Unit tests
- Full, half, one-day, leap-year, and boundary-date proration.
- Currency rounding and minimum-charge rules.
- Discounts, taxes, fees, and negotiated-price eligibility.
- Valid and invalid plan transitions.
- State-transition guards and idempotency.
- Language selection and fallback.
### 16.2 Integration tests
- Quote snapshot remains unchanged after catalog price changes.
- Duplicate active request is rejected under concurrency.
- Evidence upload is tied to the correct company, request, and quote.
- Only authorized platform admins can approve.
- Approval triggers exactly one activation.
- Activation atomically writes plan history and entitlements.
- Notification failures do not roll back activation.
- Expiry and scheduled-renewal workers are idempotent.
- Stale admin screens cannot approve an already changed request.
### 16.3 End-to-end scenarios
1. Immediate annual upgrade with exact half-term proration.
2. Immediate upgrade near renewal using minimum-charge behavior.
3. Upgrade at renewal, then successful scheduled activation.
4. Correction required, replacement evidence, approval, and activation.
5. Rejected evidence with no entitlement change.
6. Quote expires before approval and requires requoting.
7. Two admins approve concurrently; only one activation occurs.
8. Company changes language after quote; existing communication follows the documented snapshot policy.
9. Arabic flow verifies translations, RTL layout, dates, currency, email, and PDF/payment instructions.
10. Activation fails after approval and succeeds through idempotent retry.
11. Unauthorized user and cross-tenant access attempts are denied.
12. Catalog plan changes while a quote is pending; frozen quote and plan versions remain reproducible.
### 16.4 Manual UI testing
- Desktop, tablet, and mobile widths.
- EN/FR LTR and AR RTL.
- Light and dark themes if supported by the product.
- Keyboard-only and screen-reader-critical flows.
- Long French and Arabic content, large currency values, error messages, empty states, upload progress, and slow network behavior.
## 17. Delivery Phases
### Phase 1 — Policy and contract decisions
- Approve proration basis, effective-date rule, quote validity, minimum charges, discount transfer, tax treatment, cancellation, and admin override policy.
- Define eligible plan transition matrix.
- Inventory current subscription, plan, payment, role, localization, notification, and entitlement architecture.
Exit criterion: no unresolved decision can change stored financial amounts, renewal dates, or activation authorization.
### Phase 2 — Data and domain foundation
- Add upgrade request, quote, plan history, payment linkage, constraints, indexes, and migrations.
- Implement state machine, eligibility, quote calculation, and audit events.
- Add backfill only where required; do not fabricate historical quotes.
Exit criterion: domain and migration tests pass, including rollback/forward compatibility.
### Phase 3 — Company workflow
- Build plan comparison, quote review, acceptance, payment instructions, evidence upload, status tracking, cancellation, and scheduled-upgrade flow.
- Add EN/FR/AR copy and RTL behavior.
Exit criterion: a company admin can complete each allowed path without platform-admin database intervention.
### Phase 4 — Platform-admin workflow
- Build review queue, evidence review, correction, rejection, approval, override controls, audit view, and activation incident queue.
Exit criterion: payment review and recovery are fully operable through authorized product interfaces.
### Phase 5 — Activation and communication
- Implement atomic activation, entitlement refresh, outbox processing, notifications, expiry worker, and scheduled-renewal activation.
Exit criterion: exactly-once business effects are demonstrated under retries and concurrent actions.
### Phase 6 — Hardening and release
- Complete security review, accessibility audit, localization QA, performance tests, operational dashboards, alerts, runbook, and staged rollout.
- Pilot with internal/test companies before general availability.
Exit criterion: all release blockers are closed and rollback/recovery has been rehearsed.
## 18. Definition of Done
- All product-policy decisions in Phase 1 are documented and approved.
- Immediate prorated and renewal-scheduled upgrades work for eligible subscriptions.
- Financial calculations are reproducible from immutable snapshots.
- No upgraded feature is accessible before verified payment and successful activation.
- Subscription and plan history are preserved and auditable.
- Duplicate requests, duplicate approvals, and duplicate activations are prevented.
- EN, FR, and AR flows are complete; Arabic is verified RTL.
- Correct recipients receive localized notifications; ordinary company users do not receive billing notices.
- Security, accessibility, automated, and manual tests pass.
- Monitoring, alerts, support documentation, and recovery runbook are operational.
- Release can be rolled back without corrupting active subscriptions or losing approved-payment records.
## 19. Decisions Required Before Coding
1. Is proration based on calendar days, exact timestamps, or whole billing months? Recommendation: calendar days in the billing timezone.
2. Which date controls the price: quote creation, claimed payment, cleared payment, or admin approval? Recommendation: quote is valid through a fixed expiry; after expiry, requote.
3. How long is a quote valid? Recommendation: 7 days, capped at renewal.
4. Are taxes included in displayed plan prices, and how is tax recomputed for upgrades?
5. Do negotiated discounts transfer to target plans? Recommendation: only when explicitly marked transferable.
6. What is the minimum charge per currency?
7. Can grace-period subscriptions upgrade, or must overdue balances be cleared first? Recommendation: clear overdue balances first unless a platform admin approves a combined settlement.
8. Can platform admins override the calculated amount? Recommendation: only with a separate permission, reason, and audit event.
9. What happens if the company pays after quote expiry? Recommendation: do not activate automatically; reconcile payment against a new approved quote.
10. Does the company language snapshot remain fixed for the request, or follow later company-language changes? Recommendation: snapshot financial documents; use the current company language for later status notifications only if this distinction is clearly implemented.
These are blocking business rules, not implementation details. Coding before resolving them risks incorrect charges, disputed renewal dates, and unauthorized access.
@@ -0,0 +1,179 @@
CREATE TYPE "SubscriptionUpgradeRequestType" AS ENUM (
'IMMEDIATE_PRORATED',
'AT_RENEWAL',
'TERM_RESET'
);
CREATE TYPE "SubscriptionUpgradeRequestStatus" AS ENUM (
'DRAFT',
'QUOTED',
'PAYMENT_PENDING',
'PAYMENT_REVIEW',
'CORRECTION_REQUIRED',
'APPROVED',
'ACTIVATED',
'SCHEDULED',
'REJECTED',
'EXPIRED',
'CANCELLED',
'ACTIVATION_FAILED',
'SUPERSEDED'
);
CREATE TABLE "subscription_upgrade_requests" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"requestType" "SubscriptionUpgradeRequestType" NOT NULL,
"fromPlan" "Plan" NOT NULL,
"fromBillingPeriod" "BillingPeriod" NOT NULL,
"toPlan" "Plan" NOT NULL,
"toBillingPeriod" "BillingPeriod" NOT NULL,
"status" "SubscriptionUpgradeRequestStatus" NOT NULL DEFAULT 'DRAFT',
"requestedByEmployeeId" TEXT NOT NULL,
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"effectiveAt" TIMESTAMP(3),
"scheduledFor" TIMESTAMP(3),
"quoteId" TEXT,
"companyLanguageSnapshot" TEXT NOT NULL DEFAULT 'en',
"billingTimezoneSnapshot" TEXT NOT NULL DEFAULT 'Africa/Casablanca',
"acceptedTermsVersion" TEXT,
"acceptedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"cancelledByEmployeeId" TEXT,
"cancellationReason" TEXT,
"expiresAt" TIMESTAMP(3),
"billingInvoiceId" TEXT,
"approvedPaymentAttemptId" TEXT,
"approvedByAdminId" TEXT,
"approvedAt" TIMESTAMP(3),
"activationKey" TEXT,
"activatedAt" TIMESTAMP(3),
"rejectionReason" TEXT,
"correctionReason" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscription_upgrade_requests_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "subscription_upgrade_quotes" (
"id" TEXT NOT NULL,
"upgradeRequestId" TEXT NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MAD',
"currentEligibleNetTermPrice" INTEGER NOT NULL,
"targetNetTermPrice" INTEGER NOT NULL,
"termStart" TIMESTAMP(3) NOT NULL,
"renewalDate" TIMESTAMP(3) NOT NULL,
"pricingEffectiveAt" TIMESTAMP(3) NOT NULL,
"termDays" INTEGER NOT NULL,
"remainingDays" INTEGER NOT NULL,
"prorationNumerator" INTEGER NOT NULL,
"prorationDenominator" INTEGER NOT NULL,
"currentCredit" INTEGER NOT NULL,
"targetRemainingValue" INTEGER NOT NULL,
"discountAmount" INTEGER NOT NULL DEFAULT 0,
"taxAmount" INTEGER NOT NULL DEFAULT 0,
"feeAmount" INTEGER NOT NULL DEFAULT 0,
"subtotalAmount" INTEGER NOT NULL,
"totalAmount" INTEGER NOT NULL,
"calculationVersion" TEXT NOT NULL,
"inputSnapshot" JSONB NOT NULL DEFAULT '{}',
"integrityHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"expiredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_upgrade_quotes_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "subscription_plan_history" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"previousPlan" "Plan" NOT NULL,
"previousBillingPeriod" "BillingPeriod" NOT NULL,
"newPlan" "Plan" NOT NULL,
"newBillingPeriod" "BillingPeriod" NOT NULL,
"changeType" TEXT NOT NULL,
"sourceUpgradeRequestId" TEXT,
"approvedPaymentAttemptId" TEXT,
"effectiveAt" TIMESTAMP(3) NOT NULL,
"previousTermStart" TIMESTAMP(3),
"previousTermEnd" TIMESTAMP(3),
"newTermStart" TIMESTAMP(3),
"newTermEnd" TIMESTAMP(3),
"actorType" TEXT NOT NULL,
"actorId" TEXT,
"approvingAdminId" TEXT,
"entitlementSnapshot" JSONB NOT NULL DEFAULT '{}',
"auditCorrelationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_plan_history_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "subscription_upgrade_requests_quoteId_key" ON "subscription_upgrade_requests"("quoteId");
CREATE UNIQUE INDEX "subscription_upgrade_requests_billingInvoiceId_key" ON "subscription_upgrade_requests"("billingInvoiceId");
CREATE UNIQUE INDEX "subscription_upgrade_requests_activationKey_key" ON "subscription_upgrade_requests"("activationKey");
CREATE UNIQUE INDEX "subscription_plan_history_sourceUpgradeRequestId_key" ON "subscription_plan_history"("sourceUpgradeRequestId");
CREATE INDEX "subscription_upgrade_requests_companyId_status_idx" ON "subscription_upgrade_requests"("companyId", "status");
CREATE INDEX "subscription_upgrade_requests_subscriptionId_status_idx" ON "subscription_upgrade_requests"("subscriptionId", "status");
CREATE INDEX "subscription_upgrade_requests_expiresAt_idx" ON "subscription_upgrade_requests"("expiresAt");
CREATE INDEX "subscription_upgrade_requests_scheduledFor_idx" ON "subscription_upgrade_requests"("scheduledFor");
CREATE INDEX "subscription_upgrade_quotes_upgradeRequestId_idx" ON "subscription_upgrade_quotes"("upgradeRequestId");
CREATE INDEX "subscription_upgrade_quotes_expiresAt_idx" ON "subscription_upgrade_quotes"("expiresAt");
CREATE INDEX "subscription_plan_history_subscriptionId_idx" ON "subscription_plan_history"("subscriptionId");
CREATE INDEX "subscription_plan_history_companyId_idx" ON "subscription_plan_history"("companyId");
CREATE INDEX "subscription_plan_history_changeType_idx" ON "subscription_plan_history"("changeType");
CREATE UNIQUE INDEX "subscription_upgrade_requests_one_non_terminal_per_subscription"
ON "subscription_upgrade_requests"("subscriptionId")
WHERE "status" IN (
'DRAFT',
'QUOTED',
'PAYMENT_PENDING',
'PAYMENT_REVIEW',
'CORRECTION_REQUIRED',
'APPROVED',
'SCHEDULED',
'ACTIVATION_FAILED'
);
ALTER TABLE "subscription_upgrade_requests"
ADD CONSTRAINT "subscription_upgrade_requests_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "subscription_upgrade_requests"
ADD CONSTRAINT "subscription_upgrade_requests_subscriptionId_fkey"
FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "subscription_upgrade_requests"
ADD CONSTRAINT "subscription_upgrade_requests_requestedByEmployeeId_fkey"
FOREIGN KEY ("requestedByEmployeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "subscription_upgrade_requests"
ADD CONSTRAINT "subscription_upgrade_requests_quoteId_fkey"
FOREIGN KEY ("quoteId") REFERENCES "subscription_upgrade_quotes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "subscription_upgrade_requests"
ADD CONSTRAINT "subscription_upgrade_requests_billingInvoiceId_fkey"
FOREIGN KEY ("billingInvoiceId") REFERENCES "billing_invoices"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "subscription_upgrade_requests"
ADD CONSTRAINT "subscription_upgrade_requests_approvedByAdminId_fkey"
FOREIGN KEY ("approvedByAdminId") REFERENCES "admin_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "subscription_upgrade_quotes"
ADD CONSTRAINT "subscription_upgrade_quotes_upgradeRequestId_fkey"
FOREIGN KEY ("upgradeRequestId") REFERENCES "subscription_upgrade_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "subscription_plan_history"
ADD CONSTRAINT "subscription_plan_history_subscriptionId_fkey"
FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "subscription_plan_history"
ADD CONSTRAINT "subscription_plan_history_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+145
View File
@@ -45,6 +45,28 @@ enum SubscriptionStatus {
UNPAID UNPAID
} }
enum SubscriptionUpgradeRequestType {
IMMEDIATE_PRORATED
AT_RENEWAL
TERM_RESET
}
enum SubscriptionUpgradeRequestStatus {
DRAFT
QUOTED
PAYMENT_PENDING
PAYMENT_REVIEW
CORRECTION_REQUIRED
APPROVED
ACTIVATED
SCHEDULED
REJECTED
EXPIRED
CANCELLED
ACTIVATION_FAILED
SUPERSEDED
}
enum InvoiceStatus { enum InvoiceStatus {
PENDING PENDING
PAID PAID
@@ -618,6 +640,8 @@ model Company {
subscriptionPaymentRef String? subscriptionPaymentRef String?
subscription Subscription? subscription Subscription?
subscriptionUpgradeRequests SubscriptionUpgradeRequest[]
subscriptionPlanHistory SubscriptionPlanHistory[]
billingAccounts BillingAccount[] billingAccounts BillingAccount[]
billingInvoices BillingInvoice[] billingInvoices BillingInvoice[]
brand BrandSettings? brand BrandSettings?
@@ -692,6 +716,8 @@ model Subscription {
billingInvoices BillingInvoice[] billingInvoices BillingInvoice[]
collectionsCases CollectionsCase[] collectionsCases CollectionsCase[]
events SubscriptionEvent[] events SubscriptionEvent[]
upgradeRequests SubscriptionUpgradeRequest[]
planHistory SubscriptionPlanHistory[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -853,6 +879,7 @@ model BillingInvoice {
subscriptionId String? subscriptionId String?
subscription Subscription? @relation(fields: [subscriptionId], references: [id]) subscription Subscription? @relation(fields: [subscriptionId], references: [id])
legacySubscriptionInvoice SubscriptionInvoice? legacySubscriptionInvoice SubscriptionInvoice?
subscriptionUpgradeRequest SubscriptionUpgradeRequest?
invoiceNumber String? @unique invoiceNumber String? @unique
invoiceSequence Int? @unique invoiceSequence Int? @unique
invoiceType BillingInvoiceType invoiceType BillingInvoiceType
@@ -907,6 +934,122 @@ model BillingInvoice {
@@map("billing_invoices") @@map("billing_invoices")
} }
model SubscriptionUpgradeRequest {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
requestType SubscriptionUpgradeRequestType
fromPlan Plan
fromBillingPeriod BillingPeriod
toPlan Plan
toBillingPeriod BillingPeriod
status SubscriptionUpgradeRequestStatus @default(DRAFT)
requestedByEmployeeId String
requestedByEmployee Employee @relation("SubscriptionUpgradeRequester", fields: [requestedByEmployeeId], references: [id])
requestedAt DateTime @default(now())
effectiveAt DateTime?
scheduledFor DateTime?
quoteId String? @unique
quote SubscriptionUpgradeQuote? @relation("ActiveSubscriptionUpgradeQuote", fields: [quoteId], references: [id])
quotes SubscriptionUpgradeQuote[] @relation("SubscriptionUpgradeQuotes")
companyLanguageSnapshot String @default("en")
billingTimezoneSnapshot String @default("Africa/Casablanca")
acceptedTermsVersion String?
acceptedAt DateTime?
cancelledAt DateTime?
cancelledByEmployeeId String?
cancellationReason String?
expiresAt DateTime?
billingInvoiceId String? @unique
billingInvoice BillingInvoice? @relation(fields: [billingInvoiceId], references: [id])
approvedPaymentAttemptId String?
approvedByAdminId String?
approvedByAdmin AdminUser? @relation("SubscriptionUpgradeApprover", fields: [approvedByAdminId], references: [id], onDelete: SetNull)
approvedAt DateTime?
activationKey String? @unique
activatedAt DateTime?
rejectionReason String?
correctionReason String?
version Int @default(1)
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId, status])
@@index([subscriptionId, status])
@@index([expiresAt])
@@index([scheduledFor])
@@map("subscription_upgrade_requests")
}
model SubscriptionUpgradeQuote {
id String @id @default(cuid())
upgradeRequestId String
upgradeRequest SubscriptionUpgradeRequest @relation("SubscriptionUpgradeQuotes", fields: [upgradeRequestId], references: [id], onDelete: Cascade)
activeForUpgradeRequest SubscriptionUpgradeRequest? @relation("ActiveSubscriptionUpgradeQuote")
currency String @default("MAD")
currentEligibleNetTermPrice Int
targetNetTermPrice Int
termStart DateTime
renewalDate DateTime
pricingEffectiveAt DateTime
termDays Int
remainingDays Int
prorationNumerator Int
prorationDenominator Int
currentCredit Int
targetRemainingValue Int
discountAmount Int @default(0)
taxAmount Int @default(0)
feeAmount Int @default(0)
subtotalAmount Int
totalAmount Int
calculationVersion String
inputSnapshot Json @default("{}")
integrityHash String
expiresAt DateTime
expiredAt DateTime?
createdAt DateTime @default(now())
@@index([upgradeRequestId])
@@index([expiresAt])
@@map("subscription_upgrade_quotes")
}
model SubscriptionPlanHistory {
id String @id @default(cuid())
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
previousPlan Plan
previousBillingPeriod BillingPeriod
newPlan Plan
newBillingPeriod BillingPeriod
changeType String
sourceUpgradeRequestId String?
approvedPaymentAttemptId String?
effectiveAt DateTime
previousTermStart DateTime?
previousTermEnd DateTime?
newTermStart DateTime?
newTermEnd DateTime?
actorType String
actorId String?
approvingAdminId String?
entitlementSnapshot Json @default("{}")
auditCorrelationId String
createdAt DateTime @default(now())
@@unique([sourceUpgradeRequestId])
@@index([subscriptionId])
@@index([companyId])
@@index([changeType])
@@map("subscription_plan_history")
}
model BillingInvoiceLineItem { model BillingInvoiceLineItem {
id String @id @default(cuid()) id String @id @default(cuid())
invoiceId String invoiceId String
@@ -1365,6 +1508,7 @@ model Employee {
billingContacts BillingContact[] billingContacts BillingContact[]
manualPaymentSubmissions ManualPaymentSubmission[] manualPaymentSubmissions ManualPaymentSubmission[]
manualPaymentDocuments ManualPaymentDocument[] manualPaymentDocuments ManualPaymentDocument[]
subscriptionUpgradeRequests SubscriptionUpgradeRequest[] @relation("SubscriptionUpgradeRequester")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2290,6 +2434,7 @@ model AdminUser {
ownedBillingAccounts BillingAccount[] @relation("BillingAccountCollectionsOwner") ownedBillingAccounts BillingAccount[] @relation("BillingAccountCollectionsOwner")
confirmedManualPayments BillingPaymentAttempt[] @relation("ManualPaymentConfirmingAdmin") confirmedManualPayments BillingPaymentAttempt[] @relation("ManualPaymentConfirmingAdmin")
reviewedManualSubmissions ManualPaymentSubmission[] @relation("ManualPaymentReviewingAdmin") reviewedManualSubmissions ManualPaymentSubmission[] @relation("ManualPaymentReviewingAdmin")
approvedSubscriptionUpgrades SubscriptionUpgradeRequest[] @relation("SubscriptionUpgradeApprover")
ownedCollectionsCases CollectionsCase[] @relation("CollectionsCaseOwner") ownedCollectionsCases CollectionsCase[] @relation("CollectionsCaseOwner")
assignedCollectionsTasks CollectionsCallTask[] @relation("CollectionsTaskAssignee") assignedCollectionsTasks CollectionsCallTask[] @relation("CollectionsTaskAssignee")
completedCollectionsTasks CollectionsCallTask[] @relation("CollectionsTaskCompleter") completedCollectionsTasks CollectionsCallTask[] @relation("CollectionsTaskCompleter")