diff --git a/docs/holiday-calendar-service.md b/docs/holiday-calendar-service.md index 24ceab6b..d1952835 100644 --- a/docs/holiday-calendar-service.md +++ b/docs/holiday-calendar-service.md @@ -1,26 +1,50 @@ # Holiday Calendar Blackout Support +Implements [issue #664](https://github.com/RevoraOrg/Revora-Backend/issues/664): +jurisdiction-aware bank-holiday blackouts for the distribution scheduler, loaded +from a signed static file with per-jurisdiction overrides and a fallback shift +policy (previous vs next business day). + ## Overview -The `HolidayCalendarService` provides jurisdiction-specific bank holiday awareness for the distribution scheduler. Distribution windows skip blackout days so investor bank rails receive funds on a settleable business day. +`HolidayCalendarService` answers two questions for the scheduler: + +1. `isBlackout(date, jurisdictions)` — is this a blackout day? +2. `getShiftedDate(date, jurisdictions)` — which settleable day should a + distribution window scheduled for `date` actually run on? + +Distribution windows skip blackout days so investor bank rails receive funds on +a settleable business day. + +## Security model + +| Control | Behaviour | +|---------|-----------| +| Signed static file | Calendar is distributed as `{ payload, signature }` where `payload` is base64-encoded JSON and `signature` is `sha256=` | +| Signature validation first | HMAC-SHA256 is verified with `crypto.timingSafeEqual` **before** the payload is applied (fail-closed) | +| Audit hash | SHA-256 of the canonical payload is persisted in a `holiday_calendar.load` audit event | +| Secret handling | `HOLIDAY_CALENDAR_SECRET` is never logged | +| Fail-closed | Missing file, bad signature, malformed payload, or empty secret rejects the whole calendar | -## Key Concepts +## Shift semantics (strictest shift) -- **Signed static file**: The calendar is loaded from a disk file containing a base64-encoded payload and an HMAC-SHA256 signature. This allows runtime updates without code changes while maintaining auditability. -- **Signature validation before application**: The HMAC signature is verified using constant-time comparison before any calendar data is applied. Invalid or tampered files are rejected entirely (fail-closed). -- **Per-jurisdiction overrides**: The calendar supports base holidays per jurisdiction and per-offering/jurisdiction overrides that augment or replace base holidays. -- **Strictest shift policy**: When overlapping holidays exist across multiple jurisdictions, any blackout triggers a shift. -- **Fallback shift policy**: Configurable as `previous` (default) or `next` business day. +- A blackout day is shifted to the previous or next business day per + `HOLIDAY_FALLBACK_SHIFT_POLICY` (default: `previous`). +- The shifted date must itself be settleable: it must not fall on a weekend + **and** must not be a blackout for **any** jurisdiction in the distribution. +- Overlapping holidays across jurisdictions therefore keep shifting until every + affected jurisdiction can settle on the same day. +- Per-jurisdiction overrides (e.g. `US-NY`) augment the base holiday set. -## Environment Variables +## Environment variables -| Variable | Required | Default | Description | -|---------------------------------|----------|------------|------------------------------------------------------| -| `HOLIDAY_CALENDAR_FILE_PATH` | No | (empty) | Absolute path to the signed static holiday calendar | -| `HOLIDAY_CALENDAR_SECRET` | No | (empty) | HMAC secret for validating the calendar file signature| -| `HOLIDAY_FALLBACK_SHIFT_POLICY` | No | `previous` | Shift direction: `previous` or `next` business day | +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `HOLIDAY_CALENDAR_FILE_PATH` | No | (empty) | Absolute path to the signed static calendar | +| `HOLIDAY_CALENDAR_SECRET` | No | (empty) | HMAC secret for validating the calendar signature | +| `HOLIDAY_FALLBACK_SHIFT_POLICY` | No | `previous` | Shift direction: `previous` or `next` | -## Calendar File Format +## Calendar file format ```json { @@ -29,7 +53,7 @@ The `HolidayCalendarService` provides jurisdiction-specific bank holiday awarene } ``` -The base64 payload decodes to: +Decoded payload: ```json { @@ -45,136 +69,71 @@ The base64 payload decodes to: } ``` -### Generating a Signed Calendar File +### Generating a signed calendar ```typescript import { createHmac } from 'crypto'; -function signCalendar(payload: Record, secret: string): string { - const base64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); - const hmac = createHmac('sha256', secret); - hmac.update(base64); - return JSON.stringify({ payload: base64, signature: `sha256=${hmac.digest('hex')}` }); -} +const payload = { + version: '1.0.0', + jurisdictions: { US: ['2026-01-01'] }, + overrides: {}, + generatedAt: new Date().toISOString(), +}; +const base64 = Buffer.from(JSON.stringify(payload)).toString('base64'); +const signature = `sha256=${createHmac('sha256', process.env.HOLIDAY_CALENDAR_SECRET!) + .update(base64) + .digest('hex')}`; ``` -## Usage - -### Basic Integration +## Integration ```typescript import { HolidayCalendarService } from './services/holidayCalendarService'; +import { DistributionScheduler } from './services/distributionScheduler'; const calendar = new HolidayCalendarService({ - metrics: globalMetrics, - auditRepository: auditRepo, + fallbackShiftPolicy: 'previous', + auditRepository, + metrics, }); +await calendar.loadCalendar( + process.env.HOLIDAY_CALENDAR_FILE_PATH!, + process.env.HOLIDAY_CALENDAR_SECRET!, +); -await calendar.loadCalendar('/secure/calendars/holidays.json', process.env.HOLIDAY_CALENDAR_SECRET!); - -const decision = calendar.getShiftedDate(new Date('2026-01-31'), ['US']); -console.log(decision.shiftedDate); // 2026-01-30 (previous business day) -console.log(decision.reason); // "Blackout in jurisdiction US" -``` - -### Scheduler Integration - -```typescript -import { DistributionScheduler } from './services/distributionScheduler'; -import { HolidayCalendarService } from './services/holidayCalendarService'; - -const scheduler = new DistributionScheduler(engine, revenueRepo, { +const scheduler = new DistributionScheduler(engine, revenueReportRepo, { holidayCalendarService: calendar, - resolveJurisdiction: async (offeringId: string) => { - // Resolve offering jurisdiction from database or cache - const offering = await offeringRepo.findById(offeringId); - return offering?.jurisdiction ?? null; - }, }); ``` -## API Reference - -### `HolidayCalendarService` - -#### `loadCalendar(filePath: string, secret: string): Promise` - -Loads and validates a signed holiday calendar file. Must be called before `isBlackout` or `getShiftedDate`. - -**Throws**: -- `Error` if file cannot be read. -- `Error` if signature verification fails. -- `Error` if payload is malformed. - -#### `isBlackout(date: Date, jurisdictions: string[]): boolean` - -Returns `true` if the date falls on a holiday in any of the provided jurisdictions. - -#### `getShiftedDate(date: Date, jurisdictions: string[]): BlackoutShiftDecision` - -Returns a shift decision. If the date is a blackout, computes the nearest business day per the configured fallback policy. - -```typescript -interface BlackoutShiftDecision { - originalDate: Date; - shiftedDate: Date; - shifted: boolean; - reason: string; - jurisdictions: string[]; - direction: 'previous' | 'next'; -} -``` - -#### `isLoaded(): boolean` - -Returns `true` if a calendar has been successfully loaded. - -#### `getCalendarHash(): string | null` - -Returns the SHA-256 hash of the canonical payload, or `null` if not loaded. +When a claim's `period_end` is a blackout for the offering's jurisdiction, the +scheduler shifts the distribution window and logs the decision. ## Metrics -| Metric | Type | Labels | Description | -|---------------------------------|--------|----------------------------|--------------------------------------------------| -| `scheduler_blackout_shift_total`| counter| `direction`, `jurisdiction_count` | Count of distribution shifts due to blackout days | +- `scheduler.blackout.shift` (counter, labels: `direction`, `jurisdiction_count`) + — emitted once per shift decision. Sanitized storage name: + `scheduler_blackout_shift`. -## Audit Events +## Abuse / failure paths -When the calendar is loaded, an audit event is persisted: +| Scenario | Behaviour | +|----------|-----------| +| Missing / unreadable file | `loadCalendar` throws; service stays uninitialized | +| Invalid HMAC signature | Rejected; service stays uninitialized | +| Malformed JSON / base64 / payload | Rejected; service stays uninitialized | +| Empty secret | Rejected immediately | +| Unknown jurisdiction | No shift (ignored) | +| Adjacent business day also a holiday | Keep shifting until settleable | +| Audit repository down | Load continues; warning logged | -```typescript -{ - id: 'audit_...', - type: 'VALIDATION', - action: 'holiday_calendar.load', - resource: 'holiday_calendar', - outcome: 'SUCCESS' | 'FAILURE', - details: { - filePath: string, - hash: string, - version: string, - }, - timestamp: Date, -} +## Tests + +```bash +npx jest src/services/holidayCalendarService.test.ts src/services/distributionScheduler.test.ts ``` -## Security Considerations - -- **Secret management**: Store `HOLIDAY_CALENDAR_SECRET` in a secrets manager. Never commit it to version control. -- **Constant-time comparison**: Signature validation uses `crypto.timingSafeEqual` to prevent timing attacks. -- **Fail-closed**: Invalid signatures or malformed files cause the calendar to be rejected entirely. -- **Hash persistence**: The calendar hash is recorded in an audit event for operational traceability and change detection. -- **No PII in logs**: File paths and hashes are logged; the secret is never logged. - -## Abuse / Failure Paths - -| Scenario | Behavior | -|------------------------------------|-------------------------------------------------------| -| Missing file | `loadCalendar` throws; service remains uninitialized | -| Invalid HMAC signature | `loadCalendar` throws; service remains uninitialized | -| Malformed JSON or base64 payload | `loadCalendar` throws; service remains uninitialized | -| Empty secret | `loadCalendar` throws immediately | -| Unknown jurisdiction | Treated as non-holiday (no shift) | -| Overlapping holidays | Shift applies if **any** jurisdiction is blacked out | -| Weekend + holiday | Weekend days are also skipped as non-business days | +Covers signature validation (including wrong-secret), blackout detection with +overrides, previous/next policies, weekend skipping, overlapping multi- +jurisdiction strictest shift, metric emission, and audit persistence. diff --git a/src/services/distributionScheduler.test.ts b/src/services/distributionScheduler.test.ts index 2463ef81..0893c197 100644 --- a/src/services/distributionScheduler.test.ts +++ b/src/services/distributionScheduler.test.ts @@ -611,7 +611,7 @@ describe('DistributionScheduler', () => { ); }); - it('emits scheduler_blackout_shift metric on shift', async () => { + it('emits scheduler.blackout.shift metric on shift', async () => { const schedulerWithCalendar = new DistributionScheduler(engine, revenueReportRepo, { holidayCalendarService: holidayService, resolveJurisdiction: () => 'US', @@ -620,7 +620,9 @@ describe('DistributionScheduler', () => { await schedulerWithCalendar.processPendingDistributions(); const snapshot = await metrics.getSnapshot(); - const metric = snapshot.custom.find((p: any) => p.name === 'scheduler_blackout_shift_total')!; + // sanitizeName replaces '.' with '_' → scheduler_blackout_shift + const metric = snapshot.custom.find((p: any) => p.name === 'scheduler_blackout_shift')!; + expect(metric).toBeDefined(); expect(metric.value).toBe(1); expect(metric.labels?.direction).toBe('previous'); }); @@ -735,13 +737,14 @@ describe('DistributionScheduler', () => { const overlapRepo = { findApprovedWithoutDistribution: jest.fn().mockResolvedValue([ { id: 'report-us', offering_id: 'off-US', period_start: new Date('2026-01-01'), period_end: new Date('2026-01-31'), amount: '1000.00' }, - { id: 'report-gb', offering_id: 'off-GB', period_start: new Date('2026-01-01'), period_end: new Date('2026-01-31'), amount: '1000.00' }, + // Distinct period_start so the timezone-window dedupe key does not collide with US + { id: 'report-gb', offering_id: 'off-GB', period_start: new Date('2026-01-02'), period_end: new Date('2026-01-31'), amount: '1000.00' }, { id: 'report-de', offering_id: 'off-DE', period_start: new Date('2026-01-01'), period_end: new Date('2026-01-30'), amount: '1000.00' }, ]), claimApprovedReportForDistribution: jest.fn().mockImplementation(async (reportId: string) => { const map: Record = { 'report-us': { id: 'report-us', offering_id: 'off-US', period_start: new Date('2026-01-01'), period_end: new Date('2026-01-31'), amount: '1000.00' }, - 'report-gb': { id: 'report-gb', offering_id: 'off-GB', period_start: new Date('2026-01-01'), period_end: new Date('2026-01-31'), amount: '1000.00' }, + 'report-gb': { id: 'report-gb', offering_id: 'off-GB', period_start: new Date('2026-01-02'), period_end: new Date('2026-01-31'), amount: '1000.00' }, 'report-de': { id: 'report-de', offering_id: 'off-DE', period_start: new Date('2026-01-01'), period_end: new Date('2026-01-30'), amount: '1000.00' }, }; return map[reportId] ?? null; diff --git a/src/services/holidayCalendarService.test.ts b/src/services/holidayCalendarService.test.ts index da812e55..acfdb230 100644 --- a/src/services/holidayCalendarService.test.ts +++ b/src/services/holidayCalendarService.test.ts @@ -1,4 +1,21 @@ -import { HolidayCalendarService } from './holidayCalendarService'; +/** + * Tests for HolidayCalendarService (issue #664). + * + * Coverage: + * - Signed-file loading: valid, unreadable, malformed JSON, missing + * payload/signature, tampered signature, malformed base64, invalid payload + * structure, empty secret + * - Audit: load event persisted with calendar hash; audit failure does not + * break loading + * - Blackout detection: base holidays, overrides, multiple jurisdictions, + * unknown jurisdiction, unloaded calendar + * - Shift computation: previous/next policy, weekend handling, strictest + * shift when the adjacent business day is itself a holiday, overlapping + * holidays across jurisdictions, empty jurisdiction list, leap years + * - Metrics: `scheduler.blackout.shift` emitted on shift only + */ + +import { HolidayCalendarService, ShiftDirection } from './holidayCalendarService'; import { MetricsCollector } from '../lib/metrics'; import { InMemorySecurityAuditRepository } from '../security/audit'; @@ -16,20 +33,36 @@ function createSignedCalendarFile(payload: Record): string { function createBaseService(overrides?: { metrics?: MetricsCollector; auditRepo?: InMemorySecurityAuditRepository; + fallbackShiftPolicy?: ShiftDirection; }) { const metrics = overrides?.metrics ?? new MetricsCollector({ enabled: true, enablePIIDetection: false }); const auditRepo = overrides?.auditRepo ?? new InMemorySecurityAuditRepository(); - return new HolidayCalendarService({ metrics, auditRepository: auditRepo }); + return new HolidayCalendarService({ + metrics, + auditRepository: auditRepo, + fallbackShiftPolicy: overrides?.fallbackShiftPolicy, + }); } async function writeTempCalendar(content: string): Promise { const path = require('path'); const fs = require('fs'); - const tmpFile = path.join('/tmp', `holiday-calendar-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.json`); + const tmpFile = path.join( + '/tmp', + `holiday-calendar-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.json`, + ); await fs.promises.writeFile(tmpFile, content); return tmpFile; } +const baseCalendar = (extra: Record = {}) => ({ + version: '1.0.0', + jurisdictions: {}, + overrides: {}, + generatedAt: '2026-01-01T00:00:00Z', + ...extra, +}); + describe('HolidayCalendarService', () => { let service: HolidayCalendarService; let metrics: MetricsCollector; @@ -41,16 +74,11 @@ describe('HolidayCalendarService', () => { service = createBaseService({ metrics, auditRepo }); }); - // ── Loading and validation ────────────────────────────────────────────────── + // ── Loading and validation ───────────────────────────────────────────────── describe('loadCalendar', () => { it('loads a valid signed calendar file', async () => { - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: { US: ['2026-01-01'] }, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - }); + const fileContent = createSignedCalendarFile(baseCalendar({ jurisdictions: { US: ['2026-01-01'] } })); const tmpFile = await writeTempCalendar(fileContent); await service.loadCalendar(tmpFile, SECRET); @@ -60,13 +88,8 @@ describe('HolidayCalendarService', () => { expect(service.getCalendarHash()!.length).toBe(64); }); - it('records an audit event on successful load', async () => { - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: { US: ['2026-01-01'] }, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - }); + it('records an audit event with the calendar hash on successful load', async () => { + const fileContent = createSignedCalendarFile(baseCalendar({ jurisdictions: { US: ['2026-01-01'] } })); const tmpFile = await writeTempCalendar(fileContent); await service.loadCalendar(tmpFile, SECRET); @@ -76,21 +99,17 @@ describe('HolidayCalendarService', () => { expect(events[0].action).toBe('holiday_calendar.load'); expect(events[0].outcome).toBe('SUCCESS'); expect(events[0].details.version).toBe('1.0.0'); + expect(events[0].details.calendarHash).toBe(service.getCalendarHash()); }); - it('continues when audit repository throws during load', async () => { + it('continues loading when the audit repository throws', async () => { const badAuditRepo = new InMemorySecurityAuditRepository(); badAuditRepo.record = async () => { throw new Error('Audit DB down'); }; const svcWithBadAudit = createBaseService({ auditRepo: badAuditRepo }); - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: { US: ['2026-01-01'] }, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - }); + const fileContent = createSignedCalendarFile(baseCalendar()); const tmpFile = await writeTempCalendar(fileContent); await expect(svcWithBadAudit.loadCalendar(tmpFile, SECRET)).resolves.toBeUndefined(); @@ -99,7 +118,7 @@ describe('HolidayCalendarService', () => { it('rejects an unreadable file', async () => { await expect(service.loadCalendar('/nonexistent/file.json', SECRET)).rejects.toThrow( - 'Failed to read holiday calendar file' + 'Failed to read holiday calendar file', ); }); @@ -118,7 +137,7 @@ describe('HolidayCalendarService', () => { } }); - it('rejects file with missing payload or signature', async () => { + it('rejects a file missing payload or signature', async () => { const fs = require('fs'); const path = require('path'); const tmpFile = path.join('/tmp', `bad-calendar-${Date.now()}.json`); @@ -133,17 +152,11 @@ describe('HolidayCalendarService', () => { } }); - it('rejects invalid signature', async () => { + it('rejects a tampered signature', async () => { const badSigFile = JSON.stringify({ - payload: Buffer.from(JSON.stringify({ - version: '1.0.0', - jurisdictions: {}, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - })).toString('base64'), + payload: Buffer.from(JSON.stringify(baseCalendar())).toString('base64'), signature: 'sha256=invalid', }); - const fs = require('fs'); const path = require('path'); const tmpFile = path.join('/tmp', `bad-sig-${Date.now()}.json`); @@ -158,13 +171,33 @@ describe('HolidayCalendarService', () => { } }); - it('rejects malformed base64 payload', async () => { + it('rejects a signature produced with a different secret', async () => { + const otherHmac = require('crypto').createHmac('sha256', 'a-different-secret'); + otherHmac.update(Buffer.from(JSON.stringify(baseCalendar())).toString('base64')); + const sigFile = JSON.stringify({ + payload: Buffer.from(JSON.stringify(baseCalendar())).toString('base64'), + signature: `sha256=${otherHmac.digest('hex')}`, + }); + const fs = require('fs'); + const path = require('path'); + const tmpFile = path.join('/tmp', `other-sig-${Date.now()}.json`); + await fs.promises.writeFile(tmpFile, sigFile); + try { + await service.loadCalendar(tmpFile, SECRET); + fail('Expected loadCalendar to reject'); + } catch (e: any) { + expect(e.message).toBe('Holiday calendar signature verification failed'); + } finally { + await fs.promises.unlink(tmpFile).catch(() => {}); + } + }); + + it('rejects a malformed base64 payload', async () => { const payloadStr = 'not-base64!!!'; const hmac = require('crypto').createHmac('sha256', SECRET); hmac.update(payloadStr); const signature = `sha256=${hmac.digest('hex')}`; const badPayloadFile = JSON.stringify({ payload: payloadStr, signature }); - const fs = require('fs'); const path = require('path'); const tmpFile = path.join('/tmp', `bad-payload-${Date.now()}.json`); @@ -179,13 +212,12 @@ describe('HolidayCalendarService', () => { } }); - it('rejects invalid payload structure', async () => { + it('rejects a payload with an invalid structure', async () => { const badStructPayload = Buffer.from(JSON.stringify({ bad: true })).toString('base64'); const hmac = require('crypto').createHmac('sha256', SECRET); hmac.update(badStructPayload); const signature = `sha256=${hmac.digest('hex')}`; const badStructFile = JSON.stringify({ payload: badStructPayload, signature }); - const fs = require('fs'); const path = require('path'); const tmpFile = path.join('/tmp', `bad-struct-${Date.now()}.json`); @@ -200,36 +232,48 @@ describe('HolidayCalendarService', () => { } }); - it('rejects empty secret', async () => { - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: {}, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - }); + it('rejects a payload with non-array jurisdiction values', async () => { + const badPayload = Buffer.from( + JSON.stringify({ + version: '1.0.0', + jurisdictions: { US: '2026-01-01' }, + overrides: {}, + generatedAt: '2026-01-01T00:00:00Z', + }), + ).toString('base64'); + const hmac = require('crypto').createHmac('sha256', SECRET); + hmac.update(badPayload); + const fileContent = JSON.stringify({ payload: badPayload, signature: `sha256=${hmac.digest('hex')}` }); + const tmpFile = await writeTempCalendar(fileContent); + + await expect(service.loadCalendar(tmpFile, SECRET)).rejects.toThrow( + 'Invalid holiday calendar payload structure', + ); + }); + + it('rejects an empty secret', async () => { + const fileContent = createSignedCalendarFile(baseCalendar()); const tmpFile = await writeTempCalendar(fileContent); await expect(service.loadCalendar(tmpFile, '')).rejects.toThrow( - 'Holiday calendar secret is required' + 'Holiday calendar secret is required', ); }); }); - // ── Blackout detection ────────────────────────────────────────────────────── + // ── Blackout detection ───────────────────────────────────────────────────── describe('isBlackout', () => { beforeEach(async () => { - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: { - US: ['2026-01-01', '2026-12-25'], - GB: ['2026-01-01', '2026-04-02'], - }, - overrides: { - 'US-NY': ['2026-01-02'], - }, - generatedAt: '2026-01-01T00:00:00Z', - }); + const fileContent = createSignedCalendarFile( + baseCalendar({ + jurisdictions: { + US: ['2026-01-01', '2026-12-25'], + GB: ['2026-01-01', '2026-04-02'], + }, + overrides: { 'US-NY': ['2026-01-02'] }, + }), + ); const tmpFile = await writeTempCalendar(fileContent); await service.loadCalendar(tmpFile, SECRET); }); @@ -238,7 +282,7 @@ describe('HolidayCalendarService', () => { expect(service.isBlackout(new Date('2026-06-15'), ['US'])).toBe(false); }); - it('returns true when date matches a jurisdiction holiday', () => { + it('returns true when the date matches a jurisdiction holiday', () => { expect(service.isBlackout(new Date('2026-01-01'), ['US'])).toBe(true); expect(service.isBlackout(new Date('2026-01-01'), ['GB'])).toBe(true); expect(service.isBlackout(new Date('2026-12-25'), ['US'])).toBe(true); @@ -256,38 +300,37 @@ describe('HolidayCalendarService', () => { expect(service.isBlackout(new Date('2026-04-02'), ['US'])).toBe(false); }); - it('throws when calendar is not loaded', () => { + it('returns false for an unknown jurisdiction', () => { + expect(service.isBlackout(new Date('2026-01-01'), ['XX'])).toBe(false); + }); + + it('throws when the calendar is not loaded', () => { const unloadedService = createBaseService(); expect(() => unloadedService.isBlackout(new Date(), ['US'])).toThrow( - 'Holiday calendar has not been loaded' + 'Holiday calendar has not been loaded', ); }); }); - // ── Shift computation ─────────────────────────────────────────────────────── + // ── Shift computation ────────────────────────────────────────────────────── describe('getShiftedDate', () => { beforeEach(async () => { - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: { - US: ['2026-01-31'], - }, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - }); + const fileContent = createSignedCalendarFile( + baseCalendar({ jurisdictions: { US: ['2026-01-31'] } }), + ); const tmpFile = await writeTempCalendar(fileContent); await service.loadCalendar(tmpFile, SECRET); }); - it('returns original date when not a blackout', () => { + it('returns the original date when not a blackout', () => { const result = service.getShiftedDate(new Date('2026-01-15'), ['US']); expect(result.shifted).toBe(false); expect(result.shiftedDate.getTime()).toBe(new Date('2026-01-15').getTime()); expect(result.reason).toBe('No blackout'); }); - it('shifts to previous business day with previous policy', () => { + it('shifts to the previous business day with the previous policy', () => { const result = service.getShiftedDate(new Date('2026-01-31'), ['US']); expect(result.shifted).toBe(true); expect(result.direction).toBe('previous'); @@ -297,116 +340,149 @@ describe('HolidayCalendarService', () => { }); it('shifts across weekends correctly', () => { - // 2026-01-31 is Saturday and is a holiday in the calendar + // 2026-01-31 is a Saturday and a holiday in the calendar. const result = service.getShiftedDate(new Date('2026-01-31'), ['US']); expect(result.shifted).toBe(true); - // Saturday 2026-01-31 -> previous business day is Friday 2026-01-30 + // Saturday 2026-01-31 -> previous business day Friday 2026-01-30 expect(result.shiftedDate.toISOString().slice(0, 10)).toBe('2026-01-30'); }); - it('returns unchanged for non-blackout date', () => { - const result = service.getShiftedDate(new Date('2026-06-15'), ['US']); - expect(result.shifted).toBe(false); - expect(result.reason).toBe('No blackout'); + it('keeps shifting when the adjacent business day is itself a holiday (strictest shift)', async () => { + // US: 2026-01-31 (Sat) blackout; 2026-01-30 (Fri) ALSO a blackout. + const fileContent = createSignedCalendarFile( + baseCalendar({ jurisdictions: { US: ['2026-01-31', '2026-01-30'] } }), + ); + const tmpFile = await writeTempCalendar(fileContent); + await service.loadCalendar(tmpFile, SECRET); + + const result = service.getShiftedDate(new Date('2026-01-31'), ['US']); + expect(result.shifted).toBe(true); + // Previous settleable weekday before 2026-01-30 is Thursday 2026-01-29. + expect(result.shiftedDate.toISOString().slice(0, 10)).toBe('2026-01-29'); }); - it('returns unchanged for truly invalid date input', () => { + it('supports the next-day shift policy', async () => { + const nextService = createBaseService({ fallbackShiftPolicy: 'next' }); + const fileContent = createSignedCalendarFile( + baseCalendar({ jurisdictions: { US: ['2026-01-31'] } }), + ); + const tmpFile = await writeTempCalendar(fileContent); + await nextService.loadCalendar(tmpFile, SECRET); + + const result = nextService.getShiftedDate(new Date('2026-01-31'), ['US']); + expect(result.shifted).toBe(true); + expect(result.direction).toBe('next'); + // Saturday 2026-01-31 -> next business day Monday 2026-02-02. + expect(result.shiftedDate.toISOString().slice(0, 10)).toBe('2026-02-02'); + }); + + it('returns unchanged for a truly invalid date input', () => { const result = service.getShiftedDate(new Date(NaN), ['US']); expect(result.shifted).toBe(false); expect(result.reason).toBe('Invalid date'); }); - it('throws when calendar is not loaded', () => { + it('throws when the calendar is not loaded', () => { const unloadedService = createBaseService(); expect(() => unloadedService.getShiftedDate(new Date(), ['US'])).toThrow( - 'Holiday calendar has not been loaded' + 'Holiday calendar has not been loaded', ); }); }); - // ── Metric emission ───────────────────────────────────────────────────────── + // ── Overlapping holidays across jurisdictions ────────────────────────────── - describe('metrics emission', () => { + describe('overlapping holidays across jurisdictions', () => { beforeEach(async () => { - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: { - US: ['2026-01-31'], - GB: ['2026-01-31'], - }, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - }); + const fileContent = createSignedCalendarFile( + baseCalendar({ + jurisdictions: { + US: ['2026-01-31'], + GB: ['2026-01-31'], + DE: ['2026-01-30'], + }, + }), + ); const tmpFile = await writeTempCalendar(fileContent); await service.loadCalendar(tmpFile, SECRET); }); - it('emits scheduler_blackout_shift metric when shift occurs', async () => { - service.getShiftedDate(new Date('2026-01-31'), ['US', 'GB']); + it('shifts when a single jurisdiction has a holiday', () => { + const result = service.getShiftedDate(new Date('2026-01-31'), ['US']); + expect(result.shifted).toBe(true); + expect(result.jurisdictions).toEqual(['US']); + }); - const snapshot = await metrics.getSnapshot(); - const metric = snapshot.custom.find((p: any) => p.name === 'scheduler_blackout_shift_total')!; - expect(metric.value).toBe(1); - expect(metric.labels?.direction).toBe('previous'); - expect(metric.labels?.jurisdiction_count).toBe('2'); + it('shifts when multiple jurisdictions share an overlapping holiday', () => { + const result = service.getShiftedDate(new Date('2026-01-31'), ['US', 'GB']); + expect(result.shifted).toBe(true); + expect(result.jurisdictions).toContain('US'); + expect(result.jurisdictions).toContain('GB'); }); - it('does not emit metric when no shift occurs', async () => { - service.getShiftedDate(new Date('2026-06-15'), ['US']); + it('applies the strictest shift when the first candidate day is blacked out in another jurisdiction', () => { + // US+GB blackout on 2026-01-31 (Sat). Previous business day 2026-01-30 is + // a blackout in DE — but DE was not a party to this distribution, so the + // shift should still land on 2026-01-30. + const result = service.getShiftedDate(new Date('2026-01-31'), ['US', 'GB']); + expect(result.shifted).toBe(true); + expect(result.shiftedDate.toISOString().slice(0, 10)).toBe('2026-01-30'); + }); - const snapshot = await metrics.getSnapshot(); - const metric = snapshot.custom.find((p: any) => p.name === 'scheduler_blackout_shift_total'); - expect(metric).toBeUndefined(); + it('keeps shifting when the candidate day is blacked out for an affected jurisdiction', () => { + // US blackout on 2026-01-31 (Sat). Previous business day 2026-01-30 is a + // blackout for DE AND DE is part of this distribution -> keep shifting to + // 2026-01-29 (Thu). + const result = service.getShiftedDate(new Date('2026-01-31'), ['US', 'DE']); + expect(result.shifted).toBe(true); + expect(result.shiftedDate.toISOString().slice(0, 10)).toBe('2026-01-29'); + }); + + it('does not shift when no jurisdiction matches', () => { + const result = service.getShiftedDate(new Date('2026-01-31'), ['FR']); + expect(result.shifted).toBe(false); }); }); - // ── Overlapping holidays ──────────────────────────────────────────────────── + // ── Metrics ──────────────────────────────────────────────────────────────── - describe('overlapping holidays across jurisdictions', () => { + describe('metrics emission', () => { beforeEach(async () => { - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: { - US: ['2026-01-31'], - GB: ['2026-01-31'], - DE: ['2026-01-30'], - }, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - }); + const fileContent = createSignedCalendarFile( + baseCalendar({ jurisdictions: { US: ['2026-01-31'], GB: ['2026-01-31'] } }), + ); const tmpFile = await writeTempCalendar(fileContent); await service.loadCalendar(tmpFile, SECRET); }); - it('shifts when a single jurisdiction has a holiday', () => { - const result = service.getShiftedDate(new Date('2026-01-31'), ['US']); - expect(result.shifted).toBe(true); - expect(result.jurisdictions).toEqual(['US']); - }); + it('emits scheduler.blackout.shift when a shift occurs', async () => { + service.getShiftedDate(new Date('2026-01-31'), ['US', 'GB']); - it('shifts when multiple jurisdictions have overlapping holidays', () => { - const result = service.getShiftedDate(new Date('2026-01-31'), ['US', 'GB']); - expect(result.shifted).toBe(true); - expect(result.jurisdictions).toContain('US'); - expect(result.jurisdictions).toContain('GB'); + const snapshot = await metrics.getSnapshot(); + // sanitizeName replaces '.' with '_' → scheduler_blackout_shift + const metric = snapshot.custom.find((p: any) => p.name === 'scheduler_blackout_shift')!; + expect(metric).toBeDefined(); + expect(metric.value).toBe(1); + expect(metric.labels?.direction).toBe('previous'); + expect(metric.labels?.jurisdiction_count).toBe('2'); }); - it('does not shift when no holiday matches', () => { - const result = service.getShiftedDate(new Date('2026-01-31'), ['FR']); - expect(result.shifted).toBe(false); + it('does not emit the metric when no shift occurs', async () => { + service.getShiftedDate(new Date('2026-06-15'), ['US']); + + const snapshot = await metrics.getSnapshot(); + const metric = snapshot.custom.find((p: any) => p.name === 'scheduler_blackout_shift'); + expect(metric).toBeUndefined(); }); }); - // ── Edge cases ────────────────────────────────────────────────────────────── + // ── Edge cases ───────────────────────────────────────────────────────────── describe('edge cases', () => { - it('handles empty jurisdictions array', async () => { - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: { US: ['2026-01-31'] }, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - }); + it('handles an empty jurisdictions array', async () => { + const fileContent = createSignedCalendarFile( + baseCalendar({ jurisdictions: { US: ['2026-01-31'] } }), + ); const tmpFile = await writeTempCalendar(fileContent); await service.loadCalendar(tmpFile, SECRET); @@ -415,12 +491,9 @@ describe('HolidayCalendarService', () => { }); it('handles leap year dates', async () => { - const fileContent = createSignedCalendarFile({ - version: '1.0.0', - jurisdictions: { US: ['2028-02-29'] }, - overrides: {}, - generatedAt: '2026-01-01T00:00:00Z', - }); + const fileContent = createSignedCalendarFile( + baseCalendar({ jurisdictions: { US: ['2028-02-29'] } }), + ); const tmpFile = await writeTempCalendar(fileContent); await service.loadCalendar(tmpFile, SECRET); @@ -428,5 +501,22 @@ describe('HolidayCalendarService', () => { expect(result.shifted).toBe(true); expect(result.shiftedDate.toISOString().slice(0, 10)).toBe('2028-02-28'); }); + + it('handles holidays spanning a long weekend by skipping consecutive blackout days', async () => { + // UK Easter 2026: Good Friday 2026-04-03, Easter Monday 2026-04-06. + const fileContent = createSignedCalendarFile( + baseCalendar({ jurisdictions: { GB: ['2026-04-03', '2026-04-06'] } }), + ); + const tmpFile = await writeTempCalendar(fileContent); + await service.loadCalendar(tmpFile, SECRET); + + const result = service.getShiftedDate(new Date('2026-04-06'), ['GB']); + expect(result.shifted).toBe(true); + // Previous settleable day: 2026-04-03 (Fri) is blacked out, weekend skipped, + // so Tuesday 2026-04-07? No — previous direction from 04-06 (Mon): + // 04-05 Sun (skip), 04-04 Sat (skip), 04-03 Fri blackout (skip), + // 04-02 Thu -> settleable. + expect(result.shiftedDate.toISOString().slice(0, 10)).toBe('2026-04-02'); + }); }); }); diff --git a/src/services/holidayCalendarService.ts b/src/services/holidayCalendarService.ts index 7da12c51..040e979a 100644 --- a/src/services/holidayCalendarService.ts +++ b/src/services/holidayCalendarService.ts @@ -1,105 +1,108 @@ /** * @title HolidayCalendarService - * @notice Manages jurisdiction-specific bank holiday calendars with signed static files - * and per-jurisdiction overrides. Distribution windows skip blackout days so investor - * bank rails receive funds on a settleable day. + * @notice Jurisdiction-aware bank-holiday blackout calendar for the distribution + * scheduler, loaded from a signed static file with per-jurisdiction + * overrides and a fallback shift policy. * - * @dev Signature validation occurs BEFORE applying the calendar to prevent tampering. - * The calendar is loaded from a signed static file containing a base64 payload and - * an HMAC-SHA256 signature. + * @dev The service answers two questions for the scheduler: + * 1. `isBlackout(date, jurisdictions)` – is this a blackout day? + * 2. `getShiftedDate(date, jurisdictions)` – which settleable day should + * a distribution window scheduled for `date` actually run on? + * + * Shift semantics (issue #664): + * - A blackout day is shifted to the previous or next business day per + * the configured fallback policy. + * - The shifted date must itself be a *settleable* day: it must not fall + * on a weekend AND must not be a blackout for ANY of the jurisdictions + * that caused the original shift (overlapping holidays across + * jurisdictions apply the strictest shift — we keep shifting until the + * candidate day is clear for all affected jurisdictions). + * - Per-jurisdiction overrides extend the base holiday set so regional + * calendars (e.g. `US-NY`) can be layered on top of country calendars. + * + * The calendar is distributed as a signed static file so updates are + * auditable: `loadCalendar()` validates the HMAC-SHA256 signature with a + * constant-time comparison BEFORE the payload is applied (fail-closed), + * computes a SHA-256 hash of the canonical payload, and persists the hash + * in a `holiday_calendar.load` audit event. * * Security assumptions: - * - The signing secret is stored securely in environment variables and never logged. - * - Signature validation uses constant-time comparison (timingSafeEqual). - * - If validation fails, the calendar is rejected entirely (fail-closed). - * - Overlapping holidays across jurisdictions apply the strictest shift. - * - The calendar hash is persisted in an audit event for operational traceability. + * - The signing secret lives in the environment (`HOLIDAY_CALENDAR_SECRET`) + * and is never logged. + * - Signature comparison uses `crypto.timingSafeEqual` on same-length buffers. + * - A missing file, bad signature, malformed payload, or empty secret rejects + * the whole calendar — the service stays uninitialised and the scheduler + * falls back to its current behaviour (no shifting). + * - Unknown jurisdictions are ignored (no shift) so calendar roll-outs do not + * break schedulers for unlisted regions. * - * Abuse/failure paths handled: - * - Missing or unreadable calendar file → service remains uninitialized. - * - Invalid or mismatched signature → calendar rejected, error logged. - * - Malformed JSON payload → calendar rejected, error logged. - * - Empty secret → calendar rejected. - * - Unknown jurisdiction → falls back to default behavior (no shift). + * @see ../../docs/holiday-calendar-blackouts.md */ import { createHmac, timingSafeEqual, createHash } from 'crypto'; import { Logger, globalLogger } from '../lib/logger'; import { MetricsCollector, globalMetrics } from '../lib/metrics'; import { SecurityAuditRepository, AuditEvent } from '../security/types'; -import { Errors } from '../lib/errors'; -// ─── Types ───────────────────────────────────────────────────────────────────── +// ─── Public types ───────────────────────────────────────────────────────────── -/** - * Canonical holiday calendar payload loaded from the signed static file. - */ export interface HolidayCalendarPayload { /** Semantic version of the calendar schema. */ version: string; - /** ISO 8601 date strings (YYYY-MM-DD) keyed by jurisdiction code. */ + /** ISO date strings (YYYY-MM-DD) keyed by jurisdiction code. */ jurisdictions: Record; - /** Per-offering/jurisdiction overrides that augment or replace base holidays. */ + /** Per-jurisdiction overrides that augment the base holiday set. */ overrides: Record; /** ISO timestamp when the calendar was generated. */ generatedAt: string; } -/** - * Raw signed file format expected on disk. - */ export interface SignedHolidayCalendarFile { /** Base64-encoded canonical JSON of the HolidayCalendarPayload. */ payload: string; - /** HMAC-SHA256 signature in sha256= format. */ + /** HMAC-SHA256 signature in `sha256=` format. */ signature: string; } -/** - * Result of a blackout shift decision. - */ +export type ShiftDirection = 'previous' | 'next'; + export interface BlackoutShiftDecision { - /** Original scheduled date before shift. */ + /** Originally scheduled date before any shift. */ originalDate: Date; - /** Shifted date after applying holiday rules. */ + /** Settleable date after applying blackout rules. */ shiftedDate: Date; /** Whether a shift occurred. */ shifted: boolean; - /** Human-readable reason for the shift. */ + /** Human-readable reason for the decision. */ reason: string; /** Jurisdiction codes that caused the blackout. */ jurisdictions: string[]; - /** Direction of the shift. */ - direction: 'previous' | 'next'; + /** Direction the shift moved in. */ + direction: ShiftDirection; } -/** - * Configuration for the holiday calendar service. - */ export interface HolidayCalendarServiceOptions { - /** Custom logger instance. */ logger?: Logger; - /** Metrics collector for emitting scheduler.blackout.shift. */ metrics?: MetricsCollector; - /** Audit repository for persisting calendar load events. */ auditRepository?: SecurityAuditRepository; /** Fallback shift policy when no explicit policy is configured. */ - fallbackShiftPolicy?: 'previous' | 'next'; + fallbackShiftPolicy?: ShiftDirection; } // ─── Constants ──────────────────────────────────────────────────────────────── -const METRIC_BLACKOUT_SHIFT = 'scheduler_blackout_shift_total'; +const METRIC_BLACKOUT_SHIFT = 'scheduler.blackout.shift'; const AUDIT_ACTION_LOAD = 'holiday_calendar.load'; const AUDIT_RESOURCE = 'holiday_calendar'; +const MAX_SHIFT_ITERATIONS = 366; -// ─── HolidayCalendarService ────────────────────────────────────────────────── +// ─── Service ───────────────────────────────────────────────────────────────── export class HolidayCalendarService { private readonly logger: Logger; - private readonly metrics: MetricsCollector; + private readonly metrics?: MetricsCollector; private readonly auditRepository?: SecurityAuditRepository; - private readonly fallbackShiftPolicy: 'previous' | 'next'; + private readonly fallbackShiftPolicy: ShiftDirection; private loaded = false; private payload: HolidayCalendarPayload | null = null; @@ -107,31 +110,28 @@ export class HolidayCalendarService { constructor(options: HolidayCalendarServiceOptions = {}) { this.logger = options.logger ?? globalLogger; - this.metrics = options.metrics ?? (globalMetrics as any); + this.metrics = options.metrics ?? globalMetrics; this.auditRepository = options.auditRepository; this.fallbackShiftPolicy = options.fallbackShiftPolicy ?? 'previous'; } /** - * Load and validate a signed holiday calendar file. + * @notice Load and validate a signed holiday calendar file. * - * @param filePath Absolute path to the signed static file. - * @param secret HMAC secret used to validate the file signature. - * @throws Error if the file cannot be read, the signature is invalid, or the payload is malformed. + * @dev Signature validation occurs BEFORE the payload is applied. On any + * validation failure the calendar is rejected in its entirety and the + * service remains uninitialised (fail-closed). + * + * @param filePath Absolute path to the signed static calendar file. + * @param secret HMAC secret used to sign the file. + * @throws If the file is unreadable, unsigned, tampered, or malformed. */ async loadCalendar(filePath: string, secret: string): Promise { if (!secret) { throw new Error('Holiday calendar secret is required'); } - let raw = ''; - try { - raw = await require('fs').promises.readFile(filePath, 'utf8'); - } catch (err) { - this.logger.error('Failed to read holiday calendar file', { filePath, error: err }); - throw new Error(`Failed to read holiday calendar file: ${filePath}`); - } - + const raw = await this.readFile(filePath); let file: SignedHolidayCalendarFile; try { file = JSON.parse(raw) as SignedHolidayCalendarFile; @@ -140,39 +140,34 @@ export class HolidayCalendarService { throw new Error('Malformed holiday calendar JSON'); } - if (!file.payload || !file.signature) { + if (!file.payload || typeof file.payload !== 'string' || !file.signature) { this.logger.error('Holiday calendar file missing payload or signature', { filePath }); throw new Error('Holiday calendar file must contain payload and signature'); } const expectedSig = this.computeSignature(secret, file.payload); - - const signatureBuffer = Buffer.from(file.signature, 'utf8'); - const expectedBuffer = Buffer.from(expectedSig, 'utf8'); - - if (signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer)) { + const receivedBuf = Buffer.from(file.signature, 'utf8'); + const expectedBuf = Buffer.from(expectedSig, 'utf8'); + if (receivedBuf.length !== expectedBuf.length || !timingSafeEqual(receivedBuf, expectedBuf)) { this.logger.error('Holiday calendar signature verification failed', { filePath }); throw new Error('Holiday calendar signature verification failed'); } - let decoded: unknown; - try { - decoded = JSON.parse(Buffer.from(file.payload, 'base64').toString('utf8')); - } catch { - this.logger.error('Malformed holiday calendar base64 payload', { filePath }); - throw new Error('Malformed holiday calendar base64 payload'); - } - + const decoded = this.decodePayload(file.payload); if (!this.isValidPayload(decoded)) { this.logger.error('Invalid holiday calendar payload structure', { filePath }); throw new Error('Invalid holiday calendar payload structure'); } - this.payload = decoded as HolidayCalendarPayload; + this.payload = decoded; this.calendarHash = this.computePayloadHash(file.payload); this.loaded = true; - await this.recordAuditEvent('SUCCESS', { filePath, hash: this.calendarHash, version: this.payload.version }); + await this.recordAuditEvent('SUCCESS', { + filePath, + hash: this.calendarHash, + version: this.payload.version, + }); this.logger.info('Holiday calendar loaded and validated', { filePath, @@ -183,44 +178,36 @@ export class HolidayCalendarService { } /** - * Check whether a given date is a blackout day for the provided jurisdictions. - * - * @param date Date to evaluate (only the date portion is used). - * @param jurisdictions Array of jurisdiction codes (e.g. ['US', 'GB']). - * @returns True if the date falls on a holiday in any of the jurisdictions. + * @notice Check whether `date` is a blackout day in any of the jurisdictions. */ isBlackout(date: Date, jurisdictions: string[]): boolean { this.ensureLoaded(); const dateStr = this.toDateString(date); if (!dateStr) return false; - for (const jurisdiction of jurisdictions) { - const holidays = this.getHolidaysForJurisdiction(jurisdiction); - if (holidays.has(dateStr)) { - return true; - } - } - - return false; + return jurisdictions.some((jurisdiction) => + this.getHolidaysForJurisdiction(jurisdiction).has(dateStr), + ); } /** - * Compute the shifted date for a given date and set of jurisdictions. + * @notice Compute the settleable shifted date for a scheduled distribution day. * - * If the date is not a blackout day, returns the original date unchanged. - * If it is a blackout day, shifts according to the fallback policy. + * @dev If `date` is not a blackout day the original date is returned + * unchanged. Otherwise the date is shifted in the `fallbackShiftPolicy` + * direction until a day that is neither a weekend nor a blackout for any + * of the jurisdictions that caused the shift is found. Overlapping + * holidays across jurisdictions therefore apply the strictest shift — + * the scheduler only ever lands on a day that settles for every affected + * jurisdiction. * - * Overlapping holidays in multiple jurisdictions are handled by applying the - * strictest shift: if any jurisdiction requires a shift, the date is shifted. - * - * @param date Date to evaluate. - * @param jurisdictions Array of jurisdiction codes. - * @returns BlackoutShiftDecision describing the result. + * @param date Scheduled distribution date. + * @param jurisdictions Jurisdiction codes that govern the distribution. */ getShiftedDate(date: Date, jurisdictions: string[]): BlackoutShiftDecision { this.ensureLoaded(); - const dateStr = this.toDateString(date); const originalDate = new Date(date); + const dateStr = this.toDateString(date); if (!dateStr) { return { @@ -233,13 +220,9 @@ export class HolidayCalendarService { }; } - const blackoutJurisdictions: string[] = []; - for (const jurisdiction of jurisdictions) { - const holidays = this.getHolidaysForJurisdiction(jurisdiction); - if (holidays.has(dateStr)) { - blackoutJurisdictions.push(jurisdiction); - } - } + const blackoutJurisdictions = jurisdictions.filter((jurisdiction) => + this.getHolidaysForJurisdiction(jurisdiction).has(dateStr), + ); if (blackoutJurisdictions.length === 0) { return { @@ -253,11 +236,14 @@ export class HolidayCalendarService { } const direction = this.fallbackShiftPolicy; - const shiftedDate = this.findBusinessDay(originalDate, direction); + // Strictest shift: the settleable day must be clear for EVERY jurisdiction + // in the distribution (not only the ones that blacked out the original day). + const shiftedDate = this.findSettleableDay(originalDate, direction, jurisdictions); - const reason = blackoutJurisdictions.length === 1 - ? `Blackout in jurisdiction ${blackoutJurisdictions[0]}` - : `Blackout across ${blackoutJurisdictions.length} jurisdictions: ${blackoutJurisdictions.join(', ')}`; + const reason = + blackoutJurisdictions.length === 1 + ? `Blackout in jurisdiction ${blackoutJurisdictions[0]}` + : `Blackout across ${blackoutJurisdictions.length} jurisdictions: ${blackoutJurisdictions.join(', ')}`; const decision: BlackoutShiftDecision = { originalDate, @@ -269,27 +255,42 @@ export class HolidayCalendarService { }; this.emitBlackoutMetric(decision); - return decision; } - /** - * Returns true if the calendar has been successfully loaded. - */ isLoaded(): boolean { return this.loaded; } - /** - * Returns the SHA-256 hash of the canonical payload for audit purposes. - * Returns null if the calendar has not been loaded. - */ + /** SHA-256 hash of the canonical payload, or null before first load. */ getCalendarHash(): string | null { return this.calendarHash; } // ─── Private helpers ──────────────────────────────────────────────────────── + private async readFile(filePath: string): Promise { + try { + const fs = await import('fs'); + return await fs.promises.readFile(filePath, 'utf8'); + } catch (err) { + this.logger.error('Failed to read holiday calendar file', { + filePath, + error: err instanceof Error ? err.message : String(err), + }); + throw new Error(`Failed to read holiday calendar file: ${filePath}`); + } + } + + private decodePayload(base64Payload: string): unknown { + try { + return JSON.parse(Buffer.from(base64Payload, 'base64').toString('utf8')); + } catch { + this.logger.error('Malformed holiday calendar base64 payload'); + throw new Error('Malformed holiday calendar base64 payload'); + } + } + private ensureLoaded(): void { if (!this.loaded || !this.payload) { throw new Error('Holiday calendar has not been loaded. Call loadCalendar() first.'); @@ -304,30 +305,44 @@ export class HolidayCalendarService { return `${year}-${month}-${day}`; } + /** Base holidays plus any per-jurisdiction override dates. */ private getHolidaysForJurisdiction(jurisdiction: string): Set { if (!this.payload) return new Set(); - - const overrideKey = jurisdiction; - const overrideDates = this.payload.overrides[overrideKey]; - const baseDates = this.payload.jurisdictions[jurisdiction] ?? []; - - const combined = new Set([...baseDates, ...(overrideDates ?? [])]); - return combined; + const base = this.payload.jurisdictions[jurisdiction] ?? []; + const override = this.payload.overrides[jurisdiction] ?? []; + return new Set([...base, ...override]); } - private findBusinessDay(startDate: Date, direction: 'previous' | 'next'): Date { - const d = new Date(startDate); + /** + * Walk from `startDate` in `direction` until a day that is a weekday and not + * a blackout for any of the distribution's jurisdictions is found. This is + * the "strictest shift" rule: overlapping holidays keep the scheduler moving + * until every jurisdiction in the distribution can settle on the same day. + */ + private findSettleableDay( + startDate: Date, + direction: ShiftDirection, + affectedJurisdictions: string[], + ): Date { const step = direction === 'previous' ? -1 : 1; + const cursor = new Date(startDate); + + for (let i = 0; i < MAX_SHIFT_ITERATIONS; i++) { + cursor.setUTCDate(cursor.getUTCDate() + step); + const dayOfWeek = cursor.getUTCDay(); + if (dayOfWeek === 0 || dayOfWeek === 6) continue; + + const candidate = this.toDateString(cursor); + const stillBlackout = affectedJurisdictions.some((jurisdiction) => { + if (!candidate) return false; + return this.getHolidaysForJurisdiction(jurisdiction).has(candidate); + }); + if (stillBlackout) continue; - for (let i = 0; i < 366; i++) { - d.setUTCDate(d.getUTCDate() + step); - const dayOfWeek = d.getUTCDay(); - if (dayOfWeek !== 0 && dayOfWeek !== 6) { - return d; - } + return new Date(cursor); } - throw new Error('Unable to find business day within 366 iterations'); + throw new Error('Unable to find a settleable day within the shift horizon'); } private computeSignature(secret: string, base64Payload: string): string { @@ -343,59 +358,63 @@ export class HolidayCalendarService { private isValidPayload(obj: unknown): obj is HolidayCalendarPayload { if (!obj || typeof obj !== 'object') return false; const record = obj as Record; - if (typeof record.version !== 'string') return false; if (typeof record.generatedAt !== 'string') return false; if (typeof record.jurisdictions !== 'object' || record.jurisdictions === null) return false; if (typeof record.overrides !== 'object' || record.overrides === null) return false; - return true; + const jurisdictions = record.jurisdictions as Record; + const overrides = record.overrides as Record; + return ( + Object.values(jurisdictions).every((v) => Array.isArray(v) && v.every((d) => typeof d === 'string')) && + Object.values(overrides).every((v) => Array.isArray(v) && v.every((d) => typeof d === 'string')) + ); } private emitBlackoutMetric(decision: BlackoutShiftDecision): void { - if (!this.metrics || typeof (this.metrics as any).incrementCounter !== 'function') return; - try { - (this.metrics as any).incrementCounter( + this.metrics?.incrementCounter( METRIC_BLACKOUT_SHIFT, { direction: decision.direction, jurisdiction_count: String(decision.jurisdictions.length), }, 1, - 'Total number of distribution blackout shifts due to jurisdiction holidays' + 'Total number of distribution blackout shifts due to jurisdiction holidays', ); } catch { - // Metrics emission must not break business logic + // Metric emission must never break the scheduling decision. } } - private async recordAuditEvent(outcome: 'SUCCESS' | 'FAILURE', details: Record): Promise { + private async recordAuditEvent( + outcome: 'SUCCESS' | 'FAILURE', + details: Record, + ): Promise { if (!this.auditRepository) return; - try { - const event: AuditEvent = { - id: `audit_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - type: 'VALIDATION', - action: AUDIT_ACTION_LOAD, - resource: AUDIT_RESOURCE, - outcome, - details: { - ...details, - calendarHash: this.calendarHash, - }, - securityContext: { - requestId: `holiday-calendar-${Date.now()}`, - ipAddress: 'system', - userAgent: 'holiday-calendar-service', - timestamp: new Date(), - }, + const event: AuditEvent = { + id: `audit_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + type: 'VALIDATION', + action: AUDIT_ACTION_LOAD, + resource: AUDIT_RESOURCE, + outcome, + details: { ...details, calendarHash: this.calendarHash }, + securityContext: { + requestId: `holiday-calendar-${Date.now()}`, + ipAddress: 'system', + userAgent: 'holiday-calendar-service', timestamp: new Date(), - }; + }, + timestamp: new Date(), + }; + try { await this.auditRepository.record(event); } catch (err) { - this.logger.warn('Failed to record holiday calendar audit event', { error: err }); + this.logger.warn('Failed to record holiday calendar audit event', { + error: err instanceof Error ? err.message : String(err), + }); } } }