From b0bea0bef1b516316f76c3c9811f10e2d2a138f2 Mon Sep 17 00:00:00 2001 From: LagoESP Date: Wed, 29 Apr 2026 20:18:07 +0200 Subject: [PATCH] feat: add backend pricing calculation RPC and refactor create_lead function Co-authored-by: Copilot --- docker-compose.local.yml | 1 + docker-compose.yml | 2 + frontend/app.js | 63 ++---- .../08-backend-pricing-and-security.sql | 205 ++++++++++++++++++ supabase/migrations/post-boot.sql | 24 +- 5 files changed, 238 insertions(+), 57 deletions(-) create mode 100644 supabase/migrations/08-backend-pricing-and-security.sql diff --git a/docker-compose.local.yml b/docker-compose.local.yml index a126199..4733f4b 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -22,6 +22,7 @@ services: - ./supabase/migrations/05-create-lead-rpc.sql:/sql/05-create-lead-rpc.sql:ro - ./supabase/migrations/06-admin-pricing-documents.sql:/sql/06-admin-pricing-documents.sql:ro - ./supabase/migrations/07-sales-orders.sql:/sql/07-sales-orders.sql:ro + - ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro kong: volumes: diff --git a/docker-compose.yml b/docker-compose.yml index e320d67..ce52f54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -215,6 +215,7 @@ services: - /mnt/user/appdata/mc-cars/supabase/migrations/05-create-lead-rpc.sql:/sql/05-create-lead-rpc.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/06-admin-pricing-documents.sql:/sql/06-admin-pricing-documents.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/07-sales-orders.sql:/sql/07-sales-orders.sql:ro + - /mnt/user/appdata/mc-cars/supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro entrypoint: ["sh","-c"] command: - | @@ -238,6 +239,7 @@ services: psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/05-create-lead-rpc.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/06-admin-pricing-documents.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/07-sales-orders.sql + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/08-backend-pricing-and-security.sql echo "post-init done." restart: "no" networks: [mccars] diff --git a/frontend/app.js b/frontend/app.js index 690c162..aba70b2 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -351,7 +351,7 @@ function calcWeekendDays(from, to) { return count; } -function updateSidebar() { +async function updateSidebar() { const v = state.vehicles.find(x => x.id === bpfCar.value); const { from, to } = getBpfDates(); if (!v || !from || !to) { @@ -364,18 +364,25 @@ function updateSidebar() { if (!fromD || !toD) return; if (toD <= fromD) return; - const totalDays = Math.ceil((toD - fromD) / (1000 * 60 * 60 * 24)); - const weekendDays = bpfDurationMode === "weekend" ? 2 : calcWeekendDays(from, to); - const weekdays = bpfDurationMode === "weekend" ? 0 : (totalDays - weekendDays); + // Fetch price from backend RPC + const { data: price, error } = await supabase.rpc("calculate_price", { + p_vehicle_id: v.id, + p_date_from: from, + p_date_to: to, + }); + if (error || !price) { console.error("calculate_price error:", error, "data:", price); return; } - const weekdayCost = weekdays * v.daily_price_eur; - const weekendCost = weekendDays * (v.weekend_price_eur || v.daily_price_eur); - const subtotal = weekdayCost + weekendCost; - const vat = Math.round(subtotal * 0.20); - const total = subtotal + vat; - const deposit = v.kaution_eur || 5000; - const kmPerWeekendDay = v.max_km_weekend || v.max_daily_km || 150; - const kmPerWeekday = v.max_daily_km || 150; + const totalDays = price.total_days; + const weekdays = price.weekday_count; + const weekendDays = price.weekend_day_count; + const weekdayCost = price.daily_subtotal; + const weekendCost = price.weekend_subtotal; + const subtotal = price.subtotal_eur; + const vat = price.vat_eur; + const total = price.total_eur; + const deposit = price.deposit_eur; + const kmPerWeekday = price.max_daily_km; + const kmPerWeekendDay = price.max_km_weekend; const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay); bpfSidebarPlaceholder.style.display = "none"; @@ -383,8 +390,8 @@ function updateSidebar() { bpfSidebarContent.innerHTML = `

${t("bpfPriceOverview")}

${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}
- ${weekdays > 0 ? `
${t("bpfWeekdays")} (${weekdays} × € ${v.daily_price_eur})€ ${weekdayCost.toLocaleString("de-DE")}
` : ""} - ${weekendDays > 0 ? `
${t("bpfWeekendDays")} (${weekendDays} × € ${v.weekend_price_eur || v.daily_price_eur})€ ${weekendCost.toLocaleString("de-DE")}
` : ""} + ${weekdays > 0 ? `
${t("bpfWeekdays")} (${weekdays} × € ${price.daily_price_eur})€ ${weekdayCost.toLocaleString("de-DE")}
` : ""} + ${weekendDays > 0 ? `
${t("bpfWeekendDays")} (${weekendDays} × € ${price.weekend_price_eur})€ ${weekendCost.toLocaleString("de-DE")}
` : ""}
${t("bpfSubtotal")}€ ${subtotal.toLocaleString("de-DE")}
${t("bpfVat")}€ ${vat.toLocaleString("de-DE")}
${t("bpfTotal")}€ ${total.toLocaleString("de-DE")}
@@ -411,40 +418,16 @@ document.querySelector("#bpfSubmit").addEventListener("click", async () => { const vehicle = state.vehicles.find(v => v.id === bpfCar.value); const { from, to } = getBpfDates(); - const vFrom = parseYmdLocal(from); - const vTo = parseYmdLocal(to); - let weekdayCost = 0, weekendCost = 0, subtotal = 0, vat = 0, total = 0, deposit = 0; - let totalDays = 0, weekdays = 0, weekendDays = 0; - if (vehicle && vFrom && vTo && vTo > vFrom) { - totalDays = Math.ceil((vTo - vFrom) / (1000 * 60 * 60 * 24)); - weekendDays = bpfDurationMode === "weekend" ? 2 : calcWeekendDays(from, to); - weekdays = bpfDurationMode === "weekend" ? 0 : (totalDays - weekendDays); - weekdayCost = weekdays * vehicle.daily_price_eur; - weekendCost = weekendDays * (vehicle.weekend_price_eur || vehicle.daily_price_eur); - subtotal = weekdayCost + weekendCost; - vat = Math.round(subtotal * 0.20); - total = subtotal + vat; - deposit = vehicle.kaution_eur || 5000; - } const payload = { p_name: bpfName.value, p_email: bpfEmail.value, p_phone: bpfPhone.value || "", p_vehicle_id: bpfCar.value || null, p_vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "", - p_date_from: bpfFrom.value || null, - p_date_to: bpfTo.value || null, + p_date_from: from || null, + p_date_to: to || null, p_message: bpfMessage.value || "", p_source: "website", - p_daily_subtotal: weekdayCost, - p_weekend_subtotal: weekendCost, - p_subtotal_eur: subtotal, - p_vat_eur: vat, - p_total_eur: total, - p_deposit_eur: deposit, - p_total_days: totalDays, - p_weekday_count: weekdays, - p_weekend_day_count: weekendDays, }; // Create lead via RPC (returns inserted id without anon SELECT privileges) diff --git a/supabase/migrations/08-backend-pricing-and-security.sql b/supabase/migrations/08-backend-pricing-and-security.sql new file mode 100644 index 0000000..0e58037 --- /dev/null +++ b/supabase/migrations/08-backend-pricing-and-security.sql @@ -0,0 +1,205 @@ +-- 08-backend-pricing-and-security.sql +-- 1. Server-side price calculation RPC (read-only, callable by anon for display) +-- 2. Refactored create_lead RPC that computes prices internally (no price params from frontend) +-- 3. Unique constraint on lead_attachments to enforce max 1 id_document + 1 income_proof per lead + +-- ============================================================================= +-- 1. calculate_price RPC +-- ============================================================================= +create or replace function public.calculate_price( + p_vehicle_id uuid, + p_date_from date, + p_date_to date +) +returns jsonb +language plpgsql +stable +security definer +as $$ +declare + v_vehicle record; + v_total_days integer; + v_weekend_days integer; + v_weekdays integer; + v_daily_subtotal integer; + v_weekend_subtotal integer; + v_subtotal_eur integer; + v_vat_eur integer; + v_total_eur integer; + v_deposit_eur integer; + v_cur date; + v_dow integer; +begin + if p_vehicle_id is null or p_date_from is null or p_date_to is null then + raise exception 'vehicle_id, date_from and date_to are required'; + end if; + + if p_date_to <= p_date_from then + raise exception 'date_to must be after date_from'; + end if; + + select daily_price_eur, weekend_price_eur, kaution_eur, max_daily_km, max_km_weekend + into v_vehicle + from public.vehicles + where id = p_vehicle_id; + + if not found then + raise exception 'Vehicle not found'; + end if; + + -- Count days + v_total_days := (p_date_to - p_date_from); + v_weekend_days := 0; + v_cur := p_date_from; + while v_cur < p_date_to loop + v_dow := extract(isodow from v_cur); -- 6=Sat, 7=Sun + if v_dow in (6, 7) then + v_weekend_days := v_weekend_days + 1; + end if; + v_cur := v_cur + 1; + end loop; + v_weekdays := v_total_days - v_weekend_days; + + -- Calculate prices + v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur; + v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end); + v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal; + v_vat_eur := round(v_subtotal_eur * 0.20); + v_total_eur := v_subtotal_eur + v_vat_eur; + v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000); + + return jsonb_build_object( + 'total_days', v_total_days, + 'weekday_count', v_weekdays, + 'weekend_day_count', v_weekend_days, + 'daily_subtotal', v_daily_subtotal, + 'weekend_subtotal', v_weekend_subtotal, + 'subtotal_eur', v_subtotal_eur, + 'vat_eur', v_vat_eur, + 'total_eur', v_total_eur, + 'deposit_eur', v_deposit_eur, + 'daily_price_eur', v_vehicle.daily_price_eur, + 'weekend_price_eur', (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end), + 'max_daily_km', coalesce(v_vehicle.max_daily_km, 150), + 'max_km_weekend', coalesce(v_vehicle.max_km_weekend, v_vehicle.max_daily_km, 150) + ); +end; +$$; + +grant execute on function public.calculate_price(uuid, date, date) to anon, authenticated, service_role; + +-- ============================================================================= +-- 2. Refactored create_lead – computes prices server-side, no price params +-- ============================================================================= + +-- Drop old overloaded signatures +drop function if exists public.create_lead( + text, text, text, uuid, text, date, date, text, text +); +drop function if exists public.create_lead( + text, text, text, uuid, text, date, date, text, text, + integer, integer, integer, integer, integer, integer, integer, integer, integer +); +drop function if exists public.create_lead( + text, text, text, uuid, text, date, date, text, text, + integer, integer, integer, integer, integer, integer, integer, integer, integer, + text, text +); + +create or replace function public.create_lead( + p_name text, + p_email text, + p_phone text default '', + p_vehicle_id uuid default null, + p_vehicle_label text default '', + p_date_from date default null, + p_date_to date default null, + p_message text default '', + p_source text default 'website', + p_ip_address text default '', + p_ip_country text default '' +) +returns uuid +language plpgsql +security definer +as $$ +declare + v_lead_id uuid; + v_vehicle record; + v_total_days integer := 0; + v_weekend_days integer := 0; + v_weekdays integer := 0; + v_daily_subtotal integer := 0; + v_weekend_subtotal integer := 0; + v_subtotal_eur integer := 0; + v_vat_eur integer := 0; + v_total_eur integer := 0; + v_deposit_eur integer := 0; + v_cur date; + v_dow integer; +begin + -- Compute prices server-side if vehicle and dates are provided + if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then + select daily_price_eur, weekend_price_eur, kaution_eur + into v_vehicle + from public.vehicles + where id = p_vehicle_id; + + if found then + v_total_days := (p_date_to - p_date_from); + v_cur := p_date_from; + while v_cur < p_date_to loop + v_dow := extract(isodow from v_cur); + if v_dow in (6, 7) then + v_weekend_days := v_weekend_days + 1; + end if; + v_cur := v_cur + 1; + end loop; + v_weekdays := v_total_days - v_weekend_days; + + v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur; + v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end); + v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal; + v_vat_eur := round(v_subtotal_eur * 0.20); + v_total_eur := v_subtotal_eur + v_vat_eur; + v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000); + end if; + end if; + + insert into public.leads ( + name, email, phone, vehicle_id, vehicle_label, date_from, date_to, + message, source, + daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, + total_days, weekday_count, weekend_day_count, ip_address, ip_country + ) values ( + p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to, + p_message, p_source, + v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur, + v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country + ) + returning id into v_lead_id; + return v_lead_id; +end; +$$; + +grant execute on function public.create_lead( + text, text, text, uuid, text, date, date, text, text, text, text +) to anon, authenticated, service_role; + +-- ============================================================================= +-- 3. Enforce max 1 id_document + 1 income_proof per lead +-- ============================================================================= + +-- Unique partial index: only one 'id_document' per lead +drop index if exists lead_attachments_unique_id_document; +create unique index lead_attachments_unique_id_document + on public.lead_attachments (lead_id) + where kind = 'id_document'; + +-- Unique partial index: only one 'income_proof' per lead +drop index if exists lead_attachments_unique_income_proof; +create unique index lead_attachments_unique_income_proof + on public.lead_attachments (lead_id) + where kind = 'income_proof'; + +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/post-boot.sql b/supabase/migrations/post-boot.sql index ab62318..582fb93 100644 --- a/supabase/migrations/post-boot.sql +++ b/supabase/migrations/post-boot.sql @@ -65,9 +65,7 @@ grant anon, authenticated, service_role to supabase_storage_admin; grant select on storage.buckets to anon, authenticated; grant all on storage.buckets to service_role; -grant select on storage.objects to anon; grant insert on storage.objects to anon; -grant update on storage.objects to anon; grant select, insert, update, delete on storage.objects to authenticated; grant all on storage.objects to service_role; @@ -110,27 +108,19 @@ drop policy if exists "custdocs_public_upload" on storage.objects; drop policy if exists "custdocs_public_upsert_update" on storage.objects; drop policy if exists "custdocs_admin_read" on storage.objects; drop policy if exists "custdocs_admin_delete" on storage.objects; +drop policy if exists "custdocs_admin_insert" on storage.objects; --- Anon can upload (insert) during booking flow +-- Anon can only INSERT (upload) during booking flow — no SELECT/UPDATE/DELETE create policy "custdocs_anon_upload" on storage.objects for insert to anon with check (bucket_id = 'customer-documents'); --- Anon needs SELECT + UPDATE for x-upsert to work (Supabase storage requirement) -create policy "custdocs_anon_select" - on storage.objects for select to anon - using (bucket_id = 'customer-documents'); - -create policy "custdocs_anon_update" - on storage.objects for update to anon - using (bucket_id = 'customer-documents') - with check (bucket_id = 'customer-documents'); - --- Authenticated admins can read/delete +-- Authenticated admins can read (view documents) create policy "custdocs_admin_read" on storage.objects for select to authenticated using (bucket_id = 'customer-documents'); -create policy "custdocs_admin_delete" - on storage.objects for delete to authenticated - using (bucket_id = 'customer-documents'); +-- Authenticated admins can upload new documents +create policy "custdocs_admin_insert" + on storage.objects for insert to authenticated + with check (bucket_id = 'customer-documents');