Skip to content

Commit fba36cf

Browse files
authored
Merge pull request #17 from emanuellcs/fix-finance
Fix finance
2 parents 13f75b4 + fd38fc4 commit fba36cf

14 files changed

Lines changed: 1685 additions & 2526 deletions

File tree

actions/appointments.ts

Lines changed: 36 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -3,102 +3,51 @@
33
import { revalidatePath } from "next/cache";
44
import { getSupabaseAdmin } from "@/lib/supabase/admin";
55
import { parseSupabaseError } from "@/lib/error-handler";
6-
import { Appointment, SaleStatus } from "@/types";
6+
import { Appointment, SaleStatus, AppointmentStatus } from "@/types";
77
import { supabaseAppointmentToAppointment } from "@/lib/utils/mapping";
8-
import { createSaleAction } from "./finance";
98

109
/**
11-
* Creates a new appointment.
10+
* Creates a new appointment atomically with its sale using an RPC.
11+
* Resolves Rule 1 (Atomicity) and Rule 2 (Commission Tracking).
1212
*/
1313
export async function createAppointmentAction(
1414
appointment: Omit<Appointment, "id" | "created_at">,
1515
) {
1616
try {
1717
const supabase = getSupabaseAdmin();
1818

19-
// 1. Create the appointment
20-
const payload = {
21-
client_id: parseInt(appointment.clientId),
22-
professional_id: appointment.professionalId,
23-
start_time: appointment.startTime,
24-
end_time: appointment.endTime,
25-
status: appointment.status,
26-
notes: appointment.notes || null,
19+
const rpcPayload = {
20+
p_client_id: parseInt(appointment.clientId),
21+
p_professional_id: appointment.professionalId,
22+
p_start_time: appointment.startTime,
23+
p_end_time: appointment.endTime,
24+
p_notes: appointment.notes || null,
25+
p_service_variants:
26+
appointment.serviceVariants?.map((sv) => ({
27+
service_variant_id: parseInt(sv.serviceVariantId),
28+
quantity: sv.quantity,
29+
})) || [],
2730
};
2831

29-
const { data: appointmentData, error: appointmentError } = await supabase
30-
.from("appointments")
31-
.insert([payload])
32-
.select(`*, clients(full_name)`)
33-
.single();
32+
// Rule 1: Use atomic RPC to prevent "ghost appointments"
33+
const { data, error } = await supabase.rpc(
34+
"create_appointment_with_sale",
35+
rpcPayload,
36+
);
3437

35-
if (appointmentError) {
38+
if (error) {
3639
return {
3740
success: false,
38-
error: parseSupabaseError(appointmentError).description,
41+
error: parseSupabaseError(error).description,
3942
};
4043
}
4144

42-
const appointmentId = appointmentData.id;
43-
44-
// 2. Create appointment services and calculate total price
45-
let totalAmount = 0;
46-
const items: Array<{
47-
serviceVariantId: string;
48-
quantity: number;
49-
unitPrice: number;
50-
subtotal: number;
51-
professionalId: string;
52-
}> = [];
53-
54-
if (appointment.serviceVariants && appointment.serviceVariants.length > 0) {
55-
for (const sv of appointment.serviceVariants) {
56-
// Fetch price for each variant
57-
const { data: variantData } = await supabase
58-
.from("service_variants")
59-
.select("price")
60-
.eq("id", parseInt(sv.serviceVariantId))
61-
.single();
62-
63-
const unitPrice = variantData?.price || 0;
64-
const subtotal = unitPrice * sv.quantity;
65-
totalAmount += subtotal;
66-
67-
items.push({
68-
serviceVariantId: sv.serviceVariantId,
69-
quantity: sv.quantity,
70-
unitPrice: unitPrice,
71-
subtotal: subtotal,
72-
professionalId: appointment.professionalId,
73-
});
74-
75-
// Insert into appointment_services
76-
await supabase.from("appointment_services").insert([
77-
{
78-
appointment_id: appointmentId,
79-
service_variant_id: parseInt(sv.serviceVariantId),
80-
quantity: sv.quantity,
81-
},
82-
]);
83-
}
84-
85-
// 3. Create a pending sale automatically
86-
await createSaleAction({
87-
clientId: appointment.clientId,
88-
appointmentId: String(appointmentId),
89-
items: items,
90-
totalAmount: totalAmount,
91-
status: SaleStatus.PENDING,
92-
notes: `Gerada automaticamente do agendamento #${appointmentId}`,
93-
});
94-
}
95-
9645
revalidatePath("/agenda");
9746
revalidatePath("/financeiro");
9847

9948
return {
10049
success: true,
101-
data: supabaseAppointmentToAppointment(appointmentData),
50+
data: supabaseAppointmentToAppointment(data),
10251
};
10352
} catch (error: unknown) {
10453
console.error("Error in createAppointmentAction:", error);
@@ -107,7 +56,8 @@ export async function createAppointmentAction(
10756
}
10857

10958
/**
110-
* Updates an existing appointment.
59+
* Updates an existing appointment and syncs related sale status.
60+
* Resolves Rule 3 (End-of-day reconciliation).
11161
*/
11262
export async function updateAppointmentAction(
11363
id: string,
@@ -146,7 +96,19 @@ export async function updateAppointmentAction(
14696
return { success: false, error: parseSupabaseError(error).description };
14797
}
14898

99+
// Rule 3 Sync: If appointment is cancelled, cancel the associated sale
100+
if (appointment.status === AppointmentStatus.CANCELLED) {
101+
await supabase
102+
.from("sales")
103+
.update({
104+
status: SaleStatus.CANCELLED,
105+
updated_at: new Date().toISOString(),
106+
})
107+
.eq("appointment_id", parseInt(id));
108+
}
109+
149110
revalidatePath("/agenda");
111+
revalidatePath("/financeiro");
150112
return { success: true, data: supabaseAppointmentToAppointment(data) };
151113
} catch (error: unknown) {
152114
console.error("Error in updateAppointmentAction:", error);

actions/finance.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,13 @@ export async function createSaleAction(sale: NewSale) {
144144

145145
/**
146146
* Helper to check if a sale is fully paid and update its status.
147+
* Now also updates related appointments to 'completed'.
147148
*/
148149
async function syncSaleStatus(supabase: any, saleId: number) {
149150
// Fetch fresh data including payments
150151
const { data: sale } = await supabase
151152
.from("sales")
152-
.select("total_amount, payments(amount, status)")
153+
.select("total_amount, appointment_id, payments(amount, status)")
153154
.eq("id", saleId)
154155
.single();
155156

@@ -170,6 +171,17 @@ async function syncSaleStatus(supabase: any, saleId: number) {
170171
updated_at: new Date().toISOString(),
171172
})
172173
.eq("id", saleId);
174+
175+
// Issue A & B Fix: Sync appointment status
176+
if (sale.appointment_id && newStatus === "paid") {
177+
await supabase
178+
.from("appointments")
179+
.update({
180+
status: "completed",
181+
updated_at: new Date().toISOString(),
182+
})
183+
.eq("id", sale.appointment_id);
184+
}
173185
}
174186

175187
/**
@@ -296,3 +308,102 @@ export async function updatePaymentStatusAction(
296308
return { success: false, error: "Falha ao atualizar status do pagamento." };
297309
}
298310
}
311+
312+
/**
313+
* Updates a sale's generic data (total, notes, status, etc).
314+
*/
315+
export async function updateSaleAction(id: string, updates: Partial<Sale>) {
316+
try {
317+
const supabase = getSupabaseAdmin();
318+
const payload: any = { updated_at: new Date().toISOString() };
319+
if (updates.totalAmount !== undefined)
320+
payload.total_amount = updates.totalAmount;
321+
if (updates.notes !== undefined) payload.notes = updates.notes;
322+
if (updates.status !== undefined) payload.status = updates.status;
323+
324+
const { data, error } = await supabase
325+
.from("sales")
326+
.update(payload)
327+
.eq("id", parseInt(id))
328+
.select(
329+
`*, client:clients(full_name), professional:professionals!sales_professional_id_fkey(full_name), items:sale_items(*, professional:professionals(full_name), variant:service_variants(variant_name, service:services(name))), payments(*)`,
330+
)
331+
.single();
332+
333+
if (error) {
334+
return { success: false, error: parseSupabaseError(error).description };
335+
}
336+
337+
revalidatePath("/financeiro");
338+
revalidatePath("/relatorios");
339+
return { success: true, data: supabaseSaleToSale(data) };
340+
} catch (error: any) {
341+
console.error("Error in updateSaleAction:", error);
342+
return { success: false, error: "Falha ao atualizar venda." };
343+
}
344+
}
345+
346+
/**
347+
* Process a manual payment from physical POS (Issue C, D, Fatal Flaw).
348+
* Resolves Rule 2 (Commission Tracking) by ensuring professional_id is persisted.
349+
*/
350+
export async function processManualPaymentAction(
351+
saleId: number,
352+
paymentMethod: string,
353+
amount: number,
354+
professionalId?: string,
355+
) {
356+
try {
357+
const supabase = getSupabaseAdmin();
358+
359+
// Rule 2 Fix: Resolve professional_id from the sale if not provided
360+
// This ensures commission tracking is maintained even if manual payment is registered without explicit prof ID
361+
let resolvedProfId = professionalId;
362+
if (!resolvedProfId) {
363+
const { data: saleRow } = await supabase
364+
.from("sales")
365+
.select("professional_id")
366+
.eq("id", saleId)
367+
.single();
368+
resolvedProfId = saleRow?.professional_id;
369+
}
370+
371+
// Issue D: Track WHO got paid and WHEN
372+
const payload: any = {
373+
sale_id: saleId,
374+
amount: amount,
375+
payment_method: paymentMethod,
376+
status: "paid",
377+
paid_at: new Date().toISOString(),
378+
professional_id: resolvedProfId || null,
379+
};
380+
381+
const { error } = await supabase.from("payments").insert([payload]);
382+
383+
if (error) {
384+
return { success: false, error: parseSupabaseError(error).description };
385+
}
386+
387+
// Fatal Flaw: Sum all successful payments and conditionally update status
388+
await syncSaleStatus(supabase, saleId);
389+
390+
// Fetch fresh sale data to determine if fully paid
391+
const { data: freshSale } = await supabase
392+
.from("sales")
393+
.select("status")
394+
.eq("id", saleId)
395+
.single();
396+
397+
revalidatePath("/financeiro");
398+
revalidatePath("/agenda");
399+
revalidatePath("/relatorios");
400+
401+
return {
402+
success: true,
403+
isFullyPaid: freshSale?.status === "paid",
404+
};
405+
} catch (error: any) {
406+
console.error("Error in processManualPaymentAction:", error);
407+
return { success: false, error: "Falha ao processar pagamento." };
408+
}
409+
}

0 commit comments

Comments
 (0)