feat: add backend pricing calculation RPC and refactor create_lead function

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
LagoESP
2026-04-29 20:18:07 +02:00
parent 4c1931cdf4
commit b0bea0bef1
5 changed files with 238 additions and 57 deletions
+1
View File
@@ -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:
+2
View File
@@ -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]
+23 -40
View File
@@ -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 = `
<h4>${t("bpfPriceOverview")}</h4>
<div class="bpf-price-row"><span>${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}</span></div>
${weekdays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekdays")} (${weekdays} ×${v.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} ×${v.weekend_price_eur || v.daily_price_eur})</span><span>€ ${weekendCost.toLocaleString("de-DE")}</span></div>` : ""}
${weekdays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekdays")} (${weekdays} ×${price.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} ×${price.weekend_price_eur})</span><span>€ ${weekendCost.toLocaleString("de-DE")}</span></div>` : ""}
<div class="bpf-price-row"><span>${t("bpfSubtotal")}</span><span>€ ${subtotal.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted"><span>${t("bpfVat")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
@@ -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)
@@ -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';
+7 -17
View File
@@ -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');