@placeshakr/config 10.28.1 → 10.30.2-alpha.1
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/dist/auth/policy.js +6 -0
- package/dist/auth/policy.js.map +1 -1
- package/dist/billing/index.js +9 -0
- package/dist/billing/index.js.map +1 -0
- package/dist/billing/pennylane.js +400 -0
- package/dist/billing/pennylane.js.map +1 -0
- package/dist/billing/permissions.js +27 -0
- package/dist/billing/permissions.js.map +1 -0
- package/dist/billing/prefill.js +208 -0
- package/dist/billing/prefill.js.map +1 -0
- package/dist/billing/types.js +3 -0
- package/dist/billing/types.js.map +1 -0
- package/dist/billing/validate.js +189 -0
- package/dist/billing/validate.js.map +1 -0
- package/dist/environment/base.js +30 -1
- package/dist/environment/base.js.map +1 -1
- package/dist/environment/decorators.js +2 -0
- package/dist/environment/decorators.js.map +1 -1
- package/dist/helpers/index.js +1 -0
- package/dist/helpers/index.js.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/types/auth/policy.d.ts +2 -1
- package/dist/types/auth/policy.d.ts.map +1 -1
- package/dist/types/billing/index.d.ts +6 -0
- package/dist/types/billing/index.d.ts.map +1 -0
- package/dist/types/billing/pennylane.d.ts +191 -0
- package/dist/types/billing/pennylane.d.ts.map +1 -0
- package/dist/types/billing/permissions.d.ts +23 -0
- package/dist/types/billing/permissions.d.ts.map +1 -0
- package/dist/types/billing/prefill.d.ts +42 -0
- package/dist/types/billing/prefill.d.ts.map +1 -0
- package/dist/types/billing/types.d.ts +30 -0
- package/dist/types/billing/types.d.ts.map +1 -0
- package/dist/types/billing/validate.d.ts +48 -0
- package/dist/types/billing/validate.d.ts.map +1 -0
- package/dist/types/environment/base.d.ts.map +1 -1
- package/dist/types/environment/decorators.d.ts.map +1 -1
- package/dist/types/environment/types.d.ts +5 -0
- package/dist/types/environment/types.d.ts.map +1 -1
- package/dist/types/helpers/index.d.ts +1 -0
- package/dist/types/helpers/index.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/types.d.ts +478 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types.js +70 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/auth/policy.ts +21 -1
- package/src/billing/index.ts +5 -0
- package/src/billing/pennylane.ts +530 -0
- package/src/billing/permissions.ts +25 -0
- package/src/billing/prefill.ts +309 -0
- package/src/billing/types.ts +32 -0
- package/src/billing/validate.ts +198 -0
- package/src/environment/base.ts +32 -1
- package/src/environment/decorators.ts +2 -0
- package/src/environment/types.ts +5 -0
- package/src/helpers/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/types.ts +512 -1
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import * as articho from '../types';
|
|
2
|
+
import { billingDisplayName } from './validate';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Contrato de entrada del callable `pennylaneV2-createCustomerInvoice`.
|
|
6
|
+
* Se declara aquí, en el paquete compartido, para que la app y las Cloud
|
|
7
|
+
* Functions hablen exactamente del mismo objeto.
|
|
8
|
+
*/
|
|
9
|
+
export type PennylaneAddressPayload = {
|
|
10
|
+
address: string;
|
|
11
|
+
postalCode: string;
|
|
12
|
+
city: string;
|
|
13
|
+
countryAlpha2: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PennylaneInvoicePayload = {
|
|
17
|
+
customerId?: number | null;
|
|
18
|
+
customer?: {
|
|
19
|
+
name: string;
|
|
20
|
+
/** Empresa o particular. Decide el endpoint que usa la Cloud Function. */
|
|
21
|
+
legalType?: articho.BillingLegalType;
|
|
22
|
+
/** Solo en particulares: PennyLane pide el nombre partido. */
|
|
23
|
+
firstName?: string;
|
|
24
|
+
lastName?: string;
|
|
25
|
+
billingAddress: PennylaneAddressPayload;
|
|
26
|
+
deliveryAddress?: PennylaneAddressPayload;
|
|
27
|
+
regNo?: string;
|
|
28
|
+
vatNumber?: string;
|
|
29
|
+
emails?: string[];
|
|
30
|
+
phone?: string;
|
|
31
|
+
recipient?: string;
|
|
32
|
+
notes?: string;
|
|
33
|
+
paymentConditions?: string;
|
|
34
|
+
billingLanguage?: string;
|
|
35
|
+
externalReference?: string;
|
|
36
|
+
archiId?: string;
|
|
37
|
+
};
|
|
38
|
+
date: string;
|
|
39
|
+
deadline: string;
|
|
40
|
+
lines: Array<{
|
|
41
|
+
label: string;
|
|
42
|
+
quantity: number;
|
|
43
|
+
unit: string;
|
|
44
|
+
unitPrice: number;
|
|
45
|
+
vatRate: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
discount?: { type: 'absolute' | 'relative'; value: number };
|
|
48
|
+
}>;
|
|
49
|
+
draft?: boolean;
|
|
50
|
+
currency?: string;
|
|
51
|
+
language?: string;
|
|
52
|
+
label?: string;
|
|
53
|
+
externalReference?: string;
|
|
54
|
+
pdfInvoiceSubject?: string;
|
|
55
|
+
specialMention?: string;
|
|
56
|
+
discount?: { type: 'absolute' | 'relative'; value: number };
|
|
57
|
+
sendToPa?: boolean;
|
|
58
|
+
projectId?: string | null;
|
|
59
|
+
billId?: string | null;
|
|
60
|
+
creditedInvoiceId?: number | null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Días que añade cada condición de pago a la fecha de la factura. */
|
|
64
|
+
const TERM_DAYS: Record<string, number> = {
|
|
65
|
+
[articho.TermsOfPayment.UPON_RECEIPT]: 0,
|
|
66
|
+
[articho.TermsOfPayment.MONTH_END]: 30,
|
|
67
|
+
[articho.TermsOfPayment.DAYS_10]: 10,
|
|
68
|
+
[articho.TermsOfPayment.DAYS_20]: 20,
|
|
69
|
+
[articho.TermsOfPayment.DAYS_30]: 30,
|
|
70
|
+
[articho.TermsOfPayment.DAYS_30_END_MONTH]: 30,
|
|
71
|
+
[articho.TermsOfPayment.DAYS_45]: 45,
|
|
72
|
+
[articho.TermsOfPayment.DAYS_60]: 60,
|
|
73
|
+
[articho.TermsOfPayment.DAYS_60_END_MONTH]: 60,
|
|
74
|
+
[articho.TermsOfPayment.DAYS_90]: 90,
|
|
75
|
+
[articho.TermsOfPayment.DAYS_90_END_MONTH]: 90,
|
|
76
|
+
[articho.TermsOfPayment.DAYS_120]: 120,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Condición de pago de Articho → la que entiende PennyLane. */
|
|
80
|
+
const PAYMENT_CONDITIONS: Record<string, articho.BillingPaymentConditions> = {
|
|
81
|
+
[articho.TermsOfPayment.UPON_RECEIPT]: articho.BillingPaymentConditions.UPON_RECEIPT,
|
|
82
|
+
[articho.TermsOfPayment.DAYS_10]: articho.BillingPaymentConditions.DAYS_7,
|
|
83
|
+
[articho.TermsOfPayment.DAYS_20]: articho.BillingPaymentConditions.DAYS_15,
|
|
84
|
+
[articho.TermsOfPayment.DAYS_30]: articho.BillingPaymentConditions.DAYS_30,
|
|
85
|
+
[articho.TermsOfPayment.MONTH_END]: articho.BillingPaymentConditions.DAYS_30_EOM,
|
|
86
|
+
[articho.TermsOfPayment.DAYS_30_END_MONTH]:
|
|
87
|
+
articho.BillingPaymentConditions.DAYS_30_EOM,
|
|
88
|
+
[articho.TermsOfPayment.DAYS_45]: articho.BillingPaymentConditions.DAYS_45,
|
|
89
|
+
[articho.TermsOfPayment.DAYS_60]: articho.BillingPaymentConditions.DAYS_60,
|
|
90
|
+
[articho.TermsOfPayment.DAYS_60_END_MONTH]: articho.BillingPaymentConditions.DAYS_60,
|
|
91
|
+
[articho.TermsOfPayment.DAYS_90]: articho.BillingPaymentConditions.DAYS_60,
|
|
92
|
+
[articho.TermsOfPayment.DAYS_90_END_MONTH]: articho.BillingPaymentConditions.DAYS_60,
|
|
93
|
+
[articho.TermsOfPayment.DAYS_120]: articho.BillingPaymentConditions.DAYS_60,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Porcentaje de IVA → código de PennyLane.
|
|
98
|
+
* @param {number} rate Porcentaje (20, 10, 5.5, 0).
|
|
99
|
+
* @param {boolean} hasTva false cuando la factura va sin IVA.
|
|
100
|
+
* @return {string} Código de IVA.
|
|
101
|
+
*/
|
|
102
|
+
export const toVatRateCode = (rate?: number, hasTva = true): string => {
|
|
103
|
+
if (!hasTva) return 'exempt';
|
|
104
|
+
|
|
105
|
+
switch (Math.round((rate ?? 0) * 10) / 10) {
|
|
106
|
+
case 20:
|
|
107
|
+
return 'FR_200';
|
|
108
|
+
case 10:
|
|
109
|
+
return 'FR_100';
|
|
110
|
+
case 5.5:
|
|
111
|
+
return 'FR_055';
|
|
112
|
+
case 2.1:
|
|
113
|
+
return 'FR_021';
|
|
114
|
+
case 0:
|
|
115
|
+
return 'exempt';
|
|
116
|
+
default:
|
|
117
|
+
return 'any';
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const toISODate = (value: number) => new Date(value).toISOString().slice(0, 10);
|
|
122
|
+
|
|
123
|
+
const toDiscount = (
|
|
124
|
+
value?: number,
|
|
125
|
+
type?: articho.BillDiscountType
|
|
126
|
+
): { type: 'absolute' | 'relative'; value: number } | undefined => {
|
|
127
|
+
if (!value) return undefined;
|
|
128
|
+
return {
|
|
129
|
+
type: type === articho.BillDiscountType.PERCENT ? 'relative' : 'absolute',
|
|
130
|
+
value,
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Determina si una factura emitida ya está sincronizada o enviada a PennyLane.
|
|
136
|
+
*
|
|
137
|
+
* Casos especiales:
|
|
138
|
+
* 1. Si el estado es FAILED, NO se considera enviada para permitir reintentar tras corregir el error.
|
|
139
|
+
* 2. Si fue importada desde PennyLane, ya existe en PennyLane.
|
|
140
|
+
* 3. Para avoirs (CREDIT_NOTE), invoiceId puede haber sido copiado de la factura origen.
|
|
141
|
+
* Solo se considera verdaderamente enviada si está SYNCED, tiene un invoiceId propio y
|
|
142
|
+
* un creditedInvoiceId distinto de su propio invoiceId.
|
|
143
|
+
*
|
|
144
|
+
* @param {articho.BillIssued} [bill] Factura emitida.
|
|
145
|
+
* @return {boolean} true si ya está enviada/sincronizada en PennyLane.
|
|
146
|
+
*/
|
|
147
|
+
export const isBillIssuedSentToPennylane = (bill?: articho.BillIssued): boolean => {
|
|
148
|
+
if (!bill) return false;
|
|
149
|
+
|
|
150
|
+
const isFailed = bill.metadata?.pennylane?.status === 'FAILED';
|
|
151
|
+
if (isFailed) return false;
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
bill.imported === true ||
|
|
155
|
+
bill.source === 'pennylane' ||
|
|
156
|
+
Boolean(bill.metadata?.pennylane?.importedBillId)
|
|
157
|
+
) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const isCreditNote = bill.subType === articho.BillIssuedType.CREDIT_NOTE;
|
|
162
|
+
if (isCreditNote) {
|
|
163
|
+
return Boolean(
|
|
164
|
+
bill.metadata?.pennylane?.status === 'SYNCED' &&
|
|
165
|
+
bill.metadata?.pennylane?.invoiceId &&
|
|
166
|
+
bill.metadata?.pennylane?.creditedInvoiceId &&
|
|
167
|
+
String(bill.metadata.pennylane.invoiceId) !==
|
|
168
|
+
String(bill.metadata.pennylane.creditedInvoiceId)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return Boolean(
|
|
173
|
+
bill.metadata?.pennylane?.status === 'SYNCED' ||
|
|
174
|
+
bill.metadata?.pennylane?.invoiceId ||
|
|
175
|
+
bill.metadata?.sentToPennyLane
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Motivos por los que una factura de Articho no se puede enviar a PennyLane.
|
|
181
|
+
* @param {articho.BillIssued} bill Factura emitida.
|
|
182
|
+
* @return {string[]} Claves de traducción de los bloqueos encontrados.
|
|
183
|
+
*/
|
|
184
|
+
export const getBillBlockers = (bill: articho.BillIssued): string[] => {
|
|
185
|
+
const blockers: string[] = [];
|
|
186
|
+
|
|
187
|
+
if (!bill?.elements?.length) blockers.push('pennylane.blocker.noElements');
|
|
188
|
+
|
|
189
|
+
if (bill?.subType === articho.BillIssuedType.CREDIT_NOTE) {
|
|
190
|
+
// Un avoir solo se puede enviar si tiene una factura de origen asociada
|
|
191
|
+
if (!bill?.billSource?.id) {
|
|
192
|
+
blockers.push('pennylane.blocker.creditNoteWithoutSource');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (isBillIssuedSentToPennylane(bill)) {
|
|
197
|
+
blockers.push('pennylane.blocker.alreadySent');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const withoutLabel = bill?.elements?.some((element) => !element?.description?.trim());
|
|
201
|
+
if (withoutLabel) blockers.push('pennylane.blocker.elementWithoutLabel');
|
|
202
|
+
|
|
203
|
+
return blockers;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Traduce o formatea de forma legible los mensajes de error devueltos por PennyLane
|
|
208
|
+
* para que el usuario en la interfaz comprenda qué ocurrió sin ver códigos HTTP crudos.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} [rawMessage] Mensaje de error recibido.
|
|
211
|
+
* @return {string} Mensaje traducido y formateado.
|
|
212
|
+
*/
|
|
213
|
+
export const formatPennylaneErrorMessage = (rawMessage?: string): string => {
|
|
214
|
+
if (!rawMessage) return '';
|
|
215
|
+
|
|
216
|
+
const msg = rawMessage.trim();
|
|
217
|
+
|
|
218
|
+
// Si el mensaje ya está estructurado y traducido en francés, conservarlo
|
|
219
|
+
if (
|
|
220
|
+
msg.includes('Authentification échouée') ||
|
|
221
|
+
msg.includes('Accès refusé par PennyLane') ||
|
|
222
|
+
msg.includes('Ressource introuvable sur PennyLane') ||
|
|
223
|
+
msg.includes('Données rejetées par PennyLane') ||
|
|
224
|
+
msg.includes('Limite de requêtes atteinte') ||
|
|
225
|
+
msg.includes('Le service PennyLane est temporairement indisponible')
|
|
226
|
+
) {
|
|
227
|
+
return msg;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Mapeo según patrones o códigos HTTP
|
|
231
|
+
if (msg.includes('401') || msg.toLowerCase().includes('unauthorized')) {
|
|
232
|
+
return "Authentification échouée auprès de PennyLane (401). Le jeton API est manquant, invalide ou a expiré.";
|
|
233
|
+
}
|
|
234
|
+
if (msg.includes('403') || msg.toLowerCase().includes('forbidden')) {
|
|
235
|
+
return "Accès refusé par PennyLane (403). La clé API ne dispose pas des droits nécessaires pour cette entreprise.";
|
|
236
|
+
}
|
|
237
|
+
if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
|
238
|
+
return "Ressource introuvable sur PennyLane (404) : le client, le tiers ou la facture d'origine n'existe pas.";
|
|
239
|
+
}
|
|
240
|
+
if (msg.includes('409') || msg.toLowerCase().includes('conflict')) {
|
|
241
|
+
return "Conflit sur PennyLane (409) : une facture ou ressource similaire existe déjà.";
|
|
242
|
+
}
|
|
243
|
+
if (msg.includes('413') || msg.toLowerCase().includes('too large')) {
|
|
244
|
+
return "Le document envoyé dépasse la limite de taille autorisée par PennyLane (100 Mo max).";
|
|
245
|
+
}
|
|
246
|
+
if (msg.includes('422') || msg.toLowerCase().includes('unprocessable')) {
|
|
247
|
+
return msg.length > 40 && !msg.startsWith('PennyLane 422')
|
|
248
|
+
? msg
|
|
249
|
+
: "Les données envoyées ne respectent pas les règles comptables de PennyLane (422) : vérifiez les montants, lignes et TVA.";
|
|
250
|
+
}
|
|
251
|
+
if (msg.includes('429') || msg.toLowerCase().includes('rate limit')) {
|
|
252
|
+
return "Limite de requêtes atteinte sur PennyLane (429). Veuillez patienter quelques instants avant de réessayer.";
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
msg.includes('500') ||
|
|
256
|
+
msg.includes('502') ||
|
|
257
|
+
msg.includes('503') ||
|
|
258
|
+
msg.includes('504') ||
|
|
259
|
+
msg.toLowerCase().includes('internal server error')
|
|
260
|
+
) {
|
|
261
|
+
return "Le service PennyLane est temporairement indisponible ou rencontre une erreur interne. Veuillez réessayer dans quelques instants.";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return msg;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Traduce una factura emitida de Articho a la entrada del callable de PennyLane.
|
|
269
|
+
*
|
|
270
|
+
* Cada `BillElement` se convierte en una línea: PennyLane calcula los totales
|
|
271
|
+
* a partir de ellas, así que no se envían ni el total ni el número de factura
|
|
272
|
+
* —ese lo asigna PennyLane, que es justo lo que se busca—.
|
|
273
|
+
*
|
|
274
|
+
* Para avoirs (`CREDIT_NOTE`), los precios unitarios se invierten a negativo
|
|
275
|
+
* según la especificación de PennyLane v2 y se vincula `credited_invoice_id`.
|
|
276
|
+
*
|
|
277
|
+
* @param {articho.BillIssued} bill Factura emitida.
|
|
278
|
+
* @param {object} context Destinatario y opciones de envío.
|
|
279
|
+
* @return {PennylaneInvoicePayload} Carga útil para el callable.
|
|
280
|
+
*/
|
|
281
|
+
export const billIssuedToPennylanePayload = (
|
|
282
|
+
bill: articho.BillIssued,
|
|
283
|
+
context: {
|
|
284
|
+
entity: articho.BillIssuedEntity;
|
|
285
|
+
billing: articho.BillingIdentity;
|
|
286
|
+
entityId?: string;
|
|
287
|
+
projectId?: string;
|
|
288
|
+
sendToPa?: boolean;
|
|
289
|
+
draft?: boolean;
|
|
290
|
+
creditedInvoiceId?: string | number | null;
|
|
291
|
+
}
|
|
292
|
+
): PennylaneInvoicePayload => {
|
|
293
|
+
const isCreditNote = bill?.subType === articho.BillIssuedType.CREDIT_NOTE;
|
|
294
|
+
const date = bill?.date ?? Date.now();
|
|
295
|
+
const termDays = isCreditNote ? 0 : (TERM_DAYS[bill?.termsOfPayment ?? ''] ?? 30);
|
|
296
|
+
const deadline = date + termDays * 24 * 60 * 60 * 1000;
|
|
297
|
+
|
|
298
|
+
const { billing } = context;
|
|
299
|
+
|
|
300
|
+
const creditedInvoiceId =
|
|
301
|
+
context.creditedInvoiceId !== undefined && context.creditedInvoiceId !== null
|
|
302
|
+
? Number(context.creditedInvoiceId)
|
|
303
|
+
: undefined;
|
|
304
|
+
|
|
305
|
+
const displayLabel = isCreditNote
|
|
306
|
+
? bill?.billSource?.reference
|
|
307
|
+
? `Avoir sur facture ${bill.billSource.reference}`
|
|
308
|
+
: bill?.title || 'Avoir'
|
|
309
|
+
: bill?.title;
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
// Si ya sabemos el id en PennyLane lo usamos y nos ahorramos la búsqueda.
|
|
313
|
+
customerId: billing?.pennylaneCustomerId ? Number(billing.pennylaneCustomerId) : null,
|
|
314
|
+
customer: {
|
|
315
|
+
// En un particular la razón social no existe: se compone del nombre.
|
|
316
|
+
name: billingDisplayName(billing),
|
|
317
|
+
...(billing.legalType ? { legalType: billing.legalType } : {}),
|
|
318
|
+
...(billing.legalType === 'individual'
|
|
319
|
+
? {
|
|
320
|
+
firstName: billing.firstName ?? '',
|
|
321
|
+
lastName: billing.lastName ?? '',
|
|
322
|
+
}
|
|
323
|
+
: {}),
|
|
324
|
+
billingAddress: {
|
|
325
|
+
address: billing.address.address,
|
|
326
|
+
postalCode: billing.address.postalCode,
|
|
327
|
+
city: billing.address.city,
|
|
328
|
+
countryAlpha2: (billing.address.countryAlpha2 || 'FR').toUpperCase(),
|
|
329
|
+
},
|
|
330
|
+
// Un particular no tiene ninguno de los dos. Si algo se coló en la
|
|
331
|
+
// ficha, no viaja: PennyLane rechazaría la creación del tercero.
|
|
332
|
+
...(billing.legalType !== 'individual' && billing.registrationNumber
|
|
333
|
+
? { regNo: billing.registrationNumber }
|
|
334
|
+
: {}),
|
|
335
|
+
...(billing.legalType !== 'individual' && billing.vatNumber
|
|
336
|
+
? { vatNumber: billing.vatNumber }
|
|
337
|
+
: {}),
|
|
338
|
+
...(billing.emails?.length ? { emails: billing.emails } : {}),
|
|
339
|
+
...(billing.phone ? { phone: billing.phone } : {}),
|
|
340
|
+
...(billing.recipient ? { recipient: billing.recipient } : {}),
|
|
341
|
+
...(billing.notes ? { notes: billing.notes } : {}),
|
|
342
|
+
paymentConditions:
|
|
343
|
+
billing.paymentConditions ??
|
|
344
|
+
PAYMENT_CONDITIONS[bill?.termsOfPayment ?? ''] ??
|
|
345
|
+
articho.BillingPaymentConditions.DAYS_30,
|
|
346
|
+
billingLanguage: billing.language ?? articho.BillingLanguage.FR,
|
|
347
|
+
...(context.entityId ? { archiId: context.entityId } : {}),
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
date: toISODate(date),
|
|
351
|
+
deadline: toISODate(deadline),
|
|
352
|
+
|
|
353
|
+
lines: [...(bill?.elements ?? [])]
|
|
354
|
+
.sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0))
|
|
355
|
+
.map((element) => {
|
|
356
|
+
const rawPrice = element.priceHT ?? 0;
|
|
357
|
+
const unitPrice = isCreditNote ? -Math.abs(rawPrice) : rawPrice;
|
|
358
|
+
return {
|
|
359
|
+
label: element.description,
|
|
360
|
+
quantity: element.quantity ?? 1,
|
|
361
|
+
unit: 'piece',
|
|
362
|
+
unitPrice,
|
|
363
|
+
vatRate: toVatRateCode(element.tva, bill?.tva !== false),
|
|
364
|
+
...(toDiscount(element.discount, element.discountType)
|
|
365
|
+
? { discount: toDiscount(element.discount, element.discountType) }
|
|
366
|
+
: {}),
|
|
367
|
+
};
|
|
368
|
+
}),
|
|
369
|
+
|
|
370
|
+
draft: context.draft ?? false,
|
|
371
|
+
currency: 'EUR',
|
|
372
|
+
language: billing.language ?? articho.BillingLanguage.FR,
|
|
373
|
+
label: displayLabel,
|
|
374
|
+
externalReference: bill?.id ? `articho:${bill.id}` : undefined,
|
|
375
|
+
pdfInvoiceSubject: displayLabel,
|
|
376
|
+
...(bill?.comments ? { specialMention: bill.comments } : {}),
|
|
377
|
+
...(toDiscount(bill?.discount, bill?.discountType)
|
|
378
|
+
? { discount: toDiscount(bill?.discount, bill?.discountType) }
|
|
379
|
+
: {}),
|
|
380
|
+
|
|
381
|
+
sendToPa: context.sendToPa ?? false,
|
|
382
|
+
projectId: context.projectId ?? null,
|
|
383
|
+
billId: bill?.id ?? null,
|
|
384
|
+
...(creditedInvoiceId ? { creditedInvoiceId } : {}),
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** Known PennyLane company IDs mapped by Articho enterprise ID. Used as fallback when not configured in Firestore. */
|
|
389
|
+
export const ENTERPRISE_PENNYLANE_COMPANY_IDS: Record<string, string> = {
|
|
390
|
+
'45106373900119': '156553',
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Generates a direct URL to view an invoice in the PennyLane web application.
|
|
395
|
+
* Returns null if the invoice ID or company ID is missing.
|
|
396
|
+
*
|
|
397
|
+
* Examples:
|
|
398
|
+
* - Customer (Ventes):
|
|
399
|
+
* https://app.pennylane.com/companies/156553/clients/customer_invoices?invoice_id=28823318994944
|
|
400
|
+
* - Supplier (Achats):
|
|
401
|
+
* https://app.pennylane.com/companies/156553/supplier_invoices?invoice_id=28645132099584
|
|
402
|
+
*
|
|
403
|
+
* @param {object} options
|
|
404
|
+
* @param {string | number} [options.invoiceId] PennyLane invoice ID (or prefixed ID like 'pl_123').
|
|
405
|
+
* @param {string | number} [options.companyId] PennyLane company ID (e.g. '156553', from Firestore config).
|
|
406
|
+
* @param {string} [options.enterpriseId] Articho enterprise ID.
|
|
407
|
+
* @param {'customer' | 'supplier'} [options.type] 'customer' for customer invoices (ventes), 'supplier' for supplier invoices (achats).
|
|
408
|
+
* @return {string | null} Full PennyLane app URL or null if cannot be formed.
|
|
409
|
+
*/
|
|
410
|
+
export const getPennylaneInvoiceUrl = (options?: {
|
|
411
|
+
invoiceId?: string | number | null;
|
|
412
|
+
companyId?: string | number | null;
|
|
413
|
+
enterpriseId?: string | null;
|
|
414
|
+
type?: 'customer' | 'supplier';
|
|
415
|
+
}): string | null => {
|
|
416
|
+
if (!options?.invoiceId) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const companyId =
|
|
421
|
+
options?.companyId ||
|
|
422
|
+
(options?.enterpriseId ? ENTERPRISE_PENNYLANE_COMPANY_IDS[options.enterpriseId] : undefined) ||
|
|
423
|
+
ENTERPRISE_PENNYLANE_COMPANY_IDS['45106373900119'];
|
|
424
|
+
|
|
425
|
+
if (!companyId) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const base = 'https://app.pennylane.com';
|
|
430
|
+
const section =
|
|
431
|
+
options?.type === 'customer' ? 'clients/customer_invoices' : 'supplier_invoices';
|
|
432
|
+
const cleanId = String(options.invoiceId).replace(/^pl_/, '').trim();
|
|
433
|
+
|
|
434
|
+
return `${base}/companies/${companyId}/${section}?invoice_id=${cleanId}`;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Normalizes a string for loose comparison (lowercase, trimmed, collapsed whitespace, accent-free).
|
|
439
|
+
*
|
|
440
|
+
* @param {string | null | undefined} val Input string.
|
|
441
|
+
* @return {string} Normalized string.
|
|
442
|
+
*/
|
|
443
|
+
export const normalizeSupplierName = (val?: string | null): string => {
|
|
444
|
+
return (val ?? '')
|
|
445
|
+
.trim()
|
|
446
|
+
.toLowerCase()
|
|
447
|
+
.normalize('NFD')
|
|
448
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
449
|
+
.replace(/\s+/g, ' ');
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Normalizes a SIREN / SIRET registration number (digits only).
|
|
454
|
+
*
|
|
455
|
+
* @param {string | null | undefined} val Registration number.
|
|
456
|
+
* @return {string} Normalized digits only.
|
|
457
|
+
*/
|
|
458
|
+
export const normalizeRegNo = (val?: string | null): string => {
|
|
459
|
+
return (val ?? '').replace(/\D/g, '');
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Checks whether a counterparty matches any entry in the enterprise's ignored suppliers list.
|
|
464
|
+
*
|
|
465
|
+
* Match criteria:
|
|
466
|
+
* 1. Matching PennyLane supplier ID
|
|
467
|
+
* 2. Matching SIREN/SIRET registration number (at least 9 digits / SIREN match)
|
|
468
|
+
* 3. Matching normalized supplier name
|
|
469
|
+
*
|
|
470
|
+
* @param {object} [counterparty] Counterparty details from imported bill or invoice.
|
|
471
|
+
* @param {articho.PennylaneIgnoredSupplier[]} [ignoredSuppliers] Enterprise ignored suppliers list.
|
|
472
|
+
* @return {articho.PennylaneIgnoredSupplier | null} The matching rule if found, or null.
|
|
473
|
+
*/
|
|
474
|
+
export const findMatchingIgnoredSupplier = (
|
|
475
|
+
counterparty?: {
|
|
476
|
+
name?: string | null;
|
|
477
|
+
regNo?: string | null;
|
|
478
|
+
pennylaneId?: string | null;
|
|
479
|
+
} | null,
|
|
480
|
+
ignoredSuppliers?: articho.PennylaneIgnoredSupplier[]
|
|
481
|
+
): articho.PennylaneIgnoredSupplier | null => {
|
|
482
|
+
if (!counterparty || !ignoredSuppliers || ignoredSuppliers.length === 0) return null;
|
|
483
|
+
|
|
484
|
+
const plId = counterparty.pennylaneId ? String(counterparty.pennylaneId).trim() : null;
|
|
485
|
+
const regNo = normalizeRegNo(counterparty.regNo);
|
|
486
|
+
const name = normalizeSupplierName(counterparty.name);
|
|
487
|
+
|
|
488
|
+
for (const rule of ignoredSuppliers) {
|
|
489
|
+
// 1. Match by PennyLane ID
|
|
490
|
+
if (plId && rule.pennylaneId && String(rule.pennylaneId).trim() === plId) {
|
|
491
|
+
return rule;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 2. Match by SIREN / SIRET (at least 9 digits)
|
|
495
|
+
if (regNo.length >= 9 && rule.regNo) {
|
|
496
|
+
const ruleRegNo = normalizeRegNo(rule.regNo);
|
|
497
|
+
if (ruleRegNo.length >= 9 && regNo.substring(0, 9) === ruleRegNo.substring(0, 9)) {
|
|
498
|
+
return rule;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// 3. Match by normalized name (exact normalized string match)
|
|
503
|
+
if (name && rule.name) {
|
|
504
|
+
const ruleName = normalizeSupplierName(rule.name);
|
|
505
|
+
if (name === ruleName) {
|
|
506
|
+
return rule;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return null;
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Returns true if the counterparty is ignored by the enterprise configuration.
|
|
516
|
+
*
|
|
517
|
+
* @param {object} [counterparty] Counterparty details.
|
|
518
|
+
* @param {articho.PennylaneIgnoredSupplier[]} [ignoredSuppliers] List of ignored suppliers.
|
|
519
|
+
* @return {boolean} True if ignored.
|
|
520
|
+
*/
|
|
521
|
+
export const isSupplierIgnored = (
|
|
522
|
+
counterparty?: {
|
|
523
|
+
name?: string | null;
|
|
524
|
+
regNo?: string | null;
|
|
525
|
+
pennylaneId?: string | null;
|
|
526
|
+
} | null,
|
|
527
|
+
ignoredSuppliers?: articho.PennylaneIgnoredSupplier[]
|
|
528
|
+
): boolean => {
|
|
529
|
+
return Boolean(findMatchingIgnoredSupplier(counterparty, ignoredSuppliers));
|
|
530
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ROLE } from '../auth';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Roles que pueden enviar una factura a PennyLane.
|
|
5
|
+
*
|
|
6
|
+
* Deliberadamente corto: enviar una factura crea un asiento en la contabilidad
|
|
7
|
+
* real de la empresa y, cuando se transmite a la plataforma agréée, sale de
|
|
8
|
+
* Articho hacia un tercero. No es una acción reversible desde aquí.
|
|
9
|
+
*
|
|
10
|
+
* Un agent puede preparar la factura y rellenar los datos del tercero; el
|
|
11
|
+
* envío lo hace un administrador.
|
|
12
|
+
*/
|
|
13
|
+
export const PENNYLANE_SENDER_ROLES: ROLE[] = [ROLE.ADMIN, ROLE.OWNER, ROLE.SUPERADMIN];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Única fuente de verdad sobre quién puede enviar a PennyLane. La usan tanto
|
|
17
|
+
* la interfaz (para no mostrar el botón) como las Cloud Functions (para
|
|
18
|
+
* rechazar la llamada). Si divergieran, el botón aparecería para gente a la
|
|
19
|
+
* que después le rebotaría el envío.
|
|
20
|
+
*
|
|
21
|
+
* @param {ROLE} role Rol del perfil que intenta enviar.
|
|
22
|
+
* @return {boolean} true si puede enviar.
|
|
23
|
+
*/
|
|
24
|
+
export const canSendToPennylane = (role?: ROLE | string | null): boolean =>
|
|
25
|
+
!!role && PENNYLANE_SENDER_ROLES.includes(role as ROLE);
|