fix billing and 2fa admin
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s
This commit is contained in:
@@ -3,8 +3,11 @@ import {
|
||||
billingAccountUpdateSchema,
|
||||
billingCreditNoteSchema,
|
||||
billingRefundSchema,
|
||||
collectionsOverrideSchema,
|
||||
confirmManualPaymentSchema,
|
||||
createBillingInvoiceSchema,
|
||||
payBillingInvoiceSchema,
|
||||
platformBillingSettingsSchema,
|
||||
} from './admin.schemas'
|
||||
|
||||
describe('admin billing schemas', () => {
|
||||
@@ -44,10 +47,38 @@ describe('admin billing schemas', () => {
|
||||
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
|
||||
})
|
||||
|
||||
it('bounds platform billing tax settings', () => {
|
||||
expect(platformBillingSettingsSchema.parse({ taxRate: 20 })).toEqual({ taxRate: 20 })
|
||||
expect(platformBillingSettingsSchema.safeParse({ taxRate: -1 }).success).toBe(false)
|
||||
expect(platformBillingSettingsSchema.safeParse({ taxRate: 101 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires positive money movements for payments, credit notes, and refunds', () => {
|
||||
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
|
||||
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
|
||||
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
|
||||
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
|
||||
})
|
||||
|
||||
it('requires cleared-funds attestation and an idempotency key for manual subscription settlement', () => {
|
||||
const confirmation = {
|
||||
submissionId: 'submission_1',
|
||||
method: 'BANK_TRANSFER',
|
||||
externalReference: 'BANK TXN 123',
|
||||
amount: 19900,
|
||||
receivedAt: '2026-08-09T12:00:00.000Z',
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
fundsVerified: true,
|
||||
}
|
||||
expect(confirmManualPaymentSchema.safeParse(confirmation).success).toBe(true)
|
||||
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, fundsVerified: false }).success).toBe(false)
|
||||
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, amount: 0 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps suspension and notification override controls separate', () => {
|
||||
const base = { reason: 'Verified finance dispute', expiresAt: '2026-09-01T12:00:00.000Z' }
|
||||
expect(collectionsOverrideSchema.parse({ ...base, type: 'PAYMENT_DISPUTE', pauseSuspension: true, pauseNotifications: false })).toMatchObject({ pauseSuspension: true, pauseNotifications: false })
|
||||
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION' }).success).toBe(false)
|
||||
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION', revisedSuspensionAt: '2026-08-25T12:00:00.000Z' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listBillingAccounts } from './admin.billing.service'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
subscriptionInvoice: { findMany: vi.fn() },
|
||||
company: { findMany: vi.fn(), findUniqueOrThrow: vi.fn() },
|
||||
billingAccount: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
billingInvoice: { groupBy: vi.fn(), findFirst: vi.fn(), create: vi.fn() },
|
||||
billingCreditBalance: { create: vi.fn() },
|
||||
billingEvent: { create: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../services/invoicePdfService', () => ({
|
||||
generateInvoicePdf: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('admin billing service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(prisma.subscriptionInvoice.findMany).mockResolvedValue([])
|
||||
vi.mocked(prisma.company.findMany).mockResolvedValue([{ id: 'company_1' }] as never)
|
||||
vi.mocked(prisma.billingAccount.count).mockResolvedValue(1 as never)
|
||||
vi.mocked(prisma.billingInvoice.groupBy)
|
||||
.mockResolvedValueOnce([
|
||||
{ billingAccountId: 'billing_empty', _count: { _all: 0 }, _sum: { amountDue: 0 } },
|
||||
{ billingAccountId: 'billing_due', _count: { _all: 1 }, _sum: { amountDue: 14900 } },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ status: 'OPEN', _count: { _all: 1 }, _sum: { amountDue: 14900, totalAmount: 14900 } },
|
||||
] as never)
|
||||
})
|
||||
|
||||
it('demotes duplicate primary billing accounts and lists only the canonical company row', async () => {
|
||||
vi.mocked(prisma.billingAccount.findMany)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'billing_empty', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-10T12:00:00.000Z'), creditBalances: [] },
|
||||
{ id: 'billing_due', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-09T12:00:00.000Z'), creditBalances: [] },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'billing_due',
|
||||
companyId: 'company_1',
|
||||
isPrimary: true,
|
||||
legalName: 'Atlas car',
|
||||
billingEmail: 'moulay.elabidi@gmail.com',
|
||||
createdAt: new Date('2026-08-09T12:00:00.000Z'),
|
||||
company: { id: 'company_1', name: 'Atlas car', email: 'moulay.elabidi@gmail.com', slug: 'atlas-car', status: 'PENDING', subscription: { status: 'PAYMENT_PENDING' } },
|
||||
creditBalances: [],
|
||||
invoices: [{ id: 'invoice_1', status: 'OPEN', amountDue: 14900, amountPaid: 0, currency: 'MAD' }],
|
||||
},
|
||||
] as never)
|
||||
|
||||
const result = await listBillingAccounts({ page: 1, pageSize: 100 })
|
||||
|
||||
expect(prisma.billingAccount.updateMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1', id: { not: 'billing_due' }, isPrimary: true },
|
||||
data: { isPrimary: false },
|
||||
})
|
||||
expect(prisma.billingAccount.findMany).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
where: { isPrimary: true },
|
||||
}))
|
||||
expect(result.data).toHaveLength(1)
|
||||
expect(result.data[0].id).toBe('billing_due')
|
||||
expect(result.data[0].openBalance).toBe(14900)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { NotFoundError, ValidationError } from '../../http/errors'
|
||||
import { generateInvoicePdf } from '../../services/invoicePdfService'
|
||||
import { calculateTaxAmount, getPlatformBillingSettings, updatePlatformBillingSettings } from '../subscriptions/billingTax'
|
||||
|
||||
const BLOCKING_INVOICE_TYPES = new Set([
|
||||
'SUBSCRIPTION_INITIAL',
|
||||
@@ -10,7 +11,6 @@ const BLOCKING_INVOICE_TYPES = new Set([
|
||||
])
|
||||
|
||||
const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'])
|
||||
|
||||
function toSequenceNumber(value: unknown) {
|
||||
if (typeof value === 'bigint') return Number(value)
|
||||
if (typeof value === 'number') return value
|
||||
@@ -85,6 +85,10 @@ function calculateLineAmounts(items: Array<{ type: string; amount: number }>) {
|
||||
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }
|
||||
}
|
||||
|
||||
function invoiceTaxRate(invoice: { taxRecords?: Array<{ taxRate?: number | null; taxExempt?: boolean }> }) {
|
||||
return invoice.taxRecords?.find((record) => !record.taxExempt && typeof record.taxRate === 'number')?.taxRate ?? null
|
||||
}
|
||||
|
||||
function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances?: any[] }>(account: T) {
|
||||
const invoices = account.invoices ?? []
|
||||
const creditBalances = account.creditBalances ?? []
|
||||
@@ -104,6 +108,22 @@ function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenBalance(invoices: any[] = []) {
|
||||
return invoices
|
||||
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
|
||||
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
|
||||
}
|
||||
|
||||
function chooseCanonicalBillingAccount<T extends { id: string; createdAt?: Date; invoices?: any[] }>(accounts: T[]) {
|
||||
return [...accounts].sort((a: any, b: any) => {
|
||||
const openBalanceDelta = getOpenBalance(b.invoices) - getOpenBalance(a.invoices)
|
||||
if (openBalanceDelta) return openBalanceDelta
|
||||
const invoiceCountDelta = (b.invoices?.length ?? 0) - (a.invoices?.length ?? 0)
|
||||
if (invoiceCountDelta) return invoiceCountDelta
|
||||
return new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()
|
||||
})[0]
|
||||
}
|
||||
|
||||
async function createBillingEvent(tx: any, data: {
|
||||
billingAccountId?: string | null
|
||||
invoiceId?: string | null
|
||||
@@ -142,11 +162,41 @@ async function createAuditLog(data: {
|
||||
}
|
||||
|
||||
async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) {
|
||||
const existing = await tx.billingAccount.findFirst({
|
||||
const existingAccounts = await tx.billingAccount.findMany({
|
||||
where: { companyId, isPrimary: true },
|
||||
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (existing) return existing
|
||||
if (existingAccounts.length) {
|
||||
if (existingAccounts.length > 1) {
|
||||
const accountIds = existingAccounts.map((account: any) => account.id)
|
||||
const invoiceGroups = await tx.billingInvoice.groupBy({
|
||||
by: ['billingAccountId'],
|
||||
where: { billingAccountId: { in: accountIds } },
|
||||
_count: { _all: true },
|
||||
_sum: { amountDue: true },
|
||||
})
|
||||
const invoiceGroupByAccount = new Map<string, any>(invoiceGroups.map((group: any) => [group.billingAccountId, group]))
|
||||
const canonical = chooseCanonicalBillingAccount(existingAccounts.map((account: any) => {
|
||||
const group = invoiceGroupByAccount.get(account.id)
|
||||
const invoiceCount = group?._count?._all ?? 0
|
||||
const amountDue = group?._sum?.amountDue ?? 0
|
||||
return {
|
||||
...account,
|
||||
invoices: Array.from({ length: invoiceCount }, () => ({
|
||||
status: 'OPEN',
|
||||
amountDue: Math.floor(amountDue / Math.max(invoiceCount, 1)),
|
||||
})),
|
||||
}
|
||||
}))
|
||||
await tx.billingAccount.updateMany({
|
||||
where: { companyId, id: { not: canonical.id }, isPrimary: true },
|
||||
data: { isPrimary: false },
|
||||
})
|
||||
return existingAccounts.find((account: any) => account.id === canonical.id) ?? canonical
|
||||
}
|
||||
return existingAccounts[0]
|
||||
}
|
||||
|
||||
const company = await tx.company.findUniqueOrThrow({
|
||||
where: { id: companyId },
|
||||
@@ -346,7 +396,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
|
||||
}
|
||||
|
||||
function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) {
|
||||
const where: any = {}
|
||||
const where: any = { isPrimary: true }
|
||||
if (query.q) {
|
||||
where.OR = [
|
||||
{ legalName: { contains: query.q, mode: 'insensitive' } },
|
||||
@@ -510,6 +560,13 @@ export async function getBillingAccountDetail(companyId: string) {
|
||||
lineItems: { orderBy: { createdAt: 'asc' } },
|
||||
paymentIntents: { orderBy: { createdAt: 'desc' } },
|
||||
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
|
||||
manualPaymentSubmissions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
|
||||
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
},
|
||||
},
|
||||
taxRecords: true,
|
||||
creditNotes: { orderBy: { createdAt: 'desc' } },
|
||||
refunds: { orderBy: { createdAt: 'desc' } },
|
||||
@@ -575,6 +632,25 @@ export async function updateBillingAccount(
|
||||
return updated
|
||||
}
|
||||
|
||||
export function getBillingPlatformSettings() {
|
||||
return getPlatformBillingSettings()
|
||||
}
|
||||
|
||||
export async function updateBillingPlatformSettings(data: { taxRate: number }, adminId: string, ip?: string) {
|
||||
const before = await getPlatformBillingSettings()
|
||||
const updated = await updatePlatformBillingSettings({ taxRate: data.taxRate, updatedBy: adminId })
|
||||
await createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: 'UPDATE_PLATFORM_BILLING_SETTINGS',
|
||||
resource: 'PlatformBillingSettings',
|
||||
resourceId: updated.id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: ip,
|
||||
})
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function setDunningPaused(
|
||||
billingAccountId: string,
|
||||
paused: boolean,
|
||||
@@ -704,6 +780,7 @@ export async function createDraftInvoice(
|
||||
}
|
||||
|
||||
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
|
||||
const platformBillingSettings = await getPlatformBillingSettings()
|
||||
const invoice = await prisma.$transaction(async (tx: any) => {
|
||||
const current = await tx.billingInvoice.findUnique({
|
||||
where: { id: invoiceId },
|
||||
@@ -771,9 +848,8 @@ export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: s
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
const { taxRate, taxAmount } = calculateTaxAmount(taxBase, account.taxExempt, platformBillingSettings.taxRate)
|
||||
|
||||
if (taxAmount > 0) {
|
||||
await tx.billingInvoiceLineItem.create({
|
||||
@@ -911,6 +987,9 @@ export async function payInvoice(
|
||||
if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
|
||||
throw new ValidationError('Invoice is not payable in its current state')
|
||||
}
|
||||
if (current.subscriptionId) {
|
||||
throw new ValidationError('Subscription invoices require provider confirmation or the dedicated cleared-manual-payment workflow')
|
||||
}
|
||||
|
||||
const amount = data.amount ?? current.amountDue
|
||||
if (amount <= 0 || amount > current.amountDue) {
|
||||
@@ -1389,6 +1468,7 @@ export async function getInvoicePdf(invoiceId: string) {
|
||||
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,
|
||||
@@ -1401,6 +1481,7 @@ export async function getInvoicePdf(invoiceId: string) {
|
||||
subtotalAmount: invoice.subtotalAmount,
|
||||
discountAmount: invoice.discountAmount,
|
||||
creditAmount: invoice.creditAmount,
|
||||
taxRate: invoiceTaxRate(invoice),
|
||||
taxAmount: invoice.taxAmount,
|
||||
totalAmount: invoice.totalAmount,
|
||||
amountPaid: invoice.amountPaid,
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
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')
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,7 @@ export function createAdmin(data: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
preferredLocale: string
|
||||
passwordHash: string
|
||||
permissions?: any[]
|
||||
}) {
|
||||
@@ -354,6 +355,7 @@ export function createAdmin(data: {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
role: data.role as any,
|
||||
preferredLocale: data.preferredLocale,
|
||||
passwordHash: data.passwordHash,
|
||||
permissions: data.permissions ? { create: data.permissions } : undefined,
|
||||
},
|
||||
@@ -372,6 +374,7 @@ export function updateAdmin(
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
role?: string
|
||||
preferredLocale?: string
|
||||
passwordHash?: string
|
||||
isActive?: boolean
|
||||
},
|
||||
@@ -383,6 +386,7 @@ export function updateAdmin(
|
||||
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
|
||||
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
|
||||
...(data.role !== undefined ? { role: data.role as any } : {}),
|
||||
...(data.preferredLocale !== undefined ? { preferredLocale: data.preferredLocale } : {}),
|
||||
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
|
||||
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,9 @@ import { setSessionCookie, clearSessionCookie } from '../../security/sessionCook
|
||||
import * as service from './admin.service'
|
||||
import * as subService from '../subscriptions/subscription.service'
|
||||
import * as menuService from '../menu/menu.service'
|
||||
import * as manualPaymentsService from './admin.manual-payments.service'
|
||||
import * as collectionsService from '../subscriptions/subscription.collections.service'
|
||||
import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService'
|
||||
import { presentAdminUser } from './admin.presenter'
|
||||
import {
|
||||
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
|
||||
@@ -17,9 +20,14 @@ import {
|
||||
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
|
||||
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
|
||||
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
|
||||
platformBillingSettingsSchema,
|
||||
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
|
||||
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
|
||||
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
|
||||
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
|
||||
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
|
||||
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
|
||||
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
|
||||
} from './admin.schemas'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -279,6 +287,17 @@ router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), asyn
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/notifications/me', requireAdminAuth, async (req, res, next) => {
|
||||
try { ok(res, await getAdminNotificationInbox(req.admin.id)) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/notifications/me/:id/read', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, await markAdminNotificationRead(req.admin.id, id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Audit logs ────────────────────────────────────────────────
|
||||
|
||||
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||
@@ -344,6 +363,98 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/manual-payment-submissions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try { ok(res, await manualPaymentsService.listManualPaymentSubmissions(parseQuery(manualPaymentSubmissionsQuerySchema, req))) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/manual-payment-submissions/:submissionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
|
||||
ok(res, await manualPaymentsService.getManualPaymentSubmission(submissionId, req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/manual-payment-submissions/:submissionId/documents/:documentId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { submissionId, documentId } = parseParams(manualPaymentDocumentParamsSchema, req)
|
||||
const { document, bytes } = await manualPaymentsService.getAdminPaymentDocument(submissionId, documentId, req.admin.id, req.ip)
|
||||
const safeName = document.originalFilename.replace(/["\\\r\n]/g, '_')
|
||||
res.setHeader('Content-Type', document.detectedMimeType)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`)
|
||||
res.setHeader('Content-Length', bytes.length)
|
||||
res.setHeader('Cache-Control', 'private, no-store')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.end(bytes)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/manual-payment-submissions/:submissionId/reject', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
|
||||
const { reason } = parseBody(rejectManualPaymentSubmissionSchema, req)
|
||||
ok(res, await manualPaymentsService.rejectManualPaymentSubmission(submissionId, reason, req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/invoices/:invoiceId/manual-payments', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||
try {
|
||||
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
|
||||
ok(res, await manualPaymentsService.confirmManualPayment(invoiceId, parseBody(confirmManualPaymentSchema, req), req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/collections', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try { ok(res, await collectionsService.listCollectionsCases(parseQuery(collectionsQuerySchema, req))) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/collections/:caseId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
|
||||
ok(res, await collectionsService.getCollectionsCase(caseId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/billing/collections/:caseId/assignee', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
|
||||
const { adminId } = parseBody(collectionsAssigneeSchema, req)
|
||||
ok(res, await collectionsService.assignCollectionsCase(caseId, adminId, req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/collection-tasks/:taskId/outcomes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { taskId } = parseParams(collectionTaskIdParamSchema, req)
|
||||
ok(res, await collectionsService.recordCallOutcome(taskId, parseBody(collectionTaskOutcomeSchema, req), req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/collections/:caseId/overrides', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||
try {
|
||||
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
|
||||
ok(res, await collectionsService.createCollectionsOverride(caseId, parseBody(collectionsOverrideSchema, req), req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/collections/:caseId/overrides/:overrideId/revoke', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||
try {
|
||||
const { caseId, overrideId } = parseParams(collectionsOverrideParamsSchema, req)
|
||||
ok(res, await collectionsService.revokeCollectionsOverride(caseId, overrideId, req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getBillingPlatformSettings())
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.updateBillingPlatformSettings(parseBody(platformBillingSettingsSchema, req), req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { companyId } = parseParams(companyIdParamSchema, req)
|
||||
|
||||
@@ -87,6 +87,7 @@ export const createAdminSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim(),
|
||||
lastName: z.string().min(1).max(100).trim(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
preferredLocale: z.enum(['ar', 'en', 'fr']).default('en'),
|
||||
password: z.string().min(8),
|
||||
permissions: z.array(permissionSchema).optional(),
|
||||
})
|
||||
@@ -96,6 +97,7 @@ export const updateAdminSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
|
||||
preferredLocale: z.enum(['ar', 'en', 'fr']).optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
@@ -173,7 +175,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
|
||||
terms: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
lateFeePerHour: z.number().int().nullable().optional(), taxRate: z.number().nullable().optional(),
|
||||
lateFeePerHour: z.number().int().nullable().optional(),
|
||||
signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
|
||||
}).optional(),
|
||||
accountingSettings: z.object({
|
||||
@@ -243,6 +245,10 @@ export const billingAccountUpdateSchema = z.object({
|
||||
netTermsDays: z.number().int().min(0).max(365).optional(),
|
||||
})
|
||||
|
||||
export const platformBillingSettingsSchema = z.object({
|
||||
taxRate: z.number().min(0).max(100),
|
||||
})
|
||||
|
||||
export const billingLineItemInputSchema = z.object({
|
||||
type: z.enum([
|
||||
'SUBSCRIPTION_FEE',
|
||||
@@ -292,6 +298,80 @@ export const payBillingInvoiceSchema = z.object({
|
||||
providerPaymentId: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
|
||||
const manualPaymentReferenceSchema = z.string()
|
||||
.trim()
|
||||
.min(3)
|
||||
.max(120)
|
||||
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters')
|
||||
|
||||
export const manualPaymentSubmissionIdParamSchema = z.object({ submissionId: z.string().min(1) })
|
||||
export const manualPaymentDocumentParamsSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
documentId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const manualPaymentSubmissionsQuerySchema = z.object({
|
||||
status: z.enum(['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED']).default('SUBMITTED'),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
export const rejectManualPaymentSubmissionSchema = z.object({
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
})
|
||||
|
||||
export const confirmManualPaymentSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
method: z.enum(['BANK_TRANSFER', 'CHECK']),
|
||||
externalReference: manualPaymentReferenceSchema,
|
||||
amount: z.number().int().positive(),
|
||||
receivedAt: z.string().datetime(),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
correctionReason: z.string().trim().min(3).max(500).optional(),
|
||||
idempotencyKey: z.string().uuid(),
|
||||
fundsVerified: z.literal(true),
|
||||
})
|
||||
|
||||
export const collectionsQuerySchema = z.object({
|
||||
status: z.enum(['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED']).optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
actionDueBefore: z.string().datetime().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
export const collectionsCaseIdParamSchema = z.object({ caseId: z.string().min(1) })
|
||||
export const collectionTaskIdParamSchema = z.object({ taskId: z.string().min(1) })
|
||||
export const collectionsOverrideParamsSchema = z.object({ caseId: z.string().min(1), overrideId: z.string().min(1) })
|
||||
|
||||
export const collectionsAssigneeSchema = z.object({ adminId: z.string().min(1) })
|
||||
export const collectionTaskOutcomeSchema = z.object({
|
||||
outcome: z.enum(['CONTACTED', 'NO_ANSWER', 'PAYMENT_PROMISED', 'ISSUE_ESCALATED']),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
promisedPaymentAt: z.string().datetime().optional(),
|
||||
nextFollowUpAt: z.string().datetime().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (['NO_ANSWER', 'ISSUE_ESCALATED'].includes(data.outcome) && !data.note) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['note'], message: 'A note is required for this outcome' })
|
||||
}
|
||||
if (data.outcome === 'PAYMENT_PROMISED' && (!data.promisedPaymentAt || !data.nextFollowUpAt)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['promisedPaymentAt'], message: 'Promised payment and follow-up times are required' })
|
||||
}
|
||||
})
|
||||
|
||||
export const collectionsOverrideSchema = z.object({
|
||||
type: z.enum(['PAYMENT_DISPUTE', 'MANUAL_EXTENSION']),
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
expiresAt: z.string().datetime(),
|
||||
revisedSuspensionAt: z.string().datetime().optional(),
|
||||
pauseSuspension: z.boolean().default(true),
|
||||
pauseNotifications: z.boolean().default(false),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.type === 'MANUAL_EXTENSION' && !data.revisedSuspensionAt) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['revisedSuspensionAt'], message: 'A manual extension requires a revised suspension time' })
|
||||
}
|
||||
})
|
||||
|
||||
export const retryBillingInvoiceSchema = z.object({
|
||||
paymentMethodId: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
|
||||
@@ -3,8 +3,10 @@ import bcrypt from 'bcryptjs'
|
||||
|
||||
vi.mock('./admin.repo', () => ({
|
||||
findAdminByEmail: vi.fn(),
|
||||
findAdminByIdOrThrow: vi.fn(),
|
||||
setAdminPasswordReset: vi.fn(),
|
||||
updateAdminLastLogin: vi.fn(),
|
||||
updateAdminTotpSecret: vi.fn(),
|
||||
createAuditLog: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -12,6 +14,10 @@ vi.mock('../../services/notificationService', () => ({
|
||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,test') },
|
||||
}))
|
||||
|
||||
const redisStore = new Map<string, string>()
|
||||
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
@@ -33,7 +39,7 @@ vi.mock('../../lib/redis', () => ({
|
||||
|
||||
import * as repo from './admin.repo'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { forgotPassword, login } from './admin.service'
|
||||
import { forgotPassword, login, setupTotp } from './admin.service'
|
||||
|
||||
describe('admin.service forgotPassword', () => {
|
||||
const originalAdminUrl = process.env.ADMIN_URL
|
||||
@@ -123,4 +129,20 @@ describe('admin.service forgotPassword', () => {
|
||||
}))
|
||||
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3')
|
||||
})
|
||||
|
||||
it('reuses a pending TOTP setup secret so duplicate dev setup calls keep codes valid', async () => {
|
||||
vi.mocked(repo.findAdminByIdOrThrow).mockResolvedValue({
|
||||
id: 'admin_4',
|
||||
email: 'admin4@example.test',
|
||||
totpEnabled: false,
|
||||
totpSecret: 'JBSWY3DPEHPK3PXP',
|
||||
} as any)
|
||||
|
||||
const first = await setupTotp('admin_4', 'admin4@example.test')
|
||||
const second = await setupTotp('admin_4', 'admin4@example.test')
|
||||
|
||||
expect(first.secret).toBe('JBSWY3DPEHPK3PXP')
|
||||
expect(second.secret).toBe('JBSWY3DPEHPK3PXP')
|
||||
expect(repo.updateAdminTotpSecret).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -171,8 +171,13 @@ export async function login(email: string, password: string, totpCode?: string,
|
||||
}
|
||||
|
||||
export async function setupTotp(adminId: string, email: string) {
|
||||
const secret = authenticator.generateSecret()
|
||||
await repo.updateAdminTotpSecret(adminId, secret)
|
||||
const admin = await repo.findAdminByIdOrThrow(adminId)
|
||||
const secret = admin.totpSecret && !admin.totpEnabled
|
||||
? admin.totpSecret
|
||||
: authenticator.generateSecret()
|
||||
if (secret !== admin.totpSecret) {
|
||||
await repo.updateAdminTotpSecret(adminId, secret)
|
||||
}
|
||||
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
|
||||
const qrCode = await qrcode.toDataURL(otpauth)
|
||||
return { secret, qrCode }
|
||||
@@ -335,7 +340,7 @@ export async function listAdmins() {
|
||||
return admins.map((admin: any) => presenter.presentAdminUser(admin))
|
||||
}
|
||||
|
||||
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
|
||||
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; preferredLocale: string; password: string; permissions?: any[] }) {
|
||||
const admin = await repo.createAdmin({
|
||||
...body,
|
||||
passwordHash: await bcrypt.hash(body.password, 12),
|
||||
@@ -350,6 +355,7 @@ export async function updateAdmin(
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
role?: string
|
||||
preferredLocale?: string
|
||||
password?: string
|
||||
isActive?: boolean
|
||||
},
|
||||
@@ -359,6 +365,7 @@ export async function updateAdmin(
|
||||
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
|
||||
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
|
||||
...(body.role !== undefined ? { role: body.role } : {}),
|
||||
...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}),
|
||||
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
|
||||
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
|
||||
})
|
||||
@@ -406,6 +413,18 @@ export function updateBillingAccount(
|
||||
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
|
||||
}
|
||||
|
||||
export function getBillingPlatformSettings() {
|
||||
return billingService.getBillingPlatformSettings()
|
||||
}
|
||||
|
||||
export function updateBillingPlatformSettings(
|
||||
data: Parameters<typeof billingService.updateBillingPlatformSettings>[0],
|
||||
adminId: string,
|
||||
ip?: string,
|
||||
) {
|
||||
return billingService.updateBillingPlatformSettings(data, adminId, ip)
|
||||
}
|
||||
|
||||
export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
|
||||
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user