@placeshakr/config 10.29.4 → 10.31.0-alpha.2

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.
Files changed (62) hide show
  1. package/dist/auth/policy.js +6 -0
  2. package/dist/auth/policy.js.map +1 -1
  3. package/dist/billing/index.js +9 -0
  4. package/dist/billing/index.js.map +1 -0
  5. package/dist/billing/pennylane.js +400 -0
  6. package/dist/billing/pennylane.js.map +1 -0
  7. package/dist/billing/permissions.js +27 -0
  8. package/dist/billing/permissions.js.map +1 -0
  9. package/dist/billing/prefill.js +208 -0
  10. package/dist/billing/prefill.js.map +1 -0
  11. package/dist/billing/types.js +3 -0
  12. package/dist/billing/types.js.map +1 -0
  13. package/dist/billing/validate.js +189 -0
  14. package/dist/billing/validate.js.map +1 -0
  15. package/dist/environment/base.js +28 -0
  16. package/dist/environment/base.js.map +1 -1
  17. package/dist/environment/decorators.js +2 -0
  18. package/dist/environment/decorators.js.map +1 -1
  19. package/dist/helpers/index.js +1 -0
  20. package/dist/helpers/index.js.map +1 -1
  21. package/dist/index.js +3 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/types/auth/policy.d.ts +2 -1
  24. package/dist/types/auth/policy.d.ts.map +1 -1
  25. package/dist/types/billing/index.d.ts +6 -0
  26. package/dist/types/billing/index.d.ts.map +1 -0
  27. package/dist/types/billing/pennylane.d.ts +191 -0
  28. package/dist/types/billing/pennylane.d.ts.map +1 -0
  29. package/dist/types/billing/permissions.d.ts +23 -0
  30. package/dist/types/billing/permissions.d.ts.map +1 -0
  31. package/dist/types/billing/prefill.d.ts +42 -0
  32. package/dist/types/billing/prefill.d.ts.map +1 -0
  33. package/dist/types/billing/types.d.ts +30 -0
  34. package/dist/types/billing/types.d.ts.map +1 -0
  35. package/dist/types/billing/validate.d.ts +48 -0
  36. package/dist/types/billing/validate.d.ts.map +1 -0
  37. package/dist/types/environment/base.d.ts.map +1 -1
  38. package/dist/types/environment/decorators.d.ts.map +1 -1
  39. package/dist/types/environment/types.d.ts +5 -0
  40. package/dist/types/environment/types.d.ts.map +1 -1
  41. package/dist/types/helpers/index.d.ts +1 -0
  42. package/dist/types/helpers/index.d.ts.map +1 -1
  43. package/dist/types/index.d.ts +2 -1
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/dist/types/types.d.ts +491 -0
  46. package/dist/types/types.d.ts.map +1 -1
  47. package/dist/types.js +70 -1
  48. package/dist/types.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/auth/policy.ts +21 -1
  51. package/src/billing/index.ts +5 -0
  52. package/src/billing/pennylane.ts +530 -0
  53. package/src/billing/permissions.ts +25 -0
  54. package/src/billing/prefill.ts +309 -0
  55. package/src/billing/types.ts +32 -0
  56. package/src/billing/validate.ts +198 -0
  57. package/src/environment/base.ts +30 -0
  58. package/src/environment/decorators.ts +2 -0
  59. package/src/environment/types.ts +5 -0
  60. package/src/helpers/index.ts +2 -0
  61. package/src/index.ts +2 -0
  62. package/src/types.ts +524 -0
@@ -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);