"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.listBillingAccounts = listBillingAccounts; exports.getBillingAccountDetail = getBillingAccountDetail; exports.updateBillingAccount = updateBillingAccount; exports.setDunningPaused = setDunningPaused; exports.createDraftInvoice = createDraftInvoice; exports.finalizeInvoice = finalizeInvoice; exports.payInvoice = payInvoice; exports.retryInvoicePayment = retryInvoicePayment; exports.voidInvoice = voidInvoice; exports.markInvoiceUncollectible = markInvoiceUncollectible; exports.issueCreditNote = issueCreditNote; exports.issueRefund = issueRefund; exports.getInvoicePdf = getInvoicePdf; const prisma_1 = require("../../lib/prisma"); const errors_1 = require("../../http/errors"); const invoicePdfService_1 = require("../../services/invoicePdfService"); const BLOCKING_INVOICE_TYPES = new Set([ 'SUBSCRIPTION_INITIAL', 'SUBSCRIPTION_RENEWAL', 'TRIAL_CONVERSION', 'SUBSCRIPTION_UPGRADE', ]); const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID']); function toSequenceNumber(value) { if (typeof value === 'bigint') return Number(value); if (typeof value === 'number') return value; if (typeof value === 'string') return Number(value); throw new Error('Invalid invoice sequence value'); } function buildSequentialInvoiceNumber(sequence, date) { return `INV-${date.getFullYear()}-${String(sequence).padStart(6, '0')}`; } function isBlockingInvoiceType(invoiceType) { return BLOCKING_INVOICE_TYPES.has(invoiceType); } function getInvoiceTermsDays(invoiceTerms, explicitNetTermsDays) { if (explicitNetTermsDays > 0) return explicitNetTermsDays; const map = { DUE_ON_RECEIPT: 0, NET_7: 7, NET_15: 15, NET_30: 30, NET_45: 45, NET_60: 60, }; return map[invoiceTerms] ?? 0; } function addDays(date, days) { const copy = new Date(date); copy.setDate(copy.getDate() + days); return copy; } function normalizeAddress(address) { if (!address) return null; if (typeof address === 'string') return { formatted: address }; return address; } function fmtLegacyInvoiceType(_invoice) { return 'SUBSCRIPTION_INITIAL'; } function mapLegacyInvoiceStatus(status) { switch (status) { case 'PAID': return 'PAID'; case 'FAILED': return 'PAST_DUE'; case 'REFUNDED': return 'REFUNDED'; case 'VOIDED': return 'VOID'; default: return 'OPEN'; } } function calculateLineAmounts(items) { const subtotalAmount = items .filter((item) => !['DISCOUNT', 'CREDIT', 'TAX'].includes(item.type)) .reduce((sum, item) => sum + item.amount, 0); const discountAmount = Math.abs(items.filter((item) => item.type === 'DISCOUNT').reduce((sum, item) => sum + item.amount, 0)); const creditAmount = Math.abs(items.filter((item) => item.type === 'CREDIT').reduce((sum, item) => sum + item.amount, 0)); const taxAmount = items.filter((item) => item.type === 'TAX').reduce((sum, item) => sum + item.amount, 0); const totalAmount = Math.max(subtotalAmount - discountAmount - creditAmount + taxAmount, 0); return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }; } async function createBillingEvent(tx, data) { await tx.billingEvent.create({ data: { billingAccountId: data.billingAccountId ?? null, invoiceId: data.invoiceId ?? null, subscriptionId: data.subscriptionId ?? null, companyId: data.companyId ?? null, eventType: data.eventType, source: data.source, payload: data.payload ?? {}, occurredAt: new Date(), }, }); } async function createAuditLog(data) { await prisma_1.prisma.auditLog.create({ data: data }); } async function ensurePrimaryBillingAccount(companyId, tx = prisma_1.prisma) { const existing = await tx.billingAccount.findFirst({ where: { companyId, isPrimary: true }, include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true }, }); if (existing) return existing; const company = await tx.company.findUniqueOrThrow({ where: { id: companyId }, include: { accountingSettings: true, contractSettings: true, subscription: true }, }); const created = await tx.billingAccount.create({ data: { companyId, isPrimary: true, legalName: company.name, billingEmail: company.email, billingAddress: normalizeAddress(company.address), defaultCurrency: company.accountingSettings?.currency ?? 'MAD', invoiceTerms: 'DUE_ON_RECEIPT', netTermsDays: 0, metadata: {}, }, include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true }, }); await tx.billingCreditBalance.create({ data: { billingAccountId: created.id, currency: created.defaultCurrency, balanceAmount: 0, }, }); await createBillingEvent(tx, { billingAccountId: created.id, companyId, eventType: 'billing_account.created', source: 'system', payload: { companyId }, }); return tx.billingAccount.findUniqueOrThrow({ where: { id: created.id }, include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true }, }); } async function getNextInvoiceSequence(tx) { const latest = await tx.billingInvoice.findFirst({ where: { invoiceSequence: { not: null } }, select: { invoiceSequence: true }, orderBy: { invoiceSequence: 'desc' }, }); return (latest?.invoiceSequence ?? 0) + 1; } async function maybeRestoreSubscription(tx, invoice) { if (!invoice.subscriptionId || !invoice.isSubscriptionBlocking) return; const subscription = await tx.subscription.findUnique({ where: { id: invoice.subscriptionId } }); if (!subscription) return; if (['PAYMENT_PENDING', 'PAST_DUE', 'SUSPENDED', 'TRIALING', 'UNPAID'].includes(subscription.status)) { await tx.subscription.update({ where: { id: subscription.id }, data: { status: 'ACTIVE', paymentPendingSince: null, paymentDueAt: null, pastDueSince: null, suspendedAt: null, retryCount: 0, }, }); } } async function maybeCancelSubscriptionForWriteoff(tx, invoice) { if (!invoice.subscriptionId || !invoice.isSubscriptionBlocking) return; const subscription = await tx.subscription.findUnique({ where: { id: invoice.subscriptionId } }); if (!subscription || ['CANCELLED', 'EXPIRED'].includes(subscription.status)) return; await tx.subscription.update({ where: { id: subscription.id }, data: { status: 'CANCELLED', cancelledAt: new Date(), endedAt: new Date(), }, }); } async function syncOneLegacyInvoice(tx, legacy) { const account = await ensurePrimaryBillingAccount(legacy.companyId, tx); const status = mapLegacyInvoiceStatus(legacy.status); const sequence = await getNextInvoiceSequence(tx); const invoiceDate = legacy.createdAt ?? new Date(); const invoiceNumber = buildSequentialInvoiceNumber(sequence, invoiceDate); const amountPaid = ['PAID', 'REFUNDED'].includes(status) ? legacy.amount : 0; const amountDue = ['OPEN', 'PAST_DUE', 'PAYMENT_PENDING'].includes(status) ? legacy.amount : 0; const billingInvoice = await tx.billingInvoice.create({ data: { billingAccountId: account.id, companyId: legacy.companyId, subscriptionId: legacy.subscriptionId, invoiceNumber, invoiceSequence: sequence, invoiceType: fmtLegacyInvoiceType(legacy), status, currency: legacy.currency, subtotalAmount: legacy.amount, totalAmount: legacy.amount, amountPaid, amountDue, invoiceDate, dueAt: legacy.dueAt ?? legacy.createdAt ?? new Date(), finalizedAt: legacy.createdAt ?? new Date(), paidAt: legacy.paidAt, voidedAt: legacy.voidedAt, billingName: account.legalName, billingEmail: account.billingEmail, billingAddress: account.billingAddress, paymentProvider: legacy.paymentProvider, isSubscriptionBlocking: true, metadata: { legacySubscriptionInvoiceId: legacy.id }, }, }); await tx.billingInvoiceLineItem.create({ data: { invoiceId: billingInvoice.id, subscriptionId: legacy.subscriptionId, plan: legacy.subscription?.plan ?? null, type: 'SUBSCRIPTION_FEE', description: `${legacy.subscription?.plan ?? 'Subscription'} legacy charge`, quantity: 1, unitAmount: legacy.amount, amount: legacy.amount, currency: legacy.currency, periodStart: legacy.subscription?.currentPeriodStart ?? null, periodEnd: legacy.subscription?.currentPeriodEnd ?? null, metadata: { legacySubscriptionInvoiceId: legacy.id }, }, }); for (const attempt of legacy.attempts ?? []) { await tx.billingPaymentAttempt.create({ data: { invoiceId: billingInvoice.id, billingAccountId: account.id, providerPaymentId: attempt.providerPaymentId, status: attempt.status === 'succeeded' ? 'SUCCEEDED' : attempt.status === 'failed' ? 'FAILED' : 'PENDING', amount: legacy.amount, currency: legacy.currency, failureCode: attempt.failureCode, failureMessage: attempt.failureMessage, attemptedAt: attempt.attemptedAt, metadata: { legacyPaymentAttemptId: attempt.id }, }, }); } await tx.subscriptionInvoice.update({ where: { id: legacy.id }, data: { billingInvoiceId: billingInvoice.id }, }); await createBillingEvent(tx, { billingAccountId: account.id, invoiceId: billingInvoice.id, subscriptionId: legacy.subscriptionId, companyId: legacy.companyId, eventType: 'invoice.created', source: 'legacy_sync', payload: { legacySubscriptionInvoiceId: legacy.id, status }, }); } async function syncLegacySubscriptionInvoices(companyId) { const unsynced = await prisma_1.prisma.subscriptionInvoice.findMany({ where: { billingInvoiceId: null, ...(companyId ? { companyId } : {}), }, include: { subscription: true, attempts: true, }, orderBy: { createdAt: 'asc' }, take: companyId ? 100 : 250, }); for (const legacy of unsynced) { await prisma_1.prisma.$transaction(async (tx) => { const latest = await tx.subscriptionInvoice.findUnique({ where: { id: legacy.id }, include: { subscription: true, attempts: true }, }); if (!latest || latest.billingInvoiceId) return; await syncOneLegacyInvoice(tx, latest); }); } } function buildBillingAccountWhere(query) { const where = {}; if (query.q) { where.OR = [ { legalName: { contains: query.q, mode: 'insensitive' } }, { billingEmail: { contains: query.q, mode: 'insensitive' } }, { company: { name: { contains: query.q, mode: 'insensitive' } } }, { company: { email: { contains: query.q, mode: 'insensitive' } } }, { company: { slug: { contains: query.q, mode: 'insensitive' } } }, ]; } if (query.status || query.plan) { where.company = { subscription: { ...(query.status ? { status: query.status } : {}), ...(query.plan ? { plan: query.plan } : {}), }, }; } return where; } async function listBillingAccounts(query) { await syncLegacySubscriptionInvoices(); const matchingCompanies = await prisma_1.prisma.company.findMany({ where: { ...(query.q ? { OR: [ { name: { contains: query.q, mode: 'insensitive' } }, { email: { contains: query.q, mode: 'insensitive' } }, { slug: { contains: query.q, mode: 'insensitive' } }, ], } : {}), ...(query.status || query.plan ? { subscription: { ...(query.status ? { status: query.status } : {}), ...(query.plan ? { plan: query.plan } : {}), }, } : {}), }, select: { id: true }, take: 250, }); for (const company of matchingCompanies) { await ensurePrimaryBillingAccount(company.id); } const where = buildBillingAccountWhere(query); const [accounts, total, invoiceAgg] = await Promise.all([ prisma_1.prisma.billingAccount.findMany({ where, include: { company: { select: { id: true, name: true, email: true, slug: true, status: true, subscription: { select: { id: true, plan: true, billingPeriod: true, status: true, currentPeriodEnd: true, cancelAtPeriodEnd: true, }, }, }, }, creditBalances: true, invoices: { orderBy: { createdAt: 'desc' }, take: 5, select: { id: true, invoiceNumber: true, invoiceType: true, status: true, totalAmount: true, amountDue: true, amountPaid: true, currency: true, dueAt: true, paidAt: true, createdAt: true, }, }, }, orderBy: { createdAt: 'desc' }, skip: (query.page - 1) * query.pageSize, take: query.pageSize, }), prisma_1.prisma.billingAccount.count({ where }), prisma_1.prisma.billingInvoice.groupBy({ by: ['status'], _sum: { amountDue: true, totalAmount: true }, _count: { _all: true }, }), ]); const invoiceAggItems = invoiceAgg; const stats = { billingAccountCount: total, openInvoiceCount: invoiceAggItems .filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status)) .reduce((sum, item) => sum + item._count._all, 0), pastDueInvoiceCount: invoiceAggItems .filter((item) => item.status === 'PAST_DUE') .reduce((sum, item) => sum + item._count._all, 0), paidInvoiceCount: invoiceAggItems .filter((item) => item.status === 'PAID') .reduce((sum, item) => sum + item._count._all, 0), accountsReceivableTotal: invoiceAggItems .filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status)) .reduce((sum, item) => sum + (item._sum.amountDue ?? 0), 0), recognizedRevenueTotal: invoiceAggItems .filter((item) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(item.status)) .reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0), }; const data = accounts.map((account) => { const openBalance = account.invoices .filter((invoice) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status)) .reduce((sum, invoice) => sum + invoice.amountDue, 0); const paidBalance = account.invoices .filter((invoice) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status)) .reduce((sum, invoice) => sum + invoice.amountPaid, 0); return { ...account, openBalance, paidBalance, creditBalance: account.creditBalances.reduce((sum, item) => sum + item.balanceAmount, 0), }; }); return { data, total, stats }; } async function getBillingAccountDetail(companyId) { await syncLegacySubscriptionInvoices(companyId); await ensurePrimaryBillingAccount(companyId); const account = await prisma_1.prisma.billingAccount.findFirst({ where: { companyId, isPrimary: true }, include: { company: { include: { subscription: true, contractSettings: true, accountingSettings: true, }, }, paymentMethods: true, creditBalances: true, creditLedgerEntries: { orderBy: { createdAt: 'desc' }, take: 50, }, events: { orderBy: { createdAt: 'desc' }, take: 50, }, invoices: { orderBy: { createdAt: 'desc' }, take: 50, include: { lineItems: { orderBy: { createdAt: 'asc' } }, paymentIntents: { orderBy: { createdAt: 'desc' } }, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, taxRecords: true, creditNotes: { orderBy: { createdAt: 'desc' } }, refunds: { orderBy: { createdAt: 'desc' } }, }, }, }, }); if (!account) throw new errors_1.NotFoundError('Billing account not found'); return account; } async function updateBillingAccount(billingAccountId, data, adminId, ip) { const before = await prisma_1.prisma.billingAccount.findUniqueOrThrow({ where: { id: billingAccountId } }); const updated = await prisma_1.prisma.billingAccount.update({ where: { id: billingAccountId }, data: { ...(data.legalName !== undefined ? { legalName: data.legalName } : {}), ...(data.billingEmail !== undefined ? { billingEmail: data.billingEmail } : {}), ...(data.billingAddress !== undefined ? { billingAddress: normalizeAddress(data.billingAddress) } : {}), ...(data.taxId !== undefined ? { taxId: data.taxId } : {}), ...(data.taxExempt !== undefined ? { taxExempt: data.taxExempt } : {}), ...(data.invoiceTerms !== undefined ? { invoiceTerms: data.invoiceTerms } : {}), ...(data.netTermsDays !== undefined ? { netTermsDays: data.netTermsDays } : {}), }, }); await createAuditLog({ adminUserId: adminId, action: 'UPDATE_BILLING_ACCOUNT', resource: 'BillingAccount', resourceId: billingAccountId, companyId: before.companyId, before, after: updated, ipAddress: ip, }); await prisma_1.prisma.billingEvent.create({ data: { billingAccountId, companyId: before.companyId, eventType: 'billing_account.updated', source: 'admin', payload: data, occurredAt: new Date(), }, }); return updated; } async function setDunningPaused(billingAccountId, paused, adminId, ip) { const account = await prisma_1.prisma.billingAccount.findUniqueOrThrow({ where: { id: billingAccountId } }); const updated = await prisma_1.prisma.billingAccount.update({ where: { id: billingAccountId }, data: { dunningPaused: paused, dunningPausedAt: paused ? new Date() : null, dunningPausedBy: paused ? adminId : null, }, }); await createAuditLog({ adminUserId: adminId, action: paused ? 'PAUSE_DUNNING' : 'RESUME_DUNNING', resource: 'BillingAccount', resourceId: billingAccountId, companyId: account.companyId, after: { dunningPaused: paused }, ipAddress: ip, }); await prisma_1.prisma.billingEvent.create({ data: { billingAccountId, companyId: account.companyId, eventType: paused ? 'dunning.paused' : 'dunning.resumed', source: 'admin', payload: { pausedBy: adminId }, occurredAt: new Date(), }, }); return updated; } async function createDraftInvoice(billingAccountId, data, adminId, ip) { if (!data.lineItems.length) throw new errors_1.ValidationError('Invoice requires at least one line item'); const invoice = await prisma_1.prisma.$transaction(async (tx) => { const account = await tx.billingAccount.findUniqueOrThrow({ where: { id: billingAccountId }, include: { company: true }, }); const created = await tx.billingInvoice.create({ data: { billingAccountId, companyId: account.companyId, subscriptionId: data.subscriptionId ?? null, invoiceType: data.invoiceType, status: 'DRAFT', currency: data.currency ?? account.defaultCurrency, dueAt: data.dueAt ? new Date(data.dueAt) : null, isSubscriptionBlocking: data.isSubscriptionBlocking ?? isBlockingInvoiceType(data.invoiceType), adminReason: data.adminReason ?? null, createdByAdminId: adminId, billingName: account.legalName, billingEmail: account.billingEmail, billingAddress: normalizeAddress(account.billingAddress) ?? undefined, metadata: {}, lineItems: { create: data.lineItems.map((item) => ({ type: item.type, description: item.description, quantity: item.quantity, unitAmount: item.unitAmount, amount: item.quantity * item.unitAmount, currency: data.currency ?? account.defaultCurrency, periodStart: item.periodStart ? new Date(item.periodStart) : null, periodEnd: item.periodEnd ? new Date(item.periodEnd) : null, })), }, }, include: { lineItems: true }, }); await createBillingEvent(tx, { billingAccountId, invoiceId: created.id, subscriptionId: created.subscriptionId, companyId: account.companyId, eventType: 'invoice.created', source: 'admin', payload: { invoiceType: created.invoiceType, lineItemCount: data.lineItems.length }, }); return created; }); await createAuditLog({ adminUserId: adminId, action: 'CREATE_BILLING_INVOICE', resource: 'BillingInvoice', resourceId: invoice.id, companyId: invoice.companyId, after: invoice, note: data.adminReason ?? undefined, ipAddress: ip, }); return invoice; } async function finalizeInvoice(invoiceId, adminId, ip) { const invoice = await prisma_1.prisma.$transaction(async (tx) => { const current = await tx.billingInvoice.findUnique({ where: { id: invoiceId }, include: { billingAccount: { include: { company: { include: { contractSettings: true } }, creditBalances: true, }, }, lineItems: true, }, }); if (!current) throw new errors_1.NotFoundError('Invoice not found'); if (current.status !== 'DRAFT') throw new errors_1.ValidationError('Only draft invoices can be finalized'); if (!current.lineItems.length) throw new errors_1.ValidationError('Invoice requires at least one line item'); const account = current.billingAccount; const currency = current.currency || account.defaultCurrency; const existingCreditAmount = Math.abs(current.lineItems.filter((item) => item.type === 'CREDIT').reduce((sum, item) => sum + item.amount, 0)); const { subtotalAmount, discountAmount } = calculateLineAmounts(current.lineItems); const availableBalance = account.creditBalances.find((balance) => balance.currency === currency)?.balanceAmount ?? 0; const preTaxBase = Math.max(subtotalAmount - discountAmount - existingCreditAmount, 0); const autoCreditToApply = Math.min(availableBalance, preTaxBase); if (autoCreditToApply > 0) { await tx.billingInvoiceLineItem.create({ data: { invoiceId: current.id, type: 'CREDIT', description: 'Account credit applied', quantity: 1, unitAmount: -autoCreditToApply, amount: -autoCreditToApply, currency, metadata: { autoApplied: true }, }, }); await tx.billingCreditBalance.update({ where: { billingAccountId_currency: { billingAccountId: account.id, currency, }, }, data: { balanceAmount: { decrement: autoCreditToApply }, }, }); await tx.billingCreditLedgerEntry.create({ data: { billingAccountId: account.id, invoiceId: current.id, type: 'applied', amount: -autoCreditToApply, currency, reason: 'Applied automatically on invoice finalization', createdBy: adminId, }, }); } const taxRate = account.taxExempt ? 0 : Number(account.company.contractSettings?.taxRate ?? 0); const taxBase = Math.max(preTaxBase - autoCreditToApply, 0); const taxAmount = taxRate > 0 ? Math.round(taxBase * (taxRate / 100)) : 0; if (taxAmount > 0) { await tx.billingInvoiceLineItem.create({ data: { invoiceId: current.id, type: 'TAX', description: `Tax (${taxRate}%)`, quantity: 1, unitAmount: taxAmount, amount: taxAmount, currency, }, }); await tx.billingTaxRecord.create({ data: { invoiceId: current.id, taxRate, taxAmount, taxType: 'VAT', taxExempt: false, metadata: {}, }, }); } else if (account.taxExempt) { await tx.billingTaxRecord.create({ data: { invoiceId: current.id, taxRate: 0, taxAmount: 0, taxType: 'EXEMPT', taxExempt: true, exemptionReason: 'Billing account marked tax exempt', metadata: {}, }, }); } const finalLineItems = await tx.billingInvoiceLineItem.findMany({ where: { invoiceId: current.id } }); const amounts = calculateLineAmounts(finalLineItems); const finalizedAt = new Date(); const sequence = await getNextInvoiceSequence(tx); const invoiceNumber = buildSequentialInvoiceNumber(sequence, finalizedAt); const dueAt = current.dueAt ?? addDays(finalizedAt, getInvoiceTermsDays(account.invoiceTerms, account.netTermsDays)); const status = amounts.totalAmount === 0 ? 'PAID' : 'OPEN'; const updated = await tx.billingInvoice.update({ where: { id: current.id }, data: { invoiceNumber, invoiceSequence: sequence, status, subtotalAmount: amounts.subtotalAmount, discountAmount: amounts.discountAmount, creditAmount: amounts.creditAmount, taxAmount: amounts.taxAmount, totalAmount: amounts.totalAmount, amountPaid: 0, amountDue: amounts.totalAmount, invoiceDate: finalizedAt, dueAt, finalizedAt, paidAt: status === 'PAID' ? finalizedAt : null, billingName: account.legalName, billingEmail: account.billingEmail, billingAddress: normalizeAddress(account.billingAddress) ?? undefined, }, include: { lineItems: { orderBy: { createdAt: 'asc' } }, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, taxRecords: true, creditNotes: true, refunds: true, }, }); await createBillingEvent(tx, { billingAccountId: account.id, invoiceId: updated.id, subscriptionId: updated.subscriptionId, companyId: updated.companyId, eventType: 'invoice.finalized', source: 'admin', payload: { invoiceNumber, totalAmount: updated.totalAmount }, }); if (updated.status === 'PAID') { await createBillingEvent(tx, { billingAccountId: account.id, invoiceId: updated.id, subscriptionId: updated.subscriptionId, companyId: updated.companyId, eventType: 'invoice.paid', source: 'system', payload: { method: 'credit_balance' }, }); await maybeRestoreSubscription(tx, updated); } return updated; }); await createAuditLog({ adminUserId: adminId, action: 'FINALIZE_BILLING_INVOICE', resource: 'BillingInvoice', resourceId: invoiceId, companyId: invoice.companyId, after: { status: invoice.status, invoiceNumber: invoice.invoiceNumber }, ipAddress: ip, }); return invoice; } async function payInvoice(invoiceId, data, adminId, ip) { const invoice = await prisma_1.prisma.$transaction(async (tx) => { const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId }, include: { billingAccount: true, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, }, }); if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) { throw new errors_1.ValidationError('Invoice is not payable in its current state'); } const amount = data.amount ?? current.amountDue; if (amount <= 0 || amount > current.amountDue) { throw new errors_1.ValidationError('Payment amount must be greater than zero and no more than the invoice balance'); } const intent = await tx.billingPaymentIntent.create({ data: { invoiceId: current.id, billingAccountId: current.billingAccountId, paymentMethodId: data.paymentMethodId ?? null, providerPaymentIntentId: data.providerPaymentId ?? null, status: 'SUCCEEDED', amount, currency: current.currency, metadata: { source: 'admin_manual_collection' }, }, }); await tx.billingPaymentAttempt.create({ data: { invoiceId: current.id, billingAccountId: current.billingAccountId, paymentIntentId: intent.id, paymentMethodId: data.paymentMethodId ?? null, providerPaymentId: data.providerPaymentId ?? null, status: 'SUCCEEDED', amount, currency: current.currency, attemptedAt: new Date(), metadata: { source: 'admin_manual_collection' }, }, }); const nextAmountPaid = current.amountPaid + amount; const nextAmountDue = Math.max(current.amountDue - amount, 0); const nextStatus = nextAmountDue === 0 ? 'PAID' : 'PARTIALLY_PAID'; const updated = await tx.billingInvoice.update({ where: { id: current.id }, data: { amountPaid: nextAmountPaid, amountDue: nextAmountDue, status: nextStatus, paidAt: nextAmountDue === 0 ? new Date() : current.paidAt, }, include: { lineItems: { orderBy: { createdAt: 'asc' } }, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, taxRecords: true, creditNotes: true, refunds: true, }, }); await createBillingEvent(tx, { billingAccountId: updated.billingAccountId, invoiceId: updated.id, subscriptionId: updated.subscriptionId, companyId: updated.companyId, eventType: nextAmountDue === 0 ? 'invoice.paid' : 'invoice.partially_paid', source: 'admin', payload: { amount, amountPaid: nextAmountPaid, amountDue: nextAmountDue }, }); if (nextAmountDue === 0) { await maybeRestoreSubscription(tx, updated); } return updated; }); await createAuditLog({ adminUserId: adminId, action: 'PAY_BILLING_INVOICE', resource: 'BillingInvoice', resourceId: invoiceId, companyId: invoice.companyId, after: { amountPaid: invoice.amountPaid, amountDue: invoice.amountDue, status: invoice.status }, ipAddress: ip, }); return invoice; } async function retryInvoicePayment(invoiceId, data, adminId, ip) { const invoice = await prisma_1.prisma.$transaction(async (tx) => { const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId }, include: { billingAccount: true }, }); if (!['OPEN', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) { throw new errors_1.ValidationError('Only open or overdue invoices can be retried'); } const intent = await tx.billingPaymentIntent.create({ data: { invoiceId: current.id, billingAccountId: current.billingAccountId, paymentMethodId: data.paymentMethodId ?? current.billingAccount.defaultPaymentMethodId ?? null, status: 'PROCESSING', amount: current.amountDue, currency: current.currency, metadata: { source: 'admin_retry' }, }, }); await tx.billingPaymentAttempt.create({ data: { invoiceId: current.id, billingAccountId: current.billingAccountId, paymentIntentId: intent.id, paymentMethodId: data.paymentMethodId ?? current.billingAccount.defaultPaymentMethodId ?? null, status: 'PENDING', amount: current.amountDue, currency: current.currency, attemptedAt: new Date(), metadata: { source: 'admin_retry' }, }, }); const updated = await tx.billingInvoice.update({ where: { id: current.id }, data: { status: 'PAYMENT_PENDING' }, include: { lineItems: { orderBy: { createdAt: 'asc' } }, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, taxRecords: true, creditNotes: true, refunds: true, }, }); await createBillingEvent(tx, { billingAccountId: updated.billingAccountId, invoiceId: updated.id, subscriptionId: updated.subscriptionId, companyId: updated.companyId, eventType: 'invoice.payment_started', source: 'admin', payload: { amountDue: updated.amountDue }, }); return updated; }); await createAuditLog({ adminUserId: adminId, action: 'RETRY_BILLING_INVOICE_PAYMENT', resource: 'BillingInvoice', resourceId: invoiceId, companyId: invoice.companyId, after: { status: invoice.status }, ipAddress: ip, }); return invoice; } async function voidInvoice(invoiceId, reason, adminId, ip) { const invoice = await prisma_1.prisma.$transaction(async (tx) => { const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } }); if (!['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) { throw new errors_1.ValidationError('Only unpaid invoices can be voided'); } if (current.amountPaid > 0) { throw new errors_1.ValidationError('Paid invoices cannot be voided through this workflow'); } const updated = await tx.billingInvoice.update({ where: { id: invoiceId }, data: { status: 'VOID', amountDue: 0, voidedAt: new Date(), }, include: { lineItems: { orderBy: { createdAt: 'asc' } }, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, taxRecords: true, creditNotes: true, refunds: true, }, }); await createBillingEvent(tx, { billingAccountId: updated.billingAccountId, invoiceId: updated.id, subscriptionId: updated.subscriptionId, companyId: updated.companyId, eventType: 'invoice.voided', source: 'admin', payload: { reason }, }); return updated; }); await createAuditLog({ adminUserId: adminId, action: 'VOID_BILLING_INVOICE', resource: 'BillingInvoice', resourceId: invoiceId, companyId: invoice.companyId, note: reason, after: { status: invoice.status }, ipAddress: ip, }); return invoice; } async function markInvoiceUncollectible(invoiceId, reason, adminId, ip) { const invoice = await prisma_1.prisma.$transaction(async (tx) => { const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } }); if (!['OPEN', 'PAST_DUE', 'PAYMENT_PENDING', 'PARTIALLY_PAID'].includes(current.status)) { throw new errors_1.ValidationError('Only open invoices can be marked uncollectible'); } const updated = await tx.billingInvoice.update({ where: { id: invoiceId }, data: { status: 'UNCOLLECTIBLE', markedUncollectibleAt: new Date(), }, include: { lineItems: { orderBy: { createdAt: 'asc' } }, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, taxRecords: true, creditNotes: true, refunds: true, }, }); await createBillingEvent(tx, { billingAccountId: updated.billingAccountId, invoiceId: updated.id, subscriptionId: updated.subscriptionId, companyId: updated.companyId, eventType: 'invoice.marked_uncollectible', source: 'admin', payload: { reason }, }); await maybeCancelSubscriptionForWriteoff(tx, updated); return updated; }); await createAuditLog({ adminUserId: adminId, action: 'MARK_BILLING_INVOICE_UNCOLLECTIBLE', resource: 'BillingInvoice', resourceId: invoiceId, companyId: invoice.companyId, note: reason, after: { status: invoice.status }, ipAddress: ip, }); return invoice; } async function issueCreditNote(invoiceId, data, adminId, ip) { const invoice = await prisma_1.prisma.$transaction(async (tx) => { const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId }, include: { creditNotes: true }, }); if (!EDITABLE_BILLING_STATUSES.has(current.status)) { throw new errors_1.ValidationError('Credit notes can only be issued against active receivable invoices'); } if (data.amount <= 0 || data.amount > current.amountDue) { throw new errors_1.ValidationError('Credit note amount must be positive and no more than the outstanding balance'); } await tx.billingCreditNote.create({ data: { invoiceId, billingAccountId: current.billingAccountId, amount: data.amount, currency: current.currency, reason: data.reason, status: 'APPLIED', createdBy: adminId, }, }); const nextTotalAmount = Math.max(current.totalAmount - data.amount, 0); const nextAmountDue = Math.max(current.amountDue - data.amount, 0); const updated = await tx.billingInvoice.update({ where: { id: invoiceId }, data: { creditAmount: current.creditAmount + data.amount, totalAmount: nextTotalAmount, amountDue: nextAmountDue, status: nextAmountDue === 0 ? 'PAID' : current.status, paidAt: nextAmountDue === 0 ? new Date() : current.paidAt, }, include: { lineItems: { orderBy: { createdAt: 'asc' } }, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, taxRecords: true, creditNotes: { orderBy: { createdAt: 'desc' } }, refunds: true, }, }); await createBillingEvent(tx, { billingAccountId: updated.billingAccountId, invoiceId: updated.id, subscriptionId: updated.subscriptionId, companyId: updated.companyId, eventType: 'credit.applied', source: 'admin', payload: { amount: data.amount, reason: data.reason }, }); if (nextAmountDue === 0) { await maybeRestoreSubscription(tx, updated); } return updated; }); await createAuditLog({ adminUserId: adminId, action: 'ISSUE_BILLING_CREDIT_NOTE', resource: 'BillingInvoice', resourceId: invoiceId, companyId: invoice.companyId, note: data.reason, after: { creditAmount: invoice.creditAmount, amountDue: invoice.amountDue, status: invoice.status }, ipAddress: ip, }); return invoice; } async function issueRefund(invoiceId, data, adminId, ip) { const invoice = await prisma_1.prisma.$transaction(async (tx) => { const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId }, include: { paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, refunds: true, }, }); if (!['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(current.status)) { throw new errors_1.ValidationError('Only paid invoices can be refunded'); } const refundedSoFar = current.refunds .filter((refund) => refund.status === 'SUCCEEDED') .reduce((sum, refund) => sum + refund.amount, 0); const refundableAmount = Math.max(current.totalAmount - refundedSoFar, 0); if (data.amount <= 0 || data.amount > refundableAmount) { throw new errors_1.ValidationError('Refund amount must be positive and within the refundable amount'); } const latestSuccessfulAttempt = current.paymentAttempts.find((attempt) => attempt.status === 'SUCCEEDED') ?? null; await tx.billingRefund.create({ data: { invoiceId, paymentAttemptId: latestSuccessfulAttempt?.id ?? null, billingAccountId: current.billingAccountId, amount: data.amount, currency: current.currency, reason: data.reason, status: 'SUCCEEDED', createdBy: adminId, }, }); const totalRefunded = refundedSoFar + data.amount; const nextStatus = totalRefunded >= current.totalAmount ? 'REFUNDED' : 'PARTIALLY_REFUNDED'; const updated = await tx.billingInvoice.update({ where: { id: invoiceId }, data: { status: nextStatus }, include: { lineItems: { orderBy: { createdAt: 'asc' } }, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, taxRecords: true, creditNotes: true, refunds: { orderBy: { createdAt: 'desc' } }, }, }); await createBillingEvent(tx, { billingAccountId: updated.billingAccountId, invoiceId: updated.id, subscriptionId: updated.subscriptionId, companyId: updated.companyId, eventType: 'refund.succeeded', source: 'admin', payload: { amount: data.amount, reason: data.reason }, }); return updated; }); await createAuditLog({ adminUserId: adminId, action: 'ISSUE_BILLING_REFUND', resource: 'BillingInvoice', resourceId: invoiceId, companyId: invoice.companyId, note: data.reason, after: { status: invoice.status }, ipAddress: ip, }); return invoice; } async function getInvoicePdf(invoiceId) { await syncLegacySubscriptionInvoices(); const invoice = await prisma_1.prisma.billingInvoice.findUnique({ where: { id: invoiceId }, include: { company: true, subscription: true, lineItems: { orderBy: { createdAt: 'asc' } }, paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, taxRecords: true, creditNotes: { orderBy: { createdAt: 'desc' } }, refunds: { orderBy: { createdAt: 'desc' } }, }, }); if (!invoice) throw new errors_1.NotFoundError('Invoice not found'); if (!invoice.invoiceNumber) throw new errors_1.ValidationError('Invoice must be finalized before a PDF can be generated'); const latestPaymentAttempt = invoice.paymentAttempts.find((attempt) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null; const pdfBuffer = await (0, invoicePdfService_1.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) => ({ 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, taxAmount: invoice.taxAmount, totalAmount: invoice.totalAmount, amountPaid: invoice.amountPaid, amountDue: invoice.amountDue, }, }); return { pdfBuffer, invoiceNumber: invoice.invoiceNumber }; } //# sourceMappingURL=admin.billing.service.js.map