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

This commit is contained in:
root
2026-08-10 22:35:55 -04:00
parent 10ca76fc1e
commit 5f06256271
73 changed files with 8803 additions and 570 deletions
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { assertPaymentEvidenceFile, sanitizeEvidenceFilename } from './paymentEvidence'
function file(buffer: Buffer, originalname: string, mimetype: string): Express.Multer.File {
return { buffer, originalname, mimetype, size: buffer.length } as Express.Multer.File
}
function png(width = 16, height = 16) {
const head = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from([0, 0, 0, 13]),
Buffer.from('IHDR'),
])
const dimensions = Buffer.alloc(8)
dimensions.writeUInt32BE(width, 0)
dimensions.writeUInt32BE(height, 4)
const ihdrRest = Buffer.alloc(9)
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
return Buffer.concat([head, dimensions, ihdrRest, iend])
}
function jpeg(width = 16, height = 16) {
return Buffer.from([
0xff, 0xd8,
0xff, 0xc0, 0x00, 0x0b, 0x08,
(height >> 8) & 0xff, height & 0xff,
(width >> 8) & 0xff, width & 0xff,
0x01, 0x01, 0x11, 0x00,
0xff, 0xd9,
])
}
describe('payment evidence validation', () => {
it('accepts a structurally bounded PDF by content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs with trailing bytes after the EOF marker', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n\u0000\u0000')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs that contain common byte sequences inside document content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n(<html><svg>PK\u0003\u0004)</script>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid evidence files reported with compatible browser MIME aliases', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/octet-stream'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/x-pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid image evidence with trailing bytes and common JPEG extensions', () => {
expect(assertPaymentEvidenceFile(file(Buffer.concat([png(), Buffer.from('\n')]), 'receipt.png', 'image/x-png'))).toEqual({ mime: 'image/png', ext: '.png' })
expect(assertPaymentEvidenceFile(file(Buffer.concat([jpeg(), Buffer.from('\n')]), 'receipt.jfif', 'image/pjpeg'))).toEqual({ mime: 'image/jpeg', ext: '.jpg' })
})
it('rejects spoofed MIME types and active content', () => {
const html = Buffer.from('<!doctype html><script>alert(1)</script>')
expect(() => assertPaymentEvidenceFile(file(html, 'receipt.pdf', 'application/pdf'))).toThrow(/suspicious/i)
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)])
expect(() => assertPaymentEvidenceFile(file(png, 'receipt.pdf', 'application/pdf'))).toThrow(/valid PDF, JPEG, and PNG/i)
})
it('sanitizes filenames without allowing path traversal', () => {
expect(sanitizeEvidenceFilename('../../bank<receipt>.pdf')).toBe('bank_receipt_.pdf')
})
})
+128
View File
@@ -0,0 +1,128 @@
import path from 'path'
import multer from 'multer'
import { ValidationError } from '../errors'
export const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
export const PAYMENT_EVIDENCE_MAX_FILES = 3
export const PAYMENT_EVIDENCE_MAX_TOTAL_SIZE = 20 * 1024 * 1024
export const paymentEvidenceUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: PAYMENT_EVIDENCE_MAX_FILE_SIZE, files: 1, fields: 5, parts: 8 },
})
export type DetectedPaymentEvidence = {
mime: 'application/pdf' | 'image/jpeg' | 'image/png'
ext: '.pdf' | '.jpg' | '.png'
}
const allowedExtensions: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['.pdf'],
'image/jpeg': ['.jpg', '.jpeg', '.jpe', '.jfif'],
'image/png': ['.png'],
}
const allowedDeclaredMimes: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['application/pdf', 'application/x-pdf', 'application/octet-stream'],
'image/jpeg': ['image/jpeg', 'image/pjpeg', 'application/octet-stream'],
'image/png': ['image/png', 'image/x-png', 'application/octet-stream'],
}
function hasSpoofedLeadingContainerSignature(buffer: Buffer) {
const prefix = buffer.subarray(0, 512)
const text = prefix.toString('latin1').trimStart().toLowerCase()
return text.startsWith('<script')
|| text.startsWith('<!doctype html')
|| text.startsWith('<html')
|| text.startsWith('<svg')
|| prefix.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x4d, 0x5a, 0x90, 0x00]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
}
function detectType(buffer: Buffer): DetectedPaymentEvidence | null {
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
const iendIndex = buffer.lastIndexOf(iend)
if (buffer.length >= 33 && iendIndex >= 0 && buffer.length - iendIndex <= 2048) {
return { mime: 'image/png', ext: '.png' }
}
}
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
const eoiIndex = buffer.lastIndexOf(Buffer.from([0xff, 0xd9]))
if (eoiIndex >= 0 && buffer.length - eoiIndex <= 2048) {
return { mime: 'image/jpeg', ext: '.jpg' }
}
}
if (buffer.length >= 12 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
const content = buffer.toString('latin1')
// Real-world PDFs may include a newline or small binary marker after EOF.
// Require an EOF marker near the end instead of at the exact final byte.
const eofIndex = content.lastIndexOf('%%EOF')
if (eofIndex >= 0 && content.length - eofIndex <= 2048 && !/\/Encrypt\b/.test(content)) return { mime: 'application/pdf', ext: '.pdf' }
}
return null
}
function assertSafeJpegDimensions(buffer: Buffer) {
let offset = 2
while (offset + 8 < buffer.length) {
if (buffer[offset] !== 0xff) { offset += 1; continue }
const marker = buffer[offset + 1]
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2
continue
}
const segmentLength = buffer.readUInt16BE(offset + 2)
if (segmentLength < 2 || offset + 2 + segmentLength > buffer.length) break
const isStartOfFrame = marker !== undefined
&& ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf))
if (isStartOfFrame) {
const height = buffer.readUInt16BE(offset + 5)
const width = buffer.readUInt16BE(offset + 7)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
return
}
offset += 2 + segmentLength
}
throw new ValidationError('The JPEG file is malformed')
}
function assertSafePngDimensions(buffer: Buffer) {
if (buffer.length < 24) throw new ValidationError('The PNG file is malformed')
const width = buffer.readUInt32BE(16)
const height = buffer.readUInt32BE(20)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
}
export function assertPaymentEvidenceFile(file: Express.Multer.File | undefined): DetectedPaymentEvidence {
if (!file) throw new ValidationError('A payment evidence file is required')
if (file.size <= 0 || file.size > PAYMENT_EVIDENCE_MAX_FILE_SIZE) {
throw new ValidationError('Payment evidence files must be between 1 byte and 10 MB')
}
if (hasSpoofedLeadingContainerSignature(file.buffer)) throw new ValidationError('Unsupported or suspicious payment evidence file')
const detected = detectType(file.buffer)
if (!detected) throw new ValidationError('Only valid PDF, JPEG, and PNG evidence files are accepted')
const declaredMime = file.mimetype.toLowerCase()
if (!allowedDeclaredMimes[detected.mime].includes(declaredMime)) {
throw new ValidationError('The declared file type does not match its content')
}
const extension = path.extname(file.originalname).toLowerCase()
if (!allowedExtensions[detected.mime].includes(extension)) {
throw new ValidationError('The filename extension does not match the file content')
}
if (detected.mime === 'image/png') assertSafePngDimensions(file.buffer)
if (detected.mime === 'image/jpeg') assertSafeJpegDimensions(file.buffer)
return detected
}
export function sanitizeEvidenceFilename(value: string) {
const base = path.basename(value).normalize('NFKC').replace(/[\u0000-\u001f\u007f]/g, '').replace(/[^\p{L}\p{N}._ -]/gu, '_')
return (base || 'payment-evidence').slice(0, 180)
}
+15 -19
View File
@@ -8,14 +8,12 @@ import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies'
import { sendNotification } from './services/notificationService'
import { processNotificationOutbox, sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
runPaymentPendingTimeoutJob,
runPastDueTimeoutJob,
runSuspensionTimeoutJob,
runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service'
import { runCollectionsWorker } from './modules/subscriptions/subscription.collections.service'
const app = createApp()
const server = http.createServer(app)
@@ -108,26 +106,24 @@ cron.schedule('0 * * * *', async () => {
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
})
// Hourly: payment_pending → past_due after 7 days
cron.schedule('15 * * * *', async () => {
const n = await runPaymentPendingTimeoutJob()
if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`)
})
// Hourly: past_due → suspended after 7 days
cron.schedule('30 * * * *', async () => {
const n = await runPastDueTimeoutJob()
if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`)
})
// Daily: suspended → cancelled after 16 days; and period-end cancellations
// Daily: explicit period-end cancellations. Subscription collections use the
// timezone-aware 30-day grace worker below, so the old fixed 7-day chain is
// intentionally not scheduled.
cron.schedule('0 1 * * *', async () => {
const nSuspend = await runSuspensionTimeoutJob()
const nPeriod = await runPeriodEndCancellationJob()
if (nSuspend > 0) console.log(`[subscription] suspension_timeout: ${nSuspend} cancelled`)
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
})
cron.schedule('*/15 * * * *', async () => {
const n = await runCollectionsWorker()
if (n > 0) console.log(`[subscription] collections: ${n} cases processed`)
})
cron.schedule('* * * * *', async () => {
const n = await processNotificationOutbox()
if (n > 0) console.log(`[notifications] outbox: ${n} events completed`)
})
// Daily: send trial-ending reminders (3 days before trial end)
cron.schedule('0 9 * * *', async () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
+63
View File
@@ -50,6 +50,14 @@ export function assertStorageConfiguration(): string {
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
)
}
if (process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED === 'true') {
if (process.env.PRIVATE_STORAGE_PERSISTENCE_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed persistent private storage in production.')
}
if (process.env.PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed encryption at rest in production.')
}
}
}
return storageRoot
@@ -141,3 +149,58 @@ export async function deleteImage(imageUrl: string): Promise<void> {
fs.unlinkSync(filePath)
}
}
function normalizePrivateStorageKey(storageKey: string) {
const normalized = storageKey.replace(/\\/g, '/').replace(/^\/+/, '')
if (!normalized || normalized.includes('..') || path.isAbsolute(normalized)) {
throw new Error('Invalid private storage key')
}
return normalized
}
export function resolvePrivateDocumentPath(storageKey: string): string {
const root = ensureStorageRoot('private')
const filePath = path.join(root, normalizePrivateStorageKey(storageKey))
if (!isWithinPath(filePath, root)) throw new Error('Private document path escapes storage root')
return filePath
}
export async function storePaymentEvidenceInQuarantine(
buffer: Buffer,
companyId: string,
submissionId: string,
extension: string,
) {
const safeExtension = ['.pdf', '.jpg', '.png'].includes(extension) ? extension : ''
if (!safeExtension) throw new Error('Unsupported private document extension')
const storageKey = path.posix.join(
'payment-evidence',
'quarantine',
companyId,
submissionId,
`${crypto.randomBytes(24).toString('hex')}${safeExtension}`,
)
const filePath = resolvePrivateDocumentPath(storageKey)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, buffer, { mode: 0o600, flag: 'wx' })
return { storageKey, filePath }
}
export async function promotePaymentEvidence(storageKey: string) {
const sourcePath = resolvePrivateDocumentPath(storageKey)
const cleanKey = normalizePrivateStorageKey(storageKey).replace('/quarantine/', '/clean/')
if (cleanKey === storageKey) throw new Error('Only quarantined evidence can be promoted')
const targetPath = resolvePrivateDocumentPath(cleanKey)
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
fs.renameSync(sourcePath, targetPath)
return cleanKey
}
export function readPrivateDocument(storageKey: string): Buffer {
return fs.readFileSync(resolvePrivateDocumentPath(storageKey))
}
export async function deletePrivateDocument(storageKey: string): Promise<void> {
const filePath = resolvePrivateDocumentPath(storageKey)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
@@ -13,7 +13,7 @@ vi.mock('../lib/prisma', () => ({
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from './requireAdminAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
@@ -163,3 +163,35 @@ describe('requireAdminRole middleware', () => {
expect(res.status).not.toHaveBeenCalled()
})
})
describe('requireFreshAdmin2FA middleware', () => {
it('allows a 2FA-verified admin session until the session ends', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 24 * 60 * 60 * 1000,
} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose session has no 2FA verification proof', () => {
const req = { admin: { id: 'admin_1', totpEnabled: true } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'fresh_2fa_required',
message: 'Admin 2FA verification is required for this session',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
})
+2 -5
View File
@@ -20,8 +20,6 @@ const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
'/auth/2fa/verify',
])
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
function is2faEnrollmentExempt(req: Request) {
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
}
@@ -84,9 +82,8 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
}
const last2faAt = req.adminAuthLast2faAt
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
if (!req.adminAuthLast2faAt) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
}
next()
@@ -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 labonnement 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
}
}
+4
View File
@@ -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 } : {}),
},
+111
View File
@@ -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)
+81 -1
View File
@@ -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()
})
})
+22 -3
View File
@@ -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)
}
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas'
@@ -126,8 +126,7 @@ export async function startAccount(body: AccountStartInput) {
text: emailTexts[lang],
}).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message ?? String(err))
console.error('[AccountStart] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set', '| pass:', process.env.MAIL_PASSWORD ? '***' : 'not set')
console.error('[AccountStart] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
console.error('[AccountStart] Email provider config:', describeEmailProviderConfig())
})
return {
@@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken'
import { signActorToken } from '../../security/tokens'
import { AppError } from '../../http/errors'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { sendTransactionalEmail } from '../../services/notificationService'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
import { presentEmployeeSession } from './auth.presenter'
import * as repo from './auth.employee.repo'
@@ -137,7 +137,7 @@ export async function forgotPassword(email: string) {
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
}).catch((err) => {
console.error('[ForgotPassword] Email delivery failed:', err?.message)
console.error('[ForgotPassword] Provider config — resendKey present:', !!process.env.RESEND_API_KEY, '| smtpHost:', process.env.MAIL_HOST ?? 'not set')
console.error('[ForgotPassword] Email provider config:', describeEmailProviderConfig())
})
}
@@ -230,8 +230,7 @@ export async function resendVerification(email: string) {
text: `Verify your email by visiting:\n${verifyUrl}`,
}).catch((err) => {
console.error('[ResendVerification] Email delivery failed:', err?.message ?? String(err))
console.error('[ResendVerification] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set')
console.error('[ResendVerification] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
console.error('[ResendVerification] Email provider config:', describeEmailProviderConfig())
})
return { message: 'A new verification link has been sent to your email.' }
@@ -24,7 +24,6 @@ describe('company schemas edge cases', () => {
fuelPolicyType: 'FULL_TO_FULL',
additionalDriverPolicy: 'Additional drivers must be approved before pickup.',
additionalDriverCharge: 'PER_DAY',
taxRate: 20,
})).toMatchObject({
fuelPolicy: 'Return with the same fuel level.',
fuelPolicyType: 'FULL_TO_FULL',
@@ -111,7 +111,6 @@ export const contractSettingsSchema = z.object({
invoiceFooterNote: z.string().optional(),
signatureRequired: z.boolean().optional(),
showTax: z.boolean().optional(),
taxRate: z.number().optional(),
taxLabel: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
fuelPolicyNote: z.string().optional(),
@@ -1,6 +1,7 @@
import { prisma } from '../../lib/prisma'
import * as repo from './reservation.repo'
import { parseReservationExtras, serializeContractFields } from './reservation.presenter'
import { calculateTaxAmount, getPlatformBillingSettings } from '../subscriptions/billingTax'
export function formatDocumentNumber(prefix: string, sequence: number): string {
return `${prefix}-${String(sequence).padStart(6, '0')}`
@@ -134,8 +135,10 @@ export async function getContract(id: string, companyId: string) {
})
const subtotal = invoiceLineItems.reduce((s, i) => s + i.total, 0)
const taxes = contractSettings.showTax && contractSettings.taxRate
? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: contractSettings.taxRate, amount: Math.round(subtotal * contractSettings.taxRate / 100) }]
const platformBillingSettings = await getPlatformBillingSettings()
const tax = calculateTaxAmount(subtotal, false, platformBillingSettings.taxRate)
const taxes = contractSettings.showTax && tax.taxAmount > 0
? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: tax.taxRate, amount: tax.taxAmount }]
: []
const taxTotal = taxes.reduce((s, t) => s + t.amount, 0)
const grandTotal = subtotal + taxTotal
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from 'vitest'
import { calculateTaxAmount, getConfiguredInvoiceTaxRate } from './billingTax'
describe('platform billing tax', () => {
const originalSubscriptionTaxRate = process.env.SUBSCRIPTION_TAX_RATE
const originalInvoiceTaxRate = process.env.INVOICE_TAX_RATE
afterEach(() => {
if (originalSubscriptionTaxRate === undefined) delete process.env.SUBSCRIPTION_TAX_RATE
else process.env.SUBSCRIPTION_TAX_RATE = originalSubscriptionTaxRate
if (originalInvoiceTaxRate === undefined) delete process.env.INVOICE_TAX_RATE
else process.env.INVOICE_TAX_RATE = originalInvoiceTaxRate
})
it('uses one platform tax rate for all accounts', () => {
process.env.SUBSCRIPTION_TAX_RATE = '18'
process.env.INVOICE_TAX_RATE = '9'
expect(getConfiguredInvoiceTaxRate()).toBe(18)
expect(calculateTaxAmount(10_000, false, getConfiguredInvoiceTaxRate())).toEqual({
priceBeforeTax: 10_000,
taxRate: 18,
taxAmount: 1_800,
totalAmount: 11_800,
})
})
it('falls back to the default rate and respects tax exemptions', () => {
process.env.SUBSCRIPTION_TAX_RATE = 'not-a-rate'
delete process.env.INVOICE_TAX_RATE
expect(getConfiguredInvoiceTaxRate()).toBe(20)
expect(calculateTaxAmount(10_000, true)).toEqual({
priceBeforeTax: 10_000,
taxRate: 0,
taxAmount: 0,
totalAmount: 10_000,
})
})
})
@@ -0,0 +1,55 @@
import { prisma } from '../../lib/prisma'
const DEFAULT_INVOICE_TAX_RATE = 20
const PLATFORM_BILLING_SETTINGS_ID = 'default'
function normalizeTaxRate(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100 ? parsed : DEFAULT_INVOICE_TAX_RATE
}
export function getConfiguredInvoiceTaxRate() {
const raw = process.env.SUBSCRIPTION_TAX_RATE ?? process.env.INVOICE_TAX_RATE
if (raw === undefined || raw.trim() === '') return DEFAULT_INVOICE_TAX_RATE
return normalizeTaxRate(raw)
}
export async function getPlatformBillingSettings() {
const settings = await prisma.platformBillingSettings.findUnique({
where: { id: PLATFORM_BILLING_SETTINGS_ID },
})
return {
id: PLATFORM_BILLING_SETTINGS_ID,
taxRate: normalizeTaxRate(settings?.taxRate ?? getConfiguredInvoiceTaxRate()),
updatedAt: settings?.updatedAt ?? null,
updatedBy: settings?.updatedBy ?? null,
}
}
export async function updatePlatformBillingSettings(data: { taxRate: number; updatedBy?: string | null }) {
const taxRate = normalizeTaxRate(data.taxRate)
return prisma.platformBillingSettings.upsert({
where: { id: PLATFORM_BILLING_SETTINGS_ID },
create: {
id: PLATFORM_BILLING_SETTINGS_ID,
taxRate,
updatedBy: data.updatedBy ?? null,
},
update: {
taxRate,
updatedBy: data.updatedBy ?? null,
},
})
}
export function calculateTaxAmount(priceBeforeTax: number, taxExempt?: boolean, platformTaxRate = getConfiguredInvoiceTaxRate()) {
const taxRate = taxExempt ? 0 : normalizeTaxRate(platformTaxRate)
const taxAmount = taxRate > 0 ? Math.round(priceBeforeTax * (taxRate / 100)) : 0
return {
priceBeforeTax,
taxRate,
taxAmount,
totalAmount: priceBeforeTax + taxAmount,
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { calculateCollectionsSchedule } from './subscription.collections.service'
function localParts(date: Date, timeZone: string) {
return new Intl.DateTimeFormat('en-CA', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).format(date)
}
describe('timezone-safe subscription collections schedule', () => {
it('preserves the local expiration wall time across a DST transition', () => {
const timeZone = 'America/New_York'
const expiration = new Date('2026-03-01T15:30:00.000Z') // 10:30 local, before DST
const schedule = calculateCollectionsSchedule(expiration, timeZone, '09:00')
expect(localParts(schedule.finalSuspensionAt, timeZone)).toContain('2026-03-31, 10:30')
})
it('uses local reminder time for day milestones and exact time for 48/24 hours', () => {
const expiration = new Date('2026-09-30T17:00:00.000Z')
const schedule = calculateCollectionsSchedule(expiration, 'Africa/Casablanca', '09:00')
expect(localParts(schedule.reminder14At, 'Africa/Casablanca')).toContain('2026-09-16, 09:00')
expect(expiration.getTime() - schedule.reminder48At.getTime()).toBe(48 * 60 * 60 * 1000)
expect(expiration.getTime() - schedule.reminder24At.getTime()).toBe(24 * 60 * 60 * 1000)
})
it('rejects fixed offsets and invalid timezones', () => {
expect(() => calculateCollectionsSchedule(new Date(), 'UTC+01:00')).toThrow(/IANA timezone/i)
expect(() => calculateCollectionsSchedule(new Date(), 'Mars/Olympus')).toThrow(/IANA timezone/i)
})
})
@@ -0,0 +1,680 @@
import crypto from 'crypto'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezonePlugin from 'dayjs/plugin/timezone'
import { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../../lib/prisma'
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '../../http/errors'
import { sendNotification } from '../../services/notificationService'
import {
coerceNotificationLocale,
formatLocalizedCurrency,
formatLocalizedDate,
type NotificationLocale,
} from '../../services/notificationLocalizationService'
import {
addBillingPeriod,
calculateTaxForAccount,
ensurePrimaryBillingAccount,
isValidIanaTimezone,
taxLineItem,
taxRecordCreate,
} from './subscription.manual.service'
import { getPlatformBillingSettings } from './billingTax'
import { getPaymentOptions } from './subscription.payment-config'
dayjs.extend(utc)
dayjs.extend(timezonePlugin)
type CollectionsMilestone = 'DUE_14D' | 'DUE_7D' | 'DUE_48H' | 'DUE_24H' | 'GRACE_DAILY' | 'GRACE_FINAL'
const typeByMilestone: Record<CollectionsMilestone, any> = {
DUE_14D: 'SUBSCRIPTION_PAYMENT_DUE_14D',
DUE_7D: 'SUBSCRIPTION_PAYMENT_DUE_7D',
DUE_48H: 'SUBSCRIPTION_PAYMENT_DUE_48H',
DUE_24H: 'SUBSCRIPTION_PAYMENT_DUE_24H',
GRACE_DAILY: 'SUBSCRIPTION_GRACE_DAILY',
GRACE_FINAL: 'SUBSCRIPTION_GRACE_FINAL',
}
const templateByMilestone: Record<CollectionsMilestone, string> = {
DUE_14D: 'subscription.payment_due_14d.v1',
DUE_7D: 'subscription.payment_due_7d.v1',
DUE_48H: 'subscription.payment_due_48h.v1',
DUE_24H: 'subscription.payment_due_24h.v1',
GRACE_DAILY: 'subscription.grace_daily.v1',
GRACE_FINAL: 'subscription.grace_final.v1',
}
const labels: Record<NotificationLocale, {
titles: Record<CollectionsMilestone, string>
due: (invoice: string, amount: string, expiration: string) => string
grace: (invoice: string, amount: string, day: number, remaining: number, suspension: string) => string
action: string
callScript: (company: string, invoice: string, amount: string, expiration: string) => string
}> = {
en: {
titles: { DUE_14D: 'Subscription payment due in 14 days', DUE_7D: 'Subscription payment due in 7 days', DUE_48H: 'Payment due in 48 hours', DUE_24H: 'Final expiration warning', GRACE_DAILY: 'Subscription payment overdue', GRACE_FINAL: 'Final warning before suspension' },
due: (invoice, amount, expiration) => `Invoice ${invoice} for ${amount} remains unpaid. Your current subscription period expires on ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `Invoice ${invoice} for ${amount} is overdue. Grace day ${day}; ${remaining} day(s) remain. Service is scheduled for suspension on ${suspension} unless payment is confirmed.`,
action: 'Open Subscription in the dashboard to pay by Stripe or view the configured bank/check instructions.',
callScript: (company, invoice, amount, expiration) => `Hello, I am calling RentalDriveGo regarding ${company}'s invoice ${invoice} for ${amount}. The subscription expires on ${expiration}. Can I help confirm the payment plan?`,
},
fr: {
titles: { DUE_14D: 'Paiement de labonnement dû dans 14 jours', DUE_7D: 'Paiement de labonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de labonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' },
due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période dabonnement actuelle expire le ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `La facture ${invoice} de ${amount} est en retard. Jour de grâce ${day} ; il reste ${remaining} jour(s). Le service sera suspendu le ${suspension} si le paiement nest pas confirmé.`,
action: 'Ouvrez Abonnement dans le tableau de bord pour payer par Stripe ou consulter les instructions de virement/chèque.',
callScript: (company, invoice, amount, expiration) => `Bonjour, je vous appelle de RentalDriveGo au sujet de la facture ${invoice} de ${company}, dun montant de ${amount}. Labonnement expire le ${expiration}. Puis-je vous aider à confirmer le mode de règlement ?`,
},
ar: {
titles: { DUE_14D: 'استحقاق دفع الاشتراك خلال 14 يوماً', DUE_7D: 'استحقاق دفع الاشتراك خلال 7 أيام', DUE_48H: 'استحقاق الدفع خلال 48 ساعة', DUE_24H: 'التحذير الأخير قبل انتهاء الاشتراك', GRACE_DAILY: 'دفع الاشتراك متأخر', GRACE_FINAL: 'التحذير الأخير قبل تعليق الخدمة' },
due: (invoice, amount, expiration) => `لا تزال الفاتورة ${invoice} بمبلغ ${amount} غير مدفوعة. تنتهي فترة الاشتراك الحالية في ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `الفاتورة ${invoice} بمبلغ ${amount} متأخرة. يوم السماح ${day}، ويتبقى ${remaining} يوم. ستُعلّق الخدمة في ${suspension} ما لم يتم تأكيد الدفع.`,
action: 'افتح صفحة الاشتراك في لوحة التحكم للدفع عبر Stripe أو لعرض تعليمات التحويل البنكي أو الشيك.',
callScript: (company, invoice, amount, expiration) => `مرحباً، أتصل بكم من RentalDriveGo بخصوص فاتورة شركة ${company} رقم ${invoice} بمبلغ ${amount}. ينتهي الاشتراك في ${expiration}. هل يمكنني مساعدتكم في تأكيد خطة الدفع؟`,
},
}
const suspensionCopy: Record<NotificationLocale, { title: string; body: (invoice: string) => string }> = {
en: { title: 'Subscription suspended', body: (invoice) => `Your subscription was suspended because invoice ${invoice} remains unpaid after the grace period. Payment confirmation restores access.` },
fr: { title: 'Abonnement suspendu', body: (invoice) => `Votre abonnement a été suspendu, car la facture ${invoice} reste impayée après le délai de grâce. La confirmation du paiement rétablit laccès.` },
ar: { title: 'تم تعليق الاشتراك', body: (invoice) => `تم تعليق اشتراكك لأن الفاتورة ${invoice} ما زالت غير مدفوعة بعد فترة السماح. يؤدي تأكيد الدفع إلى استعادة الوصول.` },
}
const overrideCopy: Record<NotificationLocale, { title: string; created: (invoice: string, until: string) => string; revoked: (invoice: string) => string }> = {
en: { title: 'Subscription payment schedule updated', created: (invoice, until) => `A temporary collections override was applied to invoice ${invoice} through ${until}.`, revoked: (invoice) => `The temporary collections override for invoice ${invoice} was revoked. The standard schedule applies again.` },
fr: { title: 'Calendrier de paiement de labonnement mis à jour', created: (invoice, until) => `Une dérogation temporaire de recouvrement a été appliquée à la facture ${invoice} jusquau ${until}.`, revoked: (invoice) => `La dérogation temporaire concernant la facture ${invoice} a été révoquée. Le calendrier standard sapplique à nouveau.` },
ar: { title: 'تم تحديث جدول دفع الاشتراك', created: (invoice, until) => `تم تطبيق استثناء مؤقت للتحصيل على الفاتورة ${invoice} حتى ${until}.`, revoked: (invoice) => `تم إلغاء استثناء التحصيل المؤقت للفاتورة ${invoice}. عاد جدول المتابعة المعتاد للتطبيق.` },
}
const configurationCopy: Record<NotificationLocale, { title: string; body: (company: string) => string }> = {
en: { title: 'Collections configuration required', body: (company) => `${company} cannot be auto-suspended until its billing timezone, verified recipient, and collections owner configuration are valid.` },
fr: { title: 'Configuration du recouvrement requise', body: (company) => `${company} ne peut pas être suspendue automatiquement tant que le fuseau de facturation, le destinataire vérifié et le responsable du recouvrement ne sont pas valides.` },
ar: { title: 'إعدادات التحصيل مطلوبة', body: (company) => `لا يمكن تعليق ${company} تلقائياً حتى تصبح المنطقة الزمنية للفوترة وجهة الاتصال الموثقة ومسؤول التحصيل صالحة.` },
}
function flag(name: string) {
return process.env[name] === 'true'
}
function localDate(value: Date, timezone: string) {
return dayjs(value).tz(timezone).format('YYYY-MM-DD')
}
function atLocalTime(date: Date, timezone: string, localTime: string) {
const datePart = dayjs(date).tz(timezone).format('YYYY-MM-DD')
return dayjs.tz(`${datePart} ${localTime}`, timezone).toDate()
}
function addCalendarDays(value: Date, days: number, timezone: string) {
const local = dayjs(value).tz(timezone)
return dayjs.tz(local.add(days, 'day').format('YYYY-MM-DD HH:mm:ss.SSS'), timezone).toDate()
}
export function calculateCollectionsSchedule(expirationAt: Date, timezone: string, reminderLocalTime = '09:00') {
if (!isValidIanaTimezone(timezone)) throw new ValidationError('A valid IANA timezone is required')
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderLocalTime)) throw new ValidationError('Reminder local time must use HH:mm')
const expiry = dayjs(expirationAt).tz(timezone)
const reminderForDaysBefore = (days: number) => atLocalTime(expiry.subtract(days, 'day').toDate(), timezone, reminderLocalTime)
return {
originalExpirationAt: new Date(expirationAt),
reminder14At: reminderForDaysBefore(14),
reminder7At: reminderForDaysBefore(7),
reminder48At: new Date(expirationAt.getTime() - 48 * 60 * 60 * 1000),
reminder24At: new Date(expirationAt.getTime() - 24 * 60 * 60 * 1000),
finalSuspensionAt: addCalendarDays(expirationAt, 30, timezone),
}
}
function formatForCase(value: Date, locale: NotificationLocale, timezone: string) {
return formatLocalizedDate(value, locale, {
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: timezone,
})
}
function customerMessage(caseData: any, milestone: CollectionsMilestone, locale: NotificationLocale, graceDay?: number) {
const copy = labels[locale]
const amount = formatLocalizedCurrency(caseData.invoice.amountDue, caseData.invoice.currency, locale)
const expiry = formatForCase(caseData.originalExpirationAt, locale, caseData.billingAccount.timezone)
const suspension = formatForCase(caseData.finalSuspensionAt, locale, caseData.billingAccount.timezone)
const core = milestone === 'GRACE_DAILY' || milestone === 'GRACE_FINAL'
? copy.grace(caseData.invoice.invoiceNumber ?? caseData.invoice.id, amount, graceDay ?? 1, Math.max(0, 30 - (graceDay ?? 1)), suspension)
: copy.due(caseData.invoice.invoiceNumber ?? caseData.invoice.id, amount, expiry)
return { title: copy.titles[milestone], body: `${core}\n\n${copy.action}`, amount, expiry, suspension }
}
function effectiveContactLocale(contact: any, account: any): NotificationLocale {
const enabled = account.enabledCommunicationLocales as string[]
const employeeLocale = contact.employee?.preferredLanguage
return coerceNotificationLocale(
contact.locale && enabled.includes(contact.locale)
? contact.locale
: employeeLocale && enabled.includes(employeeLocale)
? employeeLocale
: account.defaultCommunicationLocale,
)
}
async function sendOverrideChangedNotification(caseId: string, overrideId: string, action: 'created' | 'revoked') {
const caseData = await prisma.collectionsCase.findUnique({
where: { id: caseId },
include: {
invoice: true,
billingAccount: { include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } } },
overrides: { where: { id: overrideId }, take: 1 },
},
}) as any
const override = caseData?.overrides?.[0]
if (!caseData || !override) return
for (const contact of caseData.billingAccount.billingContacts) {
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const copy = overrideCopy[locale]
const invoice = caseData.invoice.invoiceNumber ?? caseData.invoice.id
const until = formatForCase(override.expiresAt, locale, caseData.billingAccount.timezone)
await sendNotification({
type: 'COLLECTIONS_OVERRIDE_CHANGED',
title: copy.title,
body: action === 'created' ? copy.created(invoice, until) : copy.revoked(invoice),
companyId: caseData.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: `subscription.collections_override_${action}.v1`,
idempotencyKey: `collections:${caseData.invoiceId}:override:${overrideId}:${action}:${contact.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
data: { collectionsCaseId: caseData.id, invoiceId: caseData.invoiceId, overrideId, action, timezone: caseData.billingAccount.timezone, templateVersion: 1, localizationFallback: false },
policy: { mandatory: true },
})
}
}
async function sendMilestoneNotifications(caseData: any, milestone: CollectionsMilestone, eventKey: string, graceDay?: number) {
for (const contact of caseData.billingAccount.billingContacts) {
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const content = customerMessage(caseData, milestone, locale, graceDay)
await sendNotification({
type: typeByMilestone[milestone],
title: content.title,
body: content.body,
data: {
collectionsCaseId: caseData.id,
invoiceId: caseData.invoiceId,
invoiceNumber: caseData.invoice.invoiceNumber,
amountDue: caseData.invoice.amountDue,
currency: caseData.invoice.currency,
originalExpirationAt: caseData.originalExpirationAt,
finalSuspensionAt: caseData.finalSuspensionAt,
graceDay: graceDay ?? null,
timezone: caseData.billingAccount.timezone,
templateVersion: 1,
localizationFallback: false,
},
companyId: caseData.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: templateByMilestone[milestone],
idempotencyKey: `collections:${caseData.invoiceId}:${eventKey}:${contact.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
policy: { mandatory: true },
})
}
const admin = caseData.collectionsOwnerAdmin
if (admin) {
const emailMilestone = milestone === 'DUE_48H' || milestone === 'DUE_24H'
const internalLocale = coerceNotificationLocale(admin.preferredLocale)
const companyName = caseData.invoice.company.name
await sendNotification({
type: milestone === 'DUE_48H' ? 'COLLECTIONS_CALL_REQUIRED' : typeByMilestone[milestone],
title: `Collections: ${companyName}`,
body: `${caseData.invoice.invoiceNumber ?? caseData.invoice.id} · ${milestone} · ${caseData.billingAccount.defaultCommunicationLocale}`,
companyId: caseData.companyId,
adminUserId: admin.id,
channels: emailMilestone ? ['IN_APP', 'EMAIL'] : ['IN_APP'],
locale: internalLocale,
idempotencyKey: `collections:${caseData.invoiceId}:${eventKey}:admin:${admin.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
policy: { mandatory: true },
})
}
}
async function createRequiredCallTask(caseData: any) {
const contact = caseData.billingAccount.billingContacts.find((item: any) => item.isPrimary) ?? caseData.billingAccount.billingContacts[0]
if (!contact || !caseData.collectionsOwnerAdmin) return null
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const content = customerMessage(caseData, 'DUE_48H', locale)
return prisma.collectionsCallTask.upsert({
where: { collectionsCaseId_taskType: { collectionsCaseId: caseData.id, taskType: 'PRE_EXPIRY_48H_CALL' } },
update: {},
create: {
collectionsCaseId: caseData.id,
taskType: 'PRE_EXPIRY_48H_CALL',
assignedAdminId: caseData.collectionsOwnerAdmin.id,
billingContactId: contact.id,
dueAt: caseData.reminder48At,
companyDefaultLocale: caseData.billingAccount.defaultCommunicationLocale,
contactLocale: locale,
customerScript: labels[locale].callScript(caseData.invoice.company.name, caseData.invoice.invoiceNumber ?? caseData.invoice.id, content.amount, content.expiry),
},
})
}
async function getRenewalPrice(plan: string, billingPeriod: string) {
const configured = await prisma.pricingConfig.findUnique({ where: { plan_billingPeriod: { plan, billingPeriod } } })
return configured?.amount ?? (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
}
export async function ensureRenewalCollectionsCases(now = new Date()) {
if (!flag('SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED')) return 0
const horizon = new Date(now.getTime() + 21 * 24 * 60 * 60 * 1000)
const subscriptions = await prisma.subscription.findMany({
where: { status: 'ACTIVE', currentPeriodEnd: { not: null, lte: horizon, gt: new Date(now.getTime() - 31 * 24 * 60 * 60 * 1000) }, cancelAtPeriodEnd: false },
include: { company: { include: { employees: { where: { role: 'OWNER', isActive: true }, orderBy: { createdAt: 'asc' }, take: 1 } } } },
})
let createdCount = 0
for (const subscription of subscriptions as any[]) {
if (!subscription.currentPeriodEnd) continue
const owner = subscription.company.employees[0]
const account = await ensurePrimaryBillingAccount(subscription.companyId, owner?.id)
const schedule = calculateCollectionsSchedule(subscription.currentPeriodEnd, account.timezone, account.reminderLocalTime)
const renewalKey = `${subscription.id}:${subscription.currentPeriodEnd.toISOString()}`
const existing = await prisma.billingInvoice.findUnique({ where: { renewalKey }, include: { collectionsCase: true } })
if (existing?.collectionsCase) continue
const amount = await getRenewalPrice(subscription.plan, subscription.billingPeriod)
if (!Number.isInteger(amount) || amount <= 0) continue
const lastInvoice = await prisma.billingInvoice.findFirst({
where: { subscriptionId: subscription.id, status: 'PAID' },
orderBy: { paidAt: 'desc' },
select: { collectionMethod: true },
})
const enabledMethods = getPaymentOptions(coerceNotificationLocale(account.defaultCommunicationLocale)).methods.filter((item: any) => item.enabled).map((item: any) => item.method)
const collectionMethod = enabledMethods.includes(lastInvoice?.collectionMethod) ? lastInvoice!.collectionMethod : (enabledMethods[0] ?? 'STRIPE')
const assignee = account.collectionsOwnerAdminId
? await prisma.adminUser.findFirst({ where: { id: account.collectionsOwnerAdminId, isActive: true } })
: await prisma.adminUser.findFirst({ where: { isActive: true, role: { in: ['FINANCE', 'ADMIN', 'SUPER_ADMIN'] } }, orderBy: { createdAt: 'asc' } })
const ready = isValidIanaTimezone(account.timezone)
&& account.billingContacts.some((contact: any) => contact.isActive && contact.receivePaymentNotices && contact.verifiedAt)
&& Boolean(assignee)
const platformBillingSettings = await getPlatformBillingSettings()
await prisma.$transaction(async (tx: any) => {
let invoice = existing
if (!invoice) {
const tax = calculateTaxForAccount(amount, account, platformBillingSettings.taxRate)
const invoiceNumber = `INV-${now.getUTCFullYear()}-${crypto.randomUUID().slice(0, 8).toUpperCase()}`
invoice = await tx.billingInvoice.create({
data: {
billingAccountId: account.id,
companyId: subscription.companyId,
subscriptionId: subscription.id,
invoiceNumber,
invoiceType: 'SUBSCRIPTION_RENEWAL',
status: 'OPEN',
currency: subscription.currency,
subtotalAmount: amount,
taxAmount: tax.taxAmount,
totalAmount: tax.totalAmount,
amountDue: tax.totalAmount,
invoiceDate: now,
dueAt: subscription.currentPeriodEnd,
finalizedAt: now,
billingName: account.legalName,
billingEmail: account.billingEmail,
billingAddress: account.billingAddress ?? undefined,
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL',
collectionMethod,
requestedPlan: subscription.plan,
requestedBillingPeriod: subscription.billingPeriod,
renewalKey,
isSubscriptionBlocking: true,
metadata: { source: 'renewal_collections_worker' },
lineItems: {
create: [
{
subscriptionId: subscription.id,
plan: subscription.plan,
type: 'SUBSCRIPTION_FEE',
description: `${subscription.plan} subscription renewal — ${subscription.billingPeriod}`,
quantity: 1,
unitAmount: amount,
amount,
currency: subscription.currency,
periodStart: subscription.currentPeriodEnd,
periodEnd: addBillingPeriod(subscription.currentPeriodEnd, subscription.billingPeriod),
},
...taxLineItem(tax, subscription.currency),
],
},
...(taxRecordCreate(account, tax) ? { taxRecords: taxRecordCreate(account, tax) } : {}),
},
})
await tx.subscriptionInvoice.create({
data: {
companyId: subscription.companyId,
subscriptionId: subscription.id,
requestedPlan: subscription.plan,
requestedBillingPeriod: subscription.billingPeriod,
amount: tax.totalAmount,
currency: subscription.currency,
status: 'PENDING',
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL',
billingInvoiceId: invoice!.id,
dueAt: subscription.currentPeriodEnd,
},
})
}
if (!invoice) throw new Error('Renewal invoice creation failed')
await tx.collectionsCase.create({
data: {
invoiceId: invoice.id,
subscriptionId: subscription.id,
billingAccountId: account.id,
companyId: subscription.companyId,
status: now >= schedule.originalExpirationAt ? 'GRACE_PERIOD' : 'SCHEDULED',
...schedule,
graceStartedAt: now >= schedule.originalExpirationAt ? schedule.originalExpirationAt : null,
nextActionAt: ready ? schedule.reminder14At : null,
collectionsOwnerAdminId: assignee?.id ?? null,
},
})
})
createdCount += 1
}
return createdCount
}
function nextReminderAfter(caseData: any, now: Date) {
const candidates = [caseData.reminder14At, caseData.reminder7At, caseData.reminder48At, caseData.reminder24At, caseData.originalExpirationAt]
.map((value: Date | string) => new Date(value))
.filter((value: Date) => value > now)
.sort((a: Date, b: Date) => a.getTime() - b.getTime())
return candidates[0] ?? caseData.originalExpirationAt
}
async function recordCollectionsEvent(caseData: any, key: string, eventType: string, payload: Record<string, unknown> = {}) {
try {
await prisma.collectionsEvent.create({
data: { collectionsCaseId: caseData.id, companyId: caseData.companyId, eventType, idempotencyKey: key, payload: payload as any },
})
return true
} catch (error: any) {
if (error?.code === 'P2002') return false
throw error
}
}
async function processMilestone(caseData: any, milestone: CollectionsMilestone, key: string, graceDay?: number) {
const exists = await prisma.collectionsEvent.findUnique({ where: { collectionsCaseId_idempotencyKey: { collectionsCaseId: caseData.id, idempotencyKey: key } } })
if (exists) return false
await sendMilestoneNotifications(caseData, milestone, key, graceDay)
if (milestone === 'DUE_48H') await createRequiredCallTask(caseData)
return recordCollectionsEvent(caseData, key, `collections.${milestone.toLowerCase()}`, { graceDay: graceDay ?? null })
}
async function processCollectionsCase(caseId: string, now: Date) {
const caseData = await prisma.collectionsCase.findUnique({
where: { id: caseId },
include: {
invoice: { include: { company: true } },
subscription: true,
billingAccount: { include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] } } },
collectionsOwnerAdmin: true,
overrides: { where: { status: 'ACTIVE' }, orderBy: { expiresAt: 'desc' } },
},
}) as any
if (!caseData || ['RESOLVED', 'SUSPENDED'].includes(caseData.status)) return
if (caseData.invoice.status === 'PAID') {
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { status: 'RESOLVED', resolvedAt: now, nextActionAt: null } })
await prisma.collectionsCallTask.updateMany({ where: { collectionsCaseId: caseData.id, status: 'OPEN' }, data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' } })
return
}
const configured = isValidIanaTimezone(caseData.billingAccount.timezone)
&& caseData.billingAccount.billingContacts.length > 0
&& caseData.collectionsOwnerAdmin?.isActive
if (!configured) {
const safeTimezone = isValidIanaTimezone(caseData.billingAccount.timezone) ? caseData.billingAccount.timezone : 'UTC'
const eventKey = `configuration-blocked:${localDate(now, safeTimezone)}`
const firstOccurrenceToday = await recordCollectionsEvent(caseData, eventKey, 'collections.configuration_blocked')
if (firstOccurrenceToday && caseData.collectionsOwnerAdmin?.isActive) {
const locale = coerceNotificationLocale(caseData.collectionsOwnerAdmin.preferredLocale)
const copy = configurationCopy[locale]
await sendNotification({
type: 'COLLECTIONS_CALL_REQUIRED',
title: copy.title,
body: copy.body(caseData.invoice.company.name),
companyId: caseData.companyId,
adminUserId: caseData.collectionsOwnerAdmin.id,
channels: ['IN_APP', 'EMAIL'],
locale,
templateKey: 'subscription.collections_configuration_required.v1',
idempotencyKey: `collections:${caseData.invoiceId}:${eventKey}:admin:${caseData.collectionsOwnerAdmin.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
data: { collectionsCaseId: caseData.id, invoiceId: caseData.invoiceId, timezone: caseData.billingAccount.timezone, templateVersion: 1 },
policy: { mandatory: true },
})
}
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { nextActionAt: new Date(now.getTime() + 24 * 60 * 60 * 1000) } })
return
}
for (const override of caseData.overrides) {
if (override.expiresAt <= now) await prisma.collectionsOverride.update({ where: { id: override.id }, data: { status: 'EXPIRED' } })
}
const activeOverride = caseData.overrides.find((override: any) => override.expiresAt > now)
if (now < caseData.originalExpirationAt) {
const milestones: Array<[CollectionsMilestone, Date, string]> = [
['DUE_14D', caseData.reminder14At, 'due-14d'],
['DUE_7D', caseData.reminder7At, 'due-7d'],
['DUE_48H', caseData.reminder48At, 'due-48h'],
['DUE_24H', caseData.reminder24At, 'due-24h'],
]
const due = milestones.filter((item) => item[1] <= now).reverse()
if (due[0] && !activeOverride?.pauseNotifications) await processMilestone(caseData, due[0][0], due[0][2])
await prisma.collectionsCase.update({
where: { id: caseData.id },
data: { status: due.length ? 'PRE_DUE' : 'SCHEDULED', nextActionAt: nextReminderAfter(caseData, now), version: { increment: 1 } },
})
return
}
if (caseData.status !== 'GRACE_PERIOD') {
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { status: 'GRACE_PERIOD', graceStartedAt: caseData.originalExpirationAt } })
}
const suspensionAt = activeOverride?.revisedSuspensionAt ?? caseData.finalSuspensionAt
if (now >= suspensionAt) {
if (activeOverride?.pauseSuspension || !flag('SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED')) {
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { nextActionAt: activeOverride?.expiresAt ?? new Date(now.getTime() + 60 * 60 * 1000) } })
return
}
const suspended = await prisma.$transaction(async (tx: any) => {
const unpaid = await tx.billingInvoice.findFirst({ where: { id: caseData.invoiceId, status: { not: 'PAID' }, amountDue: { gt: 0 } } })
if (!unpaid) return false
const changed = await tx.collectionsCase.updateMany({ where: { id: caseData.id, status: { in: ['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD'] } }, data: { status: 'SUSPENDED', suspendedAt: now, nextActionAt: null } })
if (changed.count !== 1) return false
await tx.subscription.update({ where: { id: caseData.subscriptionId }, data: { status: 'SUSPENDED', suspendedAt: now } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseData.id, companyId: caseData.companyId, eventType: 'collections.suspended', idempotencyKey: 'suspended', payload: { finalSuspensionAt: suspensionAt } } })
await tx.subscriptionEvent.create({ data: { subscriptionId: caseData.subscriptionId, companyId: caseData.companyId, eventType: 'subscription.suspended', source: 'collections_worker', payload: { invoiceId: caseData.invoiceId }, occurredAt: now } })
await tx.billingEvent.create({ data: { billingAccountId: caseData.billingAccountId, invoiceId: caseData.invoiceId, subscriptionId: caseData.subscriptionId, companyId: caseData.companyId, eventType: 'collections.suspended', source: 'system', payload: {}, occurredAt: now } })
return true
})
if (suspended) {
for (const contact of caseData.billingAccount.billingContacts) {
const locale = effectiveContactLocale(contact, caseData.billingAccount)
const copy = suspensionCopy[locale]
const invoice = caseData.invoice.invoiceNumber ?? caseData.invoiceId
await sendNotification({
type: 'SUBSCRIPTION_SUSPENDED',
title: copy.title,
body: copy.body(invoice),
companyId: caseData.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: 'subscription.suspended.v1',
idempotencyKey: `collections:${caseData.invoiceId}:suspended:${contact.id}`,
sourceType: 'collections_case',
sourceId: caseData.id,
data: { collectionsCaseId: caseData.id, invoiceId: caseData.invoiceId, timezone: caseData.billingAccount.timezone, templateVersion: 1, localizationFallback: false },
policy: { mandatory: true },
})
}
}
return
}
const expiryDay = dayjs(caseData.originalExpirationAt).tz(caseData.billingAccount.timezone).startOf('day')
const nowDay = dayjs(now).tz(caseData.billingAccount.timezone).startOf('day')
const graceDay = Math.max(1, Math.min(30, nowDay.diff(expiryDay, 'day') + 1))
const localKey = localDate(now, caseData.billingAccount.timezone)
if (!activeOverride?.pauseNotifications) {
await processMilestone(caseData, graceDay >= 30 ? 'GRACE_FINAL' : 'GRACE_DAILY', `grace:${localKey}`, graceDay)
}
const tomorrow = dayjs(now).tz(caseData.billingAccount.timezone).add(1, 'day').toDate()
const nextDaily = atLocalTime(tomorrow, caseData.billingAccount.timezone, caseData.billingAccount.reminderLocalTime)
await prisma.collectionsCase.update({ where: { id: caseData.id }, data: { nextActionAt: nextDaily < suspensionAt ? nextDaily : suspensionAt, version: { increment: 1 } } })
}
export async function runCollectionsWorker(now = new Date()) {
if (!flag('SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED')) return 0
await ensureRenewalCollectionsCases(now)
const cases = await prisma.collectionsCase.findMany({
where: { status: { in: ['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD'] }, nextActionAt: { lte: now }, OR: [{ processingLeaseUntil: null }, { processingLeaseUntil: { lt: now } }] },
select: { id: true, version: true },
orderBy: { nextActionAt: 'asc' },
take: 100,
})
let processed = 0
for (const item of cases) {
const workerId = crypto.randomUUID()
const claimed = await prisma.collectionsCase.updateMany({
where: { id: item.id, version: item.version, OR: [{ processingLeaseUntil: null }, { processingLeaseUntil: { lt: now } }] },
data: { processingLeaseUntil: new Date(now.getTime() + 5 * 60 * 1000), processingBy: workerId, version: { increment: 1 } },
})
if (claimed.count !== 1) continue
try {
await processCollectionsCase(item.id, now)
processed += 1
} finally {
await prisma.collectionsCase.updateMany({ where: { id: item.id, processingBy: workerId }, data: { processingLeaseUntil: null, processingBy: null } })
}
}
return processed
}
export async function listCollectionsCases(query: { status?: string; assignedTo?: string; actionDueBefore?: string; page: number; pageSize: number }) {
const where: any = {
...(query.status ? { status: query.status } : {}),
...(query.assignedTo ? { collectionsOwnerAdminId: query.assignedTo } : {}),
...(query.actionDueBefore ? { nextActionAt: { lte: new Date(query.actionDueBefore) } } : {}),
}
const [data, total] = await Promise.all([
prisma.collectionsCase.findMany({
where,
include: {
invoice: { include: { company: true } },
billingAccount: { include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true } } } },
collectionsOwnerAdmin: { select: { id: true, firstName: true, lastName: true, email: true } },
tasks: { where: { status: 'OPEN' }, orderBy: { dueAt: 'asc' } },
overrides: { where: { status: 'ACTIVE' }, orderBy: { expiresAt: 'desc' } },
},
orderBy: [{ nextActionAt: 'asc' }, { originalExpirationAt: 'asc' }],
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma.collectionsCase.count({ where }),
])
return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.max(1, Math.ceil(total / query.pageSize)) }
}
export async function getCollectionsCase(caseId: string) {
const value = await prisma.collectionsCase.findUnique({
where: { id: caseId },
include: { invoice: { include: { company: true, manualPaymentSubmissions: { include: { documents: true } } } }, billingAccount: { include: { billingContacts: true } }, collectionsOwnerAdmin: true, tasks: { orderBy: { createdAt: 'desc' } }, overrides: { orderBy: { createdAt: 'desc' } }, events: { orderBy: { occurredAt: 'desc' } } },
})
if (!value) throw new NotFoundError('Collections case not found')
return value
}
export async function assignCollectionsCase(caseId: string, adminId: string, actorId: string) {
const admin = await prisma.adminUser.findFirst({ where: { id: adminId, isActive: true, role: { in: ['FINANCE', 'ADMIN', 'SUPER_ADMIN'] } } })
if (!admin) throw new ValidationError('Assignee must be an active finance-capable admin')
return prisma.$transaction(async (tx: any) => {
const value = await tx.collectionsCase.update({ where: { id: caseId }, data: { collectionsOwnerAdminId: adminId } })
await tx.collectionsCallTask.updateMany({ where: { collectionsCaseId: caseId, status: 'OPEN' }, data: { assignedAdminId: adminId } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseId, companyId: value.companyId, eventType: 'collections.assigned', idempotencyKey: `assigned:${adminId}:${Date.now()}`, actorType: 'admin', actorId, payload: { assignedAdminId: adminId } } })
return value
})
}
async function assertTaskActor(task: any, actorId: string) {
if (task.assignedAdminId === actorId) return
const actor = await prisma.adminUser.findUnique({ where: { id: actorId } })
if (!actor || !['SUPER_ADMIN', 'ADMIN'].includes(actor.role)) throw new ForbiddenError('Only the assigned collections admin may complete this task')
}
export async function recordCallOutcome(taskId: string, data: { outcome: string; note?: string; promisedPaymentAt?: string; nextFollowUpAt?: string }, adminId: string) {
const task = await prisma.collectionsCallTask.findUnique({ where: { id: taskId }, include: { collectionsCase: true } })
if (!task) throw new NotFoundError('Collections task not found')
if (task.status !== 'OPEN') throw new ConflictError('Collections task is not open')
await assertTaskActor(task, adminId)
const completedAt = new Date()
return prisma.$transaction(async (tx: any) => {
const updated = await tx.collectionsCallTask.update({
where: { id: taskId },
data: { status: 'COMPLETED', outcome: data.outcome as any, note: data.note, promisedPaymentAt: data.promisedPaymentAt ? new Date(data.promisedPaymentAt) : null, nextFollowUpAt: data.nextFollowUpAt ? new Date(data.nextFollowUpAt) : null, completedByAdminId: adminId, completedAt },
})
if (data.outcome === 'PAYMENT_PROMISED' && data.nextFollowUpAt) {
await tx.collectionsCallTask.upsert({
where: { collectionsCaseId_taskType: { collectionsCaseId: task.collectionsCaseId, taskType: 'PAYMENT_PROMISE_FOLLOW_UP' } },
update: { status: 'OPEN', assignedAdminId: task.assignedAdminId, dueAt: new Date(data.nextFollowUpAt), outcome: null, completedAt: null, completedByAdminId: null },
create: { collectionsCaseId: task.collectionsCaseId, taskType: 'PAYMENT_PROMISE_FOLLOW_UP', assignedAdminId: task.assignedAdminId, billingContactId: task.billingContactId, dueAt: new Date(data.nextFollowUpAt), companyDefaultLocale: task.companyDefaultLocale, contactLocale: task.contactLocale, customerScript: task.customerScript },
})
}
await tx.collectionsEvent.create({ data: { collectionsCaseId: task.collectionsCaseId, companyId: task.collectionsCase.companyId, eventType: 'collections.call_outcome', idempotencyKey: `call-outcome:${task.id}`, actorType: 'admin', actorId: adminId, payload: { outcome: data.outcome, note: data.note ?? null, promisedPaymentAt: data.promisedPaymentAt ?? null } } })
return updated
})
}
export async function createCollectionsOverride(caseId: string, data: { type: string; reason: string; expiresAt: string; revisedSuspensionAt?: string; pauseSuspension: boolean; pauseNotifications: boolean }, adminId: string) {
const expiresAt = new Date(data.expiresAt)
if (expiresAt <= new Date() || expiresAt.getTime() > Date.now() + 90 * 24 * 60 * 60 * 1000) throw new ValidationError('Override expiry must be in the future and no more than 90 days away')
const value = await prisma.collectionsCase.findUnique({ where: { id: caseId } })
if (!value) throw new NotFoundError('Collections case not found')
const revisedSuspensionAt = data.revisedSuspensionAt ? new Date(data.revisedSuspensionAt) : null
if (revisedSuspensionAt && (revisedSuspensionAt <= value.originalExpirationAt || revisedSuspensionAt > expiresAt)) {
throw new ValidationError('Revised suspension time must be after the original expiration and no later than the override expiry')
}
const override = await prisma.$transaction(async (tx: any) => {
const active = await tx.collectionsOverride.findFirst({ where: { collectionsCaseId: caseId, status: 'ACTIVE', expiresAt: { gt: new Date() } } })
if (active) throw new ConflictError('Revoke the active collections override before creating another')
const override = await tx.collectionsOverride.create({ data: { collectionsCaseId: caseId, type: data.type as any, reason: data.reason, expiresAt, revisedSuspensionAt, pauseSuspension: data.pauseSuspension, pauseNotifications: data.pauseNotifications, createdByAdminId: adminId } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseId, companyId: value.companyId, eventType: 'collections.override_created', idempotencyKey: `override:${override.id}:created`, actorType: 'admin', actorId: adminId, payload: { overrideId: override.id, type: override.type, expiresAt } } })
return override
}, { isolationLevel: 'Serializable' as any })
await sendOverrideChangedNotification(caseId, override.id, 'created')
return override
}
export async function revokeCollectionsOverride(caseId: string, overrideId: string, adminId: string) {
const value = await prisma.collectionsOverride.findFirst({ where: { id: overrideId, collectionsCaseId: caseId, status: 'ACTIVE' }, include: { collectionsCase: true } })
if (!value) throw new NotFoundError('Active collections override not found')
const updated = await prisma.$transaction(async (tx: any) => {
const updated = await tx.collectionsOverride.update({ where: { id: overrideId }, data: { status: 'REVOKED', revokedByAdminId: adminId, revokedAt: new Date() } })
await tx.collectionsCase.update({ where: { id: caseId }, data: { nextActionAt: new Date() } })
await tx.collectionsEvent.create({ data: { collectionsCaseId: caseId, companyId: value.collectionsCase.companyId, eventType: 'collections.override_revoked', idempotencyKey: `override:${overrideId}:revoked`, actorType: 'admin', actorId: adminId, payload: { overrideId } } })
return updated
})
await sendOverrideChangedNotification(caseId, overrideId, 'revoked')
return updated
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
communicationSettingsSchema,
createManualPaymentSubmissionSchema,
manualCheckoutSchema,
} from './subscription.schemas'
import { addBillingPeriod, normalizeExternalReference } from './subscription.manual.service'
describe('manual subscription payment contracts', () => {
it('accepts only bank transfer and check manual checkout', () => {
const base = { plan: 'GROWTH', billingPeriod: 'ANNUAL', currency: 'MAD', idempotencyKey: crypto.randomUUID() }
expect(manualCheckoutSchema.safeParse({ ...base, method: 'BANK_TRANSFER' }).success).toBe(true)
expect(manualCheckoutSchema.safeParse({ ...base, method: 'STRIPE' }).success).toBe(false)
})
it('normalizes references without discarding the display value', () => {
const submittedReference = ' bank txn-123 '
const parsed = createManualPaymentSubmissionSchema.parse({ method: 'BANK_TRANSFER', submittedReference, idempotencyKey: crypto.randomUUID() })
expect(parsed.submittedReference).toBe('bank txn-123')
expect(normalizeExternalReference(parsed.submittedReference)).toBe('BANK TXN-123')
})
it('enforces the company-enabled communication language set', () => {
const valid = {
timezone: 'Africa/Casablanca', reminderLocalTime: '09:00',
enabledCommunicationLocales: ['ar', 'fr'], defaultCommunicationLocale: 'fr',
contacts: [{ email: 'billing@example.ma', locale: 'ar', isPrimary: true, receivePaymentNotices: true, isActive: true }],
}
expect(communicationSettingsSchema.safeParse(valid).success).toBe(true)
expect(communicationSettingsSchema.safeParse({ ...valid, defaultCommunicationLocale: 'en' }).success).toBe(false)
expect(communicationSettingsSchema.safeParse({ ...valid, contacts: [{ ...valid.contacts[0], locale: 'en' }] }).success).toBe(false)
expect(communicationSettingsSchema.safeParse({ ...valid, enabledCommunicationLocales: [] }).success).toBe(false)
})
it('clamps month-end and leap-year billing periods', () => {
expect(addBillingPeriod(new Date('2028-01-31T12:00:00.000Z'), 'MONTHLY').toISOString()).toBe('2028-02-29T12:00:00.000Z')
expect(addBillingPeriod(new Date('2028-02-29T12:00:00.000Z'), 'ANNUAL').toISOString()).toBe('2029-02-28T12:00:00.000Z')
})
})
@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createManualPaymentSubmission, submitManualPaymentSubmission } from './subscription.manual.service'
import { prisma } from '../../lib/prisma'
import { sendNotification } from '../../services/notificationService'
vi.mock('../../lib/prisma', () => ({
prisma: {
$transaction: vi.fn(),
billingAccount: { findFirst: vi.fn(), findUnique: vi.fn(), findUniqueOrThrow: vi.fn() },
billingInvoice: { findFirst: vi.fn() },
manualPaymentSubmission: { findUnique: vi.fn(), findFirst: vi.fn(), upsert: vi.fn() },
adminUser: { findMany: vi.fn() },
},
}))
vi.mock('../../services/notificationService', () => ({
sendNotification: vi.fn(),
}))
vi.mock('../../lib/storage', () => ({
deletePrivateDocument: vi.fn(),
promotePaymentEvidence: vi.fn(),
readPrivateDocument: vi.fn(),
storePaymentEvidenceInQuarantine: vi.fn(),
}))
vi.mock('../../services/paymentEvidenceScanner', () => ({
scanPaymentEvidenceFile: vi.fn(),
}))
describe('manual subscription payment submissions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('treats an already submitted payment package as idempotent without sending another notice', async () => {
const submission = {
id: 'submission_1',
invoiceId: 'invoice_1',
billingAccountId: 'billing_1',
companyId: 'company_1',
method: 'BANK_TRANSFER',
submittedReference: 'bank ref 123',
status: 'SUBMITTED',
submittedAt: new Date('2026-08-10T12:00:00.000Z'),
reviewedAt: null,
rejectionReason: null,
documents: [{ id: 'doc_1', kind: 'BANK_TRANSFER_RECEIPT', originalFilename: 'receipt.pdf', detectedMimeType: 'application/pdf', byteSize: 1024, scanStatus: 'CLEAN', uploadedAt: new Date('2026-08-10T12:01:00.000Z') }],
invoice: { invoiceNumber: 'INV-2026-000001' },
}
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback({
manualPaymentSubmission: {
findFirst: vi.fn().mockResolvedValue(submission),
},
}))
await expect(submitManualPaymentSubmission('company_1', 'submission_1')).resolves.toMatchObject({
id: 'submission_1',
status: 'SUBMITTED',
documents: [{ id: 'doc_1', scanStatus: 'CLEAN' }],
})
expect(sendNotification).not.toHaveBeenCalled()
})
it('reuses the active submission created by a concurrent request after the invoice-level unique guard fires', async () => {
const concurrentSubmission = {
id: 'submission_2',
invoiceId: 'invoice_1',
billingAccountId: 'billing_1',
companyId: 'company_1',
method: 'BANK_TRANSFER',
submittedReference: 'bank ref 456',
normalizedSubmittedReference: 'BANK REF 456',
status: 'DRAFT',
submittedAt: null,
reviewedAt: null,
rejectionReason: null,
documents: [],
}
vi.mocked(prisma.billingAccount.findFirst).mockResolvedValue({
id: 'billing_1',
billingContacts: [{ id: 'contact_1' }],
} as never)
vi.mocked(prisma.billingAccount.findUniqueOrThrow).mockResolvedValue({
id: 'billing_1',
billingContacts: [{ id: 'contact_1' }],
} as never)
vi.mocked(prisma.billingInvoice.findFirst).mockResolvedValue({
id: 'invoice_1',
companyId: 'company_1',
billingAccountId: 'billing_1',
status: 'OPEN',
collectionMethod: 'BANK_TRANSFER',
} as never)
vi.mocked(prisma.manualPaymentSubmission.findUnique).mockResolvedValue(null as never)
vi.mocked(prisma.manualPaymentSubmission.findFirst)
.mockResolvedValueOnce(null as never)
.mockResolvedValueOnce(concurrentSubmission as never)
vi.mocked(prisma.manualPaymentSubmission.upsert).mockRejectedValueOnce({ code: 'P2002' })
await expect(createManualPaymentSubmission('company_1', 'employee_1', 'invoice_1', {
method: 'BANK_TRANSFER',
submittedReference: ' bank ref 456 ',
idempotencyKey: 'idem_1',
})).resolves.toMatchObject({
id: 'submission_2',
status: 'DRAFT',
submittedReference: 'bank ref 456',
})
})
it('sends detailed customer and admin notifications when payment evidence is submitted', async () => {
const submittedAt = new Date('2026-08-10T12:00:00.000Z')
const draftSubmission = {
id: 'submission_3',
invoiceId: 'invoice_3',
billingAccountId: 'billing_3',
companyId: 'company_3',
method: 'CHECK',
submittedReference: 'check-7788',
status: 'DRAFT',
submittedAt: null,
reviewedAt: null,
rejectionReason: null,
documents: [{ id: 'doc_3', kind: 'CHECK_COPY', originalFilename: 'check.jpg', detectedMimeType: 'image/jpeg', byteSize: 2048, scanStatus: 'CLEAN', uploadedAt: submittedAt }],
invoice: {
id: 'invoice_3',
invoiceNumber: 'INV-2026-000003',
status: 'OPEN',
subscriptionId: 'subscription_3',
},
}
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback({
manualPaymentSubmission: {
findFirst: vi.fn().mockResolvedValue(draftSubmission),
update: vi.fn().mockResolvedValue({ ...draftSubmission, status: 'SUBMITTED', submittedAt }),
},
billingEvent: { create: vi.fn().mockResolvedValue({ id: 'event_1' }) },
}))
vi.mocked(prisma.billingAccount.findUnique).mockResolvedValue({
id: 'billing_3',
defaultCommunicationLocale: 'en',
enabledCommunicationLocales: ['en'],
timezone: 'Africa/Casablanca',
billingContacts: [{ id: 'contact_3', employeeId: null, locale: 'en', employee: null }],
} as never)
vi.mocked(prisma.manualPaymentSubmission.findUnique).mockResolvedValue({
...draftSubmission,
status: 'SUBMITTED',
submittedAt,
submittedByEmployee: { email: 'owner@example.test' },
documents: draftSubmission.documents,
invoice: {
id: 'invoice_3',
invoiceNumber: 'INV-2026-000003',
amountDue: 14900,
currency: 'MAD',
requestedPlan: 'STARTER',
requestedBillingPeriod: 'MONTHLY',
dueAt: new Date('2026-08-24T00:00:00.000Z'),
company: { name: 'Atlas car' },
subscription: { currentPeriodStart: null, currentPeriodEnd: null },
lineItems: [{ periodStart: new Date('2026-08-10T00:00:00.000Z'), periodEnd: new Date('2026-09-10T00:00:00.000Z') }],
},
} as never)
vi.mocked(prisma.adminUser.findMany).mockResolvedValue([{ id: 'admin_finance', preferredLocale: 'en' }] as never)
await submitManualPaymentSubmission('company_3', 'submission_3')
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
billingContactId: 'contact_3',
body: expect.stringContaining('Payment type: Check'),
data: expect.objectContaining({ amountDue: 14900, paymentType: 'CHECK' }),
}))
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
adminUserId: 'admin_finance',
channels: ['IN_APP', 'EMAIL'],
title: 'Payment evidence submitted: INV-2026-000003',
body: expect.stringContaining('Action required: open Admin Billing'),
}))
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../services/stripeService', () => ({
getConfigurationStatus: vi.fn(),
}))
import { getConfigurationStatus } from '../../services/stripeService'
import { getPaymentOptions } from './subscription.payment-config'
const ENV_KEYS = [
'MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED',
'MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED',
'PAYMENT_EVIDENCE_SCANNER_MODE',
'PAYMENT_EVIDENCE_SCANNER_PATH',
'BANK_TRANSFER_ENABLED',
'BANK_TRANSFER_ACCOUNT_NAME',
'BANK_TRANSFER_BANK_NAME',
'BANK_TRANSFER_ACCOUNT_REFERENCE',
'CHECK_PAYMENT_ENABLED',
'CHECK_PAYMENT_PAYEE',
'CHECK_PAYMENT_DELIVERY_ADDRESS',
'NODE_ENV',
] as const
describe('subscription payment configuration', () => {
const originalEnv = new Map<string, string | undefined>()
beforeEach(() => {
vi.mocked(getConfigurationStatus).mockReturnValue({ configured: true, problems: [] })
for (const key of ENV_KEYS) originalEnv.set(key, process.env[key])
})
afterEach(() => {
for (const key of ENV_KEYS) {
const value = originalEnv.get(key)
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
originalEnv.clear()
vi.clearAllMocks()
})
it('exposes local manual payment methods when evidence scanning is ready', () => {
process.env.NODE_ENV = 'development'
expect(getPaymentOptions('en').methods).toEqual([
{ method: 'STRIPE', enabled: true },
{
method: 'BANK_TRANSFER',
enabled: true,
instructions: {
accountName: 'RentalDriveGo SARL',
bankName: 'Local Development Bank',
accountReference: 'DEV-MA64-0000-0000-0000',
message: 'Enter the bank transfer reference and upload the transfer receipt.',
},
},
{
method: 'CHECK',
enabled: true,
instructions: {
payee: 'RentalDriveGo SARL',
deliveryAddress: 'Local development billing desk',
message: 'Enter the check number and upload a check copy.',
},
},
])
})
it('hides manual methods in production when the evidence pipeline is not ready', () => {
process.env.NODE_ENV = 'production'
process.env.MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED = 'true'
process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED = 'true'
process.env.BANK_TRANSFER_ENABLED = 'true'
process.env.CHECK_PAYMENT_ENABLED = 'true'
expect(getPaymentOptions('en').methods).toEqual([{ method: 'STRIPE', enabled: true }])
})
})
@@ -0,0 +1,91 @@
import { ValidationError } from '../../http/errors'
import { getConfigurationStatus as getStripeConfigurationStatus } from '../../services/stripeService'
import type { NotificationLocale } from '../../services/notificationLocalizationService'
export type ManualCollectionMethod = 'BANK_TRANSFER' | 'CHECK'
const isProduction = () => process.env.NODE_ENV === 'production'
const useLocalDefaults = () => !isProduction()
const enabled = (value: string | undefined, defaultValue = false) => value === undefined ? defaultValue : value === 'true'
const localDefault = (value: string | undefined, fallback: string) => value?.trim() || (useLocalDefaults() ? fallback : '')
const messages: Record<NotificationLocale, Record<ManualCollectionMethod, string>> = {
en: {
BANK_TRANSFER: 'Enter the bank transfer reference and upload the transfer receipt.',
CHECK: 'Enter the check number and upload a check copy.',
},
fr: {
BANK_TRANSFER: 'Saisissez la référence du virement et téléversez le reçu.',
CHECK: 'Saisissez le numéro du chèque et téléversez une copie.',
},
ar: {
BANK_TRANSFER: 'أدخل مرجع التحويل البنكي وارفع إيصال التحويل.',
CHECK: 'أدخل رقم الشيك وارفع نسخة من الشيك.',
},
}
export function manualPaymentFeatureEnabled() {
return enabled(process.env.MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED, useLocalDefaults())
}
export function paymentEvidenceUploadEnabled() {
return manualPaymentFeatureEnabled() && enabled(process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED, useLocalDefaults())
}
export function paymentEvidencePipelineReady() {
if (!paymentEvidenceUploadEnabled()) return false
if (process.env.PAYMENT_EVIDENCE_SCANNER_PATH?.trim()) return true
return !isProduction() && (process.env.PAYMENT_EVIDENCE_SCANNER_MODE === undefined || process.env.PAYMENT_EVIDENCE_SCANNER_MODE === 'stub-clean')
}
export function getPaymentOptions(locale: NotificationLocale = 'en') {
const stripeStatus = getStripeConfigurationStatus()
const methods: Array<Record<string, unknown>> = [{ method: 'STRIPE', enabled: stripeStatus.configured }]
// A manual method is unusable unless evidence can be scanned and submitted.
// Keeping it hidden also makes the rollout fail closed when the scanner is
// accidentally omitted from a production deployment.
if (!manualPaymentFeatureEnabled() || !paymentEvidencePipelineReady()) return { methods }
if (enabled(process.env.BANK_TRANSFER_ENABLED, useLocalDefaults())) {
const accountName = localDefault(process.env.BANK_TRANSFER_ACCOUNT_NAME, 'RentalDriveGo SARL')
const bankName = localDefault(process.env.BANK_TRANSFER_BANK_NAME, 'Local Development Bank')
const accountReference = localDefault(process.env.BANK_TRANSFER_ACCOUNT_REFERENCE, 'DEV-MA64-0000-0000-0000')
if (accountName && bankName && accountReference) {
methods.push({
method: 'BANK_TRANSFER',
enabled: true,
instructions: { accountName, bankName, accountReference, message: messages[locale].BANK_TRANSFER },
})
}
}
if (enabled(process.env.CHECK_PAYMENT_ENABLED, useLocalDefaults())) {
const payee = localDefault(process.env.CHECK_PAYMENT_PAYEE, 'RentalDriveGo SARL')
const deliveryAddress = localDefault(process.env.CHECK_PAYMENT_DELIVERY_ADDRESS, 'Local development billing desk')
if (payee && deliveryAddress) {
methods.push({
method: 'CHECK',
enabled: true,
instructions: { payee, deliveryAddress, message: messages[locale].CHECK },
})
}
}
return { methods }
}
export function requireManualMethodEnabled(method: ManualCollectionMethod, locale: NotificationLocale = 'en') {
const option = getPaymentOptions(locale).methods.find((item) => item.method === method && item.enabled === true)
if (!option) throw new ValidationError(`${method === 'BANK_TRANSFER' ? 'Bank transfer' : 'Check'} payments are not available`)
return option
}
export function manualPaymentDueDays(method: ManualCollectionMethod) {
const raw = method === 'CHECK'
? process.env.CHECK_PAYMENT_DUE_DAYS
: process.env.BANK_TRANSFER_DUE_DAYS
const fallback = method === 'CHECK' ? 14 : 7
const value = Number(raw ?? fallback)
return Number.isInteger(value) && value >= 1 && value <= 60 ? value : fallback
}
@@ -3,8 +3,9 @@ import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { parseBody, parseParams } from '../../http/validate'
import { created, ok } from '../../http/respond'
import { paymentEvidenceUpload } from '../../http/upload/paymentEvidence'
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
@@ -17,7 +18,15 @@ import {
startTrialSchema,
cancelSchema,
reactivateSchema,
manualCheckoutSchema,
invoiceIdParamSchema,
submissionIdParamSchema,
submissionDocumentParamsSchema,
createManualPaymentSubmissionSchema,
manualPaymentDocumentFieldsSchema,
communicationSettingsSchema,
} from './subscription.schemas'
import * as manualService from './subscription.manual.service'
const publicRouter = Router()
const webhookRouter = Router()
@@ -83,7 +92,24 @@ router.get('/me', async (req, res, next) => {
})
router.get('/invoices', async (req, res, next) => {
try { ok(res, await service.getInvoices(req.companyId)) } catch (err) { next(err) }
try { ok(res, await manualService.getCanonicalInvoices(req.companyId)) } catch (err) { next(err) }
})
router.get('/invoices/:invoiceId/pdf', requireRole('OWNER'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { pdfBuffer, invoiceNumber } = await manualService.getPaidInvoicePdf(req.companyId, invoiceId)
const safeNumber = invoiceNumber.replace(/["\\\r\n]/g, '_')
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${safeNumber}.pdf"`)
res.setHeader('Content-Length', pdfBuffer.length)
res.setHeader('Cache-Control', 'private, no-store')
res.end(pdfBuffer)
} catch (err) { next(err) }
})
router.get('/payment-options', async (req, res, next) => {
try { ok(res, await manualService.getCompanyPaymentOptions(req.companyId, req.employee.id)) } catch (err) { next(err) }
})
router.get('/events', async (req, res, next) => {
@@ -108,6 +134,83 @@ router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/manual-checkout', requireRole('OWNER'), async (req, res, next) => {
try {
created(res, await manualService.createManualCheckout(
req.companyId,
req.employee.id,
parseBody(manualCheckoutSchema, req),
))
} catch (err) { next(err) }
})
router.post('/invoices/:invoiceId/manual-payment-submissions', requireRole('OWNER'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
created(res, await manualService.createManualPaymentSubmission(
req.companyId,
req.employee.id,
invoiceId,
parseBody(createManualPaymentSubmissionSchema, req),
))
} catch (err) { next(err) }
})
router.post(
'/manual-payment-submissions/:submissionId/documents',
requireRole('OWNER'),
paymentEvidenceUpload.single('file'),
async (req, res, next) => {
try {
const { submissionId } = parseParams(submissionIdParamSchema, req)
const { kind } = parseBody(manualPaymentDocumentFieldsSchema, req)
created(res, await manualService.uploadManualPaymentDocument(req.companyId, req.employee.id, submissionId, kind, req.file))
} catch (err) { next(err) }
},
)
router.delete('/manual-payment-submissions/:submissionId/documents/:documentId', requireRole('OWNER'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(submissionDocumentParamsSchema, req)
ok(res, await manualService.deleteManualPaymentDocument(req.companyId, submissionId, documentId))
} catch (err) { next(err) }
})
router.post('/manual-payment-submissions/:submissionId/submit', requireRole('OWNER'), async (req, res, next) => {
try {
const { submissionId } = parseParams(submissionIdParamSchema, req)
ok(res, await manualService.submitManualPaymentSubmission(req.companyId, submissionId))
} catch (err) { next(err) }
})
router.get('/manual-payment-submissions/:submissionId/documents/:documentId', requireRole('OWNER'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(submissionDocumentParamsSchema, req)
const { document, bytes } = await manualService.getCustomerPaymentDocument(req.companyId, submissionId, documentId)
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.get('/communication-settings', requireRole('OWNER'), async (req, res, next) => {
try { ok(res, await manualService.getCommunicationSettings(req.companyId, req.employee.id)) } catch (err) { next(err) }
})
router.put('/communication-settings', requireRole('OWNER'), async (req, res, next) => {
try {
ok(res, await manualService.updateCommunicationSettings(
req.companyId,
req.employee.id,
parseBody(communicationSettingsSchema, req),
))
} catch (err) { next(err) }
})
router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(reactivateSchema, req)
@@ -4,6 +4,13 @@ const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
const providerEnum = z.enum(['STRIPE'])
const currencyEnum = z.enum(['MAD', 'EUR', 'USD'])
const manualMethodEnum = z.enum(['BANK_TRANSFER', 'CHECK'])
const localeEnum = z.enum(['ar', 'en', 'fr'])
const referenceSchema = z.string()
.trim()
.min(3)
.max(120)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters')
export const checkoutSchema = z.object({
plan: planEnum,
@@ -43,3 +50,60 @@ export const reactivateSchema = z.object({
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
export const manualCheckoutSchema = z.object({
plan: planEnum,
billingPeriod: billingPeriodEnum,
currency: z.literal('MAD'),
method: manualMethodEnum,
idempotencyKey: z.string().uuid(),
})
export const invoiceIdParamSchema = z.object({ invoiceId: z.string().min(1) })
export const submissionIdParamSchema = z.object({ submissionId: z.string().min(1) })
export const submissionDocumentParamsSchema = z.object({
submissionId: z.string().min(1),
documentId: z.string().min(1),
})
export const createManualPaymentSubmissionSchema = z.object({
method: manualMethodEnum,
submittedReference: referenceSchema,
idempotencyKey: z.string().uuid(),
})
export const manualPaymentDocumentFieldsSchema = z.object({
kind: z.enum(['BANK_TRANSFER_RECEIPT', 'CHECK_COPY', 'OTHER_SUPPORTING_EVIDENCE']),
})
export const billingContactInputSchema = z.object({
id: z.string().min(1).optional(),
employeeId: z.string().min(1).nullable().optional(),
email: z.string().email().max(255).trim().toLowerCase(),
locale: localeEnum.nullable().optional(),
isPrimary: z.boolean(),
receivePaymentNotices: z.boolean().default(true),
isActive: z.boolean().default(true),
})
export const communicationSettingsSchema = z.object({
timezone: z.string().min(1).max(100),
reminderLocalTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
enabledCommunicationLocales: z.array(localeEnum).min(1).max(3).transform((values) => Array.from(new Set(values))),
defaultCommunicationLocale: localeEnum,
contacts: z.array(billingContactInputSchema).min(1).max(20),
}).superRefine((data, ctx) => {
if (!data.enabledCommunicationLocales.includes(data.defaultCommunicationLocale)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultCommunicationLocale'], message: 'Default locale must be enabled' })
}
data.contacts.forEach((contact, index) => {
if (contact.locale && !data.enabledCommunicationLocales.includes(contact.locale)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contacts', index, 'locale'], message: 'Contact locale must be enabled' })
}
})
if (!data.contacts.some((contact) => contact.isActive && contact.isPrimary && contact.receivePaymentNotices)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contacts'], message: 'An active primary payment contact is required' })
}
})
export { manualMethodEnum, localeEnum, referenceSchema }
@@ -54,6 +54,12 @@ vi.mock('./subscription.repo', () => ({
setSuspended: vi.fn(),
}))
vi.mock('./subscription.manual.service', () => ({
createCanonicalStripeCheckoutInvoice: vi.fn(),
finalizeCanonicalOnlinePayment: vi.fn().mockResolvedValue(false),
recordCanonicalOnlinePaymentFailure: vi.fn().mockResolvedValue(false),
}))
vi.mock('../../security/webhookIdempotency', () => ({
getWebhookEventId: vi.fn((provider: string, event: any) => event.id ?? event.transaction_id ?? `${provider}_event`),
processWebhookOnce: vi.fn(async ({ handle }: { handle: () => Promise<unknown> }) => {
@@ -67,6 +73,7 @@ import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as stripe from '../../services/stripeService'
import * as repo from './subscription.repo'
import * as manualService from './subscription.manual.service'
import * as service from './subscription.service'
describe('subscription.service operational edges', () => {
@@ -131,7 +138,7 @@ describe('subscription.service operational edges', () => {
vi.mocked(repo.findOrCreateSubscription).mockResolvedValue({ id: 'sub_1' } as never)
vi.mocked(stripe.isConfigured).mockReturnValue(true)
vi.mocked(stripe.createCheckoutSession).mockResolvedValue({ checkoutUrl: 'https://checkout.stripe.test/session', sessionId: 'cs_test_123' } as never)
vi.mocked(repo.createInvoice).mockResolvedValue({ id: 'invoice_1' } as never)
vi.mocked(manualService.createCanonicalStripeCheckoutInvoice).mockResolvedValue({ id: 'invoice_1' } as never)
await expect(service.checkout('company_1', {
plan: 'GROWTH',
@@ -150,15 +157,12 @@ describe('subscription.service operational edges', () => {
subscriptionId: 'sub_1',
type: 'SUBSCRIPTION',
}))
expect(repo.createInvoice).toHaveBeenCalledWith(expect.objectContaining({
expect(manualService.createCanonicalStripeCheckoutInvoice).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1',
subscriptionId: 'sub_1',
requestedPlan: 'GROWTH',
requestedBillingPeriod: 'MONTHLY',
plan: 'GROWTH',
billingPeriod: 'MONTHLY',
amount: 19900,
paymentProvider: 'STRIPE',
amanpayTransactionId: null,
paypalCaptureId: null,
stripeCheckoutSessionId: 'cs_test_123',
dueAt: new Date('2026-06-08T00:00:00.000Z'),
}))
@@ -7,6 +7,11 @@ import * as stripe from '../../services/stripeService'
import * as repo from './subscription.repo'
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
import {
createCanonicalStripeCheckoutInvoice,
finalizeCanonicalOnlinePayment,
recordCanonicalOnlinePaymentFailure,
} from './subscription.manual.service'
// ─── Helpers ──────────────────────────────────────────────────
@@ -114,6 +119,7 @@ async function handlePaymentSuccess(subscriptionId: string, invoiceId: string, p
billingPeriod?: string | null
currency?: string | null
}) {
if (await finalizeCanonicalOnlinePayment(invoiceId)) return
const sub = await repo.findById(subscriptionId)
if (!sub) return
@@ -144,6 +150,7 @@ async function handlePaymentFailure(
failureCode?: string,
failureMessage?: string,
) {
if (await recordCanonicalOnlinePaymentFailure(invoiceId, failureCode, failureMessage)) return
const sub = await repo.findById(subscriptionId)
if (!sub) return
@@ -274,11 +281,6 @@ export async function checkout(companyId: string, body: {
const orderId = `sub-${companyId}-${Date.now()}`
const description = `${body.plan} plan — ${body.billingPeriod}`
let checkoutUrl: string
let amanpayTransactionId: string | null = null
let paypalCaptureId: string | null = null
let stripeCheckoutSessionId: string | null = null
if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured on this platform')
const result = await stripe.createCheckoutSession({
amount, currency: body.currency, orderId, description,
@@ -289,18 +291,19 @@ export async function checkout(companyId: string, body: {
subscriptionId: subscription.id,
type: 'SUBSCRIPTION',
})
checkoutUrl = result.checkoutUrl
stripeCheckoutSessionId = result.sessionId
const dueAt = new Date(Date.now() + SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000)
const invoice = await repo.createInvoice({
companyId, subscriptionId: subscription.id,
requestedPlan: body.plan,
requestedBillingPeriod: body.billingPeriod,
amount, currency: body.currency,
paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, stripeCheckoutSessionId, dueAt,
const invoice = await createCanonicalStripeCheckoutInvoice({
companyId,
subscriptionId: subscription.id,
plan: body.plan,
billingPeriod: body.billingPeriod,
amount,
currency: body.currency,
stripeCheckoutSessionId: result.sessionId,
dueAt,
})
return { invoice, checkoutUrl }
return { invoice, checkoutUrl: result.checkoutUrl }
}
export async function capturePaypal(companyId: string, paypalOrderId: string) {
@@ -72,14 +72,19 @@ describe('invoicePdfService', () => {
subtotalAmount: 80000,
discountAmount: 0,
creditAmount: 0,
taxAmount: 0,
totalAmount: 80000,
taxRate: 20,
taxAmount: 16000,
totalAmount: 96000,
amountPaid: 0,
amountDue: 80000,
amountDue: 96000,
},
})
expect(result.subarray(0, 8).toString()).toBe('%PDF-1.4')
expect(result.toString()).toContain('INV-2026-000002')
const pdf = result.toString()
expect(pdf).toContain('INV-2026-000002')
expect(pdf).toContain('Price before tax: 800.00 MAD')
expect(pdf).toContain('Tax \\(20%\\): 160.00 MAD')
expect(pdf).toContain('TTC: 960.00 MAD')
})
})
+24 -17
View File
@@ -25,6 +25,7 @@ interface InvoiceData {
transactionId?: string | null
paidAt?: string | null
lineItems?: Array<{
type?: string | null
description: string
amount: number
currency: string
@@ -37,6 +38,7 @@ interface InvoiceData {
subtotalAmount: number
discountAmount: number
creditAmount: number
taxRate?: number | null
taxAmount: number
totalAmount: number
amountPaid: number
@@ -340,13 +342,19 @@ function pdfText(value: unknown) {
.replace(/[^\x20-\x7E]/g, '')
}
function fmtTaxRate(value?: number | null) {
if (typeof value !== 'number' || !Number.isFinite(value)) return '0%'
return `${Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, '')}%`
}
function InvoiceDocument({ data }: { data: InvoiceData }) {
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
const addressStr = formatAddress(data.company.address)
const planLabel = data.subscription ? (PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan) : 'Manual'
const periodLabel = data.subscription ? (PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod) : 'Custom'
const lineItems = data.lineItems?.length
? data.lineItems
const invoiceLineItems = data.lineItems?.filter((item) => item.type !== 'TAX') ?? []
const lineItems = invoiceLineItems.length
? invoiceLineItems
: [{
description: `${planLabel} Plan — ${periodLabel} Subscription`,
amount: data.amount,
@@ -490,7 +498,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
React.createElement(Text, { style: s.totalLabel }, 'Price before tax'),
React.createElement(Text, { style: s.totalValue }, fmt(totals.subtotalAmount, data.currency)),
),
totals.discountAmount > 0
@@ -509,18 +517,16 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`),
)
: null,
totals.taxAmount > 0
? React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Tax'),
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
)
: null,
React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, `Tax (${fmtTaxRate(totals.taxRate)})`),
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
),
React.createElement(
View,
{ style: s.grandTotalRow },
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
React.createElement(Text, { style: s.grandTotalLabel }, 'TTC'),
React.createElement(Text, { style: s.grandTotalValue }, fmt(totals.totalAmount, data.currency)),
),
@@ -589,8 +595,9 @@ function pdfEscape(value: unknown) {
}
function buildSimpleInvoicePdf(data: InvoiceData) {
const lineItems = data.lineItems?.length
? data.lineItems
const invoiceLineItems = data.lineItems?.filter((item) => item.type !== 'TAX') ?? []
const lineItems = invoiceLineItems.length
? invoiceLineItems
: [{ description: 'Invoice charge', amount: data.amount, currency: data.currency }]
const totals = data.totals ?? {
subtotalAmount: data.amount,
@@ -616,11 +623,11 @@ function buildSimpleInvoicePdf(data: InvoiceData) {
'Line Items',
...lineItems.map((item) => `${item.description} - ${fmt(item.amount, item.currency)}`),
'',
`Subtotal: ${fmt(totals.subtotalAmount, data.currency)}`,
`Price before tax: ${fmt(totals.subtotalAmount, data.currency)}`,
totals.discountAmount ? `Discounts: -${fmt(totals.discountAmount, data.currency)}` : null,
totals.creditAmount ? `Credits: -${fmt(totals.creditAmount, data.currency)}` : null,
totals.taxAmount ? `Tax: ${fmt(totals.taxAmount, data.currency)}` : null,
`Total: ${fmt(totals.totalAmount, data.currency)}`,
`Tax (${fmtTaxRate(totals.taxRate)}): ${fmt(totals.taxAmount, data.currency)}`,
`TTC: ${fmt(totals.totalAmount, data.currency)}`,
`Amount Paid: ${fmt(totals.amountPaid, data.currency)}`,
`Amount Due: ${fmt(totals.amountDue, data.currency)}`,
'',
@@ -88,6 +88,8 @@ describe('notificationService command boundaries', () => {
recipientType: 'EMPLOYEE',
employeeId: 'employee_1',
renterId: null,
billingContactId: null,
adminUserId: null,
},
})
expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({
@@ -141,7 +143,87 @@ describe('notificationService command boundaries', () => {
channels: ['IN_APP'],
})
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'An explicit employee or renter recipient is required' }])
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'An explicit employee, renter, billing contact, or admin recipient is required' }])
expect(prismaMock.tx.notificationEvent.create).not.toHaveBeenCalled()
})
it('treats placeholder email provider secrets as not configured', async () => {
const previousEnv = {
RESEND_API_KEY: process.env.RESEND_API_KEY,
EMAIL_FROM: process.env.EMAIL_FROM,
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
MAIL_HOST: process.env.MAIL_HOST,
MAIL_PORT: process.env.MAIL_PORT,
MAIL_USERNAME: process.env.MAIL_USERNAME,
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
MAIL_FROM_ADDRESS: process.env.MAIL_FROM_ADDRESS,
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
}
const resendConstructor = vi.fn()
try {
process.env.EMAIL_PROVIDER = 'gmail'
process.env.RESEND_API_KEY = 're_PLACEHOLDER'
process.env.EMAIL_FROM = 'noreply@example.com'
process.env.EMAIL_FROM_NAME = 'Example App'
process.env.MAIL_HOST = 'smtp.gmail.com'
process.env.MAIL_PORT = '587'
process.env.MAIL_USERNAME = 'rentaldrivego@gmail.com'
process.env.MAIL_PASSWORD = 'placeholder'
process.env.MAIL_FROM_ADDRESS = 'rentaldrivego@gmail.com'
process.env.MAIL_FROM_NAME = 'RentalDriveGo'
vi.resetModules()
vi.doMock('resend', () => ({ Resend: resendConstructor }))
vi.doMock('../lib/prisma', () => ({ prisma: prismaMock.prisma }))
vi.doMock('./notificationLocalizationService', async () => {
const actual = await vi.importActual<typeof import('./notificationLocalizationService')>('./notificationLocalizationService')
return {
...actual,
resolveNotificationLocale: vi.fn().mockResolvedValue('en'),
resolveNotificationTemplate: vi.fn(),
}
})
const service = await import('./notificationService')
expect(resendConstructor).not.toHaveBeenCalled()
expect(service.describeEmailProviderConfig()).toEqual({
selectedProvider: 'smtp',
resend: {
apiKey: 'placeholder',
sender: 'not configured',
enabled: false,
},
smtp: {
host: 'configured',
port: 'configured',
username: 'configured',
password: 'placeholder',
sender: 'configured',
transport: 'not configured',
issues: [
'MAIL_PASSWORD is a placeholder; set a Gmail app password',
'SMTP transport is not configured',
],
},
})
await expect(service.sendTransactionalEmail({
to: 'owner@example.test',
subject: 'Verify',
html: '<p>Verify</p>',
text: 'Verify',
})).rejects.toThrow('SMTP email provider is selected but not configured: MAIL_PASSWORD is a placeholder; set a Gmail app password')
} finally {
Object.entries(previousEnv).forEach(([key, value]) => {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
})
vi.resetModules()
}
})
})
+399 -32
View File
@@ -3,47 +3,77 @@ import { prisma } from '../lib/prisma'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
import {
renderLocalizedEmailHtml,
coerceNotificationLocale,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
import type { NotificationTemplateVariables } from './notificationLocalizationService'
import { generateInvoicePdf } from './invoicePdfService'
const resendApiKey =
process.env.RESEND_API_KEY &&
process.env.RESEND_API_KEY !== 're_...' &&
process.env.RESEND_API_KEY.startsWith('re_')
? process.env.RESEND_API_KEY
: null
function envValue(name: string) {
const value = process.env[name]?.trim()
return value ? value : null
}
const resend = resendApiKey ? new Resend(resendApiKey) : null
function isPlaceholderEnvValue(value: string | null) {
if (!value) return true
const normalized = value.trim().toLowerCase()
return (
normalized.includes('${') ||
normalized.includes('placeholder') ||
normalized.includes('example') ||
normalized.startsWith('your-') ||
normalized === 'changeme' ||
normalized === 'change_me' ||
normalized === 're_...' ||
normalized === 're_placeholder'
)
}
function resolveEmailProvider() {
const value = envValue('EMAIL_PROVIDER')?.toLowerCase()
if (value === 'gmail') return 'smtp'
if (value === 'smtp' || value === 'resend') return value
return 'auto'
}
function resolveResendApiKey() {
const value = envValue('RESEND_API_KEY')
return value && value.startsWith('re_') && !isPlaceholderEnvValue(value) ? value : null
}
const emailProvider = resolveEmailProvider()
const resendApiKey = resolveResendApiKey()
const resend = emailProvider !== 'smtp' && resendApiKey ? new Resend(resendApiKey) : null
const emailFromAddress =
process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com'
? process.env.EMAIL_FROM
envValue('EMAIL_FROM') && !isPlaceholderEnvValue(envValue('EMAIL_FROM'))
? envValue('EMAIL_FROM')
: null
const emailFromName =
process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App'
? process.env.EMAIL_FROM_NAME
envValue('EMAIL_FROM_NAME') && !isPlaceholderEnvValue(envValue('EMAIL_FROM_NAME'))
? envValue('EMAIL_FROM_NAME')
: null
const smtpHost = process.env.MAIL_HOST
const smtpPort = Number(process.env.MAIL_PORT ?? 0)
const smtpUser = process.env.MAIL_USERNAME
const smtpPass = process.env.MAIL_PASSWORD
const smtpHost = envValue('MAIL_HOST')
const smtpPort = Number(envValue('MAIL_PORT') ?? 0)
const smtpUser = envValue('MAIL_USERNAME')
const smtpPass = envValue('MAIL_PASSWORD')
const smtpSecure =
process.env.MAIL_SCHEME === 'smtps' ||
process.env.MAIL_ENCRYPTION === 'ssl' ||
smtpPort === 465
const smtpFromAddress =
process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${')
? process.env.MAIL_FROM_ADDRESS
envValue('MAIL_FROM_ADDRESS') && !isPlaceholderEnvValue(envValue('MAIL_FROM_ADDRESS'))
? envValue('MAIL_FROM_ADDRESS')
: null
const smtpFromName =
process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${')
? process.env.MAIL_FROM_NAME
envValue('MAIL_FROM_NAME') && !isPlaceholderEnvValue(envValue('MAIL_FROM_NAME'))
? envValue('MAIL_FROM_NAME')
: null
type SmtpTransport = {
@@ -52,7 +82,7 @@ type SmtpTransport = {
let smtpTransport: SmtpTransport | null = null
if (smtpHost && smtpPort && smtpUser && smtpPass) {
if (smtpHost && smtpPort && smtpUser && smtpPass && !isPlaceholderEnvValue(smtpPass)) {
try {
const nodemailer = require('nodemailer') as {
createTransport(options: Record<string, unknown>): SmtpTransport
@@ -80,6 +110,8 @@ interface SendNotificationOptions {
companyId?: string
employeeId?: string
renterId?: string
billingContactId?: string
adminUserId?: string
billingAccountId?: string
email?: string
phone?: string
@@ -91,11 +123,14 @@ interface SendNotificationOptions {
idempotencyKey?: string
sourceType?: string
sourceId?: string
policy?: NotificationPolicy
}
type NotificationAudience =
| { type: 'EMPLOYEE'; employeeId: string }
| { type: 'RENTER'; renterId: string }
| { type: 'BILLING_CONTACT'; billingContactId: string }
| { type: 'ADMIN'; adminUserId: string }
| { type: 'COMPANY_EMPLOYEES' }
type NotificationPolicy = {
@@ -130,7 +165,15 @@ function uniqueChannels(channels: NotificationChannel[] | undefined): Notificati
}
function buildLegacyIdempotencyKey(opts: SendNotificationOptions) {
const target = opts.employeeId ? `employee:${opts.employeeId}` : opts.renterId ? `renter:${opts.renterId}` : `company:${opts.companyId ?? 'none'}`
const target = opts.employeeId
? `employee:${opts.employeeId}`
: opts.renterId
? `renter:${opts.renterId}`
: opts.billingContactId
? `billing-contact:${opts.billingContactId}`
: opts.adminUserId
? `admin:${opts.adminUserId}`
: `company:${opts.companyId ?? 'none'}`
const sourceId = opts.sourceId ?? String(opts.data?.id ?? opts.data?.reservationId ?? opts.data?.bookingId ?? target)
return [
'legacy-notification',
@@ -150,7 +193,7 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
where: { id: audience.employeeId, companyId, isActive: true },
select: { id: true, email: true, preferredLanguage: true },
})
return employee ? [{ recipientType: 'EMPLOYEE' as const, employeeId: employee.id, renterId: null, email: employee.email, locale: employee.preferredLanguage }] : []
return employee ? [{ recipientType: 'EMPLOYEE' as const, employeeId: employee.id, renterId: null, billingContactId: null, adminUserId: null, email: employee.email, locale: employee.preferredLanguage }] : []
}
if (audience.type === 'RENTER') {
@@ -158,7 +201,23 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
where: { id: audience.renterId },
select: { id: true, email: true, preferredLocale: true },
})
return renter ? [{ recipientType: 'RENTER' as const, employeeId: null, renterId: renter.id, email: renter.email, locale: renter.preferredLocale }] : []
return renter ? [{ recipientType: 'RENTER' as const, employeeId: null, renterId: renter.id, billingContactId: null, adminUserId: null, email: renter.email, locale: renter.preferredLocale }] : []
}
if (audience.type === 'BILLING_CONTACT') {
const contact = await prisma.billingContact.findFirst({
where: { id: audience.billingContactId, companyId, isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } },
select: { id: true, email: true, locale: true },
})
return contact ? [{ recipientType: 'BILLING_CONTACT' as const, employeeId: null, renterId: null, billingContactId: contact.id, adminUserId: null, email: contact.email, locale: contact.locale }] : []
}
if (audience.type === 'ADMIN') {
const admin = await prisma.adminUser.findFirst({
where: { id: audience.adminUserId, isActive: true },
select: { id: true, email: true, preferredLocale: true },
})
return admin ? [{ recipientType: 'ADMIN' as const, employeeId: null, renterId: null, billingContactId: null, adminUserId: admin.id, email: admin.email, locale: admin.preferredLocale }] : []
}
const employees = await prisma.employee.findMany({
@@ -169,6 +228,8 @@ async function resolveAudienceRecipients(companyId: string, audience: Notificati
recipientType: 'EMPLOYEE' as const,
employeeId: employee.id,
renterId: null,
billingContactId: null,
adminUserId: null,
email: employee.email,
locale: employee.preferredLanguage,
}))
@@ -178,6 +239,8 @@ async function resolvePreferenceDecision(input: {
companyId: string
employeeId: string | null
renterId: string | null
billingContactId?: string | null
adminUserId?: string | null
type: NotificationType
channel: NotificationChannel
policy?: NotificationPolicy
@@ -194,9 +257,11 @@ async function resolvePreferenceDecision(input: {
const personalWhere = input.employeeId
? { employeeId: input.employeeId, notificationType: input.type, channel: input.channel }
: { renterId: input.renterId!, notificationType: input.type, channel: input.channel }
: input.renterId
? { renterId: input.renterId, notificationType: input.type, channel: input.channel }
: null
const personal = await prisma.notificationPreference.findFirst({ where: personalWhere as any })
const personal = personalWhere ? await prisma.notificationPreference.findFirst({ where: personalWhere as any }) : null
if (personal) {
return {
enabled: personal.enabled,
@@ -228,15 +293,61 @@ async function resolvePreferenceDecision(input: {
}
function resolveSmtpReplyTo() {
if (!process.env.MAIL_REPLY_TO_ADDRESS || process.env.MAIL_REPLY_TO_ADDRESS.includes('${')) {
const replyToAddress = envValue('MAIL_REPLY_TO_ADDRESS')
const replyToName = envValue('MAIL_REPLY_TO_NAME')
if (!replyToAddress || isPlaceholderEnvValue(replyToAddress)) {
return undefined
}
if (process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')) {
return `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>`
if (replyToName && !isPlaceholderEnvValue(replyToName)) {
return `${replyToName} <${replyToAddress}>`
}
return process.env.MAIL_REPLY_TO_ADDRESS
return replyToAddress
}
function envStatus(value: string | null, configured: boolean) {
if (!value) return 'not set'
if (isPlaceholderEnvValue(value)) return 'placeholder'
return configured ? 'configured' : 'invalid format'
}
function smtpConfigurationIssues() {
const issues: string[] = []
if (!smtpHost) issues.push('MAIL_HOST is not set')
if (!smtpPort) issues.push('MAIL_PORT is not set or invalid')
if (!smtpUser) issues.push('MAIL_USERNAME is not set')
if (!smtpPass) {
issues.push('MAIL_PASSWORD is not set')
} else if (isPlaceholderEnvValue(smtpPass)) {
issues.push('MAIL_PASSWORD is a placeholder; set a Gmail app password')
}
if (!smtpFromAddress) issues.push('MAIL_FROM_ADDRESS is not configured')
if (!smtpTransport) issues.push('SMTP transport is not configured')
return issues
}
export function describeEmailProviderConfig() {
const rawResendKey = envValue('RESEND_API_KEY')
const rawMailPassword = envValue('MAIL_PASSWORD')
return {
selectedProvider: emailProvider,
resend: {
apiKey: envStatus(rawResendKey, Boolean(resendApiKey)),
sender: emailFromAddress && emailFromName ? 'configured' : 'not configured',
enabled: emailProvider !== 'smtp' && Boolean(resend),
},
smtp: {
host: smtpHost ? 'configured' : 'not set',
port: smtpPort ? 'configured' : 'not set',
username: smtpUser ? 'configured' : 'not set',
password: envStatus(rawMailPassword, Boolean(smtpPass && !isPlaceholderEnvValue(smtpPass))),
sender: smtpFromAddress ? 'configured' : 'not configured',
transport: smtpTransport ? 'configured' : 'not configured',
issues: smtpConfigurationIssues(),
},
}
}
async function sendEmailWithProviders(opts: {
@@ -244,9 +355,14 @@ async function sendEmailWithProviders(opts: {
subject: string
html: string
text: string
attachments?: EmailAttachment[]
}) {
const errors: string[] = []
if (emailProvider === 'smtp' && !smtpTransport) {
throw new Error(`SMTP email provider is selected but not configured: ${smtpConfigurationIssues().join('; ')}`)
}
if (resend) {
try {
if (!emailFromAddress || !emailFromName) {
@@ -259,6 +375,10 @@ async function sendEmailWithProviders(opts: {
subject: opts.subject,
html: opts.html,
text: opts.text,
attachments: opts.attachments?.map((attachment) => ({
filename: attachment.filename,
content: attachment.content.toString('base64'),
})),
})
if (error) {
@@ -288,6 +408,7 @@ async function sendEmailWithProviders(opts: {
html: opts.html,
text: opts.text,
replyTo: resolveSmtpReplyTo(),
attachments: opts.attachments,
})
return {
@@ -306,6 +427,94 @@ async function sendEmailWithProviders(opts: {
throw new Error('No email provider is configured')
}
type EmailAttachment = {
filename: string
content: Buffer
contentType?: string
}
function invoiceTaxRate(invoice: { taxRecords?: Array<{ taxRate?: number | null; taxExempt?: boolean }> }) {
return invoice.taxRecords?.find((record) => !record.taxExempt && typeof record.taxRate === 'number')?.taxRate ?? null
}
async function buildInvoicePdfAttachment(invoiceId: string): Promise<EmailAttachment | null> {
const invoice = await prisma.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
company: true,
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
},
})
if (!invoice?.invoiceNumber) return null
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt: any) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
const content = await generateInvoicePdf({
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(),
dueDate: invoice.dueAt?.toISOString() ?? null,
company: {
name: invoice.billingName ?? invoice.company.name,
email: invoice.billingEmail ?? invoice.company.email,
phone: invoice.company.phone,
address: invoice.billingAddress ?? invoice.company.address,
},
subscription: invoice.subscription
? {
plan: invoice.subscription.plan,
billingPeriod: invoice.subscription.billingPeriod,
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
currency: invoice.subscription.currency,
}
: undefined,
amount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider ?? 'MANUAL',
transactionId: latestPaymentAttempt?.providerPaymentId ?? latestPaymentAttempt?.externalReference ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description,
amount: item.amount,
currency: item.currency,
quantity: item.quantity,
unitAmount: item.unitAmount,
periodStart: item.periodStart?.toISOString() ?? null,
periodEnd: item.periodEnd?.toISOString() ?? null,
})),
totals: {
subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
amountDue: invoice.amountDue,
},
})
return {
filename: `${invoice.invoiceNumber}.pdf`,
content,
contentType: 'application/pdf',
}
}
async function resolveEmailAttachments(event: any): Promise<EmailAttachment[]> {
const attachments = Array.isArray(event.data?.emailAttachments) ? event.data.emailAttachments : []
const resolved: EmailAttachment[] = []
for (const attachment of attachments) {
if (attachment?.type === 'invoice_pdf' && typeof attachment.invoiceId === 'string') {
const invoicePdf = await buildInvoicePdfAttachment(attachment.invoiceId)
if (invoicePdf) resolved.push(invoicePdf)
}
}
return resolved
}
export async function sendNotification(opts: SendNotificationOptions) {
if (!opts.companyId) {
return uniqueChannels(opts.channels).map((channel) => ({
@@ -319,13 +528,17 @@ export async function sendNotification(opts: SendNotificationOptions) {
? { type: 'EMPLOYEE', employeeId: opts.employeeId }
: opts.renterId
? { type: 'RENTER', renterId: opts.renterId }
: null
: opts.billingContactId
? { type: 'BILLING_CONTACT', billingContactId: opts.billingContactId }
: opts.adminUserId
? { type: 'ADMIN', adminUserId: opts.adminUserId }
: null
if (!audience) {
return uniqueChannels(opts.channels).map((channel) => ({
channel,
success: false,
error: 'An explicit employee or renter recipient is required',
error: 'An explicit employee, renter, billing contact, or admin recipient is required',
}))
}
@@ -346,6 +559,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
body: opts.body,
data: opts.data,
locale: opts.locale,
policy: opts.policy,
})
return command.deliveries.map((delivery) => ({
@@ -369,7 +583,7 @@ export async function createNotification(opts: CreateNotificationOptions) {
})
const channels = uniqueChannels(opts.channels)
const renderChannel: NotificationChannel = channels.includes('IN_APP') ? 'IN_APP' : channels[0]!
const rendered = opts.templateKey
const rendered = opts.templateKey && (!opts.title || !opts.body)
? await resolveNotificationTemplate({
templateKey: opts.templateKey,
channel: renderChannel,
@@ -415,6 +629,8 @@ export async function createNotification(opts: CreateNotificationOptions) {
recipientType: recipient.recipientType,
employeeId: recipient.employeeId,
renterId: recipient.renterId,
billingContactId: recipient.billingContactId,
adminUserId: recipient.adminUserId,
},
})
@@ -423,6 +639,8 @@ export async function createNotification(opts: CreateNotificationOptions) {
companyId: opts.companyId,
employeeId: recipient.employeeId,
renterId: recipient.renterId,
billingContactId: recipient.billingContactId,
adminUserId: recipient.adminUserId,
type: opts.type,
channel,
policy: opts.policy,
@@ -472,6 +690,155 @@ export async function createNotification(opts: CreateNotificationOptions) {
}
}
const COLLECTIONS_REMINDER_TYPES = new Set<NotificationType>([
'SUBSCRIPTION_PAYMENT_DUE_14D',
'SUBSCRIPTION_PAYMENT_DUE_7D',
'SUBSCRIPTION_PAYMENT_DUE_48H',
'SUBSCRIPTION_PAYMENT_DUE_24H',
'SUBSCRIPTION_GRACE_DAILY',
'SUBSCRIPTION_GRACE_FINAL',
'COLLECTIONS_CALL_REQUIRED',
])
async function deliveryEmail(recipient: any) {
return recipient.employee?.email
?? recipient.renter?.email
?? recipient.billingContact?.email
?? recipient.adminUser?.email
?? null
}
export async function processNotificationOutbox(limit = 50) {
const entries = await prisma.notificationOutbox.findMany({
where: { status: 'PENDING' },
include: {
notificationEvent: {
include: {
recipients: {
include: {
employee: { select: { email: true } },
renter: { select: { email: true } },
billingContact: { select: { email: true, isActive: true, verifiedAt: true } },
adminUser: { select: { email: true, isActive: true } },
deliveries: true,
},
},
},
},
},
orderBy: { createdAt: 'asc' },
take: Math.max(1, Math.min(limit, 200)),
})
let processed = 0
for (const entry of entries as any[]) {
const event = entry.notificationEvent
if (event.sourceType === 'collections_case' && COLLECTIONS_REMINDER_TYPES.has(event.type)) {
const collectionsCase = await prisma.collectionsCase.findUnique({ where: { id: event.sourceId }, select: { status: true } })
if (!collectionsCase || ['RESOLVED', 'SUSPENDED'].includes(collectionsCase.status)) {
await prisma.notificationDelivery.updateMany({
where: { notificationRecipient: { notificationEventId: event.id }, status: { in: ['PENDING', 'QUEUED', 'FAILED'] } },
data: { status: 'SKIPPED', failureCode: 'COLLECTIONS_CASE_CLOSED', failureReason: 'Suppressed because the collections case is closed.' },
})
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
processed += 1
continue
}
}
for (const recipient of event.recipients) {
for (const delivery of recipient.deliveries) {
if (!['PENDING', 'QUEUED', 'FAILED'].includes(delivery.status)) continue
if (delivery.nextAttemptAt && delivery.nextAttemptAt > new Date()) continue
try {
if (delivery.channel === 'IN_APP') {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: { status: 'SENT', sentAt: new Date(), attemptCount: { increment: 1 }, lastAttemptAt: new Date() },
})
} else if (delivery.channel === 'EMAIL') {
const to = await deliveryEmail(recipient)
const externalContactInvalid = recipient.billingContact && (!recipient.billingContact.isActive || !recipient.billingContact.verifiedAt)
const adminInvalid = recipient.adminUser && !recipient.adminUser.isActive
if (!to || externalContactInvalid || adminInvalid) throw new Error('Recipient email is unavailable')
const result = await sendEmailWithProviders({
to,
subject: event.title,
html: renderLocalizedEmailHtml(event.body, coerceNotificationLocale(event.locale)),
text: event.body,
attachments: await resolveEmailAttachments(event),
})
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: {
status: 'SENT',
sentAt: new Date(),
provider: result.provider,
providerMessageId: result.providerMessageId,
attemptCount: { increment: 1 },
lastAttemptAt: new Date(),
failureCode: null,
failureReason: null,
},
})
} else {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: { status: 'SKIPPED', failureCode: 'UNSUPPORTED_CHANNEL', failureReason: 'Channel is not implemented.' },
})
}
} catch (error: any) {
const attempts = delivery.attemptCount + 1
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: {
status: attempts >= 5 ? 'DEAD_LETTER' : 'FAILED',
attemptCount: attempts,
lastAttemptAt: new Date(),
nextAttemptAt: attempts >= 5 ? null : new Date(Date.now() + Math.min(60, 2 ** attempts) * 60_000),
failureCode: 'DELIVERY_FAILED',
failureReason: String(error?.message ?? 'Delivery failed').slice(0, 500),
},
})
}
}
}
const remaining = await prisma.notificationDelivery.count({
where: {
notificationRecipient: { notificationEventId: event.id },
status: { in: ['PENDING', 'QUEUED', 'FAILED'] },
},
})
if (remaining === 0) {
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
processed += 1
}
}
return processed
}
export async function getAdminNotificationInbox(adminUserId: string, page = 1, pageSize = 50) {
const where = { adminUserId, archivedAt: null }
const [data, total, unread] = await Promise.all([
prisma.notificationRecipient.findMany({
where,
include: { notificationEvent: true, deliveries: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.notificationRecipient.count({ where }),
prisma.notificationRecipient.count({ where: { ...where, readAt: null } }),
])
return { data, total, unread, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) }
}
export async function markAdminNotificationRead(adminUserId: string, recipientId: string) {
const recipient = await prisma.notificationRecipient.findFirst({ where: { id: recipientId, adminUserId } })
if (!recipient) throw new Error('Admin notification not found')
return prisma.notificationRecipient.update({ where: { id: recipient.id }, data: { readAt: recipient.readAt ?? new Date() } })
}
export async function sendTransactionalEmail(opts: {
to: string
subject: string
@@ -0,0 +1,42 @@
import { spawn } from 'child_process'
export type PaymentEvidenceScanResult = {
status: 'CLEAN' | 'QUARANTINED' | 'SCAN_FAILED'
code: string
}
const SCAN_TIMEOUT_MS = Number(process.env.PAYMENT_EVIDENCE_SCAN_TIMEOUT_MS ?? 60_000)
export async function scanPaymentEvidenceFile(filePath: string): Promise<PaymentEvidenceScanResult> {
if (process.env.NODE_ENV !== 'production' && process.env.PAYMENT_EVIDENCE_SCANNER_MODE === 'stub-clean') {
return { status: 'CLEAN', code: 'DEVELOPMENT_STUB_CLEAN' }
}
const scannerPath = process.env.PAYMENT_EVIDENCE_SCANNER_PATH?.trim()
if (!scannerPath) return { status: 'SCAN_FAILED', code: 'SCANNER_NOT_CONFIGURED' }
return new Promise((resolve) => {
const child = spawn(scannerPath, ['--no-summary', filePath], {
shell: false,
stdio: ['ignore', 'ignore', 'ignore'],
})
let settled = false
const finish = (result: PaymentEvidenceScanResult) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(result)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish({ status: 'SCAN_FAILED', code: 'SCANNER_TIMEOUT' })
}, Number.isFinite(SCAN_TIMEOUT_MS) && SCAN_TIMEOUT_MS > 0 ? SCAN_TIMEOUT_MS : 60_000)
child.once('error', () => finish({ status: 'SCAN_FAILED', code: 'SCANNER_EXECUTION_FAILED' }))
child.once('exit', (code) => {
if (code === 0) finish({ status: 'CLEAN', code: 'SCANNER_CLEAN' })
else if (code === 1) finish({ status: 'QUARANTINED', code: 'MALWARE_DETECTED' })
else finish({ status: 'SCAN_FAILED', code: `SCANNER_EXIT_${code ?? 'UNKNOWN'}` })
})
})
}
+32
View File
@@ -59,6 +59,11 @@ import {
employeeLanguageSchema,
} from '../modules/auth/auth.employee.schemas'
import { renterUpdateSchema, renterFcmTokenSchema } from '../modules/auth/auth.renter.schemas'
import {
manualCheckoutSchema,
createManualPaymentSubmissionSchema,
communicationSettingsSchema,
} from '../modules/subscriptions/subscription.schemas'
// ─────────────────────────────────────────────────────────────────────────────
@@ -167,6 +172,10 @@ export const openApiDocument: JsonObject = {
EmployeeLanguage: s(employeeLanguageSchema),
RenterUpdate: s(renterUpdateSchema),
RenterFcmToken: s(renterFcmTokenSchema),
// ── Subscription billing ────────────────────────────────────
ManualSubscriptionCheckout: s(manualCheckoutSchema),
ManualPaymentSubmissionCreate: s(createManualPaymentSubmissionSchema),
SubscriptionCommunicationSettings: s(communicationSettingsSchema),
},
},
paths: {
@@ -982,6 +991,29 @@ export const openApiDocument: JsonObject = {
'/subscriptions/checkout': {
post: { tags: ['Subscriptions'], summary: 'Checkout new subscription (Owner)', responses: { '200': ok } },
},
'/subscriptions/payment-options': {
get: { tags: ['Subscriptions'], summary: 'Available subscription collection methods and safe payer instructions', responses: { '200': ok } },
},
'/subscriptions/manual-checkout': {
post: { tags: ['Subscriptions'], summary: 'Create or reuse a bank transfer/check invoice (Owner)', requestBody: jsonBody('#/components/schemas/ManualSubscriptionCheckout'), responses: { '200': ok, '400': err4 } },
},
'/subscriptions/invoices/{invoiceId}/manual-payment-submissions': {
post: { tags: ['Subscriptions'], summary: 'Create an evidence draft for an invoice (Owner)', parameters: [idPath('invoiceId')], requestBody: jsonBody('#/components/schemas/ManualPaymentSubmissionCreate'), responses: { '200': ok, '400': err4 } },
},
'/subscriptions/manual-payment-submissions/{submissionId}/documents': {
post: { tags: ['Subscriptions'], summary: 'Upload and scan one private evidence document (Owner)', parameters: [idPath('submissionId')], responses: { '201': ok, '400': err4 } },
},
'/subscriptions/manual-payment-submissions/{submissionId}/submit': {
post: { tags: ['Subscriptions'], summary: 'Lock and submit clean evidence for finance review (Owner)', parameters: [idPath('submissionId')], responses: { '200': ok, '400': err4 } },
},
'/subscriptions/manual-payment-submissions/{submissionId}/documents/{documentId}': {
get: { tags: ['Subscriptions'], summary: 'Download owned clean evidence (Owner)', parameters: [idPath('submissionId'), idPath('documentId')], responses: { '200': ok, '404': err404 } },
delete: { tags: ['Subscriptions'], summary: 'Delete evidence while the submission is a draft (Owner)', parameters: [idPath('submissionId'), idPath('documentId')], responses: { '200': ok, '404': err404 } },
},
'/subscriptions/communication-settings': {
get: { tags: ['Subscriptions'], summary: 'Billing contacts, timezone, and AR/EN/FR policy (Owner)', responses: { '200': ok } },
put: { tags: ['Subscriptions'], summary: 'Update billing communication settings (Owner)', requestBody: jsonBody('#/components/schemas/SubscriptionCommunicationSettings'), responses: { '200': ok, '400': err4 } },
},
'/subscriptions/change-plan': {
post: { tags: ['Subscriptions'], summary: 'Change plan (Owner)', responses: { '200': ok } },
},
+2
View File
@@ -7,6 +7,8 @@ const delegates = [
'billingCreditNote',
'billingCreditLedgerEntry',
'billingCreditBalance',
'manualPaymentDocument',
'manualPaymentSubmission',
'billingPaymentAttempt',
'billingPaymentIntent',
'billingInvoiceLineItem',