@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,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;
|
package/src/environment/base.ts
CHANGED
|
@@ -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
|
|
@@ -292,6 +299,7 @@ const productionEnvironment: Environment = {
|
|
|
292
299
|
'searchable(userId)',
|
|
293
300
|
'searchable(email)',
|
|
294
301
|
'searchable(provider)',
|
|
302
|
+
'searchable(providers)',
|
|
295
303
|
'searchable(scope)',
|
|
296
304
|
'status',
|
|
297
305
|
],
|
|
@@ -370,7 +378,7 @@ const productionEnvironment: Environment = {
|
|
|
370
378
|
],
|
|
371
379
|
ranking: [
|
|
372
380
|
'attribute',
|
|
373
|
-
'desc(
|
|
381
|
+
'desc(updatedAt)',
|
|
374
382
|
'proximity',
|
|
375
383
|
'typo',
|
|
376
384
|
'geo',
|
|
@@ -541,6 +549,29 @@ const productionEnvironment: Environment = {
|
|
|
541
549
|
maxValuesPerFacet: 1000,
|
|
542
550
|
maxFacetHits: 100,
|
|
543
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
|
+
},
|
|
544
575
|
// used only for managment
|
|
545
576
|
systemMaterials: {
|
|
546
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',
|
package/src/environment/types.ts
CHANGED
|
@@ -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
|
|
package/src/helpers/index.ts
CHANGED
|
@@ -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,
|