7b8f81336a
Build & Push / Pipeline Tests (push) Failing after 2m0s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Failing after 1m7s
554 lines
23 KiB
TypeScript
554 lines
23 KiB
TypeScript
import crypto from 'crypto'
|
||
import { prisma } from '../../lib/prisma'
|
||
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
|
||
import { readPrivateDocument } from '../../lib/storage'
|
||
import { sendNotification } from '../../services/notificationService'
|
||
import { coerceNotificationLocale, type NotificationLocale } from '../../services/notificationLocalizationService'
|
||
import { addBillingPeriod, normalizeExternalReference } from '../subscriptions/subscription.manual.service'
|
||
|
||
const PAYABLE_STATUSES = ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE']
|
||
|
||
const customerPaymentCopy: Record<NotificationLocale, {
|
||
confirmedTitle: string
|
||
confirmed: (details: PaymentConfirmationDetails) => string
|
||
rejectedTitle: string
|
||
rejected: (invoice: string, reason: string) => string
|
||
}> = {
|
||
en: {
|
||
confirmedTitle: 'Subscription payment confirmed',
|
||
confirmed: (details) => buildConfirmedPaymentBody(details),
|
||
rejectedTitle: 'Payment evidence needs attention',
|
||
rejected: (invoice, reason) => `The evidence submitted for invoice ${invoice} was rejected: ${reason}. Upload corrected evidence from Subscription.`,
|
||
},
|
||
fr: {
|
||
confirmedTitle: 'Paiement de l’abonnement confirmé',
|
||
confirmed: (details) => buildConfirmedPaymentBody(details),
|
||
rejectedTitle: 'Justificatif de paiement à corriger',
|
||
rejected: (invoice, reason) => `Le justificatif de la facture ${invoice} a été refusé : ${reason}. Téléversez un justificatif corrigé depuis Abonnement.`,
|
||
},
|
||
ar: {
|
||
confirmedTitle: 'تم تأكيد دفع الاشتراك',
|
||
confirmed: (details) => buildConfirmedPaymentBody(details),
|
||
rejectedTitle: 'مستند الدفع يحتاج إلى تصحيح',
|
||
rejected: (invoice, reason) => `تم رفض مستند الفاتورة ${invoice}: ${reason}. حمّل مستنداً مصححاً من صفحة الاشتراك.`,
|
||
},
|
||
}
|
||
|
||
type PaymentConfirmationDetails = {
|
||
invoice: string
|
||
amountPaid: number
|
||
currency: string
|
||
paymentType?: string | null
|
||
paymentReference?: string | null
|
||
receivedAt?: Date | string | null
|
||
confirmedAt?: Date | string | null
|
||
plan?: string | null
|
||
billingPeriod?: string | null
|
||
periodStart?: Date | string | null
|
||
periodEnd?: Date | string | null
|
||
}
|
||
|
||
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'
|
||
if (method === 'STRIPE') return 'Online card payment'
|
||
return method ?? 'Manual payment'
|
||
}
|
||
|
||
function buildConfirmedPaymentBody(details: PaymentConfirmationDetails) {
|
||
return [
|
||
'Dear customer,',
|
||
'',
|
||
'We confirm that your subscription payment has been verified and recorded. Your subscription is now active for the period shown below.',
|
||
'',
|
||
`Invoice: ${details.invoice}`,
|
||
`Amount paid: ${fmtMoney(details.amountPaid, details.currency)}`,
|
||
`Payment type: ${paymentMethodLabel(details.paymentType)}`,
|
||
details.paymentReference ? `Payment reference: ${details.paymentReference}` : null,
|
||
`Funds received/cleared on: ${fmtDate(details.receivedAt)}`,
|
||
`Payment confirmed on: ${fmtDate(details.confirmedAt)}`,
|
||
`Subscription plan: ${details.plan ?? 'Current plan'}`,
|
||
`Billing period: ${details.billingPeriod ?? 'Current billing period'}`,
|
||
`Subscription start: ${fmtDate(details.periodStart)}`,
|
||
`Subscription end: ${fmtDate(details.periodEnd)}`,
|
||
'',
|
||
'A PDF copy of the invoice is attached for your records.',
|
||
'',
|
||
'Regards,',
|
||
'RentalDriveGo Finance',
|
||
].filter((line): line is string => line !== null).join('\n')
|
||
}
|
||
|
||
async function notifyPaymentResult(data: {
|
||
billingAccountId: string
|
||
companyId: string
|
||
invoiceId: string
|
||
invoiceNumber?: string | null
|
||
kind: 'confirmed' | 'rejected'
|
||
sourceId: string
|
||
reason?: string
|
||
}) {
|
||
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 invoiceRecord = await prisma.billingInvoice.findUnique({
|
||
where: { id: data.invoiceId },
|
||
include: {
|
||
subscription: true,
|
||
lineItems: { orderBy: { createdAt: 'asc' } },
|
||
paymentAttempts: { orderBy: { attemptedAt: 'desc' }, take: 1 },
|
||
},
|
||
})
|
||
const paymentAttempt = invoiceRecord?.paymentAttempts?.[0] ?? null
|
||
const periodStart = invoiceRecord?.subscription?.currentPeriodStart ?? invoiceRecord?.lineItems?.[0]?.periodStart ?? null
|
||
const periodEnd = invoiceRecord?.subscription?.currentPeriodEnd ?? invoiceRecord?.lineItems?.[0]?.periodEnd ?? 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 = customerPaymentCopy[locale]
|
||
const invoice = data.invoiceNumber ?? data.invoiceId
|
||
const details: PaymentConfirmationDetails = {
|
||
invoice,
|
||
amountPaid: invoiceRecord?.amountPaid ?? paymentAttempt?.amount ?? 0,
|
||
currency: invoiceRecord?.currency ?? paymentAttempt?.currency ?? 'MAD',
|
||
paymentType: paymentAttempt?.manualMethod ?? invoiceRecord?.collectionMethod ?? invoiceRecord?.paymentProvider ?? 'MANUAL',
|
||
paymentReference: paymentAttempt?.externalReference ?? paymentAttempt?.providerPaymentId ?? null,
|
||
receivedAt: paymentAttempt?.receivedAt ?? invoiceRecord?.paidAt ?? null,
|
||
confirmedAt: paymentAttempt?.confirmedAt ?? invoiceRecord?.paidAt ?? null,
|
||
plan: invoiceRecord?.requestedPlan ?? invoiceRecord?.subscription?.plan ?? null,
|
||
billingPeriod: invoiceRecord?.requestedBillingPeriod ?? invoiceRecord?.subscription?.billingPeriod ?? null,
|
||
periodStart,
|
||
periodEnd,
|
||
}
|
||
await sendNotification({
|
||
type: data.kind === 'confirmed' ? 'SUBSCRIPTION_PAYMENT_CONFIRMED' : 'MANUAL_PAYMENT_EVIDENCE_REJECTED',
|
||
title: data.kind === 'confirmed' ? copy.confirmedTitle : copy.rejectedTitle,
|
||
body: data.kind === 'confirmed' ? copy.confirmed(details) : copy.rejected(invoice, data.reason ?? ''),
|
||
companyId: data.companyId,
|
||
employeeId: contact.employeeId ?? undefined,
|
||
billingContactId: contact.employeeId ? undefined : contact.id,
|
||
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
|
||
locale,
|
||
templateKey: data.kind === 'confirmed' ? 'subscription.payment_confirmed.v1' : 'subscription.payment_evidence_rejected.v1',
|
||
idempotencyKey: `manual-payment:${data.kind}:${data.sourceId}:${contact.id}`,
|
||
sourceType: 'manual_payment',
|
||
sourceId: data.sourceId,
|
||
data: {
|
||
invoiceId: data.invoiceId,
|
||
amountPaid: details.amountPaid,
|
||
currency: details.currency,
|
||
paymentType: details.paymentType,
|
||
paymentReference: details.paymentReference,
|
||
subscriptionStart: details.periodStart,
|
||
subscriptionEnd: details.periodEnd,
|
||
timezone: account.timezone,
|
||
templateVersion: 1,
|
||
localizationFallback: false,
|
||
...(data.kind === 'confirmed' ? { emailAttachments: [{ type: 'invoice_pdf', invoiceId: data.invoiceId }] } : {}),
|
||
},
|
||
policy: { mandatory: true },
|
||
})
|
||
}
|
||
}
|
||
|
||
function requestHash(value: Record<string, unknown>) {
|
||
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||
}
|
||
|
||
export async function listManualPaymentSubmissions(query: { status: string; page: number; pageSize: number }) {
|
||
const where = { status: query.status as any }
|
||
const [data, total] = await Promise.all([
|
||
prisma.manualPaymentSubmission.findMany({
|
||
where,
|
||
include: {
|
||
invoice: { include: { company: true, subscription: true } },
|
||
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
|
||
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||
},
|
||
orderBy: { submittedAt: 'asc' },
|
||
skip: (query.page - 1) * query.pageSize,
|
||
take: query.pageSize,
|
||
}),
|
||
prisma.manualPaymentSubmission.count({ where }),
|
||
])
|
||
return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.max(1, Math.ceil(total / query.pageSize)) }
|
||
}
|
||
|
||
export async function getManualPaymentSubmission(submissionId: string, adminId?: string) {
|
||
let submission = await prisma.manualPaymentSubmission.findUnique({
|
||
where: { id: submissionId },
|
||
include: {
|
||
invoice: {
|
||
include: {
|
||
company: { select: { id: true, name: true, email: true } },
|
||
subscription: true,
|
||
billingAccount: true,
|
||
lineItems: true,
|
||
},
|
||
},
|
||
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
|
||
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||
reviewedByAdmin: { select: { id: true, firstName: true, lastName: true } },
|
||
},
|
||
})
|
||
if (!submission) throw new NotFoundError('Payment submission not found')
|
||
if (adminId && submission.status === 'SUBMITTED') {
|
||
await prisma.manualPaymentSubmission.updateMany({
|
||
where: { id: submission.id, status: 'SUBMITTED' },
|
||
data: { status: 'UNDER_REVIEW', reviewedByAdminId: adminId, reviewedAt: new Date() },
|
||
})
|
||
return getManualPaymentSubmission(submissionId)
|
||
}
|
||
return submission
|
||
}
|
||
|
||
export async function getAdminPaymentDocument(submissionId: string, documentId: string, adminId: string, ip?: string) {
|
||
const document = await prisma.manualPaymentDocument.findFirst({
|
||
where: { id: documentId, submissionId, deletedAt: null, scanStatus: 'CLEAN' },
|
||
include: { submission: { include: { invoice: true } } },
|
||
})
|
||
if (!document || document.submission.invoiceId !== document.invoiceId) throw new NotFoundError('Clean payment evidence document not found')
|
||
await prisma.auditLog.create({
|
||
data: {
|
||
adminUserId: adminId,
|
||
action: 'VIEW_MANUAL_PAYMENT_EVIDENCE',
|
||
resource: 'ManualPaymentDocument',
|
||
resourceId: document.id,
|
||
companyId: document.companyId,
|
||
ipAddress: ip,
|
||
after: { submissionId, invoiceId: document.invoiceId, sha256: document.sha256 },
|
||
},
|
||
})
|
||
return { document, bytes: readPrivateDocument(document.storageKey) }
|
||
}
|
||
|
||
export async function rejectManualPaymentSubmission(submissionId: string, reason: string, adminId: string, ip?: string) {
|
||
const updated = await prisma.$transaction(async (tx: any) => {
|
||
const current = await tx.manualPaymentSubmission.findUnique({
|
||
where: { id: submissionId },
|
||
include: { invoice: true },
|
||
})
|
||
if (!current) throw new NotFoundError('Payment submission not found')
|
||
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(current.status)) throw new ConflictError('Submission cannot be rejected in its current state')
|
||
const reviewedAt = new Date()
|
||
const updated = await tx.manualPaymentSubmission.update({
|
||
where: { id: current.id },
|
||
data: { status: 'REJECTED', rejectionReason: reason, reviewedByAdminId: adminId, reviewedAt },
|
||
include: { documents: { where: { deletedAt: null } } },
|
||
})
|
||
await tx.billingEvent.create({
|
||
data: {
|
||
billingAccountId: current.billingAccountId,
|
||
invoiceId: current.invoiceId,
|
||
subscriptionId: current.invoice.subscriptionId,
|
||
companyId: current.companyId,
|
||
eventType: 'payment_evidence.rejected',
|
||
source: 'admin',
|
||
payload: { submissionId, reason, adminId },
|
||
occurredAt: reviewedAt,
|
||
},
|
||
})
|
||
await tx.auditLog.create({
|
||
data: {
|
||
adminUserId: adminId,
|
||
action: 'REJECT_MANUAL_PAYMENT_EVIDENCE',
|
||
resource: 'ManualPaymentSubmission',
|
||
resourceId: submissionId,
|
||
companyId: current.companyId,
|
||
before: { status: current.status },
|
||
after: { status: 'REJECTED', reason },
|
||
ipAddress: ip,
|
||
},
|
||
})
|
||
return { ...updated, invoice: current.invoice }
|
||
})
|
||
await notifyPaymentResult({
|
||
billingAccountId: updated.billingAccountId,
|
||
companyId: updated.companyId,
|
||
invoiceId: updated.invoiceId,
|
||
invoiceNumber: updated.invoice.invoiceNumber,
|
||
kind: 'rejected',
|
||
sourceId: updated.id,
|
||
reason,
|
||
})
|
||
return updated
|
||
}
|
||
|
||
export async function confirmManualPayment(invoiceId: string, data: {
|
||
submissionId: string
|
||
method: 'BANK_TRANSFER' | 'CHECK'
|
||
externalReference: string
|
||
amount: number
|
||
receivedAt: string
|
||
note?: string
|
||
correctionReason?: string
|
||
idempotencyKey: string
|
||
fundsVerified: true
|
||
}, adminId: string, ip?: string) {
|
||
const normalizedReference = normalizeExternalReference(data.externalReference)
|
||
const normalizedPayload = {
|
||
invoiceId,
|
||
submissionId: data.submissionId,
|
||
method: data.method,
|
||
normalizedReference,
|
||
amount: data.amount,
|
||
receivedAt: new Date(data.receivedAt).toISOString(),
|
||
note: data.note ?? null,
|
||
correctionReason: data.correctionReason ?? null,
|
||
fundsVerified: true,
|
||
}
|
||
const hash = requestHash(normalizedPayload)
|
||
const receivedAt = new Date(data.receivedAt)
|
||
if (receivedAt.getTime() > Date.now() + 5 * 60 * 1000) throw new ValidationError('Settlement time cannot be in the future')
|
||
|
||
let collectionsCaseId: string | null = null
|
||
try {
|
||
const result = await prisma.$transaction(async (tx: any) => {
|
||
const current = await tx.billingInvoice.findUnique({
|
||
where: { id: invoiceId },
|
||
include: {
|
||
billingAccount: true,
|
||
subscription: true,
|
||
legacySubscriptionInvoice: true,
|
||
collectionsCase: true,
|
||
manualPaymentSubmissions: {
|
||
where: { id: data.submissionId },
|
||
include: { documents: { where: { deletedAt: null } } },
|
||
},
|
||
},
|
||
})
|
||
if (!current) throw new NotFoundError('Invoice not found')
|
||
if (current.invoiceType === 'SUBSCRIPTION_UPGRADE') {
|
||
throw new ValidationError('Subscription upgrade invoices must be approved through the upgrade review workflow')
|
||
}
|
||
|
||
const duplicate = await tx.billingPaymentAttempt.findFirst({
|
||
where: { billingAccountId: current.billingAccountId, idempotencyKey: data.idempotencyKey },
|
||
include: { invoice: true },
|
||
})
|
||
if (duplicate) {
|
||
const metadata = duplicate.metadata as any
|
||
if (duplicate.invoiceId !== invoiceId || metadata?.confirmationRequestHash !== hash) {
|
||
throw new ConflictError('Idempotency key was already used with a different payment confirmation')
|
||
}
|
||
return { invoice: duplicate.invoice, paymentAttempt: duplicate, duplicate: true }
|
||
}
|
||
|
||
if (!PAYABLE_STATUSES.includes(current.status)) throw new ConflictError('Invoice is not payable in its current state')
|
||
if (current.collectionMethod !== data.method) throw new ValidationError('Payment method must match the invoice collection method')
|
||
if (current.currency !== 'MAD') throw new ValidationError('Manual subscription confirmation requires MAD currency')
|
||
if (data.amount !== current.amountDue || data.amount !== current.totalAmount - current.amountPaid) {
|
||
throw new ConflictError('The full current invoice balance must be confirmed')
|
||
}
|
||
const submission = current.manualPaymentSubmissions[0]
|
||
if (!submission || submission.invoiceId !== current.id || submission.companyId !== current.companyId) {
|
||
throw new ValidationError('Submission does not belong to this invoice')
|
||
}
|
||
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(submission.status)) throw new ConflictError('Evidence is not awaiting review')
|
||
if (submission.method !== data.method) throw new ValidationError('Submission method does not match the confirmation method')
|
||
if (!submission.documents.length || submission.documents.some((document: any) => document.scanStatus !== 'CLEAN')) {
|
||
throw new ValidationError('Every attached evidence document must be clean')
|
||
}
|
||
if (submission.normalizedSubmittedReference !== normalizedReference && !data.correctionReason) {
|
||
throw new ValidationError('A correction reason is required when the confirmed reference differs from the submitted reference')
|
||
}
|
||
|
||
const confirmedAt = new Date()
|
||
const intent = await tx.billingPaymentIntent.create({
|
||
data: {
|
||
invoiceId: current.id,
|
||
billingAccountId: current.billingAccountId,
|
||
status: 'SUCCEEDED',
|
||
amount: data.amount,
|
||
currency: current.currency,
|
||
metadata: { source: 'admin_manual_subscription_confirmation', method: data.method },
|
||
},
|
||
})
|
||
const attempt = await tx.billingPaymentAttempt.create({
|
||
data: {
|
||
invoiceId: current.id,
|
||
billingAccountId: current.billingAccountId,
|
||
paymentIntentId: intent.id,
|
||
channel: 'OFFLINE',
|
||
manualMethod: data.method,
|
||
externalReference: data.externalReference,
|
||
normalizedExternalReference: normalizedReference,
|
||
receivedAt,
|
||
confirmedAt,
|
||
confirmedByAdminId: adminId,
|
||
idempotencyKey: data.idempotencyKey,
|
||
note: data.note ?? null,
|
||
status: 'SUCCEEDED',
|
||
amount: data.amount,
|
||
currency: current.currency,
|
||
attemptedAt: confirmedAt,
|
||
metadata: {
|
||
source: 'admin_manual_subscription_confirmation',
|
||
submissionId: submission.id,
|
||
confirmationRequestHash: hash,
|
||
correctionReason: data.correctionReason ?? null,
|
||
fundsVerified: true,
|
||
},
|
||
},
|
||
})
|
||
|
||
const paid = await tx.billingInvoice.updateMany({
|
||
where: { id: current.id, status: { in: PAYABLE_STATUSES }, amountDue: data.amount },
|
||
data: { status: 'PAID', amountPaid: { increment: data.amount }, amountDue: 0, paidAt: confirmedAt },
|
||
})
|
||
if (paid.count !== 1) throw new ConflictError('Invoice changed while payment was being confirmed')
|
||
|
||
await tx.manualPaymentSubmission.update({
|
||
where: { id: submission.id },
|
||
data: {
|
||
status: 'APPROVED',
|
||
reviewedByAdminId: adminId,
|
||
reviewedAt: confirmedAt,
|
||
paymentAttemptId: attempt.id,
|
||
},
|
||
})
|
||
|
||
if (current.legacySubscriptionInvoice) {
|
||
await tx.subscriptionInvoice.update({
|
||
where: { id: current.legacySubscriptionInvoice.id },
|
||
data: { status: 'PAID', paidAt: confirmedAt, failedAt: null },
|
||
})
|
||
}
|
||
|
||
if (!current.subscription) throw new ValidationError('Subscription invoice is missing its subscription')
|
||
const period = (current.requestedBillingPeriod ?? current.subscription.billingPeriod) as 'MONTHLY' | 'ANNUAL'
|
||
const isRenewal = current.invoiceType === 'SUBSCRIPTION_RENEWAL'
|
||
const periodStart = isRenewal
|
||
? (current.collectionsCase?.originalExpirationAt ?? current.subscription.currentPeriodEnd ?? confirmedAt)
|
||
: confirmedAt
|
||
await tx.subscription.update({
|
||
where: { id: current.subscription.id },
|
||
data: {
|
||
plan: current.requestedPlan ?? current.subscription.plan,
|
||
billingPeriod: period,
|
||
currency: current.currency,
|
||
status: 'ACTIVE',
|
||
currentPeriodStart: periodStart,
|
||
currentPeriodEnd: addBillingPeriod(periodStart, period),
|
||
paymentPendingSince: null,
|
||
paymentDueAt: null,
|
||
pastDueSince: null,
|
||
suspendedAt: null,
|
||
retryCount: 0,
|
||
},
|
||
})
|
||
|
||
if (current.collectionsCase) {
|
||
collectionsCaseId = current.collectionsCase.id
|
||
await tx.collectionsCase.update({
|
||
where: { id: current.collectionsCase.id },
|
||
data: {
|
||
status: 'RESOLVED',
|
||
resolvedAt: confirmedAt,
|
||
nextActionAt: null,
|
||
resolutionPaymentAttemptId: attempt.id,
|
||
},
|
||
})
|
||
await tx.collectionsCallTask.updateMany({
|
||
where: { collectionsCaseId: current.collectionsCase.id, status: 'OPEN' },
|
||
data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' },
|
||
})
|
||
await tx.collectionsEvent.create({
|
||
data: {
|
||
collectionsCaseId: current.collectionsCase.id,
|
||
companyId: current.companyId,
|
||
eventType: 'collections.resolved',
|
||
idempotencyKey: `payment:${attempt.id}`,
|
||
actorType: 'admin',
|
||
actorId: adminId,
|
||
payload: { paymentAttemptId: attempt.id },
|
||
},
|
||
})
|
||
}
|
||
|
||
await tx.billingEvent.create({
|
||
data: {
|
||
billingAccountId: current.billingAccountId,
|
||
invoiceId: current.id,
|
||
subscriptionId: current.subscription.id,
|
||
companyId: current.companyId,
|
||
eventType: 'invoice.paid',
|
||
source: 'admin',
|
||
payload: { paymentAttemptId: attempt.id, method: data.method, submissionId: submission.id },
|
||
occurredAt: confirmedAt,
|
||
},
|
||
})
|
||
await tx.subscriptionEvent.create({
|
||
data: {
|
||
subscriptionId: current.subscription.id,
|
||
companyId: current.companyId,
|
||
eventType: 'subscription.activated',
|
||
source: 'admin',
|
||
payload: { invoiceId: current.id, paymentAttemptId: attempt.id },
|
||
occurredAt: confirmedAt,
|
||
},
|
||
})
|
||
await tx.auditLog.create({
|
||
data: {
|
||
adminUserId: adminId,
|
||
action: 'CONFIRM_MANUAL_SUBSCRIPTION_PAYMENT',
|
||
resource: 'BillingInvoice',
|
||
resourceId: current.id,
|
||
companyId: current.companyId,
|
||
before: { status: current.status, amountDue: current.amountDue },
|
||
after: { status: 'PAID', amountDue: 0, paymentAttemptId: attempt.id, method: data.method },
|
||
note: data.note,
|
||
ipAddress: ip,
|
||
},
|
||
})
|
||
|
||
const invoice = await tx.billingInvoice.findUniqueOrThrow({
|
||
where: { id: current.id },
|
||
include: { paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, manualPaymentSubmissions: { include: { documents: true } } },
|
||
})
|
||
return { invoice, paymentAttempt: attempt, duplicate: false }
|
||
}, { isolationLevel: 'Serializable' as any })
|
||
|
||
if (collectionsCaseId) {
|
||
await prisma.notificationOutbox.updateMany({
|
||
where: {
|
||
status: 'PENDING',
|
||
notificationEvent: { sourceType: 'collections_case', sourceId: collectionsCaseId },
|
||
},
|
||
data: { status: 'PUBLISHED', failureReason: 'Suppressed because payment was confirmed' },
|
||
})
|
||
}
|
||
await notifyPaymentResult({
|
||
billingAccountId: result.invoice.billingAccountId,
|
||
companyId: result.invoice.companyId,
|
||
invoiceId: result.invoice.id,
|
||
invoiceNumber: result.invoice.invoiceNumber,
|
||
kind: 'confirmed',
|
||
sourceId: result.paymentAttempt.id,
|
||
})
|
||
return result
|
||
} catch (error: any) {
|
||
if (error?.code === 'P2002' || error?.code === 'P2034') {
|
||
throw new ConflictError('The payment reference, idempotency key, or invoice was confirmed concurrently')
|
||
}
|
||
throw error
|
||
}
|
||
}
|