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