@taxora/sdk 1.2.0 → 1.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@taxora/sdk` are documented here. Versions follow [SemVer](https://semver.org/).
4
+
5
+ ## [1.3.0] — 2026-10-13
6
+
7
+ ### Added
8
+
9
+ - **Webhook signature verification** for Taxora's opt-in HMAC-signed webhooks (`Taxora-Signature: t=…,v1=…`):
10
+ - `verifyWebhookSignature(rawBody, header, secret, { tolerance })` — constant-time comparison, timestamp
11
+ tolerance against replays (default 300 s), accepts several `v1` entries (rotation) and several secrets.
12
+ - `constructWebhookEvent(rawBody, header, secret, options)` — verifies, then returns a typed
13
+ `TaxoraWebhookEvent` (discriminated union on `event`).
14
+ - `generateWebhookSignatureHeader()` for testing your own handlers, plus `parseWebhookSignatureHeader()` and
15
+ `computeWebhookSignature()`.
16
+ - `WebhookSignatureVerificationError` with a machine-readable `reason`.
17
+ - Event types: `vat.validated`, `vat.state_changed`, `compliance.ledger.state_changed`,
18
+ `compliance.transaction.state_changed`, `enrichment.completed`, `enrichment.updated`, `webhook.test`.
19
+
20
+ ## [1.2.0]
21
+
22
+ - Per-country compliance services incl. Norway e-invoicing (EHF / Peppol). See the GitHub release notes.
package/README.md CHANGED
@@ -151,6 +151,12 @@ Each endpoint handles:
151
151
  | `Token` | Access token with type and expiry; 15-second buffer for proactive refresh |
152
152
  | `VatCertificateExport` | Bulk export job reference (`exportId` + optional message) |
153
153
  | `ProviderDocument` | Attached provider document metadata (type, date, MIME, hash, nested line item) |
154
+ | `ComplianceEnrollment` | Per-country compliance enrollment: country, regime, `service`, provisioning status |
155
+ | `ComplianceTransaction` | Recorded transaction / e-invoice: amounts, state, buyer fields, `documentType`, `providerState` |
156
+ | `ComplianceTaxReport` | DGFiP tax report lifecycle of a transaction (France) |
157
+ | `SireneLookupResult` | Company data from the French SIRENE registry |
158
+ | `RegistryCompany` | Company from a national register (Norway: Brønnøysund) — org number, VAT / register flags |
159
+ | `PeppolLookupResult` | Peppol directory lookup: `status` (reachable / not_reachable / pending), document types |
154
160
 
155
161
  All DTOs expose `toArray()` for serialization and a static `fromArray()` factory.
156
162
 
@@ -443,6 +449,12 @@ submit transactions (individually or via CSV import), and track the resulting DG
443
449
  All `compliance` routes require an active E-Reporting subscription / feature grant on your
444
450
  account — except `requestEReportingAccess()`, which is how you ask for one.
445
451
 
452
+ **Per-country services:** each enrollment books one service for one country — `e_reporting`
453
+ (France, regime `dgfip_flux10`) or `e_invoicing` (Norway EHF / Peppol BIS 3.0, regime
454
+ `peppol_bis3`, see [below](#-e-invoicing-norway-ehf--peppol)). Every country is unlocked and
455
+ billed separately (one-off setup fee + per document). Enrolling for a service your company does not
456
+ have yields a 403 `HttpException` whose body carries `code: "service_not_active"`.
457
+
446
458
  ### Request feature activation
447
459
 
448
460
  Not enabled yet? This route is deliberately **not** gated — it requests activation of the
@@ -455,6 +467,8 @@ await client.eReporting.requestEReportingAccess({
455
467
  phone: '+43 660 1234567',
456
468
  message: 'Please activate e-reporting for our account.',
457
469
  language: 'de', // locale of the confirmation mail
470
+ service: 'e_invoicing', // optional: 'e_reporting' (default) | 'e_invoicing'
471
+ countries: ['NO'], // optional: the countries you want the service for
458
472
  });
459
473
  ```
460
474
 
@@ -541,6 +555,8 @@ const transactions = await client.eReporting.listTransactions({
541
555
  dateTo: '2026-07-31',
542
556
  state: 'pending', // 'pending' | 'sending' | 'submitted' | 'error'
543
557
  transactionType: 'b2c_outbound',
558
+ complianceEnrollmentId: enrollment.id, // optional: one enrollment only
559
+ country: 'FR', // optional: one country only
544
560
  page: 1,
545
561
  perPage: 25,
546
562
  });
@@ -624,6 +640,119 @@ for (const row of stats.byCountry) {
624
640
  }
625
641
  ```
626
642
 
643
+ ### 🇳🇴 E-Invoicing Norway (EHF / Peppol)
644
+
645
+ Send EHF e-invoices and credit notes (Peppol BIS Billing 3.0) to Norwegian buyers. The
646
+ `e_invoicing` service for Norway is billed separately from French e-reporting (one-off setup
647
+ fee + per document) — ask for it via `requestEReportingAccess({ service: 'e_invoicing', countries: ['NO'] })`.
648
+
649
+ **1. Registry lookup** — resolve an organisation number, `NO…MVA` VAT number or company name in
650
+ the Brønnøysund register (rate-limited to 30 requests/minute; 404 when nothing is found):
651
+
652
+ ```ts
653
+ import { NorwegianOrgNumber } from '@taxora/sdk';
654
+
655
+ const [company] = await client.eReporting.registryLookup('NO', '923609016');
656
+ console.log(company.companyName, company.vatNumber, company.vatRegistered, company.enterpriseRegister);
657
+
658
+ // Client-side helper (mod-11 check digit, accepts spaces / NO prefix / MVA suffix)
659
+ NorwegianOrgNumber.isValid('NO 923 609 016 MVA'); // true
660
+ NorwegianOrgNumber.normalize('923609016MVA'); // '923609016'
661
+ NorwegianOrgNumber.toVatNumber('923609016'); // 'NO923609016MVA'
662
+ ```
663
+
664
+ **2. Enrollment** — registers your company as a Peppol participant. The organisation number is
665
+ validated and normalized before sending:
666
+
667
+ ```ts
668
+ const noEnrollment = await client.eReporting.createNorwayEnrollment({
669
+ orgNumber: company.orgNumber,
670
+ companyName: company.companyName,
671
+ address: company.address!,
672
+ city: company.city!,
673
+ postalcode: company.postalcode!,
674
+ email: 'faktura@acme.no',
675
+ vatRegistered: company.vatRegistered, // optional, default true → "MVA" on invoices
676
+ enterpriseRegister: company.enterpriseRegister, // optional, default false → "Foretaksregisteret"
677
+ reception: false, // optional: also receive e-invoices over Peppol
678
+ });
679
+ // Equivalent: client.eReporting.createEnrollment({ country: 'NO', ... })
680
+
681
+ console.log(noEnrollment.service, noEnrollment.regime); // 'e_invoicing' 'peppol_bis3'
682
+ ```
683
+
684
+ **3. Peppol lookup** — check that the buyer can receive EHF invoices (free of charge). The
685
+ directory resolves asynchronously: `pending` means ask again in a few seconds.
686
+
687
+ ```ts
688
+ let reach = await client.eReporting.peppolLookup('NO', '974760673'); // scheme defaults to 0192
689
+ if (reach.isPending) {
690
+ await new Promise((r) => setTimeout(r, 3000));
691
+ reach = await client.eReporting.peppolLookup('NO', '974760673');
692
+ }
693
+ console.log(reach.status, reach.reachable, reach.documentTypes); // 'reachable' true [...]
694
+ ```
695
+
696
+ **4. Send an invoice** — Norway requires `transactionType: 'b2b_domestic_outbound'`, the buyer's
697
+ organisation number (`counterpartyRegisterId`) and `counterpartyName` (enforced server-side, 422):
698
+
699
+ ```ts
700
+ const invoice = await client.eReporting.createTransaction({
701
+ complianceEnrollmentId: noEnrollment.id,
702
+ transactionType: 'b2b_domestic_outbound',
703
+ invoiceNumber: 'NO-2026-0001',
704
+ invoiceDate: '2026-09-25',
705
+ dueDate: '2026-10-09',
706
+ currency: 'NOK',
707
+ subtotal: 2000,
708
+ taxAmount: 500,
709
+ total: 2500,
710
+ counterpartyName: 'EXAMPLE BUYER AS',
711
+ counterpartyCountry: 'NO',
712
+ counterpartyRegisterId: '974760673',
713
+ counterpartyAddress: 'Storgata 1',
714
+ counterpartyCity: 'Oslo',
715
+ counterpartyPostalcode: '0155',
716
+ counterpartyEmail: 'ap@buyer.example', // optional
717
+ buyerReference: 'PO-4711', // optional, defaults to the buyer's org number
718
+ invoiceLines: [
719
+ { description: 'Consulting', quantity: 2, price: 1000, taxes: [{ name: 'MVA', percent: 25, category: 'S' }] },
720
+ ],
721
+ });
722
+
723
+ // `state` tracks submission; `providerState` the Peppol delivery (e.g. sent, accepted, refused, paid)
724
+ const current = await client.eReporting.getTransaction(invoice.id);
725
+ console.log(current.state, current.providerState, current.documentType); // … 'invoice'
726
+ ```
727
+
728
+ **5. Send a credit note** — set `isCreditNote` and reference the corrected invoice:
729
+
730
+ ```ts
731
+ const creditNote = await client.eReporting.createTransaction({
732
+ complianceEnrollmentId: noEnrollment.id,
733
+ transactionType: 'b2b_domestic_outbound',
734
+ invoiceNumber: 'NO-2026-0001-CN',
735
+ invoiceDate: '2026-09-30',
736
+ currency: 'NOK',
737
+ subtotal: 1000,
738
+ taxAmount: 250,
739
+ total: 1250,
740
+ counterpartyName: 'EXAMPLE BUYER AS',
741
+ counterpartyRegisterId: '974760673',
742
+ isCreditNote: true,
743
+ amendedNumber: 'NO-2026-0001', // required for credit notes in Norway
744
+ amendedDate: '2026-09-25',
745
+ extraInfo: 'Credit for one returned consulting day',
746
+ invoiceLines: [
747
+ { description: 'Consulting', quantity: 1, price: 1000, taxes: [{ name: 'MVA', percent: 25, category: 'S' }] },
748
+ ],
749
+ });
750
+ console.log(creditNote.isCreditNote); // true
751
+
752
+ // All Norwegian documents
753
+ const noDocs = await client.eReporting.listTransactions({ country: 'NO' });
754
+ ```
755
+
627
756
  ---
628
757
 
629
758
  ## 📜 Certificates
@@ -765,6 +894,68 @@ const noRetries = TaxoraClientFactory.create({ apiKey: '…', retryPolicy: Retry
765
894
 
766
895
  ---
767
896
 
897
+ ## 🔐 Webhook Signatures
898
+
899
+ Taxora can sign every webhook request with HMAC-SHA256 (opt-in per endpoint: dashboard → **Developer → Webhooks**,
900
+ or `POST /v1/webhooks/{id}/signing/enable`). Signed requests carry:
901
+
902
+ ```
903
+ Taxora-Signature: t=1716115200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
904
+ Taxora-Webhook-Id: 550e8400-e29b-41d4-a716-446655440000
905
+ ```
906
+
907
+ `constructWebhookEvent` verifies the signature (constant-time, 5-minute replay window) and returns a typed event:
908
+
909
+ ```ts
910
+ import express from 'express';
911
+ import { constructWebhookEvent, WebhookSignatureVerificationError } from '@taxora/sdk';
912
+
913
+ const app = express();
914
+
915
+ // express.raw — NOT express.json: the signature covers the exact bytes Taxora sent.
916
+ app.post('/taxora-webhook', express.raw({ type: 'application/json' }), (req, res) => {
917
+ let event;
918
+ try {
919
+ event = constructWebhookEvent(req.body, req.header('Taxora-Signature'), process.env.TAXORA_WEBHOOK_SECRET!, {
920
+ tolerance: 300, // seconds, default
921
+ });
922
+ } catch (err) {
923
+ if (err instanceof WebhookSignatureVerificationError) {
924
+ return res.status(400).send(err.message); // err.reason: 'no_matching_signature' | 'timestamp_outside_tolerance' | …
925
+ }
926
+ throw err;
927
+ }
928
+
929
+ switch (event.event) {
930
+ case 'vat.validated':
931
+ console.log(event.data.object.vat_uid, event.data.object.state); // typed
932
+ break;
933
+ case 'enrichment.completed':
934
+ console.log(event.data.object.foundCount);
935
+ break;
936
+ }
937
+
938
+ res.sendStatus(200);
939
+ });
940
+ ```
941
+
942
+ Next.js App Router: `const rawBody = await request.text();` (not `request.json()`).
943
+
944
+ | Helper | Purpose |
945
+ | ---------------------------------------------------------------------- | ------------------------------------------------------------ |
946
+ | `verifyWebhookSignature(rawBody, header, secret, { tolerance })` | Returns `true` or throws `WebhookSignatureVerificationError` |
947
+ | `constructWebhookEvent(rawBody, header, secret, { tolerance })` | Verifies, then parses into `TaxoraWebhookEvent` |
948
+ | `generateWebhookSignatureHeader(rawBody, secret, timestamp?)` | Builds a valid header — for testing your own handler |
949
+ | `parseWebhookSignatureHeader(header)` / `computeWebhookSignature(...)` | Low-level building blocks |
950
+
951
+ - **Rotation:** while Taxora rotates your secret, requests carry one `v1=` per live secret — any match is accepted.
952
+ `secret` may also be an array, so you can accept old and new secret on your side during a deploy.
953
+ - **Raw body:** pass a `string`, `Buffer` or `Uint8Array`. Passing an already parsed object throws
954
+ `reason: 'invalid_body'`.
955
+ - **Idempotency:** dedupe on `event.id` (= `Taxora-Webhook-Id`), which stays the same across retries.
956
+
957
+ ---
958
+
768
959
  ## 🔌 VatState Helpers
769
960
 
770
961
  ```ts