compctl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * compctl trid command - Check TRID timing compliance
3
+ */
4
+
5
+ import { Command } from 'commander';
6
+ import { checkTridTiming } from '../lib/checks';
7
+
8
+ export function createTridCommand(): Command {
9
+ const trid = new Command('trid')
10
+ .description('Check TRID timing compliance')
11
+ .requiredOption('--app-date <date>', 'Application date (YYYY-MM-DD)')
12
+ .option('--le-date <date>', 'Loan Estimate disclosure date')
13
+ .option('--cd-date <date>', 'Closing Disclosure date')
14
+ .option('--closing-date <date>', 'Scheduled closing date')
15
+ .option('--format <type>', 'Output format (json|table)', 'table')
16
+ .action(async (options) => {
17
+ const result = checkTridTiming({
18
+ applicationDate: options.appDate,
19
+ leDisclosureDate: options.leDate,
20
+ cdDisclosureDate: options.cdDate,
21
+ closingDate: options.closingDate,
22
+ });
23
+
24
+ if (options.format === 'json') {
25
+ console.log(JSON.stringify(result, null, 2));
26
+ } else {
27
+ console.log('═══════════════════════════════════════════════════════════════');
28
+ console.log('TRID TIMING COMPLIANCE');
29
+ console.log('═══════════════════════════════════════════════════════════════');
30
+ console.log('');
31
+ console.log(`Application Date: ${result.applicationDate}`);
32
+ if (result.leDisclosureDate) console.log(`LE Disclosure: ${result.leDisclosureDate}`);
33
+ if (result.cdDisclosureDate) console.log(`CD Disclosure: ${result.cdDisclosureDate}`);
34
+ if (result.closingDate) console.log(`Closing Date: ${result.closingDate}`);
35
+ console.log('');
36
+ console.log('STATUS');
37
+ console.log('───────────────────────────────────────────────────────────────');
38
+ const leStatus = result.leCompliant ? '✓ COMPLIANT' : '✗ NON-COMPLIANT';
39
+ const cdStatus = result.cdCompliant ? '✓ COMPLIANT' : '✗ NON-COMPLIANT';
40
+ console.log(`Loan Estimate: ${leStatus}`);
41
+ if (result.daysUntilLeDeadline) {
42
+ console.log(` Days until deadline: ${result.daysUntilLeDeadline}`);
43
+ }
44
+ console.log(`Closing Disclosure: ${cdStatus}`);
45
+ if (result.daysUntilCdDeadline) {
46
+ console.log(` Days until deadline: ${result.daysUntilCdDeadline}`);
47
+ }
48
+
49
+ if (result.findings.length > 0) {
50
+ console.log('');
51
+ console.log('FINDINGS');
52
+ console.log('───────────────────────────────────────────────────────────────');
53
+ for (const f of result.findings) {
54
+ const icon = f.severity === 'critical' ? '🚨' : f.severity === 'major' ? '⚠' : 'ℹ';
55
+ console.log(`${icon} [${f.code}] ${f.description}`);
56
+ if (f.remediation) console.log(` → ${f.remediation}`);
57
+ }
58
+ }
59
+ console.log('═══════════════════════════════════════════════════════════════');
60
+ }
61
+ });
62
+
63
+ return trid;
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * compctl - Lending compliance checks
5
+ * Part of the LendCtl Suite
6
+ */
7
+
8
+ import { Command } from 'commander';
9
+ import { createTridCommand } from './commands/trid';
10
+ import { createAtrCommand } from './commands/atr';
11
+ import { createAdverseCommand } from './commands/adverse';
12
+
13
+ const program = new Command();
14
+
15
+ program
16
+ .name('compctl')
17
+ .description('Lending compliance checks (TRID, ATR/QM, HMDA, ECOA) - part of the LendCtl Suite')
18
+ .version('0.1.0');
19
+
20
+ program.addCommand(createTridCommand());
21
+ program.addCommand(createAtrCommand());
22
+ program.addCommand(createAdverseCommand());
23
+
24
+ program.parse();
25
+
26
+ export * from './lib/checks';
27
+ export * from './types';
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Compliance check implementations
3
+ */
4
+
5
+ import { LoanData, TridTiming, AtrQmResult, Finding, ComplianceCheck, AdverseActionNotice } from '../types';
6
+
7
+ /**
8
+ * Check TRID timing requirements
9
+ */
10
+ export function checkTridTiming(data: {
11
+ applicationDate: string;
12
+ leDisclosureDate?: string;
13
+ cdDisclosureDate?: string;
14
+ closingDate?: string;
15
+ }): TridTiming {
16
+ const findings: Finding[] = [];
17
+ const appDate = new Date(data.applicationDate);
18
+ const today = new Date();
19
+
20
+ // LE must be provided within 3 business days of application
21
+ const leDeadline = addBusinessDays(appDate, 3);
22
+ const daysUntilLeDeadline = businessDaysBetween(today, leDeadline);
23
+
24
+ let leCompliant = true;
25
+ if (data.leDisclosureDate) {
26
+ const leDate = new Date(data.leDisclosureDate);
27
+ if (leDate > leDeadline) {
28
+ leCompliant = false;
29
+ findings.push({
30
+ code: 'TRID-LE-001',
31
+ severity: 'critical',
32
+ description: 'Loan Estimate not provided within 3 business days',
33
+ remediation: 'Issue LE immediately and document reason for delay',
34
+ });
35
+ }
36
+ } else if (daysUntilLeDeadline < 0) {
37
+ leCompliant = false;
38
+ findings.push({
39
+ code: 'TRID-LE-002',
40
+ severity: 'critical',
41
+ description: 'Loan Estimate deadline has passed',
42
+ remediation: 'Issue LE immediately',
43
+ });
44
+ }
45
+
46
+ // CD must be provided at least 3 business days before closing
47
+ let cdCompliant = true;
48
+ let daysUntilCdDeadline: number | undefined;
49
+
50
+ if (data.closingDate) {
51
+ const closingDate = new Date(data.closingDate);
52
+ const cdDeadline = subtractBusinessDays(closingDate, 3);
53
+ daysUntilCdDeadline = businessDaysBetween(today, cdDeadline);
54
+
55
+ if (data.cdDisclosureDate) {
56
+ const cdDate = new Date(data.cdDisclosureDate);
57
+ if (cdDate > cdDeadline) {
58
+ cdCompliant = false;
59
+ findings.push({
60
+ code: 'TRID-CD-001',
61
+ severity: 'critical',
62
+ description: 'Closing Disclosure not provided 3 business days before closing',
63
+ remediation: 'Reschedule closing or document consumer waiver',
64
+ });
65
+ }
66
+ } else if (daysUntilCdDeadline !== undefined && daysUntilCdDeadline < 0) {
67
+ cdCompliant = false;
68
+ findings.push({
69
+ code: 'TRID-CD-002',
70
+ severity: 'critical',
71
+ description: 'CD deadline has passed for scheduled closing',
72
+ remediation: 'Issue CD immediately or reschedule closing',
73
+ });
74
+ }
75
+ }
76
+
77
+ return {
78
+ applicationDate: data.applicationDate,
79
+ leDisclosureDate: data.leDisclosureDate,
80
+ cdDisclosureDate: data.cdDisclosureDate,
81
+ closingDate: data.closingDate,
82
+ leCompliant,
83
+ cdCompliant,
84
+ daysUntilLeDeadline: daysUntilLeDeadline > 0 ? daysUntilLeDeadline : undefined,
85
+ daysUntilCdDeadline: daysUntilCdDeadline !== undefined && daysUntilCdDeadline > 0 ? daysUntilCdDeadline : undefined,
86
+ findings,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Check ATR/QM requirements
92
+ */
93
+ export function checkAtrQm(loan: LoanData): AtrQmResult {
94
+ const findings: Finding[] = [];
95
+ const riskyFeatures: string[] = [];
96
+
97
+ // Check for risky features that disqualify QM
98
+ if (loan.negativeAmortization) {
99
+ riskyFeatures.push('Negative amortization');
100
+ }
101
+ if (loan.interestOnly) {
102
+ riskyFeatures.push('Interest-only payments');
103
+ }
104
+ if (loan.balloonPayment) {
105
+ riskyFeatures.push('Balloon payment');
106
+ }
107
+ if (loan.prepaymentPenalty) {
108
+ riskyFeatures.push('Prepayment penalty');
109
+ }
110
+ if (loan.termMonths > 360) {
111
+ riskyFeatures.push('Term exceeds 30 years');
112
+ }
113
+
114
+ // Points and fees limit (3% for loans >= $100k)
115
+ const pointsAndFeesLimit = loan.loanAmount >= 100000
116
+ ? loan.loanAmount * 0.03
117
+ : Math.min(loan.loanAmount * 0.03, 3000);
118
+
119
+ const pointsAndFeesExceeded = loan.pointsAndFees > pointsAndFeesLimit;
120
+ if (pointsAndFeesExceeded) {
121
+ riskyFeatures.push('Points and fees exceed limit');
122
+ findings.push({
123
+ code: 'QM-PF-001',
124
+ severity: 'critical',
125
+ description: `Points and fees ($${loan.pointsAndFees}) exceed ${(pointsAndFeesLimit).toFixed(0)} limit`,
126
+ remediation: 'Reduce fees or document as non-QM',
127
+ });
128
+ }
129
+
130
+ // DTI check
131
+ const dtiLimit = 43;
132
+ const dtiExceeded = loan.dti > dtiLimit;
133
+ if (dtiExceeded) {
134
+ findings.push({
135
+ code: 'QM-DTI-001',
136
+ severity: 'major',
137
+ description: `DTI (${loan.dti}%) exceeds QM safe harbor limit (${dtiLimit}%)`,
138
+ remediation: 'May still qualify with AUS approval or as non-QM',
139
+ });
140
+ }
141
+
142
+ // Determine QM status
143
+ const hasRiskyFeatures = riskyFeatures.length > 0;
144
+ let isQm = !hasRiskyFeatures && !pointsAndFeesExceeded;
145
+ let qmType: AtrQmResult['qmType'];
146
+
147
+ if (isQm) {
148
+ if (loan.dti <= 43) {
149
+ qmType = 'safe-harbor';
150
+ } else {
151
+ qmType = 'rebuttable-presumption';
152
+ isQm = true; // Still QM but not safe harbor
153
+ }
154
+ } else {
155
+ qmType = 'non-qm';
156
+ }
157
+
158
+ return {
159
+ isQm,
160
+ qmType,
161
+ dti: loan.dti,
162
+ dtiLimit,
163
+ pointsAndFees: loan.pointsAndFees,
164
+ pointsAndFeesLimit,
165
+ hasRiskyFeatures,
166
+ riskyFeatures,
167
+ findings,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Check ECOA compliance and generate adverse action notice
173
+ */
174
+ export function checkEcoa(loan: LoanData, decision: 'approved' | 'denied' | 'countered', reasons?: string[]): ComplianceCheck {
175
+ const findings: Finding[] = [];
176
+ const warnings: string[] = [];
177
+
178
+ if (decision === 'denied' || decision === 'countered') {
179
+ if (!reasons || reasons.length === 0) {
180
+ findings.push({
181
+ code: 'ECOA-AA-001',
182
+ severity: 'critical',
183
+ description: 'Adverse action requires specific reasons',
184
+ remediation: 'Provide specific reasons for denial',
185
+ });
186
+ } else if (reasons.length > 4) {
187
+ warnings.push('Best practice: limit adverse action reasons to 4 primary factors');
188
+ }
189
+ }
190
+
191
+ return {
192
+ regulation: 'ECOA',
193
+ passed: findings.filter(f => f.severity === 'critical').length === 0,
194
+ findings,
195
+ warnings,
196
+ recommendations: ['Document all adverse action reasons contemporaneously'],
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Generate ECOA-compliant adverse action notice
202
+ */
203
+ export function generateAdverseAction(
204
+ applicantName: string,
205
+ applicationDate: string,
206
+ creditorName: string,
207
+ creditorAddress: string,
208
+ reasons: string[]
209
+ ): AdverseActionNotice {
210
+ return {
211
+ applicantName,
212
+ applicationDate,
213
+ decisionDate: new Date().toISOString().split('T')[0],
214
+ creditorName,
215
+ creditorAddress,
216
+ reasons: reasons.slice(0, 4), // Max 4 reasons
217
+ ecoaNotice: `The federal Equal Credit Opportunity Act prohibits creditors from discriminating against credit applicants on the basis of race, color, religion, national origin, sex, marital status, age (provided the applicant has the capacity to enter into a binding contract); because all or part of the applicant's income derives from any public assistance program; or because the applicant has in good faith exercised any right under the Consumer Credit Protection Act. The federal agency that administers compliance with this law concerning this creditor is: Consumer Financial Protection Bureau, 1700 G Street NW, Washington, DC 20552.`,
218
+ fcraNotice: `You have the right to obtain a free copy of your credit report from the credit reporting agency that provided information used in this decision. You also have the right to dispute the accuracy or completeness of any information in your credit report.`,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Standard adverse action reasons
224
+ */
225
+ export const ADVERSE_ACTION_REASONS = [
226
+ 'Credit score does not meet minimum requirements',
227
+ 'Debt-to-income ratio exceeds guidelines',
228
+ 'Insufficient credit history',
229
+ 'Delinquent past or present credit obligations',
230
+ 'Collection action or judgment',
231
+ 'Bankruptcy',
232
+ 'Insufficient income for amount requested',
233
+ 'Unable to verify employment',
234
+ 'Unable to verify income',
235
+ 'Length of employment',
236
+ 'Insufficient property value',
237
+ 'Property type not eligible',
238
+ 'Loan-to-value ratio exceeds guidelines',
239
+ 'Unacceptable collateral',
240
+ 'Unable to verify residence',
241
+ 'Temporary or irregular employment',
242
+ ];
243
+
244
+ // Helper functions
245
+ function addBusinessDays(date: Date, days: number): Date {
246
+ const result = new Date(date);
247
+ let added = 0;
248
+ while (added < days) {
249
+ result.setDate(result.getDate() + 1);
250
+ if (result.getDay() !== 0 && result.getDay() !== 6) {
251
+ added++;
252
+ }
253
+ }
254
+ return result;
255
+ }
256
+
257
+ function subtractBusinessDays(date: Date, days: number): Date {
258
+ const result = new Date(date);
259
+ let subtracted = 0;
260
+ while (subtracted < days) {
261
+ result.setDate(result.getDate() - 1);
262
+ if (result.getDay() !== 0 && result.getDay() !== 6) {
263
+ subtracted++;
264
+ }
265
+ }
266
+ return result;
267
+ }
268
+
269
+ function businessDaysBetween(start: Date, end: Date): number {
270
+ let count = 0;
271
+ const current = new Date(start);
272
+ while (current < end) {
273
+ if (current.getDay() !== 0 && current.getDay() !== 6) {
274
+ count++;
275
+ }
276
+ current.setDate(current.getDate() + 1);
277
+ }
278
+ return count;
279
+ }
package/src/types.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Compliance types for compctl
3
+ */
4
+
5
+ /** Regulation identifiers */
6
+ export type Regulation =
7
+ | 'TRID' | 'ATR_QM' | 'HMDA' | 'ECOA' | 'RESPA'
8
+ | 'TILA' | 'FCRA' | 'UDAAP' | 'SCRA' | 'FLOOD';
9
+
10
+ /** Compliance check result */
11
+ export interface ComplianceCheck {
12
+ regulation: Regulation;
13
+ passed: boolean;
14
+ findings: Finding[];
15
+ warnings: string[];
16
+ recommendations: string[];
17
+ }
18
+
19
+ /** Individual finding */
20
+ export interface Finding {
21
+ code: string;
22
+ severity: 'critical' | 'major' | 'minor' | 'info';
23
+ description: string;
24
+ remediation?: string;
25
+ }
26
+
27
+ /** TRID timing check */
28
+ export interface TridTiming {
29
+ applicationDate: string;
30
+ leDisclosureDate?: string;
31
+ cdDisclosureDate?: string;
32
+ closingDate?: string;
33
+ leCompliant: boolean;
34
+ cdCompliant: boolean;
35
+ daysUntilLeDeadline?: number;
36
+ daysUntilCdDeadline?: number;
37
+ findings: Finding[];
38
+ }
39
+
40
+ /** ATR/QM check result */
41
+ export interface AtrQmResult {
42
+ isQm: boolean;
43
+ qmType?: 'safe-harbor' | 'rebuttable-presumption' | 'non-qm';
44
+ dti: number;
45
+ dtiLimit: number;
46
+ pointsAndFees: number;
47
+ pointsAndFeesLimit: number;
48
+ hasRiskyFeatures: boolean;
49
+ riskyFeatures: string[];
50
+ findings: Finding[];
51
+ }
52
+
53
+ /** HMDA LAR data */
54
+ export interface HmdaLarData {
55
+ loanId: string;
56
+ actionTaken: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
57
+ actionTakenDate: string;
58
+ loanType: 1 | 2 | 3 | 4;
59
+ loanPurpose: 1 | 2 | 31 | 32 | 4 | 5;
60
+ lienStatus: 1 | 2;
61
+ loanAmount: number;
62
+ combinedLtv?: number;
63
+ interestRate?: number;
64
+ rateSpread?: number;
65
+ hoepaStatus: 1 | 2 | 3;
66
+ propertyType: 1 | 2 | 3;
67
+ constructionMethod: 1 | 2;
68
+ occupancyType: 1 | 2 | 3;
69
+ propertyValue?: number;
70
+ applicantEthnicity: string[];
71
+ applicantRace: string[];
72
+ applicantSex: 1 | 2 | 3 | 4;
73
+ applicantAge?: number;
74
+ income?: number;
75
+ creditScore?: number;
76
+ dti?: number;
77
+ ausResult?: string;
78
+ }
79
+
80
+ /** Adverse action reasons */
81
+ export interface AdverseActionNotice {
82
+ applicantName: string;
83
+ applicationDate: string;
84
+ decisionDate: string;
85
+ creditorName: string;
86
+ creditorAddress: string;
87
+ reasons: string[];
88
+ ecoaNotice: string;
89
+ fcraNotice?: string;
90
+ }
91
+
92
+ /** Loan data for compliance checks */
93
+ export interface LoanData {
94
+ loanId: string;
95
+ applicationDate: string;
96
+ loanAmount: number;
97
+ propertyValue: number;
98
+ interestRate: number;
99
+ termMonths: number;
100
+ monthlyPayment: number;
101
+ dti: number;
102
+ ltv: number;
103
+ creditScore: number;
104
+ pointsAndFees: number;
105
+ prepaymentPenalty?: boolean;
106
+ negativeAmortization?: boolean;
107
+ interestOnly?: boolean;
108
+ balloonPayment?: boolean;
109
+ armFeatures?: { initialRate: number; margin: number; caps: number[] };
110
+ borrowerIncome: number;
111
+ propertyType: string;
112
+ occupancy: string;
113
+ loanPurpose: string;
114
+ loanType: string;
115
+ }
@@ -0,0 +1,159 @@
1
+ import { checkTridTiming, checkAtrQm, checkEcoa, generateAdverseAction } from '../src/lib/checks';
2
+ import { LoanData } from '../src/types';
3
+
4
+ describe('checkTridTiming', () => {
5
+ it('should pass when LE provided in time', () => {
6
+ const today = new Date();
7
+ const appDate = new Date();
8
+ appDate.setDate(today.getDate() - 1);
9
+
10
+ const result = checkTridTiming({
11
+ applicationDate: appDate.toISOString().split('T')[0],
12
+ leDisclosureDate: today.toISOString().split('T')[0],
13
+ });
14
+
15
+ expect(result.leCompliant).toBe(true);
16
+ });
17
+
18
+ it('should track application date', () => {
19
+ const result = checkTridTiming({
20
+ applicationDate: '2026-02-26',
21
+ });
22
+
23
+ expect(result.applicationDate).toBe('2026-02-26');
24
+ });
25
+ });
26
+
27
+ describe('checkAtrQm', () => {
28
+ const baseLoan: LoanData = {
29
+ loanId: 'TEST-001',
30
+ applicationDate: '2026-02-26',
31
+ loanAmount: 300000,
32
+ propertyValue: 375000,
33
+ interestRate: 6.5,
34
+ termMonths: 360,
35
+ monthlyPayment: 1896,
36
+ dti: 35,
37
+ ltv: 80,
38
+ creditScore: 720,
39
+ pointsAndFees: 5000,
40
+ borrowerIncome: 100000,
41
+ propertyType: 'single-family',
42
+ occupancy: 'primary',
43
+ loanPurpose: 'purchase',
44
+ loanType: 'conventional',
45
+ };
46
+
47
+ it('should identify QM safe harbor for compliant loan', () => {
48
+ const result = checkAtrQm(baseLoan);
49
+ expect(result.isQm).toBe(true);
50
+ expect(result.qmType).toBe('safe-harbor');
51
+ });
52
+
53
+ it('should flag negative amortization as risky', () => {
54
+ const loan = { ...baseLoan, negativeAmortization: true };
55
+ const result = checkAtrQm(loan);
56
+ expect(result.isQm).toBe(false);
57
+ expect(result.riskyFeatures).toContain('Negative amortization');
58
+ });
59
+
60
+ it('should flag interest-only as risky', () => {
61
+ const loan = { ...baseLoan, interestOnly: true };
62
+ const result = checkAtrQm(loan);
63
+ expect(result.isQm).toBe(false);
64
+ expect(result.riskyFeatures).toContain('Interest-only payments');
65
+ });
66
+
67
+ it('should flag balloon payment as risky', () => {
68
+ const loan = { ...baseLoan, balloonPayment: true };
69
+ const result = checkAtrQm(loan);
70
+ expect(result.isQm).toBe(false);
71
+ expect(result.riskyFeatures).toContain('Balloon payment');
72
+ });
73
+
74
+ it('should flag excessive points and fees', () => {
75
+ const loan = { ...baseLoan, pointsAndFees: 15000 }; // 5% of $300k
76
+ const result = checkAtrQm(loan);
77
+ expect(result.isQm).toBe(false);
78
+ expect(result.findings.some(f => f.code === 'QM-PF-001')).toBe(true);
79
+ });
80
+
81
+ it('should flag term over 30 years', () => {
82
+ const loan = { ...baseLoan, termMonths: 480 }; // 40 years
83
+ const result = checkAtrQm(loan);
84
+ expect(result.riskyFeatures).toContain('Term exceeds 30 years');
85
+ });
86
+
87
+ it('should identify rebuttable presumption for high DTI', () => {
88
+ const loan = { ...baseLoan, dti: 45 };
89
+ const result = checkAtrQm(loan);
90
+ expect(result.isQm).toBe(true);
91
+ expect(result.qmType).toBe('rebuttable-presumption');
92
+ });
93
+ });
94
+
95
+ describe('checkEcoa', () => {
96
+ const baseLoan: LoanData = {
97
+ loanId: 'TEST-001',
98
+ applicationDate: '2026-02-26',
99
+ loanAmount: 300000,
100
+ propertyValue: 375000,
101
+ interestRate: 6.5,
102
+ termMonths: 360,
103
+ monthlyPayment: 1896,
104
+ dti: 35,
105
+ ltv: 80,
106
+ creditScore: 720,
107
+ pointsAndFees: 5000,
108
+ borrowerIncome: 100000,
109
+ propertyType: 'single-family',
110
+ occupancy: 'primary',
111
+ loanPurpose: 'purchase',
112
+ loanType: 'conventional',
113
+ };
114
+
115
+ it('should require reasons for denial', () => {
116
+ const result = checkEcoa(baseLoan, 'denied');
117
+ expect(result.passed).toBe(false);
118
+ expect(result.findings.some(f => f.code === 'ECOA-AA-001')).toBe(true);
119
+ });
120
+
121
+ it('should pass with reasons provided', () => {
122
+ const result = checkEcoa(baseLoan, 'denied', ['DTI too high', 'Credit score']);
123
+ expect(result.passed).toBe(true);
124
+ });
125
+
126
+ it('should pass for approvals', () => {
127
+ const result = checkEcoa(baseLoan, 'approved');
128
+ expect(result.passed).toBe(true);
129
+ });
130
+ });
131
+
132
+ describe('generateAdverseAction', () => {
133
+ it('should generate complete notice', () => {
134
+ const notice = generateAdverseAction(
135
+ 'John Smith',
136
+ '2026-02-26',
137
+ 'ABC Mortgage',
138
+ '123 Main St',
139
+ ['DTI exceeds limit', 'Credit score too low']
140
+ );
141
+
142
+ expect(notice.applicantName).toBe('John Smith');
143
+ expect(notice.reasons.length).toBe(2);
144
+ expect(notice.ecoaNotice).toContain('Equal Credit Opportunity Act');
145
+ expect(notice.fcraNotice).toContain('credit report');
146
+ });
147
+
148
+ it('should limit reasons to 4', () => {
149
+ const notice = generateAdverseAction(
150
+ 'John Smith',
151
+ '2026-02-26',
152
+ 'ABC Mortgage',
153
+ '123 Main St',
154
+ ['Reason 1', 'Reason 2', 'Reason 3', 'Reason 4', 'Reason 5', 'Reason 6']
155
+ );
156
+
157
+ expect(notice.reasons.length).toBe(4);
158
+ });
159
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "resolveJsonModule": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist", "tests"]
19
+ }