@silexpert/core 2.0.121 → 2.0.122

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silexpert/core",
3
- "version": "2.0.121",
3
+ "version": "2.0.122",
4
4
  "description": "Silex Core perform HTTP Request and object binding. Silex core helps developers to retrieve and return formatted object",
5
5
  "keywords": [
6
6
  "accounting",
@@ -10,6 +10,7 @@ import {
10
10
  GenerateProjectExercice,
11
11
  IGedFile,
12
12
  SendPreviewProjectExercice,
13
+ QuerySignUrlForProject,
13
14
  } from '../../types/index.js';
14
15
  import { Resource } from '../Resource.js';
15
16
 
@@ -71,6 +72,10 @@ export class Exercice extends Resource {
71
72
  });
72
73
  }
73
74
 
75
+ getSignUrlForProject(params: QuerySignUrlForProject): Promise<string> {
76
+ return this.axios.$get('exercices/project/sign-url', { params });
77
+ }
78
+
74
79
  getProgress(idExercice: number): Promise<number> {
75
80
  return this.axios.$get(`exercices/${idExercice}/progress`);
76
81
  }
@@ -17,89 +17,103 @@ export enum EInvoicingStatusCode {
17
17
 
18
18
  export const EInvoicingStatusCodeMetadata: Record<
19
19
  EInvoicingStatusCode,
20
- { shortLabel: string; label: string; color: string; description?: string }
20
+ { shortLabel: string; label: string; pluralLabel: string; color: string; description?: string }
21
21
  > = {
22
22
  [EInvoicingStatusCode.DEPOSITED]: {
23
23
  shortLabel: 'Déposée',
24
24
  label: 'Déposée',
25
+ pluralLabel: 'Déposées',
25
26
  color: 'cerulean',
26
27
  description: 'Facture déposée et contrôles techniques validés.',
27
28
  },
28
29
  [EInvoicingStatusCode.ISSUED]: {
29
30
  shortLabel: 'Émise',
30
31
  label: 'Émise par la plateforme',
32
+ pluralLabel: 'Émises',
31
33
  color: 'success',
32
34
  description: 'Facture transmise au réseau de facturation.',
33
35
  },
34
36
  [EInvoicingStatusCode.RECEIVED]: {
35
37
  shortLabel: 'Reçue',
36
38
  label: 'Reçue',
39
+ pluralLabel: 'Reçues',
37
40
  color: 'success',
38
41
  description: 'Facture reçue par la plateforme du destinataire.',
39
42
  },
40
43
  [EInvoicingStatusCode.MADE_AVAILABLE]: {
41
44
  shortLabel: 'Mise à disposition',
42
45
  label: 'Mise à disposition',
46
+ pluralLabel: 'Mises à disposition',
43
47
  color: 'fire',
44
48
  description: 'Facture disponible pour le destinataire.',
45
49
  },
46
50
  [EInvoicingStatusCode.HANDLED]: {
47
51
  shortLabel: 'Prise en charge',
48
52
  label: 'Prise en charge',
53
+ pluralLabel: 'Prises en charge',
49
54
  color: 'fire',
50
55
  description: 'Facture en cours de traitement par le destinataire.',
51
56
  },
52
57
  [EInvoicingStatusCode.APPROVED]: {
53
58
  shortLabel: 'Approuvée',
54
59
  label: 'Approuvée',
60
+ pluralLabel: 'Approuvées',
55
61
  color: 'success',
56
62
  description: 'Facture entièrement acceptée.',
57
63
  },
58
64
  [EInvoicingStatusCode.PARTIALLY_APPROVED]: {
59
65
  shortLabel: 'Approbation partielle',
60
66
  label: 'Approbation partielle',
67
+ pluralLabel: 'Approbations partielles',
61
68
  color: 'error',
62
69
  description: 'Facture partiellement acceptée.',
63
70
  },
64
71
  [EInvoicingStatusCode.IN_DISPUTE]: {
65
72
  shortLabel: 'En litige',
66
73
  label: 'En litige',
74
+ pluralLabel: 'En litige',
67
75
  color: 'error',
68
76
  description: 'Facture contestée par le destinataire.',
69
77
  },
70
78
  [EInvoicingStatusCode.SUSPENDED]: {
71
79
  shortLabel: 'Suspendue',
72
80
  label: 'Suspendue',
81
+ pluralLabel: 'Suspendues',
73
82
  color: 'error',
74
83
  description: 'Traitement suspendu, informations attendues.',
75
84
  },
76
85
  [EInvoicingStatusCode.COMPLETED]: {
77
86
  shortLabel: 'Complétée',
78
87
  label: 'Complétée',
88
+ pluralLabel: 'Complétées',
79
89
  color: 'fire',
80
90
  description: 'Informations complémentaires transmises.',
81
91
  },
82
92
  [EInvoicingStatusCode.REFUSED]: {
83
93
  shortLabel: 'Refusée',
84
94
  label: 'Refusée',
95
+ pluralLabel: 'Refusées',
85
96
  color: 'error',
86
97
  description: 'Facture refusée par le destinataire.',
87
98
  },
88
99
  [EInvoicingStatusCode.PAYMENT_TRANSMITTED]: {
89
100
  shortLabel: 'Paiement transmis',
90
101
  label: 'Paiement transmis',
102
+ pluralLabel: 'Paiements transmis',
91
103
  color: 'success',
92
104
  description: 'Paiement déclaré par le destinataire.',
93
105
  },
94
106
  [EInvoicingStatusCode.PAID]: {
95
107
  shortLabel: 'Encaissée',
96
108
  label: 'Encaissée',
109
+ pluralLabel: 'Encaissées',
97
110
  color: 'success',
98
111
  description: 'Paiement reçu et confirmé.',
99
112
  },
100
113
  [EInvoicingStatusCode.REJECTED]: {
101
114
  shortLabel: 'Rejetée',
102
115
  label: 'Rejetée',
116
+ pluralLabel: 'Rejetées',
103
117
  color: 'error',
104
118
  description: 'Facture rejetée, correction nécessaire.',
105
119
  },
@@ -30,6 +30,7 @@ export enum UnitId {
30
30
  HECTARE = 20,
31
31
  MINUTE = 21,
32
32
  GRAM = 22,
33
+ UNIT = 23,
33
34
  }
34
35
 
35
36
  export type IUnitList = Array<{ name: string; id: number; shortName: string; eInvoicingCode: string | null }>;
@@ -41,6 +42,12 @@ export const Unit: IUnit = {
41
42
  shortName: 'art',
42
43
  codeEN16931: 'NAR',
43
44
  },
45
+ UNIT: {
46
+ id: UnitId.UNIT,
47
+ name: 'Unité',
48
+ shortName: 'u',
49
+ codeEN16931: 'NAR',
50
+ },
44
51
  HOUR: {
45
52
  id: UnitId.HOUR,
46
53
  name: 'Heure',
@@ -54,4 +54,9 @@ export class UpdateBalanceSheet {
54
54
  @IsString()
55
55
  @IsOptional()
56
56
  token?: string;
57
+
58
+ @ApiPropertyOptional()
59
+ @IsString()
60
+ @IsOptional()
61
+ projectSignatureLink?: string;
57
62
  }
@@ -21,6 +21,7 @@ export type ReadEstimateInvoice = IEstimateInvoice & { id: number };
21
21
 
22
22
  export type ReadProductEditing = IProductInvoice & {
23
23
  totalTaxExcluded: number;
24
+ totalTaxExcludedRounded: number;
24
25
  unitFormatted: string;
25
26
  vatFormatted: string;
26
27
  discountFormatted: string | null;
@@ -1,5 +1,5 @@
1
- import { IsBoolean, IsDate, IsIn, IsOptional } from 'class-validator';
2
- import { ApiPropertyOptional } from '@nestjs/swagger';
1
+ import { IsBoolean, IsDate, IsIn, IsOptional, IsString } from 'class-validator';
2
+ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
3
3
  import { ToBoolean } from '../../../../utils/transforms/boolean.transform.js';
4
4
 
5
5
  export class QueryExercice {
@@ -44,3 +44,9 @@ export class QueryExerciceProject {
44
44
  @IsOptional()
45
45
  type?: 'juridical' | 'default';
46
46
  }
47
+
48
+ export class QuerySignUrlForProject {
49
+ @ApiProperty()
50
+ @IsString()
51
+ token: string;
52
+ }
@@ -17,6 +17,7 @@ export class IBalanceSheet {
17
17
  netIncome?: number;
18
18
  total?: number;
19
19
  token?: string | null;
20
+ projectSignatureLink?: string | null;
20
21
  idFile?: number;
21
22
  attestationDetail?: string;
22
23
  endDate?: Date;
@@ -1,6 +1,13 @@
1
1
  import { MathematicalSign } from '../types/Enum/Util.js';
2
2
  import { FormattedAmount } from '../types/Interface/api/amount/FormattedAmount.js';
3
3
 
4
+ // Round the magnitude first so negative half-cent values match the formatter's symmetric rounding.
5
+ export function roundAmountToInteger(value: number): number {
6
+ const roundedAbsoluteValue = Math.round(Math.abs(value));
7
+ if (roundedAbsoluteValue === 0) return 0;
8
+ return value < 0 ? -roundedAbsoluteValue : roundedAbsoluteValue;
9
+ }
10
+
4
11
  export function integerToFormattedAmountString(value: number): string {
5
12
  const abs = Math.abs(value / 100);
6
13
  let integer = Math.trunc(abs);
@@ -73,7 +73,7 @@ export function generateCommercialDocument(
73
73
  }
74
74
 
75
75
  const totalVats = sumBy(vats, (v) => round(v.amount));
76
- const totalTaxExcluded = sumBy(products, (p) => round(p.totalTaxExcluded));
76
+ const totalTaxExcluded = sumBy(products, 'totalTaxExcludedRounded');
77
77
 
78
78
  const totalTaxIncluded = totalTaxExcluded + totalVats;
79
79
 
@@ -3,7 +3,7 @@ import { IProductInvoice, ReadProductEditing } from '../../types/index.js';
3
3
  import { Unit } from '../../types/Enum/Unit.js';
4
4
  import { DiscountType, VatMarginCategyList } from '../../types/Enum/CommercialManagement.js';
5
5
  import { getById } from './vat.js';
6
- import { integerToFormattedAmountString } from '../amount.js';
6
+ import { integerToFormattedAmountString, roundAmountToInteger } from '../amount.js';
7
7
 
8
8
  export function getDiscount(product: IProductInvoice): number {
9
9
  if (product.discount2 === null || product.discount2 === undefined || product.discount2 <= 0) return 0;
@@ -24,16 +24,20 @@ export function getTotalTaxExcluded(product: IProductInvoice): number {
24
24
  }
25
25
 
26
26
  export function productToReadProductEditing(payload: IProductInvoice): ReadProductEditing {
27
+ const totalTaxExcluded = getTotalTaxExcluded(payload);
28
+ const totalTaxExcludedRounded = roundAmountToInteger(totalTaxExcluded);
29
+
27
30
  return {
28
31
  ...payload,
29
- totalTaxExcluded: getTotalTaxExcluded(payload),
32
+ totalTaxExcluded,
33
+ totalTaxExcludedRounded,
30
34
  unitFormatted: Object.values(Unit).find((unit) => unit.id === payload.unit)?.shortName ?? '',
31
35
  vatFormatted: getById(payload.idVat)?.saleName ?? '',
32
36
  priceTaxExcludedFormatted: isDefined(payload.priceTaxExcluded)
33
37
  ? integerToFormattedAmountString(payload.priceTaxExcluded)
34
38
  : null,
35
39
  totalTaxExcludedFormatted: isDefined(payload.priceTaxExcluded)
36
- ? integerToFormattedAmountString(getTotalTaxExcluded(payload))
40
+ ? integerToFormattedAmountString(totalTaxExcludedRounded)
37
41
  : null,
38
42
  discountFormatted:
39
43
  payload.discount2 && payload.discount2 > 0 ? integerToFormattedAmountString(getDiscount(payload)) : null,
@@ -1,6 +1,6 @@
1
1
  import { sumBy } from 'lodash-es';
2
2
  import { Vat, VatType } from '../../types/Enum/Vat.js';
3
- import { integerToFormattedAmountString } from '../amount.js';
3
+ import { integerToFormattedAmountString, roundAmountToInteger } from '../amount.js';
4
4
  import { ReadProductEditing, ReadVatEditing } from '../../types/index.js';
5
5
 
6
6
  export function getAmount(amount: number | null, vatValue: number): number {
@@ -15,19 +15,17 @@ export function getById(id: number): VatType | undefined {
15
15
 
16
16
  export function vatToReadVatEditing(products: ReadProductEditing[]): ReadVatEditing {
17
17
  const { idVat } = products[0];
18
+ const base = sumBy(products, 'totalTaxExcludedRounded');
19
+ const amount = roundAmountToInteger(getAmount(base, getById(idVat)?.value ?? 0));
20
+
18
21
  return {
19
22
  id: idVat,
20
23
  name: getById(idVat)?.saleName ?? '',
21
- amount: sumBy(products, (p) => getAmount(p.totalTaxExcluded, getById(idVat)?.value ?? 0)),
24
+ amount,
22
25
  value: getById(idVat)?.value ?? 0,
23
- baseFormatted: integerToFormattedAmountString(
24
- sumBy(products, (p) => (p.totalTaxExcluded ? +p.totalTaxExcluded : 0)),
25
- ),
26
- amountFormatted: integerToFormattedAmountString(
27
- (sumBy(products, (p) => (p.totalTaxExcluded ? +p.totalTaxExcluded : 0)) * (getById(idVat)?.value ?? 0)) / 100,
28
- ),
29
- toPay:
30
- (sumBy(products, (p) => (p.totalTaxExcluded ? +p.totalTaxExcluded : 0)) * (getById(idVat)?.value ?? 0)) / 100,
31
- base: sumBy(products, (p) => (p.totalTaxExcluded ? +p.totalTaxExcluded : 0)),
26
+ baseFormatted: integerToFormattedAmountString(base),
27
+ amountFormatted: integerToFormattedAmountString(amount),
28
+ toPay: amount,
29
+ base,
32
30
  };
33
31
  }
@@ -2,6 +2,9 @@ import { describe, expect, test } from 'vitest';
2
2
  import { generateCommercialDocument } from '../commercialManagement/document.js';
3
3
  import { EstimateInvoiceType } from '../../types/Enum/EstimateInvoice.js';
4
4
  import { CreateInvoiceDocument } from '../../types/Interface/api/commercialManagement/Create.js';
5
+ import { DiscountType } from '../../types/Enum/CommercialManagement.js';
6
+ import { Unit } from '../../types/Enum/Unit.js';
7
+ import { Vat } from '../../types/Enum/Vat.js';
5
8
 
6
9
  // Payload reproducing a real-world case where the seller address fields (`name`, `street`,
7
10
  // `number`, `zipCode`, `city`) are entirely absent from the object, not just `null`.
@@ -113,4 +116,96 @@ describe('generateCommercialDocument', () => {
113
116
 
114
117
  expect(result.body).not.toContain('<p>undefined</p>');
115
118
  });
119
+
120
+ test('should calculate VAT from rounded line totals after discounts and fractional quantities', () => {
121
+ const payload = {
122
+ ...payloadWithoutSellerAddress,
123
+ products: Array.from({ length: 6 }, (_, index) => ({
124
+ idVat: Vat.TX_20.id,
125
+ unit: Unit.ITEM.id,
126
+ itemSaleName: `Rounded line ${index + 1}`,
127
+ priceTaxExcluded: 101,
128
+ quantity: 1.0049,
129
+ discount2: 100,
130
+ discountType: DiscountType.PERCENT,
131
+ reorder: index,
132
+ })),
133
+ } as unknown as CreateInvoiceDocument;
134
+
135
+ const result = generateCommercialDocument(EstimateInvoiceType.FACTURE, payload);
136
+
137
+ expect(result.body).toContain('1,00€');
138
+ expect(result.data.vats).toEqual(
139
+ expect.arrayContaining([
140
+ expect.objectContaining({ base: 600, amount: 120, baseFormatted: '6,00', amountFormatted: '1,20' }),
141
+ ]),
142
+ );
143
+ expect(result.data.totalTaxExcluded).toBe(600);
144
+ expect(result.data.totalTaxIncluded).toBe(720);
145
+ expect(result.data.totalTaxIncludedFormatted).toBe('7,20');
146
+ });
147
+
148
+ test('should use the rounded half-cent line amount in the PDF and VAT recap', () => {
149
+ const payload = {
150
+ ...payloadWithoutSellerAddress,
151
+ products: [
152
+ {
153
+ idVat: Vat.TX_20.id,
154
+ unit: Unit.ITEM.id,
155
+ itemSaleName: 'Half-cent line',
156
+ priceTaxExcluded: 201,
157
+ quantity: 0.5,
158
+ },
159
+ ],
160
+ } as unknown as CreateInvoiceDocument;
161
+
162
+ const result = generateCommercialDocument(EstimateInvoiceType.FACTURE, payload);
163
+
164
+ expect(result.body).toContain('1,01€');
165
+ expect(result.data.products[0]).toMatchObject({
166
+ totalTaxExcluded: 100.5,
167
+ totalTaxExcludedRounded: 101,
168
+ totalTaxExcludedFormatted: '1,01',
169
+ });
170
+ expect(result.data.vats).toEqual(
171
+ expect.arrayContaining([expect.objectContaining({ base: 101, baseFormatted: '1,01' })]),
172
+ );
173
+ expect(result.data.totalTaxExcluded).toBe(101);
174
+ expect(result.data.totalTaxExcludedFormatted).toBe('1,01');
175
+ });
176
+
177
+ test('should round negative credit note lines symmetrically across VAT rates', () => {
178
+ const payload = {
179
+ ...payloadWithoutSellerAddress,
180
+ products: [
181
+ {
182
+ idVat: Vat.TX_5_5.id,
183
+ unit: Unit.ITEM.id,
184
+ itemSaleName: 'Half-cent VAT line',
185
+ priceTaxExcluded: -200,
186
+ quantity: 0.5,
187
+ },
188
+ {
189
+ idVat: Vat.TX_20.id,
190
+ unit: Unit.ITEM.id,
191
+ itemSaleName: 'Half-cent line',
192
+ priceTaxExcluded: -201,
193
+ quantity: 0.5,
194
+ },
195
+ ],
196
+ } as unknown as CreateInvoiceDocument;
197
+
198
+ const result = generateCommercialDocument(EstimateInvoiceType.AVOIR, payload);
199
+
200
+ expect(result.body).toContain('- 1,00€');
201
+ expect(result.body).toContain('- 1,01€');
202
+ expect(result.data.vats).toEqual(
203
+ expect.arrayContaining([
204
+ expect.objectContaining({ value: 5.5, base: -100, amount: -6, baseFormatted: '- 1,00' }),
205
+ expect.objectContaining({ value: 20, base: -101, amount: -20, baseFormatted: '- 1,01' }),
206
+ ]),
207
+ );
208
+ expect(result.data.totalTaxExcluded).toBe(-201);
209
+ expect(result.data.totalTaxIncluded).toBe(-227);
210
+ });
116
211
  });