implement driver license date validation
This commit is contained in:
@@ -12,19 +12,29 @@ vi.mock('../lib/prisma', () => ({
|
||||
}))
|
||||
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { validateAndFlagLicense, validateLicense } from './licenseValidationService'
|
||||
import {
|
||||
validateAndFlagLicense,
|
||||
validateLicense,
|
||||
validateLicenseDate,
|
||||
parseExpirationDate,
|
||||
ErrorCode,
|
||||
} from './licenseValidationService'
|
||||
|
||||
const NOW = new Date('2026-06-09T12:00:00.000Z')
|
||||
|
||||
describe('licenseValidationService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// ─── validateLicense (legacy) ────────────────────────────────────────
|
||||
|
||||
it('treats missing license expiry as valid but explicitly records that no date exists', () => {
|
||||
expect(validateLicense(null)).toEqual({
|
||||
status: 'VALID',
|
||||
@@ -34,13 +44,22 @@ describe('licenseValidationService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requires approval for licenses expiring inside the three-month risk window', () => {
|
||||
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z'))
|
||||
it('requires approval for licenses expiring inside the 30-day risk window', () => {
|
||||
const result = validateLicense(new Date('2026-07-04T12:00:00.000Z'))
|
||||
|
||||
expect(result.status).toBe('EXPIRING')
|
||||
expect(result.daysUntilExpiry).toBe(30)
|
||||
expect(result.daysUntilExpiry).toBe(25)
|
||||
expect(result.requiresApproval).toBe(true)
|
||||
expect(result.message).toContain('License expires in 30 day(s)')
|
||||
expect(result.message).toContain('License expires in 25 day(s)')
|
||||
})
|
||||
|
||||
it('marks exactly 30 days as valid (not expiring)', () => {
|
||||
// 2026-07-09 is exactly 30 days from 2026-06-09
|
||||
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z'))
|
||||
|
||||
expect(result.status).toBe('VALID')
|
||||
expect(result.daysUntilExpiry).toBe(30)
|
||||
expect(result.requiresApproval).toBe(false)
|
||||
})
|
||||
|
||||
it('requires approval for already expired licenses and reports negative days until expiry', () => {
|
||||
@@ -73,4 +92,108 @@ describe('licenseValidationService', () => {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// ─── validateLicenseDate (new public API) ────────────────────────────
|
||||
|
||||
it('rejects missing date', () => {
|
||||
const result = validateLicenseDate(null)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_MISSING_DATE)
|
||||
})
|
||||
|
||||
it('rejects undefined date', () => {
|
||||
const result = validateLicenseDate(undefined)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_MISSING_DATE)
|
||||
})
|
||||
|
||||
it('rejects already expired license', () => {
|
||||
const result = validateLicenseDate(new Date('2026-06-04T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_EXPIRED)
|
||||
expect(result.days_remaining).toBe(-5)
|
||||
})
|
||||
|
||||
it('rejects license expiring in less than 30 days', () => {
|
||||
const result = validateLicenseDate(new Date('2026-06-20T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_EXPIRING_SOON)
|
||||
expect(result.days_remaining).toBe(11)
|
||||
})
|
||||
|
||||
it('accepts license with exactly 30 days remaining', () => {
|
||||
const result = validateLicenseDate(new Date('2026-07-09T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.days_remaining).toBe(30)
|
||||
})
|
||||
|
||||
it('accepts license with more than 30 days remaining', () => {
|
||||
const result = validateLicenseDate(new Date('2027-06-09T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.days_remaining).toBe(365)
|
||||
expect(result.message).toBe('License verified successfully')
|
||||
})
|
||||
|
||||
it('rejects future date more than 20 years from now', () => {
|
||||
const result = validateLicenseDate(new Date('2050-06-09T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_FUTURE_DATE)
|
||||
})
|
||||
|
||||
it('handles leap year date correctly', () => {
|
||||
const result = validateLicenseDate(new Date('2028-02-29T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(true)
|
||||
// 2028-02-29 minus 2026-06-09 = should be > 30 days
|
||||
expect(result.days_remaining).toBeGreaterThan(600)
|
||||
})
|
||||
|
||||
// ─── parseExpirationDate ─────────────────────────────────────────────
|
||||
|
||||
it('parses ISO 8601 date (YYYY-MM-DD)', () => {
|
||||
const d = parseExpirationDate('2027-06-11')
|
||||
expect(d).toBeInstanceOf(Date)
|
||||
expect(d!.getUTCFullYear()).toBe(2027)
|
||||
expect(d!.getUTCMonth()).toBe(5) // June
|
||||
expect(d!.getUTCDate()).toBe(11)
|
||||
})
|
||||
|
||||
it('parses US format (MM/DD/YYYY)', () => {
|
||||
const d = parseExpirationDate('06/11/2027')
|
||||
expect(d).toBeInstanceOf(Date)
|
||||
expect(d!.getFullYear()).toBe(2027)
|
||||
expect(d!.getMonth()).toBe(5) // June
|
||||
expect(d!.getDate()).toBe(11)
|
||||
})
|
||||
|
||||
it('parses DD/MM/YYYY when first part > 12', () => {
|
||||
const d = parseExpirationDate('13/06/2027')
|
||||
expect(d).toBeInstanceOf(Date)
|
||||
expect(d!.getFullYear()).toBe(2027)
|
||||
expect(d!.getMonth()).toBe(5) // June
|
||||
expect(d!.getDate()).toBe(13)
|
||||
})
|
||||
|
||||
it('parses DD-MMM-YYYY (military format)', () => {
|
||||
const d = parseExpirationDate('11-JUN-2027')
|
||||
expect(d).toBeInstanceOf(Date)
|
||||
expect(d!.getFullYear()).toBe(2027)
|
||||
expect(d!.getMonth()).toBe(5) // June
|
||||
expect(d!.getDate()).toBe(11)
|
||||
})
|
||||
|
||||
it('returns null for completely invalid date string', () => {
|
||||
expect(parseExpirationDate('not-a-date')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseExpirationDate('')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(parseExpirationDate(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for undefined input', () => {
|
||||
expect(parseExpirationDate(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { LicenseStatus } from '@rentaldrivego/database'
|
||||
|
||||
const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24
|
||||
|
||||
/** Minimum days of remaining validity required (configurable via env, default 30). */
|
||||
function getMinValidityDays(): number {
|
||||
const raw = process.env.LICENSE_MIN_VALIDITY_DAYS
|
||||
if (raw) {
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
/** Maximum years in the future considered reasonable for a license expiration date. */
|
||||
const MAX_FUTURE_YEARS = 20
|
||||
|
||||
// ── Error codes matching doc/error-code reference ────────────────────────
|
||||
export const ErrorCode = {
|
||||
LICENSE_EXPIRED: 'LICENSE_EXPIRED',
|
||||
LICENSE_EXPIRING_SOON: 'LICENSE_EXPIRING_SOON',
|
||||
LICENSE_INVALID_FORMAT:'LICENSE_INVALID_FORMAT',
|
||||
LICENSE_FUTURE_DATE: 'LICENSE_FUTURE_DATE',
|
||||
LICENSE_MISSING_DATE: 'LICENSE_MISSING_DATE',
|
||||
} as const
|
||||
|
||||
export interface LicenseValidationResult {
|
||||
status: 'VALID' | 'EXPIRING' | 'EXPIRED'
|
||||
@@ -10,19 +32,174 @@ export interface LicenseValidationResult {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface DetailedValidationResult {
|
||||
valid: boolean
|
||||
days_remaining: number | null
|
||||
expiration_date: string | null
|
||||
message: string
|
||||
error_code?: string
|
||||
details?: {
|
||||
expiration_date?: string
|
||||
days_remaining?: number
|
||||
minimum_required?: number
|
||||
suggested_action?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an expiration date string into a Date.
|
||||
* Returns null if the string is not a valid date per ISO 8601 or common
|
||||
* US/EU formats (YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY).
|
||||
*/
|
||||
export function parseExpirationDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null
|
||||
|
||||
// Try ISO 8601 first (YYYY-MM-DD or full ISO)
|
||||
const iso = new Date(raw)
|
||||
if (!isNaN(iso.getTime()) && raw.length >= 10) {
|
||||
// Ensure we don't accept "2026-06" as valid — need at least YYYY-MM-DD
|
||||
const parts = raw.split('T')[0].split('-')
|
||||
if (parts.length === 3) return iso
|
||||
}
|
||||
|
||||
// Try MM/DD/YYYY and DD/MM/YYYY
|
||||
const slashMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
|
||||
if (slashMatch) {
|
||||
// Prefer US order (MM/DD/YYYY) as default; document says "varies by state"
|
||||
const m1 = Number(slashMatch[1])
|
||||
const m2 = Number(slashMatch[2])
|
||||
const year = Number(slashMatch[3])
|
||||
// If first part > 12, it's likely DD/MM/YYYY
|
||||
if (m1 > 12) {
|
||||
const d = new Date(year, m2 - 1, m1)
|
||||
if (!isNaN(d.getTime())) return d
|
||||
}
|
||||
const d = new Date(year, m1 - 1, m2)
|
||||
if (!isNaN(d.getTime())) return d
|
||||
}
|
||||
|
||||
// Try DD-MMM-YYYY (e.g., 11-JUN-2027)
|
||||
const mmmMatch = raw.match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/)
|
||||
if (mmmMatch) {
|
||||
const d = new Date(`${mmmMatch[2]} ${mmmMatch[1]}, ${mmmMatch[3]}`)
|
||||
if (!isNaN(d.getTime())) return d
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure validation logic — no side effects, no DB calls.
|
||||
*
|
||||
* Returns a DetailedValidationResult suitable for the public API.
|
||||
*/
|
||||
export function validateLicenseDate(expirationDate: Date | null | undefined): DetailedValidationResult {
|
||||
if (!expirationDate) {
|
||||
return {
|
||||
valid: false,
|
||||
days_remaining: null,
|
||||
expiration_date: null,
|
||||
message: 'No expiration date provided. Please provide a valid driver\'s license expiration date.',
|
||||
error_code: ErrorCode.LICENSE_MISSING_DATE,
|
||||
details: {
|
||||
suggested_action: 'Please provide the expiration date from your driver\'s license.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const nowMs = now.getTime()
|
||||
const expMs = expirationDate.getTime()
|
||||
const daysRemaining = Math.ceil((expMs - nowMs) / MS_PER_DAY)
|
||||
const expDateStr = expirationDate.toISOString().split('T')[0]
|
||||
const minValidityDays = getMinValidityDays()
|
||||
|
||||
// Expired
|
||||
if (expMs <= nowMs) {
|
||||
return {
|
||||
valid: false,
|
||||
days_remaining: daysRemaining,
|
||||
expiration_date: expDateStr,
|
||||
message: `License has expired. Your license expired on ${expDateStr}.`,
|
||||
error_code: ErrorCode.LICENSE_EXPIRED,
|
||||
details: {
|
||||
expiration_date: expDateStr,
|
||||
days_remaining: daysRemaining,
|
||||
minimum_required: minValidityDays,
|
||||
suggested_action: 'Please renew your driver\'s license and upload the updated document.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Expiring soon (1 – minValidityDays-1 days remaining)
|
||||
if (daysRemaining < minValidityDays) {
|
||||
return {
|
||||
valid: false,
|
||||
days_remaining: daysRemaining,
|
||||
expiration_date: expDateStr,
|
||||
message: `License must have at least ${minValidityDays} days remaining validity. Your license expires in ${daysRemaining} days on ${expDateStr}.`,
|
||||
error_code: ErrorCode.LICENSE_EXPIRING_SOON,
|
||||
details: {
|
||||
expiration_date: expDateStr,
|
||||
days_remaining: daysRemaining,
|
||||
minimum_required: minValidityDays,
|
||||
suggested_action: `Please upload a valid license with more than ${minValidityDays} days remaining.`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Future date (>MAX_FUTURE_YEARS from now)
|
||||
const maxFutureMs = nowMs + MAX_FUTURE_YEARS * 365 * MS_PER_DAY
|
||||
if (expMs > maxFutureMs) {
|
||||
return {
|
||||
valid: false,
|
||||
days_remaining: daysRemaining,
|
||||
expiration_date: expDateStr,
|
||||
message: `The provided expiration date (${expDateStr}) is too far in the future. Please verify the date is correct.`,
|
||||
error_code: ErrorCode.LICENSE_FUTURE_DATE,
|
||||
details: {
|
||||
expiration_date: expDateStr,
|
||||
days_remaining: daysRemaining,
|
||||
minimum_required: minValidityDays,
|
||||
suggested_action: 'Please check the expiration date on your license and re-enter it correctly.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Valid
|
||||
const nextReminder = new Date(expMs - minValidityDays * MS_PER_DAY).toISOString().split('T')[0]
|
||||
return {
|
||||
valid: true,
|
||||
days_remaining: daysRemaining,
|
||||
expiration_date: expDateStr,
|
||||
message: 'License verified successfully',
|
||||
details: {
|
||||
days_remaining: daysRemaining,
|
||||
minimum_required: minValidityDays,
|
||||
suggested_action: 'License is valid.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy validation function used internally by the customer service.
|
||||
* Returns a simpler result compatible with existing callers.
|
||||
*/
|
||||
export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult {
|
||||
if (!licenseExpiry) {
|
||||
return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / MS_PER_DAY)
|
||||
const minValidityDays = getMinValidityDays()
|
||||
const minValidityMs = minValidityDays * MS_PER_DAY
|
||||
|
||||
if (licenseExpiry <= now) {
|
||||
return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` }
|
||||
}
|
||||
|
||||
if (licenseExpiry.getTime() - now.getTime() < THREE_MONTHS_MS) {
|
||||
if (licenseExpiry.getTime() - now.getTime() < minValidityMs) {
|
||||
return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` }
|
||||
}
|
||||
|
||||
@@ -42,11 +219,11 @@ export async function validateAndFlagLicense(customerId: string, companyId?: str
|
||||
await prisma.customer.updateMany({
|
||||
where: { id: customerId, companyId },
|
||||
data: {
|
||||
licenseExpired: result.status === 'EXPIRED',
|
||||
licenseExpiringSoon: result.status === 'EXPIRING',
|
||||
licenseValidationStatus: status,
|
||||
flagged: result.requiresApproval ? true : customer.flagged,
|
||||
flagReason: result.requiresApproval ? result.message : customer.flagReason,
|
||||
licenseExpired: result.status === 'EXPIRED',
|
||||
licenseExpiringSoon: result.status === 'EXPIRING',
|
||||
licenseValidationStatus: status,
|
||||
flagged: result.requiresApproval ? true : customer.flagged,
|
||||
flagReason: result.requiresApproval ? result.message : customer.flagReason,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user