Files
carmanagement/apps/api/src/modules/subscriptions/subscription.manual.service.ts
T
root fe8ffbeb9f
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 48s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
update payment method, remove stripe, paypal, amanapay
2026-08-17 21:05:29 -04:00

1413 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'crypto'
import { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../../lib/prisma'
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
import {
PAYMENT_EVIDENCE_MAX_FILES,
PAYMENT_EVIDENCE_MAX_TOTAL_SIZE,
assertPaymentEvidenceFile,
sanitizeEvidenceFilename,
} from '../../http/upload/paymentEvidence'
import {
deletePrivateDocument,
promotePaymentEvidence,
readPrivateDocument,
storePaymentEvidenceInQuarantine,
} from '../../lib/storage'
import { scanPaymentEvidenceFile } from '../../services/paymentEvidenceScanner'
import { coerceNotificationLocale, type NotificationLocale } from '../../services/notificationLocalizationService'
import { sendNotification } from '../../services/notificationService'
import { generateInvoicePdf } from '../../services/invoicePdfService'
import {
getPaymentOptions,
manualPaymentDueDays,
paymentEvidencePipelineReady,
paymentEvidenceUploadEnabled,
requireManualMethodEnabled,
type ManualCollectionMethod,
} from './subscription.payment-config'
import { calculateTaxAmount, getPlatformBillingSettings } from './billingTax'
const PAYABLE_INVOICE_STATUSES = ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE']
const onlinePaymentConfirmedCopy: Record<NotificationLocale, { title: string; body: (invoice: string) => string }> = {
en: { title: 'Subscription payment confirmed', body: (invoice) => `Online payment for invoice ${invoice} was confirmed. Your subscription is active.` },
fr: { title: 'Paiement de labonnement confirmé', body: (invoice) => `Le paiement en ligne de la facture ${invoice} a été confirmé. Votre abonnement est actif.` },
ar: { title: 'تم تأكيد دفع الاشتراك', body: (invoice) => `تم تأكيد الدفع الإلكتروني للفاتورة ${invoice}. اشتراكك نشط الآن.` },
}
const paymentEvidenceSubmittedCopy: Record<NotificationLocale, { title: string; body: (invoice: string) => string }> = {
en: {
title: 'Payment evidence submitted',
body: (invoice) => `Your payment evidence for invoice ${invoice} was submitted. Finance will review it and email you after verification.`,
},
fr: {
title: 'Justificatif de paiement envoyé',
body: (invoice) => `Votre justificatif de paiement pour la facture ${invoice} a été envoyé. La finance le vérifiera et vous enverra un e-mail après validation.`,
},
ar: {
title: 'تم إرسال إثبات الدفع',
body: (invoice) => `تم إرسال إثبات الدفع للفاتورة ${invoice}. سيراجعه فريق المالية وسيتم إعلامك عبر البريد الإلكتروني بعد التحقق.`,
},
}
function fmtMoney(amount: number, currency: string) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format((amount ?? 0) / 100)
}
function fmtDate(value?: Date | string | null) {
if (!value) return 'Not set'
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
function paymentMethodLabel(method?: string | null) {
if (method === 'BANK_TRANSFER') return 'Bank transfer'
if (method === 'CHECK') return 'Check'
return method ?? 'Manual payment'
}
function buildEvidenceSubmittedBody(data: {
companyName?: string | null
invoiceNumber: string
method: string
submittedReference: string
amountDue: number
currency: string
requestedPlan?: string | null
requestedBillingPeriod?: string | null
periodStart?: Date | string | null
periodEnd?: Date | string | null
dueAt?: Date | string | null
submittedAt?: Date | string | null
submittedByEmail?: string | null
documentCount: number
documents?: Array<{ originalFilename: string; kind: string; byteSize: number }>
audience: 'customer' | 'admin'
}) {
const lines = data.audience === 'admin'
? [
'A company has submitted manual payment evidence for finance review.',
'',
`Company: ${data.companyName ?? 'Unknown company'}`,
`Invoice: ${data.invoiceNumber}`,
]
: [
'We have received your manual payment evidence. Finance will review the uploaded documents and verify that funds have cleared before activating or renewing the subscription.',
'',
`Invoice: ${data.invoiceNumber}`,
]
lines.push(
`Payment type: ${paymentMethodLabel(data.method)}`,
`Submitted reference: ${data.submittedReference}`,
`Amount due: ${fmtMoney(data.amountDue, data.currency)}`,
`Requested subscription: ${data.requestedPlan ?? 'Current plan'} / ${data.requestedBillingPeriod ?? 'Current billing period'}`,
`Subscription period start: ${fmtDate(data.periodStart)}`,
`Subscription period end: ${fmtDate(data.periodEnd)}`,
`Invoice due date: ${fmtDate(data.dueAt)}`,
`Submitted on: ${fmtDate(data.submittedAt)}`,
)
if (data.submittedByEmail) lines.push(`Submitted by: ${data.submittedByEmail}`)
lines.push(
`Documents submitted: ${data.documentCount}`,
...(data.documents?.length
? data.documents.map((document) => `- ${document.originalFilename} (${document.kind}, ${Math.ceil(document.byteSize / 1024)} KB)`)
: []),
'',
data.audience === 'admin'
? 'Action required: open Admin Billing, review the evidence, independently verify bank/check settlement, then confirm or reject the payment.'
: 'Important: uploaded evidence is not final confirmation of payment. You will receive a separate confirmation after finance verifies settlement.',
)
return lines.join('\n')
}
function buildOnlinePaymentConfirmedBody(data: {
invoice: string
amountPaid: number
currency: string
paymentType: string
paymentReference?: string | null
paidAt?: Date | string | null
plan?: string | null
billingPeriod?: string | null
periodStart?: Date | string | null
periodEnd?: Date | string | null
}) {
return [
'Dear customer,',
'',
'We confirm that your subscription payment has been received and recorded. Your subscription is now active for the period shown below.',
'',
`Invoice: ${data.invoice}`,
`Amount paid: ${fmtMoney(data.amountPaid, data.currency)}`,
`Payment type: ${paymentMethodLabel(data.paymentType)}`,
data.paymentReference ? `Payment reference: ${data.paymentReference}` : null,
`Payment date: ${fmtDate(data.paidAt)}`,
`Subscription plan: ${data.plan ?? 'Current plan'}`,
`Billing period: ${data.billingPeriod ?? 'Current billing period'}`,
`Subscription start: ${fmtDate(data.periodStart)}`,
`Subscription end: ${fmtDate(data.periodEnd)}`,
'',
'A PDF copy of the invoice is attached for your records.',
'',
'Regards,',
'RentalDriveGo Finance',
].filter((line): line is string => line !== null).join('\n')
}
export function normalizeExternalReference(reference: string) {
return reference.normalize('NFKC').trim().replace(/\s+/g, ' ').toUpperCase()
}
export function isValidIanaTimezone(timezone: string) {
if (/^(UTC|GMT)[+-]/i.test(timezone) || /^[+-]\d{2}:?\d{2}$/.test(timezone)) return false
try {
new Intl.DateTimeFormat('en', { timeZone: timezone }).format(new Date())
return true
} catch {
return false
}
}
export function addBillingPeriod(date: Date, period: 'MONTHLY' | 'ANNUAL') {
const result = new Date(date)
const originalDay = result.getUTCDate()
result.setUTCDate(1)
if (period === 'ANNUAL') result.setUTCFullYear(result.getUTCFullYear() + 1)
else result.setUTCMonth(result.getUTCMonth() + 1)
const lastDay = new Date(Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0)).getUTCDate()
result.setUTCDate(Math.min(originalDay, lastDay))
return result
}
function supportedLocale(value?: string | null): NotificationLocale {
return coerceNotificationLocale(value)
}
async function createBillingEvent(tx: any, data: {
billingAccountId: string
invoiceId?: string | null
subscriptionId?: string | null
companyId: string
eventType: string
source: string
payload?: Record<string, unknown>
}) {
return tx.billingEvent.create({
data: {
...data,
invoiceId: data.invoiceId ?? null,
subscriptionId: data.subscriptionId ?? null,
payload: data.payload ?? {},
occurredAt: new Date(),
},
})
}
export async function ensurePrimaryBillingAccount(companyId: string, employeeId?: string, db: any = prisma) {
const existing = await db.billingAccount.findFirst({
where: { companyId, isPrimary: true },
include: {
company: { include: { contractSettings: true } },
billingContacts: { where: { isActive: true }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
},
})
if (existing) {
if (employeeId && existing.billingContacts.length === 0) {
const employee = await db.employee.findFirst({ where: { id: employeeId, companyId, isActive: true, role: 'OWNER' } })
if (employee) {
await db.billingContact.create({
data: {
billingAccountId: existing.id,
companyId,
employeeId: employee.id,
email: employee.email,
locale: supportedLocale(employee.preferredLanguage),
isPrimary: true,
receivePaymentNotices: true,
verifiedAt: employee.emailVerified ?? new Date(),
},
})
}
}
return db.billingAccount.findUniqueOrThrow({
where: { id: existing.id },
include: {
company: { include: { contractSettings: true } },
billingContacts: { where: { isActive: true }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
},
})
}
const company = await db.company.findUniqueOrThrow({
where: { id: companyId },
include: { accountingSettings: true, brand: true },
})
const employee = employeeId
? await db.employee.findFirst({ where: { id: employeeId, companyId, isActive: true, role: 'OWNER' } })
: null
const defaultLocale = supportedLocale(company.brand?.defaultLocale)
const account = await db.billingAccount.create({
data: {
companyId,
isPrimary: true,
legalName: company.name,
billingEmail: employee?.email ?? company.email,
billingAddress: company.address ?? undefined,
defaultCurrency: company.accountingSettings?.currency ?? 'MAD',
preferredLanguage: defaultLocale,
timezone: process.env.DEFAULT_BILLING_TIMEZONE ?? 'Africa/Casablanca',
enabledCommunicationLocales: [defaultLocale],
defaultCommunicationLocale: defaultLocale,
metadata: { communicationSettingsRequireOwnerReview: !company.brand?.defaultLocale },
},
})
await db.billingCreditBalance.create({
data: { billingAccountId: account.id, currency: account.defaultCurrency, balanceAmount: 0 },
})
if (employee) {
await db.billingContact.create({
data: {
billingAccountId: account.id,
companyId,
employeeId: employee.id,
email: employee.email,
locale: supportedLocale(employee.preferredLanguage),
isPrimary: true,
receivePaymentNotices: true,
verifiedAt: employee.emailVerified ?? new Date(),
},
})
}
return db.billingAccount.findUniqueOrThrow({
where: { id: account.id },
include: {
company: { include: { contractSettings: true } },
billingContacts: { where: { isActive: true }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
},
})
}
async function resolvePrice(plan: string, billingPeriod: string) {
const configured = await prisma.pricingConfig.findUnique({ where: { plan_billingPeriod: { plan, billingPeriod } } })
const fallback = (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
const amount = configured?.amount ?? fallback
if (!Number.isInteger(amount) || amount <= 0) throw new ValidationError('Invalid plan or billing period')
return amount
}
export function calculateTaxForAccount(priceBeforeTax: number, account: any, platformTaxRate?: number) {
return calculateTaxAmount(priceBeforeTax, account.taxExempt, platformTaxRate)
}
export function taxLineItem(tax: ReturnType<typeof calculateTaxForAccount>, currency: string) {
if (tax.taxAmount <= 0) return []
return [{
type: 'TAX',
description: `Tax (${tax.taxRate}%)`,
quantity: 1,
unitAmount: tax.taxAmount,
amount: tax.taxAmount,
currency,
}]
}
export function taxRecordCreate(account: any, tax: ReturnType<typeof calculateTaxForAccount>) {
if (tax.taxRate <= 0 && !account.taxExempt) return undefined
return {
create: {
taxRate: tax.taxRate,
taxAmount: tax.taxAmount,
taxType: account.taxExempt ? 'EXEMPT' : 'VAT',
taxExempt: Boolean(account.taxExempt),
exemptionReason: account.taxExempt ? 'Billing account marked tax exempt' : null,
metadata: {},
},
}
}
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
}
function buildSequentialInvoiceNumber(sequence: number, date: Date) {
return `INV-${date.getUTCFullYear()}-${String(sequence).padStart(6, '0')}`
}
function invoiceTaxRate(invoice: { taxRecords?: Array<{ taxRate?: number | null; taxExempt?: boolean }> }) {
return invoice.taxRecords?.find((record) => !record.taxExempt && typeof record.taxRate === 'number')?.taxRate ?? null
}
export async function getCompanyPaymentOptions(companyId: string, employeeId: string) {
const account = await ensurePrimaryBillingAccount(companyId, employeeId)
return getPaymentOptions(supportedLocale(account.defaultCommunicationLocale))
}
export async function createManualCheckout(companyId: string, employeeId: string, data: {
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'
method: ManualCollectionMethod
idempotencyKey: string
}) {
const account = await ensurePrimaryBillingAccount(companyId, employeeId)
const option = requireManualMethodEnabled(data.method, supportedLocale(account.defaultCommunicationLocale))
const amount = await resolvePrice(data.plan, data.billingPeriod)
const platformBillingSettings = await getPlatformBillingSettings()
const tax = calculateTaxForAccount(amount, account, platformBillingSettings.taxRate)
return prisma.$transaction(async (tx: any) => {
const duplicate = await tx.billingInvoice.findFirst({
where: { billingAccountId: account.id, checkoutIdempotencyKey: data.idempotencyKey },
include: { lineItems: true, manualPaymentSubmissions: { include: { documents: true } } },
})
if (duplicate) return { invoice: duplicate, instructions: option.instructions, duplicate: true }
let subscription = await tx.subscription.findUnique({ where: { companyId } })
if (!subscription) {
subscription = await tx.subscription.create({
data: {
companyId,
plan: data.plan,
billingPeriod: data.billingPeriod,
currency: data.currency,
status: 'PAYMENT_PENDING',
paymentPendingSince: new Date(),
},
})
}
const now = new Date()
const isRenewal = subscription.status === 'ACTIVE' && subscription.currentPeriodEnd && subscription.currentPeriodEnd > now
if (isRenewal) {
const renewalKey = `${subscription.id}:${subscription.currentPeriodEnd!.toISOString()}`
const scheduled = await tx.billingInvoice.findUnique({
where: { renewalKey },
include: { lineItems: true, legacySubscriptionInvoice: true, manualPaymentSubmissions: { include: { documents: true } } },
})
if (scheduled) {
if (!PAYABLE_INVOICE_STATUSES.includes(scheduled.status)) throw new ConflictError('The renewal invoice is not payable')
if (scheduled.amountDue !== tax.totalAmount || scheduled.requestedPlan !== data.plan || scheduled.requestedBillingPeriod !== data.billingPeriod) {
throw new ConflictError('An existing renewal invoice must be resolved before changing the renewal terms')
}
const invoice = await tx.billingInvoice.update({
where: { id: scheduled.id },
data: {
collectionMethod: data.method,
paymentProvider: 'MANUAL',
checkoutIdempotencyKey: data.idempotencyKey,
metadata: { ...((scheduled.metadata as Record<string, unknown>) ?? {}), source: 'customer_manual_checkout' },
},
include: { lineItems: true, manualPaymentSubmissions: { include: { documents: true } } },
})
if (scheduled.legacySubscriptionInvoice) {
await tx.subscriptionInvoice.update({
where: { id: scheduled.legacySubscriptionInvoice.id },
data: { paymentProvider: 'MANUAL' },
})
}
await createBillingEvent(tx, {
billingAccountId: account.id,
invoiceId: invoice.id,
subscriptionId: subscription.id,
companyId,
eventType: 'manual_subscription_invoice.selected',
source: 'customer',
payload: { method: data.method, reusedRenewalInvoice: true },
})
return { invoice, instructions: option.instructions, duplicate: false }
}
}
const existingOpen = await tx.billingInvoice.findFirst({
where: {
companyId,
subscriptionId: subscription.id,
status: { in: PAYABLE_INVOICE_STATUSES },
collectionMethod: { in: ['BANK_TRANSFER', 'CHECK'] },
},
include: { lineItems: true, manualPaymentSubmissions: { include: { documents: true } } },
})
if (existingOpen) {
if (
existingOpen.collectionMethod === data.method
&& existingOpen.amountDue === tax.totalAmount
&& existingOpen.requestedPlan === data.plan
&& existingOpen.requestedBillingPeriod === data.billingPeriod
) {
return { invoice: existingOpen, instructions: option.instructions, duplicate: false, reusedOpenRequest: true }
}
throw new ConflictError('An open manual subscription payment request already exists; submit evidence or wait for finance review before changing the request')
}
const dueAt = new Date(now)
dueAt.setUTCDate(dueAt.getUTCDate() + manualPaymentDueDays(data.method))
const invoiceType = isRenewal ? 'SUBSCRIPTION_RENEWAL' : 'SUBSCRIPTION_INITIAL'
const invoiceSequence = await getNextInvoiceSequence(tx)
const invoiceNumber = buildSequentialInvoiceNumber(invoiceSequence, now)
const invoice = await tx.billingInvoice.create({
data: {
billingAccountId: account.id,
companyId,
subscriptionId: subscription.id,
invoiceNumber,
invoiceSequence,
invoiceType,
status: 'OPEN',
currency: data.currency,
subtotalAmount: amount,
taxAmount: tax.taxAmount,
totalAmount: tax.totalAmount,
amountDue: tax.totalAmount,
invoiceDate: now,
dueAt,
finalizedAt: now,
billingName: account.legalName,
billingEmail: account.billingEmail,
billingAddress: account.billingAddress ?? undefined,
paymentProvider: 'MANUAL',
collectionMethod: data.method,
requestedPlan: data.plan,
requestedBillingPeriod: data.billingPeriod,
checkoutIdempotencyKey: data.idempotencyKey,
isSubscriptionBlocking: true,
metadata: { source: 'customer_manual_checkout' },
lineItems: {
create: [
{
subscriptionId: subscription.id,
plan: data.plan,
type: 'SUBSCRIPTION_FEE',
description: `${data.plan} subscription — ${data.billingPeriod}`,
quantity: 1,
unitAmount: amount,
amount,
currency: data.currency,
periodStart: isRenewal ? subscription.currentPeriodEnd : now,
periodEnd: isRenewal ? addBillingPeriod(subscription.currentPeriodEnd!, data.billingPeriod) : addBillingPeriod(now, data.billingPeriod),
},
...taxLineItem(tax, data.currency),
],
},
...(taxRecordCreate(account, tax) ? { taxRecords: taxRecordCreate(account, tax) } : {}),
},
include: { lineItems: true, manualPaymentSubmissions: { include: { documents: true } } },
})
await tx.subscriptionInvoice.create({
data: {
companyId,
subscriptionId: subscription.id,
requestedPlan: data.plan,
requestedBillingPeriod: data.billingPeriod,
amount: tax.totalAmount,
currency: data.currency,
status: 'PENDING',
paymentProvider: 'MANUAL',
billingInvoiceId: invoice.id,
dueAt,
},
})
if (!isRenewal && subscription.status !== 'PAYMENT_PENDING') {
await tx.subscription.update({
where: { id: subscription.id },
data: { status: 'PAYMENT_PENDING', paymentPendingSince: now, paymentDueAt: dueAt },
})
}
await createBillingEvent(tx, {
billingAccountId: account.id,
invoiceId: invoice.id,
subscriptionId: subscription.id,
companyId,
eventType: 'manual_subscription_invoice.created',
source: 'customer',
payload: { method: data.method, requestedPlan: data.plan, requestedBillingPeriod: data.billingPeriod },
})
return { invoice, instructions: option.instructions, duplicate: false }
})
}
export async function finalizeCanonicalOnlinePayment(legacyInvoiceId: string, providerPaymentId?: string) {
let collectionsCaseId: string | null = null
const handled = await prisma.$transaction(async (tx: any) => {
const legacy = await tx.subscriptionInvoice.findUnique({
where: { id: legacyInvoiceId },
include: { billingInvoice: { include: { collectionsCase: true } }, subscription: true },
})
if (!legacy?.billingInvoice) return false
const invoice = legacy.billingInvoice
if (invoice.status === 'PAID') return true
if (!PAYABLE_INVOICE_STATUSES.includes(invoice.status)) throw new ConflictError('Invoice is not payable')
const paidAt = new Date()
const attempt = await tx.billingPaymentAttempt.create({
data: {
invoiceId: invoice.id,
billingAccountId: invoice.billingAccountId,
providerPaymentId: providerPaymentId ?? legacy.providerInvoiceId,
channel: 'ONLINE',
status: 'SUCCEEDED',
amount: invoice.amountDue,
currency: invoice.currency,
attemptedAt: paidAt,
metadata: { source: 'provider_webhook', legacySubscriptionInvoiceId: legacy.id },
},
})
const updated = await tx.billingInvoice.updateMany({
where: { id: invoice.id, status: { in: PAYABLE_INVOICE_STATUSES }, amountDue: invoice.amountDue },
data: { status: 'PAID', amountPaid: { increment: invoice.amountDue }, amountDue: 0, paidAt },
})
if (updated.count !== 1) throw new ConflictError('Invoice changed while the payment was being finalized')
await tx.subscriptionInvoice.update({ where: { id: legacy.id }, data: { status: 'PAID', paidAt, failedAt: null } })
await tx.manualPaymentSubmission.updateMany({
where: { invoiceId: invoice.id, status: { in: ['DRAFT', 'SUBMITTED', 'UNDER_REVIEW'] } },
data: { status: 'REJECTED', rejectionReason: 'Invoice was paid online.', reviewedAt: paidAt },
})
const period = (invoice.requestedBillingPeriod ?? legacy.requestedBillingPeriod ?? legacy.subscription.billingPeriod) as 'MONTHLY' | 'ANNUAL'
const isRenewal = invoice.invoiceType === 'SUBSCRIPTION_RENEWAL'
const periodStart = isRenewal
? (invoice.collectionsCase?.originalExpirationAt ?? legacy.subscription.currentPeriodEnd ?? paidAt)
: paidAt
await tx.subscription.update({
where: { id: legacy.subscriptionId },
data: {
plan: invoice.requestedPlan ?? legacy.requestedPlan ?? legacy.subscription.plan,
billingPeriod: period,
currency: invoice.currency,
status: 'ACTIVE',
currentPeriodStart: periodStart,
currentPeriodEnd: addBillingPeriod(periodStart, period),
paymentPendingSince: null,
paymentDueAt: null,
pastDueSince: null,
suspendedAt: null,
retryCount: 0,
},
})
if (invoice.collectionsCase) {
collectionsCaseId = invoice.collectionsCase.id
await tx.collectionsCase.update({
where: { id: invoice.collectionsCase.id },
data: { status: 'RESOLVED', resolvedAt: paidAt, nextActionAt: null, resolutionPaymentAttemptId: attempt.id },
})
await tx.collectionsCallTask.updateMany({
where: { collectionsCaseId: invoice.collectionsCase.id, status: 'OPEN' },
data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' },
})
}
await createBillingEvent(tx, {
billingAccountId: invoice.billingAccountId,
invoiceId: invoice.id,
subscriptionId: legacy.subscriptionId,
companyId: legacy.companyId,
eventType: 'invoice.paid',
source: 'webhook',
payload: { paymentAttemptId: attempt.id, channel: 'ONLINE' },
})
await tx.subscriptionEvent.create({
data: {
subscriptionId: legacy.subscriptionId,
companyId: legacy.companyId,
eventType: 'subscription.activated',
source: 'webhook',
payload: { invoiceId: invoice.id, paymentAttemptId: attempt.id },
occurredAt: paidAt,
},
})
return true
})
if (handled && collectionsCaseId) {
await prisma.notificationOutbox.updateMany({
where: {
status: 'PENDING',
notificationEvent: { sourceType: 'collections_case', sourceId: collectionsCaseId },
},
data: { status: 'PUBLISHED', failureReason: 'Suppressed because payment was confirmed' },
})
}
if (handled) {
const paidInvoice = await prisma.subscriptionInvoice.findUnique({
where: { id: legacyInvoiceId },
include: {
billingInvoice: {
include: {
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' }, take: 1 },
billingAccount: {
include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } },
},
},
},
},
})
const invoice = paidInvoice?.billingInvoice
if (invoice) {
const account = invoice.billingAccount
for (const contact of account.billingContacts) {
const enabled = account.enabledCommunicationLocales as string[]
const locale = coerceNotificationLocale(
contact.locale && enabled.includes(contact.locale)
? contact.locale
: contact.employee?.preferredLanguage && enabled.includes(contact.employee.preferredLanguage)
? contact.employee.preferredLanguage
: account.defaultCommunicationLocale,
)
const copy = onlinePaymentConfirmedCopy[locale]
const invoiceLabel = invoice.invoiceNumber ?? invoice.id
const paymentAttempt = invoice.paymentAttempts?.[0] ?? null
const periodStart = invoice.subscription?.currentPeriodStart ?? invoice.lineItems?.[0]?.periodStart ?? null
const periodEnd = invoice.subscription?.currentPeriodEnd ?? invoice.lineItems?.[0]?.periodEnd ?? null
await sendNotification({
type: 'SUBSCRIPTION_PAYMENT_CONFIRMED',
title: copy.title,
body: buildOnlinePaymentConfirmedBody({
invoice: invoiceLabel,
amountPaid: invoice.amountPaid || paymentAttempt?.amount || invoice.totalAmount,
currency: invoice.currency,
paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'BANK_TRANSFER',
paymentReference: paymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt,
plan: invoice.requestedPlan ?? invoice.subscription?.plan ?? null,
billingPeriod: invoice.requestedBillingPeriod ?? invoice.subscription?.billingPeriod ?? null,
periodStart,
periodEnd,
}),
companyId: invoice.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: 'subscription.payment_confirmed.v1',
idempotencyKey: `online-payment:confirmed:${invoice.id}:${contact.id}`,
sourceType: 'online_payment',
sourceId: invoice.id,
data: {
invoiceId: invoice.id,
amountPaid: invoice.amountPaid || paymentAttempt?.amount || invoice.totalAmount,
currency: invoice.currency,
paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'BANK_TRANSFER',
paymentReference: paymentAttempt?.providerPaymentId ?? null,
subscriptionStart: periodStart,
subscriptionEnd: periodEnd,
timezone: account.timezone,
templateVersion: 1,
localizationFallback: false,
emailAttachments: [{ type: 'invoice_pdf', invoiceId: invoice.id }],
},
policy: { mandatory: true },
})
}
}
}
return handled
}
export async function recordCanonicalOnlinePaymentFailure(
legacyInvoiceId: string,
failureCode?: string,
failureMessage?: string,
) {
return prisma.$transaction(async (tx: any) => {
const legacy = await tx.subscriptionInvoice.findUnique({
where: { id: legacyInvoiceId },
include: { billingInvoice: true, subscription: true },
})
if (!legacy?.billingInvoice) return false
const invoice = legacy.billingInvoice
if (invoice.status === 'PAID') return true
const attemptedAt = new Date()
await tx.billingPaymentAttempt.create({
data: {
invoiceId: invoice.id,
billingAccountId: invoice.billingAccountId,
providerPaymentId: legacy.providerInvoiceId,
channel: 'ONLINE',
status: 'FAILED',
amount: invoice.amountDue,
currency: invoice.currency,
failureCode,
failureMessage,
attemptedAt,
metadata: { source: 'provider_webhook', legacySubscriptionInvoiceId: legacy.id },
},
})
await tx.billingInvoice.updateMany({
where: { id: invoice.id, status: { in: ['OPEN', 'PAYMENT_PENDING'] } },
data: { status: 'PAYMENT_PENDING' },
})
await tx.subscriptionInvoice.update({ where: { id: legacy.id }, data: { status: 'FAILED', failedAt: attemptedAt } })
if (invoice.invoiceType === 'SUBSCRIPTION_INITIAL' && legacy.subscription.status !== 'ACTIVE') {
await tx.subscription.update({
where: { id: legacy.subscriptionId },
data: { status: 'PAYMENT_PENDING', paymentPendingSince: attemptedAt, paymentDueAt: invoice.dueAt },
})
}
await createBillingEvent(tx, {
billingAccountId: invoice.billingAccountId,
invoiceId: invoice.id,
subscriptionId: legacy.subscriptionId,
companyId: legacy.companyId,
eventType: 'payment.failed',
source: 'webhook',
payload: { failureCode, failureMessage },
})
return true
})
}
function safeDocument(document: any) {
return {
id: document.id,
kind: document.kind,
originalFilename: document.originalFilename,
detectedMimeType: document.detectedMimeType,
byteSize: document.byteSize,
scanStatus: document.scanStatus,
uploadedAt: document.uploadedAt,
}
}
function safeSubmission(submission: any) {
return {
id: submission.id,
method: submission.method,
submittedReference: submission.submittedReference,
status: submission.status,
submittedAt: submission.submittedAt,
reviewedAt: submission.reviewedAt,
rejectionReason: submission.rejectionReason,
documents: (submission.documents ?? []).filter((item: any) => !item.deletedAt).map(safeDocument),
}
}
export async function getCanonicalInvoices(companyId: string) {
const [invoices, legacyInvoices] = await Promise.all([
prisma.billingInvoice.findMany({
where: { companyId, subscriptionId: { not: null } },
include: {
manualPaymentSubmissions: { orderBy: { createdAt: 'desc' }, include: { documents: { orderBy: { uploadedAt: 'asc' } } } },
paymentAttempts: { where: { status: 'SUCCEEDED' }, orderBy: { attemptedAt: 'desc' }, take: 1 },
},
orderBy: { createdAt: 'desc' },
take: 50,
}),
prisma.subscriptionInvoice.findMany({
where: { companyId, billingInvoiceId: null },
orderBy: { createdAt: 'desc' },
take: 50,
}),
])
const canonical = invoices.map((invoice: any) => ({
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
amount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
amountDue: invoice.amountDue,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider,
collectionMethod: invoice.collectionMethod,
requestedPlan: invoice.requestedPlan,
requestedBillingPeriod: invoice.requestedBillingPeriod,
dueAt: invoice.dueAt,
paidAt: invoice.paidAt,
createdAt: invoice.createdAt,
confirmedReference: invoice.paymentAttempts[0]?.externalReference
? `••••${invoice.paymentAttempts[0].externalReference.slice(-4)}`
: null,
manualPaymentSubmission: invoice.manualPaymentSubmissions[0]
? safeSubmission(invoice.manualPaymentSubmissions[0])
: null,
}))
const legacy = legacyInvoices.map((invoice: any) => ({
id: invoice.id,
invoiceNumber: null,
amount: invoice.amount,
amountPaid: invoice.status === 'PAID' ? invoice.amount : 0,
amountDue: invoice.status === 'PAID' ? 0 : invoice.amount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider,
collectionMethod: 'BANK_TRANSFER',
requestedPlan: invoice.requestedPlan,
requestedBillingPeriod: invoice.requestedBillingPeriod,
dueAt: invoice.dueAt,
paidAt: invoice.paidAt,
createdAt: invoice.createdAt,
confirmedReference: null,
manualPaymentSubmission: null,
}))
return [...canonical, ...legacy]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 50)
}
export async function getPaidInvoicePdf(companyId: string, invoiceId: string) {
const invoice = await prisma.billingInvoice.findFirst({
where: { id: invoiceId, companyId, subscriptionId: { not: null }, status: 'PAID' },
include: {
company: true,
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
},
})
if (!invoice) throw new NotFoundError('Invoice not found')
if (!invoice.invoiceNumber) throw new ValidationError('Invoice must be verified before a PDF can be generated')
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt: any) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
const pdfBuffer = await generateInvoicePdf({
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(),
dueDate: invoice.dueAt?.toISOString() ?? null,
company: {
name: invoice.billingName ?? invoice.company.name,
email: invoice.billingEmail ?? invoice.company.email,
phone: invoice.company.phone,
address: invoice.billingAddress ?? invoice.company.address,
},
subscription: invoice.subscription
? {
plan: invoice.subscription.plan,
billingPeriod: invoice.subscription.billingPeriod,
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
currency: invoice.subscription.currency,
}
: undefined,
amount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider ?? 'MANUAL',
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description,
amount: item.amount,
currency: item.currency,
quantity: item.quantity,
unitAmount: item.unitAmount,
periodStart: item.periodStart?.toISOString() ?? null,
periodEnd: item.periodEnd?.toISOString() ?? null,
})),
totals: {
subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
amountDue: invoice.amountDue,
},
})
return { pdfBuffer, invoiceNumber: invoice.invoiceNumber }
}
export async function createManualPaymentSubmission(companyId: string, employeeId: string, invoiceId: string, data: {
method: ManualCollectionMethod
submittedReference: string
idempotencyKey: string
}) {
const account = await ensurePrimaryBillingAccount(companyId, employeeId)
const invoice = await prisma.billingInvoice.findFirst({ where: { id: invoiceId, companyId, billingAccountId: account.id } })
if (!invoice) throw new NotFoundError('Invoice not found')
if (!PAYABLE_INVOICE_STATUSES.includes(invoice.status)) throw new ConflictError('Invoice is not payable')
if (invoice.collectionMethod !== data.method) throw new ValidationError('Submission method must match the invoice')
const idempotent = await prisma.manualPaymentSubmission.findUnique({
where: { billingAccountId_idempotencyKey: { billingAccountId: account.id, idempotencyKey: data.idempotencyKey } },
include: { documents: true },
})
if (idempotent) {
if (idempotent.invoiceId !== invoiceId || idempotent.method !== data.method || idempotent.normalizedSubmittedReference !== normalizeExternalReference(data.submittedReference)) {
throw new ConflictError('Idempotency key was already used with a different submission')
}
return safeSubmission(idempotent)
}
const openSubmission = await prisma.manualPaymentSubmission.findFirst({
where: { invoiceId, status: { in: ['DRAFT', 'SUBMITTED', 'UNDER_REVIEW'] } },
include: { documents: true },
orderBy: { createdAt: 'desc' },
})
if (openSubmission) {
if (openSubmission.method !== data.method) {
throw new ConflictError('This invoice already has a payment submission for a different method')
}
if (openSubmission.status === 'DRAFT') {
const normalizedSubmittedReference = normalizeExternalReference(data.submittedReference)
if (openSubmission.normalizedSubmittedReference === normalizedSubmittedReference) return safeSubmission(openSubmission)
const updated = await prisma.manualPaymentSubmission.update({
where: { id: openSubmission.id },
data: {
submittedReference: data.submittedReference,
normalizedSubmittedReference,
},
include: { documents: true },
})
return safeSubmission(updated)
}
if (openSubmission.normalizedSubmittedReference === normalizeExternalReference(data.submittedReference)) return safeSubmission(openSubmission)
throw new ConflictError('This invoice already has a payment submission awaiting review')
}
let submission: any
try {
submission = await prisma.manualPaymentSubmission.upsert({
where: { billingAccountId_idempotencyKey: { billingAccountId: account.id, idempotencyKey: data.idempotencyKey } },
update: {},
create: {
invoiceId,
billingAccountId: account.id,
companyId,
method: data.method,
submittedReference: data.submittedReference,
normalizedSubmittedReference: normalizeExternalReference(data.submittedReference),
submittedByEmployeeId: employeeId,
idempotencyKey: data.idempotencyKey,
},
include: { documents: true },
})
} catch (error: any) {
if (error?.code !== 'P2002') throw error
const concurrentSubmission = await prisma.manualPaymentSubmission.findFirst({
where: { invoiceId, status: { in: ['DRAFT', 'SUBMITTED', 'UNDER_REVIEW'] } },
include: { documents: true },
orderBy: { createdAt: 'desc' },
})
if (
concurrentSubmission
&& concurrentSubmission.method === data.method
&& concurrentSubmission.normalizedSubmittedReference === normalizeExternalReference(data.submittedReference)
) {
return safeSubmission(concurrentSubmission)
}
throw new ConflictError('This invoice already has a payment submission awaiting review')
}
if (submission.invoiceId !== invoiceId || submission.method !== data.method || submission.normalizedSubmittedReference !== normalizeExternalReference(data.submittedReference)) {
throw new ConflictError('Idempotency key was already used with a different submission')
}
return safeSubmission(submission)
}
export async function uploadManualPaymentDocument(
companyId: string,
employeeId: string,
submissionId: string,
kind: string,
file: Express.Multer.File | undefined,
) {
if (!paymentEvidenceUploadEnabled() || !paymentEvidencePipelineReady()) throw new ValidationError('Payment evidence upload is not ready')
if (!file) throw new ValidationError('A payment evidence file is required')
const detected = assertPaymentEvidenceFile(file)
const submission = await prisma.manualPaymentSubmission.findFirst({
where: { id: submissionId, companyId },
include: { documents: { where: { deletedAt: null } }, invoice: true },
})
if (!submission) throw new NotFoundError('Payment submission not found')
if (submission.status !== 'DRAFT') throw new ConflictError('Submitted evidence is immutable')
const sha256 = crypto.createHash('sha256').update(file.buffer).digest('hex')
const duplicate = submission.documents.find((item: any) => item.sha256 === sha256)
if (duplicate) return safeDocument(duplicate)
if (submission.documents.length >= PAYMENT_EVIDENCE_MAX_FILES) throw new ValidationError('A submission can contain at most three files')
const totalSize = submission.documents.reduce((sum: number, item: any) => sum + item.byteSize, 0) + file.size
if (totalSize > PAYMENT_EVIDENCE_MAX_TOTAL_SIZE) throw new ValidationError('The total evidence size cannot exceed 20 MB')
const stored = await storePaymentEvidenceInQuarantine(file.buffer, companyId, submissionId, detected.ext)
let document: any
try {
document = await prisma.manualPaymentDocument.create({
data: {
submissionId,
invoiceId: submission.invoiceId,
companyId,
kind: kind as any,
storageKey: stored.storageKey,
originalFilename: sanitizeEvidenceFilename(file.originalname),
detectedMimeType: detected.mime,
detectedExtension: detected.ext,
byteSize: file.size,
sha256,
scanStatus: 'SCANNING',
uploadedByEmployeeId: employeeId,
},
})
} catch (error) {
await deletePrivateDocument(stored.storageKey).catch(() => {})
throw error
}
const scan = await scanPaymentEvidenceFile(stored.filePath)
let storageKey = stored.storageKey
if (scan.status === 'CLEAN') storageKey = await promotePaymentEvidence(stored.storageKey)
document = await prisma.manualPaymentDocument.update({
where: { id: document.id },
data: { scanStatus: scan.status, scannerResultCode: scan.code, storageKey },
})
return safeDocument(document)
}
export async function deleteManualPaymentDocument(companyId: string, submissionId: string, documentId: string) {
const document = await prisma.manualPaymentDocument.findFirst({
where: { id: documentId, submissionId, companyId, deletedAt: null },
include: { submission: true },
})
if (!document) throw new NotFoundError('Payment evidence document not found')
if (document.submission.status !== 'DRAFT') throw new ConflictError('Submitted evidence is immutable')
await prisma.manualPaymentDocument.update({ where: { id: document.id }, data: { deletedAt: new Date() } })
await deletePrivateDocument(document.storageKey)
return { deleted: true }
}
async function notifyManualPaymentEvidenceSubmitted(data: {
billingAccountId: string
companyId: string
invoiceId: string
invoiceNumber?: string | null
submissionId: string
documentCount: number
}) {
const account = await prisma.billingAccount.findUnique({
where: { id: data.billingAccountId },
include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } },
})
if (!account) return
const submission = await prisma.manualPaymentSubmission.findUnique({
where: { id: data.submissionId },
include: {
submittedByEmployee: { select: { email: true } },
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
invoice: { include: { company: true, subscription: true, lineItems: true } },
},
})
if (!submission) return
const invoiceRecord = submission.invoice
const invoice = data.invoiceNumber ?? invoiceRecord.invoiceNumber ?? data.invoiceId
const periodStart = invoiceRecord.lineItems[0]?.periodStart ?? invoiceRecord.subscription?.currentPeriodStart ?? null
const periodEnd = invoiceRecord.lineItems[0]?.periodEnd ?? invoiceRecord.subscription?.currentPeriodEnd ?? null
for (const contact of account.billingContacts) {
const enabled = account.enabledCommunicationLocales as string[]
const locale = coerceNotificationLocale(
contact.locale && enabled.includes(contact.locale)
? contact.locale
: contact.employee?.preferredLanguage && enabled.includes(contact.employee.preferredLanguage)
? contact.employee.preferredLanguage
: account.defaultCommunicationLocale,
)
const copy = paymentEvidenceSubmittedCopy[locale]
const body = buildEvidenceSubmittedBody({
invoiceNumber: invoice,
method: submission.method,
submittedReference: submission.submittedReference,
amountDue: invoiceRecord.amountDue,
currency: invoiceRecord.currency,
requestedPlan: invoiceRecord.requestedPlan,
requestedBillingPeriod: invoiceRecord.requestedBillingPeriod,
periodStart,
periodEnd,
dueAt: invoiceRecord.dueAt,
submittedAt: submission.submittedAt,
submittedByEmail: submission.submittedByEmployee.email,
documentCount: submission.documents.length,
documents: submission.documents,
audience: 'customer',
})
await sendNotification({
type: 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED',
title: copy.title,
body,
companyId: data.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: 'subscription.payment_evidence_submitted.v1',
idempotencyKey: `manual-payment:submitted:${data.submissionId}:${contact.id}`,
sourceType: 'manual_payment',
sourceId: data.submissionId,
data: {
invoiceId: data.invoiceId,
submissionId: data.submissionId,
documentCount: data.documentCount,
amountDue: invoiceRecord.amountDue,
currency: invoiceRecord.currency,
paymentType: submission.method,
submittedReference: submission.submittedReference,
timezone: account.timezone,
templateVersion: 1,
localizationFallback: false,
},
policy: { mandatory: true },
})
}
const admins = await prisma.adminUser.findMany({
where: { isActive: true, role: { in: ['FINANCE', 'ADMIN', 'SUPER_ADMIN'] } },
select: { id: true, preferredLocale: true },
})
for (const admin of admins) {
const locale = coerceNotificationLocale(admin.preferredLocale)
await sendNotification({
type: 'MANUAL_PAYMENT_EVIDENCE_SUBMITTED',
title: `Payment evidence submitted: ${invoice}`,
body: buildEvidenceSubmittedBody({
companyName: invoiceRecord.company.name,
invoiceNumber: invoice,
method: submission.method,
submittedReference: submission.submittedReference,
amountDue: invoiceRecord.amountDue,
currency: invoiceRecord.currency,
requestedPlan: invoiceRecord.requestedPlan,
requestedBillingPeriod: invoiceRecord.requestedBillingPeriod,
periodStart,
periodEnd,
dueAt: invoiceRecord.dueAt,
submittedAt: submission.submittedAt,
submittedByEmail: submission.submittedByEmployee.email,
documentCount: submission.documents.length,
documents: submission.documents,
audience: 'admin',
}),
companyId: data.companyId,
adminUserId: admin.id,
channels: ['IN_APP', 'EMAIL'],
locale,
templateKey: 'subscription.payment_evidence_submitted.admin.v1',
idempotencyKey: `manual-payment:submitted:${data.submissionId}:admin:${admin.id}`,
sourceType: 'manual_payment',
sourceId: data.submissionId,
data: {
invoiceId: data.invoiceId,
submissionId: data.submissionId,
amountDue: invoiceRecord.amountDue,
currency: invoiceRecord.currency,
paymentType: submission.method,
submittedReference: submission.submittedReference,
templateVersion: 1,
},
policy: { mandatory: true },
})
}
}
export async function submitManualPaymentSubmission(companyId: string, submissionId: string) {
const result = await prisma.$transaction(async (tx: any) => {
const submission = await tx.manualPaymentSubmission.findFirst({
where: { id: submissionId, companyId },
include: { documents: { where: { deletedAt: null } }, invoice: true },
})
if (!submission) throw new NotFoundError('Payment submission not found')
if (submission.status === 'SUBMITTED' || submission.status === 'UNDER_REVIEW') {
return { response: safeSubmission(submission), notification: null }
}
if (submission.status !== 'DRAFT') throw new ConflictError('Payment submission cannot be submitted in its current state')
if (!PAYABLE_INVOICE_STATUSES.includes(submission.invoice.status)) throw new ConflictError('Invoice is no longer payable')
if (submission.documents.length === 0) throw new ValidationError('At least one clean evidence document is required')
if (submission.documents.some((item: any) => item.scanStatus !== 'CLEAN')) {
throw new ValidationError('Every evidence document must pass malware scanning before submission')
}
const submittedAt = new Date()
const updated = await tx.manualPaymentSubmission.update({
where: { id: submission.id },
data: { status: 'SUBMITTED', submittedAt },
include: { documents: { where: { deletedAt: null } } },
})
await createBillingEvent(tx, {
billingAccountId: submission.billingAccountId,
invoiceId: submission.invoiceId,
subscriptionId: submission.invoice.subscriptionId,
companyId,
eventType: 'payment_evidence.submitted',
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: {
billingAccountId: submission.billingAccountId,
invoiceId: submission.invoiceId,
invoiceNumber: submission.invoice.invoiceNumber,
submissionId: submission.id,
documentCount: submission.documents.length,
},
}
})
if (result.notification) {
await notifyManualPaymentEvidenceSubmitted({
billingAccountId: result.notification.billingAccountId,
companyId,
invoiceId: result.notification.invoiceId,
invoiceNumber: result.notification.invoiceNumber,
submissionId: result.notification.submissionId,
documentCount: result.notification.documentCount,
})
}
return result.response
}
export async function getCustomerPaymentDocument(companyId: string, submissionId: string, documentId: string) {
const document = await prisma.manualPaymentDocument.findFirst({
where: { id: documentId, submissionId, companyId, deletedAt: null, scanStatus: 'CLEAN' },
include: { submission: { include: { invoice: true } } },
})
if (!document || document.submission.invoice.companyId !== companyId) throw new NotFoundError('Payment evidence document not found')
return { document, bytes: readPrivateDocument(document.storageKey) }
}
export async function getCommunicationSettings(companyId: string, employeeId: string) {
const account = await ensurePrimaryBillingAccount(companyId, employeeId)
return {
timezone: account.timezone,
reminderLocalTime: account.reminderLocalTime,
enabledCommunicationLocales: account.enabledCommunicationLocales,
defaultCommunicationLocale: account.defaultCommunicationLocale,
contacts: account.billingContacts.map((contact: any) => ({
id: contact.id,
employeeId: contact.employeeId,
email: contact.email,
locale: contact.locale,
effectiveLocale: contact.locale && account.enabledCommunicationLocales.includes(contact.locale)
? contact.locale
: account.defaultCommunicationLocale,
isPrimary: contact.isPrimary,
receivePaymentNotices: contact.receivePaymentNotices,
isActive: contact.isActive,
verified: Boolean(contact.verifiedAt),
})),
}
}
export async function updateCommunicationSettings(companyId: string, employeeId: string, data: {
timezone: string
reminderLocalTime: string
enabledCommunicationLocales: Array<'ar' | 'en' | 'fr'>
defaultCommunicationLocale: 'ar' | 'en' | 'fr'
contacts: Array<{
id?: string
employeeId?: string | null
email: string
locale?: 'ar' | 'en' | 'fr' | null
isPrimary: boolean
receivePaymentNotices: boolean
isActive: boolean
}>
}) {
if (!isValidIanaTimezone(data.timezone)) throw new ValidationError('A valid IANA timezone is required')
const account = await ensurePrimaryBillingAccount(companyId, employeeId)
await prisma.$transaction(async (tx: any) => {
const existing = await tx.billingContact.findMany({ where: { billingAccountId: account.id } })
const retainedIds = new Set<string>()
const proposed: Array<{ verified: boolean; primary: boolean; receives: boolean; active: boolean }> = []
for (const input of data.contacts) {
let linkedEmployee: any = null
if (input.employeeId) {
linkedEmployee = await tx.employee.findFirst({
where: { id: input.employeeId, companyId, isActive: true, role: 'OWNER' },
})
if (!linkedEmployee) throw new ValidationError('Linked billing contacts must be active company owners')
if (linkedEmployee.email.toLowerCase() !== input.email.toLowerCase()) {
throw new ValidationError('Linked billing contact email must match the employee email')
}
}
const prior = input.id ? existing.find((item: any) => item.id === input.id) : existing.find((item: any) => item.email === input.email)
if (input.id && !prior) throw new NotFoundError('Billing contact not found')
const verifiedAt = linkedEmployee
? (linkedEmployee.emailVerified ?? prior?.verifiedAt ?? new Date())
: prior?.email === input.email ? prior.verifiedAt : null
const saved = prior
? await tx.billingContact.update({
where: { id: prior.id },
data: {
employeeId: input.employeeId ?? null,
email: input.email,
locale: input.locale ?? null,
isPrimary: input.isPrimary,
receivePaymentNotices: input.receivePaymentNotices,
isActive: input.isActive,
verifiedAt,
},
})
: await tx.billingContact.create({
data: {
billingAccountId: account.id,
companyId,
employeeId: input.employeeId ?? null,
email: input.email,
locale: input.locale ?? null,
isPrimary: input.isPrimary,
receivePaymentNotices: input.receivePaymentNotices,
isActive: input.isActive,
verifiedAt,
},
})
retainedIds.add(saved.id)
proposed.push({ verified: Boolean(saved.verifiedAt), primary: saved.isPrimary, receives: saved.receivePaymentNotices, active: saved.isActive })
}
if (!proposed.some((item) => item.verified && item.primary && item.receives && item.active)) {
throw new ValidationError('At least one active, verified primary payment contact is required')
}
const removedIds = existing.filter((item: any) => !retainedIds.has(item.id)).map((item: any) => item.id)
if (removedIds.length) await tx.billingContact.updateMany({ where: { id: { in: removedIds } }, data: { isActive: false, receivePaymentNotices: false, isPrimary: false } })
await tx.billingAccount.update({
where: { id: account.id },
data: {
timezone: data.timezone,
reminderLocalTime: data.reminderLocalTime,
enabledCommunicationLocales: data.enabledCommunicationLocales,
defaultCommunicationLocale: data.defaultCommunicationLocale,
preferredLanguage: data.defaultCommunicationLocale,
},
})
await createBillingEvent(tx, {
billingAccountId: account.id,
companyId,
eventType: 'billing_communication_settings.updated',
source: 'customer',
payload: {
timezone: data.timezone,
reminderLocalTime: data.reminderLocalTime,
enabledCommunicationLocales: data.enabledCommunicationLocales,
defaultCommunicationLocale: data.defaultCommunicationLocale,
contactCount: data.contacts.length,
},
})
})
return getCommunicationSettings(companyId, employeeId)
}