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