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');