@factpulse/sdk 3.0.37 → 4.0.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/.openapi-generator/FILES +6 -6
- package/CHANGELOG.md +10 -13
- package/README.md +135 -148
- package/api/invoice-processing-api.ts +2 -2
- package/dist/esm/models/chorus-pro-credentials.d.ts +19 -7
- package/dist/esm/models/chorus-pro-destination.d.ts +2 -2
- package/dist/esm/models/{facture-electronique-rest-api-schemas-chorus-pro-chorus-pro-credentials.d.ts → facture-electronique-models-invoice-type-code.d.ts} +63 -18
- package/dist/{models/facture-electronique-rest-api-schemas-ereporting-invoice-type-code.js → esm/models/facture-electronique-models-invoice-type-code.js} +66 -10
- package/dist/esm/models/{facture-electronique-rest-api-schemas-validation-validation-error-response.d.ts → facture-electronique-rest-api-schemas-cdar-validation-error-response.d.ts} +13 -4
- package/dist/esm/models/{facture-electronique-rest-api-schemas-ereporting-invoice-type-code.d.ts → facture-electronique-rest-api-schemas-processing-chorus-pro-credentials.d.ts} +11 -9
- package/dist/esm/models/get-chorus-pro-id-request.d.ts +2 -2
- package/dist/esm/models/get-invoice-request.d.ts +2 -2
- package/dist/esm/models/get-structure-request.d.ts +2 -2
- package/dist/esm/models/index.d.ts +3 -3
- package/dist/esm/models/index.js +3 -3
- package/dist/esm/models/invoice-input.d.ts +2 -2
- package/dist/esm/models/invoice-type-code.d.ts +6 -65
- package/dist/esm/models/invoice-type-code.js +6 -65
- package/dist/esm/models/recipient.d.ts +1 -1
- package/dist/esm/models/scheme-id.d.ts +7 -7
- package/dist/esm/models/scheme-id.js +7 -7
- package/dist/esm/models/search-structure-request.d.ts +2 -2
- package/dist/esm/models/simplified-invoice-data.d.ts +2 -2
- package/dist/esm/models/submit-complete-invoice-response.d.ts +1 -1
- package/dist/esm/models/submit-invoice-request.d.ts +2 -2
- package/dist/esm/models/supplier.d.ts +1 -1
- package/dist/esm/models/validate-cdarresponse.d.ts +3 -3
- package/dist/esm/models/validation-error-response.d.ts +3 -12
- package/dist/esm/src/helpers/client.d.ts +43 -265
- package/dist/esm/src/helpers/client.js +196 -779
- package/dist/esm/src/helpers/index.d.ts +1 -2
- package/dist/esm/src/helpers/index.js +1 -3
- package/dist/models/chorus-pro-credentials.d.ts +19 -7
- package/dist/models/chorus-pro-destination.d.ts +2 -2
- package/dist/models/{facture-electronique-rest-api-schemas-chorus-pro-chorus-pro-credentials.d.ts → facture-electronique-models-invoice-type-code.d.ts} +63 -18
- package/dist/models/facture-electronique-models-invoice-type-code.js +85 -0
- package/dist/models/{facture-electronique-rest-api-schemas-validation-validation-error-response.d.ts → facture-electronique-rest-api-schemas-cdar-validation-error-response.d.ts} +13 -4
- package/dist/{esm/models/facture-electronique-rest-api-schemas-ereporting-invoice-type-code.js → models/facture-electronique-rest-api-schemas-processing-chorus-pro-credentials.d.ts} +11 -10
- package/dist/models/get-chorus-pro-id-request.d.ts +2 -2
- package/dist/models/get-invoice-request.d.ts +2 -2
- package/dist/models/get-structure-request.d.ts +2 -2
- package/dist/models/index.d.ts +3 -3
- package/dist/models/index.js +3 -3
- package/dist/models/invoice-input.d.ts +2 -2
- package/dist/models/invoice-type-code.d.ts +6 -65
- package/dist/models/invoice-type-code.js +6 -65
- package/dist/models/recipient.d.ts +1 -1
- package/dist/models/scheme-id.d.ts +7 -7
- package/dist/models/scheme-id.js +7 -7
- package/dist/models/search-structure-request.d.ts +2 -2
- package/dist/models/simplified-invoice-data.d.ts +2 -2
- package/dist/models/submit-complete-invoice-response.d.ts +1 -1
- package/dist/models/submit-invoice-request.d.ts +2 -2
- package/dist/models/supplier.d.ts +1 -1
- package/dist/models/validate-cdarresponse.d.ts +3 -3
- package/dist/models/validation-error-response.d.ts +3 -12
- package/dist/src/helpers/client.d.ts +43 -265
- package/dist/src/helpers/client.js +199 -823
- package/dist/src/helpers/index.d.ts +1 -2
- package/dist/src/helpers/index.js +2 -12
- package/docs/ChorusProCredentials.md +9 -9
- package/docs/ChorusProDestination.md +1 -1
- package/docs/FactureElectroniqueModelsInvoiceTypeCode.md +39 -0
- package/docs/FactureElectroniqueRestApiSchemasCdarValidationErrorResponse.md +27 -0
- package/docs/FactureElectroniqueRestApiSchemasProcessingChorusProCredentials.md +29 -0
- package/docs/GetChorusProIdRequest.md +1 -1
- package/docs/GetInvoiceRequest.md +1 -1
- package/docs/GetStructureRequest.md +1 -1
- package/docs/InvoiceInput.md +1 -1
- package/docs/InvoiceTypeCode.md +6 -28
- package/docs/Recipient.md +1 -1
- package/docs/SchemeID.md +4 -4
- package/docs/SearchStructureRequest.md +1 -1
- package/docs/SimplifiedInvoiceData.md +1 -1
- package/docs/SubmitCompleteInvoiceResponse.md +2 -2
- package/docs/SubmitInvoiceRequest.md +1 -1
- package/docs/Supplier.md +1 -1
- package/docs/ValidateCDARResponse.md +2 -2
- package/docs/ValidationErrorResponse.md +3 -9
- package/models/chorus-pro-credentials.ts +19 -7
- package/models/chorus-pro-destination.ts +2 -2
- package/models/{facture-electronique-rest-api-schemas-chorus-pro-chorus-pro-credentials.ts → facture-electronique-models-invoice-type-code.ts} +67 -18
- package/models/{facture-electronique-rest-api-schemas-validation-validation-error-response.ts → facture-electronique-rest-api-schemas-cdar-validation-error-response.ts} +13 -4
- package/models/{facture-electronique-rest-api-schemas-ereporting-invoice-type-code.ts → facture-electronique-rest-api-schemas-processing-chorus-pro-credentials.ts} +11 -13
- package/models/get-chorus-pro-id-request.ts +2 -2
- package/models/get-invoice-request.ts +2 -2
- package/models/get-structure-request.ts +2 -2
- package/models/index.ts +3 -3
- package/models/invoice-input.ts +2 -2
- package/models/invoice-type-code.ts +6 -65
- package/models/recipient.ts +1 -1
- package/models/scheme-id.ts +7 -7
- package/models/search-structure-request.ts +2 -2
- package/models/simplified-invoice-data.ts +2 -2
- package/models/submit-complete-invoice-response.ts +1 -1
- package/models/submit-invoice-request.ts +2 -2
- package/models/supplier.ts +1 -1
- package/models/validate-cdarresponse.ts +3 -3
- package/models/validation-error-response.ts +3 -12
- package/package.json +1 -1
- package/src/helpers/client.ts +211 -834
- package/src/helpers/index.ts +1 -3
- package/dist/models/facture-electronique-rest-api-schemas-ereporting-invoice-type-code.d.ts +0 -22
- package/docs/FactureElectroniqueRestApiSchemasChorusProChorusProCredentials.md +0 -29
- package/docs/FactureElectroniqueRestApiSchemasEreportingInvoiceTypeCode.md +0 -17
- package/docs/FactureElectroniqueRestApiSchemasValidationValidationErrorResponse.md +0 -21
- /package/dist/esm/models/{facture-electronique-rest-api-schemas-chorus-pro-chorus-pro-credentials.js → facture-electronique-rest-api-schemas-cdar-validation-error-response.js} +0 -0
- /package/dist/esm/models/{facture-electronique-rest-api-schemas-validation-validation-error-response.js → facture-electronique-rest-api-schemas-processing-chorus-pro-credentials.js} +0 -0
- /package/dist/models/{facture-electronique-rest-api-schemas-chorus-pro-chorus-pro-credentials.js → facture-electronique-rest-api-schemas-cdar-validation-error-response.js} +0 -0
- /package/dist/models/{facture-electronique-rest-api-schemas-validation-validation-error-response.js → facture-electronique-rest-api-schemas-processing-chorus-pro-credentials.js} +0 -0
package/src/helpers/client.ts
CHANGED
|
@@ -1,914 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FactPulse SDK - Thin HTTP wrapper with auto-polling.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const client = new FactPulseClient({ email: 'user@example.com', password: 'secret', clientUid: 'xxx' });
|
|
6
|
+
*
|
|
7
|
+
* // POST /api/v1/processing/invoices/submit-complete-async
|
|
8
|
+
* const result = await client.post('processing/invoices/submit-complete-async', {
|
|
9
|
+
* invoiceData: {...},
|
|
10
|
+
* sourcePdf: Buffer.from(pdf).toString('base64'),
|
|
11
|
+
* destination: { type: 'afnor' }
|
|
12
|
+
* });
|
|
13
|
+
* const pdfBytes = result.content; // auto-decoded, auto-polled
|
|
14
|
+
*
|
|
15
|
+
* // GET /api/v1/chorus-pro/structures/123
|
|
16
|
+
* const structure = await client.get('chorus-pro/structures/123');
|
|
17
|
+
*/
|
|
1
18
|
import axios, { AxiosInstance, AxiosError } from 'axios';
|
|
2
|
-
import FormData from 'form-data';
|
|
3
|
-
import * as fs from 'fs';
|
|
4
|
-
import * as path from 'path';
|
|
5
|
-
import { FactPulseAuthError, FactPulsePollingTimeout, FactPulseValidationError, ValidationErrorDetail } from './exceptions';
|
|
6
|
-
|
|
7
|
-
// =============================================================================
|
|
8
|
-
// Credentials interfaces - for simplified configuration
|
|
9
|
-
// =============================================================================
|
|
10
|
-
|
|
11
|
-
/** Chorus Pro credentials for Zero-Trust mode. */
|
|
12
|
-
export interface ChorusProCredentials {
|
|
13
|
-
pisteClientId: string;
|
|
14
|
-
pisteClientSecret: string;
|
|
15
|
-
chorusProLogin: string;
|
|
16
|
-
chorusProPassword: string;
|
|
17
|
-
sandbox?: boolean;
|
|
18
|
-
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
export class FactPulseError extends Error {
|
|
21
|
+
statusCode?: number;
|
|
22
|
+
details?: unknown[];
|
|
23
|
+
constructor(message: string, statusCode?: number, details?: unknown[]) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'FactPulseError';
|
|
26
|
+
this.statusCode = statusCode;
|
|
27
|
+
this.details = details ?? [];
|
|
28
|
+
}
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export interface FactPulseClientConfig {
|
|
30
32
|
email: string;
|
|
31
33
|
password: string;
|
|
34
|
+
clientUid: string;
|
|
32
35
|
apiUrl?: string;
|
|
33
|
-
|
|
34
|
-
chorusCredentials?: ChorusProCredentials;
|
|
35
|
-
afnorCredentials?: AFNORCredentials;
|
|
36
|
-
pollingInterval?: number;
|
|
36
|
+
timeout?: number;
|
|
37
37
|
pollingTimeout?: number;
|
|
38
38
|
maxRetries?: number;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// =============================================================================
|
|
42
|
-
// Helpers for anyOf types - avoids verbosity of generated wrappers
|
|
43
|
-
// =============================================================================
|
|
44
|
-
|
|
45
|
-
type AmountValue = string | number | null | undefined;
|
|
46
|
-
|
|
47
|
-
/** Converts a value to an amount string for the API. */
|
|
48
|
-
export function amount(value: AmountValue): string {
|
|
49
|
-
if (value === null || value === undefined) return '0.00';
|
|
50
|
-
if (typeof value === 'number') return value.toFixed(2);
|
|
51
|
-
if (typeof value === 'string') return value;
|
|
52
|
-
return '0.00';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Creates a simplified InvoiceTotals object. */
|
|
56
|
-
export function invoiceTotals(
|
|
57
|
-
exclTax: AmountValue, vat: AmountValue, inclTax: AmountValue, amountDue: AmountValue,
|
|
58
|
-
options?: { globalAllowanceAmount?: AmountValue; globalAllowanceReason?: string; prepayment?: AmountValue }
|
|
59
|
-
): Record<string, unknown> {
|
|
60
|
-
const result: Record<string, unknown> = {
|
|
61
|
-
totalNetAmount: amount(exclTax), vatAmount: amount(vat),
|
|
62
|
-
totalGrossAmount: amount(inclTax), amountDue: amount(amountDue),
|
|
63
|
-
};
|
|
64
|
-
if (options?.globalAllowanceAmount !== undefined) result.globalAllowanceAmount = amount(options.globalAllowanceAmount);
|
|
65
|
-
if (options?.globalAllowanceReason !== undefined) result.globalAllowanceReason = options.globalAllowanceReason;
|
|
66
|
-
if (options?.prepayment !== undefined) result.prepayment = amount(options.prepayment);
|
|
67
|
-
return result;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Creates an invoice line (aligned with InvoiceLine in models.py).
|
|
71
|
-
* For VAT rate: either vatRate (code e.g.: "VAT20") or manualVatRate (value e.g.: 20.00) */
|
|
72
|
-
export function invoiceLine(
|
|
73
|
-
lineNumber: number, itemName: string, quantity: AmountValue, unitNetPrice: AmountValue, lineNetAmount: AmountValue,
|
|
74
|
-
options?: { vatRate?: string; manualVatRate?: AmountValue; vatCategory?: string; unit?: string; reference?: string;
|
|
75
|
-
lineAllowanceAmount?: AmountValue; allowanceReasonCode?: string; allowanceReason?: string;
|
|
76
|
-
periodStartDate?: string; periodEndDate?: string }
|
|
77
|
-
): Record<string, unknown> {
|
|
78
|
-
const result: Record<string, unknown> = {
|
|
79
|
-
lineNumber, itemName, quantity: amount(quantity), unitNetPrice: amount(unitNetPrice),
|
|
80
|
-
lineNetAmount: amount(lineNetAmount),
|
|
81
|
-
vatCategory: options?.vatCategory ?? 'S', unit: options?.unit ?? 'LUMP_SUM',
|
|
82
|
-
};
|
|
83
|
-
// Either vatRate (code) or manualVatRate (value)
|
|
84
|
-
if (options?.vatRate) result.vatRate = options.vatRate;
|
|
85
|
-
else result.manualVatRate = amount(options?.manualVatRate ?? '20.00');
|
|
86
|
-
if (options?.reference) result.reference = options.reference;
|
|
87
|
-
if (options?.lineAllowanceAmount !== undefined) result.lineAllowanceAmount = amount(options.lineAllowanceAmount);
|
|
88
|
-
if (options?.allowanceReasonCode) result.allowanceReasonCode = options.allowanceReasonCode;
|
|
89
|
-
if (options?.allowanceReason) result.allowanceReason = options.allowanceReason;
|
|
90
|
-
if (options?.periodStartDate) result.periodStartDate = options.periodStartDate;
|
|
91
|
-
if (options?.periodEndDate) result.periodEndDate = options.periodEndDate;
|
|
92
|
-
return result;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Creates a VAT line (aligned with VATLine in models.py).
|
|
96
|
-
* For rate: either rate (code e.g.: "VAT20") or manualRate (value e.g.: 20.00) */
|
|
97
|
-
export function vatLine(
|
|
98
|
-
taxableAmount: AmountValue, vatAmount: AmountValue,
|
|
99
|
-
options?: { rate?: string; manualRate?: AmountValue; category?: string }
|
|
100
|
-
): Record<string, unknown> {
|
|
101
|
-
const result: Record<string, unknown> = {
|
|
102
|
-
taxableAmount: amount(taxableAmount), vatAmount: amount(vatAmount), category: options?.category ?? 'S',
|
|
103
|
-
};
|
|
104
|
-
// Either rate (code) or manualRate (value)
|
|
105
|
-
if (options?.rate) result.rate = options.rate;
|
|
106
|
-
else result.manualRate = amount(options?.manualRate ?? '20.00');
|
|
107
|
-
return result;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Creates a postal address for the FactPulse API. */
|
|
111
|
-
export function postalAddress(
|
|
112
|
-
lineOne: string, postalCode: string, city: string,
|
|
113
|
-
options?: { countryCode?: string; lineTwo?: string; lineThree?: string }
|
|
114
|
-
): Record<string, unknown> {
|
|
115
|
-
const result: Record<string, unknown> = { lineOne, postalCode, city, countryCode: options?.countryCode ?? 'FR' };
|
|
116
|
-
if (options?.lineTwo) result.lineTwo = options.lineTwo;
|
|
117
|
-
if (options?.lineThree) result.lineThree = options.lineThree;
|
|
118
|
-
return result;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Creates an electronic address for the FactPulse API. schemeId: "0009"=SIREN, "0225"=SIRET */
|
|
122
|
-
export function electronicAddress(identifier: string, schemeId = '0009'): Record<string, unknown> {
|
|
123
|
-
return { identifier, schemeId };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Computes the French intra-community VAT number from a SIREN. */
|
|
127
|
-
function computeVatIntra(siren: string): string | null {
|
|
128
|
-
if (siren.length !== 9 || !/^\d+$/.test(siren)) return null;
|
|
129
|
-
const cle = (12 + 3 * (parseInt(siren, 10) % 97)) % 97;
|
|
130
|
-
return `FR${cle.toString().padStart(2, '0')}${siren}`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Creates a supplier (issuer) with auto-computed SIREN, intra-EU VAT number and addresses. */
|
|
134
|
-
export function supplier(
|
|
135
|
-
name: string, siret: string, addressLine1: string, postalCode: string, city: string,
|
|
136
|
-
options?: { supplierId?: number; siren?: string; vatNumber?: string; iban?: string; countryCode?: string; addressLine2?: string; supplierServiceId?: number; supplierBankDetailsCode?: number }
|
|
137
|
-
): Record<string, unknown> {
|
|
138
|
-
const opts = options ?? {};
|
|
139
|
-
const siren = opts.siren ?? (siret.length === 14 ? siret.slice(0, 9) : undefined);
|
|
140
|
-
const vatNumber = opts.vatNumber ?? (siren ? computeVatIntra(siren) : null);
|
|
141
|
-
const result: Record<string, unknown> = {
|
|
142
|
-
name, supplierId: opts.supplierId ?? 0, siret,
|
|
143
|
-
electronicAddress: electronicAddress(siret, '0225'),
|
|
144
|
-
postalAddress: postalAddress(addressLine1, postalCode, city, { countryCode: opts.countryCode, lineTwo: opts.addressLine2 }),
|
|
145
|
-
};
|
|
146
|
-
if (siren) result.siren = siren;
|
|
147
|
-
if (vatNumber) result.vatNumber = vatNumber;
|
|
148
|
-
if (opts.iban) result.iban = opts.iban;
|
|
149
|
-
if (opts.supplierServiceId) result.supplierServiceId = opts.supplierServiceId;
|
|
150
|
-
if (opts.supplierBankDetailsCode) result.supplierBankDetailsCode = opts.supplierBankDetailsCode;
|
|
151
|
-
return result;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Creates a recipient (customer) with auto-computed SIREN and addresses. */
|
|
155
|
-
export function recipient(
|
|
156
|
-
name: string, siret: string, addressLine1: string, postalCode: string, city: string,
|
|
157
|
-
options?: { siren?: string; countryCode?: string; addressLine2?: string; executingServiceCode?: string }
|
|
158
|
-
): Record<string, unknown> {
|
|
159
|
-
const opts = options ?? {};
|
|
160
|
-
const siren = opts.siren ?? (siret.length === 14 ? siret.slice(0, 9) : undefined);
|
|
161
|
-
const result: Record<string, unknown> = {
|
|
162
|
-
name, siret,
|
|
163
|
-
electronicAddress: electronicAddress(siret, '0225'),
|
|
164
|
-
postalAddress: postalAddress(addressLine1, postalCode, city, { countryCode: opts.countryCode, lineTwo: opts.addressLine2 }),
|
|
165
|
-
};
|
|
166
|
-
if (siren) result.siren = siren;
|
|
167
|
-
if (opts.executingServiceCode) result.executingServiceCode = opts.executingServiceCode;
|
|
168
|
-
return result;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Creates a beneficiary (factor) for factoring.
|
|
173
|
-
*
|
|
174
|
-
* The beneficiary (BG-10 / PayeeTradeParty) is used when payment
|
|
175
|
-
* must be made to a third party different from the supplier, typically
|
|
176
|
-
* a factor (factoring company).
|
|
177
|
-
*
|
|
178
|
-
* For factored invoices, you also need to:
|
|
179
|
-
* - Use a factored document type (393, 396, 501, 502, 472, 473)
|
|
180
|
-
* - Add an ACC note with the subrogation mention
|
|
181
|
-
* - The beneficiary's IBAN will be used for payment
|
|
182
|
-
*
|
|
183
|
-
* @param name Factor's business name (BT-59)
|
|
184
|
-
* @param options Options: siret (BT-60), siren (BT-61), iban, bic
|
|
185
|
-
* @returns Dict ready to be used in a factored invoice
|
|
186
|
-
*
|
|
187
|
-
* @example
|
|
188
|
-
* const factor = beneficiary('FACTOR SAS', {
|
|
189
|
-
* siret: '30000000700033',
|
|
190
|
-
* iban: 'FR76 3000 4000 0500 0012 3456 789',
|
|
191
|
-
* });
|
|
192
|
-
*/
|
|
193
|
-
export function beneficiary(
|
|
194
|
-
name: string,
|
|
195
|
-
options?: { siret?: string; siren?: string; iban?: string; bic?: string }
|
|
196
|
-
): Record<string, unknown> {
|
|
197
|
-
const opts = options ?? {};
|
|
198
|
-
// Auto-compute SIREN from SIRET
|
|
199
|
-
const siren = opts.siren ?? (opts.siret && opts.siret.length === 14 ? opts.siret.slice(0, 9) : undefined);
|
|
200
|
-
|
|
201
|
-
const result: Record<string, unknown> = { name };
|
|
202
|
-
if (opts.siret) result.siret = opts.siret;
|
|
203
|
-
if (siren) result.siren = siren;
|
|
204
|
-
if (opts.iban) result.iban = opts.iban;
|
|
205
|
-
if (opts.bic) result.bic = opts.bic;
|
|
206
|
-
return result;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// =============================================================================
|
|
210
|
-
// Main client
|
|
211
|
-
// =============================================================================
|
|
212
|
-
|
|
213
41
|
const DEFAULT_API_URL = 'https://factpulse.fr';
|
|
214
|
-
const DEFAULT_POLLING_INTERVAL = 2000;
|
|
215
|
-
const DEFAULT_POLLING_TIMEOUT = 120000;
|
|
216
|
-
const DEFAULT_MAX_RETRIES = 1;
|
|
217
|
-
|
|
218
|
-
interface InternalConfig {
|
|
219
|
-
email: string; password: string; apiUrl: string; clientUid: string;
|
|
220
|
-
chorusCredentials?: ChorusProCredentials; afnorCredentials?: AFNORCredentials;
|
|
221
|
-
pollingInterval: number; pollingTimeout: number; maxRetries: number;
|
|
222
|
-
}
|
|
223
42
|
|
|
224
43
|
export class FactPulseClient {
|
|
225
|
-
private
|
|
226
|
-
private
|
|
227
|
-
private
|
|
228
|
-
private
|
|
229
|
-
private
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
44
|
+
private readonly apiUrl: string;
|
|
45
|
+
private readonly email: string;
|
|
46
|
+
private readonly password: string;
|
|
47
|
+
private readonly clientUid: string;
|
|
48
|
+
private readonly timeout: number;
|
|
49
|
+
private readonly pollingTimeout: number;
|
|
50
|
+
private readonly httpClient: AxiosInstance;
|
|
51
|
+
private token: string | null = null;
|
|
52
|
+
private tokenExpiresAt = 0;
|
|
233
53
|
|
|
234
54
|
constructor(config: FactPulseClientConfig) {
|
|
235
|
-
this.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
};
|
|
245
|
-
this.chorusCredentials = config.chorusCredentials;
|
|
246
|
-
this.afnorCredentials = config.afnorCredentials;
|
|
247
|
-
this.httpClient = axios.create({ timeout: 30000, headers: { 'Content-Type': 'application/json' } });
|
|
55
|
+
this.apiUrl = (config.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, '');
|
|
56
|
+
this.email = config.email;
|
|
57
|
+
this.password = config.password;
|
|
58
|
+
this.clientUid = config.clientUid;
|
|
59
|
+
this.timeout = config.timeout ?? 60000;
|
|
60
|
+
this.pollingTimeout = config.pollingTimeout ?? 120000;
|
|
61
|
+
this.httpClient = axios.create({
|
|
62
|
+
timeout: this.timeout,
|
|
63
|
+
validateStatus: () => true, // Handle all status codes manually
|
|
64
|
+
});
|
|
248
65
|
}
|
|
249
66
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return
|
|
253
|
-
piste_client_id: this.chorusCredentials.pisteClientId,
|
|
254
|
-
piste_client_secret: this.chorusCredentials.pisteClientSecret,
|
|
255
|
-
chorus_pro_login: this.chorusCredentials.chorusProLogin,
|
|
256
|
-
chorus_pro_password: this.chorusCredentials.chorusProPassword,
|
|
257
|
-
sandbox: this.chorusCredentials.sandbox ?? true,
|
|
258
|
-
};
|
|
67
|
+
/** POST request to /api/v1/{path} */
|
|
68
|
+
async post(path: string, data?: Record<string, unknown>): Promise<unknown> {
|
|
69
|
+
return this._doRequest('POST', path, data, true);
|
|
259
70
|
}
|
|
260
71
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return
|
|
264
|
-
client_id: this.afnorCredentials.clientId,
|
|
265
|
-
client_secret: this.afnorCredentials.clientSecret,
|
|
266
|
-
flow_service_url: this.afnorCredentials.flowServiceUrl,
|
|
267
|
-
};
|
|
72
|
+
/** GET request to /api/v1/{path} */
|
|
73
|
+
async get(path: string, params?: Record<string, unknown>): Promise<unknown> {
|
|
74
|
+
return this._doRequest('GET', path, params, true);
|
|
268
75
|
}
|
|
269
76
|
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
77
|
+
// Dynamic endpoint builder (alternative syntax)
|
|
78
|
+
get processing() { return this._endpoint('processing'); }
|
|
79
|
+
get chorus_pro() { return this._endpoint('chorus-pro'); }
|
|
80
|
+
get afnor() { return this._endpoint('afnor'); }
|
|
273
81
|
|
|
274
|
-
private
|
|
275
|
-
|
|
276
|
-
if (this.config.clientUid) payload.client_uid = this.config.clientUid;
|
|
277
|
-
try {
|
|
278
|
-
const response = await this.httpClient.post(`${this.config.apiUrl}/api/token/`, payload);
|
|
279
|
-
return response.data;
|
|
280
|
-
} catch (error) {
|
|
281
|
-
const axiosError = error as AxiosError<{ detail?: string }>;
|
|
282
|
-
throw new FactPulseAuthError(`Unable to obtain JWT token: ${axiosError.response?.data?.detail || axiosError.message}`);
|
|
283
|
-
}
|
|
82
|
+
private _endpoint(path: string): Endpoint {
|
|
83
|
+
return new Endpoint(this, path);
|
|
284
84
|
}
|
|
285
85
|
|
|
286
|
-
async
|
|
287
|
-
|
|
288
|
-
if (forceRefresh || !this.accessToken || (this.tokenExpiresAt && now >= this.tokenExpiresAt)) {
|
|
289
|
-
const tokens = await this.obtainToken();
|
|
290
|
-
this.accessToken = tokens.access; this.refreshToken = tokens.refresh;
|
|
291
|
-
this.tokenExpiresAt = now + 28 * 60 * 1000;
|
|
292
|
-
}
|
|
86
|
+
async _request(method: 'GET' | 'POST', path: string, data?: Record<string, unknown>): Promise<unknown> {
|
|
87
|
+
return this._doRequest(method, path, data, true);
|
|
293
88
|
}
|
|
294
89
|
|
|
295
|
-
|
|
90
|
+
private async _doRequest(method: 'GET' | 'POST', path: string, data: Record<string, unknown> | undefined, retryAuth: boolean): Promise<unknown> {
|
|
91
|
+
await this._ensureAuth();
|
|
92
|
+
const url = `${this.apiUrl}/api/v1/${path}`;
|
|
93
|
+
const headers = { Authorization: `Bearer ${this.token}` };
|
|
296
94
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
95
|
+
let response;
|
|
96
|
+
try {
|
|
97
|
+
response = method === 'GET'
|
|
98
|
+
? await this.httpClient.get(url, { headers, params: data })
|
|
99
|
+
: await this.httpClient.post(url, data, { headers });
|
|
100
|
+
} catch (e) {
|
|
101
|
+
throw new FactPulseError(`Network error: ${(e as Error).message}`);
|
|
102
|
+
}
|
|
302
103
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
const response = await this.httpClient.get(`${this.config.apiUrl}/api/v1/processing/tasks/${taskId}/status`, {
|
|
308
|
-
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
309
|
-
});
|
|
310
|
-
const { status, result } = response.data;
|
|
311
|
-
if (status === 'SUCCESS') return (result as Record<string, unknown>) || {};
|
|
312
|
-
if (status === 'FAILURE') {
|
|
313
|
-
// Format AFNOR: errorMessage, details
|
|
314
|
-
const failureResult = result as Record<string, unknown> | undefined;
|
|
315
|
-
const errors: ValidationErrorDetail[] = Array.isArray(result?.details) ? result.details.filter((e: unknown): e is ValidationErrorDetail => typeof e === 'object' && e !== null) : [];
|
|
316
|
-
throw new FactPulseValidationError(`Task ${taskId} failed: ${result?.errorMessage || 'Unknown error'}`, errors);
|
|
317
|
-
}
|
|
318
|
-
await new Promise(resolve => setTimeout(resolve, currentInterval));
|
|
319
|
-
currentInterval = Math.min(currentInterval * 1.5, 10000);
|
|
320
|
-
} catch (error) {
|
|
321
|
-
if (error instanceof FactPulseValidationError || error instanceof FactPulsePollingTimeout) throw error;
|
|
322
|
-
const axiosError = error as AxiosError;
|
|
323
|
-
if (axiosError.response?.status === 401) { this.resetAuth(); continue; }
|
|
324
|
-
throw new FactPulseValidationError(`API Error: ${axiosError.message}`);
|
|
325
|
-
}
|
|
104
|
+
if (response.status === 401 && retryAuth) {
|
|
105
|
+
this._invalidateToken();
|
|
106
|
+
return this._doRequest(method, path, data, false);
|
|
326
107
|
}
|
|
327
|
-
}
|
|
328
108
|
|
|
329
|
-
|
|
330
|
-
const jsonData = typeof invoiceData === 'string' ? invoiceData : JSON.stringify(invoiceData);
|
|
331
|
-
let taskId: string | null = null;
|
|
332
|
-
|
|
333
|
-
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
334
|
-
await this.ensureAuthenticated();
|
|
335
|
-
const form = new FormData();
|
|
336
|
-
form.append('invoice_data', Buffer.from(jsonData, 'utf-8'), { contentType: 'application/json' });
|
|
337
|
-
form.append('profile', profile);
|
|
338
|
-
form.append('output_format', outputFormat);
|
|
339
|
-
form.append('source_pdf', fs.createReadStream(pdfPath), { filename: path.basename(pdfPath), contentType: 'application/pdf' });
|
|
340
|
-
try {
|
|
341
|
-
const response = await this.httpClient.post(`${this.config.apiUrl}/api/v1/processing/generate-invoice`, form, {
|
|
342
|
-
headers: { ...form.getHeaders(), Authorization: `Bearer ${this.accessToken}` }, timeout: 60000,
|
|
343
|
-
});
|
|
344
|
-
taskId = response.data.taskId; break;
|
|
345
|
-
} catch (error) {
|
|
346
|
-
const axiosError = error as AxiosError<{ detail?: unknown; errorMessage?: string }>;
|
|
347
|
-
if (axiosError.response?.status === 401 && attempt < this.config.maxRetries) { this.resetAuth(); continue; }
|
|
348
|
-
|
|
349
|
-
// Extract error details from response body
|
|
350
|
-
const responseData = axiosError.response?.data;
|
|
351
|
-
let errorMsg = `API Error (${axiosError.response?.status || 'unknown'}): ${axiosError.message}`;
|
|
352
|
-
const errors: ValidationErrorDetail[] = [];
|
|
353
|
-
|
|
354
|
-
if (responseData) {
|
|
355
|
-
// Format FastAPI/Pydantic: {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}
|
|
356
|
-
if (Array.isArray(responseData.detail)) {
|
|
357
|
-
errorMsg = 'Validation error';
|
|
358
|
-
for (const err of responseData.detail) {
|
|
359
|
-
if (typeof err === 'object' && err !== null) {
|
|
360
|
-
const loc = (err as { loc?: unknown[] }).loc || [];
|
|
361
|
-
errors.push({
|
|
362
|
-
level: 'ERROR',
|
|
363
|
-
item: loc.map(String).join(' -> '),
|
|
364
|
-
reason: (err as { msg?: string }).msg || String(err),
|
|
365
|
-
source: 'validation',
|
|
366
|
-
code: (err as { type?: string }).type,
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
} else if (typeof responseData.detail === 'string') {
|
|
371
|
-
errorMsg = responseData.detail;
|
|
372
|
-
} else if (responseData.errorMessage) {
|
|
373
|
-
errorMsg = responseData.errorMessage;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
109
|
+
const result = this._parseResponse(response.status, response.data);
|
|
376
110
|
|
|
377
|
-
|
|
378
|
-
|
|
111
|
+
// Auto-poll: support both taskId (camelCase) and task_id (snake_case)
|
|
112
|
+
if (result && typeof result === 'object') {
|
|
113
|
+
const r = result as Record<string, unknown>;
|
|
114
|
+
const taskId = r.taskId ?? r.task_id;
|
|
115
|
+
if (taskId) {
|
|
116
|
+
return this._poll(taskId as string);
|
|
379
117
|
}
|
|
380
118
|
}
|
|
381
|
-
if (!taskId) throw new FactPulseValidationError("No task ID");
|
|
382
|
-
if (!sync) return taskId;
|
|
383
|
-
const result = await this.pollTask(taskId, timeout);
|
|
384
|
-
|
|
385
|
-
// Check for business error (task succeeded but business result is ERROR)
|
|
386
|
-
if (result.status === 'ERROR') {
|
|
387
|
-
const errorMsg = (result.errorMessage as string) || 'Business error';
|
|
388
|
-
const errors: ValidationErrorDetail[] = Array.isArray(result.details)
|
|
389
|
-
? result.details.filter((e: unknown): e is ValidationErrorDetail => typeof e === 'object' && e !== null)
|
|
390
|
-
: [];
|
|
391
|
-
throw new FactPulseValidationError(errorMsg, errors);
|
|
392
|
-
}
|
|
393
119
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
120
|
+
// Auto-decode: support both content_b64 and contentB64
|
|
121
|
+
if (result && typeof result === 'object') {
|
|
122
|
+
const r = result as Record<string, unknown>;
|
|
123
|
+
const b64Content = r.content_b64 ?? r.contentB64;
|
|
124
|
+
if (b64Content) {
|
|
125
|
+
r.content = Buffer.from(b64Content as string, 'base64');
|
|
126
|
+
delete r.content_b64;
|
|
127
|
+
delete r.contentB64;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
397
130
|
|
|
398
|
-
|
|
399
|
-
if (m === null || m === undefined) return '0.00';
|
|
400
|
-
if (typeof m === 'number') return m.toFixed(2);
|
|
401
|
-
if (typeof m === 'string') return m;
|
|
402
|
-
return '0.00';
|
|
131
|
+
return result;
|
|
403
132
|
}
|
|
404
133
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
// =========================================================================
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Retrieves AFNOR credentials (stored or zero-trust mode).
|
|
411
|
-
* Zero-trust mode: Returns the afnorCredentials provided to the constructor.
|
|
412
|
-
* Stored mode: Retrieves credentials via GET /api/v1/afnor/credentials.
|
|
413
|
-
*/
|
|
414
|
-
private async getAfnorCredentialsInternal(): Promise<AFNORCredentials> {
|
|
415
|
-
// Zero-trust mode: credentials provided to the constructor
|
|
416
|
-
if (this.afnorCredentials) {
|
|
417
|
-
return this.afnorCredentials;
|
|
418
|
-
}
|
|
134
|
+
private _parseResponse(status: number, data: unknown): unknown {
|
|
135
|
+
if (status >= 200 && status < 300) return data;
|
|
419
136
|
|
|
420
|
-
|
|
421
|
-
|
|
137
|
+
let msg = `HTTP ${status}`;
|
|
138
|
+
const details: unknown[] = [];
|
|
422
139
|
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (axiosError.response?.status === 400) {
|
|
439
|
-
const detail = axiosError.response.data?.detail;
|
|
440
|
-
if (typeof detail === 'object' && detail?.error === 'NO_CLIENT_UID') {
|
|
441
|
-
throw new FactPulseAuthError(
|
|
442
|
-
"No client_uid in JWT. To use AFNOR endpoints, either:\n" +
|
|
443
|
-
"1. Generate a token with a client_uid (stored mode)\n" +
|
|
444
|
-
"2. Provide AFNORCredentials to the client constructor (zero-trust mode)"
|
|
445
|
-
);
|
|
446
|
-
}
|
|
140
|
+
if (data && typeof data === 'object') {
|
|
141
|
+
const d = data as Record<string, unknown>;
|
|
142
|
+
if (Array.isArray(d.detail)) {
|
|
143
|
+
msg = 'Validation error: ' + d.detail.map((e: unknown) => {
|
|
144
|
+
if (typeof e === 'object' && e !== null) {
|
|
145
|
+
const err = e as Record<string, unknown>;
|
|
146
|
+
details.push(e);
|
|
147
|
+
return `${(err.loc as unknown[])?.slice(-1)[0] ?? '?'}: ${err.msg ?? '?'}`;
|
|
148
|
+
}
|
|
149
|
+
return String(e);
|
|
150
|
+
}).join('; ');
|
|
151
|
+
} else if (typeof d.detail === 'string') {
|
|
152
|
+
msg = d.detail;
|
|
153
|
+
} else if (typeof d.errorMessage === 'string') {
|
|
154
|
+
msg = d.errorMessage;
|
|
447
155
|
}
|
|
448
|
-
throw new FactPulseAuthError(`Failed to retrieve AFNOR credentials: ${axiosError.message}`);
|
|
449
156
|
}
|
|
450
|
-
}
|
|
451
157
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
* This method:
|
|
455
|
-
* 1. Retrieves AFNOR credentials (stored or zero-trust mode)
|
|
456
|
-
* 2. Performs AFNOR OAuth to obtain a token
|
|
457
|
-
* 3. Returns the token and the PDP URL
|
|
458
|
-
*/
|
|
459
|
-
private async getAfnorTokenAndUrl(): Promise<{ token: string; pdpBaseUrl: string }> {
|
|
460
|
-
// Step 1: Get AFNOR credentials
|
|
461
|
-
const credentials = await this.getAfnorCredentialsInternal();
|
|
462
|
-
|
|
463
|
-
// Step 2: Perform AFNOR OAuth via the FactPulse proxy
|
|
464
|
-
const oauthData = new URLSearchParams({
|
|
465
|
-
grant_type: 'client_credentials',
|
|
466
|
-
client_id: credentials.clientId,
|
|
467
|
-
client_secret: credentials.clientSecret,
|
|
468
|
-
});
|
|
158
|
+
throw new FactPulseError(msg, status, details);
|
|
159
|
+
}
|
|
469
160
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
oauthData.toString(),
|
|
474
|
-
{
|
|
475
|
-
headers: {
|
|
476
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
477
|
-
'X-PDP-Token-URL': credentials.tokenUrl,
|
|
478
|
-
},
|
|
479
|
-
}
|
|
480
|
-
);
|
|
161
|
+
private async _poll(taskId: string): Promise<unknown> {
|
|
162
|
+
const start = Date.now();
|
|
163
|
+
let interval = 1000;
|
|
481
164
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
165
|
+
while (true) {
|
|
166
|
+
const elapsed = Date.now() - start;
|
|
167
|
+
if (elapsed >= this.pollingTimeout) {
|
|
168
|
+
throw new FactPulseError(`Polling timeout after ${this.pollingTimeout}ms for task ${taskId}`);
|
|
485
169
|
}
|
|
486
170
|
|
|
487
|
-
|
|
488
|
-
token: tokenData.access_token,
|
|
489
|
-
pdpBaseUrl: credentials.flowServiceUrl,
|
|
490
|
-
};
|
|
491
|
-
} catch (error) {
|
|
492
|
-
const axiosError = error as AxiosError;
|
|
493
|
-
throw new FactPulseAuthError(`AFNOR OAuth2 failed: ${axiosError.message}`);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Performs a request to the AFNOR API with auth and error handling.
|
|
499
|
-
* IMPORTANT: This method uses the AFNOR OAuth token, NOT the FactPulse JWT!
|
|
500
|
-
*/
|
|
501
|
-
private async makeAfnorRequest<T = unknown>(
|
|
502
|
-
method: 'GET' | 'POST',
|
|
503
|
-
endpoint: string,
|
|
504
|
-
options?: { data?: unknown; files?: FormData; params?: Record<string, string> }
|
|
505
|
-
): Promise<T> {
|
|
506
|
-
// Obtenir le token AFNOR et l'URL de la PDP
|
|
507
|
-
const { token: afnorToken, pdpBaseUrl } = await this.getAfnorTokenAndUrl();
|
|
508
|
-
|
|
509
|
-
const url = `${this.config.apiUrl}/api/v1/afnor${endpoint}`;
|
|
510
|
-
|
|
511
|
-
// ALWAYS use the AFNOR token + X-PDP-Base-URL header
|
|
512
|
-
const headers: Record<string, string> = {
|
|
513
|
-
'Authorization': `Bearer ${afnorToken}`,
|
|
514
|
-
'X-PDP-Base-URL': pdpBaseUrl,
|
|
515
|
-
};
|
|
516
|
-
|
|
517
|
-
try {
|
|
171
|
+
await this._ensureAuth();
|
|
518
172
|
let response;
|
|
519
|
-
|
|
520
|
-
response = await this.httpClient.
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
timeout: 60000,
|
|
527
|
-
});
|
|
528
|
-
} else {
|
|
529
|
-
response = await this.httpClient.request({
|
|
530
|
-
method,
|
|
531
|
-
url,
|
|
532
|
-
data: options?.data,
|
|
533
|
-
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
534
|
-
params: options?.params,
|
|
535
|
-
timeout: 30000,
|
|
536
|
-
});
|
|
173
|
+
try {
|
|
174
|
+
response = await this.httpClient.get(
|
|
175
|
+
`${this.apiUrl}/api/v1/processing/tasks/${taskId}/status`,
|
|
176
|
+
{ headers: { Authorization: `Bearer ${this.token}` } }
|
|
177
|
+
);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
throw new FactPulseError(`Network error while polling: ${(e as Error).message}`);
|
|
537
180
|
}
|
|
538
|
-
return response.data;
|
|
539
|
-
} catch (error) {
|
|
540
|
-
const axiosError = error as AxiosError<{ errorMessage?: string; detail?: string }>;
|
|
541
|
-
const errorMsg = axiosError.response?.data?.errorMessage ||
|
|
542
|
-
axiosError.response?.data?.detail ||
|
|
543
|
-
axiosError.message;
|
|
544
|
-
throw new FactPulseValidationError(`AFNOR Error: ${errorMsg}`);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// ==================== AFNOR Directory ====================
|
|
549
|
-
|
|
550
|
-
/** Gets a facility by SIRET in the AFNOR directory. */
|
|
551
|
-
async getSiretAfnor(siret: string): Promise<Record<string, unknown>> {
|
|
552
|
-
return this.makeAfnorRequest('GET', `/directory/v1/siret/code-insee:${siret}`);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/** Gets a legal unit by SIREN in the AFNOR directory. */
|
|
556
|
-
async getSirenAfnor(siren: string): Promise<Record<string, unknown>> {
|
|
557
|
-
return this.makeAfnorRequest('GET', `/directory/v1/siren/code-insee:${siren}`);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/** Searches for legal units (SIREN) in the AFNOR directory. */
|
|
561
|
-
async searchSirenAfnor(
|
|
562
|
-
options: { filters?: Record<string, unknown>; limit?: number } = {}
|
|
563
|
-
): Promise<Record<string, unknown>> {
|
|
564
|
-
const { filters = {}, limit = 25 } = options;
|
|
565
|
-
return this.makeAfnorRequest('POST', '/directory/v1/siren/search', { data: { filters, limit } });
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/** Searches for routing codes in the AFNOR directory. */
|
|
569
|
-
async searchRoutingCodesAfnor(
|
|
570
|
-
options: { filters?: Record<string, unknown>; limit?: number } = {}
|
|
571
|
-
): Promise<Record<string, unknown>> {
|
|
572
|
-
const { filters = {}, limit = 25 } = options;
|
|
573
|
-
return this.makeAfnorRequest('POST', '/directory/v1/routing-code/search', { data: { filters, limit } });
|
|
574
|
-
}
|
|
575
181
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
182
|
+
if (response.status === 401) {
|
|
183
|
+
this._invalidateToken();
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
580
186
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Submits an invoice to a PDP via the AFNOR API.
|
|
585
|
-
* Authentication uses the AFNOR OAuth token (obtained automatically),
|
|
586
|
-
* either via stored credentials (stored mode), or via the afnorCredentials
|
|
587
|
-
* provided to the constructor (zero-trust mode).
|
|
588
|
-
*
|
|
589
|
-
* @param pdfBuffer Buffer of the Factur-X PDF to submit
|
|
590
|
-
* @param flowName Flow name (e.g.: "Invoice INV-2025-001")
|
|
591
|
-
* @param options Options: trackingId, flowSyntax (CII/UBL), flowProfile
|
|
592
|
-
*/
|
|
593
|
-
async submitInvoiceAfnor(
|
|
594
|
-
pdfBuffer: Buffer,
|
|
595
|
-
flowName: string,
|
|
596
|
-
options: { trackingId?: string; flowSyntax?: string; flowProfile?: string } = {}
|
|
597
|
-
): Promise<Record<string, unknown>> {
|
|
598
|
-
const { trackingId, flowSyntax = 'CII', flowProfile = 'EN16931' } = options;
|
|
599
|
-
|
|
600
|
-
// Compute SHA-256
|
|
601
|
-
const crypto = require('crypto');
|
|
602
|
-
const sha256 = crypto.createHash('sha256').update(pdfBuffer).digest('hex');
|
|
603
|
-
|
|
604
|
-
// Prepare flowInfo
|
|
605
|
-
const flowInfo: Record<string, unknown> = { name: flowName, flowSyntax, flowProfile, sha256 };
|
|
606
|
-
if (trackingId) flowInfo.trackingId = trackingId;
|
|
607
|
-
|
|
608
|
-
const form = new FormData();
|
|
609
|
-
form.append('file', pdfBuffer, { filename: 'facture.pdf', contentType: 'application/pdf' });
|
|
610
|
-
form.append('flowInfo', JSON.stringify(flowInfo), { contentType: 'application/json' });
|
|
611
|
-
|
|
612
|
-
return this.makeAfnorRequest('POST', '/flow/v1/flows', { files: form });
|
|
613
|
-
}
|
|
187
|
+
const data = response.data as Record<string, unknown>;
|
|
188
|
+
const status = data.status;
|
|
614
189
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
190
|
+
if (status === 'SUCCESS') {
|
|
191
|
+
const result = (data.result ?? {}) as Record<string, unknown>;
|
|
192
|
+
if (result.content_b64) {
|
|
193
|
+
result.content = Buffer.from(result.content_b64 as string, 'base64');
|
|
194
|
+
delete result.content_b64;
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
623
198
|
|
|
624
|
-
|
|
625
|
-
|
|
199
|
+
if (status === 'FAILURE') {
|
|
200
|
+
const result = (data.result ?? {}) as Record<string, unknown>;
|
|
201
|
+
throw new FactPulseError(
|
|
202
|
+
(result.errorMessage as string) ?? 'Task failed',
|
|
203
|
+
undefined,
|
|
204
|
+
result.details as unknown[]
|
|
205
|
+
);
|
|
206
|
+
}
|
|
626
207
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const url = `${this.config.apiUrl}/api/v1/afnor/flow/v1/flows/${flowId}`;
|
|
632
|
-
const response = await this.httpClient.get(url, {
|
|
633
|
-
headers: {
|
|
634
|
-
'Authorization': `Bearer ${afnorToken}`,
|
|
635
|
-
'X-PDP-Base-URL': pdpBaseUrl,
|
|
636
|
-
},
|
|
637
|
-
responseType: 'arraybuffer',
|
|
638
|
-
});
|
|
639
|
-
return Buffer.from(response.data);
|
|
208
|
+
await new Promise(r => setTimeout(r, Math.min(interval, this.pollingTimeout - elapsed)));
|
|
209
|
+
interval = Math.min(interval * 1.5, 10000);
|
|
210
|
+
}
|
|
640
211
|
}
|
|
641
212
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
* metadata into a unified JSON format. Supports Factur-X, CII and UBL.
|
|
646
|
-
*
|
|
647
|
-
* Note: This endpoint uses FactPulse JWT authentication (not AFNOR OAuth).
|
|
648
|
-
* The FactPulse server handles calling the PDP with stored credentials.
|
|
649
|
-
*
|
|
650
|
-
* @param flowId Flow identifier (UUID)
|
|
651
|
-
* @param includeDocument If true, includes the original document encoded in base64
|
|
652
|
-
* @returns Invoice metadata (supplier, amounts, dates, etc.)
|
|
653
|
-
*
|
|
654
|
-
* @example
|
|
655
|
-
* const invoice = await client.getIncomingInvoiceAfnor("550e8400-...");
|
|
656
|
-
* console.log(`Supplier: ${invoice.supplier.name}`);
|
|
657
|
-
* console.log(`Total incl. tax: ${invoice.total_incl_tax} ${invoice.currency}`);
|
|
658
|
-
*/
|
|
659
|
-
async getIncomingInvoiceAfnor(
|
|
660
|
-
flowId: string,
|
|
661
|
-
includeDocument: boolean = false
|
|
662
|
-
): Promise<Record<string, unknown>> {
|
|
663
|
-
await this.ensureAuthenticated();
|
|
664
|
-
|
|
665
|
-
const url = `${this.config.apiUrl}/api/v1/afnor/incoming-flows/${flowId}`;
|
|
666
|
-
const params: Record<string, string> = {};
|
|
667
|
-
if (includeDocument) {
|
|
668
|
-
params.include_document = 'true';
|
|
213
|
+
private async _ensureAuth(): Promise<void> {
|
|
214
|
+
if (Date.now() >= this.tokenExpiresAt) {
|
|
215
|
+
await this._refreshToken();
|
|
669
216
|
}
|
|
217
|
+
}
|
|
670
218
|
|
|
219
|
+
private async _refreshToken(): Promise<void> {
|
|
220
|
+
let response;
|
|
671
221
|
try {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
222
|
+
response = await this.httpClient.post(`${this.apiUrl}/api/token/`, {
|
|
223
|
+
username: this.email,
|
|
224
|
+
password: this.password,
|
|
225
|
+
client_uid: this.clientUid,
|
|
676
226
|
});
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const axiosError = error as AxiosError<{ detail?: string }>;
|
|
680
|
-
throw new FactPulseValidationError(
|
|
681
|
-
`Incoming flow error: ${axiosError.response?.data?.detail || axiosError.message}`
|
|
682
|
-
);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
throw new FactPulseError(`Auth network error: ${(e as Error).message}`);
|
|
683
229
|
}
|
|
684
|
-
}
|
|
685
230
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
// ==================== Chorus Pro ====================
|
|
691
|
-
|
|
692
|
-
async rechercherStructureChorus(criteria: Record<string, unknown>): Promise<unknown[]> {
|
|
693
|
-
await this.ensureAuthenticated();
|
|
694
|
-
const response = await this.httpClient.post(`${this.config.apiUrl}/api/v1/chorus-pro/structures/rechercher`, criteria, {
|
|
695
|
-
headers: { Authorization: `Bearer ${this.accessToken}`, 'Content-Type': 'application/json' },
|
|
696
|
-
});
|
|
697
|
-
return response.data;
|
|
698
|
-
}
|
|
231
|
+
if (response.status !== 200) {
|
|
232
|
+
throw new FactPulseError(`Authentication failed: HTTP ${response.status}`, response.status);
|
|
233
|
+
}
|
|
699
234
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
});
|
|
705
|
-
return response.data;
|
|
706
|
-
}
|
|
235
|
+
const data = response.data as { access?: string };
|
|
236
|
+
if (!data.access) {
|
|
237
|
+
throw new FactPulseError('Invalid auth response');
|
|
238
|
+
}
|
|
707
239
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
* @param idStructureCpp Chorus Pro ID of the structure
|
|
711
|
-
* @returns Object with listeServices, total, codeRetour, libelle
|
|
712
|
-
*/
|
|
713
|
-
async listerServicesStructureChorus(idStructureCpp: number): Promise<Record<string, unknown>> {
|
|
714
|
-
await this.ensureAuthenticated();
|
|
715
|
-
const response = await this.httpClient.get(`${this.config.apiUrl}/api/v1/chorus-pro/structures/${idStructureCpp}/services`, {
|
|
716
|
-
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
717
|
-
});
|
|
718
|
-
return response.data;
|
|
240
|
+
this.token = data.access;
|
|
241
|
+
this.tokenExpiresAt = Date.now() + 28 * 60 * 1000;
|
|
719
242
|
}
|
|
720
243
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
if (results.length > 0 && results[0].id_structure_cpp) return results[0].id_structure_cpp as number;
|
|
724
|
-
return null;
|
|
244
|
+
private _invalidateToken(): void {
|
|
245
|
+
this.tokenExpiresAt = 0;
|
|
725
246
|
}
|
|
247
|
+
}
|
|
726
248
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
const response = await this.httpClient.post(`${this.config.apiUrl}/api/v1/chorus/factures/soumettre`, invoiceData, {
|
|
730
|
-
headers: { Authorization: `Bearer ${this.accessToken}`, 'Content-Type': 'application/json' },
|
|
731
|
-
});
|
|
732
|
-
return response.data;
|
|
733
|
-
}
|
|
249
|
+
class Endpoint {
|
|
250
|
+
constructor(private client: FactPulseClient, private path: string) {}
|
|
734
251
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
headers: { Authorization: `Bearer ${this.accessToken}`, 'Content-Type': 'application/json' },
|
|
739
|
-
});
|
|
740
|
-
return response.data;
|
|
252
|
+
// Chain path segments: client.processing.invoices → processing/invoices
|
|
253
|
+
private _child(segment: string): Endpoint {
|
|
254
|
+
return new Endpoint(this.client, `${this.path}/${segment.replace(/_/g, '-')}`);
|
|
741
255
|
}
|
|
742
256
|
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
* @param options.profile - Factur-X profile (MINIMUM, BASIC, EN16931, EXTENDED). If not specified, auto-detected.
|
|
750
|
-
* @param options.useVerapdf - Enable strict PDF/A validation with VeraPDF (default: false)
|
|
751
|
-
*/
|
|
752
|
-
async validateFacturxPdf(
|
|
753
|
-
pdfBuffer: Buffer,
|
|
754
|
-
options: { profile?: string; useVerapdf?: boolean } = {}
|
|
755
|
-
): Promise<Record<string, unknown>> {
|
|
756
|
-
await this.ensureAuthenticated();
|
|
757
|
-
const form = new FormData();
|
|
758
|
-
form.append('pdf_file', pdfBuffer, { filename: 'facture.pdf', contentType: 'application/pdf' });
|
|
759
|
-
if (options.profile) {
|
|
760
|
-
form.append('profile', options.profile);
|
|
761
|
-
}
|
|
762
|
-
form.append('use_verapdf', String(options.useVerapdf ?? false));
|
|
763
|
-
const response = await this.httpClient.post(`${this.config.apiUrl}/api/v1/processing/validate-facturx-pdf`, form, {
|
|
764
|
-
headers: { ...form.getHeaders(), Authorization: `Bearer ${this.accessToken}` },
|
|
765
|
-
});
|
|
766
|
-
return response.data;
|
|
767
|
-
}
|
|
257
|
+
// Dynamic property access for path building
|
|
258
|
+
get invoices() { return this._child('invoices'); }
|
|
259
|
+
get structures() { return this._child('structures'); }
|
|
260
|
+
get tasks() { return this._child('tasks'); }
|
|
261
|
+
get validate_facturx_pdf() { return this._child('validate-facturx-pdf'); }
|
|
262
|
+
get submit_complete_async() { return this._child('submit-complete-async'); }
|
|
768
263
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
const form = new FormData();
|
|
772
|
-
form.append('xml_file', Buffer.from(xmlContent, 'utf-8'), { filename: 'facture.xml', contentType: 'application/xml' });
|
|
773
|
-
form.append('profile', profile);
|
|
774
|
-
const response = await this.httpClient.post(`${this.config.apiUrl}/api/v1/processing/validate-xml`, form, {
|
|
775
|
-
headers: { ...form.getHeaders(), Authorization: `Bearer ${this.accessToken}` },
|
|
776
|
-
});
|
|
777
|
-
return response.data;
|
|
778
|
-
}
|
|
264
|
+
// For dynamic segments like client.structures['123']
|
|
265
|
+
[key: string]: unknown;
|
|
779
266
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
form.append('pdf_file', pdfBuffer, { filename: 'document.pdf', contentType: 'application/pdf' });
|
|
784
|
-
const response = await this.httpClient.post(`${this.config.apiUrl}/api/v1/processing/validate-pdf-signature`, form, {
|
|
785
|
-
headers: { ...form.getHeaders(), Authorization: `Bearer ${this.accessToken}` },
|
|
786
|
-
});
|
|
787
|
-
return response.data;
|
|
267
|
+
// POST request
|
|
268
|
+
async call(data?: Record<string, unknown>): Promise<unknown> {
|
|
269
|
+
return this.client._request('POST', this.path, data);
|
|
788
270
|
}
|
|
789
271
|
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
* Signs a PDF with the server-configured certificate (via JWT client_uid).
|
|
794
|
-
* The certificate must be previously configured in Django Admin.
|
|
795
|
-
*/
|
|
796
|
-
async signPdf(
|
|
797
|
-
pdfBuffer: Buffer,
|
|
798
|
-
options: { reason?: string; location?: string; contact?: string; usePadesLt?: boolean; useTimestamp?: boolean } = {}
|
|
799
|
-
): Promise<Buffer> {
|
|
800
|
-
await this.ensureAuthenticated();
|
|
801
|
-
const form = new FormData();
|
|
802
|
-
form.append('pdf_file', pdfBuffer, { filename: 'document.pdf', contentType: 'application/pdf' });
|
|
803
|
-
if (options.reason) form.append('reason', options.reason);
|
|
804
|
-
if (options.location) form.append('location', options.location);
|
|
805
|
-
if (options.contact) form.append('contact', options.contact);
|
|
806
|
-
if (options.usePadesLt !== undefined) form.append('use_pades_lt', String(options.usePadesLt));
|
|
807
|
-
if (options.useTimestamp !== undefined) form.append('use_timestamp', String(options.useTimestamp));
|
|
808
|
-
const response = await this.httpClient.post(`${this.config.apiUrl}/api/v1/processing/sign-pdf`, form, {
|
|
809
|
-
headers: { ...form.getHeaders(), Authorization: `Bearer ${this.accessToken}` },
|
|
810
|
-
});
|
|
811
|
-
// The API returns JSON with pdf_signe_base64
|
|
812
|
-
const data = response.data as { pdf_signe_base64?: string };
|
|
813
|
-
if (data.pdf_signe_base64) {
|
|
814
|
-
return Buffer.from(data.pdf_signe_base64, 'base64');
|
|
815
|
-
}
|
|
816
|
-
throw new Error('Invalid signature response');
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* Generates a test certificate (NOT FOR PRODUCTION).
|
|
821
|
-
* The certificate must then be configured in Django Admin.
|
|
822
|
-
*/
|
|
823
|
-
async generateTestCertificate(
|
|
824
|
-
options: { cn?: string; organisation?: string; email?: string; validityDays?: number; keySize?: number } = {}
|
|
825
|
-
): Promise<Record<string, unknown>> {
|
|
826
|
-
await this.ensureAuthenticated();
|
|
827
|
-
const response = await this.httpClient.post(`${this.config.apiUrl}/api/v1/processing/generate-test-certificate`, {
|
|
828
|
-
cn: options.cn || 'Test Organisation',
|
|
829
|
-
organisation: options.organisation || 'Test Organisation',
|
|
830
|
-
email: options.email || 'test@example.com',
|
|
831
|
-
validity_days: options.validityDays || 365,
|
|
832
|
-
key_size: options.keySize || 2048,
|
|
833
|
-
}, {
|
|
834
|
-
headers: { Authorization: `Bearer ${this.accessToken}`, 'Content-Type': 'application/json' },
|
|
835
|
-
});
|
|
836
|
-
return response.data;
|
|
272
|
+
// GET request
|
|
273
|
+
async get(params?: Record<string, unknown>): Promise<unknown> {
|
|
274
|
+
return this.client._request('GET', this.path, params);
|
|
837
275
|
}
|
|
276
|
+
}
|
|
838
277
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
async generateCompleteFacturx(
|
|
850
|
-
invoiceData: Record<string, unknown>,
|
|
851
|
-
pdfPath: string,
|
|
852
|
-
options: {
|
|
853
|
-
profile?: string;
|
|
854
|
-
validate?: boolean;
|
|
855
|
-
sign?: boolean;
|
|
856
|
-
submitAfnor?: boolean;
|
|
857
|
-
afnorFlowName?: string;
|
|
858
|
-
afnorTrackingId?: string;
|
|
859
|
-
timeout?: number;
|
|
860
|
-
} = {}
|
|
861
|
-
): Promise<{
|
|
862
|
-
pdfBytes: Buffer;
|
|
863
|
-
validation?: Record<string, unknown>;
|
|
864
|
-
signature?: { signed: boolean };
|
|
865
|
-
afnor?: Record<string, unknown>;
|
|
866
|
-
}> {
|
|
867
|
-
const {
|
|
868
|
-
profile = 'EN16931',
|
|
869
|
-
validate = true,
|
|
870
|
-
sign = false,
|
|
871
|
-
submitAfnor = false,
|
|
872
|
-
afnorFlowName,
|
|
873
|
-
afnorTrackingId,
|
|
874
|
-
timeout,
|
|
875
|
-
} = options;
|
|
876
|
-
const result: {
|
|
877
|
-
pdfBytes: Buffer;
|
|
878
|
-
validation?: Record<string, unknown>;
|
|
879
|
-
signature?: { signed: boolean };
|
|
880
|
-
afnor?: Record<string, unknown>;
|
|
881
|
-
} = { pdfBytes: Buffer.alloc(0) };
|
|
882
|
-
|
|
883
|
-
// 1. Generation
|
|
884
|
-
const pdfBytes = await this.generateFacturx(invoiceData, pdfPath, profile, 'pdf', true, timeout) as Buffer;
|
|
885
|
-
result.pdfBytes = pdfBytes;
|
|
886
|
-
|
|
887
|
-
// 2. Validation
|
|
888
|
-
if (validate) {
|
|
889
|
-
const validation = await this.validateFacturxPdf(pdfBytes, { profile });
|
|
890
|
-
result.validation = validation;
|
|
891
|
-
if (!(validation as { isCompliant?: boolean }).isCompliant) {
|
|
892
|
-
return result;
|
|
278
|
+
// Proxy to allow dynamic path segments
|
|
279
|
+
const EndpointProxy = new Proxy(Endpoint, {
|
|
280
|
+
construct(target, args) {
|
|
281
|
+
const instance = new target(args[0], args[1]);
|
|
282
|
+
return new Proxy(instance, {
|
|
283
|
+
get(obj, prop) {
|
|
284
|
+
if (typeof prop === 'string' && !prop.startsWith('_') && !(prop in obj)) {
|
|
285
|
+
return new EndpointProxy(obj['client'], `${obj['path']}/${prop.replace(/_/g, '-')}`);
|
|
286
|
+
}
|
|
287
|
+
return (obj as unknown as Record<string | symbol, unknown>)[prop];
|
|
893
288
|
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// 3. Signature (uses the server-configured certificate)
|
|
897
|
-
if (sign) {
|
|
898
|
-
const signedPdf = await this.signPdf(result.pdfBytes);
|
|
899
|
-
result.pdfBytes = signedPdf;
|
|
900
|
-
result.signature = { signed: true };
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// 4. AFNOR submission
|
|
904
|
-
if (submitAfnor) {
|
|
905
|
-
const invoiceNumber = (invoiceData.invoiceNumber || invoiceData.invoice_number || 'INVOICE') as string;
|
|
906
|
-
const flowName = afnorFlowName || `Invoice ${invoiceNumber}`;
|
|
907
|
-
const trackingId = afnorTrackingId || invoiceNumber;
|
|
908
|
-
const afnorResult = await this.submitInvoiceAfnor(result.pdfBytes, flowName, { trackingId });
|
|
909
|
-
result.afnor = afnorResult;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
return result;
|
|
289
|
+
});
|
|
913
290
|
}
|
|
914
|
-
}
|
|
291
|
+
});
|