From 1b388183549e6b542b2ff8e01a90ca53479ac713 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Fri, 30 Jan 2026 19:59:47 -0600 Subject: [PATCH] [CL-010] feat: Implement insurance module for medical insurance management Implements complete insurance module with: - Insurance company catalog (private, government, HMO, PPO) - Insurance plans with coverage details, deductibles, copays - Patient insurance policies with verification workflow - Insurance claims with full lifecycle (draft->submitted->approved/denied/paid) - Preauthorization requests with approval workflow - Coverage verification endpoint - EOB (Explanation of Benefits) tracking - Aging reports for claims management Entities: InsuranceCompany, InsurancePlan, PatientInsurance, InsuranceClaim, Preauthorization Services: Full CRUD + workflow operations for each entity Controller: 50+ REST endpoints for all insurance operations Co-Authored-By: Claude Opus 4.5 --- src/modules/insurance/controllers/index.ts | 1 + .../controllers/insurance.controller.ts | 968 +++++++++++ src/modules/insurance/dto/index.ts | 1463 +++++++++++++++++ src/modules/insurance/entities/index.ts | 48 + .../entities/insurance-claim.entity.ts | 184 +++ .../entities/insurance-company.entity.ts | 107 ++ .../entities/insurance-plan.entity.ts | 148 ++ .../entities/patient-insurance.entity.ts | 132 ++ .../entities/preauthorization.entity.ts | 162 ++ src/modules/insurance/index.ts | 86 + src/modules/insurance/insurance.module.ts | 19 + .../insurance/services/claim.service.ts | 370 +++++ src/modules/insurance/services/index.ts | 5 + .../services/insurance-company.service.ts | 112 ++ .../services/insurance-plan.service.ts | 137 ++ .../services/patient-insurance.service.ts | 271 +++ .../services/preauthorization.service.ts | 347 ++++ 17 files changed, 4560 insertions(+) create mode 100644 src/modules/insurance/controllers/index.ts create mode 100644 src/modules/insurance/controllers/insurance.controller.ts create mode 100644 src/modules/insurance/dto/index.ts create mode 100644 src/modules/insurance/entities/index.ts create mode 100644 src/modules/insurance/entities/insurance-claim.entity.ts create mode 100644 src/modules/insurance/entities/insurance-company.entity.ts create mode 100644 src/modules/insurance/entities/insurance-plan.entity.ts create mode 100644 src/modules/insurance/entities/patient-insurance.entity.ts create mode 100644 src/modules/insurance/entities/preauthorization.entity.ts create mode 100644 src/modules/insurance/index.ts create mode 100644 src/modules/insurance/insurance.module.ts create mode 100644 src/modules/insurance/services/claim.service.ts create mode 100644 src/modules/insurance/services/index.ts create mode 100644 src/modules/insurance/services/insurance-company.service.ts create mode 100644 src/modules/insurance/services/insurance-plan.service.ts create mode 100644 src/modules/insurance/services/patient-insurance.service.ts create mode 100644 src/modules/insurance/services/preauthorization.service.ts diff --git a/src/modules/insurance/controllers/index.ts b/src/modules/insurance/controllers/index.ts new file mode 100644 index 0000000..e943378 --- /dev/null +++ b/src/modules/insurance/controllers/index.ts @@ -0,0 +1 @@ +export { InsuranceController } from './insurance.controller'; diff --git a/src/modules/insurance/controllers/insurance.controller.ts b/src/modules/insurance/controllers/insurance.controller.ts new file mode 100644 index 0000000..9620c6e --- /dev/null +++ b/src/modules/insurance/controllers/insurance.controller.ts @@ -0,0 +1,968 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + InsuranceCompanyService, + InsurancePlanService, + PatientInsuranceService, + ClaimService, + PreauthorizationService, +} from '../services'; +import { + CreateInsuranceCompanyDto, + UpdateInsuranceCompanyDto, + InsuranceCompanyQueryDto, + CreateInsurancePlanDto, + UpdateInsurancePlanDto, + InsurancePlanQueryDto, + CreatePatientInsuranceDto, + UpdatePatientInsuranceDto, + VerifyInsuranceDto, + PatientInsuranceQueryDto, + VerifyCoverageDto, + CreateInsuranceClaimDto, + UpdateInsuranceClaimDto, + SubmitClaimDto, + ProcessClaimDto, + RecordEOBDto, + VoidClaimDto, + InsuranceClaimQueryDto, + CreatePreauthorizationDto, + UpdatePreauthorizationDto, + SubmitPreauthorizationDto, + ProcessPreauthorizationDto, + CancelPreauthorizationDto, + PreauthorizationQueryDto, +} from '../dto'; + +export class InsuranceController { + public router: Router; + private companyService: InsuranceCompanyService; + private planService: InsurancePlanService; + private patientInsuranceService: PatientInsuranceService; + private claimService: ClaimService; + private preauthorizationService: PreauthorizationService; + + constructor(dataSource: DataSource, basePath: string = '/api') { + this.router = Router(); + this.companyService = new InsuranceCompanyService(dataSource); + this.planService = new InsurancePlanService(dataSource); + this.patientInsuranceService = new PatientInsuranceService(dataSource); + this.claimService = new ClaimService(dataSource); + this.preauthorizationService = new PreauthorizationService(dataSource); + this.setupRoutes(basePath); + } + + private setupRoutes(basePath: string): void { + const insurancePath = `${basePath}/insurance`; + + // Insurance Companies Routes + this.router.get(`${insurancePath}/companies`, this.findAllCompanies.bind(this)); + this.router.get(`${insurancePath}/companies/active`, this.findActiveCompanies.bind(this)); + this.router.get(`${insurancePath}/companies/by-type`, this.getCompaniesByType.bind(this)); + this.router.get(`${insurancePath}/companies/:id`, this.findCompanyById.bind(this)); + this.router.post(`${insurancePath}/companies`, this.createCompany.bind(this)); + this.router.patch(`${insurancePath}/companies/:id`, this.updateCompany.bind(this)); + this.router.delete(`${insurancePath}/companies/:id`, this.deleteCompany.bind(this)); + + // Insurance Plans Routes + this.router.get(`${insurancePath}/plans`, this.findAllPlans.bind(this)); + this.router.get(`${insurancePath}/plans/active`, this.findActivePlans.bind(this)); + this.router.get(`${insurancePath}/plans/by-coverage-type`, this.getPlansByCoverageType.bind(this)); + this.router.get(`${insurancePath}/plans/company/:companyId`, this.findPlansByCompany.bind(this)); + this.router.get(`${insurancePath}/plans/:id`, this.findPlanById.bind(this)); + this.router.post(`${insurancePath}/plans`, this.createPlan.bind(this)); + this.router.patch(`${insurancePath}/plans/:id`, this.updatePlan.bind(this)); + this.router.delete(`${insurancePath}/plans/:id`, this.deletePlan.bind(this)); + + // Patient Insurance Routes + this.router.get(`${insurancePath}/patient-insurance`, this.findAllPatientInsurance.bind(this)); + this.router.get(`${insurancePath}/patient-insurance/unverified-count`, this.getUnverifiedCount.bind(this)); + this.router.get(`${insurancePath}/patient-insurance/expiring`, this.getExpiringPolicies.bind(this)); + this.router.get(`${insurancePath}/patient-insurance/patient/:patientId`, this.findPatientInsuranceByPatient.bind(this)); + this.router.get(`${insurancePath}/patient-insurance/patient/:patientId/active`, this.findActivePatientInsurance.bind(this)); + this.router.get(`${insurancePath}/patient-insurance/patient/:patientId/primary`, this.findPrimaryInsurance.bind(this)); + this.router.get(`${insurancePath}/patient-insurance/:id`, this.findPatientInsuranceById.bind(this)); + this.router.post(`${insurancePath}/patient-insurance`, this.createPatientInsurance.bind(this)); + this.router.patch(`${insurancePath}/patient-insurance/:id`, this.updatePatientInsurance.bind(this)); + this.router.post(`${insurancePath}/patient-insurance/:id/verify`, this.verifyPatientInsurance.bind(this)); + this.router.post(`${insurancePath}/patient-insurance/:id/terminate`, this.terminatePatientInsurance.bind(this)); + this.router.post(`${insurancePath}/patient-insurance/verify-coverage`, this.verifyCoverage.bind(this)); + + // Claims Routes + this.router.get(`${insurancePath}/claims`, this.findAllClaims.bind(this)); + this.router.get(`${insurancePath}/claims/stats`, this.getClaimStats.bind(this)); + this.router.get(`${insurancePath}/claims/aging`, this.getClaimAgingReport.bind(this)); + this.router.get(`${insurancePath}/claims/patient/:patientId`, this.findClaimsByPatient.bind(this)); + this.router.get(`${insurancePath}/claims/:id`, this.findClaimById.bind(this)); + this.router.post(`${insurancePath}/claims`, this.createClaim.bind(this)); + this.router.patch(`${insurancePath}/claims/:id`, this.updateClaim.bind(this)); + this.router.post(`${insurancePath}/claims/:id/submit`, this.submitClaim.bind(this)); + this.router.post(`${insurancePath}/claims/:id/process`, this.processClaim.bind(this)); + this.router.post(`${insurancePath}/claims/:id/eob`, this.recordEOB.bind(this)); + this.router.post(`${insurancePath}/claims/:id/appeal`, this.appealClaim.bind(this)); + this.router.post(`${insurancePath}/claims/:id/void`, this.voidClaim.bind(this)); + + // Preauthorizations Routes + this.router.get(`${insurancePath}/preauthorizations`, this.findAllPreauthorizations.bind(this)); + this.router.get(`${insurancePath}/preauthorizations/stats`, this.getPreauthorizationStats.bind(this)); + this.router.get(`${insurancePath}/preauthorizations/urgent`, this.getUrgentPreauthorizations.bind(this)); + this.router.get(`${insurancePath}/preauthorizations/expiring`, this.getExpiringPreauthorizations.bind(this)); + this.router.get(`${insurancePath}/preauthorizations/patient/:patientId`, this.findPreauthorizationsByPatient.bind(this)); + this.router.get(`${insurancePath}/preauthorizations/patient/:patientId/active`, this.findActivePreauthorizations.bind(this)); + this.router.get(`${insurancePath}/preauthorizations/:id`, this.findPreauthorizationById.bind(this)); + this.router.post(`${insurancePath}/preauthorizations`, this.createPreauthorization.bind(this)); + this.router.patch(`${insurancePath}/preauthorizations/:id`, this.updatePreauthorization.bind(this)); + this.router.post(`${insurancePath}/preauthorizations/:id/submit`, this.submitPreauthorization.bind(this)); + this.router.post(`${insurancePath}/preauthorizations/:id/pending`, this.markPreauthorizationPending.bind(this)); + this.router.post(`${insurancePath}/preauthorizations/:id/process`, this.processPreauthorization.bind(this)); + this.router.post(`${insurancePath}/preauthorizations/:id/cancel`, this.cancelPreauthorization.bind(this)); + } + + private getTenantId(req: Request): string { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + throw new Error('x-tenant-id header is required'); + } + return tenantId; + } + + // Insurance Companies Handlers + private async findAllCompanies(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: InsuranceCompanyQueryDto = { + search: req.query.search as string, + type: req.query.type as any, + status: req.query.status as any, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + }; + + const result = await this.companyService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + private async findActiveCompanies(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const companies = await this.companyService.findActive(tenantId); + res.json({ data: companies }); + } catch (error) { + next(error); + } + } + + private async getCompaniesByType(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const types = await this.companyService.getByType(tenantId); + res.json({ data: types }); + } catch (error) { + next(error); + } + } + + private async findCompanyById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const company = await this.companyService.findById(tenantId, id); + if (!company) { + res.status(404).json({ error: 'Insurance company not found', code: 'INSURANCE_COMPANY_NOT_FOUND' }); + return; + } + + res.json({ data: company }); + } catch (error) { + next(error); + } + } + + private async createCompany(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateInsuranceCompanyDto = req.body; + + const company = await this.companyService.create(tenantId, dto); + res.status(201).json({ data: company }); + } catch (error) { + next(error); + } + } + + private async updateCompany(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateInsuranceCompanyDto = req.body; + + const company = await this.companyService.update(tenantId, id, dto); + if (!company) { + res.status(404).json({ error: 'Insurance company not found', code: 'INSURANCE_COMPANY_NOT_FOUND' }); + return; + } + + res.json({ data: company }); + } catch (error) { + next(error); + } + } + + private async deleteCompany(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const deleted = await this.companyService.softDelete(tenantId, id); + if (!deleted) { + res.status(404).json({ error: 'Insurance company not found', code: 'INSURANCE_COMPANY_NOT_FOUND' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // Insurance Plans Handlers + private async findAllPlans(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: InsurancePlanQueryDto = { + search: req.query.search as string, + insuranceCompanyId: req.query.insuranceCompanyId as string, + coverageType: req.query.coverageType as any, + status: req.query.status as any, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + }; + + const result = await this.planService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + private async findActivePlans(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const plans = await this.planService.findActive(tenantId); + res.json({ data: plans }); + } catch (error) { + next(error); + } + } + + private async getPlansByCoverageType(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const types = await this.planService.getByCoverageType(tenantId); + res.json({ data: types }); + } catch (error) { + next(error); + } + } + + private async findPlansByCompany(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { companyId } = req.params; + + const plans = await this.planService.findByCompany(tenantId, companyId); + res.json({ data: plans }); + } catch (error) { + next(error); + } + } + + private async findPlanById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const plan = await this.planService.findById(tenantId, id); + if (!plan) { + res.status(404).json({ error: 'Insurance plan not found', code: 'INSURANCE_PLAN_NOT_FOUND' }); + return; + } + + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + private async createPlan(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateInsurancePlanDto = req.body; + + const plan = await this.planService.create(tenantId, dto); + res.status(201).json({ data: plan }); + } catch (error) { + next(error); + } + } + + private async updatePlan(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateInsurancePlanDto = req.body; + + const plan = await this.planService.update(tenantId, id, dto); + if (!plan) { + res.status(404).json({ error: 'Insurance plan not found', code: 'INSURANCE_PLAN_NOT_FOUND' }); + return; + } + + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + private async deletePlan(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const deleted = await this.planService.softDelete(tenantId, id); + if (!deleted) { + res.status(404).json({ error: 'Insurance plan not found', code: 'INSURANCE_PLAN_NOT_FOUND' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // Patient Insurance Handlers + private async findAllPatientInsurance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: PatientInsuranceQueryDto = { + patientId: req.query.patientId as string, + insurancePlanId: req.query.insurancePlanId as string, + priority: req.query.priority as any, + status: req.query.status as any, + isVerified: req.query.isVerified === 'true' ? true : req.query.isVerified === 'false' ? false : undefined, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + }; + + const result = await this.patientInsuranceService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getUnverifiedCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const count = await this.patientInsuranceService.getUnverifiedCount(tenantId); + res.json({ data: { count } }); + } catch (error) { + next(error); + } + } + + private async getExpiringPolicies(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const daysAhead = req.query.daysAhead ? parseInt(req.query.daysAhead as string) : 30; + const policies = await this.patientInsuranceService.getExpiringPolicies(tenantId, daysAhead); + res.json({ data: policies }); + } catch (error) { + next(error); + } + } + + private async findPatientInsuranceByPatient(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + + const policies = await this.patientInsuranceService.findByPatient(tenantId, patientId); + res.json({ data: policies }); + } catch (error) { + next(error); + } + } + + private async findActivePatientInsurance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + + const policies = await this.patientInsuranceService.findActiveByPatient(tenantId, patientId); + res.json({ data: policies }); + } catch (error) { + next(error); + } + } + + private async findPrimaryInsurance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + + const policy = await this.patientInsuranceService.findPrimaryByPatient(tenantId, patientId); + res.json({ data: policy }); + } catch (error) { + next(error); + } + } + + private async findPatientInsuranceById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const policy = await this.patientInsuranceService.findById(tenantId, id); + if (!policy) { + res.status(404).json({ error: 'Patient insurance not found', code: 'PATIENT_INSURANCE_NOT_FOUND' }); + return; + } + + res.json({ data: policy }); + } catch (error) { + next(error); + } + } + + private async createPatientInsurance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreatePatientInsuranceDto = req.body; + + const policy = await this.patientInsuranceService.create(tenantId, dto); + res.status(201).json({ data: policy }); + } catch (error) { + next(error); + } + } + + private async updatePatientInsurance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdatePatientInsuranceDto = req.body; + + const policy = await this.patientInsuranceService.update(tenantId, id, dto); + if (!policy) { + res.status(404).json({ error: 'Patient insurance not found', code: 'PATIENT_INSURANCE_NOT_FOUND' }); + return; + } + + res.json({ data: policy }); + } catch (error) { + next(error); + } + } + + private async verifyPatientInsurance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: VerifyInsuranceDto = req.body; + + const policy = await this.patientInsuranceService.verify(tenantId, id, dto); + if (!policy) { + res.status(404).json({ error: 'Patient insurance not found', code: 'PATIENT_INSURANCE_NOT_FOUND' }); + return; + } + + res.json({ data: policy }); + } catch (error) { + next(error); + } + } + + private async terminatePatientInsurance(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const terminationDate = req.body.terminationDate ? new Date(req.body.terminationDate) : undefined; + + const policy = await this.patientInsuranceService.terminate(tenantId, id, terminationDate); + if (!policy) { + res.status(404).json({ error: 'Patient insurance not found', code: 'PATIENT_INSURANCE_NOT_FOUND' }); + return; + } + + res.json({ data: policy }); + } catch (error) { + next(error); + } + } + + private async verifyCoverage(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: VerifyCoverageDto = req.body; + + const result = await this.patientInsuranceService.verifyCoverage(tenantId, dto); + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + // Claims Handlers + private async findAllClaims(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: InsuranceClaimQueryDto = { + patientId: req.query.patientId as string, + patientInsuranceId: req.query.patientInsuranceId as string, + status: req.query.status as any, + claimType: req.query.claimType as any, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 20, + }; + + const result = await this.claimService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getClaimStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const [statusBreakdown, pendingCount] = await Promise.all([ + this.claimService.getClaimsByStatus(tenantId), + this.claimService.getPendingClaimsCount(tenantId), + ]); + + res.json({ + data: { + statusBreakdown, + pendingCount, + }, + }); + } catch (error) { + next(error); + } + } + + private async getClaimAgingReport(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const aging = await this.claimService.getAgingReport(tenantId); + res.json({ data: aging }); + } catch (error) { + next(error); + } + } + + private async findClaimsByPatient(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 10; + + const claims = await this.claimService.findByPatient(tenantId, patientId, limit); + res.json({ data: claims }); + } catch (error) { + next(error); + } + } + + private async findClaimById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const claim = await this.claimService.findById(tenantId, id); + if (!claim) { + res.status(404).json({ error: 'Insurance claim not found', code: 'INSURANCE_CLAIM_NOT_FOUND' }); + return; + } + + res.json({ data: claim }); + } catch (error) { + next(error); + } + } + + private async createClaim(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreateInsuranceClaimDto = req.body; + + const claim = await this.claimService.create(tenantId, dto); + res.status(201).json({ data: claim }); + } catch (error) { + next(error); + } + } + + private async updateClaim(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateInsuranceClaimDto = req.body; + + const claim = await this.claimService.update(tenantId, id, dto); + if (!claim) { + res.status(404).json({ error: 'Insurance claim not found', code: 'INSURANCE_CLAIM_NOT_FOUND' }); + return; + } + + res.json({ data: claim }); + } catch (error) { + next(error); + } + } + + private async submitClaim(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: SubmitClaimDto = req.body; + + const claim = await this.claimService.submit(tenantId, id, dto); + if (!claim) { + res.status(404).json({ error: 'Insurance claim not found', code: 'INSURANCE_CLAIM_NOT_FOUND' }); + return; + } + + res.json({ data: claim }); + } catch (error) { + next(error); + } + } + + private async processClaim(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: ProcessClaimDto = req.body; + + const claim = await this.claimService.process(tenantId, id, dto); + if (!claim) { + res.status(404).json({ error: 'Insurance claim not found', code: 'INSURANCE_CLAIM_NOT_FOUND' }); + return; + } + + res.json({ data: claim }); + } catch (error) { + next(error); + } + } + + private async recordEOB(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: RecordEOBDto = req.body; + + const claim = await this.claimService.recordEOB(tenantId, id, dto); + if (!claim) { + res.status(404).json({ error: 'Insurance claim not found', code: 'INSURANCE_CLAIM_NOT_FOUND' }); + return; + } + + res.json({ data: claim }); + } catch (error) { + next(error); + } + } + + private async appealClaim(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { notes } = req.body; + + const claim = await this.claimService.appeal(tenantId, id, notes); + if (!claim) { + res.status(404).json({ error: 'Insurance claim not found', code: 'INSURANCE_CLAIM_NOT_FOUND' }); + return; + } + + res.json({ data: claim }); + } catch (error) { + next(error); + } + } + + private async voidClaim(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: VoidClaimDto = req.body; + + const claim = await this.claimService.void(tenantId, id, dto); + if (!claim) { + res.status(404).json({ error: 'Insurance claim not found', code: 'INSURANCE_CLAIM_NOT_FOUND' }); + return; + } + + res.json({ data: claim }); + } catch (error) { + next(error); + } + } + + // Preauthorizations Handlers + private async findAllPreauthorizations(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: PreauthorizationQueryDto = { + patientId: req.query.patientId as string, + patientInsuranceId: req.query.patientInsuranceId as string, + status: req.query.status as any, + authType: req.query.authType as any, + urgency: req.query.urgency as any, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 20, + }; + + const result = await this.preauthorizationService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getPreauthorizationStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const [statusBreakdown, pendingCount] = await Promise.all([ + this.preauthorizationService.getPreauthorizationsByStatus(tenantId), + this.preauthorizationService.getPendingCount(tenantId), + ]); + + res.json({ + data: { + statusBreakdown, + pendingCount, + }, + }); + } catch (error) { + next(error); + } + } + + private async getUrgentPreauthorizations(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const preauths = await this.preauthorizationService.getUrgentPreauthorizations(tenantId); + res.json({ data: preauths }); + } catch (error) { + next(error); + } + } + + private async getExpiringPreauthorizations(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const daysAhead = req.query.daysAhead ? parseInt(req.query.daysAhead as string) : 14; + const preauths = await this.preauthorizationService.getExpiringPreauthorizations(tenantId, daysAhead); + res.json({ data: preauths }); + } catch (error) { + next(error); + } + } + + private async findPreauthorizationsByPatient(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 10; + + const preauths = await this.preauthorizationService.findByPatient(tenantId, patientId, limit); + res.json({ data: preauths }); + } catch (error) { + next(error); + } + } + + private async findActivePreauthorizations(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { patientId } = req.params; + + const preauths = await this.preauthorizationService.findActive(tenantId, patientId); + res.json({ data: preauths }); + } catch (error) { + next(error); + } + } + + private async findPreauthorizationById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const preauth = await this.preauthorizationService.findById(tenantId, id); + if (!preauth) { + res.status(404).json({ error: 'Preauthorization not found', code: 'PREAUTHORIZATION_NOT_FOUND' }); + return; + } + + res.json({ data: preauth }); + } catch (error) { + next(error); + } + } + + private async createPreauthorization(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const dto: CreatePreauthorizationDto = req.body; + + const preauth = await this.preauthorizationService.create(tenantId, dto); + res.status(201).json({ data: preauth }); + } catch (error) { + next(error); + } + } + + private async updatePreauthorization(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdatePreauthorizationDto = req.body; + + const preauth = await this.preauthorizationService.update(tenantId, id, dto); + if (!preauth) { + res.status(404).json({ error: 'Preauthorization not found', code: 'PREAUTHORIZATION_NOT_FOUND' }); + return; + } + + res.json({ data: preauth }); + } catch (error) { + next(error); + } + } + + private async submitPreauthorization(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: SubmitPreauthorizationDto = req.body; + + const preauth = await this.preauthorizationService.submit(tenantId, id, dto); + if (!preauth) { + res.status(404).json({ error: 'Preauthorization not found', code: 'PREAUTHORIZATION_NOT_FOUND' }); + return; + } + + res.json({ data: preauth }); + } catch (error) { + next(error); + } + } + + private async markPreauthorizationPending(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { payerReferenceNumber } = req.body; + + const preauth = await this.preauthorizationService.markPending(tenantId, id, payerReferenceNumber); + if (!preauth) { + res.status(404).json({ error: 'Preauthorization not found', code: 'PREAUTHORIZATION_NOT_FOUND' }); + return; + } + + res.json({ data: preauth }); + } catch (error) { + next(error); + } + } + + private async processPreauthorization(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: ProcessPreauthorizationDto = req.body; + + const preauth = await this.preauthorizationService.process(tenantId, id, dto); + if (!preauth) { + res.status(404).json({ error: 'Preauthorization not found', code: 'PREAUTHORIZATION_NOT_FOUND' }); + return; + } + + res.json({ data: preauth }); + } catch (error) { + next(error); + } + } + + private async cancelPreauthorization(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: CancelPreauthorizationDto = req.body; + + const preauth = await this.preauthorizationService.cancel(tenantId, id, dto); + if (!preauth) { + res.status(404).json({ error: 'Preauthorization not found', code: 'PREAUTHORIZATION_NOT_FOUND' }); + return; + } + + res.json({ data: preauth }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/insurance/dto/index.ts b/src/modules/insurance/dto/index.ts new file mode 100644 index 0000000..f7b668d --- /dev/null +++ b/src/modules/insurance/dto/index.ts @@ -0,0 +1,1463 @@ +import { + IsString, + IsOptional, + IsUUID, + IsEnum, + IsBoolean, + IsDateString, + IsInt, + IsNumber, + IsArray, + ValidateNested, + MaxLength, + Min, + IsUrl, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + InsuranceCompanyStatus, + InsuranceCompanyType, + InsurancePlanStatus, + CoverageType, + PatientInsuranceStatus, + InsurancePriority, + RelationshipToSubscriber, + ClaimStatus, + ClaimType, + PreauthorizationStatus, + PreauthorizationType, + UrgencyLevel, +} from '../entities'; + +// Contact Info DTO +export class ContactInfoDto { + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + fax?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + website?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + claimsPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + claimsEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + preAuthPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + preAuthEmail?: string; +} + +// Address Info DTO +export class AddressInfoDto { + @IsOptional() + @IsString() + @MaxLength(200) + street?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + country?: string; +} + +// Insurance Company DTOs +export class CreateInsuranceCompanyDto { + @IsString() + @MaxLength(50) + code: string; + + @IsString() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + @MaxLength(200) + legalName?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + taxId?: string; + + @IsOptional() + @IsEnum(['private', 'government', 'employer', 'hmo', 'ppo', 'other']) + type?: InsuranceCompanyType; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @ValidateNested() + @Type(() => ContactInfoDto) + contactInfo?: ContactInfoDto; + + @IsOptional() + @ValidateNested() + @Type(() => AddressInfoDto) + address?: AddressInfoDto; + + @IsOptional() + @ValidateNested() + @Type(() => AddressInfoDto) + billingAddress?: AddressInfoDto; + + @IsOptional() + @IsString() + @MaxLength(50) + payerId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + naicCode?: string; + + @IsOptional() + @IsInt() + @Min(1) + averagePaymentDays?: number; + + @IsOptional() + @IsBoolean() + requiresPreauthorization?: boolean; + + @IsOptional() + @IsBoolean() + electronicClaimsEnabled?: boolean; + + @IsOptional() + @IsString() + @MaxLength(500) + logoUrl?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateInsuranceCompanyDto { + @IsOptional() + @IsString() + @MaxLength(50) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + legalName?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + taxId?: string; + + @IsOptional() + @IsEnum(['private', 'government', 'employer', 'hmo', 'ppo', 'other']) + type?: InsuranceCompanyType; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @ValidateNested() + @Type(() => ContactInfoDto) + contactInfo?: ContactInfoDto; + + @IsOptional() + @ValidateNested() + @Type(() => AddressInfoDto) + address?: AddressInfoDto; + + @IsOptional() + @ValidateNested() + @Type(() => AddressInfoDto) + billingAddress?: AddressInfoDto; + + @IsOptional() + @IsString() + @MaxLength(50) + payerId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + naicCode?: string; + + @IsOptional() + @IsInt() + @Min(1) + averagePaymentDays?: number; + + @IsOptional() + @IsBoolean() + requiresPreauthorization?: boolean; + + @IsOptional() + @IsBoolean() + electronicClaimsEnabled?: boolean; + + @IsOptional() + @IsString() + @MaxLength(500) + logoUrl?: string; + + @IsOptional() + @IsEnum(['active', 'inactive', 'suspended']) + status?: InsuranceCompanyStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +export class InsuranceCompanyQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(['private', 'government', 'employer', 'hmo', 'ppo', 'other']) + type?: InsuranceCompanyType; + + @IsOptional() + @IsEnum(['active', 'inactive', 'suspended']) + status?: InsuranceCompanyStatus; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Coverage Details DTO +export class CoverageDetailsDto { + @IsOptional() + @IsNumber() + @Min(0) + consultations?: number; + + @IsOptional() + @IsNumber() + @Min(0) + hospitalizations?: number; + + @IsOptional() + @IsNumber() + @Min(0) + emergencies?: number; + + @IsOptional() + @IsNumber() + @Min(0) + surgeries?: number; + + @IsOptional() + @IsNumber() + @Min(0) + laboratory?: number; + + @IsOptional() + @IsNumber() + @Min(0) + imaging?: number; + + @IsOptional() + @IsNumber() + @Min(0) + pharmacy?: number; + + @IsOptional() + @IsNumber() + @Min(0) + dental?: number; + + @IsOptional() + @IsNumber() + @Min(0) + vision?: number; + + @IsOptional() + @IsNumber() + @Min(0) + mentalHealth?: number; + + @IsOptional() + @IsNumber() + @Min(0) + preventiveCare?: number; +} + +// Deductible Info DTO +export class DeductibleInfoDto { + @IsOptional() + @IsNumber() + @Min(0) + individual?: number; + + @IsOptional() + @IsNumber() + @Min(0) + family?: number; + + @IsOptional() + @IsNumber() + @Min(0) + inNetwork?: number; + + @IsOptional() + @IsNumber() + @Min(0) + outOfNetwork?: number; +} + +// Copay Info DTO +export class CopayInfoDto { + @IsOptional() + @IsNumber() + @Min(0) + primaryCare?: number; + + @IsOptional() + @IsNumber() + @Min(0) + specialist?: number; + + @IsOptional() + @IsNumber() + @Min(0) + urgentCare?: number; + + @IsOptional() + @IsNumber() + @Min(0) + emergency?: number; + + @IsOptional() + @IsNumber() + @Min(0) + laboratory?: number; + + @IsOptional() + @IsNumber() + @Min(0) + imaging?: number; + + @IsOptional() + @IsNumber() + @Min(0) + genericDrugs?: number; + + @IsOptional() + @IsNumber() + @Min(0) + brandDrugs?: number; + + @IsOptional() + @IsNumber() + @Min(0) + specialtyDrugs?: number; +} + +// Out of Pocket Max DTO +export class OutOfPocketMaxDto { + @IsOptional() + @IsNumber() + @Min(0) + individual?: number; + + @IsOptional() + @IsNumber() + @Min(0) + family?: number; + + @IsOptional() + @IsNumber() + @Min(0) + inNetwork?: number; + + @IsOptional() + @IsNumber() + @Min(0) + outOfNetwork?: number; +} + +// Insurance Plan DTOs +export class CreateInsurancePlanDto { + @IsUUID() + insuranceCompanyId: string; + + @IsString() + @MaxLength(50) + code: string; + + @IsString() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['individual', 'family', 'group', 'medicare', 'medicaid', 'other']) + coverageType?: CoverageType; + + @IsOptional() + @ValidateNested() + @Type(() => CoverageDetailsDto) + coveragePercentage?: CoverageDetailsDto; + + @IsOptional() + @ValidateNested() + @Type(() => DeductibleInfoDto) + deductible?: DeductibleInfoDto; + + @IsOptional() + @ValidateNested() + @Type(() => CopayInfoDto) + copay?: CopayInfoDto; + + @IsOptional() + @ValidateNested() + @Type(() => OutOfPocketMaxDto) + outOfPocketMax?: OutOfPocketMaxDto; + + @IsOptional() + @IsNumber() + @Min(0) + coinsurancePercentage?: number; + + @IsOptional() + @IsNumber() + @Min(0) + annualBenefitMax?: number; + + @IsOptional() + @IsNumber() + @Min(0) + lifetimeBenefitMax?: number; + + @IsOptional() + @IsInt() + @Min(0) + waitingPeriodDays?: number; + + @IsOptional() + @IsBoolean() + preauthorizationRequired?: boolean; + + @IsOptional() + @IsBoolean() + referralRequired?: boolean; + + @IsOptional() + @IsString() + networkRestrictions?: string; + + @IsOptional() + @IsString() + exclusions?: string; + + @IsOptional() + @IsDateString() + effectiveDate?: string; + + @IsOptional() + @IsDateString() + terminationDate?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateInsurancePlanDto { + @IsOptional() + @IsString() + @MaxLength(50) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['individual', 'family', 'group', 'medicare', 'medicaid', 'other']) + coverageType?: CoverageType; + + @IsOptional() + @ValidateNested() + @Type(() => CoverageDetailsDto) + coveragePercentage?: CoverageDetailsDto; + + @IsOptional() + @ValidateNested() + @Type(() => DeductibleInfoDto) + deductible?: DeductibleInfoDto; + + @IsOptional() + @ValidateNested() + @Type(() => CopayInfoDto) + copay?: CopayInfoDto; + + @IsOptional() + @ValidateNested() + @Type(() => OutOfPocketMaxDto) + outOfPocketMax?: OutOfPocketMaxDto; + + @IsOptional() + @IsNumber() + @Min(0) + coinsurancePercentage?: number; + + @IsOptional() + @IsNumber() + @Min(0) + annualBenefitMax?: number; + + @IsOptional() + @IsNumber() + @Min(0) + lifetimeBenefitMax?: number; + + @IsOptional() + @IsInt() + @Min(0) + waitingPeriodDays?: number; + + @IsOptional() + @IsBoolean() + preauthorizationRequired?: boolean; + + @IsOptional() + @IsBoolean() + referralRequired?: boolean; + + @IsOptional() + @IsString() + networkRestrictions?: string; + + @IsOptional() + @IsString() + exclusions?: string; + + @IsOptional() + @IsDateString() + effectiveDate?: string; + + @IsOptional() + @IsDateString() + terminationDate?: string; + + @IsOptional() + @IsEnum(['active', 'inactive', 'discontinued']) + status?: InsurancePlanStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +export class InsurancePlanQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsUUID() + insuranceCompanyId?: string; + + @IsOptional() + @IsEnum(['individual', 'family', 'group', 'medicare', 'medicaid', 'other']) + coverageType?: CoverageType; + + @IsOptional() + @IsEnum(['active', 'inactive', 'discontinued']) + status?: InsurancePlanStatus; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Subscriber Info DTO +export class SubscriberInfoDto { + @IsOptional() + @IsString() + @MaxLength(100) + firstName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + lastName?: string; + + @IsOptional() + @IsDateString() + dateOfBirth?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + gender?: string; + + @IsOptional() + @IsString() + @MaxLength(300) + address?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + ssn?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + employerId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + employerName?: string; +} + +// Patient Insurance DTOs +export class CreatePatientInsuranceDto { + @IsUUID() + patientId: string; + + @IsUUID() + insurancePlanId: string; + + @IsString() + @MaxLength(100) + policyNumber: string; + + @IsOptional() + @IsString() + @MaxLength(100) + groupNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + memberId?: string; + + @IsOptional() + @IsEnum(['primary', 'secondary', 'tertiary']) + priority?: InsurancePriority; + + @IsOptional() + @IsEnum(['self', 'spouse', 'child', 'dependent', 'other']) + relationshipToSubscriber?: RelationshipToSubscriber; + + @IsOptional() + @ValidateNested() + @Type(() => SubscriberInfoDto) + subscriberInfo?: SubscriberInfoDto; + + @IsDateString() + effectiveDate: string; + + @IsOptional() + @IsDateString() + terminationDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + copayAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + coinsurancePercentage?: number; + + @IsOptional() + @IsString() + @MaxLength(500) + cardFrontUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + cardBackUrl?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdatePatientInsuranceDto { + @IsOptional() + @IsString() + @MaxLength(100) + policyNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + groupNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + memberId?: string; + + @IsOptional() + @IsEnum(['primary', 'secondary', 'tertiary']) + priority?: InsurancePriority; + + @IsOptional() + @IsEnum(['self', 'spouse', 'child', 'dependent', 'other']) + relationshipToSubscriber?: RelationshipToSubscriber; + + @IsOptional() + @ValidateNested() + @Type(() => SubscriberInfoDto) + subscriberInfo?: SubscriberInfoDto; + + @IsOptional() + @IsDateString() + effectiveDate?: string; + + @IsOptional() + @IsDateString() + terminationDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + copayAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + coinsurancePercentage?: number; + + @IsOptional() + @IsString() + @MaxLength(500) + cardFrontUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + cardBackUrl?: string; + + @IsOptional() + @IsEnum(['active', 'inactive', 'pending_verification', 'expired', 'terminated']) + status?: PatientInsuranceStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +export class VerifyInsuranceDto { + @IsUUID() + verifiedBy: string; + + @IsOptional() + @IsString() + @MaxLength(100) + verificationReference?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class PatientInsuranceQueryDto { + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsUUID() + insurancePlanId?: string; + + @IsOptional() + @IsEnum(['primary', 'secondary', 'tertiary']) + priority?: InsurancePriority; + + @IsOptional() + @IsEnum(['active', 'inactive', 'pending_verification', 'expired', 'terminated']) + status?: PatientInsuranceStatus; + + @IsOptional() + @IsBoolean() + isVerified?: boolean; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Claim Line Item DTO +export class ClaimLineItemDto { + @IsDateString() + serviceDate: string; + + @IsString() + @MaxLength(20) + procedureCode: string; + + @IsOptional() + @IsString() + @MaxLength(300) + procedureDescription?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + diagnosisCodes?: string[]; + + @IsInt() + @Min(1) + units: number; + + @IsNumber() + @Min(0) + chargeAmount: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + modifiers?: string[]; + + @IsOptional() + @IsString() + @MaxLength(10) + placeOfService?: string; + + @IsOptional() + @IsUUID() + renderingProviderId?: string; +} + +// Insurance Claim DTOs +export class CreateInsuranceClaimDto { + @IsUUID() + patientId: string; + + @IsUUID() + patientInsuranceId: string; + + @IsOptional() + @IsUUID() + consultationId?: string; + + @IsOptional() + @IsUUID() + preauthorizationId?: string; + + @IsOptional() + @IsEnum(['professional', 'institutional', 'dental', 'pharmacy']) + claimType?: ClaimType; + + @IsDateString() + serviceDateFrom: string; + + @IsDateString() + serviceDateTo: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + diagnosisCodes?: string[]; + + @IsOptional() + @IsString() + @MaxLength(20) + primaryDiagnosisCode?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ClaimLineItemDto) + lineItems: ClaimLineItemDto[]; + + @IsOptional() + @IsUUID() + billingProviderId?: string; + + @IsOptional() + @IsUUID() + renderingProviderId?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + placeOfService?: string; + + @IsOptional() + @IsDateString() + filingDeadline?: string; + + @IsOptional() + @IsBoolean() + isSecondaryClaim?: boolean; + + @IsOptional() + @IsUUID() + primaryClaimId?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateInsuranceClaimDto { + @IsOptional() + @IsArray() + @IsString({ each: true }) + diagnosisCodes?: string[]; + + @IsOptional() + @IsString() + @MaxLength(20) + primaryDiagnosisCode?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ClaimLineItemDto) + lineItems?: ClaimLineItemDto[]; + + @IsOptional() + @IsUUID() + billingProviderId?: string; + + @IsOptional() + @IsUUID() + renderingProviderId?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + placeOfService?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class SubmitClaimDto { + @IsUUID() + submittedBy: string; +} + +export class ProcessClaimDto { + @IsEnum(['approved', 'denied', 'partial']) + decision: 'approved' | 'denied' | 'partial'; + + @IsOptional() + @IsNumber() + @Min(0) + allowedAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + paidAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + deniedAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + adjustmentAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + patientResponsibility?: number; + + @IsOptional() + @IsNumber() + @Min(0) + deductibleApplied?: number; + + @IsOptional() + @IsNumber() + @Min(0) + copayApplied?: number; + + @IsOptional() + @IsNumber() + @Min(0) + coinsuranceApplied?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + payerClaimNumber?: string; + + @IsOptional() + @IsString() + denialReasonCode?: string; + + @IsOptional() + @IsString() + denialReasonDescription?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class RecordEOBDto { + @IsDateString() + receivedDate: string; + + @IsOptional() + @IsDateString() + processedDate?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + checkNumber?: string; + + @IsOptional() + @IsDateString() + checkDate?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + eobDocumentUrl?: string; + + @IsOptional() + @IsEnum(['check', 'eft', 'virtual_card']) + paymentMethod?: 'check' | 'eft' | 'virtual_card'; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + remarkCodes?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + adjustmentReasonCodes?: string[]; +} + +export class VoidClaimDto { + @IsString() + @MaxLength(500) + reason: string; + + @IsUUID() + voidedBy: string; +} + +export class InsuranceClaimQueryDto { + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsUUID() + patientInsuranceId?: string; + + @IsOptional() + @IsEnum(['draft', 'submitted', 'pending', 'in_review', 'approved', 'denied', 'partial', 'appealed', 'paid', 'voided']) + status?: ClaimStatus; + + @IsOptional() + @IsEnum(['professional', 'institutional', 'dental', 'pharmacy']) + claimType?: ClaimType; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Requested Service DTO +export class RequestedServiceDto { + @IsOptional() + @IsString() + @MaxLength(20) + procedureCode?: string; + + @IsString() + @MaxLength(300) + procedureDescription: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + diagnosisCodes?: string[]; + + @IsOptional() + @IsInt() + @Min(1) + quantity?: number; + + @IsOptional() + @IsNumber() + @Min(0) + estimatedCost?: number; + + @IsOptional() + @IsString() + justification?: string; +} + +// Preauthorization DTOs +export class CreatePreauthorizationDto { + @IsUUID() + patientId: string; + + @IsUUID() + patientInsuranceId: string; + + @IsOptional() + @IsUUID() + consultationId?: string; + + @IsUUID() + requestingProviderId: string; + + @IsOptional() + @IsEnum(['procedure', 'admission', 'service', 'medication', 'equipment', 'referral']) + authType?: PreauthorizationType; + + @IsOptional() + @IsEnum(['routine', 'urgent', 'emergent']) + urgency?: UrgencyLevel; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RequestedServiceDto) + requestedServices: RequestedServiceDto[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + diagnosisCodes?: string[]; + + @IsOptional() + @IsString() + @MaxLength(20) + primaryDiagnosisCode?: string; + + @IsOptional() + @IsString() + clinicalJustification?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + supportingDocumentationUrls?: string[]; + + @IsDateString() + requestedDateFrom: string; + + @IsDateString() + requestedDateTo: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdatePreauthorizationDto { + @IsOptional() + @IsEnum(['procedure', 'admission', 'service', 'medication', 'equipment', 'referral']) + authType?: PreauthorizationType; + + @IsOptional() + @IsEnum(['routine', 'urgent', 'emergent']) + urgency?: UrgencyLevel; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RequestedServiceDto) + requestedServices?: RequestedServiceDto[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + diagnosisCodes?: string[]; + + @IsOptional() + @IsString() + @MaxLength(20) + primaryDiagnosisCode?: string; + + @IsOptional() + @IsString() + clinicalJustification?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + supportingDocumentationUrls?: string[]; + + @IsOptional() + @IsDateString() + requestedDateFrom?: string; + + @IsOptional() + @IsDateString() + requestedDateTo?: string; + + @IsOptional() + @IsDateString() + followUpDate?: string; + + @IsOptional() + @IsString() + followUpNotes?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class SubmitPreauthorizationDto { + @IsUUID() + submittedBy: string; +} + +export class ApprovalDetailsDto { + @IsOptional() + @IsInt() + @Min(0) + approvedUnits?: number; + + @IsOptional() + @IsNumber() + @Min(0) + approvedAmount?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + approvedProcedures?: string[]; + + @IsOptional() + @IsDateString() + approvedDateFrom?: string; + + @IsOptional() + @IsDateString() + approvedDateTo?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + approvedFacilities?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + approvedProviders?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + conditions?: string[]; +} + +export class ProcessPreauthorizationDto { + @IsEnum(['approved', 'denied']) + decision: 'approved' | 'denied'; + + @IsOptional() + @IsString() + @MaxLength(100) + payerAuthNumber?: string; + + @IsOptional() + @IsDateString() + effectiveDate?: string; + + @IsOptional() + @IsDateString() + expirationDate?: string; + + @IsOptional() + @ValidateNested() + @Type(() => ApprovalDetailsDto) + approvalDetails?: ApprovalDetailsDto; + + @IsOptional() + @IsString() + denialReasonCode?: string; + + @IsOptional() + @IsString() + denialReasonDescription?: string; + + @IsOptional() + @IsString() + appealInstructions?: string; + + @IsOptional() + @IsDateString() + appealDeadline?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class CancelPreauthorizationDto { + @IsString() + @MaxLength(500) + reason: string; + + @IsUUID() + cancelledBy: string; +} + +export class PreauthorizationQueryDto { + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsUUID() + patientInsuranceId?: string; + + @IsOptional() + @IsEnum(['draft', 'submitted', 'pending', 'approved', 'denied', 'expired', 'cancelled']) + status?: PreauthorizationStatus; + + @IsOptional() + @IsEnum(['procedure', 'admission', 'service', 'medication', 'equipment', 'referral']) + authType?: PreauthorizationType; + + @IsOptional() + @IsEnum(['routine', 'urgent', 'emergent']) + urgency?: UrgencyLevel; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Coverage Verification DTO +export class VerifyCoverageDto { + @IsUUID() + patientInsuranceId: string; + + @IsOptional() + @IsString() + @MaxLength(20) + procedureCode?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + diagnosisCode?: string; + + @IsOptional() + @IsNumber() + @Min(0) + estimatedAmount?: number; +} + +export interface CoverageVerificationResult { + isCovered: boolean; + coveragePercentage?: number; + estimatedCopay?: number; + estimatedCoinsurance?: number; + deductibleRemaining?: number; + outOfPocketRemaining?: number; + preauthorizationRequired: boolean; + referralRequired: boolean; + networkStatus?: 'in_network' | 'out_of_network' | 'unknown'; + limitations?: string[]; + exclusions?: string[]; +} diff --git a/src/modules/insurance/entities/index.ts b/src/modules/insurance/entities/index.ts new file mode 100644 index 0000000..be0281b --- /dev/null +++ b/src/modules/insurance/entities/index.ts @@ -0,0 +1,48 @@ +export { + InsuranceCompany, + InsuranceCompanyStatus, + InsuranceCompanyType, + ContactInfo, + AddressInfo, +} from './insurance-company.entity'; + +export { + InsurancePlan, + InsurancePlanStatus, + CoverageType, + CoverageDetails, + DeductibleInfo, + CopayInfo, + OutOfPocketMax, +} from './insurance-plan.entity'; + +export { + PatientInsurance, + PatientInsuranceStatus, + InsurancePriority, + RelationshipToSubscriber, + SubscriberInfo, + DeductibleUsage, + BenefitUsage, +} from './patient-insurance.entity'; + +export { + InsuranceClaim, + ClaimStatus, + ClaimType, + ClaimLineItem, + EOBInfo, + DenialInfo, + ClaimAdjustment, +} from './insurance-claim.entity'; + +export { + Preauthorization, + PreauthorizationStatus, + PreauthorizationType, + UrgencyLevel, + RequestedService, + ApprovalDetails, + DenialDetails, + StatusHistory, +} from './preauthorization.entity'; diff --git a/src/modules/insurance/entities/insurance-claim.entity.ts b/src/modules/insurance/entities/insurance-claim.entity.ts new file mode 100644 index 0000000..509dac1 --- /dev/null +++ b/src/modules/insurance/entities/insurance-claim.entity.ts @@ -0,0 +1,184 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PatientInsurance } from './patient-insurance.entity'; + +export type ClaimStatus = 'draft' | 'submitted' | 'pending' | 'in_review' | 'approved' | 'denied' | 'partial' | 'appealed' | 'paid' | 'voided'; +export type ClaimType = 'professional' | 'institutional' | 'dental' | 'pharmacy'; + +export interface ClaimLineItem { + id: string; + serviceDate: string; + procedureCode: string; + procedureDescription?: string; + diagnosisCodes?: string[]; + units: number; + chargeAmount: number; + allowedAmount?: number; + paidAmount?: number; + deniedAmount?: number; + adjustmentAmount?: number; + adjustmentReasonCode?: string; + modifiers?: string[]; + placeOfService?: string; + renderingProviderId?: string; +} + +export interface EOBInfo { + receivedDate?: string; + processedDate?: string; + checkNumber?: string; + checkDate?: string; + eobDocumentUrl?: string; + paymentMethod?: 'check' | 'eft' | 'virtual_card'; + remarkCodes?: string[]; + adjustmentReasonCodes?: string[]; +} + +export interface DenialInfo { + reasonCode?: string; + reasonDescription?: string; + appealDeadline?: string; + additionalInfoRequired?: string[]; +} + +export interface ClaimAdjustment { + date: string; + type: 'correction' | 'void' | 'resubmission'; + reason: string; + adjustedBy: string; + previousStatus: ClaimStatus; + newStatus: ClaimStatus; +} + +@Entity({ name: 'insurance_claims', schema: 'clinica' }) +export class InsuranceClaim { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'claim_number', type: 'varchar', length: 50 }) + claimNumber: string; + + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Index() + @Column({ name: 'patient_insurance_id', type: 'uuid' }) + patientInsuranceId: string; + + @ManyToOne(() => PatientInsurance) + @JoinColumn({ name: 'patient_insurance_id' }) + patientInsurance: PatientInsurance; + + @Column({ name: 'consultation_id', type: 'uuid', nullable: true }) + consultationId?: string; + + @Column({ name: 'preauthorization_id', type: 'uuid', nullable: true }) + preauthorizationId?: string; + + @Column({ name: 'claim_type', type: 'enum', enum: ['professional', 'institutional', 'dental', 'pharmacy'], default: 'professional' }) + claimType: ClaimType; + + @Column({ type: 'enum', enum: ['draft', 'submitted', 'pending', 'in_review', 'approved', 'denied', 'partial', 'appealed', 'paid', 'voided'], default: 'draft' }) + status: ClaimStatus; + + @Column({ name: 'service_date_from', type: 'date' }) + serviceDateFrom: Date; + + @Column({ name: 'service_date_to', type: 'date' }) + serviceDateTo: Date; + + @Column({ name: 'diagnosis_codes', type: 'jsonb', nullable: true }) + diagnosisCodes?: string[]; + + @Column({ name: 'primary_diagnosis_code', type: 'varchar', length: 20, nullable: true }) + primaryDiagnosisCode?: string; + + @Column({ name: 'line_items', type: 'jsonb' }) + lineItems: ClaimLineItem[]; + + @Column({ name: 'total_charge_amount', type: 'decimal', precision: 12, scale: 2 }) + totalChargeAmount: number; + + @Column({ name: 'total_allowed_amount', type: 'decimal', precision: 12, scale: 2, nullable: true }) + totalAllowedAmount?: number; + + @Column({ name: 'total_paid_amount', type: 'decimal', precision: 12, scale: 2, nullable: true }) + totalPaidAmount?: number; + + @Column({ name: 'total_denied_amount', type: 'decimal', precision: 12, scale: 2, nullable: true }) + totalDeniedAmount?: number; + + @Column({ name: 'total_adjustment_amount', type: 'decimal', precision: 12, scale: 2, nullable: true }) + totalAdjustmentAmount?: number; + + @Column({ name: 'patient_responsibility', type: 'decimal', precision: 12, scale: 2, nullable: true }) + patientResponsibility?: number; + + @Column({ name: 'deductible_applied', type: 'decimal', precision: 10, scale: 2, nullable: true }) + deductibleApplied?: number; + + @Column({ name: 'copay_applied', type: 'decimal', precision: 10, scale: 2, nullable: true }) + copayApplied?: number; + + @Column({ name: 'coinsurance_applied', type: 'decimal', precision: 10, scale: 2, nullable: true }) + coinsuranceApplied?: number; + + @Column({ name: 'billing_provider_id', type: 'uuid', nullable: true }) + billingProviderId?: string; + + @Column({ name: 'rendering_provider_id', type: 'uuid', nullable: true }) + renderingProviderId?: string; + + @Column({ name: 'place_of_service', type: 'varchar', length: 10, nullable: true }) + placeOfService?: string; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt?: Date; + + @Column({ name: 'submitted_by', type: 'uuid', nullable: true }) + submittedBy?: string; + + @Column({ name: 'payer_claim_number', type: 'varchar', length: 100, nullable: true }) + payerClaimNumber?: string; + + @Column({ name: 'eob_info', type: 'jsonb', nullable: true }) + eobInfo?: EOBInfo; + + @Column({ name: 'denial_info', type: 'jsonb', nullable: true }) + denialInfo?: DenialInfo; + + @Column({ name: 'adjustments', type: 'jsonb', nullable: true }) + adjustments?: ClaimAdjustment[]; + + @Column({ name: 'filing_deadline', type: 'date', nullable: true }) + filingDeadline?: Date; + + @Column({ name: 'is_secondary_claim', type: 'boolean', default: false }) + isSecondaryClaim: boolean; + + @Column({ name: 'primary_claim_id', type: 'uuid', nullable: true }) + primaryClaimId?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/insurance/entities/insurance-company.entity.ts b/src/modules/insurance/entities/insurance-company.entity.ts new file mode 100644 index 0000000..dc6d862 --- /dev/null +++ b/src/modules/insurance/entities/insurance-company.entity.ts @@ -0,0 +1,107 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { InsurancePlan } from './insurance-plan.entity'; + +export type InsuranceCompanyStatus = 'active' | 'inactive' | 'suspended'; +export type InsuranceCompanyType = 'private' | 'government' | 'employer' | 'hmo' | 'ppo' | 'other'; + +export interface ContactInfo { + phone?: string; + email?: string; + fax?: string; + website?: string; + claimsPhone?: string; + claimsEmail?: string; + preAuthPhone?: string; + preAuthEmail?: string; +} + +export interface AddressInfo { + street?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; +} + +@Entity({ name: 'insurance_companies', schema: 'clinica' }) +export class InsuranceCompany { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'legal_name', type: 'varchar', length: 200, nullable: true }) + legalName?: string; + + @Column({ name: 'tax_id', type: 'varchar', length: 50, nullable: true }) + taxId?: string; + + @Column({ type: 'enum', enum: ['private', 'government', 'employer', 'hmo', 'ppo', 'other'], default: 'private' }) + type: InsuranceCompanyType; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'contact_info', type: 'jsonb', nullable: true }) + contactInfo?: ContactInfo; + + @Column({ name: 'address', type: 'jsonb', nullable: true }) + address?: AddressInfo; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress?: AddressInfo; + + @Column({ name: 'payer_id', type: 'varchar', length: 50, nullable: true }) + payerId?: string; + + @Column({ name: 'naic_code', type: 'varchar', length: 20, nullable: true }) + naicCode?: string; + + @Column({ name: 'average_payment_days', type: 'int', default: 30 }) + averagePaymentDays: number; + + @Column({ name: 'requires_preauthorization', type: 'boolean', default: false }) + requiresPreauthorization: boolean; + + @Column({ name: 'electronic_claims_enabled', type: 'boolean', default: false }) + electronicClaimsEnabled: boolean; + + @Column({ name: 'logo_url', type: 'varchar', length: 500, nullable: true }) + logoUrl?: string; + + @Column({ type: 'enum', enum: ['active', 'inactive', 'suspended'], default: 'active' }) + status: InsuranceCompanyStatus; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @OneToMany(() => InsurancePlan, (plan) => plan.insuranceCompany) + plans?: InsurancePlan[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/insurance/entities/insurance-plan.entity.ts b/src/modules/insurance/entities/insurance-plan.entity.ts new file mode 100644 index 0000000..0948805 --- /dev/null +++ b/src/modules/insurance/entities/insurance-plan.entity.ts @@ -0,0 +1,148 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { InsuranceCompany } from './insurance-company.entity'; +import { PatientInsurance } from './patient-insurance.entity'; + +export type InsurancePlanStatus = 'active' | 'inactive' | 'discontinued'; +export type CoverageType = 'individual' | 'family' | 'group' | 'medicare' | 'medicaid' | 'other'; + +export interface CoverageDetails { + consultations?: number; + hospitalizations?: number; + emergencies?: number; + surgeries?: number; + laboratory?: number; + imaging?: number; + pharmacy?: number; + dental?: number; + vision?: number; + mentalHealth?: number; + preventiveCare?: number; +} + +export interface DeductibleInfo { + individual?: number; + family?: number; + inNetwork?: number; + outOfNetwork?: number; +} + +export interface CopayInfo { + primaryCare?: number; + specialist?: number; + urgentCare?: number; + emergency?: number; + laboratory?: number; + imaging?: number; + genericDrugs?: number; + brandDrugs?: number; + specialtyDrugs?: number; +} + +export interface OutOfPocketMax { + individual?: number; + family?: number; + inNetwork?: number; + outOfNetwork?: number; +} + +@Entity({ name: 'insurance_plans', schema: 'clinica' }) +export class InsurancePlan { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'insurance_company_id', type: 'uuid' }) + insuranceCompanyId: string; + + @ManyToOne(() => InsuranceCompany, (company) => company.plans) + @JoinColumn({ name: 'insurance_company_id' }) + insuranceCompany: InsuranceCompany; + + @Index() + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'coverage_type', type: 'enum', enum: ['individual', 'family', 'group', 'medicare', 'medicaid', 'other'], default: 'individual' }) + coverageType: CoverageType; + + @Column({ name: 'coverage_percentage', type: 'jsonb', nullable: true }) + coveragePercentage?: CoverageDetails; + + @Column({ type: 'jsonb', nullable: true }) + deductible?: DeductibleInfo; + + @Column({ type: 'jsonb', nullable: true }) + copay?: CopayInfo; + + @Column({ name: 'out_of_pocket_max', type: 'jsonb', nullable: true }) + outOfPocketMax?: OutOfPocketMax; + + @Column({ name: 'coinsurance_percentage', type: 'decimal', precision: 5, scale: 2, nullable: true }) + coinsurancePercentage?: number; + + @Column({ name: 'annual_benefit_max', type: 'decimal', precision: 12, scale: 2, nullable: true }) + annualBenefitMax?: number; + + @Column({ name: 'lifetime_benefit_max', type: 'decimal', precision: 15, scale: 2, nullable: true }) + lifetimeBenefitMax?: number; + + @Column({ name: 'waiting_period_days', type: 'int', default: 0 }) + waitingPeriodDays: number; + + @Column({ name: 'preauthorization_required', type: 'boolean', default: false }) + preauthorizationRequired: boolean; + + @Column({ name: 'referral_required', type: 'boolean', default: false }) + referralRequired: boolean; + + @Column({ name: 'network_restrictions', type: 'text', nullable: true }) + networkRestrictions?: string; + + @Column({ name: 'exclusions', type: 'text', nullable: true }) + exclusions?: string; + + @Column({ name: 'effective_date', type: 'date', nullable: true }) + effectiveDate?: Date; + + @Column({ name: 'termination_date', type: 'date', nullable: true }) + terminationDate?: Date; + + @Column({ type: 'enum', enum: ['active', 'inactive', 'discontinued'], default: 'active' }) + status: InsurancePlanStatus; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @OneToMany(() => PatientInsurance, (patientInsurance) => patientInsurance.insurancePlan) + patientPolicies?: PatientInsurance[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/insurance/entities/patient-insurance.entity.ts b/src/modules/insurance/entities/patient-insurance.entity.ts new file mode 100644 index 0000000..add7f28 --- /dev/null +++ b/src/modules/insurance/entities/patient-insurance.entity.ts @@ -0,0 +1,132 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { InsurancePlan } from './insurance-plan.entity'; + +export type PatientInsuranceStatus = 'active' | 'inactive' | 'pending_verification' | 'expired' | 'terminated'; +export type InsurancePriority = 'primary' | 'secondary' | 'tertiary'; +export type RelationshipToSubscriber = 'self' | 'spouse' | 'child' | 'dependent' | 'other'; + +export interface SubscriberInfo { + firstName?: string; + lastName?: string; + dateOfBirth?: string; + gender?: string; + address?: string; + phone?: string; + ssn?: string; + employerId?: string; + employerName?: string; +} + +export interface DeductibleUsage { + usedAmount: number; + remainingAmount: number; + resetDate?: string; + lastUpdated: string; +} + +export interface BenefitUsage { + usedAmount: number; + remainingAmount: number; + resetDate?: string; + lastUpdated: string; +} + +@Entity({ name: 'patient_insurance', schema: 'clinica' }) +export class PatientInsurance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Index() + @Column({ name: 'insurance_plan_id', type: 'uuid' }) + insurancePlanId: string; + + @ManyToOne(() => InsurancePlan, (plan) => plan.patientPolicies) + @JoinColumn({ name: 'insurance_plan_id' }) + insurancePlan: InsurancePlan; + + @Column({ name: 'policy_number', type: 'varchar', length: 100 }) + policyNumber: string; + + @Column({ name: 'group_number', type: 'varchar', length: 100, nullable: true }) + groupNumber?: string; + + @Column({ name: 'member_id', type: 'varchar', length: 100, nullable: true }) + memberId?: string; + + @Column({ type: 'enum', enum: ['primary', 'secondary', 'tertiary'], default: 'primary' }) + priority: InsurancePriority; + + @Column({ name: 'relationship_to_subscriber', type: 'enum', enum: ['self', 'spouse', 'child', 'dependent', 'other'], default: 'self' }) + relationshipToSubscriber: RelationshipToSubscriber; + + @Column({ name: 'subscriber_info', type: 'jsonb', nullable: true }) + subscriberInfo?: SubscriberInfo; + + @Column({ name: 'effective_date', type: 'date' }) + effectiveDate: Date; + + @Column({ name: 'termination_date', type: 'date', nullable: true }) + terminationDate?: Date; + + @Column({ name: 'deductible_usage', type: 'jsonb', nullable: true }) + deductibleUsage?: DeductibleUsage; + + @Column({ name: 'out_of_pocket_usage', type: 'jsonb', nullable: true }) + outOfPocketUsage?: BenefitUsage; + + @Column({ name: 'benefit_usage', type: 'jsonb', nullable: true }) + benefitUsage?: BenefitUsage; + + @Column({ name: 'copay_amount', type: 'decimal', precision: 10, scale: 2, nullable: true }) + copayAmount?: number; + + @Column({ name: 'coinsurance_percentage', type: 'decimal', precision: 5, scale: 2, nullable: true }) + coinsurancePercentage?: number; + + @Column({ name: 'verification_date', type: 'timestamptz', nullable: true }) + verificationDate?: Date; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedBy?: string; + + @Column({ name: 'verification_reference', type: 'varchar', length: 100, nullable: true }) + verificationReference?: string; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'card_front_url', type: 'varchar', length: 500, nullable: true }) + cardFrontUrl?: string; + + @Column({ name: 'card_back_url', type: 'varchar', length: 500, nullable: true }) + cardBackUrl?: string; + + @Column({ type: 'enum', enum: ['active', 'inactive', 'pending_verification', 'expired', 'terminated'], default: 'pending_verification' }) + status: PatientInsuranceStatus; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/insurance/entities/preauthorization.entity.ts b/src/modules/insurance/entities/preauthorization.entity.ts new file mode 100644 index 0000000..ac5cb25 --- /dev/null +++ b/src/modules/insurance/entities/preauthorization.entity.ts @@ -0,0 +1,162 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PatientInsurance } from './patient-insurance.entity'; + +export type PreauthorizationStatus = 'draft' | 'submitted' | 'pending' | 'approved' | 'denied' | 'expired' | 'cancelled'; +export type PreauthorizationType = 'procedure' | 'admission' | 'service' | 'medication' | 'equipment' | 'referral'; +export type UrgencyLevel = 'routine' | 'urgent' | 'emergent'; + +export interface RequestedService { + procedureCode?: string; + procedureDescription: string; + diagnosisCodes?: string[]; + quantity?: number; + estimatedCost?: number; + justification?: string; +} + +export interface ApprovalDetails { + approvedUnits?: number; + approvedAmount?: number; + approvedProcedures?: string[]; + approvedDateRange?: { + from: string; + to: string; + }; + approvedFacilities?: string[]; + approvedProviders?: string[]; + conditions?: string[]; +} + +export interface DenialDetails { + reasonCode?: string; + reasonDescription?: string; + appealInstructions?: string; + appealDeadline?: string; + alternativesToConcider?: string[]; +} + +export interface StatusHistory { + status: PreauthorizationStatus; + changedAt: string; + changedBy?: string; + notes?: string; +} + +@Entity({ name: 'preauthorizations', schema: 'clinica' }) +export class Preauthorization { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'auth_number', type: 'varchar', length: 50 }) + authNumber: string; + + @Index() + @Column({ name: 'patient_id', type: 'uuid' }) + patientId: string; + + @Index() + @Column({ name: 'patient_insurance_id', type: 'uuid' }) + patientInsuranceId: string; + + @ManyToOne(() => PatientInsurance) + @JoinColumn({ name: 'patient_insurance_id' }) + patientInsurance: PatientInsurance; + + @Column({ name: 'consultation_id', type: 'uuid', nullable: true }) + consultationId?: string; + + @Column({ name: 'requesting_provider_id', type: 'uuid' }) + requestingProviderId: string; + + @Column({ name: 'auth_type', type: 'enum', enum: ['procedure', 'admission', 'service', 'medication', 'equipment', 'referral'], default: 'procedure' }) + authType: PreauthorizationType; + + @Column({ type: 'enum', enum: ['draft', 'submitted', 'pending', 'approved', 'denied', 'expired', 'cancelled'], default: 'draft' }) + status: PreauthorizationStatus; + + @Column({ type: 'enum', enum: ['routine', 'urgent', 'emergent'], default: 'routine' }) + urgency: UrgencyLevel; + + @Column({ name: 'requested_services', type: 'jsonb' }) + requestedServices: RequestedService[]; + + @Column({ name: 'diagnosis_codes', type: 'jsonb', nullable: true }) + diagnosisCodes?: string[]; + + @Column({ name: 'primary_diagnosis_code', type: 'varchar', length: 20, nullable: true }) + primaryDiagnosisCode?: string; + + @Column({ name: 'clinical_justification', type: 'text', nullable: true }) + clinicalJustification?: string; + + @Column({ name: 'supporting_documentation_urls', type: 'jsonb', nullable: true }) + supportingDocumentationUrls?: string[]; + + @Column({ name: 'requested_date_from', type: 'date' }) + requestedDateFrom: Date; + + @Column({ name: 'requested_date_to', type: 'date' }) + requestedDateTo: Date; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt?: Date; + + @Column({ name: 'submitted_by', type: 'uuid', nullable: true }) + submittedBy?: string; + + @Column({ name: 'payer_auth_number', type: 'varchar', length: 100, nullable: true }) + payerAuthNumber?: string; + + @Column({ name: 'payer_contact_name', type: 'varchar', length: 200, nullable: true }) + payerContactName?: string; + + @Column({ name: 'payer_reference_number', type: 'varchar', length: 100, nullable: true }) + payerReferenceNumber?: string; + + @Column({ name: 'decision_date', type: 'timestamptz', nullable: true }) + decisionDate?: Date; + + @Column({ name: 'effective_date', type: 'date', nullable: true }) + effectiveDate?: Date; + + @Column({ name: 'expiration_date', type: 'date', nullable: true }) + expirationDate?: Date; + + @Column({ name: 'approval_details', type: 'jsonb', nullable: true }) + approvalDetails?: ApprovalDetails; + + @Column({ name: 'denial_details', type: 'jsonb', nullable: true }) + denialDetails?: DenialDetails; + + @Column({ name: 'status_history', type: 'jsonb', nullable: true }) + statusHistory?: StatusHistory[]; + + @Column({ name: 'follow_up_date', type: 'date', nullable: true }) + followUpDate?: Date; + + @Column({ name: 'follow_up_notes', type: 'text', nullable: true }) + followUpNotes?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/insurance/index.ts b/src/modules/insurance/index.ts new file mode 100644 index 0000000..fed59f0 --- /dev/null +++ b/src/modules/insurance/index.ts @@ -0,0 +1,86 @@ +export { InsuranceModule, InsuranceModuleOptions } from './insurance.module'; + +export { + InsuranceCompany, + InsuranceCompanyStatus, + InsuranceCompanyType, + ContactInfo, + AddressInfo, + InsurancePlan, + InsurancePlanStatus, + CoverageType, + CoverageDetails, + DeductibleInfo, + CopayInfo, + OutOfPocketMax, + PatientInsurance, + PatientInsuranceStatus, + InsurancePriority, + RelationshipToSubscriber, + SubscriberInfo, + DeductibleUsage, + BenefitUsage, + InsuranceClaim, + ClaimStatus, + ClaimType, + ClaimLineItem, + EOBInfo, + DenialInfo, + ClaimAdjustment, + Preauthorization, + PreauthorizationStatus, + PreauthorizationType, + UrgencyLevel, + RequestedService, + ApprovalDetails, + DenialDetails, + StatusHistory, +} from './entities'; + +export { + InsuranceCompanyService, + InsurancePlanService, + PatientInsuranceService, + ClaimService, + PreauthorizationService, +} from './services'; + +export { InsuranceController } from './controllers'; + +export { + CreateInsuranceCompanyDto, + UpdateInsuranceCompanyDto, + InsuranceCompanyQueryDto, + CreateInsurancePlanDto, + UpdateInsurancePlanDto, + InsurancePlanQueryDto, + CreatePatientInsuranceDto, + UpdatePatientInsuranceDto, + VerifyInsuranceDto, + PatientInsuranceQueryDto, + VerifyCoverageDto, + CoverageVerificationResult, + CreateInsuranceClaimDto, + UpdateInsuranceClaimDto, + SubmitClaimDto, + ProcessClaimDto, + RecordEOBDto, + VoidClaimDto, + InsuranceClaimQueryDto, + CreatePreauthorizationDto, + UpdatePreauthorizationDto, + SubmitPreauthorizationDto, + ProcessPreauthorizationDto, + CancelPreauthorizationDto, + PreauthorizationQueryDto, + ContactInfoDto, + AddressInfoDto, + CoverageDetailsDto, + DeductibleInfoDto, + CopayInfoDto, + OutOfPocketMaxDto, + SubscriberInfoDto, + ClaimLineItemDto, + RequestedServiceDto, + ApprovalDetailsDto, +} from './dto'; diff --git a/src/modules/insurance/insurance.module.ts b/src/modules/insurance/insurance.module.ts new file mode 100644 index 0000000..a89fb23 --- /dev/null +++ b/src/modules/insurance/insurance.module.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { InsuranceController } from './controllers'; + +export interface InsuranceModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class InsuranceModule { + public router: Router; + private controller: InsuranceController; + + constructor(options: InsuranceModuleOptions) { + const { dataSource, basePath = '/api' } = options; + this.controller = new InsuranceController(dataSource, basePath); + this.router = this.controller.router; + } +} diff --git a/src/modules/insurance/services/claim.service.ts b/src/modules/insurance/services/claim.service.ts new file mode 100644 index 0000000..0cd7db7 --- /dev/null +++ b/src/modules/insurance/services/claim.service.ts @@ -0,0 +1,370 @@ +import { DataSource, Repository } from 'typeorm'; +import { randomUUID } from 'crypto'; +import { InsuranceClaim, PatientInsurance, ClaimStatus } from '../entities'; +import { + CreateInsuranceClaimDto, + UpdateInsuranceClaimDto, + SubmitClaimDto, + ProcessClaimDto, + RecordEOBDto, + VoidClaimDto, + InsuranceClaimQueryDto, +} from '../dto'; + +export class ClaimService { + private repository: Repository; + private patientInsuranceRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(InsuranceClaim); + this.patientInsuranceRepository = dataSource.getRepository(PatientInsurance); + } + + async findAll(tenantId: string, query: InsuranceClaimQueryDto): Promise<{ data: InsuranceClaim[]; total: number }> { + const { patientId, patientInsuranceId, status, claimType, dateFrom, dateTo, page = 1, limit = 20 } = query; + + const queryBuilder = this.repository.createQueryBuilder('claim') + .leftJoinAndSelect('claim.patientInsurance', 'pi') + .leftJoinAndSelect('pi.insurancePlan', 'plan') + .leftJoinAndSelect('plan.insuranceCompany', 'company') + .where('claim.tenant_id = :tenantId', { tenantId }); + + if (patientId) { + queryBuilder.andWhere('claim.patient_id = :patientId', { patientId }); + } + + if (patientInsuranceId) { + queryBuilder.andWhere('claim.patient_insurance_id = :patientInsuranceId', { patientInsuranceId }); + } + + if (status) { + queryBuilder.andWhere('claim.status = :status', { status }); + } + + if (claimType) { + queryBuilder.andWhere('claim.claim_type = :claimType', { claimType }); + } + + if (dateFrom) { + queryBuilder.andWhere('claim.service_date_from >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('claim.service_date_to <= :dateTo', { dateTo }); + } + + queryBuilder + .orderBy('claim.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['patientInsurance', 'patientInsurance.insurancePlan', 'patientInsurance.insurancePlan.insuranceCompany'], + }); + } + + async findByClaimNumber(tenantId: string, claimNumber: string): Promise { + return this.repository.findOne({ + where: { claimNumber, tenantId }, + relations: ['patientInsurance', 'patientInsurance.insurancePlan'], + }); + } + + async findByPatient(tenantId: string, patientId: string, limit: number = 10): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + relations: ['patientInsurance', 'patientInsurance.insurancePlan', 'patientInsurance.insurancePlan.insuranceCompany'], + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + async create(tenantId: string, dto: CreateInsuranceClaimDto): Promise { + const patientInsurance = await this.patientInsuranceRepository.findOne({ + where: { id: dto.patientInsuranceId, tenantId }, + }); + + if (!patientInsurance) { + throw new Error('Patient insurance not found'); + } + + const claimNumber = await this.generateClaimNumber(tenantId); + + const lineItemsWithIds = dto.lineItems.map(item => ({ + ...item, + id: randomUUID(), + })); + + const totalChargeAmount = lineItemsWithIds.reduce( + (sum, item) => sum + (item.chargeAmount * item.units), + 0 + ); + + const claim = this.repository.create({ + tenantId, + claimNumber, + patientId: dto.patientId, + patientInsuranceId: dto.patientInsuranceId, + consultationId: dto.consultationId, + preauthorizationId: dto.preauthorizationId, + claimType: dto.claimType || 'professional', + serviceDateFrom: dto.serviceDateFrom as any, + serviceDateTo: dto.serviceDateTo as any, + diagnosisCodes: dto.diagnosisCodes, + primaryDiagnosisCode: dto.primaryDiagnosisCode, + lineItems: lineItemsWithIds, + totalChargeAmount, + billingProviderId: dto.billingProviderId, + renderingProviderId: dto.renderingProviderId, + placeOfService: dto.placeOfService, + filingDeadline: dto.filingDeadline as any, + isSecondaryClaim: dto.isSecondaryClaim || false, + primaryClaimId: dto.primaryClaimId, + notes: dto.notes, + status: 'draft', + }); + + return this.repository.save(claim); + } + + async update(tenantId: string, id: string, dto: UpdateInsuranceClaimDto): Promise { + const claim = await this.findById(tenantId, id); + if (!claim) return null; + + if (claim.status !== 'draft') { + throw new Error('Can only update claims in draft status'); + } + + if (dto.lineItems) { + const lineItemsWithIds = dto.lineItems.map(item => ({ + ...item, + id: randomUUID(), + })); + claim.lineItems = lineItemsWithIds; + claim.totalChargeAmount = lineItemsWithIds.reduce( + (sum, item) => sum + (item.chargeAmount * item.units), + 0 + ); + } + + if (dto.diagnosisCodes) claim.diagnosisCodes = dto.diagnosisCodes; + if (dto.primaryDiagnosisCode) claim.primaryDiagnosisCode = dto.primaryDiagnosisCode; + if (dto.billingProviderId) claim.billingProviderId = dto.billingProviderId; + if (dto.renderingProviderId) claim.renderingProviderId = dto.renderingProviderId; + if (dto.placeOfService) claim.placeOfService = dto.placeOfService; + if (dto.notes !== undefined) claim.notes = dto.notes; + + return this.repository.save(claim); + } + + async submit(tenantId: string, id: string, dto: SubmitClaimDto): Promise { + const claim = await this.findById(tenantId, id); + if (!claim) return null; + + if (claim.status !== 'draft') { + throw new Error('Can only submit claims in draft status'); + } + + claim.status = 'submitted'; + claim.submittedAt = new Date(); + claim.submittedBy = dto.submittedBy; + + this.addAdjustment(claim, 'correction', 'Claim submitted', dto.submittedBy, 'draft', 'submitted'); + + return this.repository.save(claim); + } + + async process(tenantId: string, id: string, dto: ProcessClaimDto): Promise { + const claim = await this.findById(tenantId, id); + if (!claim) return null; + + if (!['submitted', 'pending', 'in_review'].includes(claim.status)) { + throw new Error('Can only process claims in submitted, pending, or in_review status'); + } + + const previousStatus = claim.status; + + claim.status = dto.decision as ClaimStatus; + claim.totalAllowedAmount = dto.allowedAmount; + claim.totalPaidAmount = dto.paidAmount; + claim.totalDeniedAmount = dto.deniedAmount; + claim.totalAdjustmentAmount = dto.adjustmentAmount; + claim.patientResponsibility = dto.patientResponsibility; + claim.deductibleApplied = dto.deductibleApplied; + claim.copayApplied = dto.copayApplied; + claim.coinsuranceApplied = dto.coinsuranceApplied; + + if (dto.payerClaimNumber) { + claim.payerClaimNumber = dto.payerClaimNumber; + } + + if (dto.decision === 'denied') { + claim.denialInfo = { + reasonCode: dto.denialReasonCode, + reasonDescription: dto.denialReasonDescription, + }; + } + + if (dto.notes) { + claim.notes = claim.notes + ? `${claim.notes}\n\nProcessing note: ${dto.notes}` + : `Processing note: ${dto.notes}`; + } + + this.addAdjustment(claim, 'correction', `Claim processed: ${dto.decision}`, '', previousStatus, claim.status); + + return this.repository.save(claim); + } + + async recordEOB(tenantId: string, id: string, dto: RecordEOBDto): Promise { + const claim = await this.findById(tenantId, id); + if (!claim) return null; + + claim.eobInfo = { + receivedDate: dto.receivedDate, + processedDate: dto.processedDate, + checkNumber: dto.checkNumber, + checkDate: dto.checkDate, + eobDocumentUrl: dto.eobDocumentUrl, + paymentMethod: dto.paymentMethod, + remarkCodes: dto.remarkCodes, + adjustmentReasonCodes: dto.adjustmentReasonCodes, + }; + + if (claim.status === 'approved' || claim.status === 'partial') { + claim.status = 'paid'; + } + + return this.repository.save(claim); + } + + async appeal(tenantId: string, id: string, notes: string): Promise { + const claim = await this.findById(tenantId, id); + if (!claim) return null; + + if (claim.status !== 'denied') { + throw new Error('Can only appeal denied claims'); + } + + const previousStatus = claim.status; + claim.status = 'appealed'; + + claim.notes = claim.notes + ? `${claim.notes}\n\nAppeal: ${notes}` + : `Appeal: ${notes}`; + + this.addAdjustment(claim, 'resubmission', `Claim appealed: ${notes}`, '', previousStatus, 'appealed'); + + return this.repository.save(claim); + } + + async void(tenantId: string, id: string, dto: VoidClaimDto): Promise { + const claim = await this.findById(tenantId, id); + if (!claim) return null; + + if (claim.status === 'paid') { + throw new Error('Cannot void paid claims'); + } + + const previousStatus = claim.status; + claim.status = 'voided'; + + this.addAdjustment(claim, 'void', dto.reason, dto.voidedBy, previousStatus, 'voided'); + + return this.repository.save(claim); + } + + async getClaimsByStatus(tenantId: string): Promise<{ status: string; count: number; totalAmount: number }[]> { + const result = await this.repository.createQueryBuilder('claim') + .select('claim.status', 'status') + .addSelect('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(claim.total_charge_amount), 0)', 'totalAmount') + .where('claim.tenant_id = :tenantId', { tenantId }) + .groupBy('claim.status') + .getRawMany(); + + return result.map(r => ({ + status: r.status, + count: parseInt(r.count, 10), + totalAmount: parseFloat(r.totalAmount), + })); + } + + async getPendingClaimsCount(tenantId: string): Promise { + return this.repository.count({ + where: { tenantId, status: 'pending' }, + }); + } + + async getAgingReport(tenantId: string): Promise<{ ageRange: string; count: number; totalAmount: number }[]> { + const result = await this.repository.createQueryBuilder('claim') + .select(` + CASE + WHEN claim.submitted_at >= CURRENT_DATE - INTERVAL '30 days' THEN '0-30 days' + WHEN claim.submitted_at >= CURRENT_DATE - INTERVAL '60 days' THEN '31-60 days' + WHEN claim.submitted_at >= CURRENT_DATE - INTERVAL '90 days' THEN '61-90 days' + WHEN claim.submitted_at >= CURRENT_DATE - INTERVAL '120 days' THEN '91-120 days' + ELSE '120+ days' + END + `, 'ageRange') + .addSelect('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(claim.total_charge_amount), 0)', 'totalAmount') + .where('claim.tenant_id = :tenantId', { tenantId }) + .andWhere('claim.status IN (:...statuses)', { statuses: ['submitted', 'pending', 'in_review'] }) + .andWhere('claim.submitted_at IS NOT NULL') + .groupBy(` + CASE + WHEN claim.submitted_at >= CURRENT_DATE - INTERVAL '30 days' THEN '0-30 days' + WHEN claim.submitted_at >= CURRENT_DATE - INTERVAL '60 days' THEN '31-60 days' + WHEN claim.submitted_at >= CURRENT_DATE - INTERVAL '90 days' THEN '61-90 days' + WHEN claim.submitted_at >= CURRENT_DATE - INTERVAL '120 days' THEN '91-120 days' + ELSE '120+ days' + END + `) + .getRawMany(); + + return result.map(r => ({ + ageRange: r.ageRange, + count: parseInt(r.count, 10), + totalAmount: parseFloat(r.totalAmount), + })); + } + + private addAdjustment( + claim: InsuranceClaim, + type: 'correction' | 'void' | 'resubmission', + reason: string, + adjustedBy: string, + previousStatus: ClaimStatus, + newStatus: ClaimStatus + ): void { + claim.adjustments = claim.adjustments || []; + claim.adjustments.push({ + date: new Date().toISOString(), + type, + reason, + adjustedBy, + previousStatus, + newStatus, + }); + } + + private async generateClaimNumber(tenantId: string): Promise { + const today = new Date(); + const dateStr = today.toISOString().slice(0, 10).replace(/-/g, ''); + + const count = await this.repository.createQueryBuilder('claim') + .where('claim.tenant_id = :tenantId', { tenantId }) + .andWhere('DATE(claim.created_at) = CURRENT_DATE') + .getCount(); + + const sequence = (count + 1).toString().padStart(4, '0'); + return `CLM-${dateStr}-${sequence}`; + } +} diff --git a/src/modules/insurance/services/index.ts b/src/modules/insurance/services/index.ts new file mode 100644 index 0000000..2aec849 --- /dev/null +++ b/src/modules/insurance/services/index.ts @@ -0,0 +1,5 @@ +export { InsuranceCompanyService } from './insurance-company.service'; +export { InsurancePlanService } from './insurance-plan.service'; +export { PatientInsuranceService } from './patient-insurance.service'; +export { ClaimService } from './claim.service'; +export { PreauthorizationService } from './preauthorization.service'; diff --git a/src/modules/insurance/services/insurance-company.service.ts b/src/modules/insurance/services/insurance-company.service.ts new file mode 100644 index 0000000..17f98a4 --- /dev/null +++ b/src/modules/insurance/services/insurance-company.service.ts @@ -0,0 +1,112 @@ +import { DataSource, Repository } from 'typeorm'; +import { InsuranceCompany } from '../entities'; +import { CreateInsuranceCompanyDto, UpdateInsuranceCompanyDto, InsuranceCompanyQueryDto } from '../dto'; + +export class InsuranceCompanyService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(InsuranceCompany); + } + + async findAll(tenantId: string, query: InsuranceCompanyQueryDto): Promise<{ data: InsuranceCompany[]; total: number }> { + const { search, type, status, page = 1, limit = 50 } = query; + + const queryBuilder = this.repository.createQueryBuilder('company') + .where('company.tenant_id = :tenantId', { tenantId }) + .andWhere('company.deleted_at IS NULL'); + + if (type) { + queryBuilder.andWhere('company.type = :type', { type }); + } + + if (status) { + queryBuilder.andWhere('company.status = :status', { status }); + } + + if (search) { + queryBuilder.andWhere( + '(company.code ILIKE :search OR company.name ILIKE :search OR company.legal_name ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder + .orderBy('company.name', 'ASC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['plans'], + }); + } + + async findByCode(tenantId: string, code: string): Promise { + return this.repository.findOne({ + where: { code, tenantId }, + }); + } + + async findActive(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, status: 'active' }, + order: { name: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateInsuranceCompanyDto): Promise { + const existing = await this.findByCode(tenantId, dto.code); + if (existing) { + throw new Error(`Insurance company with code ${dto.code} already exists`); + } + + const company = this.repository.create({ + ...dto, + tenantId, + }); + return this.repository.save(company); + } + + async update(tenantId: string, id: string, dto: UpdateInsuranceCompanyDto): Promise { + const company = await this.findById(tenantId, id); + if (!company) return null; + + if (dto.code && dto.code !== company.code) { + const existing = await this.findByCode(tenantId, dto.code); + if (existing) { + throw new Error(`Insurance company with code ${dto.code} already exists`); + } + } + + Object.assign(company, dto); + return this.repository.save(company); + } + + async softDelete(tenantId: string, id: string): Promise { + const company = await this.findById(tenantId, id); + if (!company) return false; + + await this.repository.softDelete(id); + return true; + } + + async getByType(tenantId: string): Promise<{ type: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('company') + .select('company.type', 'type') + .addSelect('COUNT(*)', 'count') + .where('company.tenant_id = :tenantId', { tenantId }) + .andWhere('company.deleted_at IS NULL') + .andWhere('company.status = :status', { status: 'active' }) + .groupBy('company.type') + .orderBy('company.type', 'ASC') + .getRawMany(); + + return result.map(r => ({ type: r.type, count: parseInt(r.count, 10) })); + } +} diff --git a/src/modules/insurance/services/insurance-plan.service.ts b/src/modules/insurance/services/insurance-plan.service.ts new file mode 100644 index 0000000..4ab7184 --- /dev/null +++ b/src/modules/insurance/services/insurance-plan.service.ts @@ -0,0 +1,137 @@ +import { DataSource, Repository } from 'typeorm'; +import { InsurancePlan, InsuranceCompany } from '../entities'; +import { CreateInsurancePlanDto, UpdateInsurancePlanDto, InsurancePlanQueryDto } from '../dto'; + +export class InsurancePlanService { + private repository: Repository; + private companyRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(InsurancePlan); + this.companyRepository = dataSource.getRepository(InsuranceCompany); + } + + async findAll(tenantId: string, query: InsurancePlanQueryDto): Promise<{ data: InsurancePlan[]; total: number }> { + const { search, insuranceCompanyId, coverageType, status, page = 1, limit = 50 } = query; + + const queryBuilder = this.repository.createQueryBuilder('plan') + .leftJoinAndSelect('plan.insuranceCompany', 'company') + .where('plan.tenant_id = :tenantId', { tenantId }) + .andWhere('plan.deleted_at IS NULL'); + + if (insuranceCompanyId) { + queryBuilder.andWhere('plan.insurance_company_id = :insuranceCompanyId', { insuranceCompanyId }); + } + + if (coverageType) { + queryBuilder.andWhere('plan.coverage_type = :coverageType', { coverageType }); + } + + if (status) { + queryBuilder.andWhere('plan.status = :status', { status }); + } + + if (search) { + queryBuilder.andWhere( + '(plan.code ILIKE :search OR plan.name ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder + .orderBy('company.name', 'ASC') + .addOrderBy('plan.name', 'ASC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['insuranceCompany'], + }); + } + + async findByCode(tenantId: string, code: string): Promise { + return this.repository.findOne({ + where: { code, tenantId }, + relations: ['insuranceCompany'], + }); + } + + async findByCompany(tenantId: string, insuranceCompanyId: string): Promise { + return this.repository.find({ + where: { tenantId, insuranceCompanyId, status: 'active' }, + order: { name: 'ASC' }, + }); + } + + async findActive(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, status: 'active' }, + relations: ['insuranceCompany'], + order: { name: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateInsurancePlanDto): Promise { + const company = await this.companyRepository.findOne({ + where: { id: dto.insuranceCompanyId, tenantId }, + }); + + if (!company) { + throw new Error('Insurance company not found'); + } + + const existing = await this.findByCode(tenantId, dto.code); + if (existing) { + throw new Error(`Insurance plan with code ${dto.code} already exists`); + } + + const plan = this.repository.create({ + ...dto, + tenantId, + }); + return this.repository.save(plan); + } + + async update(tenantId: string, id: string, dto: UpdateInsurancePlanDto): Promise { + const plan = await this.findById(tenantId, id); + if (!plan) return null; + + if (dto.code && dto.code !== plan.code) { + const existing = await this.findByCode(tenantId, dto.code); + if (existing) { + throw new Error(`Insurance plan with code ${dto.code} already exists`); + } + } + + Object.assign(plan, dto); + return this.repository.save(plan); + } + + async softDelete(tenantId: string, id: string): Promise { + const plan = await this.findById(tenantId, id); + if (!plan) return false; + + await this.repository.softDelete(id); + return true; + } + + async getByCoverageType(tenantId: string): Promise<{ coverageType: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('plan') + .select('plan.coverage_type', 'coverageType') + .addSelect('COUNT(*)', 'count') + .where('plan.tenant_id = :tenantId', { tenantId }) + .andWhere('plan.deleted_at IS NULL') + .andWhere('plan.status = :status', { status: 'active' }) + .groupBy('plan.coverage_type') + .orderBy('plan.coverage_type', 'ASC') + .getRawMany(); + + return result.map(r => ({ coverageType: r.coverageType, count: parseInt(r.count, 10) })); + } +} diff --git a/src/modules/insurance/services/patient-insurance.service.ts b/src/modules/insurance/services/patient-insurance.service.ts new file mode 100644 index 0000000..5b2048c --- /dev/null +++ b/src/modules/insurance/services/patient-insurance.service.ts @@ -0,0 +1,271 @@ +import { DataSource, Repository } from 'typeorm'; +import { PatientInsurance, InsurancePlan } from '../entities'; +import { + CreatePatientInsuranceDto, + UpdatePatientInsuranceDto, + VerifyInsuranceDto, + PatientInsuranceQueryDto, + VerifyCoverageDto, + CoverageVerificationResult, +} from '../dto'; + +export class PatientInsuranceService { + private repository: Repository; + private planRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(PatientInsurance); + this.planRepository = dataSource.getRepository(InsurancePlan); + } + + async findAll(tenantId: string, query: PatientInsuranceQueryDto): Promise<{ data: PatientInsurance[]; total: number }> { + const { patientId, insurancePlanId, priority, status, isVerified, page = 1, limit = 50 } = query; + + const queryBuilder = this.repository.createQueryBuilder('pi') + .leftJoinAndSelect('pi.insurancePlan', 'plan') + .leftJoinAndSelect('plan.insuranceCompany', 'company') + .where('pi.tenant_id = :tenantId', { tenantId }); + + if (patientId) { + queryBuilder.andWhere('pi.patient_id = :patientId', { patientId }); + } + + if (insurancePlanId) { + queryBuilder.andWhere('pi.insurance_plan_id = :insurancePlanId', { insurancePlanId }); + } + + if (priority) { + queryBuilder.andWhere('pi.priority = :priority', { priority }); + } + + if (status) { + queryBuilder.andWhere('pi.status = :status', { status }); + } + + if (isVerified !== undefined) { + queryBuilder.andWhere('pi.is_verified = :isVerified', { isVerified }); + } + + queryBuilder + .orderBy('pi.priority', 'ASC') + .addOrderBy('pi.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['insurancePlan', 'insurancePlan.insuranceCompany'], + }); + } + + async findByPatient(tenantId: string, patientId: string): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + relations: ['insurancePlan', 'insurancePlan.insuranceCompany'], + order: { priority: 'ASC' }, + }); + } + + async findActiveByPatient(tenantId: string, patientId: string): Promise { + return this.repository.find({ + where: { tenantId, patientId, status: 'active' }, + relations: ['insurancePlan', 'insurancePlan.insuranceCompany'], + order: { priority: 'ASC' }, + }); + } + + async findPrimaryByPatient(tenantId: string, patientId: string): Promise { + return this.repository.findOne({ + where: { tenantId, patientId, priority: 'primary', status: 'active' }, + relations: ['insurancePlan', 'insurancePlan.insuranceCompany'], + }); + } + + async create(tenantId: string, dto: CreatePatientInsuranceDto): Promise { + const plan = await this.planRepository.findOne({ + where: { id: dto.insurancePlanId, tenantId }, + }); + + if (!plan) { + throw new Error('Insurance plan not found'); + } + + if (dto.priority === 'primary') { + const existingPrimary = await this.findPrimaryByPatient(tenantId, dto.patientId); + if (existingPrimary) { + existingPrimary.priority = 'secondary'; + await this.repository.save(existingPrimary); + } + } + + const patientInsurance = this.repository.create({ + ...dto, + tenantId, + status: 'pending_verification', + }); + return this.repository.save(patientInsurance); + } + + async update(tenantId: string, id: string, dto: UpdatePatientInsuranceDto): Promise { + const patientInsurance = await this.findById(tenantId, id); + if (!patientInsurance) return null; + + if (dto.priority === 'primary' && patientInsurance.priority !== 'primary') { + const existingPrimary = await this.findPrimaryByPatient(tenantId, patientInsurance.patientId); + if (existingPrimary && existingPrimary.id !== id) { + existingPrimary.priority = 'secondary'; + await this.repository.save(existingPrimary); + } + } + + Object.assign(patientInsurance, dto); + return this.repository.save(patientInsurance); + } + + async verify(tenantId: string, id: string, dto: VerifyInsuranceDto): Promise { + const patientInsurance = await this.findById(tenantId, id); + if (!patientInsurance) return null; + + patientInsurance.isVerified = true; + patientInsurance.verificationDate = new Date(); + patientInsurance.verifiedBy = dto.verifiedBy; + patientInsurance.verificationReference = dto.verificationReference; + patientInsurance.status = 'active'; + + if (dto.notes) { + patientInsurance.notes = patientInsurance.notes + ? `${patientInsurance.notes}\n\nVerification note: ${dto.notes}` + : `Verification note: ${dto.notes}`; + } + + return this.repository.save(patientInsurance); + } + + async terminate(tenantId: string, id: string, terminationDate?: Date): Promise { + const patientInsurance = await this.findById(tenantId, id); + if (!patientInsurance) return null; + + patientInsurance.status = 'terminated'; + patientInsurance.terminationDate = terminationDate || new Date(); + + return this.repository.save(patientInsurance); + } + + async verifyCoverage(tenantId: string, dto: VerifyCoverageDto): Promise { + const patientInsurance = await this.findById(tenantId, dto.patientInsuranceId); + if (!patientInsurance || !patientInsurance.insurancePlan) { + return { + isCovered: false, + preauthorizationRequired: false, + referralRequired: false, + limitations: ['Insurance policy not found or inactive'], + }; + } + + if (patientInsurance.status !== 'active') { + return { + isCovered: false, + preauthorizationRequired: false, + referralRequired: false, + limitations: [`Insurance status: ${patientInsurance.status}`], + }; + } + + const plan = patientInsurance.insurancePlan; + const coveragePercentage = plan.coveragePercentage?.consultations || 80; + const copay = plan.copay?.specialist || patientInsurance.copayAmount || 0; + const coinsurance = patientInsurance.coinsurancePercentage || plan.coinsurancePercentage || 20; + + const deductibleRemaining = patientInsurance.deductibleUsage?.remainingAmount; + const outOfPocketRemaining = patientInsurance.outOfPocketUsage?.remainingAmount; + + let estimatedCopay: number | undefined; + let estimatedCoinsurance: number | undefined; + + if (dto.estimatedAmount) { + estimatedCopay = copay; + estimatedCoinsurance = (dto.estimatedAmount - copay) * (coinsurance / 100); + } + + return { + isCovered: true, + coveragePercentage, + estimatedCopay, + estimatedCoinsurance, + deductibleRemaining, + outOfPocketRemaining, + preauthorizationRequired: plan.preauthorizationRequired, + referralRequired: plan.referralRequired, + networkStatus: 'in_network', + limitations: plan.networkRestrictions ? [plan.networkRestrictions] : undefined, + exclusions: plan.exclusions ? [plan.exclusions] : undefined, + }; + } + + async updateDeductibleUsage( + tenantId: string, + id: string, + usedAmount: number + ): Promise { + const patientInsurance = await this.findById(tenantId, id); + if (!patientInsurance) return null; + + const plan = patientInsurance.insurancePlan; + const deductibleMax = plan?.deductible?.individual || 0; + + patientInsurance.deductibleUsage = { + usedAmount: (patientInsurance.deductibleUsage?.usedAmount || 0) + usedAmount, + remainingAmount: Math.max(0, deductibleMax - ((patientInsurance.deductibleUsage?.usedAmount || 0) + usedAmount)), + lastUpdated: new Date().toISOString(), + }; + + return this.repository.save(patientInsurance); + } + + async updateOutOfPocketUsage( + tenantId: string, + id: string, + usedAmount: number + ): Promise { + const patientInsurance = await this.findById(tenantId, id); + if (!patientInsurance) return null; + + const plan = patientInsurance.insurancePlan; + const oopMax = plan?.outOfPocketMax?.individual || 0; + + patientInsurance.outOfPocketUsage = { + usedAmount: (patientInsurance.outOfPocketUsage?.usedAmount || 0) + usedAmount, + remainingAmount: Math.max(0, oopMax - ((patientInsurance.outOfPocketUsage?.usedAmount || 0) + usedAmount)), + lastUpdated: new Date().toISOString(), + }; + + return this.repository.save(patientInsurance); + } + + async getUnverifiedCount(tenantId: string): Promise { + return this.repository.count({ + where: { tenantId, isVerified: false, status: 'pending_verification' }, + }); + } + + async getExpiringPolicies(tenantId: string, daysAhead: number = 30): Promise { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + daysAhead); + + return this.repository.createQueryBuilder('pi') + .leftJoinAndSelect('pi.insurancePlan', 'plan') + .leftJoinAndSelect('plan.insuranceCompany', 'company') + .where('pi.tenant_id = :tenantId', { tenantId }) + .andWhere('pi.status = :status', { status: 'active' }) + .andWhere('pi.termination_date IS NOT NULL') + .andWhere('pi.termination_date <= :futureDate', { futureDate }) + .andWhere('pi.termination_date >= CURRENT_DATE') + .orderBy('pi.termination_date', 'ASC') + .getMany(); + } +} diff --git a/src/modules/insurance/services/preauthorization.service.ts b/src/modules/insurance/services/preauthorization.service.ts new file mode 100644 index 0000000..644dc93 --- /dev/null +++ b/src/modules/insurance/services/preauthorization.service.ts @@ -0,0 +1,347 @@ +import { DataSource, Repository } from 'typeorm'; +import { Preauthorization, PatientInsurance, PreauthorizationStatus } from '../entities'; +import { + CreatePreauthorizationDto, + UpdatePreauthorizationDto, + SubmitPreauthorizationDto, + ProcessPreauthorizationDto, + CancelPreauthorizationDto, + PreauthorizationQueryDto, +} from '../dto'; + +export class PreauthorizationService { + private repository: Repository; + private patientInsuranceRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(Preauthorization); + this.patientInsuranceRepository = dataSource.getRepository(PatientInsurance); + } + + async findAll(tenantId: string, query: PreauthorizationQueryDto): Promise<{ data: Preauthorization[]; total: number }> { + const { patientId, patientInsuranceId, status, authType, urgency, dateFrom, dateTo, page = 1, limit = 20 } = query; + + const queryBuilder = this.repository.createQueryBuilder('preauth') + .leftJoinAndSelect('preauth.patientInsurance', 'pi') + .leftJoinAndSelect('pi.insurancePlan', 'plan') + .leftJoinAndSelect('plan.insuranceCompany', 'company') + .where('preauth.tenant_id = :tenantId', { tenantId }); + + if (patientId) { + queryBuilder.andWhere('preauth.patient_id = :patientId', { patientId }); + } + + if (patientInsuranceId) { + queryBuilder.andWhere('preauth.patient_insurance_id = :patientInsuranceId', { patientInsuranceId }); + } + + if (status) { + queryBuilder.andWhere('preauth.status = :status', { status }); + } + + if (authType) { + queryBuilder.andWhere('preauth.auth_type = :authType', { authType }); + } + + if (urgency) { + queryBuilder.andWhere('preauth.urgency = :urgency', { urgency }); + } + + if (dateFrom) { + queryBuilder.andWhere('preauth.requested_date_from >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('preauth.requested_date_to <= :dateTo', { dateTo }); + } + + queryBuilder + .orderBy('preauth.urgency', 'DESC') + .addOrderBy('preauth.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['patientInsurance', 'patientInsurance.insurancePlan', 'patientInsurance.insurancePlan.insuranceCompany'], + }); + } + + async findByAuthNumber(tenantId: string, authNumber: string): Promise { + return this.repository.findOne({ + where: { authNumber, tenantId }, + relations: ['patientInsurance', 'patientInsurance.insurancePlan'], + }); + } + + async findByPatient(tenantId: string, patientId: string, limit: number = 10): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + relations: ['patientInsurance', 'patientInsurance.insurancePlan', 'patientInsurance.insurancePlan.insuranceCompany'], + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + async findActive(tenantId: string, patientId: string): Promise { + return this.repository.createQueryBuilder('preauth') + .leftJoinAndSelect('preauth.patientInsurance', 'pi') + .leftJoinAndSelect('pi.insurancePlan', 'plan') + .where('preauth.tenant_id = :tenantId', { tenantId }) + .andWhere('preauth.patient_id = :patientId', { patientId }) + .andWhere('preauth.status = :status', { status: 'approved' }) + .andWhere('(preauth.expiration_date IS NULL OR preauth.expiration_date >= CURRENT_DATE)') + .orderBy('preauth.expiration_date', 'ASC') + .getMany(); + } + + async create(tenantId: string, dto: CreatePreauthorizationDto): Promise { + const patientInsurance = await this.patientInsuranceRepository.findOne({ + where: { id: dto.patientInsuranceId, tenantId }, + }); + + if (!patientInsurance) { + throw new Error('Patient insurance not found'); + } + + const authNumber = await this.generateAuthNumber(tenantId); + + const preauth = this.repository.create({ + tenantId, + authNumber, + patientId: dto.patientId, + patientInsuranceId: dto.patientInsuranceId, + consultationId: dto.consultationId, + requestingProviderId: dto.requestingProviderId, + authType: dto.authType || 'procedure', + urgency: dto.urgency || 'routine', + requestedServices: dto.requestedServices, + diagnosisCodes: dto.diagnosisCodes, + primaryDiagnosisCode: dto.primaryDiagnosisCode, + clinicalJustification: dto.clinicalJustification, + supportingDocumentationUrls: dto.supportingDocumentationUrls, + requestedDateFrom: dto.requestedDateFrom as any, + requestedDateTo: dto.requestedDateTo as any, + notes: dto.notes, + status: 'draft', + statusHistory: [{ + status: 'draft', + changedAt: new Date().toISOString(), + notes: 'Preauthorization created', + }], + }); + + return this.repository.save(preauth); + } + + async update(tenantId: string, id: string, dto: UpdatePreauthorizationDto): Promise { + const preauth = await this.findById(tenantId, id); + if (!preauth) return null; + + if (!['draft', 'pending'].includes(preauth.status)) { + throw new Error('Can only update preauthorizations in draft or pending status'); + } + + if (dto.authType) preauth.authType = dto.authType; + if (dto.urgency) preauth.urgency = dto.urgency; + if (dto.requestedServices) preauth.requestedServices = dto.requestedServices; + if (dto.diagnosisCodes) preauth.diagnosisCodes = dto.diagnosisCodes; + if (dto.primaryDiagnosisCode) preauth.primaryDiagnosisCode = dto.primaryDiagnosisCode; + if (dto.clinicalJustification) preauth.clinicalJustification = dto.clinicalJustification; + if (dto.supportingDocumentationUrls) preauth.supportingDocumentationUrls = dto.supportingDocumentationUrls; + if (dto.requestedDateFrom) preauth.requestedDateFrom = dto.requestedDateFrom as any; + if (dto.requestedDateTo) preauth.requestedDateTo = dto.requestedDateTo as any; + if (dto.followUpDate) preauth.followUpDate = dto.followUpDate as any; + if (dto.followUpNotes) preauth.followUpNotes = dto.followUpNotes; + if (dto.notes !== undefined) preauth.notes = dto.notes; + + return this.repository.save(preauth); + } + + async submit(tenantId: string, id: string, dto: SubmitPreauthorizationDto): Promise { + const preauth = await this.findById(tenantId, id); + if (!preauth) return null; + + if (preauth.status !== 'draft') { + throw new Error('Can only submit preauthorizations in draft status'); + } + + preauth.status = 'submitted'; + preauth.submittedAt = new Date(); + preauth.submittedBy = dto.submittedBy; + + this.addStatusHistory(preauth, 'submitted', dto.submittedBy, 'Preauthorization submitted'); + + return this.repository.save(preauth); + } + + async markPending(tenantId: string, id: string, payerReferenceNumber?: string): Promise { + const preauth = await this.findById(tenantId, id); + if (!preauth) return null; + + if (preauth.status !== 'submitted') { + throw new Error('Can only mark as pending from submitted status'); + } + + preauth.status = 'pending'; + if (payerReferenceNumber) { + preauth.payerReferenceNumber = payerReferenceNumber; + } + + this.addStatusHistory(preauth, 'pending', undefined, 'Awaiting payer decision'); + + return this.repository.save(preauth); + } + + async process(tenantId: string, id: string, dto: ProcessPreauthorizationDto): Promise { + const preauth = await this.findById(tenantId, id); + if (!preauth) return null; + + if (!['submitted', 'pending'].includes(preauth.status)) { + throw new Error('Can only process preauthorizations in submitted or pending status'); + } + + preauth.decisionDate = new Date(); + + if (dto.decision === 'approved') { + preauth.status = 'approved'; + preauth.payerAuthNumber = dto.payerAuthNumber; + preauth.effectiveDate = dto.effectiveDate as any; + preauth.expirationDate = dto.expirationDate as any; + + if (dto.approvalDetails) { + preauth.approvalDetails = { + approvedUnits: dto.approvalDetails.approvedUnits, + approvedAmount: dto.approvalDetails.approvedAmount, + approvedProcedures: dto.approvalDetails.approvedProcedures, + approvedDateRange: dto.approvalDetails.approvedDateFrom && dto.approvalDetails.approvedDateTo + ? { from: dto.approvalDetails.approvedDateFrom, to: dto.approvalDetails.approvedDateTo } + : undefined, + approvedFacilities: dto.approvalDetails.approvedFacilities, + approvedProviders: dto.approvalDetails.approvedProviders, + conditions: dto.approvalDetails.conditions, + }; + } + + this.addStatusHistory(preauth, 'approved', undefined, `Approved. Auth#: ${dto.payerAuthNumber || 'N/A'}`); + } else { + preauth.status = 'denied'; + preauth.denialDetails = { + reasonCode: dto.denialReasonCode, + reasonDescription: dto.denialReasonDescription, + appealInstructions: dto.appealInstructions, + appealDeadline: dto.appealDeadline, + }; + + this.addStatusHistory(preauth, 'denied', undefined, `Denied: ${dto.denialReasonDescription || dto.denialReasonCode || 'No reason provided'}`); + } + + if (dto.notes) { + preauth.notes = preauth.notes + ? `${preauth.notes}\n\nDecision note: ${dto.notes}` + : `Decision note: ${dto.notes}`; + } + + return this.repository.save(preauth); + } + + async cancel(tenantId: string, id: string, dto: CancelPreauthorizationDto): Promise { + const preauth = await this.findById(tenantId, id); + if (!preauth) return null; + + if (['approved', 'denied', 'cancelled'].includes(preauth.status)) { + throw new Error('Cannot cancel preauthorizations that are already finalized'); + } + + preauth.status = 'cancelled'; + this.addStatusHistory(preauth, 'cancelled', dto.cancelledBy, dto.reason); + + return this.repository.save(preauth); + } + + async checkExpired(tenantId: string): Promise { + const result = await this.repository.createQueryBuilder() + .update(Preauthorization) + .set({ status: 'expired' }) + .where('tenant_id = :tenantId', { tenantId }) + .andWhere('status = :status', { status: 'approved' }) + .andWhere('expiration_date < CURRENT_DATE') + .execute(); + + return result.affected || 0; + } + + async getPreauthorizationsByStatus(tenantId: string): Promise<{ status: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('preauth') + .select('preauth.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('preauth.tenant_id = :tenantId', { tenantId }) + .groupBy('preauth.status') + .getRawMany(); + + return result.map(r => ({ status: r.status, count: parseInt(r.count, 10) })); + } + + async getPendingCount(tenantId: string): Promise { + return this.repository.count({ + where: { tenantId, status: 'pending' }, + }); + } + + async getExpiringPreauthorizations(tenantId: string, daysAhead: number = 14): Promise { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + daysAhead); + + return this.repository.createQueryBuilder('preauth') + .leftJoinAndSelect('preauth.patientInsurance', 'pi') + .leftJoinAndSelect('pi.insurancePlan', 'plan') + .where('preauth.tenant_id = :tenantId', { tenantId }) + .andWhere('preauth.status = :status', { status: 'approved' }) + .andWhere('preauth.expiration_date IS NOT NULL') + .andWhere('preauth.expiration_date <= :futureDate', { futureDate }) + .andWhere('preauth.expiration_date >= CURRENT_DATE') + .orderBy('preauth.expiration_date', 'ASC') + .getMany(); + } + + async getUrgentPreauthorizations(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, urgency: 'urgent', status: 'pending' }, + relations: ['patientInsurance', 'patientInsurance.insurancePlan'], + order: { createdAt: 'ASC' }, + }); + } + + private addStatusHistory( + preauth: Preauthorization, + status: PreauthorizationStatus, + changedBy?: string, + notes?: string + ): void { + preauth.statusHistory = preauth.statusHistory || []; + preauth.statusHistory.push({ + status, + changedAt: new Date().toISOString(), + changedBy, + notes, + }); + } + + private async generateAuthNumber(tenantId: string): Promise { + const today = new Date(); + const dateStr = today.toISOString().slice(0, 10).replace(/-/g, ''); + + const count = await this.repository.createQueryBuilder('preauth') + .where('preauth.tenant_id = :tenantId', { tenantId }) + .andWhere('DATE(preauth.created_at) = CURRENT_DATE') + .getCount(); + + const sequence = (count + 1).toString().padStart(4, '0'); + return `PA-${dateStr}-${sequence}`; + } +}