fix billing and 2fa admin
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 l’abonnement confirmé',
|
||||
confirmed: (details) => buildConfirmedPaymentBody(details),
|
||||
rejectedTitle: 'Justificatif de paiement à corriger',
|
||||
rejected: (invoice, reason) => `Le justificatif de la facture ${invoice} a été refusé : ${reason}. Téléversez un justificatif corrigé depuis Abonnement.`,
|
||||
},
|
||||
ar: {
|
||||
confirmedTitle: 'تم تأكيد دفع الاشتراك',
|
||||
confirmed: (details) => buildConfirmedPaymentBody(details),
|
||||
rejectedTitle: 'مستند الدفع يحتاج إلى تصحيح',
|
||||
rejected: (invoice, reason) => `تم رفض مستند الفاتورة ${invoice}: ${reason}. حمّل مستنداً مصححاً من صفحة الاشتراك.`,
|
||||
},
|
||||
}
|
||||
|
||||
type PaymentConfirmationDetails = {
|
||||
invoice: string
|
||||
amountPaid: number
|
||||
currency: string
|
||||
paymentType?: string | null
|
||||
paymentReference?: string | null
|
||||
receivedAt?: Date | string | null
|
||||
confirmedAt?: Date | string | null
|
||||
plan?: string | null
|
||||
billingPeriod?: string | null
|
||||
periodStart?: Date | string | null
|
||||
periodEnd?: Date | string | null
|
||||
}
|
||||
|
||||
function fmtMoney(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format((amount ?? 0) / 100)
|
||||
}
|
||||
|
||||
function fmtDate(value?: Date | string | null) {
|
||||
if (!value) return 'Not set'
|
||||
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function paymentMethodLabel(method?: string | null) {
|
||||
if (method === 'BANK_TRANSFER') return 'Bank transfer'
|
||||
if (method === 'CHECK') return 'Check'
|
||||
if (method === 'STRIPE') return 'Online card payment'
|
||||
return method ?? 'Manual payment'
|
||||
}
|
||||
|
||||
function buildConfirmedPaymentBody(details: PaymentConfirmationDetails) {
|
||||
return [
|
||||
'Dear customer,',
|
||||
'',
|
||||
'We confirm that your subscription payment has been verified and recorded. Your subscription is now active for the period shown below.',
|
||||
'',
|
||||
`Invoice: ${details.invoice}`,
|
||||
`Amount paid: ${fmtMoney(details.amountPaid, details.currency)}`,
|
||||
`Payment type: ${paymentMethodLabel(details.paymentType)}`,
|
||||
details.paymentReference ? `Payment reference: ${details.paymentReference}` : null,
|
||||
`Funds received/cleared on: ${fmtDate(details.receivedAt)}`,
|
||||
`Payment confirmed on: ${fmtDate(details.confirmedAt)}`,
|
||||
`Subscription plan: ${details.plan ?? 'Current plan'}`,
|
||||
`Billing period: ${details.billingPeriod ?? 'Current billing period'}`,
|
||||
`Subscription start: ${fmtDate(details.periodStart)}`,
|
||||
`Subscription end: ${fmtDate(details.periodEnd)}`,
|
||||
'',
|
||||
'A PDF copy of the invoice is attached for your records.',
|
||||
'',
|
||||
'Regards,',
|
||||
'RentalDriveGo Finance',
|
||||
].filter((line): line is string => line !== null).join('\n')
|
||||
}
|
||||
|
||||
async function notifyPaymentResult(data: {
|
||||
billingAccountId: string
|
||||
companyId: string
|
||||
invoiceId: string
|
||||
invoiceNumber?: string | null
|
||||
kind: 'confirmed' | 'rejected'
|
||||
sourceId: string
|
||||
reason?: string
|
||||
}) {
|
||||
const account = await prisma.billingAccount.findUnique({
|
||||
where: { id: data.billingAccountId },
|
||||
include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } },
|
||||
})
|
||||
if (!account) return
|
||||
const invoiceRecord = await prisma.billingInvoice.findUnique({
|
||||
where: { id: data.invoiceId },
|
||||
include: {
|
||||
subscription: true,
|
||||
lineItems: { orderBy: { createdAt: 'asc' } },
|
||||
paymentAttempts: { orderBy: { attemptedAt: 'desc' }, take: 1 },
|
||||
},
|
||||
})
|
||||
const paymentAttempt = invoiceRecord?.paymentAttempts?.[0] ?? null
|
||||
const periodStart = invoiceRecord?.subscription?.currentPeriodStart ?? invoiceRecord?.lineItems?.[0]?.periodStart ?? null
|
||||
const periodEnd = invoiceRecord?.subscription?.currentPeriodEnd ?? invoiceRecord?.lineItems?.[0]?.periodEnd ?? null
|
||||
for (const contact of account.billingContacts) {
|
||||
const enabled = account.enabledCommunicationLocales as string[]
|
||||
const locale = coerceNotificationLocale(
|
||||
contact.locale && enabled.includes(contact.locale)
|
||||
? contact.locale
|
||||
: contact.employee?.preferredLanguage && enabled.includes(contact.employee.preferredLanguage)
|
||||
? contact.employee.preferredLanguage
|
||||
: account.defaultCommunicationLocale,
|
||||
)
|
||||
const copy = customerPaymentCopy[locale]
|
||||
const invoice = data.invoiceNumber ?? data.invoiceId
|
||||
const details: PaymentConfirmationDetails = {
|
||||
invoice,
|
||||
amountPaid: invoiceRecord?.amountPaid ?? paymentAttempt?.amount ?? 0,
|
||||
currency: invoiceRecord?.currency ?? paymentAttempt?.currency ?? 'MAD',
|
||||
paymentType: paymentAttempt?.manualMethod ?? invoiceRecord?.collectionMethod ?? invoiceRecord?.paymentProvider ?? 'MANUAL',
|
||||
paymentReference: paymentAttempt?.externalReference ?? paymentAttempt?.providerPaymentId ?? null,
|
||||
receivedAt: paymentAttempt?.receivedAt ?? invoiceRecord?.paidAt ?? null,
|
||||
confirmedAt: paymentAttempt?.confirmedAt ?? invoiceRecord?.paidAt ?? null,
|
||||
plan: invoiceRecord?.requestedPlan ?? invoiceRecord?.subscription?.plan ?? null,
|
||||
billingPeriod: invoiceRecord?.requestedBillingPeriod ?? invoiceRecord?.subscription?.billingPeriod ?? null,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
}
|
||||
await sendNotification({
|
||||
type: data.kind === 'confirmed' ? 'SUBSCRIPTION_PAYMENT_CONFIRMED' : 'MANUAL_PAYMENT_EVIDENCE_REJECTED',
|
||||
title: data.kind === 'confirmed' ? copy.confirmedTitle : copy.rejectedTitle,
|
||||
body: data.kind === 'confirmed' ? copy.confirmed(details) : copy.rejected(invoice, data.reason ?? ''),
|
||||
companyId: data.companyId,
|
||||
employeeId: contact.employeeId ?? undefined,
|
||||
billingContactId: contact.employeeId ? undefined : contact.id,
|
||||
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
|
||||
locale,
|
||||
templateKey: data.kind === 'confirmed' ? 'subscription.payment_confirmed.v1' : 'subscription.payment_evidence_rejected.v1',
|
||||
idempotencyKey: `manual-payment:${data.kind}:${data.sourceId}:${contact.id}`,
|
||||
sourceType: 'manual_payment',
|
||||
sourceId: data.sourceId,
|
||||
data: {
|
||||
invoiceId: data.invoiceId,
|
||||
amountPaid: details.amountPaid,
|
||||
currency: details.currency,
|
||||
paymentType: details.paymentType,
|
||||
paymentReference: details.paymentReference,
|
||||
subscriptionStart: details.periodStart,
|
||||
subscriptionEnd: details.periodEnd,
|
||||
timezone: account.timezone,
|
||||
templateVersion: 1,
|
||||
localizationFallback: false,
|
||||
...(data.kind === 'confirmed' ? { emailAttachments: [{ type: 'invoice_pdf', invoiceId: data.invoiceId }] } : {}),
|
||||
},
|
||||
policy: { mandatory: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function requestHash(value: Record<string, unknown>) {
|
||||
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
}
|
||||
|
||||
export async function listManualPaymentSubmissions(query: { status: string; page: number; pageSize: number }) {
|
||||
const where = { status: query.status as any }
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.manualPaymentSubmission.findMany({
|
||||
where,
|
||||
include: {
|
||||
invoice: { include: { company: true, subscription: true } },
|
||||
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
|
||||
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
},
|
||||
orderBy: { submittedAt: 'asc' },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
prisma.manualPaymentSubmission.count({ where }),
|
||||
])
|
||||
return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.max(1, Math.ceil(total / query.pageSize)) }
|
||||
}
|
||||
|
||||
export async function getManualPaymentSubmission(submissionId: string, adminId?: string) {
|
||||
let submission = await prisma.manualPaymentSubmission.findUnique({
|
||||
where: { id: submissionId },
|
||||
include: {
|
||||
invoice: {
|
||||
include: {
|
||||
company: { select: { id: true, name: true, email: true } },
|
||||
subscription: true,
|
||||
billingAccount: true,
|
||||
lineItems: true,
|
||||
},
|
||||
},
|
||||
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
|
||||
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
reviewedByAdmin: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
})
|
||||
if (!submission) throw new NotFoundError('Payment submission not found')
|
||||
if (adminId && submission.status === 'SUBMITTED') {
|
||||
await prisma.manualPaymentSubmission.updateMany({
|
||||
where: { id: submission.id, status: 'SUBMITTED' },
|
||||
data: { status: 'UNDER_REVIEW', reviewedByAdminId: adminId, reviewedAt: new Date() },
|
||||
})
|
||||
return getManualPaymentSubmission(submissionId)
|
||||
}
|
||||
return submission
|
||||
}
|
||||
|
||||
export async function getAdminPaymentDocument(submissionId: string, documentId: string, adminId: string, ip?: string) {
|
||||
const document = await prisma.manualPaymentDocument.findFirst({
|
||||
where: { id: documentId, submissionId, deletedAt: null, scanStatus: 'CLEAN' },
|
||||
include: { submission: { include: { invoice: true } } },
|
||||
})
|
||||
if (!document || document.submission.invoiceId !== document.invoiceId) throw new NotFoundError('Clean payment evidence document not found')
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
adminUserId: adminId,
|
||||
action: 'VIEW_MANUAL_PAYMENT_EVIDENCE',
|
||||
resource: 'ManualPaymentDocument',
|
||||
resourceId: document.id,
|
||||
companyId: document.companyId,
|
||||
ipAddress: ip,
|
||||
after: { submissionId, invoiceId: document.invoiceId, sha256: document.sha256 },
|
||||
},
|
||||
})
|
||||
return { document, bytes: readPrivateDocument(document.storageKey) }
|
||||
}
|
||||
|
||||
export async function rejectManualPaymentSubmission(submissionId: string, reason: string, adminId: string, ip?: string) {
|
||||
const updated = await prisma.$transaction(async (tx: any) => {
|
||||
const current = await tx.manualPaymentSubmission.findUnique({
|
||||
where: { id: submissionId },
|
||||
include: { invoice: true },
|
||||
})
|
||||
if (!current) throw new NotFoundError('Payment submission not found')
|
||||
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(current.status)) throw new ConflictError('Submission cannot be rejected in its current state')
|
||||
const reviewedAt = new Date()
|
||||
const updated = await tx.manualPaymentSubmission.update({
|
||||
where: { id: current.id },
|
||||
data: { status: 'REJECTED', rejectionReason: reason, reviewedByAdminId: adminId, reviewedAt },
|
||||
include: { documents: { where: { deletedAt: null } } },
|
||||
})
|
||||
await tx.billingEvent.create({
|
||||
data: {
|
||||
billingAccountId: current.billingAccountId,
|
||||
invoiceId: current.invoiceId,
|
||||
subscriptionId: current.invoice.subscriptionId,
|
||||
companyId: current.companyId,
|
||||
eventType: 'payment_evidence.rejected',
|
||||
source: 'admin',
|
||||
payload: { submissionId, reason, adminId },
|
||||
occurredAt: reviewedAt,
|
||||
},
|
||||
})
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
adminUserId: adminId,
|
||||
action: 'REJECT_MANUAL_PAYMENT_EVIDENCE',
|
||||
resource: 'ManualPaymentSubmission',
|
||||
resourceId: submissionId,
|
||||
companyId: current.companyId,
|
||||
before: { status: current.status },
|
||||
after: { status: 'REJECTED', reason },
|
||||
ipAddress: ip,
|
||||
},
|
||||
})
|
||||
return { ...updated, invoice: current.invoice }
|
||||
})
|
||||
await notifyPaymentResult({
|
||||
billingAccountId: updated.billingAccountId,
|
||||
companyId: updated.companyId,
|
||||
invoiceId: updated.invoiceId,
|
||||
invoiceNumber: updated.invoice.invoiceNumber,
|
||||
kind: 'rejected',
|
||||
sourceId: updated.id,
|
||||
reason,
|
||||
})
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function confirmManualPayment(invoiceId: string, data: {
|
||||
submissionId: string
|
||||
method: 'BANK_TRANSFER' | 'CHECK'
|
||||
externalReference: string
|
||||
amount: number
|
||||
receivedAt: string
|
||||
note?: string
|
||||
correctionReason?: string
|
||||
idempotencyKey: string
|
||||
fundsVerified: true
|
||||
}, adminId: string, ip?: string) {
|
||||
const normalizedReference = normalizeExternalReference(data.externalReference)
|
||||
const normalizedPayload = {
|
||||
invoiceId,
|
||||
submissionId: data.submissionId,
|
||||
method: data.method,
|
||||
normalizedReference,
|
||||
amount: data.amount,
|
||||
receivedAt: new Date(data.receivedAt).toISOString(),
|
||||
note: data.note ?? null,
|
||||
correctionReason: data.correctionReason ?? null,
|
||||
fundsVerified: true,
|
||||
}
|
||||
const hash = requestHash(normalizedPayload)
|
||||
const receivedAt = new Date(data.receivedAt)
|
||||
if (receivedAt.getTime() > Date.now() + 5 * 60 * 1000) throw new ValidationError('Settlement time cannot be in the future')
|
||||
|
||||
let collectionsCaseId: string | null = null
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx: any) => {
|
||||
const current = await tx.billingInvoice.findUnique({
|
||||
where: { id: invoiceId },
|
||||
include: {
|
||||
billingAccount: true,
|
||||
subscription: true,
|
||||
legacySubscriptionInvoice: true,
|
||||
collectionsCase: true,
|
||||
manualPaymentSubmissions: {
|
||||
where: { id: data.submissionId },
|
||||
include: { documents: { where: { deletedAt: null } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!current) throw new NotFoundError('Invoice not found')
|
||||
|
||||
const duplicate = await tx.billingPaymentAttempt.findFirst({
|
||||
where: { billingAccountId: current.billingAccountId, idempotencyKey: data.idempotencyKey },
|
||||
include: { invoice: true },
|
||||
})
|
||||
if (duplicate) {
|
||||
const metadata = duplicate.metadata as any
|
||||
if (duplicate.invoiceId !== invoiceId || metadata?.confirmationRequestHash !== hash) {
|
||||
throw new ConflictError('Idempotency key was already used with a different payment confirmation')
|
||||
}
|
||||
return { invoice: duplicate.invoice, paymentAttempt: duplicate, duplicate: true }
|
||||
}
|
||||
|
||||
if (!PAYABLE_STATUSES.includes(current.status)) throw new ConflictError('Invoice is not payable in its current state')
|
||||
if (current.collectionMethod !== data.method) throw new ValidationError('Payment method must match the invoice collection method')
|
||||
if (current.currency !== 'MAD') throw new ValidationError('Manual subscription confirmation requires MAD currency')
|
||||
if (data.amount !== current.amountDue || data.amount !== current.totalAmount - current.amountPaid) {
|
||||
throw new ConflictError('The full current invoice balance must be confirmed')
|
||||
}
|
||||
const submission = current.manualPaymentSubmissions[0]
|
||||
if (!submission || submission.invoiceId !== current.id || submission.companyId !== current.companyId) {
|
||||
throw new ValidationError('Submission does not belong to this invoice')
|
||||
}
|
||||
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(submission.status)) throw new ConflictError('Evidence is not awaiting review')
|
||||
if (submission.method !== data.method) throw new ValidationError('Submission method does not match the confirmation method')
|
||||
if (!submission.documents.length || submission.documents.some((document: any) => document.scanStatus !== 'CLEAN')) {
|
||||
throw new ValidationError('Every attached evidence document must be clean')
|
||||
}
|
||||
if (submission.normalizedSubmittedReference !== normalizedReference && !data.correctionReason) {
|
||||
throw new ValidationError('A correction reason is required when the confirmed reference differs from the submitted reference')
|
||||
}
|
||||
|
||||
const confirmedAt = new Date()
|
||||
const intent = await tx.billingPaymentIntent.create({
|
||||
data: {
|
||||
invoiceId: current.id,
|
||||
billingAccountId: current.billingAccountId,
|
||||
status: 'SUCCEEDED',
|
||||
amount: data.amount,
|
||||
currency: current.currency,
|
||||
metadata: { source: 'admin_manual_subscription_confirmation', method: data.method },
|
||||
},
|
||||
})
|
||||
const attempt = await tx.billingPaymentAttempt.create({
|
||||
data: {
|
||||
invoiceId: current.id,
|
||||
billingAccountId: current.billingAccountId,
|
||||
paymentIntentId: intent.id,
|
||||
channel: 'OFFLINE',
|
||||
manualMethod: data.method,
|
||||
externalReference: data.externalReference,
|
||||
normalizedExternalReference: normalizedReference,
|
||||
receivedAt,
|
||||
confirmedAt,
|
||||
confirmedByAdminId: adminId,
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
note: data.note ?? null,
|
||||
status: 'SUCCEEDED',
|
||||
amount: data.amount,
|
||||
currency: current.currency,
|
||||
attemptedAt: confirmedAt,
|
||||
metadata: {
|
||||
source: 'admin_manual_subscription_confirmation',
|
||||
submissionId: submission.id,
|
||||
confirmationRequestHash: hash,
|
||||
correctionReason: data.correctionReason ?? null,
|
||||
fundsVerified: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const paid = await tx.billingInvoice.updateMany({
|
||||
where: { id: current.id, status: { in: PAYABLE_STATUSES }, amountDue: data.amount },
|
||||
data: { status: 'PAID', amountPaid: { increment: data.amount }, amountDue: 0, paidAt: confirmedAt },
|
||||
})
|
||||
if (paid.count !== 1) throw new ConflictError('Invoice changed while payment was being confirmed')
|
||||
|
||||
await tx.manualPaymentSubmission.update({
|
||||
where: { id: submission.id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
reviewedByAdminId: adminId,
|
||||
reviewedAt: confirmedAt,
|
||||
paymentAttemptId: attempt.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (current.legacySubscriptionInvoice) {
|
||||
await tx.subscriptionInvoice.update({
|
||||
where: { id: current.legacySubscriptionInvoice.id },
|
||||
data: { status: 'PAID', paidAt: confirmedAt, failedAt: null },
|
||||
})
|
||||
}
|
||||
|
||||
if (!current.subscription) throw new ValidationError('Subscription invoice is missing its subscription')
|
||||
const period = (current.requestedBillingPeriod ?? current.subscription.billingPeriod) as 'MONTHLY' | 'ANNUAL'
|
||||
const isRenewal = current.invoiceType === 'SUBSCRIPTION_RENEWAL'
|
||||
const periodStart = isRenewal
|
||||
? (current.collectionsCase?.originalExpirationAt ?? current.subscription.currentPeriodEnd ?? confirmedAt)
|
||||
: confirmedAt
|
||||
await tx.subscription.update({
|
||||
where: { id: current.subscription.id },
|
||||
data: {
|
||||
plan: current.requestedPlan ?? current.subscription.plan,
|
||||
billingPeriod: period,
|
||||
currency: current.currency,
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: periodStart,
|
||||
currentPeriodEnd: addBillingPeriod(periodStart, period),
|
||||
paymentPendingSince: null,
|
||||
paymentDueAt: null,
|
||||
pastDueSince: null,
|
||||
suspendedAt: null,
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
|
||||
if (current.collectionsCase) {
|
||||
collectionsCaseId = current.collectionsCase.id
|
||||
await tx.collectionsCase.update({
|
||||
where: { id: current.collectionsCase.id },
|
||||
data: {
|
||||
status: 'RESOLVED',
|
||||
resolvedAt: confirmedAt,
|
||||
nextActionAt: null,
|
||||
resolutionPaymentAttemptId: attempt.id,
|
||||
},
|
||||
})
|
||||
await tx.collectionsCallTask.updateMany({
|
||||
where: { collectionsCaseId: current.collectionsCase.id, status: 'OPEN' },
|
||||
data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' },
|
||||
})
|
||||
await tx.collectionsEvent.create({
|
||||
data: {
|
||||
collectionsCaseId: current.collectionsCase.id,
|
||||
companyId: current.companyId,
|
||||
eventType: 'collections.resolved',
|
||||
idempotencyKey: `payment:${attempt.id}`,
|
||||
actorType: 'admin',
|
||||
actorId: adminId,
|
||||
payload: { paymentAttemptId: attempt.id },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await tx.billingEvent.create({
|
||||
data: {
|
||||
billingAccountId: current.billingAccountId,
|
||||
invoiceId: current.id,
|
||||
subscriptionId: current.subscription.id,
|
||||
companyId: current.companyId,
|
||||
eventType: 'invoice.paid',
|
||||
source: 'admin',
|
||||
payload: { paymentAttemptId: attempt.id, method: data.method, submissionId: submission.id },
|
||||
occurredAt: confirmedAt,
|
||||
},
|
||||
})
|
||||
await tx.subscriptionEvent.create({
|
||||
data: {
|
||||
subscriptionId: current.subscription.id,
|
||||
companyId: current.companyId,
|
||||
eventType: 'subscription.activated',
|
||||
source: 'admin',
|
||||
payload: { invoiceId: current.id, paymentAttemptId: attempt.id },
|
||||
occurredAt: confirmedAt,
|
||||
},
|
||||
})
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
adminUserId: adminId,
|
||||
action: 'CONFIRM_MANUAL_SUBSCRIPTION_PAYMENT',
|
||||
resource: 'BillingInvoice',
|
||||
resourceId: current.id,
|
||||
companyId: current.companyId,
|
||||
before: { status: current.status, amountDue: current.amountDue },
|
||||
after: { status: 'PAID', amountDue: 0, paymentAttemptId: attempt.id, method: data.method },
|
||||
note: data.note,
|
||||
ipAddress: ip,
|
||||
},
|
||||
})
|
||||
|
||||
const invoice = await tx.billingInvoice.findUniqueOrThrow({
|
||||
where: { id: current.id },
|
||||
include: { paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, manualPaymentSubmissions: { include: { documents: true } } },
|
||||
})
|
||||
return { invoice, paymentAttempt: attempt, duplicate: false }
|
||||
}, { isolationLevel: 'Serializable' as any })
|
||||
|
||||
if (collectionsCaseId) {
|
||||
await prisma.notificationOutbox.updateMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
notificationEvent: { sourceType: 'collections_case', sourceId: collectionsCaseId },
|
||||
},
|
||||
data: { status: 'PUBLISHED', failureReason: 'Suppressed because payment was confirmed' },
|
||||
})
|
||||
}
|
||||
await notifyPaymentResult({
|
||||
billingAccountId: result.invoice.billingAccountId,
|
||||
companyId: result.invoice.companyId,
|
||||
invoiceId: result.invoice.id,
|
||||
invoiceNumber: result.invoice.invoiceNumber,
|
||||
kind: 'confirmed',
|
||||
sourceId: result.paymentAttempt.id,
|
||||
})
|
||||
return result
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'P2002' || error?.code === 'P2034') {
|
||||
throw new ConflictError('The payment reference, idempotency key, or invoice was confirmed concurrently')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,7 @@ export function createAdmin(data: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
preferredLocale: string
|
||||
passwordHash: string
|
||||
permissions?: any[]
|
||||
}) {
|
||||
@@ -354,6 +355,7 @@ export function createAdmin(data: {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
role: data.role as any,
|
||||
preferredLocale: data.preferredLocale,
|
||||
passwordHash: data.passwordHash,
|
||||
permissions: data.permissions ? { create: data.permissions } : undefined,
|
||||
},
|
||||
@@ -372,6 +374,7 @@ export function updateAdmin(
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
role?: string
|
||||
preferredLocale?: string
|
||||
passwordHash?: string
|
||||
isActive?: boolean
|
||||
},
|
||||
@@ -383,6 +386,7 @@ export function updateAdmin(
|
||||
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
|
||||
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
|
||||
...(data.role !== undefined ? { role: data.role as any } : {}),
|
||||
...(data.preferredLocale !== undefined ? { preferredLocale: data.preferredLocale } : {}),
|
||||
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
|
||||
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,9 @@ import { setSessionCookie, clearSessionCookie } from '../../security/sessionCook
|
||||
import * as service from './admin.service'
|
||||
import * as subService from '../subscriptions/subscription.service'
|
||||
import * as menuService from '../menu/menu.service'
|
||||
import * as manualPaymentsService from './admin.manual-payments.service'
|
||||
import * as collectionsService from '../subscriptions/subscription.collections.service'
|
||||
import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService'
|
||||
import { presentAdminUser } from './admin.presenter'
|
||||
import {
|
||||
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
|
||||
@@ -17,9 +20,14 @@ import {
|
||||
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
|
||||
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
|
||||
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
|
||||
platformBillingSettingsSchema,
|
||||
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
|
||||
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
|
||||
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
|
||||
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
|
||||
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
|
||||
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
|
||||
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
|
||||
} from './admin.schemas'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -279,6 +287,17 @@ router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), asyn
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/notifications/me', requireAdminAuth, async (req, res, next) => {
|
||||
try { ok(res, await getAdminNotificationInbox(req.admin.id)) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/notifications/me/:id/read', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, await markAdminNotificationRead(req.admin.id, id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Audit logs ────────────────────────────────────────────────
|
||||
|
||||
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||
@@ -344,6 +363,98 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/manual-payment-submissions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try { ok(res, await manualPaymentsService.listManualPaymentSubmissions(parseQuery(manualPaymentSubmissionsQuerySchema, req))) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/manual-payment-submissions/:submissionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
|
||||
ok(res, await manualPaymentsService.getManualPaymentSubmission(submissionId, req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/manual-payment-submissions/:submissionId/documents/:documentId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { submissionId, documentId } = parseParams(manualPaymentDocumentParamsSchema, req)
|
||||
const { document, bytes } = await manualPaymentsService.getAdminPaymentDocument(submissionId, documentId, req.admin.id, req.ip)
|
||||
const safeName = document.originalFilename.replace(/["\\\r\n]/g, '_')
|
||||
res.setHeader('Content-Type', document.detectedMimeType)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`)
|
||||
res.setHeader('Content-Length', bytes.length)
|
||||
res.setHeader('Cache-Control', 'private, no-store')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.end(bytes)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/manual-payment-submissions/:submissionId/reject', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
|
||||
const { reason } = parseBody(rejectManualPaymentSubmissionSchema, req)
|
||||
ok(res, await manualPaymentsService.rejectManualPaymentSubmission(submissionId, reason, req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/invoices/:invoiceId/manual-payments', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||
try {
|
||||
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
|
||||
ok(res, await manualPaymentsService.confirmManualPayment(invoiceId, parseBody(confirmManualPaymentSchema, req), req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/collections', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try { ok(res, await collectionsService.listCollectionsCases(parseQuery(collectionsQuerySchema, req))) } catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/collections/:caseId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
|
||||
ok(res, await collectionsService.getCollectionsCase(caseId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/billing/collections/:caseId/assignee', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
|
||||
const { adminId } = parseBody(collectionsAssigneeSchema, req)
|
||||
ok(res, await collectionsService.assignCollectionsCase(caseId, adminId, req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/collection-tasks/:taskId/outcomes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { taskId } = parseParams(collectionTaskIdParamSchema, req)
|
||||
ok(res, await collectionsService.recordCallOutcome(taskId, parseBody(collectionTaskOutcomeSchema, req), req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/collections/:caseId/overrides', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||
try {
|
||||
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
|
||||
ok(res, await collectionsService.createCollectionsOverride(caseId, parseBody(collectionsOverrideSchema, req), req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/billing/collections/:caseId/overrides/:overrideId/revoke', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||
try {
|
||||
const { caseId, overrideId } = parseParams(collectionsOverrideParamsSchema, req)
|
||||
ok(res, await collectionsService.revokeCollectionsOverride(caseId, overrideId, req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getBillingPlatformSettings())
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.updateBillingPlatformSettings(parseBody(platformBillingSettingsSchema, req), req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { companyId } = parseParams(companyIdParamSchema, req)
|
||||
|
||||
@@ -87,6 +87,7 @@ export const createAdminSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim(),
|
||||
lastName: z.string().min(1).max(100).trim(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
preferredLocale: z.enum(['ar', 'en', 'fr']).default('en'),
|
||||
password: z.string().min(8),
|
||||
permissions: z.array(permissionSchema).optional(),
|
||||
})
|
||||
@@ -96,6 +97,7 @@ export const updateAdminSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
|
||||
preferredLocale: z.enum(['ar', 'en', 'fr']).optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
@@ -173,7 +175,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
|
||||
terms: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
lateFeePerHour: z.number().int().nullable().optional(), taxRate: z.number().nullable().optional(),
|
||||
lateFeePerHour: z.number().int().nullable().optional(),
|
||||
signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
|
||||
}).optional(),
|
||||
accountingSettings: z.object({
|
||||
@@ -243,6 +245,10 @@ export const billingAccountUpdateSchema = z.object({
|
||||
netTermsDays: z.number().int().min(0).max(365).optional(),
|
||||
})
|
||||
|
||||
export const platformBillingSettingsSchema = z.object({
|
||||
taxRate: z.number().min(0).max(100),
|
||||
})
|
||||
|
||||
export const billingLineItemInputSchema = z.object({
|
||||
type: z.enum([
|
||||
'SUBSCRIPTION_FEE',
|
||||
@@ -292,6 +298,80 @@ export const payBillingInvoiceSchema = z.object({
|
||||
providerPaymentId: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
|
||||
const manualPaymentReferenceSchema = z.string()
|
||||
.trim()
|
||||
.min(3)
|
||||
.max(120)
|
||||
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters')
|
||||
|
||||
export const manualPaymentSubmissionIdParamSchema = z.object({ submissionId: z.string().min(1) })
|
||||
export const manualPaymentDocumentParamsSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
documentId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const manualPaymentSubmissionsQuerySchema = z.object({
|
||||
status: z.enum(['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED']).default('SUBMITTED'),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
export const rejectManualPaymentSubmissionSchema = z.object({
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
})
|
||||
|
||||
export const confirmManualPaymentSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
method: z.enum(['BANK_TRANSFER', 'CHECK']),
|
||||
externalReference: manualPaymentReferenceSchema,
|
||||
amount: z.number().int().positive(),
|
||||
receivedAt: z.string().datetime(),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
correctionReason: z.string().trim().min(3).max(500).optional(),
|
||||
idempotencyKey: z.string().uuid(),
|
||||
fundsVerified: z.literal(true),
|
||||
})
|
||||
|
||||
export const collectionsQuerySchema = z.object({
|
||||
status: z.enum(['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED']).optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
actionDueBefore: z.string().datetime().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
export const collectionsCaseIdParamSchema = z.object({ caseId: z.string().min(1) })
|
||||
export const collectionTaskIdParamSchema = z.object({ taskId: z.string().min(1) })
|
||||
export const collectionsOverrideParamsSchema = z.object({ caseId: z.string().min(1), overrideId: z.string().min(1) })
|
||||
|
||||
export const collectionsAssigneeSchema = z.object({ adminId: z.string().min(1) })
|
||||
export const collectionTaskOutcomeSchema = z.object({
|
||||
outcome: z.enum(['CONTACTED', 'NO_ANSWER', 'PAYMENT_PROMISED', 'ISSUE_ESCALATED']),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
promisedPaymentAt: z.string().datetime().optional(),
|
||||
nextFollowUpAt: z.string().datetime().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (['NO_ANSWER', 'ISSUE_ESCALATED'].includes(data.outcome) && !data.note) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['note'], message: 'A note is required for this outcome' })
|
||||
}
|
||||
if (data.outcome === 'PAYMENT_PROMISED' && (!data.promisedPaymentAt || !data.nextFollowUpAt)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['promisedPaymentAt'], message: 'Promised payment and follow-up times are required' })
|
||||
}
|
||||
})
|
||||
|
||||
export const collectionsOverrideSchema = z.object({
|
||||
type: z.enum(['PAYMENT_DISPUTE', 'MANUAL_EXTENSION']),
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
expiresAt: z.string().datetime(),
|
||||
revisedSuspensionAt: z.string().datetime().optional(),
|
||||
pauseSuspension: z.boolean().default(true),
|
||||
pauseNotifications: z.boolean().default(false),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.type === 'MANUAL_EXTENSION' && !data.revisedSuspensionAt) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['revisedSuspensionAt'], message: 'A manual extension requires a revised suspension time' })
|
||||
}
|
||||
})
|
||||
|
||||
export const retryBillingInvoiceSchema = z.object({
|
||||
paymentMethodId: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
|
||||
@@ -3,8 +3,10 @@ import bcrypt from 'bcryptjs'
|
||||
|
||||
vi.mock('./admin.repo', () => ({
|
||||
findAdminByEmail: vi.fn(),
|
||||
findAdminByIdOrThrow: vi.fn(),
|
||||
setAdminPasswordReset: vi.fn(),
|
||||
updateAdminLastLogin: vi.fn(),
|
||||
updateAdminTotpSecret: vi.fn(),
|
||||
createAuditLog: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -12,6 +14,10 @@ vi.mock('../../services/notificationService', () => ({
|
||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,test') },
|
||||
}))
|
||||
|
||||
const redisStore = new Map<string, string>()
|
||||
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
@@ -33,7 +39,7 @@ vi.mock('../../lib/redis', () => ({
|
||||
|
||||
import * as repo from './admin.repo'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { forgotPassword, login } from './admin.service'
|
||||
import { forgotPassword, login, setupTotp } from './admin.service'
|
||||
|
||||
describe('admin.service forgotPassword', () => {
|
||||
const originalAdminUrl = process.env.ADMIN_URL
|
||||
@@ -123,4 +129,20 @@ describe('admin.service forgotPassword', () => {
|
||||
}))
|
||||
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3')
|
||||
})
|
||||
|
||||
it('reuses a pending TOTP setup secret so duplicate dev setup calls keep codes valid', async () => {
|
||||
vi.mocked(repo.findAdminByIdOrThrow).mockResolvedValue({
|
||||
id: 'admin_4',
|
||||
email: 'admin4@example.test',
|
||||
totpEnabled: false,
|
||||
totpSecret: 'JBSWY3DPEHPK3PXP',
|
||||
} as any)
|
||||
|
||||
const first = await setupTotp('admin_4', 'admin4@example.test')
|
||||
const second = await setupTotp('admin_4', 'admin4@example.test')
|
||||
|
||||
expect(first.secret).toBe('JBSWY3DPEHPK3PXP')
|
||||
expect(second.secret).toBe('JBSWY3DPEHPK3PXP')
|
||||
expect(repo.updateAdminTotpSecret).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -171,8 +171,13 @@ export async function login(email: string, password: string, totpCode?: string,
|
||||
}
|
||||
|
||||
export async function setupTotp(adminId: string, email: string) {
|
||||
const secret = authenticator.generateSecret()
|
||||
await repo.updateAdminTotpSecret(adminId, secret)
|
||||
const admin = await repo.findAdminByIdOrThrow(adminId)
|
||||
const secret = admin.totpSecret && !admin.totpEnabled
|
||||
? admin.totpSecret
|
||||
: authenticator.generateSecret()
|
||||
if (secret !== admin.totpSecret) {
|
||||
await repo.updateAdminTotpSecret(adminId, secret)
|
||||
}
|
||||
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
|
||||
const qrCode = await qrcode.toDataURL(otpauth)
|
||||
return { secret, qrCode }
|
||||
@@ -335,7 +340,7 @@ export async function listAdmins() {
|
||||
return admins.map((admin: any) => presenter.presentAdminUser(admin))
|
||||
}
|
||||
|
||||
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
|
||||
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; preferredLocale: string; password: string; permissions?: any[] }) {
|
||||
const admin = await repo.createAdmin({
|
||||
...body,
|
||||
passwordHash: await bcrypt.hash(body.password, 12),
|
||||
@@ -350,6 +355,7 @@ export async function updateAdmin(
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
role?: string
|
||||
preferredLocale?: string
|
||||
password?: string
|
||||
isActive?: boolean
|
||||
},
|
||||
@@ -359,6 +365,7 @@ export async function updateAdmin(
|
||||
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
|
||||
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
|
||||
...(body.role !== undefined ? { role: body.role } : {}),
|
||||
...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}),
|
||||
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
|
||||
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
|
||||
})
|
||||
@@ -406,6 +413,18 @@ export function updateBillingAccount(
|
||||
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
|
||||
}
|
||||
|
||||
export function getBillingPlatformSettings() {
|
||||
return billingService.getBillingPlatformSettings()
|
||||
}
|
||||
|
||||
export function updateBillingPlatformSettings(
|
||||
data: Parameters<typeof billingService.updateBillingPlatformSettings>[0],
|
||||
adminId: string,
|
||||
ip?: string,
|
||||
) {
|
||||
return billingService.updateBillingPlatformSettings(data, adminId, ip)
|
||||
}
|
||||
|
||||
export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
|
||||
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
|
||||
}
|
||||
|
||||
@@ -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 l’abonnement dû dans 14 jours', DUE_7D: 'Paiement de l’abonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de l’abonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' },
|
||||
due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période d’abonnement 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 n’est 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}, d’un montant de ${amount}. L’abonnement 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 l’accè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 l’abonnement mis à jour', created: (invoice, until) => `Une dérogation temporaire de recouvrement a été appliquée à la facture ${invoice} jusqu’au ${until}.`, revoked: (invoice) => `La dérogation temporaire concernant la facture ${invoice} a été révoquée. Le calendrier standard s’applique à 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'}` })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 } },
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ const delegates = [
|
||||
'billingCreditNote',
|
||||
'billingCreditLedgerEntry',
|
||||
'billingCreditBalance',
|
||||
'manualPaymentDocument',
|
||||
'manualPaymentSubmission',
|
||||
'billingPaymentAttempt',
|
||||
'billingPaymentIntent',
|
||||
'billingInvoiceLineItem',
|
||||
|
||||
Reference in New Issue
Block a user