@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,309 @@
1
+ import * as articho from '../types';
2
+
3
+ /** Cualquiera de las cinco entidades a las que Articho puede facturar. */
4
+ export type BillableEntityRecord =
5
+ | articho.Client
6
+ | articho.Pro
7
+ | articho.Seller
8
+ | articho.Provider
9
+ | articho.MaterialsProvider;
10
+
11
+ const first = (...values: Array<string | undefined | null>): string =>
12
+ values.find((value) => !!value && !!value.trim())?.trim() ?? '';
13
+
14
+ const SIREN_OR_SIRET = /^\d{9}(\d{5})?$/;
15
+
16
+ /**
17
+ * Deduce el tipo de tercero SOLO cuando hay prueba.
18
+ *
19
+ * Un identificador fiscal con formato válido es prueba de empresa: nadie que
20
+ * no lo sea tiene uno. La ausencia, en cambio, no prueba nada —la mayoría de
21
+ * las fichas antiguas nunca lo rellenaron—, así que se devuelve `undefined` y
22
+ * lo pregunta el formulario. Adivinar «particular» por un hueco convertiría en
23
+ * personas a media base de datos.
24
+ *
25
+ * @param {articho.BillIssuedEntity} entity A quién va dirigida la factura.
26
+ * @param {Record<string, any>} record Ficha del tercero.
27
+ * @param {articho.BillingIdentity} existing Datos de facturación ya guardados.
28
+ * @return {articho.BillingLegalType | undefined} Tipo, o undefined si no consta.
29
+ */
30
+ const inferLegalType = (
31
+ entity: articho.BillIssuedEntity,
32
+ record: Record<string, any>,
33
+ existing?: articho.BillingIdentity
34
+ ): articho.BillingLegalType | undefined => {
35
+ // Lo que alguien haya elegido a mano manda sobre cualquier deducción.
36
+ if (existing?.legalType) return existing.legalType;
37
+
38
+ // Sous-traitants, apporteurs y proveedores son empresa por definición.
39
+ if (
40
+ entity !== articho.BillIssuedEntity.CLIENT &&
41
+ entity !== articho.BillIssuedEntity.OTHER
42
+ ) {
43
+ return 'company';
44
+ }
45
+
46
+ const identifier = first(
47
+ existing?.registrationNumber,
48
+ record.siret,
49
+ record.siren
50
+ ).replace(/\s/g, '');
51
+
52
+ const vat = first(existing?.vatNumber, record.tvaNumber).replace(/\s/g, '');
53
+
54
+ // Si tiene SIRET/SIREN, N° de TVA intracomunitario o nombre comercial, es empresa.
55
+ if (
56
+ SIREN_OR_SIRET.test(identifier) ||
57
+ identifier ||
58
+ vat ||
59
+ first(record.commercialName)
60
+ ) {
61
+ return 'company';
62
+ }
63
+
64
+ // Un cliente sin datos de empresa es un particular por defecto.
65
+ if (entity === articho.BillIssuedEntity.CLIENT) {
66
+ return 'individual';
67
+ }
68
+
69
+ return undefined;
70
+ };
71
+
72
+ /**
73
+ * Intenta extraer código postal (5 dígitos) y ciudad de una dirección francesa en texto libre.
74
+ */
75
+ const extractPostalCodeAndCity = (
76
+ rawAddress: string
77
+ ): { postalCode?: string; city?: string } => {
78
+ if (!rawAddress) return {};
79
+ const match = rawAddress.match(
80
+ /\b(\d{5})\b\s+([A-Za-zÀ-ÿ\s-]+?)(?:,?\s*(?:France|FR))?$/i
81
+ );
82
+ if (match) {
83
+ return {
84
+ postalCode: match[1],
85
+ city: match[2].trim(),
86
+ };
87
+ }
88
+ return {};
89
+ };
90
+
91
+ /**
92
+ * Construye un borrador de datos de facturación a partir de lo que ya hay en
93
+ * la ficha del tercero.
94
+ *
95
+ * Sirve para que el modal no pida de cero lo que ya sabemos. Lo que no se
96
+ * puede derivar —código postal, ciudad y país— se deja vacío a propósito:
97
+ * las direcciones de Articho son texto libre y adivinar el desglose de una
98
+ * dirección francesa falla lo bastante como para acabar en la contabilidad.
99
+ *
100
+ * @param {articho.BillIssuedEntity} entity A quién va dirigida la factura.
101
+ * @param {BillableEntityRecord} record Ficha del tercero.
102
+ * @param {articho.EntityData} entityData Datos copiados en la factura, si los hay.
103
+ * @return {articho.BillingIdentity} Borrador para el formulario.
104
+ */
105
+ export const buildBillingDraft = (
106
+ entity: articho.BillIssuedEntity,
107
+ record?: BillableEntityRecord,
108
+ entityData?: articho.EntityData
109
+ ): articho.BillingIdentity => {
110
+ const anyRecord = (record ?? {}) as Record<string, any>;
111
+ const existing = anyRecord.billing as articho.BillingIdentity | undefined;
112
+ const legalType = inferLegalType(entity, anyRecord, existing);
113
+
114
+ // En un sous-traitant la razon social es el nombre comercial; en un
115
+ // cliente particular, el nombre del perfil. Por eso el orden cambia.
116
+ const legalName =
117
+ entity === articho.BillIssuedEntity.PRO
118
+ ? first(
119
+ existing?.legalName,
120
+ anyRecord.commercialName,
121
+ anyRecord.displayName,
122
+ entityData?.name
123
+ )
124
+ : first(
125
+ existing?.legalName,
126
+ anyRecord.commercialName,
127
+ anyRecord.name,
128
+ anyRecord.displayName,
129
+ entityData?.name
130
+ );
131
+
132
+ const rawAddress = first(
133
+ existing?.address?.address,
134
+ anyRecord.address,
135
+ anyRecord.deliveryAddress,
136
+ entityData?.address
137
+ );
138
+
139
+ const parsedGeo = extractPostalCodeAndCity(rawAddress);
140
+
141
+ const postalCode = first(
142
+ existing?.address?.postalCode,
143
+ anyRecord.postalCode,
144
+ anyRecord.zipCode,
145
+ parsedGeo.postalCode
146
+ );
147
+
148
+ const city = first(existing?.address?.city, anyRecord.city, parsedGeo.city);
149
+
150
+ const countryAlpha2 =
151
+ first(
152
+ existing?.address?.countryAlpha2,
153
+ anyRecord.country,
154
+ anyRecord.countryAlpha2
155
+ ) || 'FR';
156
+
157
+ const registrationNumber = first(
158
+ existing?.registrationNumber,
159
+ anyRecord.siret,
160
+ anyRecord.siren,
161
+ entityData?.siret
162
+ ).replace(/\s/g, '');
163
+
164
+ const vatNumber = first(
165
+ existing?.vatNumber,
166
+ anyRecord.tvaNumber,
167
+ entityData?.tva
168
+ ).replace(/\s/g, '');
169
+
170
+ const emails = existing?.emails?.length
171
+ ? existing.emails
172
+ : [
173
+ first(anyRecord.email, anyRecord.contact?.email, entityData?.email),
174
+ ...((
175
+ anyRecord.additionalEmails as articho.ExtraEmail[] | undefined
176
+ )?.map((extra) => extra?.email) ?? []),
177
+ ].filter((email): email is string => !!email);
178
+
179
+ const phone = first(
180
+ existing?.phone,
181
+ anyRecord.mainPhone,
182
+ Array.isArray(anyRecord.phone)
183
+ ? anyRecord.phone[0]?.number
184
+ : anyRecord.phone
185
+ );
186
+
187
+ let firstName = first(existing?.firstName, anyRecord.firstName);
188
+ let lastName = first(existing?.lastName, anyRecord.lastName);
189
+
190
+ const rawPersonName = first(
191
+ existing?.firstName && existing?.lastName ? '' : undefined,
192
+ anyRecord.displayName,
193
+ anyRecord.name,
194
+ entityData?.name,
195
+ existing?.legalName
196
+ );
197
+
198
+ if ((!firstName || !lastName) && rawPersonName) {
199
+ const trimmed = rawPersonName.trim();
200
+ const firstSpaceIdx = trimmed.indexOf(' ');
201
+ if (firstSpaceIdx > 0) {
202
+ if (!firstName) firstName = trimmed.slice(0, firstSpaceIdx);
203
+ if (!lastName) lastName = trimmed.slice(firstSpaceIdx + 1).trim();
204
+ } else if (!firstName) {
205
+ firstName = trimmed;
206
+ }
207
+ }
208
+
209
+ // Lo ya guardado manda: si alguien lo rellenó, no lo pisamos con conjeturas.
210
+ // Pero si algún dato fiscal o de contacto faltaba en billing y sí existe en la
211
+ // ficha del cliente (siret, tvaNumber, etc.), lo enriquecemos.
212
+ const complete =
213
+ !!existing?.address?.postalCode &&
214
+ !!existing?.address?.city &&
215
+ !!existing?.address?.address &&
216
+ (existing?.legalType === 'individual'
217
+ ? !!existing?.firstName && !!existing?.lastName
218
+ : !!existing?.legalName);
219
+
220
+ if (complete) {
221
+ return {
222
+ ...existing,
223
+ ...(legalType ? { legalType } : {}),
224
+ firstName: existing?.firstName || firstName || undefined,
225
+ lastName: existing?.lastName || lastName || undefined,
226
+ registrationNumber:
227
+ existing?.registrationNumber || registrationNumber || undefined,
228
+ vatNumber: existing?.vatNumber || vatNumber || undefined,
229
+ address: {
230
+ address: existing?.address?.address || rawAddress,
231
+ postalCode: existing?.address?.postalCode || postalCode,
232
+ city: existing?.address?.city || city,
233
+ countryAlpha2: existing?.address?.countryAlpha2 || countryAlpha2,
234
+ },
235
+ phone: existing?.phone || phone || undefined,
236
+ emails: existing?.emails?.length
237
+ ? existing.emails
238
+ : emails.length
239
+ ? [...new Set(emails)]
240
+ : undefined,
241
+ } as articho.BillingIdentity;
242
+ }
243
+
244
+ return {
245
+ ...existing,
246
+ ...(legalType ? { legalType } : {}),
247
+ firstName: firstName || undefined,
248
+ lastName: lastName || undefined,
249
+ legalName:
250
+ legalName ||
251
+ (legalType === 'individual'
252
+ ? [firstName, lastName].filter(Boolean).join(' ')
253
+ : '') ||
254
+ '',
255
+ address: {
256
+ address: rawAddress,
257
+ postalCode,
258
+ city,
259
+ countryAlpha2,
260
+ },
261
+ ...(registrationNumber ? { registrationNumber } : {}),
262
+ ...(vatNumber ? { vatNumber } : {}),
263
+ ...(emails.length ? { emails: [...new Set(emails)] } : {}),
264
+ ...(phone ? { phone } : {}),
265
+ language: existing?.language ?? articho.BillingLanguage.FR,
266
+ paymentConditions:
267
+ existing?.paymentConditions ?? articho.BillingPaymentConditions.DAYS_30,
268
+ };
269
+ };
270
+
271
+ /**
272
+ * De dónde salen y a dónde vuelven los datos de facturación según a quién se
273
+ * factura.
274
+ *
275
+ * No todos los destinatarios tienen ficha. Solo CLIENT y PRO (perfiles de
276
+ * usuario) y PROVIDER (apporteur d'affaires) apuntan a un documento real; para
277
+ * "fournisseur de matériaux", "fournisseur de services" y "personnalisé",
278
+ * Articho solo guarda un nombre escrito a mano en la propia factura
279
+ * (`useGetEntityData` en `app-admin/utils/accounting.utils.ts`). En esos casos
280
+ * los datos de facturación viven en la factura: no hay tercero que actualizar.
281
+ *
282
+ * @param {articho.BillIssuedEntity} entity Tipo de destinatario.
283
+ * @return {'userProfile' | 'provider' | 'bill'} Dónde vive el dato.
284
+ */
285
+ export const getBillingRecordSource = (
286
+ entity: articho.BillIssuedEntity
287
+ ): 'userProfile' | 'provider' | 'bill' => {
288
+ switch (entity) {
289
+ case articho.BillIssuedEntity.CLIENT:
290
+ case articho.BillIssuedEntity.PRO:
291
+ return 'userProfile';
292
+ case articho.BillIssuedEntity.PROVIDER:
293
+ return 'provider';
294
+ default:
295
+ // MATERIAL_PROVIDER, SERVICE_PROVIDER y OTHER no tienen ficha propia.
296
+ return 'bill';
297
+ }
298
+ };
299
+
300
+ /**
301
+ * ¿Los datos de facturación se guardan en una ficha reutilizable?
302
+ * Si no, se quedan en la factura y habrá que volver a escribirlos la próxima
303
+ * vez que se facture a ese mismo destinatario.
304
+ * @param {articho.BillIssuedEntity} entity Tipo de destinatario.
305
+ * @return {boolean} true si hay ficha que actualizar.
306
+ */
307
+ export const hasReusableBillingRecord = (
308
+ entity: articho.BillIssuedEntity
309
+ ): boolean => getBillingRecordSource(entity) !== 'bill';
@@ -0,0 +1,32 @@
1
+ import * as articho from '../types';
2
+
3
+ /**
4
+ * Un campo de facturación que falta o es inválido.
5
+ *
6
+ * `path` usa notación de punto (`address.postalCode`) para que el formulario
7
+ * pueda resaltar exactamente el input que falla, y `tkey` es la clave de
8
+ * traducción, no el texto: quien pinta decide el idioma.
9
+ */
10
+ export type MissingBillingField = {
11
+ path: string;
12
+ tkey: string;
13
+ /** true = imprescindible para poder facturar. false = recomendable. */
14
+ required: boolean;
15
+ };
16
+
17
+ /** De dónde salen los datos de facturación según a quién va dirigida la factura. */
18
+ export type BillingEntitySource = {
19
+ entity: articho.BillIssuedEntity;
20
+ /** Id del documento de origen (userId para perfiles, id para providers). */
21
+ id: string;
22
+ /** Nombre legible, para la cabecera del modal. */
23
+ displayName: string;
24
+ billing?: articho.BillingIdentity;
25
+ };
26
+
27
+ export type BillingCompleteness = {
28
+ complete: boolean;
29
+ missing: MissingBillingField[];
30
+ /** Campos que no son obligatorios pero conviene rellenar. */
31
+ warnings: MissingBillingField[];
32
+ };
@@ -0,0 +1,198 @@
1
+ import * as articho from '../types';
2
+ import { MissingBillingField, BillingCompleteness } from './types';
3
+
4
+ const SIREN_OR_SIRET = /^\d{9}(\d{5})?$/;
5
+ const VAT_NUMBER = /^[A-Z]{2}[0-9A-Z]{2,13}$/;
6
+ const COUNTRY_ALPHA2 = /^[A-Z]{2}$/;
7
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
8
+
9
+ const isBlank = (value?: string | null) => !value || !value.trim();
10
+
11
+ /**
12
+ * Entidades que son siempre una persona jurídica.
13
+ *
14
+ * Un sous-traitant, un apporteur d'affaires o un proveedor de materiales tienen
15
+ * SIREN por definición: si no lo tuvieran no podrían facturarnos a nosotros.
16
+ * Solo el destinatario de tipo CLIENT —y el genérico OTHER— puede ser una
17
+ * persona física.
18
+ */
19
+ const ALWAYS_COMPANY: articho.BillIssuedEntity[] = [
20
+ articho.BillIssuedEntity.PRO,
21
+ articho.BillIssuedEntity.PROVIDER,
22
+ articho.BillIssuedEntity.MATERIAL_PROVIDER,
23
+ articho.BillIssuedEntity.SERVICE_PROVIDER,
24
+ ];
25
+
26
+ /**
27
+ * Resuelve si hay que tratar al destinatario como empresa.
28
+ *
29
+ * El `legalType` de la ficha manda, porque es lo que ha dicho una persona. Solo
30
+ * cuando falta se recurre al tipo de destinatario, y ahí la única lectura
31
+ * segura es la de las cuatro entidades que son empresa por definición: para un
32
+ * CLIENT sin `legalType` no se puede suponer nada, y forzarlo a empresa es
33
+ * justamente el fallo que hacía imposible facturar a un particular.
34
+ *
35
+ * @param {articho.BillIssuedEntity} entity A quién va dirigida la factura.
36
+ * @param {articho.BillingIdentity} billing Datos de facturación.
37
+ * @return {articho.BillingLegalType | undefined} Tipo resuelto, o undefined si no se sabe.
38
+ */
39
+ export const resolveLegalType = (
40
+ entity: articho.BillIssuedEntity,
41
+ billing?: articho.BillingIdentity
42
+ ): articho.BillingLegalType | undefined => {
43
+ if (billing?.legalType) return billing.legalType;
44
+ if (ALWAYS_COMPANY.includes(entity)) return 'company';
45
+ return undefined;
46
+ };
47
+
48
+ /**
49
+ * Nombre que se imprime en la factura.
50
+ *
51
+ * En una empresa es la razón social. En un particular se compone de nombre y
52
+ * apellidos, para que no haya dos campos guardando lo mismo.
53
+ *
54
+ * @param {articho.BillingIdentity} billing Datos de facturación.
55
+ * @return {string} Nombre para mostrar e imprimir.
56
+ */
57
+ export const billingDisplayName = (billing?: articho.BillingIdentity): string => {
58
+ if (!billing) return '';
59
+
60
+ if (billing.legalType === 'individual') {
61
+ const composed = [billing.firstName, billing.lastName]
62
+ .map((part) => part?.trim())
63
+ .filter(Boolean)
64
+ .join(' ');
65
+
66
+ return composed || (billing.legalName ?? '').trim();
67
+ }
68
+
69
+ return (billing.legalName ?? '').trim();
70
+ };
71
+
72
+ /**
73
+ * Comprueba si los datos de facturación de un tercero permiten emitirle una
74
+ * factura.
75
+ *
76
+ * Esta función es la única fuente de verdad sobre qué datos hacen falta. La
77
+ * usa el modal de confirmación para saber qué pedir y la Cloud Function para
78
+ * rechazar lo que llegue incompleto. Si divergieran, el usuario rellenaría un
79
+ * formulario y aun así le rebotaría el envío.
80
+ *
81
+ * @param {articho.BillIssuedEntity} entity A quién va dirigida la factura.
82
+ * @param {articho.BillingIdentity} billing Datos de facturación actuales.
83
+ * @return {BillingCompleteness} Qué falta y qué conviene.
84
+ */
85
+ export const checkBillingIdentity = (
86
+ entity: articho.BillIssuedEntity,
87
+ billing?: articho.BillingIdentity
88
+ ): BillingCompleteness => {
89
+ const missing: MissingBillingField[] = [];
90
+ const warnings: MissingBillingField[] = [];
91
+
92
+ const require = (path: string, tkey: string) =>
93
+ missing.push({ path, tkey, required: true });
94
+ const suggest = (path: string, tkey: string) =>
95
+ warnings.push({ path, tkey, required: false });
96
+
97
+ if (!billing) {
98
+ return {
99
+ complete: false,
100
+ missing: [
101
+ { path: 'legalType', tkey: 'billing.field.legalType', required: true },
102
+ { path: 'address.address', tkey: 'billing.field.address', required: true },
103
+ { path: 'address.postalCode', tkey: 'billing.field.postalCode', required: true },
104
+ { path: 'address.city', tkey: 'billing.field.city', required: true },
105
+ { path: 'address.countryAlpha2', tkey: 'billing.field.country', required: true },
106
+ ],
107
+ warnings: [],
108
+ };
109
+ }
110
+
111
+ const legalType = resolveLegalType(entity, billing);
112
+
113
+ // --- empresa o particular -----------------------------------------------
114
+ // Sin esto no se puede validar el resto: los campos obligatorios de una
115
+ // sociedad y los de una persona no se solapan.
116
+ if (!legalType) require('legalType', 'billing.field.legalType');
117
+
118
+ // --- identidad ----------------------------------------------------------
119
+ if (legalType === 'individual') {
120
+ if (isBlank(billing.firstName)) require('firstName', 'billing.field.firstName');
121
+ if (isBlank(billing.lastName)) require('lastName', 'billing.field.lastName');
122
+ } else {
123
+ // Empresa, o todavía sin decidir: en ambos casos la razón social hace falta.
124
+ if (isBlank(billing.legalName)) require('legalName', 'billing.field.legalName');
125
+ }
126
+
127
+ // --- dirección de facturación ------------------------------------------
128
+ // Los cuatro campos son obligatorios: PennyLane rechaza la dirección entera
129
+ // si falta uno, y no hay forma de derivarlos del `address` de texto libre.
130
+ // Aplica igual a empresas y a particulares.
131
+ if (isBlank(billing.address?.address)) require('address.address', 'billing.field.address');
132
+ if (isBlank(billing.address?.postalCode)) {
133
+ require('address.postalCode', 'billing.field.postalCode');
134
+ }
135
+ if (isBlank(billing.address?.city)) require('address.city', 'billing.field.city');
136
+
137
+ if (isBlank(billing.address?.countryAlpha2)) {
138
+ require('address.countryAlpha2', 'billing.field.country');
139
+ } else if (!COUNTRY_ALPHA2.test(billing.address.countryAlpha2.toUpperCase())) {
140
+ require('address.countryAlpha2', 'billing.field.countryInvalid');
141
+ }
142
+
143
+ // --- identificación fiscal ---------------------------------------------
144
+ // Solo para empresas. Un particular no tiene SIREN ni número de TVA, y su
145
+ // factura queda fuera de la facturación electrónica: no hay a quién
146
+ // transmitirla por la red ni identidad que declarar.
147
+ const registrationNumber = billing.registrationNumber?.replace(/\s/g, '') ?? '';
148
+ const vatNumber = billing.vatNumber?.replace(/\s/g, '').toUpperCase() ?? '';
149
+
150
+ if (legalType === 'individual') {
151
+ // Un número aquí es casi siempre un error de captura, no un dato de más.
152
+ if (registrationNumber) {
153
+ suggest('registrationNumber', 'billing.field.registrationNumberOnIndividual');
154
+ }
155
+ } else {
156
+ if (!registrationNumber) {
157
+ // Sin `legalType` todavía no se bloquea por esto: primero hay que saber
158
+ // qué es. Marcarlo como obligatorio antes de elegir confunde.
159
+ if (legalType === 'company') {
160
+ require('registrationNumber', 'billing.field.registrationNumber');
161
+ } else {
162
+ suggest('registrationNumber', 'billing.field.registrationNumber');
163
+ }
164
+ } else if (!SIREN_OR_SIRET.test(registrationNumber)) {
165
+ require('registrationNumber', 'billing.field.registrationNumberInvalid');
166
+ }
167
+
168
+ if (vatNumber && !VAT_NUMBER.test(vatNumber)) {
169
+ require('vatNumber', 'billing.field.vatNumberInvalid');
170
+ } else if (!vatNumber && legalType === 'company') {
171
+ // No lo bloquea: hay empresas en franchise de TVA sin número intracom.
172
+ suggest('vatNumber', 'billing.field.vatNumber');
173
+ }
174
+ }
175
+
176
+ // --- contacto -----------------------------------------------------------
177
+ const emails = billing.emails?.filter((email) => !isBlank(email)) ?? [];
178
+
179
+ if (!emails.length) {
180
+ suggest('emails', 'billing.field.emails');
181
+ } else {
182
+ const invalid = emails.find((email) => !EMAIL.test(email.trim()));
183
+ if (invalid) require('emails', 'billing.field.emailsInvalid');
184
+ }
185
+
186
+ return { complete: missing.length === 0, missing, warnings };
187
+ };
188
+
189
+ /**
190
+ * Atajo para saber si se puede facturar sin pedir nada más.
191
+ * @param {articho.BillIssuedEntity} entity Destinatario.
192
+ * @param {articho.BillingIdentity} billing Datos actuales.
193
+ * @return {boolean} true si no falta nada obligatorio.
194
+ */
195
+ export const isBillingComplete = (
196
+ entity: articho.BillIssuedEntity,
197
+ billing?: articho.BillingIdentity
198
+ ): boolean => checkBillingIdentity(entity, billing).complete;
@@ -51,6 +51,9 @@ const productionEnvironment: Environment = {
51
51
  bills: 'enterprises/{enterpriseId}/projects/{projectId}/bills',
52
52
  counters: 'counters',
53
53
  invoices: 'enterprises/{enterpriseId}/invoices',
54
+ importedBills: 'enterprises/{enterpriseId}/importedBills',
55
+ importedBillEvents:
56
+ 'enterprises/{enterpriseId}/importedBills/{importedBillId}/events',
54
57
  materialsProvider: 'enterprises/{enterpriseId}/materialsProvider',
55
58
  materials:
56
59
  'enterprises/{enterpriseId}/materialsProvider/{materialsProviderId}/materials',
@@ -160,6 +163,9 @@ const productionEnvironment: Environment = {
160
163
  configuration:
161
164
  'enterprises/{enterpriseId}/configurations/{configurationId}',
162
165
  log: 'enterprises/{enterpriseId}/logs/{logId}',
166
+ importedBill: 'enterprises/{enterpriseId}/importedBills/{importedBillId}',
167
+ importedBillEvent:
168
+ 'enterprises/{enterpriseId}/importedBills/{importedBillId}/events/{eventId}',
163
169
  systemVersion: 'systemVersions/{systemVersionId}',
164
170
  evaluationConfig:
165
171
  'enterprises/{enterpriseId}/evaluationsConfig/{evaluationConfigId}',
@@ -201,6 +207,7 @@ const productionEnvironment: Environment = {
201
207
  estimations: 'estimations', // 14
202
208
  nomenclatures: 'nomenclatures', // 15
203
209
  workOrders: 'workOrders', // 16
210
+ importedBills: 'imported-bills', // 17
204
211
  // used only for managment
205
212
  systemMaterials: 'system-materials', // 17
206
213
  systemOperations: 'system-operations', // 18
@@ -542,6 +549,29 @@ const productionEnvironment: Environment = {
542
549
  maxValuesPerFacet: 1000,
543
550
  maxFacetHits: 100,
544
551
  },
552
+ importedBills: {
553
+ searchableAttributes: [
554
+ 'counterparty.name',
555
+ 'invoice.reference',
556
+ 'references.projectRef',
557
+ 'references.devisRef',
558
+ 'assignment.projectName',
559
+ 'assignment.projectReference',
560
+ 'assignment.devisName',
561
+ 'counterparty.regNo',
562
+ ],
563
+ attributesForFaceting: [
564
+ 'searchable(scope)',
565
+ 'searchable(enterpriseId)',
566
+ 'status',
567
+ 'direction',
568
+ 'source',
569
+ 'invoice.isPaid',
570
+ ],
571
+ ranking: ['desc(invoice.date)', 'desc(createdAt)'],
572
+ maxValuesPerFacet: 1000,
573
+ maxFacetHits: 100,
574
+ },
545
575
  // used only for managment
546
576
  systemMaterials: {
547
577
  searchableAttributes: ['name', 'unitPrice'],
@@ -39,6 +39,7 @@ const test: EnvironmentDecorator = (base: Environment) => ({
39
39
  nomenclatures: 'nomenclatures',
40
40
  operationsGroups: 'operations-groups',
41
41
  workOrders: 'work-orders',
42
+ importedBills: 'tests-imported-bills',
42
43
  // used only for managment
43
44
  systemMaterials: 'system-materials',
44
45
  systemOperations: 'system-operations',
@@ -80,6 +81,7 @@ const emulator: EnvironmentDecorator = (base: Environment) => ({
80
81
  nomenclatures: 'nomenclatures',
81
82
  operationsGroups: 'operations-groups',
82
83
  workOrders: 'work-orders',
84
+ importedBills: 'emulator-imported-bills',
83
85
  // used only for managment
84
86
  systemMaterials: 'system-materials',
85
87
  systemOperations: 'system-operations',
@@ -51,6 +51,7 @@ export interface Environment {
51
51
  estimations: string;
52
52
  nomenclatures: string;
53
53
  workOrders: string;
54
+ importedBills: string;
54
55
  // used only for management - 7
55
56
  systemMaterials: string;
56
57
  systemOperations: string;
@@ -112,6 +113,8 @@ export interface Environment {
112
113
  marketplaceNotifications: string;
113
114
  filesOfWorksExecuted: string;
114
115
  emailsEvents: string;
116
+ importedBills: string;
117
+ importedBillEvents: string;
115
118
  };
116
119
  docs: {
117
120
  user: string;
@@ -168,6 +171,8 @@ export interface Environment {
168
171
  devisCandidateTemplate: string;
169
172
  filesOfWorksExecuted: string;
170
173
  emailsEvent: string;
174
+ importedBill: string;
175
+ importedBillEvent: string;
171
176
  };
172
177
  };
173
178
 
@@ -13,6 +13,7 @@ export interface Helpers {
13
13
  medias: FactoryCreate<articho.Media>;
14
14
  materials: FactoryCreate<articho.Material>;
15
15
  bills: FactoryCreate<articho.Bill>;
16
+ importedBills: FactoryCreate<articho.ImportedBill>;
16
17
  billsIssued: FactoryCreate<articho.BillIssued>;
17
18
  billsReceived: FactoryCreate<articho.BillReceived>;
18
19
  devis: FactoryCreate<articho.Devis>;
@@ -90,6 +91,7 @@ const helpers: Helpers = {
90
91
  events: FactoryCreateFunction(),
91
92
  orders: FactoryCreateFunction(),
92
93
  bills: FactoryCreateFunction(),
94
+ importedBills: FactoryCreateFunction(),
93
95
  billsIssued: FactoryCreateFunction(),
94
96
  billsReceived: FactoryCreateFunction(),
95
97
  materialsProvider: FactoryCreateFunction(),
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  ProductEntry,
11
11
  } from './plans/types';
12
12
  import * as articho from './types';
13
+ import * as billing from './billing';
13
14
  import { flatten } from './utils/flatten';
14
15
  import { AppEvent, EventMetadata, createEvent } from './events';
15
16
  import * as WhatsApp from './whatsapp';
@@ -26,6 +27,7 @@ export {
26
27
  Environment,
27
28
  Environments,
28
29
  articho,
30
+ billing,
29
31
  security,
30
32
  SecurityContext,
31
33
  ROLE,