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 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 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 }) { 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, 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) { 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) ?? {}), 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() 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) }