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 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) { 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 } }