Skip to content

Commit 575ff4d

Browse files
Core hooks for regional compliance integrations: invoice.* events, delivery-failure alerting, webhook docs (#162)
* Add fiscal document references on folios with invoice.* events Regional tax integrations (e.g. NFS-e tax notes in Brazil) can now be built on core hooks: staff request a fiscal document on a folio (emits invoice.requested), an external integration performs the issuance against the government API and reports the official document number back (emits invoice.issued), with voiding supported (invoice.voided). Core stores only the reference and lifecycle; regional fields live in metadata jsonb. Requested in #159. Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com> * Alert staff when a webhook delivery permanently fails A delivery that exhausts all 5 retry attempts (~7h) previously just marked the row failed — silent for operators. For mandatory-delivery subscribers (e.g. government reporting integrations, #159) that is not acceptable. WebhookDeliveryService now emits an in-process webhook.delivery_failed event on permanent failure (deliberately not fanned out to external subscribers — their endpoint is the thing failing), and StaffNotificationListener turns it into a critical staff notification. Failures of staff.notification_created deliveries are skipped to prevent an alert-on-alert loop. Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com> * Document webhooks, event payload conventions, and compliance integration patterns New docs/webhooks.md covers subscribing via the Connect API, HMAC signature verification, the lean-payload / fetch-by-entityId convention, retry and at-least-once semantics, the polling reconciliation fallback, the fiscal document (invoice.*) flow, and a worked government guest-registration example. Linked from the README webhook engine section. Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com> * Sync README test counts after new webhook/fiscal-document specs CI requires the badge and docs counts to match the suite (1129 tests / 136 files after the 18 new tests in this PR). Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
1 parent 7919d3d commit 575ff4d

16 files changed

Lines changed: 1010 additions & 8 deletions

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
1515
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
1616
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
17-
<img src="https://img.shields.io/badge/Tests-1115%20passing-brightgreen" alt="1115 Tests Passing" />
17+
<img src="https://img.shields.io/badge/Tests-1129%20passing-brightgreen" alt="1129 Tests Passing" />
1818
<img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
1919
</p>
2020

@@ -376,9 +376,11 @@ executes; approval always runs the agent's own recommendation. Off by default
376376

377377
### Webhook Engine
378378
- Real-time webhook delivery on every entity state change
379-
- 64 event types including accounting events (`deposit.received`, `ar.transfer_created`, `cashdrawer.session_closed`), house-account & folio events (`houseaccount.opened`, `folio.transactions_moved`, `payment.corrected`), group events (`group.block_created`, `group.rooming_list_imported`), reservation-ops events (`reservation.note_added`, `reservation.message_sent`, `reservation.bulk_action_completed`), and AI agent events (`agent.decision_made`, `agent.cancellation_forecast_updated`, `guest.communication_drafted`, `guest.review_response_drafted`)
379+
- 73 event types including accounting events (`deposit.received`, `ar.transfer_created`, `cashdrawer.session_closed`), house-account & folio events (`houseaccount.opened`, `folio.transactions_moved`, `payment.corrected`), fiscal document events (`invoice.requested`, `invoice.issued`, `invoice.voided`), group events (`group.block_created`, `group.rooming_list_imported`), reservation-ops events (`reservation.note_added`, `reservation.message_sent`, `reservation.bulk_action_completed`), and AI agent events (`agent.decision_made`, `agent.cancellation_forecast_updated`, `guest.communication_drafted`, `guest.review_response_drafted`)
380380
- Event format: `entity.action` (e.g., `reservation.created`, `housekeeping.task_completed`)
381381
- Subscription management for external consumers
382+
- HMAC-signed deliveries with retries; permanent failures raise a critical staff notification
383+
- See **[`docs/webhooks.md`](./docs/webhooks.md)** for the integration guide (signature verification, payload conventions, fiscal documents, regional compliance examples)
382384

383385
### ChatGPT Gateway (Connect GPT)
384386
- A standalone, deployable **gateway that exposes HAIP hotel search & booking as a ChatGPT Custom GPT Action** (`tools/haip-connect-gpt`) — guests search availability and create/modify/cancel reservations by chatting
@@ -425,7 +427,7 @@ executes; approval always runs the agent's own recommendation. Off by default
425427
| OTA Channels | Booking.com + Expedia (EQC) direct + SiteMinder (pmsXchange) | Direct + aggregated OTA connectivity (ARI + content) |
426428
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
427429
| Package Manager | pnpm workspaces | Monorepo management |
428-
| Testing | Vitest (1115 tests across 134 test files) | Unit and integration tests |
430+
| Testing | Vitest (1129 tests across 136 test files) | Unit and integration tests |
429431
| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
430432
| Containers | Docker + docker-compose | Local dev and production deployment |
431433
| CI/CD | GitHub Actions | Automated testing, builds, and releases |
@@ -547,7 +549,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
547549
### Run tests
548550

549551
```bash
550-
# All tests (1115 tests across 134 test files)
552+
# All tests (1129 tests across 136 test files)
551553
pnpm test
552554

553555
# API tests only
@@ -1063,7 +1065,7 @@ HAIP is built in public and contributions are welcome.
10631065
pnpm install # Install dependencies
10641066
pnpm build # Build all workspace packages
10651067
pnpm dev # Start API in dev mode (hot reload)
1066-
pnpm test # Run all tests (1115 tests, 134 files)
1068+
pnpm test # Run all tests (1129 tests, 136 files)
10671069
pnpm typecheck # TypeScript strict check
10681070
pnpm lint # ESLint
10691071
```
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
IsString,
3+
IsNotEmpty,
4+
IsOptional,
5+
IsUUID,
6+
IsUrl,
7+
IsObject,
8+
IsDateString,
9+
MaxLength,
10+
} from 'class-validator';
11+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
12+
13+
export class RequestFiscalDocumentDto {
14+
@ApiProperty({ description: 'Property ID' })
15+
@IsUUID()
16+
@IsNotEmpty()
17+
propertyId!: string;
18+
19+
@ApiProperty({
20+
example: 'nfse',
21+
description:
22+
'Regional document type identifier (e.g. "nfse", "invoice"). Core does not interpret this value.',
23+
})
24+
@IsString()
25+
@IsNotEmpty()
26+
@MaxLength(50)
27+
documentType!: string;
28+
29+
@ApiPropertyOptional({ description: 'Regional extras passed through to the issuing integration' })
30+
@IsOptional()
31+
@IsObject()
32+
metadata?: Record<string, unknown>;
33+
}
34+
35+
export class IssueFiscalDocumentDto {
36+
@ApiProperty({ description: 'Property ID' })
37+
@IsUUID()
38+
@IsNotEmpty()
39+
propertyId!: string;
40+
41+
@ApiProperty({ example: '2026-000123', description: 'Official document number assigned by the issuer' })
42+
@IsString()
43+
@IsNotEmpty()
44+
@MaxLength(100)
45+
documentNumber!: string;
46+
47+
@ApiPropertyOptional({ description: 'Link to the official document (PDF/XML) at the issuer' })
48+
@IsOptional()
49+
@IsUrl({ require_protocol: true })
50+
documentUrl?: string;
51+
52+
@ApiPropertyOptional({ description: 'Issuance timestamp (defaults to now)' })
53+
@IsOptional()
54+
@IsDateString()
55+
issuedAt?: string;
56+
57+
@ApiPropertyOptional({ description: 'Regional extras (series, verification code, ...)' })
58+
@IsOptional()
59+
@IsObject()
60+
metadata?: Record<string, unknown>;
61+
}
62+
63+
export class VoidFiscalDocumentDto {
64+
@ApiProperty({ description: 'Property ID' })
65+
@IsUUID()
66+
@IsNotEmpty()
67+
propertyId!: string;
68+
69+
@ApiPropertyOptional({ description: 'Reason for voiding the document' })
70+
@IsOptional()
71+
@IsString()
72+
reason?: string;
73+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { NotFoundException, BadRequestException } from '@nestjs/common';
3+
import { FiscalDocumentService } from './fiscal-document.service';
4+
import { WebhookService } from '../webhook/webhook.service';
5+
import { DRIZZLE } from '../../database/database.module';
6+
7+
const mockFolio = {
8+
id: 'folio-001',
9+
propertyId: 'prop-001',
10+
folioNumber: 'F-2026-0001',
11+
status: 'settled',
12+
};
13+
14+
const mockDoc = {
15+
id: 'fdoc-001',
16+
propertyId: 'prop-001',
17+
folioId: 'folio-001',
18+
documentType: 'nfse',
19+
status: 'requested',
20+
documentNumber: null,
21+
metadata: { municipalCode: '3550308' },
22+
};
23+
24+
/**
25+
* Table-aware mock: dispatches select() results by pgTable name so folio
26+
* lookups and fiscal-document lookups can return different rows.
27+
*/
28+
function createMockDb({
29+
folioRows = [mockFolio],
30+
docRows = [mockDoc],
31+
written = [mockDoc],
32+
}: {
33+
folioRows?: any[];
34+
docRows?: any[];
35+
written?: any[];
36+
} = {}) {
37+
const tableName = (tbl: any) =>
38+
String(tbl?.[Symbol.for('drizzle:Name')] ?? tbl?._?.name ?? '');
39+
40+
return {
41+
select: vi.fn(() => ({
42+
from: vi.fn((tbl: any) => {
43+
const rows = tableName(tbl).includes('folios') ? folioRows : docRows;
44+
const result: any = Promise.resolve(rows);
45+
result.orderBy = vi.fn().mockResolvedValue(rows);
46+
return { where: vi.fn(() => result) };
47+
}),
48+
})),
49+
insert: vi.fn(() => ({
50+
values: vi.fn(() => ({
51+
returning: vi.fn().mockResolvedValue(written),
52+
})),
53+
})),
54+
update: vi.fn(() => ({
55+
set: vi.fn(() => ({
56+
where: vi.fn(() => ({
57+
returning: vi.fn().mockResolvedValue(written),
58+
})),
59+
})),
60+
})),
61+
};
62+
}
63+
64+
const mockWebhookService = { emit: vi.fn() };
65+
66+
async function buildService(db: any) {
67+
const module: TestingModule = await Test.createTestingModule({
68+
providers: [
69+
FiscalDocumentService,
70+
{ provide: DRIZZLE, useValue: db },
71+
{ provide: WebhookService, useValue: mockWebhookService },
72+
],
73+
}).compile();
74+
return module.get<FiscalDocumentService>(FiscalDocumentService);
75+
}
76+
77+
describe('FiscalDocumentService', () => {
78+
beforeEach(() => {
79+
vi.clearAllMocks();
80+
});
81+
82+
describe('request', () => {
83+
it('creates a requested document and emits invoice.requested', async () => {
84+
const svc = await buildService(createMockDb());
85+
const result = await svc.request('folio-001', {
86+
propertyId: 'prop-001',
87+
documentType: 'nfse',
88+
metadata: { municipalCode: '3550308' },
89+
});
90+
91+
expect(result.status).toBe('requested');
92+
expect(mockWebhookService.emit).toHaveBeenCalledWith(
93+
'invoice.requested',
94+
'fiscal_document',
95+
mockDoc.id,
96+
{
97+
folioId: 'folio-001',
98+
folioNumber: 'F-2026-0001',
99+
documentType: 'nfse',
100+
},
101+
'prop-001',
102+
);
103+
});
104+
105+
it('throws NotFound when the folio is not at the property (multi-tenancy)', async () => {
106+
const svc = await buildService(createMockDb({ folioRows: [] }));
107+
await expect(
108+
svc.request('folio-001', { propertyId: 'other-prop', documentType: 'nfse' }),
109+
).rejects.toThrow(NotFoundException);
110+
expect(mockWebhookService.emit).not.toHaveBeenCalled();
111+
});
112+
});
113+
114+
describe('issue', () => {
115+
it('marks a requested document issued and emits invoice.issued', async () => {
116+
const issued = {
117+
...mockDoc,
118+
status: 'issued',
119+
documentNumber: '2026-000123',
120+
issuedAt: new Date(),
121+
};
122+
const svc = await buildService(createMockDb({ written: [issued] }));
123+
124+
const result = await svc.issue('folio-001', 'fdoc-001', {
125+
propertyId: 'prop-001',
126+
documentNumber: '2026-000123',
127+
documentUrl: 'https://issuer.example.gov/doc/123.pdf',
128+
});
129+
130+
expect(result.status).toBe('issued');
131+
expect(mockWebhookService.emit).toHaveBeenCalledWith(
132+
'invoice.issued',
133+
'fiscal_document',
134+
issued.id,
135+
{
136+
folioId: 'folio-001',
137+
documentType: 'nfse',
138+
documentNumber: '2026-000123',
139+
},
140+
'prop-001',
141+
);
142+
});
143+
144+
it('rejects issuing a document that is not in requested state', async () => {
145+
const alreadyIssued = { ...mockDoc, status: 'issued' };
146+
const svc = await buildService(createMockDb({ docRows: [alreadyIssued] }));
147+
await expect(
148+
svc.issue('folio-001', 'fdoc-001', {
149+
propertyId: 'prop-001',
150+
documentNumber: '2026-000124',
151+
}),
152+
).rejects.toThrow(BadRequestException);
153+
expect(mockWebhookService.emit).not.toHaveBeenCalled();
154+
});
155+
156+
it('throws NotFound when the document is not at the property (multi-tenancy)', async () => {
157+
const svc = await buildService(createMockDb({ docRows: [] }));
158+
await expect(
159+
svc.issue('folio-001', 'fdoc-001', {
160+
propertyId: 'other-prop',
161+
documentNumber: '2026-000123',
162+
}),
163+
).rejects.toThrow(NotFoundException);
164+
});
165+
});
166+
167+
describe('void', () => {
168+
it('voids an issued document and emits invoice.voided', async () => {
169+
const issued = { ...mockDoc, status: 'issued', documentNumber: '2026-000123' };
170+
const voided = { ...issued, status: 'voided', voidReason: 'duplicate' };
171+
const svc = await buildService(
172+
createMockDb({ docRows: [issued], written: [voided] }),
173+
);
174+
175+
const result = await svc.void('folio-001', 'fdoc-001', {
176+
propertyId: 'prop-001',
177+
reason: 'duplicate',
178+
});
179+
180+
expect(result.status).toBe('voided');
181+
expect(mockWebhookService.emit).toHaveBeenCalledWith(
182+
'invoice.voided',
183+
'fiscal_document',
184+
voided.id,
185+
{
186+
folioId: 'folio-001',
187+
documentType: 'nfse',
188+
documentNumber: '2026-000123',
189+
},
190+
'prop-001',
191+
);
192+
});
193+
194+
it('rejects voiding an already-voided document', async () => {
195+
const voided = { ...mockDoc, status: 'voided' };
196+
const svc = await buildService(createMockDb({ docRows: [voided] }));
197+
await expect(
198+
svc.void('folio-001', 'fdoc-001', { propertyId: 'prop-001' }),
199+
).rejects.toThrow(BadRequestException);
200+
});
201+
});
202+
203+
describe('list', () => {
204+
it('throws NotFound when the folio is not at the property (multi-tenancy)', async () => {
205+
const svc = await buildService(createMockDb({ folioRows: [] }));
206+
await expect(svc.list('folio-001', 'other-prop')).rejects.toThrow(
207+
NotFoundException,
208+
);
209+
});
210+
211+
it('returns documents for the folio', async () => {
212+
const svc = await buildService(createMockDb());
213+
const rows = await svc.list('folio-001', 'prop-001');
214+
expect(rows).toEqual([mockDoc]);
215+
});
216+
});
217+
});

0 commit comments

Comments
 (0)