@primocaredentgroup/compensi-medici-core 0.1.0

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 (38) hide show
  1. package/convex/_generated/api.ts +110 -0
  2. package/convex/_generated/component.ts +1356 -0
  3. package/convex/_generated/dataModel.ts +60 -0
  4. package/convex/_generated/server.ts +156 -0
  5. package/convex/audit/logs.ts +69 -0
  6. package/convex/calculation/commissions.ts +497 -0
  7. package/convex/calculation/deductions.ts +119 -0
  8. package/convex/calculation/engine.ts +598 -0
  9. package/convex/calculation/fixedCommissions.ts +217 -0
  10. package/convex/calculation/orchestrator.ts +314 -0
  11. package/convex/calculation/production.ts +495 -0
  12. package/convex/calculation/productionData.ts +155 -0
  13. package/convex/calculation/ruleApplications.ts +121 -0
  14. package/convex/config/categories.ts +114 -0
  15. package/convex/config/commissionPlans.ts +166 -0
  16. package/convex/config/commissionRules.ts +154 -0
  17. package/convex/config/companyMappings.ts +77 -0
  18. package/convex/config/deductionRules.ts +113 -0
  19. package/convex/config/fixedCommissionTypes.ts +79 -0
  20. package/convex/config/globalSettings.ts +79 -0
  21. package/convex/config/invisalignConfig.ts +87 -0
  22. package/convex/config/planTemplates.ts +90 -0
  23. package/convex/config/taxProfiles.ts +122 -0
  24. package/convex/config/userTaxProfiles.ts +87 -0
  25. package/convex/convex.config.ts +3 -0
  26. package/convex/documents/draftInvoices.ts +164 -0
  27. package/convex/documents/invoiceEngine.ts +468 -0
  28. package/convex/documents/invoiceGeneration.ts +611 -0
  29. package/convex/documents/statements.ts +185 -0
  30. package/convex/ledger/entries.ts +314 -0
  31. package/convex/lib/types.ts +126 -0
  32. package/convex/schema.ts +611 -0
  33. package/convex/seedHelpers.ts +41 -0
  34. package/convex/workflow/pendingCompletions.ts +148 -0
  35. package/convex/workflow/pythonWorker.ts +190 -0
  36. package/convex/workflow/runIssues.ts +364 -0
  37. package/convex/workflow/runs.ts +718 -0
  38. package/package.json +43 -0
@@ -0,0 +1,164 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "../_generated/server";
3
+ import { invoiceStatus } from "../schema";
4
+
5
+ /**
6
+ * CRUD base per le bozze fattura.
7
+ * Da analisi.md: "generazione bozza fattura" come output del componente.
8
+ *
9
+ * Separato dalla pipeline di generazione (invoiceGeneration.ts) che
10
+ * gestisce il refresh idempotente a partire dalle commission entries.
11
+ *
12
+ * Da analisi.md: "il piano dice quanto guadagni,
13
+ * il profilo fiscale dice come lo fatturi".
14
+ *
15
+ * Immutabilità: run closed/approved non ammettono modifiche alle bozze.
16
+ */
17
+
18
+ async function assertRunWritableForInvoices(
19
+ ctx: { db: { get: (id: any) => Promise<any> } },
20
+ runId: any,
21
+ ): Promise<void> {
22
+ const run = await ctx.db.get(runId);
23
+ if (!run) throw new Error("Run non trovato");
24
+ if (run.status === "closed") {
25
+ throw new Error(
26
+ "Impossibile modificare bozze di un run chiuso. " +
27
+ "I documenti di un run chiuso sono congelati.",
28
+ );
29
+ }
30
+ if (run.status === "approved") {
31
+ throw new Error(
32
+ "Impossibile modificare bozze di un run approvato. " +
33
+ "Riportare il run in stato precedente prima di modificare.",
34
+ );
35
+ }
36
+ }
37
+
38
+ export const create = mutation({
39
+ args: {
40
+ runId: v.id("compensationRuns"),
41
+ userId: v.string(),
42
+ clinicId: v.string(),
43
+ period: v.string(),
44
+ isAutoGenerated: v.boolean(),
45
+ taxProfileId: v.optional(v.id("taxProfiles")),
46
+ taxProfileSnapshot: v.optional(v.any()),
47
+ lineItemCount: v.number(),
48
+ subtotal: v.number(),
49
+ vatAmount: v.number(),
50
+ withholdingAmount: v.number(),
51
+ contributionAmount: v.number(),
52
+ stampDutyAmount: v.number(),
53
+ total: v.number(),
54
+ netToPay: v.number(),
55
+ notes: v.optional(v.string()),
56
+ createdBy: v.string(),
57
+ },
58
+ handler: async (ctx, args) => {
59
+ await assertRunWritableForInvoices(ctx, args.runId);
60
+
61
+ const now = Date.now();
62
+ const { createdBy, ...fields } = args;
63
+
64
+ const invoiceId = await ctx.db.insert("draftInvoices", {
65
+ ...fields,
66
+ status: "draft",
67
+ createdBy,
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ });
71
+
72
+ await ctx.db.insert("auditLogs", {
73
+ entityType: "draftInvoices",
74
+ entityId: invoiceId,
75
+ action: "created",
76
+ userId: createdBy,
77
+ payload: {
78
+ total: args.total,
79
+ netToPay: args.netToPay,
80
+ clinicId: args.clinicId,
81
+ isAutoGenerated: args.isAutoGenerated,
82
+ },
83
+ createdAt: now,
84
+ });
85
+
86
+ return invoiceId;
87
+ },
88
+ });
89
+
90
+ export const updateStatus = mutation({
91
+ args: {
92
+ invoiceId: v.id("draftInvoices"),
93
+ newStatus: invoiceStatus,
94
+ userId: v.string(),
95
+ },
96
+ handler: async (ctx, args) => {
97
+ const invoice = await ctx.db.get(args.invoiceId);
98
+ if (!invoice) throw new Error("Fattura non trovata");
99
+
100
+ await assertRunWritableForInvoices(ctx, invoice.runId);
101
+
102
+ const now = Date.now();
103
+ await ctx.db.patch(args.invoiceId, {
104
+ status: args.newStatus,
105
+ updatedAt: now,
106
+ });
107
+
108
+ await ctx.db.insert("auditLogs", {
109
+ entityType: "draftInvoices",
110
+ entityId: args.invoiceId,
111
+ action: "status_changed",
112
+ userId: args.userId,
113
+ payload: { from: invoice.status, to: args.newStatus },
114
+ createdAt: now,
115
+ });
116
+ },
117
+ });
118
+
119
+ export const get = query({
120
+ args: { invoiceId: v.id("draftInvoices") },
121
+ handler: async (ctx, args) => {
122
+ return await ctx.db.get(args.invoiceId);
123
+ },
124
+ });
125
+
126
+ export const listByRun = query({
127
+ args: { runId: v.id("compensationRuns") },
128
+ handler: async (ctx, args) => {
129
+ return await ctx.db
130
+ .query("draftInvoices")
131
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
132
+ .collect();
133
+ },
134
+ });
135
+
136
+ export const listByRunAndUser = query({
137
+ args: {
138
+ runId: v.id("compensationRuns"),
139
+ userId: v.string(),
140
+ },
141
+ handler: async (ctx, args) => {
142
+ return await ctx.db
143
+ .query("draftInvoices")
144
+ .withIndex("by_run_user", (q) =>
145
+ q.eq("runId", args.runId).eq("userId", args.userId),
146
+ )
147
+ .collect();
148
+ },
149
+ });
150
+
151
+ export const listByPeriodAndUser = query({
152
+ args: {
153
+ period: v.string(),
154
+ userId: v.string(),
155
+ },
156
+ handler: async (ctx, args) => {
157
+ return await ctx.db
158
+ .query("draftInvoices")
159
+ .withIndex("by_period_user", (q) =>
160
+ q.eq("period", args.period).eq("userId", args.userId),
161
+ )
162
+ .collect();
163
+ },
164
+ });
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Motore fiscale avanzato — traduzione da create_commission_invoices.py
3
+ *
4
+ * Formula dal Python:
5
+ *
6
+ * Per profili con "(bollo compreso nei calcoli)":
7
+ * 1. Se stamp_duty e calculated > soglia e isCompanyGrouped:
8
+ * calculated += costo_bollo (il bollo viene aggiunto alla base)
9
+ * 2. contribution = calculated * contribution_percentage / 100
10
+ * 3. withholding = withholding% * withholding_on% * (calculated + contribution se "(104%)")
11
+ * 4. total = calculated + vat_amount + contribution
12
+ * 5. netToPay = total - withholding
13
+ *
14
+ * Per profili normali:
15
+ * 1. contribution = calculated * contribution_percentage / 100
16
+ * 2. withholding = withholding% * withholding_on% * (calculated + contribution se "(104%)")
17
+ * 3. total = calculated + vat_amount + contribution
18
+ * 4. netToPay = total - withholding
19
+ * 5. Se stamp_duty e total > soglia e isCompanyGrouped:
20
+ * total += costo_bollo, netToPay += costo_bollo
21
+ *
22
+ * IVA per tipo società:
23
+ * DS = 22%, Est forfettari = 0%, Est = IVA dalla fattura, default = profilo
24
+ *
25
+ * Company mapping:
26
+ * Le commissioni per DS, Est, Est_forfettari vengono rimappate su società diverse.
27
+ */
28
+
29
+ import type { Id } from "../_generated/dataModel";
30
+ import type { OutsideCompanyType } from "../lib/types";
31
+
32
+ // ── Tipi ─────────────────────────────────────────────────
33
+
34
+ export interface ResolvedTaxProfile {
35
+ _id: Id<"taxProfiles">;
36
+ code: string;
37
+ name: string;
38
+ vatRatePercent: number;
39
+ withholdingRatePercent: number;
40
+ withholdingOnPercent: number;
41
+ contributionRatePercent: number;
42
+ contributionWithholding: boolean;
43
+ contributionText?: string;
44
+ stampDutyEnabled: boolean;
45
+ stampDutyAmount: number;
46
+ stampDutyThreshold: number;
47
+ is104: boolean;
48
+ isBolloCompreso: boolean;
49
+ footerText?: string;
50
+ additionalRules?: Record<string, unknown>;
51
+ }
52
+
53
+ export interface ResolvedUserTaxAssignment {
54
+ _id: Id<"userTaxProfiles">;
55
+ userId: string;
56
+ taxProfileId: Id<"taxProfiles">;
57
+ effectiveFrom: number;
58
+ effectiveTo?: number;
59
+ }
60
+
61
+ export interface TaxProfileSnapshot {
62
+ taxProfileId: string;
63
+ code: string;
64
+ name: string;
65
+ vatRatePercent: number;
66
+ withholdingRatePercent: number;
67
+ withholdingOnPercent: number;
68
+ contributionRatePercent: number;
69
+ contributionWithholding: boolean;
70
+ is104: boolean;
71
+ isBolloCompreso: boolean;
72
+ stampDutyEnabled: boolean;
73
+ stampDutyThreshold: number;
74
+ }
75
+
76
+ export interface CompanyMapping {
77
+ fromCompanyId: string;
78
+ toCompanyId: string;
79
+ category: "DS" | "Est" | "Est_forfettari";
80
+ }
81
+
82
+ export interface CommissionForInvoice {
83
+ id: string;
84
+ userId: string;
85
+ clinicId: string;
86
+ companyId: string;
87
+ period: string;
88
+ calculated: number;
89
+ netPrice: number;
90
+ scope: string;
91
+ fixedCommissionTypeId?: string;
92
+ carePlanTypeId?: number;
93
+ carePlanRowId?: string;
94
+ invoiceVat?: number;
95
+ taxProfileName: string;
96
+ isInvoiceable: boolean;
97
+ }
98
+
99
+ export interface FiscalCalculationResult {
100
+ subtotal: number;
101
+ netPrice: number;
102
+ contributionAmount: number;
103
+ vatRate: number;
104
+ vatAmount: number;
105
+ withholdingRate: number;
106
+ withholdingOnRate: number;
107
+ withholdingAmount: number;
108
+ stampDutyAmount: number;
109
+ total: number;
110
+ netToPay: number;
111
+ }
112
+
113
+ export interface AggregatedInvoiceGroup {
114
+ userId: string;
115
+ clinicId: string;
116
+ companyId: string;
117
+ period: string;
118
+ outsideCompanyType: OutsideCompanyType;
119
+ netPrice: number;
120
+ calculated: number;
121
+ lineItemCount: number;
122
+ commissionIds: string[];
123
+ cprIds: string[];
124
+ vatRate: number;
125
+ profile: ResolvedTaxProfile;
126
+ }
127
+
128
+ export interface DraftInvoiceData {
129
+ userId: string;
130
+ clinicId: string;
131
+ companyId: string;
132
+ period: string;
133
+ outsideCompanyType: OutsideCompanyType;
134
+ isCompanyGrouped: boolean;
135
+ taxProfileId: string;
136
+ taxProfileSnapshot: TaxProfileSnapshot;
137
+ lineItemCount: number;
138
+ fiscal: FiscalCalculationResult;
139
+ commissionIds: string[];
140
+ cprIds: string[];
141
+ }
142
+
143
+ export type TaxProfileResolution =
144
+ | { status: "found"; profile: ResolvedTaxProfile; assignment: ResolvedUserTaxAssignment }
145
+ | { status: "no_assignment"; userId: string }
146
+ | { status: "multiple_assignments"; userId: string; count: number }
147
+ | { status: "profile_not_found"; userId: string; taxProfileId: string }
148
+ | { status: "profile_inactive"; userId: string; profileCode: string };
149
+
150
+ // ── 1. Risoluzione profilo fiscale ───────────────────────
151
+
152
+ export function resolveUserTaxProfile(
153
+ assignments: ResolvedUserTaxAssignment[],
154
+ profiles: Map<string, ResolvedTaxProfile>,
155
+ userId: string,
156
+ period: string,
157
+ ): TaxProfileResolution {
158
+ const { periodStart, periodEnd } = parsePeriodBounds(period);
159
+
160
+ const matching = assignments.filter((a) => {
161
+ if (a.userId !== userId) return false;
162
+ if (a.effectiveFrom > periodEnd) return false;
163
+ if (a.effectiveTo !== undefined && a.effectiveTo <= periodStart) return false;
164
+ return true;
165
+ });
166
+
167
+ if (matching.length === 0) return { status: "no_assignment", userId };
168
+ if (matching.length > 1) return { status: "multiple_assignments", userId, count: matching.length };
169
+
170
+ const assignment = matching[0];
171
+ const profile = profiles.get(assignment.taxProfileId);
172
+
173
+ if (!profile) return { status: "profile_not_found", userId, taxProfileId: assignment.taxProfileId };
174
+ if (!("isActive" in profile) || (profile as any).isActive === false) {
175
+ return { status: "profile_inactive", userId, profileCode: profile.code };
176
+ }
177
+
178
+ return { status: "found", profile, assignment };
179
+ }
180
+
181
+ // ── 2. Company mapping ───────────────────────────────────
182
+
183
+ export function determineOutsideCompanyType(
184
+ entry: CommissionForInvoice,
185
+ ): OutsideCompanyType {
186
+ if (entry.scope === "fixed" && entry.fixedCommissionTypeId === "16") {
187
+ return "DS";
188
+ }
189
+ if (entry.carePlanTypeId === 5) {
190
+ const tpName = entry.taxProfileName.toLowerCase();
191
+ if (tpName.includes("forfettar")) return "Est_forfettari";
192
+ if (entry.invoiceVat) return "Est";
193
+ }
194
+ return "None";
195
+ }
196
+
197
+ export function applyCompanyMapping(
198
+ companyId: string,
199
+ outsideCompanyType: OutsideCompanyType,
200
+ mappings: CompanyMapping[],
201
+ ): string {
202
+ if (outsideCompanyType === "None") return companyId;
203
+
204
+ const mapping = mappings.find(
205
+ (m) => m.category === outsideCompanyType && m.fromCompanyId === companyId,
206
+ );
207
+
208
+ return mapping ? mapping.toCompanyId : companyId;
209
+ }
210
+
211
+ // ── 3. IVA per tipo ──────────────────────────────────────
212
+
213
+ export function calculateVatRate(
214
+ outsideCompanyType: OutsideCompanyType,
215
+ profile: ResolvedTaxProfile,
216
+ invoiceVat?: number,
217
+ ): number {
218
+ switch (outsideCompanyType) {
219
+ case "DS": return 22;
220
+ case "Est_forfettari": return 0;
221
+ case "Est": return invoiceVat ?? profile.vatRatePercent;
222
+ default: return profile.vatRatePercent;
223
+ }
224
+ }
225
+
226
+ // ── 4. Aggregazione commissioni per fattura ──────────────
227
+
228
+ export function aggregateForInvoices(
229
+ entries: CommissionForInvoice[],
230
+ mappings: CompanyMapping[],
231
+ profiles: Map<string, ResolvedTaxProfile>,
232
+ profileByUser: Map<string, ResolvedTaxProfile>,
233
+ ): AggregatedInvoiceGroup[] {
234
+ const groups = new Map<string, AggregatedInvoiceGroup>();
235
+
236
+ for (const entry of entries) {
237
+ if (!entry.isInvoiceable) continue;
238
+
239
+ const profile = profileByUser.get(entry.userId);
240
+ if (!profile) continue;
241
+
242
+ const outsideType = determineOutsideCompanyType(entry);
243
+ const mappedCompanyId = applyCompanyMapping(entry.companyId, outsideType, mappings);
244
+ const vatRate = calculateVatRate(outsideType, profile, entry.invoiceVat);
245
+
246
+ const key = `${entry.userId}|${entry.clinicId}|${entry.period}|${outsideType}`;
247
+
248
+ const existing = groups.get(key);
249
+ if (existing) {
250
+ existing.netPrice += entry.netPrice;
251
+ existing.calculated += entry.calculated;
252
+ existing.lineItemCount++;
253
+ if (entry.id) existing.commissionIds.push(entry.id);
254
+ if (entry.carePlanRowId) existing.cprIds.push(entry.carePlanRowId);
255
+ } else {
256
+ groups.set(key, {
257
+ userId: entry.userId,
258
+ clinicId: entry.clinicId,
259
+ companyId: mappedCompanyId,
260
+ period: entry.period,
261
+ outsideCompanyType: outsideType,
262
+ netPrice: entry.netPrice,
263
+ calculated: entry.calculated,
264
+ lineItemCount: 1,
265
+ commissionIds: entry.id ? [entry.id] : [],
266
+ cprIds: entry.carePlanRowId ? [entry.carePlanRowId] : [],
267
+ vatRate,
268
+ profile,
269
+ });
270
+ }
271
+ }
272
+
273
+ return Array.from(groups.values());
274
+ }
275
+
276
+ // ── 5. Calcoli fiscali completi ──────────────────────────
277
+
278
+ export function calculateFiscalAmounts(
279
+ subtotal: number,
280
+ profile: ResolvedTaxProfile,
281
+ vatRate: number,
282
+ isCompanyGrouped: boolean,
283
+ outsideCompanyType: OutsideCompanyType,
284
+ stampDutyThreshold: number,
285
+ stampDutyCost: number,
286
+ ): FiscalCalculationResult {
287
+ let calculated = round(subtotal);
288
+ let stampDutyAmount = 0;
289
+
290
+ const stampDutyEnabled = profile.stampDutyEnabled && outsideCompanyType !== "Est";
291
+
292
+ if (profile.isBolloCompreso) {
293
+ if (stampDutyEnabled && calculated > stampDutyThreshold && isCompanyGrouped) {
294
+ calculated += stampDutyCost;
295
+ stampDutyAmount = stampDutyCost;
296
+ }
297
+
298
+ const contributionAmount = round(calculated * profile.contributionRatePercent / 100);
299
+
300
+ const withholdingBase = profile.is104
301
+ ? calculated + contributionAmount
302
+ : calculated;
303
+ const withholdingAmount = round(
304
+ profile.withholdingRatePercent / 100 * profile.withholdingOnPercent / 100 * withholdingBase,
305
+ );
306
+
307
+ const vatAmount = round(Math.max(vatRate * subtotal / 100, 0));
308
+ const total = round(calculated + vatAmount + contributionAmount);
309
+ const netToPay = round(total - withholdingAmount);
310
+
311
+ return {
312
+ subtotal: round(subtotal),
313
+ netPrice: round(subtotal),
314
+ contributionAmount,
315
+ vatRate,
316
+ vatAmount,
317
+ withholdingRate: profile.withholdingRatePercent,
318
+ withholdingOnRate: profile.withholdingOnPercent,
319
+ withholdingAmount,
320
+ stampDutyAmount,
321
+ total,
322
+ netToPay,
323
+ };
324
+ }
325
+
326
+ const contributionAmount = round(calculated * profile.contributionRatePercent / 100);
327
+
328
+ const withholdingBase = profile.is104
329
+ ? calculated + contributionAmount
330
+ : calculated;
331
+ const withholdingAmount = round(
332
+ profile.withholdingRatePercent / 100 * profile.withholdingOnPercent / 100 * withholdingBase,
333
+ );
334
+
335
+ const vatAmount = round(Math.max(vatRate * subtotal / 100, 0));
336
+ let total = round(calculated + vatAmount + contributionAmount);
337
+ let netToPay = round(total - withholdingAmount);
338
+
339
+ if (stampDutyEnabled && total > stampDutyThreshold && isCompanyGrouped) {
340
+ total += stampDutyCost;
341
+ netToPay += stampDutyCost;
342
+ stampDutyAmount = stampDutyCost;
343
+ }
344
+
345
+ return {
346
+ subtotal: round(subtotal),
347
+ netPrice: round(subtotal),
348
+ contributionAmount,
349
+ vatRate,
350
+ vatAmount,
351
+ withholdingRate: profile.withholdingRatePercent,
352
+ withholdingOnRate: profile.withholdingOnPercent,
353
+ withholdingAmount,
354
+ stampDutyAmount,
355
+ total,
356
+ netToPay,
357
+ };
358
+ }
359
+
360
+ // ── 6. Company grouping ──────────────────────────────────
361
+
362
+ /**
363
+ * Determina quale fattura è la "prima" per una combinazione userId+companyId+period.
364
+ * Solo la prima fattura riceve isCompanyGrouped=true (per il bollo).
365
+ */
366
+ export function determineCompanyGrouping(
367
+ groups: AggregatedInvoiceGroup[],
368
+ existingGroupedKeys: Set<string>,
369
+ ): Map<string, boolean> {
370
+ const sorted = [...groups].sort((a, b) => b.calculated - a.calculated);
371
+ const seenKeys = new Set<string>(existingGroupedKeys);
372
+ const result = new Map<string, boolean>();
373
+
374
+ for (const group of sorted) {
375
+ const companyKey = `${group.userId}|${group.companyId}|${group.period}`;
376
+ const groupKey = `${group.userId}|${group.clinicId}|${group.period}|${group.outsideCompanyType}`;
377
+
378
+ if (!seenKeys.has(companyKey)) {
379
+ seenKeys.add(companyKey);
380
+ result.set(groupKey, true);
381
+ } else {
382
+ result.set(groupKey, false);
383
+ }
384
+ }
385
+
386
+ return result;
387
+ }
388
+
389
+ // ── 7. Pipeline completa ─────────────────────────────────
390
+
391
+ export function generateDraftInvoices(
392
+ groups: AggregatedInvoiceGroup[],
393
+ existingGroupedKeys: Set<string>,
394
+ stampDutyThreshold: number,
395
+ stampDutyCost: number,
396
+ ): DraftInvoiceData[] {
397
+ const companyGrouping = determineCompanyGrouping(groups, existingGroupedKeys);
398
+ const results: DraftInvoiceData[] = [];
399
+
400
+ for (const group of groups) {
401
+ const groupKey = `${group.userId}|${group.clinicId}|${group.period}|${group.outsideCompanyType}`;
402
+ const isCompanyGrouped = companyGrouping.get(groupKey) ?? false;
403
+
404
+ const fiscal = calculateFiscalAmounts(
405
+ group.calculated,
406
+ group.profile,
407
+ group.vatRate,
408
+ isCompanyGrouped,
409
+ group.outsideCompanyType,
410
+ stampDutyThreshold,
411
+ stampDutyCost,
412
+ );
413
+
414
+ const snapshot = createTaxProfileSnapshot(group.profile);
415
+
416
+ results.push({
417
+ userId: group.userId,
418
+ clinicId: group.clinicId,
419
+ companyId: group.companyId,
420
+ period: group.period,
421
+ outsideCompanyType: group.outsideCompanyType,
422
+ isCompanyGrouped,
423
+ taxProfileId: group.profile._id,
424
+ taxProfileSnapshot: snapshot,
425
+ lineItemCount: group.lineItemCount,
426
+ fiscal,
427
+ commissionIds: group.commissionIds,
428
+ cprIds: group.cprIds,
429
+ });
430
+ }
431
+
432
+ return results;
433
+ }
434
+
435
+ // ── Snapshot ─────────────────────────────────────────────
436
+
437
+ export function createTaxProfileSnapshot(profile: ResolvedTaxProfile): TaxProfileSnapshot {
438
+ return {
439
+ taxProfileId: profile._id,
440
+ code: profile.code,
441
+ name: profile.name,
442
+ vatRatePercent: profile.vatRatePercent,
443
+ withholdingRatePercent: profile.withholdingRatePercent,
444
+ withholdingOnPercent: profile.withholdingOnPercent,
445
+ contributionRatePercent: profile.contributionRatePercent,
446
+ contributionWithholding: profile.contributionWithholding,
447
+ is104: profile.is104,
448
+ isBolloCompreso: profile.isBolloCompreso,
449
+ stampDutyEnabled: profile.stampDutyEnabled,
450
+ stampDutyThreshold: profile.stampDutyThreshold,
451
+ };
452
+ }
453
+
454
+ // ── Helpers ──────────────────────────────────────────────
455
+
456
+ function parsePeriodBounds(period: string): { periodStart: number; periodEnd: number } {
457
+ const [yearStr, monthStr] = period.split("-");
458
+ const year = parseInt(yearStr, 10);
459
+ const month = parseInt(monthStr, 10);
460
+ return {
461
+ periodStart: new Date(year, month - 1, 1).getTime(),
462
+ periodEnd: new Date(year, month, 0, 23, 59, 59, 999).getTime(),
463
+ };
464
+ }
465
+
466
+ function round(value: number): number {
467
+ return Math.round(value * 100) / 100;
468
+ }