@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.
- package/convex/_generated/api.ts +110 -0
- package/convex/_generated/component.ts +1356 -0
- package/convex/_generated/dataModel.ts +60 -0
- package/convex/_generated/server.ts +156 -0
- package/convex/audit/logs.ts +69 -0
- package/convex/calculation/commissions.ts +497 -0
- package/convex/calculation/deductions.ts +119 -0
- package/convex/calculation/engine.ts +598 -0
- package/convex/calculation/fixedCommissions.ts +217 -0
- package/convex/calculation/orchestrator.ts +314 -0
- package/convex/calculation/production.ts +495 -0
- package/convex/calculation/productionData.ts +155 -0
- package/convex/calculation/ruleApplications.ts +121 -0
- package/convex/config/categories.ts +114 -0
- package/convex/config/commissionPlans.ts +166 -0
- package/convex/config/commissionRules.ts +154 -0
- package/convex/config/companyMappings.ts +77 -0
- package/convex/config/deductionRules.ts +113 -0
- package/convex/config/fixedCommissionTypes.ts +79 -0
- package/convex/config/globalSettings.ts +79 -0
- package/convex/config/invisalignConfig.ts +87 -0
- package/convex/config/planTemplates.ts +90 -0
- package/convex/config/taxProfiles.ts +122 -0
- package/convex/config/userTaxProfiles.ts +87 -0
- package/convex/convex.config.ts +3 -0
- package/convex/documents/draftInvoices.ts +164 -0
- package/convex/documents/invoiceEngine.ts +468 -0
- package/convex/documents/invoiceGeneration.ts +611 -0
- package/convex/documents/statements.ts +185 -0
- package/convex/ledger/entries.ts +314 -0
- package/convex/lib/types.ts +126 -0
- package/convex/schema.ts +611 -0
- package/convex/seedHelpers.ts +41 -0
- package/convex/workflow/pendingCompletions.ts +148 -0
- package/convex/workflow/pythonWorker.ts +190 -0
- package/convex/workflow/runIssues.ts +364 -0
- package/convex/workflow/runs.ts +718 -0
- package/package.json +43 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query, internalMutation } from "../_generated/server";
|
|
3
|
+
import {
|
|
4
|
+
aggregateForInvoices,
|
|
5
|
+
calculateFiscalAmounts,
|
|
6
|
+
createTaxProfileSnapshot,
|
|
7
|
+
determineCompanyGrouping,
|
|
8
|
+
determineOutsideCompanyType,
|
|
9
|
+
generateDraftInvoices,
|
|
10
|
+
resolveUserTaxProfile,
|
|
11
|
+
applyCompanyMapping,
|
|
12
|
+
calculateVatRate,
|
|
13
|
+
type ResolvedTaxProfile,
|
|
14
|
+
type ResolvedUserTaxAssignment,
|
|
15
|
+
type TaxProfileResolution,
|
|
16
|
+
type CommissionForInvoice,
|
|
17
|
+
type CompanyMapping,
|
|
18
|
+
type AggregatedInvoiceGroup,
|
|
19
|
+
} from "./invoiceEngine";
|
|
20
|
+
|
|
21
|
+
// ── Guard ────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
async function assertRunWritable(
|
|
24
|
+
ctx: { db: { get: (id: any) => Promise<any> } },
|
|
25
|
+
runId: any,
|
|
26
|
+
): Promise<{ period: string }> {
|
|
27
|
+
const run = await ctx.db.get(runId);
|
|
28
|
+
if (!run) throw new Error("Run non trovato");
|
|
29
|
+
if (run.status === "closed") {
|
|
30
|
+
throw new Error("Impossibile generare bozze fattura su un run chiuso");
|
|
31
|
+
}
|
|
32
|
+
if (run.status === "approved") {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"Impossibile generare bozze fattura su un run approvato. " +
|
|
35
|
+
"Riportarlo in draft prima di rigenerare.",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return { period: run.period };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Clear ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export const clearAutoGeneratedInvoices = mutation({
|
|
44
|
+
args: {
|
|
45
|
+
runId: v.id("compensationRuns"),
|
|
46
|
+
userId: v.string(),
|
|
47
|
+
},
|
|
48
|
+
handler: async (ctx, args) => {
|
|
49
|
+
await assertRunWritable(ctx, args.runId);
|
|
50
|
+
|
|
51
|
+
const invoices = await ctx.db
|
|
52
|
+
.query("draftInvoices")
|
|
53
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
54
|
+
.collect();
|
|
55
|
+
|
|
56
|
+
let deletedCount = 0;
|
|
57
|
+
for (const inv of invoices) {
|
|
58
|
+
if (inv.isAutoGenerated) {
|
|
59
|
+
await ctx.db.delete(inv._id);
|
|
60
|
+
deletedCount++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (deletedCount > 0) {
|
|
65
|
+
await ctx.db.insert("auditLogs", {
|
|
66
|
+
entityType: "compensationRuns",
|
|
67
|
+
entityId: args.runId,
|
|
68
|
+
action: "draft_invoices_cleared",
|
|
69
|
+
userId: args.userId,
|
|
70
|
+
payload: { deletedCount },
|
|
71
|
+
createdAt: Date.now(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { deletedCount };
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── Helpers per caricare dati ────────────────────────────
|
|
80
|
+
|
|
81
|
+
async function loadTaxProfileData(ctx: any, userIds: string[], period: string) {
|
|
82
|
+
const allAssignments: ResolvedUserTaxAssignment[] = [];
|
|
83
|
+
for (const uid of userIds) {
|
|
84
|
+
const assignments = await ctx.db
|
|
85
|
+
.query("userTaxProfiles")
|
|
86
|
+
.withIndex("by_user", (q: any) => q.eq("userId", uid))
|
|
87
|
+
.collect();
|
|
88
|
+
for (const a of assignments) {
|
|
89
|
+
allAssignments.push({
|
|
90
|
+
_id: a._id,
|
|
91
|
+
userId: a.userId,
|
|
92
|
+
taxProfileId: a.taxProfileId,
|
|
93
|
+
effectiveFrom: a.effectiveFrom,
|
|
94
|
+
effectiveTo: a.effectiveTo,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const taxProfileIds = [...new Set(allAssignments.map((a) => a.taxProfileId))];
|
|
100
|
+
const profilesMap = new Map<string, ResolvedTaxProfile>();
|
|
101
|
+
for (const tpId of taxProfileIds) {
|
|
102
|
+
const profile = await ctx.db.get(tpId);
|
|
103
|
+
if (profile) {
|
|
104
|
+
profilesMap.set(tpId, {
|
|
105
|
+
_id: profile._id,
|
|
106
|
+
code: profile.code,
|
|
107
|
+
name: profile.name,
|
|
108
|
+
vatRatePercent: profile.vatRatePercent,
|
|
109
|
+
withholdingRatePercent: profile.withholdingRatePercent,
|
|
110
|
+
withholdingOnPercent: profile.withholdingOnPercent ?? 100,
|
|
111
|
+
contributionRatePercent: profile.contributionRatePercent,
|
|
112
|
+
contributionWithholding: profile.contributionWithholding ?? false,
|
|
113
|
+
contributionText: profile.contributionText,
|
|
114
|
+
stampDutyEnabled: profile.stampDutyEnabled ?? true,
|
|
115
|
+
stampDutyAmount: profile.stampDutyAmount,
|
|
116
|
+
stampDutyThreshold: profile.stampDutyThreshold ?? 77.47,
|
|
117
|
+
is104: profile.is104 ?? false,
|
|
118
|
+
isBolloCompreso: profile.isBolloCompreso ?? false,
|
|
119
|
+
footerText: profile.footerText,
|
|
120
|
+
additionalRules: profile.additionalRules as Record<string, unknown> | undefined,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const profileByUser = new Map<string, ResolvedTaxProfile>();
|
|
126
|
+
for (const uid of userIds) {
|
|
127
|
+
const resolution = resolveUserTaxProfile(allAssignments, profilesMap, uid, period);
|
|
128
|
+
if (resolution.status === "found") {
|
|
129
|
+
profileByUser.set(uid, resolution.profile);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { allAssignments, profilesMap, profileByUser };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function loadCompanyMappings(ctx: any): Promise<CompanyMapping[]> {
|
|
137
|
+
const mappings = await ctx.db
|
|
138
|
+
.query("companyMappings")
|
|
139
|
+
.collect();
|
|
140
|
+
|
|
141
|
+
return mappings
|
|
142
|
+
.filter((m: any) => m.isActive)
|
|
143
|
+
.map((m: any) => ({
|
|
144
|
+
fromCompanyId: m.fromCompanyId,
|
|
145
|
+
toCompanyId: m.toCompanyId,
|
|
146
|
+
category: m.category,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function loadGlobalSetting(ctx: any, key: string, defaultValue: number): Promise<number> {
|
|
151
|
+
const setting = await ctx.db
|
|
152
|
+
.query("globalSettings")
|
|
153
|
+
.withIndex("by_key", (q: any) => q.eq("key", key))
|
|
154
|
+
.first();
|
|
155
|
+
if (!setting) return defaultValue;
|
|
156
|
+
const parsed = parseFloat(setting.value);
|
|
157
|
+
return isNaN(parsed) ? defaultValue : parsed;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Refresh (idempotente) ────────────────────────────────
|
|
161
|
+
|
|
162
|
+
export const refreshDraftInvoices = mutation({
|
|
163
|
+
args: {
|
|
164
|
+
runId: v.id("compensationRuns"),
|
|
165
|
+
userId: v.string(),
|
|
166
|
+
},
|
|
167
|
+
handler: async (ctx, args) => {
|
|
168
|
+
const { period } = await assertRunWritable(ctx, args.runId);
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
|
|
171
|
+
const existingInvoices = await ctx.db
|
|
172
|
+
.query("draftInvoices")
|
|
173
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
174
|
+
.collect();
|
|
175
|
+
|
|
176
|
+
let clearedCount = 0;
|
|
177
|
+
for (const inv of existingInvoices) {
|
|
178
|
+
if (inv.isAutoGenerated) {
|
|
179
|
+
await ctx.db.delete(inv._id);
|
|
180
|
+
clearedCount++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const allEntries = await ctx.db
|
|
185
|
+
.query("ledgerEntries")
|
|
186
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
187
|
+
.collect();
|
|
188
|
+
|
|
189
|
+
const commissionEntries = allEntries.filter(
|
|
190
|
+
(e) => !e.isManual && (e.type === "commission" || e.type === "fixed"),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (commissionEntries.length === 0) {
|
|
194
|
+
await ctx.db.insert("auditLogs", {
|
|
195
|
+
entityType: "compensationRuns",
|
|
196
|
+
entityId: args.runId,
|
|
197
|
+
action: "draft_invoices_refreshed",
|
|
198
|
+
userId: args.userId,
|
|
199
|
+
payload: { clearedCount, generatedCount: 0, commissionEntries: 0 },
|
|
200
|
+
createdAt: now,
|
|
201
|
+
});
|
|
202
|
+
return { clearedCount, generatedCount: 0, commissionEntries: 0, taxProfileIssues: [], invoices: [] };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const userIds = [...new Set(commissionEntries.map((e) => e.userId))];
|
|
206
|
+
const { allAssignments, profilesMap, profileByUser } = await loadTaxProfileData(ctx, userIds, period);
|
|
207
|
+
const companyMappings = await loadCompanyMappings(ctx);
|
|
208
|
+
const stampDutyThreshold = await loadGlobalSetting(ctx, "default_stamp_duty_value", 77.47);
|
|
209
|
+
const stampDutyCost = await loadGlobalSetting(ctx, "default_stamp_duty_cost", 2.00);
|
|
210
|
+
|
|
211
|
+
const commissionsForInvoice: CommissionForInvoice[] = commissionEntries.map((e) => {
|
|
212
|
+
const meta = (e.metadata ?? {}) as Record<string, any>;
|
|
213
|
+
const profile = profileByUser.get(e.userId);
|
|
214
|
+
return {
|
|
215
|
+
id: e._id,
|
|
216
|
+
userId: e.userId,
|
|
217
|
+
clinicId: e.clinicId,
|
|
218
|
+
companyId: e.companyId ?? meta.companyId ?? "",
|
|
219
|
+
period: e.period,
|
|
220
|
+
calculated: e.amount,
|
|
221
|
+
netPrice: meta.netPrice ?? 0,
|
|
222
|
+
scope: e.scope ?? meta.scope ?? "completed_rows",
|
|
223
|
+
fixedCommissionTypeId: e.fixedCommissionTypeId ?? meta.fixedCommissionTypeId,
|
|
224
|
+
carePlanTypeId: meta.carePlanTypeId,
|
|
225
|
+
carePlanRowId: meta.carePlanRowId ?? e.sourceId,
|
|
226
|
+
invoiceVat: meta.invoiceVat,
|
|
227
|
+
taxProfileName: profile?.name ?? "",
|
|
228
|
+
isInvoiceable: meta.isInvoiceable !== false,
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const groups = aggregateForInvoices(commissionsForInvoice, companyMappings, profilesMap, profileByUser);
|
|
233
|
+
|
|
234
|
+
const existingGroupedKeys = new Set<string>();
|
|
235
|
+
const draftInvoices = generateDraftInvoices(groups, existingGroupedKeys, stampDutyThreshold, stampDutyCost);
|
|
236
|
+
|
|
237
|
+
const taxProfileIssues: Array<{ userId: string; clinicId: string; issue: string }> = [];
|
|
238
|
+
const generatedInvoices: Array<{ invoiceId: string; userId: string; clinicId: string; total: number; netToPay: number }> = [];
|
|
239
|
+
|
|
240
|
+
for (const draft of draftInvoices) {
|
|
241
|
+
const resolution = resolveUserTaxProfile(allAssignments, profilesMap, draft.userId, period);
|
|
242
|
+
if (resolution.status !== "found") {
|
|
243
|
+
taxProfileIssues.push({
|
|
244
|
+
userId: draft.userId,
|
|
245
|
+
clinicId: draft.clinicId,
|
|
246
|
+
issue: resolutionToMessage(resolution),
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const invoiceId = await ctx.db.insert("draftInvoices", {
|
|
252
|
+
runId: args.runId as any,
|
|
253
|
+
userId: draft.userId,
|
|
254
|
+
clinicId: draft.clinicId,
|
|
255
|
+
companyId: draft.companyId,
|
|
256
|
+
period,
|
|
257
|
+
status: "draft",
|
|
258
|
+
isAutoGenerated: true,
|
|
259
|
+
outsideCompanyType: draft.outsideCompanyType,
|
|
260
|
+
isCompanyGrouped: draft.isCompanyGrouped,
|
|
261
|
+
taxProfileId: resolution.profile._id,
|
|
262
|
+
taxProfileSnapshot: draft.taxProfileSnapshot,
|
|
263
|
+
lineItemCount: draft.lineItemCount,
|
|
264
|
+
netPrice: draft.fiscal.netPrice,
|
|
265
|
+
subtotal: draft.fiscal.subtotal,
|
|
266
|
+
vatRate: draft.fiscal.vatRate,
|
|
267
|
+
vatAmount: draft.fiscal.vatAmount,
|
|
268
|
+
withholdingRate: draft.fiscal.withholdingRate,
|
|
269
|
+
withholdingOnRate: draft.fiscal.withholdingOnRate,
|
|
270
|
+
withholdingAmount: draft.fiscal.withholdingAmount,
|
|
271
|
+
contributionRate: resolution.profile.contributionRatePercent,
|
|
272
|
+
contributionAmount: draft.fiscal.contributionAmount,
|
|
273
|
+
stampDutyAmount: draft.fiscal.stampDutyAmount,
|
|
274
|
+
total: draft.fiscal.total,
|
|
275
|
+
netToPay: draft.fiscal.netToPay,
|
|
276
|
+
commissionIds: draft.commissionIds,
|
|
277
|
+
cprIds: draft.cprIds,
|
|
278
|
+
createdBy: args.userId,
|
|
279
|
+
createdAt: now,
|
|
280
|
+
updatedAt: now,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
generatedInvoices.push({
|
|
284
|
+
invoiceId,
|
|
285
|
+
userId: draft.userId,
|
|
286
|
+
clinicId: draft.clinicId,
|
|
287
|
+
total: draft.fiscal.total,
|
|
288
|
+
netToPay: draft.fiscal.netToPay,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await ctx.db.insert("auditLogs", {
|
|
293
|
+
entityType: "compensationRuns",
|
|
294
|
+
entityId: args.runId,
|
|
295
|
+
action: "draft_invoices_refreshed",
|
|
296
|
+
userId: args.userId,
|
|
297
|
+
payload: {
|
|
298
|
+
clearedCount,
|
|
299
|
+
commissionEntries: commissionEntries.length,
|
|
300
|
+
generatedCount: generatedInvoices.length,
|
|
301
|
+
taxProfileIssues: taxProfileIssues.length,
|
|
302
|
+
totalNetToPay: generatedInvoices.reduce((s, inv) => s + inv.netToPay, 0),
|
|
303
|
+
},
|
|
304
|
+
createdAt: now,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
clearedCount,
|
|
309
|
+
commissionEntries: commissionEntries.length,
|
|
310
|
+
generatedCount: generatedInvoices.length,
|
|
311
|
+
taxProfileIssues,
|
|
312
|
+
invoices: generatedInvoices,
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
export const _refreshDraftInvoicesInternal = internalMutation({
|
|
318
|
+
args: {
|
|
319
|
+
runId: v.id("compensationRuns"),
|
|
320
|
+
userId: v.string(),
|
|
321
|
+
},
|
|
322
|
+
handler: async (ctx, args) => {
|
|
323
|
+
const { period } = await assertRunWritable(ctx, args.runId);
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
|
|
326
|
+
const existingInvoices = await ctx.db
|
|
327
|
+
.query("draftInvoices")
|
|
328
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
329
|
+
.collect();
|
|
330
|
+
|
|
331
|
+
let clearedCount = 0;
|
|
332
|
+
for (const inv of existingInvoices) {
|
|
333
|
+
if (inv.isAutoGenerated) {
|
|
334
|
+
await ctx.db.delete(inv._id);
|
|
335
|
+
clearedCount++;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const allEntries = await ctx.db
|
|
340
|
+
.query("ledgerEntries")
|
|
341
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
342
|
+
.collect();
|
|
343
|
+
|
|
344
|
+
const commissionEntries = allEntries.filter(
|
|
345
|
+
(e) => !e.isManual && (e.type === "commission" || e.type === "fixed"),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (commissionEntries.length === 0) {
|
|
349
|
+
return { clearedCount, generatedCount: 0, taxProfileIssues: [] };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const userIds = [...new Set(commissionEntries.map((e) => e.userId))];
|
|
353
|
+
const { allAssignments, profilesMap, profileByUser } = await loadTaxProfileData(ctx, userIds, period);
|
|
354
|
+
const companyMappings = await loadCompanyMappings(ctx);
|
|
355
|
+
const stampDutyThreshold = await loadGlobalSetting(ctx, "default_stamp_duty_value", 77.47);
|
|
356
|
+
const stampDutyCost = await loadGlobalSetting(ctx, "default_stamp_duty_cost", 2.00);
|
|
357
|
+
|
|
358
|
+
const commissionsForInvoice: CommissionForInvoice[] = commissionEntries.map((e) => {
|
|
359
|
+
const meta = (e.metadata ?? {}) as Record<string, any>;
|
|
360
|
+
const profile = profileByUser.get(e.userId);
|
|
361
|
+
return {
|
|
362
|
+
id: e._id,
|
|
363
|
+
userId: e.userId,
|
|
364
|
+
clinicId: e.clinicId,
|
|
365
|
+
companyId: e.companyId ?? meta.companyId ?? "",
|
|
366
|
+
period: e.period,
|
|
367
|
+
calculated: e.amount,
|
|
368
|
+
netPrice: meta.netPrice ?? 0,
|
|
369
|
+
scope: e.scope ?? meta.scope ?? "completed_rows",
|
|
370
|
+
fixedCommissionTypeId: e.fixedCommissionTypeId ?? meta.fixedCommissionTypeId,
|
|
371
|
+
carePlanTypeId: meta.carePlanTypeId,
|
|
372
|
+
carePlanRowId: meta.carePlanRowId ?? e.sourceId,
|
|
373
|
+
invoiceVat: meta.invoiceVat,
|
|
374
|
+
taxProfileName: profile?.name ?? "",
|
|
375
|
+
isInvoiceable: meta.isInvoiceable !== false,
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const groups = aggregateForInvoices(commissionsForInvoice, companyMappings, profilesMap, profileByUser);
|
|
380
|
+
const existingGroupedKeys = new Set<string>();
|
|
381
|
+
const draftInvoices = generateDraftInvoices(groups, existingGroupedKeys, stampDutyThreshold, stampDutyCost);
|
|
382
|
+
|
|
383
|
+
const taxProfileIssues: Array<{ userId: string; clinicId: string; issue: string }> = [];
|
|
384
|
+
let generatedCount = 0;
|
|
385
|
+
|
|
386
|
+
for (const draft of draftInvoices) {
|
|
387
|
+
const resolution = resolveUserTaxProfile(allAssignments, profilesMap, draft.userId, period);
|
|
388
|
+
if (resolution.status !== "found") {
|
|
389
|
+
taxProfileIssues.push({
|
|
390
|
+
userId: draft.userId,
|
|
391
|
+
clinicId: draft.clinicId,
|
|
392
|
+
issue: resolutionToMessage(resolution),
|
|
393
|
+
});
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
await ctx.db.insert("draftInvoices", {
|
|
398
|
+
runId: args.runId,
|
|
399
|
+
userId: draft.userId,
|
|
400
|
+
clinicId: draft.clinicId,
|
|
401
|
+
companyId: draft.companyId,
|
|
402
|
+
period,
|
|
403
|
+
status: "draft",
|
|
404
|
+
isAutoGenerated: true,
|
|
405
|
+
outsideCompanyType: draft.outsideCompanyType,
|
|
406
|
+
isCompanyGrouped: draft.isCompanyGrouped,
|
|
407
|
+
taxProfileId: resolution.profile._id,
|
|
408
|
+
taxProfileSnapshot: draft.taxProfileSnapshot,
|
|
409
|
+
lineItemCount: draft.lineItemCount,
|
|
410
|
+
netPrice: draft.fiscal.netPrice,
|
|
411
|
+
subtotal: draft.fiscal.subtotal,
|
|
412
|
+
vatRate: draft.fiscal.vatRate,
|
|
413
|
+
vatAmount: draft.fiscal.vatAmount,
|
|
414
|
+
withholdingRate: draft.fiscal.withholdingRate,
|
|
415
|
+
withholdingOnRate: draft.fiscal.withholdingOnRate,
|
|
416
|
+
withholdingAmount: draft.fiscal.withholdingAmount,
|
|
417
|
+
contributionRate: resolution.profile.contributionRatePercent,
|
|
418
|
+
contributionAmount: draft.fiscal.contributionAmount,
|
|
419
|
+
stampDutyAmount: draft.fiscal.stampDutyAmount,
|
|
420
|
+
total: draft.fiscal.total,
|
|
421
|
+
netToPay: draft.fiscal.netToPay,
|
|
422
|
+
commissionIds: draft.commissionIds,
|
|
423
|
+
cprIds: draft.cprIds,
|
|
424
|
+
createdBy: args.userId,
|
|
425
|
+
createdAt: now,
|
|
426
|
+
updatedAt: now,
|
|
427
|
+
});
|
|
428
|
+
generatedCount++;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
await ctx.db.insert("auditLogs", {
|
|
432
|
+
entityType: "compensationRuns",
|
|
433
|
+
entityId: args.runId,
|
|
434
|
+
action: "draft_invoices_refreshed",
|
|
435
|
+
userId: args.userId,
|
|
436
|
+
payload: { clearedCount, generatedCount, taxProfileIssues: taxProfileIssues.length },
|
|
437
|
+
createdAt: now,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
return { clearedCount, generatedCount, taxProfileIssues };
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ── Query ────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
export const getTotalNetToPay = query({
|
|
447
|
+
args: { runId: v.id("compensationRuns") },
|
|
448
|
+
handler: async (ctx, args) => {
|
|
449
|
+
const invoices = await ctx.db
|
|
450
|
+
.query("draftInvoices")
|
|
451
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
452
|
+
.collect();
|
|
453
|
+
|
|
454
|
+
let totalNetToPay = 0;
|
|
455
|
+
let totalSubtotal = 0;
|
|
456
|
+
let totalVat = 0;
|
|
457
|
+
let totalWithholding = 0;
|
|
458
|
+
|
|
459
|
+
for (const inv of invoices) {
|
|
460
|
+
totalNetToPay += inv.netToPay;
|
|
461
|
+
totalSubtotal += inv.subtotal;
|
|
462
|
+
totalVat += inv.vatAmount;
|
|
463
|
+
totalWithholding += inv.withholdingAmount;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
runId: args.runId,
|
|
468
|
+
invoiceCount: invoices.length,
|
|
469
|
+
totalSubtotal: Math.round(totalSubtotal * 100) / 100,
|
|
470
|
+
totalVat: Math.round(totalVat * 100) / 100,
|
|
471
|
+
totalWithholding: Math.round(totalWithholding * 100) / 100,
|
|
472
|
+
totalNetToPay: Math.round(totalNetToPay * 100) / 100,
|
|
473
|
+
};
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
export const getInvoiceSummaryByUser = query({
|
|
478
|
+
args: {
|
|
479
|
+
runId: v.id("compensationRuns"),
|
|
480
|
+
userId: v.string(),
|
|
481
|
+
},
|
|
482
|
+
handler: async (ctx, args) => {
|
|
483
|
+
const invoices = await ctx.db
|
|
484
|
+
.query("draftInvoices")
|
|
485
|
+
.withIndex("by_run_user", (q) =>
|
|
486
|
+
q.eq("runId", args.runId).eq("userId", args.userId),
|
|
487
|
+
)
|
|
488
|
+
.collect();
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
runId: args.runId,
|
|
492
|
+
userId: args.userId,
|
|
493
|
+
invoices,
|
|
494
|
+
totalNetToPay: invoices.reduce((s, inv) => s + inv.netToPay, 0),
|
|
495
|
+
totalSubtotal: invoices.reduce((s, inv) => s + inv.subtotal, 0),
|
|
496
|
+
};
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
export const listUsersWithoutTaxProfile = query({
|
|
501
|
+
args: { runId: v.id("compensationRuns") },
|
|
502
|
+
handler: async (ctx, args) => {
|
|
503
|
+
const run = await ctx.db.get(args.runId);
|
|
504
|
+
if (!run) throw new Error("Run non trovato");
|
|
505
|
+
|
|
506
|
+
const allEntries = await ctx.db
|
|
507
|
+
.query("ledgerEntries")
|
|
508
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
509
|
+
.collect();
|
|
510
|
+
|
|
511
|
+
const commissionEntries = allEntries.filter(
|
|
512
|
+
(e) => !e.isManual && (e.type === "commission" || e.type === "fixed"),
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
const userIds = [...new Set(commissionEntries.map((e) => e.userId))];
|
|
516
|
+
const usersWithoutProfile: Array<{ userId: string; commissionTotal: number; issue: string }> = [];
|
|
517
|
+
|
|
518
|
+
for (const uid of userIds) {
|
|
519
|
+
const assignments = await ctx.db
|
|
520
|
+
.query("userTaxProfiles")
|
|
521
|
+
.withIndex("by_user", (q) => q.eq("userId", uid))
|
|
522
|
+
.collect();
|
|
523
|
+
|
|
524
|
+
const resolvedAssignments: ResolvedUserTaxAssignment[] = assignments.map((a) => ({
|
|
525
|
+
_id: a._id,
|
|
526
|
+
userId: a.userId,
|
|
527
|
+
taxProfileId: a.taxProfileId,
|
|
528
|
+
effectiveFrom: a.effectiveFrom,
|
|
529
|
+
effectiveTo: a.effectiveTo,
|
|
530
|
+
}));
|
|
531
|
+
|
|
532
|
+
const profilesMap = new Map<string, ResolvedTaxProfile>();
|
|
533
|
+
for (const a of assignments) {
|
|
534
|
+
const profile = await ctx.db.get(a.taxProfileId);
|
|
535
|
+
if (profile) {
|
|
536
|
+
profilesMap.set(a.taxProfileId, {
|
|
537
|
+
_id: profile._id,
|
|
538
|
+
code: profile.code,
|
|
539
|
+
name: profile.name,
|
|
540
|
+
vatRatePercent: profile.vatRatePercent,
|
|
541
|
+
withholdingRatePercent: profile.withholdingRatePercent,
|
|
542
|
+
withholdingOnPercent: profile.withholdingOnPercent ?? 100,
|
|
543
|
+
contributionRatePercent: profile.contributionRatePercent,
|
|
544
|
+
contributionWithholding: profile.contributionWithholding ?? false,
|
|
545
|
+
stampDutyEnabled: profile.stampDutyEnabled ?? true,
|
|
546
|
+
stampDutyAmount: profile.stampDutyAmount,
|
|
547
|
+
stampDutyThreshold: profile.stampDutyThreshold ?? 77.47,
|
|
548
|
+
is104: profile.is104 ?? false,
|
|
549
|
+
isBolloCompreso: profile.isBolloCompreso ?? false,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const resolution = resolveUserTaxProfile(resolvedAssignments, profilesMap, uid, run.period);
|
|
555
|
+
if (resolution.status !== "found") {
|
|
556
|
+
const userComms = commissionEntries.filter((e) => e.userId === uid);
|
|
557
|
+
usersWithoutProfile.push({
|
|
558
|
+
userId: uid,
|
|
559
|
+
commissionTotal: userComms.reduce((s, e) => s + e.amount, 0),
|
|
560
|
+
issue: resolutionToMessage(resolution),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return { runId: args.runId, period: run.period, usersWithoutProfile, totalAffected: usersWithoutProfile.length };
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
export const listFiscalAnomalies = query({
|
|
570
|
+
args: { runId: v.id("compensationRuns") },
|
|
571
|
+
handler: async (ctx, args) => {
|
|
572
|
+
const invoices = await ctx.db
|
|
573
|
+
.query("draftInvoices")
|
|
574
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
575
|
+
.collect();
|
|
576
|
+
|
|
577
|
+
const anomalies: Array<{
|
|
578
|
+
invoiceId: string;
|
|
579
|
+
userId: string;
|
|
580
|
+
clinicId: string;
|
|
581
|
+
anomalyType: string;
|
|
582
|
+
details: Record<string, unknown>;
|
|
583
|
+
}> = [];
|
|
584
|
+
|
|
585
|
+
for (const inv of invoices) {
|
|
586
|
+
if (inv.subtotal <= 0) {
|
|
587
|
+
anomalies.push({ invoiceId: inv._id, userId: inv.userId, clinicId: inv.clinicId, anomalyType: "non_positive_subtotal", details: { subtotal: inv.subtotal } });
|
|
588
|
+
}
|
|
589
|
+
if (inv.netToPay < 0) {
|
|
590
|
+
anomalies.push({ invoiceId: inv._id, userId: inv.userId, clinicId: inv.clinicId, anomalyType: "negative_net_to_pay", details: { netToPay: inv.netToPay, total: inv.total } });
|
|
591
|
+
}
|
|
592
|
+
if (!inv.taxProfileId) {
|
|
593
|
+
anomalies.push({ invoiceId: inv._id, userId: inv.userId, clinicId: inv.clinicId, anomalyType: "missing_tax_profile", details: {} });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return { runId: args.runId, anomalies, totalAnomalies: anomalies.length, totalInvoices: invoices.length };
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
function resolutionToMessage(resolution: TaxProfileResolution): string {
|
|
604
|
+
switch (resolution.status) {
|
|
605
|
+
case "no_assignment": return `Nessun profilo fiscale assegnato per userId=${resolution.userId}`;
|
|
606
|
+
case "multiple_assignments": return `Profili fiscali multipli (${resolution.count}) per userId=${resolution.userId}`;
|
|
607
|
+
case "profile_not_found": return `Profilo fiscale ${resolution.taxProfileId} non trovato per userId=${resolution.userId}`;
|
|
608
|
+
case "profile_inactive": return `Profilo fiscale "${resolution.profileCode}" non attivo per userId=${resolution.userId}`;
|
|
609
|
+
default: return "Profilo fiscale trovato";
|
|
610
|
+
}
|
|
611
|
+
}
|