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
1413 lines
58 KiB
TypeScript
1413 lines
58 KiB
TypeScript
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 l’abonnement 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)
|
||
}
|