implement driver license date validation

This commit is contained in:
root
2026-06-11 21:53:48 -04:00
parent b52c6305b5
commit af5b7fda7e
10 changed files with 1143 additions and 15 deletions
+2
View File
@@ -31,6 +31,7 @@ import marketplaceRouter from './modules/marketplace/marketplace.routes'
import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes'
import licenseValidationRouter from './modules/licenses/license.validation.routes'
// ─── Centralized error handling ───────────────────────────────
import { errorMiddleware } from './http/errors/errorMiddleware'
@@ -170,6 +171,7 @@ export function createApp() {
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
// ─── Health / Docs ──────────────────────────────────────────
app.get(v1, (_req, res) => {
@@ -0,0 +1,69 @@
import { Router } from 'express'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { AppError } from '../../http/errors'
import { validateLicenseSchema } from './license.validation.schemas'
import { parseExpirationDate, validateLicenseDate, ErrorCode } from '../../services/licenseValidationService'
const router = Router()
/**
* POST /api/v1/licenses/validate
*
* Validates a driver's license expiration date against the minimum validity rule.
*
* Request body:
* { expiration_date: string, license_number?: string, state?: string }
*
* Success (200): license is valid (≥30 days remaining)
* Error (400): invalid format, missing date, future date
* Error (422): expired or expiring soon (<30 days remaining)
*/
router.post('/validate', async (req, res, next) => {
try {
const { expiration_date } = parseBody(validateLicenseSchema, req)
const parsedDate = parseExpirationDate(expiration_date)
if (!parsedDate) {
throw new AppError(
`Unable to parse date "${expiration_date}". Please use YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY, or DD-MMM-YYYY format.`,
400,
ErrorCode.LICENSE_INVALID_FORMAT,
{
details: {
expiration_date,
suggested_action: 'Please provide the date in a supported format (e.g., YYYY-MM-DD).',
},
},
)
}
const result = validateLicenseDate(parsedDate)
if (!result.valid) {
// Map error codes to HTTP status
const statusCode =
result.error_code === ErrorCode.LICENSE_EXPIRED || result.error_code === ErrorCode.LICENSE_EXPIRING_SOON
? 422
: 400
throw new AppError(result.message, statusCode, result.error_code!, {
details: result.details,
})
}
ok(res, {
valid: true,
days_remaining: result.days_remaining,
expiration_date: result.expiration_date,
message: result.message,
next_reminder: result.days_remaining
? new Date(Date.now() + (result.days_remaining - 30) * 86400000).toISOString().split('T')[0]
: null,
})
} catch (err) {
next(err)
}
})
export default router
@@ -0,0 +1,10 @@
import { z } from 'zod'
/** Request body for POST /api/v1/licenses/validate */
export const validateLicenseSchema = z.object({
expiration_date: z.string().min(1, 'expiration_date is required'),
license_number: z.string().max(100).optional(),
state: z.string().length(2).optional(),
})
export type ValidateLicenseInput = z.infer<typeof validateLicenseSchema>
@@ -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 {
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
vi.mock('../../lib/redis', () => ({
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
}))
import request from 'supertest'
import { createApp } from '../../app'
const app = createApp()
/** Return today's date as YYYY-MM-DD */
function today(): string {
return new Date().toISOString().split('T')[0]
}
/** Return a date offset by `days` from today, as YYYY-MM-DD */
function offsetDate(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
return d.toISOString().split('T')[0]
}
describe('License Validation API — POST /api/v1/licenses/validate', () => {
// ─── Success scenarios ──────────────────────────────────────────────
it('accepts a license expiring in 365 days (valid)', async () => {
const future = offsetDate(365)
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: future })
expect(res.status).toBe(200)
expect(res.body.data).toMatchObject({
valid: true,
message: 'License verified successfully',
})
expect(res.body.data.days_remaining).toBeGreaterThanOrEqual(365)
expect(res.body.data.next_reminder).toBeDefined()
})
it('accepts a license expiring in exactly 30 days', async () => {
const future = offsetDate(30)
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: future })
expect(res.status).toBe(200)
expect(res.body.data.valid).toBe(true)
expect(res.body.data.days_remaining).toBe(30)
})
// ─── Rejection scenarios ────────────────────────────────────────────
it('rejects a license expiring in 15 days with 422', async () => {
const nearFuture = offsetDate(15)
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: nearFuture })
expect(res.status).toBe(422)
expect(res.body.error).toBe('LICENSE_EXPIRING_SOON')
expect(res.body.message).toContain('30 days')
expect(res.body.details).toBeDefined()
expect(res.body.details.days_remaining).toBe(15)
expect(res.body.details.minimum_required).toBe(30)
})
it('rejects an expired license with 422', async () => {
const past = offsetDate(-5)
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: past })
expect(res.status).toBe(422)
expect(res.body.error).toBe('LICENSE_EXPIRED')
expect(res.body.message).toContain('expired')
})
it('rejects an invalid date format with 400', async () => {
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: 'not-a-date' })
expect(res.status).toBe(400)
expect(res.body.error).toBe('LICENSE_INVALID_FORMAT')
expect(res.body.message).toContain('Unable to parse')
})
it('rejects an empty expiration_date with 400', async () => {
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: '' })
expect(res.status).toBe(400)
expect(res.body.error).toBe('validation_error')
})
it('rejects a date too far in the future with 400', async () => {
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: '2055-01-01' })
expect(res.status).toBe(400)
expect(res.body.error).toBe('LICENSE_FUTURE_DATE')
})
// ─── Multiple date formats ──────────────────────────────────────────
it('parses MM/DD/YYYY format', async () => {
const future = new Date()
future.setFullYear(future.getFullYear() + 1)
const mm = String(future.getMonth() + 1).padStart(2, '0')
const dd = String(future.getDate()).padStart(2, '0')
const yyyy = future.getFullYear()
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: `${mm}/${dd}/${yyyy}` })
expect(res.status).toBe(200)
expect(res.body.data.valid).toBe(true)
})
it('parses DD-MMM-YYYY format', async () => {
const future = new Date()
future.setFullYear(future.getFullYear() + 1)
const dd = String(future.getDate()).padStart(2, '0')
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
const mmm = months[future.getMonth()]
const yyyy = future.getFullYear()
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: `${dd}-${mmm}-${yyyy}` })
expect(res.status).toBe(200)
expect(res.body.data.valid).toBe(true)
})
})