Compare commits

..

No commits in common. "master" and "v1.5" have entirely different histories.
master ... v1.5

346 changed files with 1846 additions and 19968 deletions

View File

@ -1,92 +0,0 @@
package main
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/database"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"time"
)
type Temperature struct {
Day float64 `json:"day"`
Min float64 `json:"min"`
}
type Weather struct {
ID int `json:"id"`
}
type Forecast struct {
Timestamp int64 `json:"dt"`
Weather []Weather `json:"weather"`
Temperature Temperature `json:"temp"`
}
type OpenWeatherMap struct {
Forecasts []Forecast `json:"list"`
}
func main() {
db, err := database.New(context.Background(), os.Getenv("CAMPER_DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
conn, err := db.Acquire(context.Background())
if err != nil {
log.Fatal(err)
}
defer conn.Release()
updateForecast(context.Background(), conn)
}
func updateForecast(ctx context.Context, conn *database.Conn) {
if _, err := conn.Exec(ctx, "set role to admin"); err != nil {
log.Fatal(err)
}
var stationURL string
if err := conn.QueryRow(ctx, `
select station_uri
from weather_forecast
`).Scan(
&stationURL,
); err != nil {
log.Fatal(err)
}
resp, err := http.Get(stationURL)
if err != nil {
log.Fatal(err)
}
var result OpenWeatherMap
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.Fatal(err)
}
for _, forecast := range result.Forecasts {
if _, err := conn.Exec(ctx, `
update weather_forecast
set weather_condition_id = $1
, day_temperature = $2
, min_temperature = $3
, forecasted_at = $4
, updated_at = current_timestamp
where station_uri = $5
`,
strconv.Itoa(forecast.Weather[0].ID),
forecast.Temperature.Day,
forecast.Temperature.Min,
time.Unix(forecast.Timestamp, 0),
stationURL,
); err != nil {
log.Fatal(err)
}
}
}

View File

@ -1,2 +1 @@
42 * * * * camper /usr/bin/psql --quiet --output=/dev/null --command='set role guest; select * from camper.flush_payments()'
21 * * * * camper /usr/bin/camper-weather

View File

@ -1,4 +1,3 @@
usr/bin/camper usr/bin
usr/bin/camper-weather usr/bin
locale usr/share/camper
web usr/share/camper

2
debian/changelog vendored
View File

@ -1,4 +1,4 @@
camper (1.8~git00000000000000.0000000-1) bookworm; urgency=medium
camper (1.5~git00000000000000.0000000-1) bookworm; urgency=medium
* Initial release

4
debian/control vendored
View File

@ -10,7 +10,6 @@ Build-Depends:
golang-any,
golang-github-jackc-pgx-v4-dev,
golang-github-leonelquinteros-gotext-dev,
golang-github-rainycape-unidecode-dev,
golang-golang-x-text-dev,
postgresql-all (>= 217~),
sqitch,
@ -46,8 +45,7 @@ Architecture: any
Depends:
${shlibs:Depends},
${misc:Depends},
adduser,
weasyprint
adduser
Built-Using: ${misc:Built-Using}
Description: Simple campground reservation management application
A simple web application to manage reservations to campgrounds, intended for

View File

@ -10,8 +10,8 @@ values ('demo@camper', 'Demo User', 'demo', 'ca')
;
alter table company alter column company_id restart with 52;
insert into company (slug, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, rtc_number, tourist_tax, tourist_tax_max_days, currency_code, default_lang_tag, legal_disclaimer)
values ('09184122-b276-4be2-9553-e4bbcbafe40d', 'El pont de Llierca, S.L.', 'ESB17377656', 'Càmping Montagut', parse_packed_phone_number('661 673 057', 'ES'), 'jordi@tandem.blog', 'https://tandem.blog/', 'Ctra. de Sadernes, Km 2', 'Montagut i Oix', 'Girona', '17855', 'ES', 'KG-000133', 60, 7, 'EUR', 'ca', 'El pont de Llierca, S.L. és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a El pont de Llierca, S.L., amb domicili Ctra. de Sadernes, Km 2, 17855 Montagut i Oix o enviant un correu electrònic a jordi@tandem.blog. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a tandem.blog.');
insert into company (slug, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, rtc_number, tourist_tax, currency_code, default_lang_tag, legal_disclaimer)
values ('09184122-b276-4be2-9553-e4bbcbafe40d', 'El pont de Llierca, S.L.', 'ESB17377656', 'Càmping Montagut', parse_packed_phone_number('661 673 057', 'ES'), 'info@campingmontagut.com', 'https://campingmontagut.com/', 'Ctra. de Sadernes, Km 2', 'Montagut i Oix', 'Girona', '17855', 'ES', 'KG-000133', 60, 'EUR', 'ca', 'El pont de Llierca, S.L. és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a El pont de Llierca, S.L., amb domicili Ctra. de Sadernes, Km 2, 17855 Montagut i Oix o enviant un correu electrònic a info@campingmontagut.com. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a campingmontagut.com.');
insert into company_host (company_id, host)
values (52, 'localhost:8080')
@ -24,21 +24,6 @@ values (52, 42, 'employee')
, (52, 43, 'admin')
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (1, 52, 'Pagament', '')
;
insert into tax_class (tax_class_id, company_id, name)
values (1, 52, 'VAT')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (1, 52, 1, 'General VAT (21 %)', 0.21)
, (2, 52, 1, 'Reduced VAT (10 %)', 0.10)
, (3, 52, 1, 'Super-reduced VAT (4 %)', 0.04)
, (4, 52, 1, 'VAT free (0 %)', 0.00)
;
select setup_redsys(52, '361716962', '1', 'test', 'redirect', 'sq7HjrUOBfKmC576ILgskD5srU870gJ7');
select setup_location(52, '<div><h3>On som</h3><p>Ctra. de Sadernes, km 2, 17855 MONTAGUT i OIX</p></div>', '<iframe src="https://www.google.com/maps/embed?pb=!1m14!1m8!1m3!1d44661.89614700166!2d2.57381383167473!3d42.24000148364468!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x12bab79ff3b0f007%3A0x65b7563a5d1548e6!2sCamping%20Montagut!5e0!3m2!1sca!2sus!4v1703225042845!5m2!1sca!2sus" width="100%" height="600" style="border:0;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>', '<div><p><strong>Càmping i Safari tents:</strong><br />de 08/04 a 09/10</p><p><strong>Cabanes i Bungalows:</strong><br />de 08/04 a 11/12</p><p><strong>ACSI</strong>:<br />de 08/04 a 11/12</p></div>');
@ -1248,15 +1233,6 @@ select set_season_range(93, daterange(make_date(extract(year from current_date):
select set_season_range(94, daterange(make_date(extract(year from current_date)::int, 9, 24), make_date(extract(year from current_date)::int, 9, 29)));
select set_season_range(93, daterange(make_date(extract(year from current_date)::int, 9, 29), make_date(extract(year from current_date)::int, 10, 1)));
select set_season_range(94, daterange(make_date(extract(year from current_date)::int, 10, 1), make_date(extract(year from current_date)::int, 10, 13)));
select set_season_range(92, daterange(make_date(extract(year from current_date)::int + 1, 4, 11), make_date(extract(year from current_date)::int + 1, 4, 22)));
select set_season_range(94, daterange(make_date(extract(year from current_date)::int + 1, 4, 22), make_date(extract(year from current_date)::int + 1, 6, 20)));
select set_season_range(92, daterange(make_date(extract(year from current_date)::int + 1, 6, 20), make_date(extract(year from current_date)::int + 1, 6, 25)));
select set_season_range(93, daterange(make_date(extract(year from current_date)::int + 1, 6, 25), make_date(extract(year from current_date)::int + 1, 7, 4)));
select set_season_range(92, daterange(make_date(extract(year from current_date)::int + 1, 7, 4), make_date(extract(year from current_date)::int + 1, 8, 25)));
select set_season_range(93, daterange(make_date(extract(year from current_date)::int + 1, 8, 25), make_date(extract(year from current_date)::int + 1, 9, 1)));
select set_season_range(94, daterange(make_date(extract(year from current_date)::int + 1, 9, 1), make_date(extract(year from current_date)::int + 1, 9, 11)));
select set_season_range(92, daterange(make_date(extract(year from current_date)::int + 1, 9, 11), make_date(extract(year from current_date)::int + 1, 9, 15)));
select set_season_range(94, daterange(make_date(extract(year from current_date)::int + 1, 9, 15), make_date(extract(year from current_date)::int + 1, 12, 9)));
select set_campsite_type_cost (slug, 92, '4.00', '7.95', '7.95', '6.40') from campsite_type where campsite_type_id = 72;
select set_campsite_type_cost (slug, 93, '2.00', '7.40', '7.40', '5.90') from campsite_type where campsite_type_id = 72;
@ -1437,22 +1413,6 @@ values (72, 77, 'en', 'Legend')
, (76, 103, 'es', 'Leyenda')
;
insert into acsi (campsite_type_id, number_adults, number_teenagers, number_children, number_dogs, cost_per_night)
values (72, 2, 0, 0, 1, 2300);
insert into acsi_calendar (campsite_type_id, acsi_range)
values (72, daterange(make_date(extract(year from current_date)::int, 2, 4), make_date(extract(year from current_date)::int, 6, 20)))
, (72, daterange(make_date(extract(year from current_date)::int, 9, 1), make_date(extract(year from current_date)::int, 10, 13)))
;
insert into acsi_option (campsite_type_id, campsite_type_option_id, units, option_group)
values (72, 102, 1, 1)
, (72, 103, 1, 2)
, (72, 104, 1, 3)
, (72, 107, 1, 4)
, (72, 109, 1, 5)
;
alter table surroundings_highlight alter column surroundings_highlight_id restart with 112;
select add_surroundings_highlight(52, 62, 'El Pont del Llierca', '<p>Pont destil romànic i bany natural a 400 m del càmping.</p>');
@ -1511,34 +1471,19 @@ select translate_surroundings_ad(52, 'es', '¡Ven a hacer barranquismo en Sadern
select translate_surroundings_ad(52, 'fr', 'Venez faire du canyoning à Sadernes !', 'Réservez votre journée');
alter table booking alter column booking_id restart with 122;
insert into booking (company_id, campsite_type_id, holder_name, stay, zone_preferences, subtotal_nights, number_adults, subtotal_adults, number_teenagers, subtotal_teenagers, number_children, subtotal_children, number_dogs, subtotal_dogs, subtotal_tourist_tax, total, acsi_card, booking_status, currency_code)
values (52, 72, 'Juli Verd', daterange((current_date + interval '23 days')::date, (current_date + interval '25 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'created', 'EUR')
, (52, 72, 'Camèlia Vermella', daterange((current_date + interval '7 days')::date, (current_date + interval '8 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'created', 'EUR')
, (52, 72, 'Margarita Blanca', daterange((current_date + interval '7 days')::date, (current_date + interval '8 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'invoiced', 'EUR')
, (52, 72, 'Rosa Blava', daterange((current_date + interval '8 days')::date, (current_date + interval '11 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'checked-in', 'EUR')
, (52, 72, 'Calèndula Groga', daterange((current_date + interval '14 days')::date, (current_date + interval '21 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'confirmed', 'EUR')
, (52, 72, 'Jacint Violeta', daterange((current_date + interval '9 days')::date, (current_date + interval '13 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'checked-in', 'EUR')
, (52, 72, 'Hortènsia Grisa', daterange((current_date + interval '4 days')::date, (current_date + interval '8 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'invoiced', 'EUR')
, (52, 72, 'Pere Gil', daterange((current_date + interval '9 days')::date, (current_date + interval '19 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, true, 'confirmed', 'EUR')
, (52, 72, 'Juli Verd', daterange((current_date + interval '11 days')::date, (current_date + interval '13 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'confirmed', 'EUR')
, (52, 72, 'Camèlia Vermella', daterange((current_date + interval '13 days')::date, (current_date + interval '15 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'confirmed', 'EUR')
, (52, 72, 'Valeriana Rosa', daterange((current_date + interval '15 days')::date, (current_date + interval '17 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'confirmed', 'EUR')
, (52, 72, 'Pere Gil', daterange((current_date + interval '24 days')::date, (current_date + interval '25 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, true, 'cancelled', 'EUR')
, (52, 72, 'Valeriana Rosa', daterange((current_date + interval '3 days')::date, (current_date + interval '8 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, true, 'cancelled', 'EUR')
insert into booking (company_id, campsite_type_id, holder_name, arrival_date, departure_date, number_dogs, acsi_card, booking_status)
values (52, 72, 'Juli Verd', current_date + interval '23 days', current_date + interval '25 days', 0, false, 'created')
, (52, 72, 'Pere Gil', current_date + interval '24 days', current_date + interval '25 days', 1, true, 'cancelled')
, (52, 73, 'Calèndula Groga', current_date + interval '24 days', current_date + interval '25 days', 0, false, 'confirmed')
, (52, 73, 'Rosa Blava', current_date + interval '15 days', current_date + interval '22 days', 0, false, 'checked-in')
, (52, 74, 'Margarita Blanca', current_date + interval '7 days', current_date + interval '8 days', 0, false, 'invoiced')
, (52, 74, 'Camèlia Vermella', current_date + interval '7 days', current_date + interval '8 days', 0, false, 'created')
, (52, 74, 'Valeriana Rosa', current_date + interval '3 days', current_date + interval '8 days', 0, true, 'cancelled')
, (52, 75, 'Jacint Violeta', current_date + interval '30 days', current_date + interval '33 days', 0, false, 'checked-in')
, (52, 76, 'Hortènsia Grisa', current_date + interval '29 days', current_date + interval '34 days', 0, false, 'invoiced')
;
insert into booking_campsite (booking_id, campsite_id, stay)
values (124, 90, daterange((current_date + interval '7 days')::date, (current_date + interval '8 days')::date))
, (124, 94, daterange((current_date + interval '7 days')::date, (current_date + interval '8 days')::date))
, (125, 90, daterange((current_date + interval '8 days')::date, (current_date + interval '11 days')::date))
, (126, 90, daterange((current_date + interval '14 days')::date, (current_date + interval '21 days')::date))
, (127, 91, daterange((current_date + interval '9 days')::date, (current_date + interval '13 days')::date))
, (128, 92, daterange((current_date + interval '4 days')::date, (current_date + interval '8 days')::date))
, (129, 93, daterange((current_date + interval '9 days')::date, (current_date + interval '19 days')::date))
, (130, 94, daterange((current_date + interval '11 days')::date, (current_date + interval '13 days')::date))
, (131, 94, daterange((current_date + interval '13 days')::date, (current_date + interval '15 days')::date))
, (132, 94, daterange((current_date + interval '15 days')::date, (current_date + interval '17 days')::date))
;
alter table amenity alter column amenity_id restart with 132;
select add_amenity(52, 'camp-esport', 'Camp Esport', '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec scelerisque lorem vestibulum enim sollicitudin ornare. Aliquam egestas pretium porttitor. Donec iaculis tempus est, id lobortis risus semper vel. Maecenas ut imperdiet neque. Donec mattis purus felis, vitae interdum risus egestas pharetra. Vestibulum dui neque, condimentum ultrices erat sed, fringilla pharetra ante. Maecenas hendrerit neque mattis risus consectetur euismod. Cras urna metus, bibendum a neque sed, pharetra commodo magna.</p>', '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec scelerisque lorem vestibulum enim sollicitudin ornare. Aliquam egestas pretium porttitor. Donec iaculis tempus est, id lobortis risus semper vel. Maecenas ut imperdiet neque. Donec mattis purus felis, vitae interdum risus egestas pharetra. Vestibulum dui neque, condimentum ultrices erat sed, fringilla pharetra ante. Maecenas hendrerit neque mattis risus consectetur euismod. Cras urna metus, bibendum a neque sed, pharetra commodo magna.</p>');

View File

@ -1,23 +0,0 @@
-- Deploy camper:acsi to pg
-- requires: roles
-- requires: schema_camper
-- requires: campsite_type
begin;
set search_path to camper, public;
create table acsi (
campsite_type_id integer primary key references campsite_type,
number_adults positive_integer not null,
number_teenagers nonnegative_integer not null,
number_children nonnegative_integer not null,
number_dogs nonnegative_integer not null,
cost_per_night nonnegative_integer not null
);
grant select on table acsi to guest;
grant select on table acsi to employee;
grant select, insert, update, delete on table acsi to admin;
commit;

View File

@ -1,21 +0,0 @@
-- Deploy camper:acsi_calendar to pg
-- requires: roles
-- requires: schema_camper
-- requires: acsi
begin;
set search_path to camper, public;
create table acsi_calendar (
campsite_type_id integer not null references acsi,
acsi_range daterange not null,
primary key (campsite_type_id, acsi_range),
constraint disallow_acsi_overlap exclude using gist (campsite_type_id with =, acsi_range with &&)
);
grant select on table acsi_calendar to guest;
grant select on table acsi_calendar to employee;
grant select, insert, update, delete on table acsi_calendar to admin;
commit;

View File

@ -1,22 +0,0 @@
-- Deploy camper:acsi_option to pg
-- requires: roles
-- requires: schema_camper
-- requires: acsi
-- requires: campsite_type_option
begin;
set search_path to camper, public;
create table acsi_option (
campsite_type_id integer not null references acsi,
campsite_type_option_id integer not null references campsite_type_option,
units positive_integer not null,
primary key (campsite_type_id, campsite_type_option_id)
);
grant select on table acsi_option to guest;
grant select on table acsi_option to employee;
grant select, insert, update, delete on table acsi_option to admin;
commit;

View File

@ -1,21 +0,0 @@
-- Deploy camper:acsi_option__option_group to pg
-- requires: acsi_option
begin;
set search_path to camper, public;
alter table acsi_option
add column option_group integer not null default 0
;
alter table acsi_option
drop constraint if exists acsi_option_pkey
;
alter table acsi_option
add primary key (campsite_type_id, campsite_type_option_id, option_group)
, alter column option_group drop default
;
commit;

View File

@ -1,108 +0,0 @@
-- Deploy camper:add_booking_from_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking__payment_fields
-- requires: booking__stay
-- requires: booking_option
-- requires: payment
-- requires: payment__acsi_card
-- requires: payment_customer
-- requires: payment_option
begin;
set search_path to camper, public;
create or replace function add_booking_from_payment(payment_slug uuid) returns integer as
$$
declare
bid integer;
begin
insert into booking
( company_id
, campsite_type_id
, stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
, holder_name
, address
, postal_code
, city
, country_code
, email
, phone
, lang_tag
)
select company_id
, campsite_type_id
, daterange(arrival_date, departure_date)
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
, coalesce(full_name, 'Unknown')
, address
, postal_code
, city
, country_code
, email
, phone
, coalesce(lang_tag, 'und')
from payment
left join payment_customer using (payment_id)
where payment.slug = payment_slug
returning booking_id into bid;
if bid is null then
raise invalid_parameter_value using message = payment_slug || ' is not a valid payment.';
end if;
insert into booking_option
( booking_id
, campsite_type_option_id
, units
, subtotal
)
select bid
, campsite_type_option_id
, units
, subtotal
from payment_option
join payment using (payment_id)
where payment.slug = payment_slug
;
return bid;
end
$$
language plpgsql
;
revoke execute on function add_booking_from_payment(uuid) from public;
grant execute on function add_booking_from_payment(uuid) to employee;
grant execute on function add_booking_from_payment(uuid) to admin;
commit;

View File

@ -1,49 +0,0 @@
-- Deploy camper:add_contact to pg
-- requires: roles
-- requires: schema_camper
-- requires: email
-- requires: extension_pg_libphonenumber
-- requires: country_code
-- requires: contact
-- requires: contact_phone
-- requires: contact_email
begin;
set search_path to camper, public;
create or replace function add_contact(company_id integer, name text, id_document_type_id text, id_document_number text, phone text, email text, address text, city text, province text, postal_code text, country_code country_code) returns uuid as
$$
declare
cid integer;
cslug uuid;
begin
insert into contact (company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code)
values (company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code)
returning contact_id, slug
into cid, cslug
;
if phone is not null and trim(phone) <> '' then
insert into contact_phone (contact_id, phone)
values (cid, parse_packed_phone_number(add_contact.phone, coalesce(country_code, 'ES')))
;
end if;
if email is not null and trim(email) <> '' then
insert into contact_email (contact_id, email)
values (cid, add_contact.email)
;
end if;
return cslug;
end
$$
language plpgsql
;
revoke execute on function add_contact(integer, text, text, text, text, text, text, text, text, text, country_code) from public;
grant execute on function add_contact(integer, text, text, text, text, text, text, text, text, text, country_code) to employee;
grant execute on function add_contact(integer, text, text, text, text, text, text, text, text, text, country_code) to admin;
commit;

View File

@ -1,75 +0,0 @@
-- Deploy camper:add_invoice to pg
-- requires: roles
-- requires: schema_camper
-- requires: invoice
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: new_invoice_product
-- requires: tax
-- requires: invoice_product
-- requires: invoice_product_product
-- requires: invoice_product_tax
-- requires: next_invoice_number
begin;
set search_path to camper, public;
create or replace function add_invoice(company integer, invoice_date date, contact_id integer, notes text, payment_method_id integer, products new_invoice_product[]) returns uuid as
$$
declare
iid integer;
pslug uuid;
product new_invoice_product;
ccode text;
ipid integer;
begin
insert into invoice (company_id, invoice_number, invoice_date, contact_id, notes, currency_code, payment_method_id)
select company_id
, next_invoice_number(add_invoice.company, invoice_date)
, invoice_date
, contact_id
, notes
, currency_code
, add_invoice.payment_method_id
from company
where company.company_id = add_invoice.company
returning invoice_id, slug, currency_code
into iid, pslug, ccode;
foreach product in array products
loop
insert into invoice_product (invoice_id, name, description, price, quantity, discount_rate)
select iid
, product.name
, coalesce(product.description, '')
, parse_price(product.price, currency.decimal_digits)
, product.quantity
, product.discount_rate
from currency
where currency_code = ccode
returning invoice_product_id
into ipid;
if product.product_id is not null then
insert into invoice_product_product (invoice_product_id, product_id)
values (ipid, product.product_id);
end if;
insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate)
select ipid, tax_id, tax.rate
from tax
join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop;
return pslug;
end;
$$
language plpgsql;
revoke execute on function add_invoice(integer, date, integer, text, integer, new_invoice_product[]) from public;
grant execute on function add_invoice(integer, date, integer, text, integer, new_invoice_product[]) to employee;
grant execute on function add_invoice(integer, date, integer, text, integer, new_invoice_product[]) to admin;
commit;

View File

@ -1,39 +0,0 @@
-- Deploy camper:available_id_document_types to pg
-- requires: id_document_type
-- requires: id_document_type_i18n
begin;
set search_path to camper;
insert into id_document_type (id_document_type_id, name)
values ('D', 'DNI')
, ('P', 'Passport')
, ('C', 'Driving license')
, ('I', 'Identification document')
, ('N', 'Spanish residence permit')
, ('X', 'Residence permit from another Member State of the European Union')
;
insert into id_document_type_i18n (id_document_type_id, lang_tag, name)
values ('D', 'ca', 'DNI')
, ('P', 'ca', 'Passaport')
, ('C', 'ca', 'Permís de conduir')
, ('I', 'ca', 'Carta o document didentitat')
, ('N', 'ca', 'Permís de residència espanyol')
, ('X', 'ca', 'Permís de residència dun altre estat membre de la Unió Europea')
, ('D', 'es', 'DNI')
, ('P', 'es', 'Pasaporte')
, ('C', 'es', 'Permiso de conducir')
, ('I', 'es', 'Carta o documento de identidad')
, ('N', 'es', 'Permiso de residencia español')
, ('X', 'es', 'Permiso de residencia de otro Estado Miembro de la Unión Europea')
, ('D', 'fr', 'DNI')
, ('P', 'fr', 'Passeport')
, ('C', 'fr', 'Permis de conduire')
, ('I', 'fr', 'Carte didentité')
, ('N', 'fr', 'Permis de séjour espagnol')
, ('X', 'fr', 'Titre de séjour dun autre État membre de lUnion européenne')
;
commit;

View File

@ -1,28 +0,0 @@
-- Deploy camper:available_invoice_status to pg
-- requires: schema_camper
-- requires: invoice_status
-- requires: invoice_status_i18n
begin;
set search_path to camper;
insert into invoice_status (invoice_status, name)
values ('created', 'Created')
, ('sent', 'Sent')
, ('paid', 'Paid')
, ('unpaid', 'Unpaid')
;
insert into invoice_status_i18n (invoice_status, lang_tag, name)
values ('created', 'ca', 'Creada')
, ('sent', 'ca', 'Enviada')
, ('paid', 'ca', 'Cobrada')
, ('unpaid', 'ca', 'No cobrada')
, ('created', 'es', 'Creada')
, ('sent', 'es', 'Enviada')
, ('paid', 'es', 'Cobrada')
, ('unpaid', 'es', 'No cobrada')
;
commit;

View File

@ -1,23 +0,0 @@
-- Deploy camper:available_sexes to pg
-- requires: sex
-- requires: sex_i18n
begin;
set search_path to camper;
insert into sex (sex_id, name)
values ('F', 'Female')
, ('M', 'Male')
;
insert into sex_i18n (sex_id, lang_tag, name)
values ('F', 'ca', 'Femení')
, ('M', 'ca', 'Masculí')
, ('F', 'es', 'Femenino')
, ('M', 'es', 'Masculino')
, ('F', 'fr', 'Féminin')
, ('M', 'fr', 'Masculin')
;
commit;

View File

@ -1,48 +0,0 @@
-- Deploy camper:booking__payment_fields to pg
-- requires: booking
-- requires: positive_integer
-- requires: nonnegative_integer
begin;
set search_path to camper, public;
alter table booking
add column address text
, add column postal_code text
, add column city text
, add column country_code country_code references country
, add column email email
, add column phone packed_phone_number
, add lang_tag text not null default 'und' references language
, add zone_preferences text not null default ''
, add subtotal_nights nonnegative_integer not null default 0
, add number_adults positive_integer not null default 1
, add subtotal_adults nonnegative_integer not null default 0
, add number_teenagers nonnegative_integer not null default 0
, add subtotal_teenagers nonnegative_integer not null default 0
, add number_children nonnegative_integer not null default 0
, add subtotal_children nonnegative_integer not null default 0
, alter column number_dogs type nonnegative_integer
, add subtotal_dogs nonnegative_integer not null default 0
, add subtotal_tourist_tax nonnegative_integer not null default 0
, add total nonnegative_integer not null default 0
, add currency_code currency_code not null default 'EUR' references currency
;
alter table booking
alter column zone_preferences drop default
, alter column subtotal_nights drop default
, alter column number_adults drop default
, alter column subtotal_adults drop default
, alter column number_teenagers drop default
, alter column subtotal_teenagers drop default
, alter column number_children drop default
, alter column subtotal_children drop default
, alter column subtotal_dogs drop default
, alter column subtotal_tourist_tax drop default
, alter column total drop default
, alter column currency_code drop default
;
commit;

View File

@ -1,24 +0,0 @@
-- Deploy camper:booking__stay to pg
-- requires: booking
begin;
set search_path to camper, public;
alter table booking
add column stay daterange constraint stay_not_empty check (not isempty(stay))
;
update booking
set stay = daterange(arrival_date, departure_date)
;
alter table booking
drop column if exists arrival_date
, drop column if exists departure_date
, alter column stay set not null
;
create index stay_idx on booking using gist (stay);
commit;

View File

@ -1,25 +0,0 @@
-- Deploy camper:booking_campsite to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: campsite
-- requires: extension_btree_gist
begin;
set search_path to camper, public;
create table booking_campsite (
booking_id integer not null references booking,
campsite_id integer not null references campsite,
stay daterange not null,
primary key (booking_id, campsite_id, stay),
exclude using gist (campsite_id with =, stay with &&)
);
create index booking_campsite_stay_idx on booking_campsite using gist (stay);
grant select, insert, update, delete on table booking_campsite to employee;
grant select, insert, update, delete on table booking_campsite to admin;
commit;

View File

@ -1,35 +0,0 @@
-- Deploy camper:booking_guest to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: sex
-- requires: id_document_type
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
create table booking_guest (
booking_guest_id integer generated by default as identity primary key,
booking_id integer not null references booking,
id_document_type_id varchar(1) not null references id_document_type,
id_document_number text not null,
id_document_issue_date date,
given_name text not null,
first_surname text not null,
second_surname text not null,
sex_id varchar(1) not null references sex,
birthdate date not null,
country_code country_code not null references country,
phone packed_phone_number,
address text not null,
created_at timestamp with time zone not null default current_timestamp,
updated_at timestamp with time zone not null default current_timestamp,
unique (booking_id, id_document_type_id, id_document_number)
);
grant select, insert, update, delete on table booking_guest to employee;
grant select, insert, update, delete on table booking_guest to admin;
commit;

View File

@ -1,20 +0,0 @@
-- Deploy camper:booking_invoice to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: invoice
begin;
set search_path to camper, public;
create table booking_invoice (
booking_id integer not null references booking,
invoice_id integer not null references invoice,
primary key (booking_id, invoice_id)
);
grant select, insert, update, delete on table booking_invoice to employee;
grant select, insert, update, delete on table booking_invoice to admin;
commit;

View File

@ -1,24 +0,0 @@
-- Deploy camper:booking_option to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: campsite_type_option
-- requires: positive_integer
-- requires: nonnegative_integer
begin;
set search_path to camper, public;
create table booking_option (
booking_id integer not null references booking,
campsite_type_option_id integer not null references campsite_type_option,
units positive_integer not null,
subtotal nonnegative_integer not null,
primary key (booking_id, campsite_type_option_id)
);
grant select, insert, update, delete on table booking_option to employee;
grant select, insert, update, delete on table booking_option to admin;
commit;

View File

@ -1,12 +0,0 @@
-- Deploy camper:campsite_type__operating_dates to pg
-- requires: campsite_type
begin;
set search_path to camper, public;
alter table campsite_type
add column operating_dates daterange not null default 'empty'
;
commit;

View File

@ -1,23 +0,0 @@
-- Deploy camper:cancel_booking to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking_campsite
begin;
set search_path to camper, public;
create or replace function cancel_booking(bid integer) returns void as
$$
delete from booking_campsite where booking_id = bid;
update booking set booking_status = 'cancelled' where booking_id = bid;
$$
language sql
;
revoke execute on function cancel_booking(integer) from public;
grant execute on function cancel_booking(integer) to employee;
grant execute on function cancel_booking(integer) to admin;
commit;

View File

@ -1,75 +0,0 @@
-- Deploy camper:check_in_guests to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking_guest
-- requires: checked_in_guest
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
create or replace function check_in_guests(bid integer, guests checked_in_guest[]) returns void as
$$
insert into booking_guest
( booking_id
, id_document_type_id
, id_document_number
, id_document_issue_date
, given_name
, first_surname
, second_surname
, sex_id
, birthdate
, country_code
, phone
, address
)
select bid
, id_document_type_id
, id_document_number
, id_document_issue_date
, given_name
, first_surname
, second_surname
, sex_id
, birthdate
, country_code
, case when phone is null or phone = '' then null else parse_packed_phone_number(phone, country_code) end
, address
from unnest(guests) as guest
on conflict (booking_id, id_document_type_id, id_document_number) do update
set id_document_type_id = excluded.id_document_type_id
, id_document_number = excluded.id_document_number
, id_document_issue_date = excluded.id_document_issue_date
, given_name = excluded.given_name
, first_surname = excluded.first_surname
, second_surname = excluded.second_surname
, sex_id = excluded.sex_id
, birthdate = excluded.birthdate
, country_code = excluded.country_code
, phone = excluded.phone
, address = excluded.address
, updated_at = current_timestamp
;
delete from booking_guest
where booking_id = bid
and updated_at < current_timestamp
;
update booking
set booking_status = 'checked-in'
where booking_id = bid
and booking_status in ('created', 'confirmed')
;
$$
language sql
;
revoke execute on function check_in_guests(integer, checked_in_guest[]) from public;
grant execute on function check_in_guests(integer, checked_in_guest[]) to employee;
grant execute on function check_in_guests(integer, checked_in_guest[]) to admin;
commit;

View File

@ -1,22 +0,0 @@
-- Deploy camper:checked_in_guest to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create type checked_in_guest as (
id_document_type_id varchar(1),
id_document_number text,
id_document_issue_date date,
given_name text,
first_surname text,
second_surname text,
sex_id varchar(1),
birthdate date,
country_code country_code,
phone text,
address text
);
commit;

View File

@ -1,64 +0,0 @@
-- Deploy camper:compute_new_invoice_amount to pg
-- requires: schema_camper
-- requires: company
-- requires: currency
-- requires: tax
-- requires: new_invoice_product
-- requires: new_invoice_amount
begin;
set search_path to camper, public;
create or replace function compute_new_invoice_amount(company_id integer, products new_invoice_product[]) returns new_invoice_amount as
$$
declare
result new_invoice_amount;
begin
if array_length(products, 1) is null then
select to_price(0, decimal_digits), array[]::text[][], to_price(0, decimal_digits)
from company
join currency using (currency_code)
where company.company_id = compute_new_invoice_amount.company_id
into result.subtotal, result.taxes, result.total;
else
with product as (
select round(parse_price(price, currency.decimal_digits) * quantity * (1 - discount_rate))::integer as subtotal
, tax
, decimal_digits
from unnest(products)
join company on company.company_id = compute_new_invoice_amount.company_id
join currency using (currency_code)
)
, tax_amount as (
select tax_id
, sum(round(subtotal * tax.rate)::integer)::integer as amount
, decimal_digits
from product, unnest(product.tax) as product_tax(tax_id)
join tax using (tax_id)
group by tax_id, decimal_digits
)
, tax_total as (
select sum(amount)::integer as amount, array_agg(array[name, to_price(amount, decimal_digits)]) as taxes
from tax_amount
join tax using (tax_id)
)
select to_price(sum(subtotal)::integer, decimal_digits)
, coalesce(taxes, array[]::text[][])
, to_price(sum(subtotal)::integer + coalesce(tax_total.amount, 0), decimal_digits) as total
from product, tax_total
group by tax_total.amount, taxes, decimal_digits
into result.subtotal, result.taxes, result.total;
end if;
return result;
end
$$
language plpgsql
stable;
revoke execute on function compute_new_invoice_amount(integer, new_invoice_product[]) from public;
grant execute on function compute_new_invoice_amount(integer, new_invoice_product[]) to employee;
grant execute on function compute_new_invoice_amount(integer, new_invoice_product[]) to admin;
commit;

View File

@ -1,45 +0,0 @@
-- Deploy camper:contact to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
-- requires: id_document_type
-- requires: country_code
-- requires: country
begin;
set search_path to camper, public;
create table contact (
contact_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
name text not null constraint name_not_empty check(length(trim(name)) > 1),
id_document_type_id varchar(1) not null references id_document_type,
id_document_number text not null,
address text not null,
city text not null,
province text not null,
postal_code text not null,
country_code country_code not null references country,
created_at timestamptz not null default current_timestamp
);
grant select, insert, update, delete on table contact to employee;
grant select, insert, update, delete on table contact to admin;
alter table contact enable row level security;
create policy company_policy
on contact
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = contact.company_id
)
);
commit;

View File

@ -1,31 +0,0 @@
-- Deploy camper:contact_email to pg
-- requires: roles
-- requires: schema_camper
-- requires: email
-- requires: contact
begin;
set search_path to camper, public;
create table contact_email (
contact_id integer primary key references contact,
email email not null
);
grant select, insert, update, delete on table contact_email to employee;
grant select, insert, update, delete on table contact_email to admin;
alter table contact_email enable row level security;
create policy company_policy
on contact_email
using (
exists(
select 1
from contact
where contact.contact_id = contact_email.contact_id
)
);
commit;

View File

@ -1,30 +0,0 @@
-- Deploy camper:contact_phone to pg
-- requires: roles
-- requires: schema_camper
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
create table contact_phone (
contact_id integer primary key references contact,
phone packed_phone_number not null
);
grant select, insert, update, delete on table contact_phone to employee;
grant select, insert, update, delete on table contact_phone to admin;
alter table contact_phone enable row level security;
create policy company_policy
on contact_phone
using (
exists(
select 1
from contact
where contact.contact_id = contact_phone.contact_id
)
);
commit;

View File

@ -1,14 +0,0 @@
-- Deploy camper:discount_rate to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create domain discount_rate as numeric
check (VALUE >= 0 and VALUE <= 1);
comment on domain discount_rate is
'A rate for discount in the range [0, 1]';
commit;

View File

@ -11,16 +11,12 @@
-- requires: payment
-- requires: payment_option
-- requires: company__tourist_tax_max_days
-- requires: acsi
-- requires: acsi_calendar
-- requires: acsi_option
-- requires: acsi_option__option_group
begin;
set search_path to camper, public;
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, acsi_card boolean, options option_units[]) returns payment as
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, options option_units[]) returns payment as
$$
declare
p payment;
@ -48,28 +44,26 @@ begin
, currency_code
, down_payment_percent
, zone_preferences
, acsi_card
)
select coalesce(payment_slug, gen_random_uuid())
, company_id
, campsite_type.campsite_type_id
, campsite_type_id
, arrival_date
, departure_date
, sum(coalesce(acsi.cost_per_night, 0) + cost.cost_per_night * (ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer - case when acsi.cost_per_night is null then 0 else 1 end))::integer
, sum(cost.cost_per_night * ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer)::integer
, num_adults
, sum(cost_per_adult * greatest(0, num_adults - coalesce(acsi.number_adults, 0)))::integer
, sum(cost_per_adult * num_adults)::integer
, num_teenagers
, sum(cost_per_teenager * greatest(0, num_teenagers - coalesce(acsi.number_teenagers, 0)))::integer
, sum(cost_per_teenager * num_teenagers)::integer
, num_children
, sum(cost_per_child * greatest(0, num_children - coalesce(acsi.number_children, 0)))::integer
, sum(cost_per_child * num_children)::integer
, num_dogs
, sum(coalesce(pet.cost_per_night, 0) * greatest(0, num_dogs - coalesce(acsi.number_dogs, 0)))::integer
, sum(case when num_dogs > 0 then coalesce(pet.cost_per_night, 0) else 0 end)::integer
, sum(case when day_num <= tourist_tax_max_days then tourist_tax * num_adults else 0 end)::integer
, 0
, currency_code
, case when arrival_date - current_date >= 7 then 0.3 else 1.0 end
, coalesce(zone_preferences, '')
, acsi_card
from generate_series(arrival_date, departure_date - 1, interval '1 day') with ordinality as date(day, day_num)
left join season_calendar on season_range @> date.day::date
left join season using (season_id)
@ -77,13 +71,9 @@ begin
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join campsite_type_cost as cost using (campsite_type_id, season_id)
left join company using (company_id)
left join (acsi join acsi_calendar using (campsite_type_id)) as acsi
on acsi_card
and acsi.campsite_type_id = campsite_type.campsite_type_id
and date.day::date <@ acsi_range
where campsite_type.slug = campsite_type_slug
group by company_id
, campsite_type.campsite_type_id
, campsite_type_id
, currency_code
on conflict (slug) do update
set company_id = excluded.company_id
@ -104,7 +94,6 @@ begin
, currency_code = excluded.currency_code
, down_payment_percent = excluded.down_payment_percent
, zone_preferences = excluded.zone_preferences
, acsi_card = excluded.acsi_card
, updated_at = current_timestamp
returning *
into p
@ -119,26 +108,6 @@ begin
from unnest(options) as option(campsite_type_option_id, units)
);
with discountable_acsi_option as (
select distinct day, campsite_type_option_id, units
from (
select day, campsite_type_option_id, units, row_number() over (partition by day, option_group order by cost desc) as rn
from (
select day, campsite_type_option_id, units, cost, min(option_group) option_group from (
select day, campsite_type_option_id, acsi_option.units, cost, option_group, count(*) over (partition by day, option_group) > 1 as already_used
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
join acsi_calendar on acsi_card and day::date <@ acsi_range
join acsi_option as acsi_option using (campsite_type_option_id)
) as with_count
group by day, campsite_type_option_id, units, cost, already_used
) as by_group
) as discountable
where acsi_card
and rn = 1
)
insert into payment_option (
payment_id
, campsite_type_option_id
@ -146,17 +115,16 @@ begin
, subtotal
)
select p.payment_id
, campsite_type_option.campsite_type_option_id
, option.units
, case when per_night then sum(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer else max(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer end
, campsite_type_option_id
, units
, case when per_night then sum(cost * units)::integer else max(cost * units)::integer end
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join campsite_type_option using (campsite_type_option_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
left join discountable_acsi_option as acsi_option using (day, campsite_type_option_id)
group by campsite_type_option.campsite_type_option_id
, option.units
group by campsite_type_option_id
, units
, per_night
on conflict (payment_id, campsite_type_option_id) do update
set units = excluded.units
@ -193,9 +161,9 @@ $$
language plpgsql
;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to admin;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to admin;
commit;

View File

@ -1,169 +0,0 @@
-- Deploy camper:draft_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: season_calendar
-- requires: season
-- requires: campsite_type
-- requires: campsite_type_pet_cost
-- requires: campsite_type_cost
-- requires: campsite_type_option_cost
-- requires: campsite_type_option
-- requires: payment
-- requires: payment_option
-- requires: company__tourist_tax_max_days
begin;
set search_path to camper, public;
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, options option_units[]) returns payment as
$$
declare
p payment;
begin
if exists(select 1 from payment where slug = payment_slug and payment_status <> 'draft') then
payment_slug = null;
end if;
insert into payment (
slug
, company_id
, campsite_type_id
, arrival_date
, departure_date
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, down_payment_percent
, zone_preferences
)
select coalesce(payment_slug, gen_random_uuid())
, company_id
, campsite_type_id
, arrival_date
, departure_date
, sum(cost.cost_per_night * ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer)::integer
, num_adults
, sum(cost_per_adult * num_adults)::integer
, num_teenagers
, sum(cost_per_teenager * num_teenagers)::integer
, num_children
, sum(cost_per_child * num_children)::integer
, num_dogs
, sum(case when num_dogs > 0 then coalesce(pet.cost_per_night, 0) else 0 end)::integer
, sum(case when day_num <= tourist_tax_max_days then tourist_tax * num_adults else 0 end)::integer
, 0
, currency_code
, case when arrival_date - current_date >= 7 then 0.3 else 1.0 end
, coalesce(zone_preferences, '')
from generate_series(arrival_date, departure_date - 1, interval '1 day') with ordinality as date(day, day_num)
left join season_calendar on season_range @> date.day::date
left join season using (season_id)
left join campsite_type using (company_id)
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join campsite_type_cost as cost using (campsite_type_id, season_id)
left join company using (company_id)
where campsite_type.slug = campsite_type_slug
group by company_id
, campsite_type_id
, currency_code
on conflict (slug) do update
set company_id = excluded.company_id
, campsite_type_id = excluded.campsite_type_id
, arrival_date = excluded.arrival_date
, departure_date = excluded.departure_date
, subtotal_nights = excluded.subtotal_nights
, number_adults = excluded.number_adults
, subtotal_adults = excluded.subtotal_adults
, number_teenagers = excluded.number_teenagers
, subtotal_teenagers = excluded.subtotal_teenagers
, number_children = excluded.number_children
, subtotal_children = excluded.subtotal_children
, number_dogs = excluded.number_dogs
, subtotal_dogs = excluded.subtotal_dogs
, subtotal_tourist_tax = excluded.subtotal_tourist_tax
, total = excluded.total
, currency_code = excluded.currency_code
, down_payment_percent = excluded.down_payment_percent
, zone_preferences = excluded.zone_preferences
, updated_at = current_timestamp
returning *
into p
;
if array_length(coalesce(options, array[]::option_units[]), 1) > 0 then
delete
from payment_option
where payment_id = p.payment_id
and campsite_type_option_id not in (
select campsite_type_option_id
from unnest(options) as option(campsite_type_option_id, units)
);
insert into payment_option (
payment_id
, campsite_type_option_id
, units
, subtotal
)
select p.payment_id
, campsite_type_option_id
, units
, case when per_night then sum(cost * units)::integer else max(cost * units)::integer end
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join campsite_type_option using (campsite_type_option_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
group by campsite_type_option_id
, units
, per_night
on conflict (payment_id, campsite_type_option_id) do update
set units = excluded.units
, subtotal = excluded.subtotal
;
with option as (
select sum(subtotal)::integer as subtotal
from payment_option
where payment_id = p.payment_id
)
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax + coalesce(option.subtotal, 0)
from option
where payment_id = p.payment_id
returning total into p.total
;
else
delete
from payment_option
where payment_id = p.payment_id;
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax
where payment_id = p.payment_id
returning total into p.total
;
end if;
return p;
end;
$$
language plpgsql
;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to admin;
commit;

View File

@ -1,183 +0,0 @@
-- Deploy camper:draft_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: season_calendar
-- requires: season
-- requires: campsite_type
-- requires: campsite_type_pet_cost
-- requires: campsite_type_cost
-- requires: campsite_type_option_cost
-- requires: campsite_type_option
-- requires: payment
-- requires: payment_option
-- requires: company__tourist_tax_max_days
-- requires: acsi
-- requires: acsi_calendar
-- requires: acsi_options
begin;
set search_path to camper, public;
drop function if exists draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]);
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, acsi_card boolean, options option_units[]) returns payment as
$$
declare
p payment;
begin
if exists(select 1 from payment where slug = payment_slug and payment_status <> 'draft') then
payment_slug = null;
end if;
insert into payment (
slug
, company_id
, campsite_type_id
, arrival_date
, departure_date
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, down_payment_percent
, zone_preferences
, acsi_card
)
select coalesce(payment_slug, gen_random_uuid())
, company_id
, campsite_type.campsite_type_id
, arrival_date
, departure_date
, sum(coalesce(acsi.cost_per_night, 0) + cost.cost_per_night * (ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer - case when acsi.cost_per_night is null then 0 else 1 end))::integer
, num_adults
, sum(cost_per_adult * greatest(0, num_adults - coalesce(acsi.number_adults, 0)))::integer
, num_teenagers
, sum(cost_per_teenager * greatest(0, num_teenagers - coalesce(acsi.number_teenagers, 0)))::integer
, num_children
, sum(cost_per_child * greatest(0, num_children - coalesce(acsi.number_children, 0)))::integer
, num_dogs
, sum(case when (num_dogs - coalesce(acsi.number_dogs, 0)) > 0 then coalesce(pet.cost_per_night, 0) else 0 end)::integer
, sum(case when day_num <= tourist_tax_max_days then tourist_tax * num_adults else 0 end)::integer
, 0
, currency_code
, case when arrival_date - current_date >= 7 then 0.3 else 1.0 end
, coalesce(zone_preferences, '')
, acsi_card
from generate_series(arrival_date, departure_date - 1, interval '1 day') with ordinality as date(day, day_num)
left join season_calendar on season_range @> date.day::date
left join season using (season_id)
left join campsite_type using (company_id)
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join campsite_type_cost as cost using (campsite_type_id, season_id)
left join company using (company_id)
left join (acsi join acsi_calendar using (campsite_type_id)) as acsi
on acsi_card
and acsi.campsite_type_id = campsite_type.campsite_type_id
and date.day::date <@ acsi_range
where campsite_type.slug = campsite_type_slug
group by company_id
, campsite_type.campsite_type_id
, currency_code
on conflict (slug) do update
set company_id = excluded.company_id
, campsite_type_id = excluded.campsite_type_id
, arrival_date = excluded.arrival_date
, departure_date = excluded.departure_date
, subtotal_nights = excluded.subtotal_nights
, number_adults = excluded.number_adults
, subtotal_adults = excluded.subtotal_adults
, number_teenagers = excluded.number_teenagers
, subtotal_teenagers = excluded.subtotal_teenagers
, number_children = excluded.number_children
, subtotal_children = excluded.subtotal_children
, number_dogs = excluded.number_dogs
, subtotal_dogs = excluded.subtotal_dogs
, subtotal_tourist_tax = excluded.subtotal_tourist_tax
, total = excluded.total
, currency_code = excluded.currency_code
, down_payment_percent = excluded.down_payment_percent
, zone_preferences = excluded.zone_preferences
, acsi_card = excluded.acsi_card
, updated_at = current_timestamp
returning *
into p
;
if array_length(coalesce(options, array[]::option_units[]), 1) > 0 then
delete
from payment_option
where payment_id = p.payment_id
and campsite_type_option_id not in (
select campsite_type_option_id
from unnest(options) as option(campsite_type_option_id, units)
);
insert into payment_option (
payment_id
, campsite_type_option_id
, units
, subtotal
)
select p.payment_id
, campsite_type_option.campsite_type_option_id
, option.units
, case when per_night then sum(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer else max(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer end
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join campsite_type_option using (campsite_type_option_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
left join acsi_calendar on acsi_card and day::date <@ acsi_range
left join acsi_option on acsi_option.campsite_type_id = acsi_calendar.campsite_type_id and acsi_option.campsite_type_option_id = campsite_type_option.campsite_type_option_id
group by campsite_type_option.campsite_type_option_id
, option.units
, per_night
on conflict (payment_id, campsite_type_option_id) do update
set units = excluded.units
, subtotal = excluded.subtotal
;
with option as (
select sum(subtotal)::integer as subtotal
from payment_option
where payment_id = p.payment_id
)
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax + coalesce(option.subtotal, 0)
from option
where payment_id = p.payment_id
returning total into p.total
;
else
delete
from payment_option
where payment_id = p.payment_id;
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax
where payment_id = p.payment_id
returning total into p.total
;
end if;
return p;
end;
$$
language plpgsql
;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to admin;
commit;

View File

@ -1,194 +0,0 @@
-- Deploy camper:draft_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: season_calendar
-- requires: season
-- requires: campsite_type
-- requires: campsite_type_pet_cost
-- requires: campsite_type_cost
-- requires: campsite_type_option_cost
-- requires: campsite_type_option
-- requires: payment
-- requires: payment_option
-- requires: company__tourist_tax_max_days
-- requires: acsi
-- requires: acsi_calendar
-- requires: acsi_options
begin;
set search_path to camper, public;
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, acsi_card boolean, options option_units[]) returns payment as
$$
declare
p payment;
begin
if exists(select 1 from payment where slug = payment_slug and payment_status <> 'draft') then
payment_slug = null;
end if;
insert into payment (
slug
, company_id
, campsite_type_id
, arrival_date
, departure_date
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, down_payment_percent
, zone_preferences
, acsi_card
)
select coalesce(payment_slug, gen_random_uuid())
, company_id
, campsite_type.campsite_type_id
, arrival_date
, departure_date
, sum(coalesce(acsi.cost_per_night, 0) + cost.cost_per_night * (ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer - case when acsi.cost_per_night is null then 0 else 1 end))::integer
, num_adults
, sum(cost_per_adult * greatest(0, num_adults - coalesce(acsi.number_adults, 0)))::integer
, num_teenagers
, sum(cost_per_teenager * greatest(0, num_teenagers - coalesce(acsi.number_teenagers, 0)))::integer
, num_children
, sum(cost_per_child * greatest(0, num_children - coalesce(acsi.number_children, 0)))::integer
, num_dogs
, sum(coalesce(pet.cost_per_night, 0) * greatest(0, num_dogs - coalesce(acsi.number_dogs, 0)))::integer
, sum(case when day_num <= tourist_tax_max_days then tourist_tax * num_adults else 0 end)::integer
, 0
, currency_code
, case when arrival_date - current_date >= 7 then 0.3 else 1.0 end
, coalesce(zone_preferences, '')
, acsi_card
from generate_series(arrival_date, departure_date - 1, interval '1 day') with ordinality as date(day, day_num)
left join season_calendar on season_range @> date.day::date
left join season using (season_id)
left join campsite_type using (company_id)
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join campsite_type_cost as cost using (campsite_type_id, season_id)
left join company using (company_id)
left join (acsi join acsi_calendar using (campsite_type_id)) as acsi
on acsi_card
and acsi.campsite_type_id = campsite_type.campsite_type_id
and date.day::date <@ acsi_range
where campsite_type.slug = campsite_type_slug
group by company_id
, campsite_type.campsite_type_id
, currency_code
on conflict (slug) do update
set company_id = excluded.company_id
, campsite_type_id = excluded.campsite_type_id
, arrival_date = excluded.arrival_date
, departure_date = excluded.departure_date
, subtotal_nights = excluded.subtotal_nights
, number_adults = excluded.number_adults
, subtotal_adults = excluded.subtotal_adults
, number_teenagers = excluded.number_teenagers
, subtotal_teenagers = excluded.subtotal_teenagers
, number_children = excluded.number_children
, subtotal_children = excluded.subtotal_children
, number_dogs = excluded.number_dogs
, subtotal_dogs = excluded.subtotal_dogs
, subtotal_tourist_tax = excluded.subtotal_tourist_tax
, total = excluded.total
, currency_code = excluded.currency_code
, down_payment_percent = excluded.down_payment_percent
, zone_preferences = excluded.zone_preferences
, acsi_card = excluded.acsi_card
, updated_at = current_timestamp
returning *
into p
;
if array_length(coalesce(options, array[]::option_units[]), 1) > 0 then
delete
from payment_option
where payment_id = p.payment_id
and campsite_type_option_id not in (
select campsite_type_option_id
from unnest(options) as option(campsite_type_option_id, units)
);
with discountable_acsi_option as (
select day, campsite_type_option_id, units
from (
select day, campsite_type_option_id, acsi_option.units, row_number() over (partition by day order by cost desc) as rn
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
join acsi_calendar on acsi_card and day::date <@ acsi_range
join acsi_option as acsi_option using (campsite_type_option_id)
) as discountable
where acsi_card
and rn = 1
)
insert into payment_option (
payment_id
, campsite_type_option_id
, units
, subtotal
)
select p.payment_id
, campsite_type_option.campsite_type_option_id
, option.units
, case when per_night then sum(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer else max(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer end
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join campsite_type_option using (campsite_type_option_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
left join discountable_acsi_option as acsi_option using (day, campsite_type_option_id)
group by campsite_type_option.campsite_type_option_id
, option.units
, per_night
on conflict (payment_id, campsite_type_option_id) do update
set units = excluded.units
, subtotal = excluded.subtotal
;
with option as (
select sum(subtotal)::integer as subtotal
from payment_option
where payment_id = p.payment_id
)
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax + coalesce(option.subtotal, 0)
from option
where payment_id = p.payment_id
returning total into p.total
;
else
delete
from payment_option
where payment_id = p.payment_id;
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax
where payment_id = p.payment_id
returning total into p.total
;
end if;
return p;
end;
$$
language plpgsql
;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to admin;
commit;

View File

@ -1,48 +0,0 @@
-- Deploy camper:edit_booking to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking__payment_fields
-- requires: booking__stay
-- requires: booking_campsite
begin;
set search_path to camper, public;
create or replace function edit_booking(bid integer, customer_name text, customer_address text, customer_post_code text, customer_city text, customer_country_code text, customer_email email, customer_phone text, customer_lang_tag text, booking_status text, campsite_ids integer[]) returns void as
$$
begin
update booking
set holder_name = customer_name
, address = customer_address
, postal_code = customer_post_code
, city = customer_city
, country_code = customer_country_code
, email = customer_email
, phone = case when customer_phone is null then null else parse_packed_phone_number(customer_phone, coalesce(customer_country_code, 'ES')) end
, lang_tag = coalesce(customer_lang_tag, 'und')
, booking_status = edit_booking.booking_status
where booking_id = bid
;
delete from booking_campsite
where booking_id = bid;
insert into booking_campsite
select bid
, campsite_id
, stay
from booking, unnest(campsite_ids) as campsite(campsite_id)
where booking_id = bid
;
end
$$
language plpgsql
;
revoke execute on function edit_booking(integer, text, text, text, text, text, email, text, text, text, integer[]) from public;
grant execute on function edit_booking(integer, text, text, text, text, text, email, text, text, text, integer[]) to employee;
grant execute on function edit_booking(integer, text, text, text, text, text, email, text, text, text, integer[]) to admin;
commit;

View File

@ -1,92 +0,0 @@
-- Deploy camper:edit_booking_from_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking__payment_fields
-- requires: booking__stay
-- requires: booking_option
-- requires: payment
-- requires: payment__acsi_card
-- requires: payment_option
begin;
set search_path to camper, public;
create or replace function edit_booking_from_payment(booking_slug uuid, payment_slug uuid) returns integer as
$$
declare
bid integer;
begin
with p as (
select company_id
, campsite_type_id
, daterange(arrival_date, departure_date) as stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
from payment
where payment.slug = payment_slug
)
update booking
set company_id = p.company_id
, campsite_type_id = p.campsite_type_id
, stay = p.stay
, subtotal_nights = p.subtotal_nights
, number_adults = p.number_adults
, subtotal_adults = p.subtotal_adults
, number_teenagers = p.number_teenagers
, subtotal_teenagers = p.subtotal_teenagers
, number_children = p.number_children
, subtotal_children = p.subtotal_children
, number_dogs = p.number_dogs
, subtotal_dogs = p.subtotal_dogs
, subtotal_tourist_tax = p.subtotal_tourist_tax
, total = p.total
, currency_code = p.currency_code
, zone_preferences = p.zone_preferences
, acsi_card = p.acsi_card
from p
where slug = booking_slug
returning booking_id into bid;
delete from booking_option
where booking_id = bid;
insert into booking_option
( booking_id
, campsite_type_option_id
, units
, subtotal
)
select bid
, campsite_type_option_id
, units
, subtotal
from payment_option
join payment using (payment_id)
where payment.slug = payment_slug
;
return bid;
end;
$$
language plpgsql
;
revoke execute on function edit_booking_from_payment(uuid, uuid) from public;
grant execute on function edit_booking_from_payment(uuid, uuid) to employee;
grant execute on function edit_booking_from_payment(uuid, uuid) to admin;
commit;

View File

@ -1,72 +0,0 @@
-- Deploy camper:edit_contact to pg
-- requires: roles
-- requires: schema_camper
-- requires: email
-- requires: country_code
-- requires: contact
-- requires: extension_pg_libphonenumber
-- requires: contact_phone
-- requires: contact_email
begin;
set search_path to camper, public;
create or replace function edit_contact(contact_slug uuid, name text, id_document_type_id text, id_document_number text, phone text, email text, address text, city text, province text, postal_code text, country_code country_code) returns uuid as
$$
declare
cid integer;
begin
update contact
set name = edit_contact.name
, id_document_type_id = edit_contact.id_document_type_id
, id_document_number = edit_contact.id_document_number
, address = edit_contact.address
, city = edit_contact.city
, province = edit_contact.province
, postal_code = edit_contact.postal_code
, country_code = edit_contact.country_code
where slug = contact_slug
returning contact_id
into cid
;
if cid is null then
return null;
end if;
if phone is null or trim(phone) = '' then
delete from contact_phone
where contact_id = cid
;
else
insert into contact_phone (contact_id, phone)
values (cid, parse_packed_phone_number(phone, coalesce(country_code, 'ES')))
on conflict (contact_id) do update
set phone = excluded.phone
;
end if;
if email is null or trim(email) = '' then
delete from contact_email
where contact_id = cid
;
else
insert into contact_email (contact_id, email)
values (cid, email)
on conflict (contact_id) do update
set email = excluded.email
;
end if;
return contact_slug;
end
$$
language plpgsql
;
revoke execute on function edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code) from public;
grant execute on function edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code) to employee;
grant execute on function edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code) to admin;
commit;

View File

@ -1,110 +0,0 @@
-- Deploy camper:edit_invoice to pg
-- requires: roles
-- requires: schema_camper
-- requires: invoice
-- requires: currency
-- requires: parse_price
-- requires: edited_invoice_product
-- requires: tax
-- requires: invoice_product
-- requires: invoice_product_product
-- requires: invoice_product_tax
begin;
set search_path to camper, public;
create or replace function edit_invoice(invoice_slug uuid, invoice_status text, contact_id integer, notes text, payment_method_id integer, products edited_invoice_product[]) returns uuid as
$$
declare
iid integer;
products_to_keep integer[];
products_to_delete integer[];
company integer;
ccode text;
product edited_invoice_product;
ipid integer;
begin
update invoice
set contact_id = edit_invoice.contact_id
, invoice_status = edit_invoice.invoice_status
, notes = edit_invoice.notes
, payment_method_id = edit_invoice.payment_method_id
where slug = invoice_slug
returning invoice_id, company_id, currency_code
into iid, company, ccode
;
if iid is null then
return null;
end if;
foreach product in array products
loop
if product.invoice_product_id is null then
insert into invoice_product (invoice_id, name, description, price, quantity, discount_rate)
select iid
, product.name
, coalesce(product.description, '')
, parse_price(product.price, currency.decimal_digits)
, product.quantity
, product.discount_rate
from currency
where currency_code = ccode
returning invoice_product_id
into ipid;
else
ipid := product.invoice_product_id;
update invoice_product
set name = product.name
, description = coalesce(product.description, '')
, price = parse_price(product.price, currency.decimal_digits)
, quantity = product.quantity
, discount_rate = product.discount_rate
from currency
where invoice_product_id = ipid
and currency_code = ccode;
end if;
products_to_keep := array_append(products_to_keep, ipid);
if product.product_id is null then
delete from invoice_product_product where invoice_product_id = ipid;
else
insert into invoice_product_product (invoice_product_id, product_id)
values (ipid, product.product_id)
on conflict (invoice_product_id) do update
set product_id = product.product_id;
end if;
delete from invoice_product_tax where invoice_product_id = ipid;
insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate)
select ipid, tax_id, tax.rate
from tax
join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop;
select array_agg(invoice_product_id)
into products_to_delete
from invoice_product
where invoice_id = iid
and not (invoice_product_id = any(products_to_keep));
if array_length(products_to_delete, 1) > 0 then
delete from invoice_product_tax where invoice_product_id = any(products_to_delete);
delete from invoice_product_product where invoice_product_id = any(products_to_delete);
delete from invoice_product where invoice_product_id = any(products_to_delete);
end if;
return invoice_slug;
end;
$$
language plpgsql;
revoke execute on function edit_invoice(uuid, text, integer, text, integer, edited_invoice_product[]) from public;
grant execute on function edit_invoice(uuid, text, integer, text, integer, edited_invoice_product[]) to employee;
grant execute on function edit_invoice(uuid, text, integer, text, integer, edited_invoice_product[]) to admin;
commit;

View File

@ -1,20 +0,0 @@
-- Deploy camper:edited_invoice_product to pg
-- requires: schema_camper
-- requires: discount_rate
begin;
set search_path to camper, public;
create type edited_invoice_product as
( invoice_product_id integer
, product_id integer
, name text
, description text
, price text
, quantity integer
, discount_rate discount_rate
, tax integer[]
);
commit;

View File

@ -1,17 +0,0 @@
-- Deploy camper:id_document_type to pg
-- requires: roles
-- requires: schema_camper
begin;
set search_path to camper, public;
create table id_document_type (
id_document_type_id varchar(1) primary key,
name text not null
);
grant select on table id_document_type to employee;
grant select on table id_document_type to admin;
commit;

View File

@ -1,21 +0,0 @@
-- Deploy camper:id_document_type_i18n to pg
-- requires: roles
-- requires: schema_camper
-- requires: id_document_type
-- requires: language
begin;
set search_path to camper, public;
create table id_document_type_i18n (
id_document_type_id varchar(1) not null references id_document_type,
lang_tag text not null references language,
name text not null,
primary key (id_document_type_id, lang_tag)
);
grant select on table id_document_type_i18n to employee;
grant select on table id_document_type_i18n to admin;
commit;

View File

@ -1,44 +0,0 @@
-- Deploy camper:invoice to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
-- requires: contact
-- requires: invoice_status
-- requires: currency
begin;
set search_path to camper, public;
create table invoice (
invoice_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
invoice_number text not null constraint invoice_number_not_empty check(length(trim(invoice_number)) > 1),
invoice_date date not null default current_date,
contact_id integer not null references contact,
invoice_status text not null default 'created' references invoice_status,
notes text not null default '',
payment_method_id integer not null references payment_method,
currency_code text not null references currency,
created_at timestamptz not null default current_timestamp
);
grant select, insert, update, delete on table invoice to employee;
grant select, insert, update, delete on table invoice to admin;
alter table invoice enable row level security;
create policy company_policy
on invoice
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = invoice.company_id
)
);
commit;

View File

@ -1,22 +0,0 @@
-- Deploy camper:invoice_amount to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: invoice_product_amount
begin;
set search_path to camper, public;
create or replace view invoice_amount as
select invoice_id
, sum(subtotal)::integer as subtotal
, sum(total)::integer as total
from invoice_product
join invoice_product_amount using (invoice_product_id)
group by invoice_id
;
grant select on table invoice_amount to employee;
grant select on table invoice_amount to admin;
commit;

View File

@ -1,32 +0,0 @@
-- Deploy camper:invoice_number_counter to pg
-- requires: schema_camper
-- requires: company
begin;
set search_path to camper, public;
create table invoice_number_counter (
company_id integer not null references company,
year integer not null constraint year_always_positive check(year > 0),
currval integer not null constraint counter_zero_or_positive check(currval >= 0),
primary key (company_id, year)
);
grant select, insert, update on table invoice_number_counter to employee;
grant select, insert, update on table invoice_number_counter to admin;
alter table invoice_number_counter enable row level security;
create policy company_policy
on invoice_number_counter
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = invoice_number_counter.company_id
)
);
commit;

View File

@ -1,38 +0,0 @@
-- Deploy camper:invoice_product to pg
-- requires: schema_camper
-- requires: invoice
-- requires: discount_rate
begin;
set search_path to camper, public;
create table invoice_product (
invoice_product_id integer generated by default as identity primary key,
invoice_id integer not null references invoice,
name text not null constraint name_not_empty check(length(trim(name)) > 0),
description text not null default '',
price integer not null,
quantity integer not null default 1,
discount_rate discount_rate not null default 0.0
);
grant select, insert, update, delete on table invoice_product to employee;
grant select, insert, update, delete on table invoice_product to admin;
grant usage on sequence invoice_product_invoice_product_id_seq to employee;
grant usage on sequence invoice_product_invoice_product_id_seq to admin;
alter table invoice_product enable row level security;
create policy company_policy
on invoice_product
using (
exists(
select 1
from invoice
where invoice.invoice_id = invoice_product.invoice_id
)
);
commit;

View File

@ -1,22 +0,0 @@
-- Deploy camper:invoice_product_amount to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: invoice_product_tax
begin;
set search_path to camper, public;
create or replace view invoice_product_amount as
select invoice_product_id
, round(price * quantity * (1 - discount_rate))::integer as subtotal
, max(round(price * quantity * (1 - discount_rate))::integer) + coalesce(sum(round(round(price * quantity * (1 - discount_rate))::integer * tax_rate)::integer)::integer, 0) as total
from invoice_product
left join invoice_product_tax using (invoice_product_id)
group by invoice_product_id, price, quantity, discount_rate
;
grant select on table invoice_product_amount to employee;
grant select on table invoice_product_amount to admin;
commit;

View File

@ -1,18 +0,0 @@
-- Deploy camper:invoice_product_product to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: product
begin;
set search_path to camper;
create table invoice_product_product (
invoice_product_id integer primary key references invoice_product,
product_id integer not null references product
);
grant select, insert, update, delete on table invoice_product_product to employee;
grant select, insert, update, delete on table invoice_product_product to admin;
commit;

View File

@ -1,33 +0,0 @@
-- Deploy camper:invoice_product_tax to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: tax
-- requires: tax_rate
begin;
set search_path to camper, public;
create table invoice_product_tax (
invoice_product_id integer not null references invoice_product,
tax_id integer not null references tax,
tax_rate tax_rate not null,
primary key (invoice_product_id, tax_id)
);
grant select, insert, update, delete on table invoice_product_tax to employee;
grant select, insert, update, delete on table invoice_product_tax to admin;
alter table invoice_product_tax enable row level security;
create policy company_policy
on invoice_product_tax
using (
exists(
select 1
from invoice_product
where invoice_product.invoice_product_id = invoice_product_tax.invoice_product_id
)
);
commit;

View File

@ -1,16 +0,0 @@
-- Deploy camper:invoice_status to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create table invoice_status (
invoice_status text primary key,
name text not null
);
grant select on table invoice_status to employee;
grant select on table invoice_status to admin;
commit;

View File

@ -1,20 +0,0 @@
-- Deploy camper:invoice_status_i18n to pg
-- requires: schema_camper
-- requires: invoice_status
-- requires: language
begin;
set search_path to camper, public;
create table invoice_status_i18n (
invoice_status text not null references invoice_status,
lang_tag text not null references language,
name text not null,
primary key (invoice_status, lang_tag)
);
grant select on table invoice_status_i18n to employee;
grant select on table invoice_status_i18n to admin;
commit;

View File

@ -1,23 +0,0 @@
-- Deploy camper:invoice_tax_amount to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: invoice_product_tax
begin;
set search_path to camper, public;
create or replace view invoice_tax_amount as
select invoice_id
, tax_id
, sum(round(round(price * quantity * (1 - discount_rate))::integer * tax_rate)::integer)::integer as amount
from invoice_product
join invoice_product_tax using (invoice_product_id)
group by invoice_id
, tax_id
;
grant select on table invoice_tax_amount to employee;
grant select on table invoice_tax_amount to admin;
commit;

View File

@ -1,67 +0,0 @@
-- Deploy camper:marshal_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: payment
-- requires: payment_customer
-- requires: payment_option
-- requires: payment__acsi_card
-- requires: payment_customer__-acsi_card
begin;
set search_path to camper, public;
create or replace function marshal_payment(pid integer) returns jsonb as
$$
select to_jsonb(ctx)
from (
select company_id
, campsite_type_id
, arrival_date
, departure_date
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
, full_name
, address
, postal_code
, city
, country_code
, email
, phone
, lang_tag
, (
select array_agg(to_jsonb(o))
from (
select campsite_type_option_id
, units
, subtotal
from payment_option
where payment_option.payment_id = payment.payment_id
) o
) as options
from payment
join payment_customer using (payment_id)
where payment_id = pid
) as ctx;
$$
language sql
;
revoke execute on function marshal_payment(integer) from public;
grant execute on function marshal_payment(integer) to guest;
grant execute on function marshal_payment(integer) to employee;
grant execute on function marshal_payment(integer) to admin;
commit;

View File

@ -1,14 +0,0 @@
-- Deploy camper:new_invoice_amount to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create type new_invoice_amount as (
subtotal text,
taxes text[][],
total text
);
commit;

View File

@ -1,19 +0,0 @@
-- Deploy camper:new_invoice_product to pg
-- requires: schema_camper
-- requires: discount_rate
begin;
set search_path to camper, public;
create type new_invoice_product as (
product_id integer,
name text,
description text,
price text,
quantity integer,
discount_rate discount_rate,
tax integer[]
);
commit;

View File

@ -1,38 +0,0 @@
-- Deploy camper:next_invoice_number to pg
-- requires: schema_camper
-- requires: invoice_number_counter
begin;
set search_path to camper, public;
create or replace function next_invoice_number(company integer, invoice_date date) returns text
as
$$
declare
num integer;
invoice_number text;
begin
insert into invoice_number_counter (company_id, year, currval)
values (next_invoice_number.company, date_part('year', invoice_date), 1)
on conflict (company_id, year) do
update
set currval = invoice_number_counter.currval + 1
returning currval
into num;
select to_char(invoice_date, to_char(num, 'FM' || replace(invoice_number_format, '"', '\""')))
into invoice_number
from company
where company_id = next_invoice_number.company;
return invoice_number;
end;
$$
language plpgsql;
revoke execute on function next_invoice_number(integer, date) from public;
grant execute on function next_invoice_number(integer, date) to employee;
grant execute on function next_invoice_number(integer, date) to admin;
commit;

View File

@ -1,22 +0,0 @@
-- Deploy camper:payment__acsi_card to pg
-- requires: payment
begin;
set search_path to camper, public;
alter table payment
add column acsi_card boolean not null default false
;
update payment
set acsi_card = payment_customer.acsi_card
from payment_customer
where payment_customer.payment_id = payment.payment_id
;
alter table payment
alter column acsi_card drop default
;
commit;

View File

@ -1,10 +0,0 @@
-- Deploy camper:payment_customer__-acsi_card to pg
-- requires: payment__acsi_card
begin;
alter table camper.payment_customer
drop column if exists acsi_card
;
commit;

View File

@ -1,34 +0,0 @@
-- Deploy camper:payment_method to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
begin;
set search_path to camper, public;
create table payment_method (
payment_method_id integer generated by default as identity primary key,
company_id integer not null references company,
name text not null constraint name_not_empty check(length(trim(name)) > 0),
instructions text not null
);
grant select, insert, update, delete on table payment_method to employee;
grant select, insert, update, delete on table payment_method to admin;
alter table payment_method enable row level security;
create policy company_policy
on payment_method
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = payment_method.company_id
)
);
commit;

View File

@ -1,40 +0,0 @@
-- Deploy camper:product to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
begin;
set search_path to camper, public;
create table product (
product_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null default gen_random_uuid(),
name text not null constraint name_not_empty check(length(trim(name)) > 0),
description text not null default '',
price integer not null,
created_at timestamptz not null default current_timestamp
);
comment on column product.price is
'Price is stored in cents.';
grant select, insert, update, delete on table product to employee;
grant select, insert, update, delete on table product to admin;
alter table product enable row level security;
create policy company_policy
on product
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = product.company_id
)
);
commit;

View File

@ -1,31 +0,0 @@
-- Deploy camper:product_tax to pg
-- requires: schema_camper
-- requires: product
-- requires: tax
begin;
set search_path to camper, public;
create table product_tax (
product_id integer not null references product,
tax_id integer not null references tax,
primary key (product_id, tax_id)
);
grant select, insert, update, delete on table product_tax to employee;
grant select, insert, update, delete on table product_tax to admin;
alter table product_tax enable row level security;
create policy company_policy
on product_tax
using (
exists(
select 1
from product
where product.product_id = product_tax.product_id
)
);
commit;

View File

@ -6,16 +6,12 @@
-- requires: country_code
-- requires: email
-- requires: extension_pg_libphonenumber
-- requires: payment__acsi_card
-- requires: payment_customer__-acsi_card
begin;
set search_path to camper, public;
drop function if exists ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean);
create or replace function ready_payment(payment_slug uuid, customer_name text, customer_address text, customer_post_code text, customer_city text, customer_country_code country_code, customer_email email, customer_phone text, customer_lang_tag text) returns integer as
create or replace function ready_payment(payment_slug uuid, customer_name text, customer_address text, customer_post_code text, customer_city text, customer_country_code country_code, customer_email email, customer_phone text, customer_lang_tag text, customer_acsi_card boolean) returns integer as
$$
declare
pid integer;
@ -32,8 +28,8 @@ begin
raise check_violation using message = 'insert or update on table "payment" violates check constraint "payment_is_draft"';
end if;
insert into payment_customer (payment_id, full_name, address, postal_code, city, country_code, email, phone, lang_tag)
values (pid, customer_name, customer_address, customer_post_code, customer_city, customer_country_code, customer_email, parse_packed_phone_number(customer_phone, customer_country_code), customer_lang_tag)
insert into payment_customer (payment_id, full_name, address, postal_code, city, country_code, email, phone, acsi_card, lang_tag)
values (pid, customer_name, customer_address, customer_post_code, customer_city, customer_country_code, customer_email, parse_packed_phone_number(customer_phone, customer_country_code), customer_acsi_card, customer_lang_tag)
on conflict (payment_id) do update
set full_name = excluded.full_name
, address = excluded.address
@ -42,6 +38,7 @@ begin
, country_code = excluded.country_code
, email = excluded.email
, phone = excluded.phone
, acsi_card = excluded.acsi_card
, lang_tag = excluded.lang_tag
;
@ -51,9 +48,9 @@ $$
language plpgsql
;
revoke execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text) from public;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text) to guest;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text) to employee;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text) to admin;
revoke execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) from public;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to guest;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to employee;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to admin;
commit;

View File

@ -1,56 +0,0 @@
-- Deploy camper:ready_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: payment
-- requires: payment_customer
-- requires: country_code
-- requires: email
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
create or replace function ready_payment(payment_slug uuid, customer_name text, customer_address text, customer_post_code text, customer_city text, customer_country_code country_code, customer_email email, customer_phone text, customer_lang_tag text, customer_acsi_card boolean) returns integer as
$$
declare
pid integer;
begin
update payment
set payment_status = 'pending'
, updated_at = current_timestamp
where slug = payment_slug
and payment_status = 'draft'
returning payment_id into pid
;
if pid is null then
raise check_violation using message = 'insert or update on table "payment" violates check constraint "payment_is_draft"';
end if;
insert into payment_customer (payment_id, full_name, address, postal_code, city, country_code, email, phone, acsi_card, lang_tag)
values (pid, customer_name, customer_address, customer_post_code, customer_city, customer_country_code, customer_email, parse_packed_phone_number(customer_phone, customer_country_code), customer_acsi_card, customer_lang_tag)
on conflict (payment_id) do update
set full_name = excluded.full_name
, address = excluded.address
, postal_code = excluded.postal_code
, city = excluded.city
, country_code = excluded.country_code
, email = excluded.email
, phone = excluded.phone
, acsi_card = excluded.acsi_card
, lang_tag = excluded.lang_tag
;
return pid;
end;
$$
language plpgsql
;
revoke execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) from public;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to guest;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to employee;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to admin;
commit;

View File

@ -1,11 +0,0 @@
-- Deploy camper:season_calendar_season_id_fkey to pg
-- requires: season
-- requires: season_calendar
begin;
alter table camper.season_calendar
add constraint season_calendar_season_id_fkey foreign key (season_id) references camper.season (season_id)
;
commit;

View File

@ -1,17 +0,0 @@
-- Deploy camper:sex to pg
-- requires: roles
-- requires: schema_camper
begin;
set search_path to camper, public;
create table sex (
sex_id varchar(1) primary key,
name text not null
);
grant select on table sex to employee;
grant select on table sex to admin;
commit;

View File

@ -1,21 +0,0 @@
-- Deploy camper:sex_i18n to pg
-- requires: roles
-- requires: schema_camper
-- requires: sex
-- requires: language
begin;
set search_path to camper, public;
create table sex_i18n (
sex_id varchar(1) not null references sex,
lang_tag text not null references language,
name text not null,
primary key (sex_id, lang_tag)
);
grant select on sex_i18n to employee;
grant select on sex_i18n to admin;
commit;

View File

@ -1,37 +0,0 @@
-- Deploy camper:tax to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
-- requires: tax_rate
-- requires: tax_class
begin;
set search_path to camper, public;
create table tax (
tax_id integer generated by default as identity primary key,
company_id integer not null references company,
tax_class_id integer not null references tax_class,
name text not null constraint name_not_empty check(length(trim(name)) > 0),
rate tax_rate not null
);
grant select, insert, update, delete on table tax to employee;
grant select, insert, update, delete on table tax to admin;
alter table tax enable row level security;
create policy company_policy
on tax
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = tax.company_id
)
);
commit;

View File

@ -1,33 +0,0 @@
-- Deploy camper:tax_class to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
begin;
set search_path to camper, public;
create table tax_class (
tax_class_id integer generated by default as identity not null primary key,
company_id integer not null references company,
name text not null constraint name_not_empty check(length(trim(name)) > 0)
);
grant select, insert, update, delete on table tax_class to employee;
grant select, insert, update, delete on table tax_class to admin;
alter table tax_class enable row level security;
create policy company_policy
on tax_class
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = tax_class.company_id
)
);
commit;

View File

@ -1,14 +0,0 @@
-- Deploy camper:tax_rate to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create domain tax_rate as numeric
check (value > -1 and value < 1);
comment on domain tax_rate is
'A rate for taxes in the range (-1, 1)';
commit;

View File

@ -1,100 +0,0 @@
-- Deploy camper:unmarshal_booking to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking_option
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
grant select, insert on table booking to guest;
grant select, insert on table booking_option to guest;
drop policy if exists delete_from_company on booking;
drop policy if exists update_company on booking;
drop policy if exists insert_to_company on booking;
drop policy if exists select_from_company on booking;
alter table booking disable row level security;
create or replace function unmarshal_booking(data jsonb) returns integer as
$$
declare
bid integer;
begin
insert into booking
( company_id
, campsite_type_id
, stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
, holder_name
, address
, postal_code
, city
, country_code
, email
, phone
, lang_tag
)
values((data->>'company_id')::integer
, (data->>'campsite_type_id')::integer
, daterange((data->>'arrival_date')::date, (data->>'departure_date')::date)
, (data->>'subtotal_nights')::integer
, (data->>'number_adults')::integer
, (data->>'subtotal_adults')::integer
, (data->>'number_teenagers')::integer
, (data->>'subtotal_teenagers')::integer
, (data->>'number_children')::integer
, (data->>'subtotal_children')::integer
, (data->>'number_dogs')::integer
, (data->>'subtotal_dogs')::integer
, (data->>'subtotal_tourist_tax')::integer
, (data->>'total')::integer
, data->>'currency_code'
, data->>'zone_preferences'
, (data->>'acsi_card')::boolean
, data->>'full_name'
, data->>'address'
, data->>'postal_code'
, data->>'city'
, data->>'country_code'
, data->>'email'
, parse_packed_phone_number(data->>'phone', data->>'country_code')
, data->>'lang_tag'
)
returning booking_id into bid;
if jsonb_typeof(data->'options') = 'array' then
insert into booking_option (booking_id, campsite_type_option_id, units, subtotal)
select bid, campsite_type_option_id, units, subtotal
from jsonb_to_recordset(data->'options') as x(campsite_type_option_id integer, units integer, subtotal integer)
;
end if;
return bid;
end;
$$
language plpgsql
;
revoke execute on function unmarshal_booking(jsonb) from public;
grant execute on function unmarshal_booking(jsonb) to guest;
grant execute on function unmarshal_booking(jsonb) to employee;
grant execute on function unmarshal_booking(jsonb) to admin;
commit;

View File

@ -1,23 +0,0 @@
-- Deploy camper:weather_forecast to pg
-- requires: schema_camper
-- requires: roles
-- requires: extension_uri
begin;
set search_path to camper, public;
create table weather_forecast (
station_uri uri primary key,
weather_condition_id text not null,
day_temperature numeric(5,2) not null,
min_temperature numeric(5,2) not null,
forecasted_at timestamp with time zone not null,
updated_at timestamp with time zone not null
);
grant select on table weather_forecast to guest;
grant select on table weather_forecast to employee;
grant select, insert, update, delete on table weather_forecast to admin;
commit;

3
go.mod
View File

@ -4,16 +4,15 @@ go 1.19
require (
github.com/jackc/pgconn v1.11.0
github.com/jackc/pgio v1.0.0
github.com/jackc/pgtype v1.10.0
github.com/jackc/pgx/v4 v4.15.0
github.com/leonelquinteros/gotext v1.5.0
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8
golang.org/x/text v0.7.0
)
require (
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.2 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect

2
go.sum
View File

@ -88,8 +88,6 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8 h1:iZTHFqK/oFrjyFDkiw5U/RjQxkMlkpq6tHQIO407i+s=
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=

View File

@ -13,11 +13,9 @@ import (
"dev.tandem.ws/tandem/camper/pkg/booking"
"dev.tandem.ws/tandem/camper/pkg/campsite"
"dev.tandem.ws/tandem/camper/pkg/company"
"dev.tandem.ws/tandem/camper/pkg/customer"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/home"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/invoice"
"dev.tandem.ws/tandem/camper/pkg/legal"
"dev.tandem.ws/tandem/camper/pkg/location"
"dev.tandem.ws/tandem/camper/pkg/media"
@ -34,14 +32,11 @@ type adminHandler struct {
booking *booking.AdminHandler
campsite *campsite.AdminHandler
company *company.AdminHandler
customer *customer.AdminHandler
home *home.AdminHandler
invoice *invoice.AdminHandler
legal *legal.AdminHandler
location *location.AdminHandler
media *media.AdminHandler
payment *payment.AdminHandler
prebooking *booking.PrebookingHandler
season *season.AdminHandler
services *services.AdminHandler
surroundings *surroundings.AdminHandler
@ -54,14 +49,11 @@ func newAdminHandler(mediaDir string) *adminHandler {
booking: booking.NewAdminHandler(),
campsite: campsite.NewAdminHandler(),
company: company.NewAdminHandler(),
customer: customer.NewAdminHandler(),
home: home.NewAdminHandler(),
invoice: invoice.NewAdminHandler(),
legal: legal.NewAdminHandler(),
location: location.NewAdminHandler(),
media: media.NewAdminHandler(mediaDir),
payment: payment.NewAdminHandler(),
prebooking: booking.NewPrebookingHandler(),
season: season.NewAdminHandler(),
services: services.NewAdminHandler(),
surroundings: surroundings.NewAdminHandler(),
@ -93,22 +85,16 @@ func (h *adminHandler) Handle(user *auth.User, company *auth.Company, conn *data
h.campsite.Handler(user, company, conn).ServeHTTP(w, r)
case "company":
h.company.Handler(user, company, conn).ServeHTTP(w, r)
case "customers":
h.customer.Handler(user, company, conn).ServeHTTP(w, r)
case "home":
h.home.Handler(user, company, conn).ServeHTTP(w, r)
case "legal":
h.legal.Handler(user, company, conn).ServeHTTP(w, r)
case "invoices":
h.invoice.Handler(user, company, conn).ServeHTTP(w, r)
case "location":
h.location.Handler(user, company, conn).ServeHTTP(w, r)
case "media":
h.media.Handler(user, company, conn).ServeHTTP(w, r)
case "payments":
h.payment.Handler(user, company, conn).ServeHTTP(w, r)
case "prebookings":
h.prebooking.Handler(user, company, conn).ServeHTTP(w, r)
case "seasons":
h.season.Handler(user, company, conn).ServeHTTP(w, r)
case "services":

View File

@ -7,23 +7,15 @@ package booking
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/ods"
"errors"
"fmt"
"golang.org/x/text/language"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
"dev.tandem.ws/tandem/camper/pkg/uuid"
)
type AdminHandler struct {
@ -43,91 +35,6 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
switch r.Method {
case http.MethodGet:
serveBookingIndex(w, r, user, company, conn)
case http.MethodPost:
addBooking(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "new":
switch r.Method {
case http.MethodGet:
serveAdminBookingForm(w, r, user, company, conn, 0, "/admin/bookings/new")
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
if !uuid.Valid(head) {
http.NotFound(w, r)
return
}
h.bookingHandler(user, company, conn, head).ServeHTTP(w, r)
}
})
}
func serveAdminBookingForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, id int, url string) {
f, err := newAdminBookingForm(r, conn, company, user.Locale)
if err != nil {
panic(err)
}
f.ID = id
f.URL = url
f.MustRender(w, r, user, company)
}
func (h *AdminHandler) bookingHandler(user *auth.User, company *auth.Company, conn *database.Conn, slug string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
if err := r.ParseForm(); err != nil {
panic(err)
}
if len(r.Form) > 0 {
// Act as if it was a new form, because everything needed to render form fields is
// already passed as in request query.
id, err := conn.GetInt(r.Context(), "select booking_id from booking where slug = $1", slug)
if err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
serveAdminBookingForm(w, r, user, company, conn, id, "/admin/bookings/"+slug)
return
}
f := newEmptyAdminBookingForm(r.Context(), conn, company, user.Locale)
if err := f.FillFromDatabase(r.Context(), conn, company, slug, user.Locale); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
f.MustRender(w, r, user, company)
case http.MethodPut:
updateBooking(w, r, user, company, conn, slug)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
case "check-in":
switch r.Method {
case http.MethodGet:
serveCheckInForm(w, r, user, company, conn, slug)
case http.MethodPost:
checkInBooking(w, r, user, company, conn, slug)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "guest":
switch r.Method {
case http.MethodGet:
serveGuestForm(w, r, user, company, conn, slug)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
@ -138,41 +45,28 @@ func (h *AdminHandler) bookingHandler(user *auth.User, company *auth.Company, co
}
func serveBookingIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
filters := newFilterForm(r.Context(), conn, company, user.Locale)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language, filters)
bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language)
if err != nil {
panic(err)
}
page := &bookingIndex{
Bookings: filters.buildCursor(bookings),
Filters: filters,
}
page := bookingIndex(bookings)
page.MustRender(w, r, user, company)
}
func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag, filters *filterForm) ([]*bookingEntry, error) {
where, args := filters.BuildQuery([]interface{}{lang.String()})
rows, err := conn.Query(ctx, fmt.Sprintf(`
select booking_id
, left(slug::text, 10)
, '/admin/bookings/' || slug
, lower(stay)
, upper(stay)
func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag) ([]*bookingEntry, error) {
rows, err := conn.Query(ctx, `
select left(slug::text, 10)
, '/admin/booking/' || slug
, arrival_date
, departure_date
, holder_name
, booking.booking_status
, coalesce(i18n.name, status.name)
from booking
join booking_status as status using (booking_status)
left join booking_status_i18n as i18n on status.booking_status = i18n.booking_status and i18n.lang_tag = $1
where (%s)
order by lower(stay) desc
, booking_id desc
LIMIT %d
`, where, filters.PerPage()+1), args...)
order by arrival_date desc
`, lang)
if err != nil {
return nil, err
}
@ -181,7 +75,7 @@ func collectBookingEntries(ctx context.Context, conn *database.Conn, lang langua
var entries []*bookingEntry
for rows.Next() {
entry := &bookingEntry{}
if err = rows.Scan(&entry.ID, &entry.Reference, &entry.URL, &entry.ArrivalDate, &entry.DepartureDate, &entry.HolderName, &entry.Status, &entry.StatusLabel); err != nil {
if err = rows.Scan(&entry.Reference, &entry.URL, &entry.ArrivalDate, &entry.DepartureDate, &entry.HolderName, &entry.Status, &entry.StatusLabel); err != nil {
return nil, err
}
entries = append(entries, entry)
@ -191,7 +85,6 @@ func collectBookingEntries(ctx context.Context, conn *database.Conn, lang langua
}
type bookingEntry struct {
ID int
Reference string
URL string
ArrivalDate time.Time
@ -201,10 +94,7 @@ type bookingEntry struct {
StatusLabel string
}
type bookingIndex struct {
Bookings []*bookingEntry
Filters *filterForm
}
type bookingIndex []*bookingEntry
func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
switch r.URL.Query().Get("format") {
@ -216,16 +106,16 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user
"Holder Name",
"Status",
}
table, err := ods.WriteTable(page.Bookings, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error {
if err := ods.WriteCellString(sb, entry.Reference); err != nil {
ods, err := writeTableOds(page, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error {
if err := writeCellString(sb, entry.Reference); err != nil {
return err
}
ods.WriteCellDate(sb, entry.ArrivalDate)
ods.WriteCellDate(sb, entry.DepartureDate)
if err := ods.WriteCellString(sb, entry.HolderName); err != nil {
writeCellDate(sb, entry.ArrivalDate)
writeCellDate(sb, entry.DepartureDate)
if err := writeCellString(sb, entry.HolderName); err != nil {
return err
}
if err := ods.WriteCellString(sb, entry.StatusLabel); err != nil {
if err := writeCellString(sb, entry.StatusLabel); err != nil {
return err
}
return nil
@ -233,432 +123,8 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user
if err != nil {
panic(err)
}
ods.MustWriteResponse(w, table, user.Locale.Pgettext("bookings.ods", "filename"))
mustWriteOdsResponse(w, ods, user.Locale.Pgettext("bookings.ods", "filename"))
default:
if httplib.IsHTMxRequest(r) && page.Filters.Paginated() {
template.MustRenderAdminNoLayout(w, r, user, company, "booking/results.gohtml", page)
} else {
template.MustRenderAdminFiles(w, r, user, company, page, "booking/index.gohtml", "booking/results.gohtml")
}
template.MustRenderAdmin(w, r, user, company, "booking/index.gohtml", page)
}
}
type adminBookingForm struct {
*bookingForm
ID int
URL string
Status string
Campsites []*CampsiteEntry
selected []int
Months []*Month
Error error
}
func newEmptyAdminBookingForm(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) *adminBookingForm {
return &adminBookingForm{
bookingForm: newEmptyBookingForm(ctx, conn, company, l),
}
}
func newAdminBookingForm(r *http.Request, conn *database.Conn, company *auth.Company, l *locale.Locale) (*adminBookingForm, error) {
inner, err := newBookingForm(r, company, conn, l)
if err != nil {
return nil, err
}
if inner.Options != nil {
for _, option := range inner.Options.Options {
option.Subtotal = findSubtotal(option.ID, inner.Cart)
}
}
f := &adminBookingForm{
bookingForm: inner,
}
// Dates and Campsite are valid
if inner.Guests != nil {
selected := r.Form["campsite"]
if err = f.FetchCampsites(r.Context(), conn, company, selected); err != nil {
return nil, err
}
}
return f, nil
}
func (f *adminBookingForm) FetchCampsites(ctx context.Context, conn *database.Conn, company *auth.Company, selected []string) error {
arrivalDate, _ := time.Parse(database.ISODateFormat, f.Dates.ArrivalDate.Val)
from := arrivalDate.AddDate(0, 0, -1)
departureDate, _ := time.Parse(database.ISODateFormat, f.Dates.DepartureDate.Val)
to := departureDate.AddDate(0, 0, 2)
f.Months = CollectMonths(from, to)
var err error
f.Campsites, err = CollectCampsiteEntries(ctx, company, conn, from, to, f.CampsiteType.String())
if err != nil {
return err
}
for _, s := range selected {
ID, _ := strconv.Atoi(s)
for _, c := range f.Campsites {
if c.ID == ID {
f.selected = append(f.selected, c.ID)
c.Selected = true
break
}
}
}
return nil
}
func findSubtotal(ID int, cart *bookingCart) string {
none := "0.0"
if cart == nil || cart.Draft == nil {
return none
}
for _, option := range cart.Draft.Options {
if option.ID == ID {
return option.Subtotal
}
}
return none
}
func (f *adminBookingForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
if httplib.IsHTMxRequest(r) {
template.MustRenderAdminNoLayoutFiles(w, r, user, company, f, "booking/fields.gohtml", "booking/grid.gohtml")
} else {
template.MustRenderAdminFiles(w, r, user, company, f, "booking/form.gohtml", "booking/fields.gohtml", "booking/grid.gohtml")
}
}
func addBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
processAdminBookingForm(w, r, user, company, conn, 0, func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error {
var err error
f.ID, err = tx.AddBookingFromPayment(ctx, f.PaymentSlug.Val)
if err != nil {
return err
}
return tx.EditBooking(
ctx,
f.ID,
f.Customer.FullName.Val,
f.Customer.Address.Val,
f.Customer.PostalCode.Val,
f.Customer.City.Val,
f.Customer.Country.String(),
f.Customer.Email.Val,
f.Customer.Phone.Val,
language.Make("und"),
"confirmed",
f.selected,
)
})
}
func updateBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
var bookingID int
var bookingStatus string
var langTag string
row := conn.QueryRow(r.Context(), "select booking_id, booking_status, lang_tag from booking where slug = $1", slug)
if err := row.Scan(&bookingID, &bookingStatus, &langTag); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(r.Form["cancel"]) > 0 {
if err := conn.CancelBooking(r.Context(), bookingID); err != nil {
panic(err)
}
if bookingStatus == "created" {
httplib.Redirect(w, r, "/admin/prebookings", http.StatusSeeOther)
} else {
httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther)
}
return
}
processAdminBookingForm(w, r, user, company, conn, bookingID, func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error {
var err error
_, err = tx.EditBookingFromPayment(ctx, slug, f.PaymentSlug.Val)
if err != nil {
return err
}
if bookingStatus == "created" {
bookingStatus = "confirmed"
}
return tx.EditBooking(
ctx,
f.ID,
f.Customer.FullName.Val,
f.Customer.Address.Val,
f.Customer.PostalCode.Val,
f.Customer.City.Val,
f.Customer.Country.String(),
f.Customer.Email.Val,
f.Customer.Phone.Val,
language.Make(langTag),
bookingStatus,
f.selected,
)
})
}
func processAdminBookingForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, bookingID int, act func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error) {
f, err := newAdminBookingForm(r, conn, company, user.Locale)
if err != nil {
panic(err)
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
f.ID = bookingID
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
tx := conn.MustBegin(r.Context())
defer tx.Rollback(r.Context())
if err := act(r.Context(), tx, f); err != nil {
panic(err)
}
tx.MustCommit(r.Context())
httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther)
}
func (f *adminBookingForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
if f.Dates == nil {
return false, errors.New("no booking date fields")
}
if f.Guests == nil {
return false, errors.New("no guests fields")
}
if f.Customer == nil {
return false, errors.New("no customer fields")
}
if f.Cart == nil {
return false, errors.New("no booking cart")
}
v.CheckSelectedOptions(f.CampsiteType, l.GettextNoop("Selected campsite type is not valid."))
f.Dates.Valid(v, l)
f.Guests.Valid(v, l)
if f.Options != nil {
f.Options.Valid(v, l)
}
var country string
if f.Customer.Country.ValidOptionsSelected() {
country = f.Customer.Country.Selected[0]
}
if v.CheckRequired(f.Customer.FullName, l.GettextNoop("Full name can not be empty.")) {
v.CheckMinLength(f.Customer.FullName, 1, l.GettextNoop("Full name must have at least one letter."))
}
if f.Customer.PostalCode.Val != "" {
if country == "" {
v.Check(f.Customer.PostalCode, false, l.GettextNoop("Country can not be empty to validate the postcode."))
} else if _, err := v.CheckValidPostalCode(ctx, conn, f.Customer.PostalCode, country, l.GettextNoop("This postcode is not valid.")); err != nil {
return false, err
}
}
if f.Customer.Email.Val != "" {
v.CheckValidEmail(f.Customer.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com."))
}
if f.Customer.Phone.Val != "" {
if country == "" {
v.Check(f.Customer.Phone, false, l.GettextNoop("Country can not be empty to validate the phone."))
} else if _, err := v.CheckValidPhone(ctx, conn, f.Customer.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil {
return false, err
}
}
if len(f.selected) == 0 {
f.Error = errors.New(l.Gettext("You must select at least one accommodation."))
v.AllOK = false
} else if f.Dates.ArrivalDate.Error == nil && f.Dates.DepartureDate.Error == nil {
if available, err := datesAvailable(ctx, conn, f.ID, f.Dates, f.selected); err != nil {
return false, err
} else if !available {
f.Error = errors.New(l.Gettext("The selected accommodations have no available openings in the requested dates."))
v.AllOK = false
}
}
return v.AllOK, nil
}
func datesAvailable(ctx context.Context, conn *database.Conn, bookingID int, dates *DateFields, selectedCampsites []int) (bool, error) {
return conn.GetBool(ctx, `
select not exists (
select 1
from camper.booking_campsite
where booking_id <> $1
and campsite_id = any ($4)
and stay && daterange($2::date, $3::date)
)
`,
bookingID,
dates.ArrivalDate,
dates.DepartureDate,
selectedCampsites,
)
}
func (f *adminBookingForm) FillFromDatabase(ctx context.Context, conn *database.Conn, company *auth.Company, slug string, l *locale.Locale) error {
f.Cart = &bookingCart{Draft: &paymentDraft{}}
f.Customer = newBookingCustomerFields(ctx, conn, l)
var arrivalDate string
var departureDate string
var acsiCard bool
var zonePreferences string
var selected []string
row := conn.QueryRow(ctx, `
select booking_id
, '/admin/bookings/' || booking.slug
, booking_status
, array[campsite_type.slug::text]
, lower(booking.stay)::text
, upper(booking.stay)::text
, upper(booking.stay) - lower(booking.stay)
, to_price(subtotal_nights, decimal_digits)
, number_adults
, to_price(subtotal_adults, decimal_digits)
, number_teenagers
, to_price(subtotal_teenagers, decimal_digits)
, number_children
, to_price(subtotal_children, decimal_digits)
, number_dogs
, to_price(subtotal_dogs, decimal_digits)
, to_price(subtotal_tourist_tax, decimal_digits)
, to_price(total, decimal_digits)
, acsi_card
, holder_name
, coalesce(address, '')
, coalesce(postal_code, '')
, coalesce(city, '')
, array[coalesce(country_code::text, '')]
, coalesce(email::text, '')
, coalesce(phone::text, '')
, zone_preferences
, array_agg(coalesce(campsite_id::text, ''))
from booking
join campsite_type using (campsite_type_id)
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join booking_campsite using (booking_id)
join currency using (currency_code)
where booking.slug = $1
group by booking_id
, campsite_type.slug
, booking.stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, acsi_card
, holder_name
, address
, postal_code
, city
, country_code
, email
, phone
, zone_preferences
, decimal_digits
`, slug)
if err := row.Scan(
&f.ID,
&f.URL,
&f.Status,
&f.CampsiteType.Selected,
&arrivalDate,
&departureDate,
&f.Cart.Draft.NumNights,
&f.Cart.Draft.Nights,
&f.Cart.Draft.NumAdults,
&f.Cart.Draft.Adults,
&f.Cart.Draft.NumTeenagers,
&f.Cart.Draft.Teenagers,
&f.Cart.Draft.NumChildren,
&f.Cart.Draft.Children,
&f.Cart.Draft.NumDogs,
&f.Cart.Draft.Dogs,
&f.Cart.Draft.TouristTax,
&f.Cart.Draft.Total,
&acsiCard,
&f.Customer.FullName.Val,
&f.Customer.Address.Val,
&f.Customer.PostalCode.Val,
&f.Customer.City.Val,
&f.Customer.Country.Selected,
&f.Customer.Email.Val,
&f.Customer.Phone.Val,
&zonePreferences,
&selected,
); err != nil {
return err
}
var err error
f.Dates, err = NewDateFields(ctx, conn, f.CampsiteType.String())
if err != nil {
return err
}
f.Dates.ArrivalDate.Val = arrivalDate
f.Dates.DepartureDate.Val = departureDate
f.Dates.AdjustValues(l)
f.Guests, err = newBookingGuestFields(ctx, conn, f.CampsiteType.String(), arrivalDate, departureDate)
if err != nil {
return err
}
f.Guests.NumberAdults.Val = strconv.Itoa(f.Cart.Draft.NumAdults)
f.Guests.NumberTeenagers.Val = strconv.Itoa(f.Cart.Draft.NumTeenagers)
f.Guests.NumberChildren.Val = strconv.Itoa(f.Cart.Draft.NumChildren)
if f.Guests.NumberDogs != nil {
f.Guests.NumberDogs.Val = strconv.Itoa(f.Cart.Draft.NumDogs)
}
if f.Guests.ACSICard != nil {
f.Guests.ACSICard.Checked = acsiCard
}
f.Guests.AdjustValues(f.Cart.Draft.NumAdults+f.Cart.Draft.NumTeenagers+f.Cart.Draft.NumChildren, l)
f.Options, err = newBookingOptionFields(ctx, conn, f.CampsiteType.String(), l)
if err != nil {
return err
}
if f.Options != nil {
if f.Options.ZonePreferences != nil {
f.Options.ZonePreferences.Val = zonePreferences
}
if err = f.Options.FillFromDatabase(ctx, conn, f.ID); err != nil {
return err
}
}
if err = f.FetchCampsites(ctx, conn, company, selected); err != nil {
return err
}
return nil
}

View File

@ -1,162 +0,0 @@
package booking
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"time"
"dev.tandem.ws/tandem/camper/pkg/season"
)
type Month struct {
Year int
Month time.Month
Name string
Days []time.Time
Spans []*Span
}
type Span struct {
Weekend bool
Today bool
Count int
}
func isWeekend(t time.Time) bool {
switch t.Weekday() {
case time.Saturday, time.Sunday:
return true
default:
return false
}
}
func CollectMonths(from time.Time, to time.Time) []*Month {
current := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
var months []*Month
for !current.Equal(to) {
span := &Span{
Weekend: isWeekend(current),
Today: current.Equal(today),
}
month := &Month{
Year: current.Year(),
Month: current.Month(),
Name: season.LongMonthNames[current.Month()-1],
Days: make([]time.Time, 0, 31),
Spans: make([]*Span, 0, 10),
}
month.Spans = append(month.Spans, span)
for current.Month() == month.Month && !current.Equal(to) {
month.Days = append(month.Days, current)
if span.Weekend != isWeekend(current) || span.Today != current.Equal(today) {
span = &Span{
Weekend: isWeekend(current),
Today: current.Equal(today),
}
month.Spans = append(month.Spans, span)
}
span.Count = span.Count + 1
current = current.AddDate(0, 0, 1)
}
months = append(months, month)
}
return months
}
type CampsiteEntry struct {
ID int
Label string
Type string
TypeSlug string
Active bool
Selected bool
Bookings map[time.Time]*CampsiteBooking
}
type CampsiteBooking struct {
URL string
Holder string
Status string
Nights int
Begin bool
End bool
}
func CollectCampsiteEntries(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time, campsiteType string) ([]*CampsiteEntry, error) {
rows, err := conn.Query(ctx, `
select campsite_id
, campsite.label
, campsite_type.name
, campsite_type.slug
, campsite.active
from campsite
join campsite_type using (campsite_type_id)
where campsite.company_id = $1
and ($2::uuid is null or campsite_type.slug = $2::uuid)
order by label`, company.ID, database.ZeroNullUUID(campsiteType))
if err != nil {
return nil, err
}
defer rows.Close()
byLabel := make(map[string]*CampsiteEntry)
var campsites []*CampsiteEntry
for rows.Next() {
entry := &CampsiteEntry{}
if err = rows.Scan(&entry.ID, &entry.Label, &entry.Type, &entry.TypeSlug, &entry.Active); err != nil {
return nil, err
}
campsites = append(campsites, entry)
byLabel[entry.Label] = entry
}
if err := collectCampsiteBookings(ctx, company, conn, from, to, byLabel); err != nil {
return nil, err
}
return campsites, nil
}
func collectCampsiteBookings(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time, campsites map[string]*CampsiteEntry) error {
rows, err := conn.Query(ctx, `
select campsite.label
, lower(booking_campsite.stay * daterange($2::date, $3::date))
, '/admin/bookings/' || booking.slug
, holder_name
, booking_status
, upper(booking_campsite.stay * daterange($2::date, $3::date)) - lower(booking_campsite.stay * daterange($2::date, $3::date))
, booking_campsite.stay &> daterange($2::date, $3::date)
, booking_campsite.stay &< daterange($2::date, ($3 - 1)::date)
from booking_campsite
join booking using (booking_id)
join campsite using (campsite_id)
where booking.company_id = $1
and booking_campsite.stay && daterange($2::date, $3::date)
order by label`, company.ID, from, to)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
entry := &CampsiteBooking{}
var label string
var date time.Time
if err = rows.Scan(&label, &date, &entry.URL, &entry.Holder, &entry.Status, &entry.Nights, &entry.Begin, &entry.End); err != nil {
return err
}
campsite := campsites[label]
if campsite != nil {
if campsite.Bookings == nil {
campsite.Bookings = make(map[time.Time]*CampsiteBooking)
}
campsite.Bookings[date] = entry
}
}
return nil
}

View File

@ -10,7 +10,6 @@ import (
)
type bookingCart struct {
Draft *paymentDraft
Lines []*cartLine
Total string
DownPayment string
@ -24,79 +23,49 @@ type cartLine struct {
Subtotal string
}
type paymentDraft struct {
NumAdults int
NumTeenagers int
NumChildren int
NumDogs int
NumNights int
Nights string
Adults string
Teenagers string
Children string
Dogs string
TouristTax string
Total string
DownPaymentPercent int
DownPayment string
Options []*paymentOption
}
type paymentOption struct {
ID int
Label string
Units int
Subtotal string
}
func draftPayment(ctx context.Context, conn *database.Conn, f *bookingForm, campsiteType string) (*paymentDraft, error) {
func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, campsiteType string) (*bookingCart, error) {
cart := &bookingCart{
Total: "0.0",
}
if f.Dates == nil {
return nil, nil
return cart, nil
}
if f.Guests == nil {
return nil, nil
}
arrivalDate, err := time.Parse(database.ISODateFormat, f.Dates.ArrivalDate.Val)
if err != nil {
return nil, nil
return cart, nil
}
departureDate, err := time.Parse(database.ISODateFormat, f.Dates.DepartureDate.Val)
if err != nil {
return nil, nil
return cart, nil
}
draft := &paymentDraft{}
draft.NumAdults, err = strconv.Atoi(f.Guests.NumberAdults.Val)
if err != nil {
return nil, nil
if f.Guests == nil {
return cart, nil
}
draft.NumTeenagers, err = strconv.Atoi(f.Guests.NumberTeenagers.Val)
numAdults, err := strconv.Atoi(f.Guests.NumberAdults.Val)
if err != nil {
return nil, nil
return cart, nil
}
draft.NumChildren, err = strconv.Atoi(f.Guests.NumberChildren.Val)
numTeenagers, err := strconv.Atoi(f.Guests.NumberTeenagers.Val)
if err != nil {
return nil, nil
return cart, nil
}
numChildren, err := strconv.Atoi(f.Guests.NumberChildren.Val)
if err != nil {
return cart, nil
}
numDogs := 0
if f.Guests.NumberDogs != nil {
draft.NumDogs, err = strconv.Atoi(f.Guests.NumberDogs.Val)
numDogs, err = strconv.Atoi(f.Guests.NumberDogs.Val)
if err != nil {
return nil, nil
return cart, nil
}
}
var zonePreferences string
zonePreferences := ""
if f.Options != nil && f.Options.ZonePreferences != nil {
zonePreferences = f.Options.ZonePreferences.Val
}
var acsiCard bool
if f.Guests.ACSICard != nil {
acsiCard = f.Guests.ACSICard.Checked
}
optionMap := make(map[int]*campsiteTypeOption)
var typeOptions []*campsiteTypeOption
if f.Options != nil {
@ -128,42 +97,65 @@ func draftPayment(ctx context.Context, conn *database.Conn, f *bookingForm, camp
, to_price(total, decimal_digits)
, to_price(payment.down_payment, decimal_digits)
, (payment.down_payment_percent * 100)::int
from draft_payment($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) as payment
from draft_payment($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) as payment
join currency using (currency_code)
`,
database.ZeroNullUUID(f.PaymentSlug.Val),
arrivalDate,
departureDate,
campsiteType,
draft.NumAdults,
draft.NumTeenagers,
draft.NumChildren,
draft.NumDogs,
numAdults,
numTeenagers,
numChildren,
numDogs,
zonePreferences,
acsiCard,
database.OptionUnitsArray(optionUnits),
)
var paymentID int
var numNights int
var nights string
var adults string
var teenagers string
var children string
var dogs string
var touristTax string
var total string
var downPayment string
if err = row.Scan(
&f.PaymentSlug.Val,
&paymentID,
&draft.NumNights,
&draft.Nights,
&draft.Adults,
&draft.Teenagers,
&draft.Children,
&draft.Dogs,
&draft.TouristTax,
&draft.Total,
&draft.DownPayment,
&draft.DownPaymentPercent,
&numNights,
&nights,
&adults,
&teenagers,
&children,
&dogs,
&touristTax,
&total,
&downPayment,
&cart.DownPaymentPercent,
); err != nil {
if database.ErrorIsNotFound(err) {
return nil, nil
return cart, nil
}
return nil, err
}
maybeAddLine := func(units int, subtotal string, concept string) {
if units > 0 && subtotal != "" {
cart.Lines = append(cart.Lines, &cartLine{
Concept: concept,
Units: units,
Subtotal: subtotal,
})
}
}
maybeAddLine(numNights, nights, locale.PgettextNoop("Night", "cart"))
maybeAddLine(numAdults, adults, locale.PgettextNoop("Adult", "cart"))
maybeAddLine(numTeenagers, teenagers, locale.PgettextNoop("Teenager", "cart"))
maybeAddLine(numChildren, children, locale.PgettextNoop("Child", "cart"))
maybeAddLine(numDogs, dogs, locale.PgettextNoop("Dog", "cart"))
rows, err := conn.Query(ctx, `
select campsite_type_option_id
, units
@ -172,7 +164,6 @@ func draftPayment(ctx context.Context, conn *database.Conn, f *bookingForm, camp
join payment using (payment_id)
join currency using (currency_code)
where payment_id = $1
order by campsite_type_option_id
`, paymentID)
if err != nil {
return nil, err
@ -191,62 +182,20 @@ func draftPayment(ctx context.Context, conn *database.Conn, f *bookingForm, camp
if option == nil {
continue
}
draft.Options = append(draft.Options, &paymentOption{
ID: option.ID,
Label: option.Label,
Units: units,
Subtotal: subtotal,
})
maybeAddLine(units, subtotal, option.Label)
}
if rows.Err() != nil {
return nil, rows.Err()
}
return draft, nil
}
maybeAddLine(numAdults, touristTax, locale.PgettextNoop("Tourist tax", "cart"))
func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, campsiteType string) (*bookingCart, error) {
cart := &bookingCart{
Total: "0.0",
}
draft, err := draftPayment(ctx, conn, f, campsiteType)
if err != nil {
return nil, err
}
if draft == nil {
return cart, nil
}
cart.Draft = draft
cart.DownPaymentPercent = draft.DownPaymentPercent
maybeAddLine := func(units int, subtotal string, concept string) {
if units > 0 && subtotal != "" {
cart.Lines = append(cart.Lines, &cartLine{
Concept: concept,
Units: units,
Subtotal: subtotal,
})
}
}
maybeAddLine(draft.NumNights, draft.Nights, locale.PgettextNoop("Night", "cart"))
maybeAddLine(draft.NumAdults, draft.Adults, locale.PgettextNoop("Adult", "cart"))
maybeAddLine(draft.NumTeenagers, draft.Teenagers, locale.PgettextNoop("Teenager", "cart"))
maybeAddLine(draft.NumChildren, draft.Children, locale.PgettextNoop("Child", "cart"))
maybeAddLine(draft.NumDogs, draft.Dogs, locale.PgettextNoop("Dog", "cart"))
for _, option := range draft.Options {
maybeAddLine(option.Units, option.Subtotal, option.Label)
}
maybeAddLine(draft.NumAdults, draft.TouristTax, locale.PgettextNoop("Tourist tax", "cart"))
if draft.Total != "0.0" {
cart.Total = draft.Total
if total != "0.0" {
cart.Total = total
cart.Enabled = f.Guests.Error == nil
if draft.DownPayment != draft.Total {
cart.DownPayment = draft.DownPayment
if downPayment != total {
cart.DownPayment = downPayment
}
}

View File

@ -1,338 +0,0 @@
package booking
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
"github.com/jackc/pgx/v4"
"math"
"net/http"
"time"
)
func serveCheckInForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
f := newCheckinForm(slug)
if err := f.FillFromDatabase(r.Context(), conn, user.Locale); err != nil {
panic(err)
}
f.MustRender(w, r, user, company)
}
func serveGuestForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
f, err := newGuestForm(r.Context(), conn, user.Locale, slug)
if err != nil {
panic(err)
}
f.MustRender(w, r, user, company)
}
func checkInBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
f := newCheckinForm(slug)
if err := f.Parse(r, user, conn); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
}
guests := make([]*database.CheckedInGuest, 0, len(f.Guests))
for _, g := range f.Guests {
guests = append(guests, g.checkedInGuest())
}
if err := conn.CheckInGuests(r.Context(), f.Slug, guests); err != nil {
panic(err)
}
httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther)
}
type checkInForm struct {
Slug string
Guests []*guestForm
}
func newCheckinForm(slug string) *checkInForm {
return &checkInForm{
Slug: slug,
}
}
func (f *checkInForm) FillFromDatabase(ctx context.Context, conn *database.Conn, l *locale.Locale) error {
documentTypes := form.MustGetDocumentTypeOptions(ctx, conn, l)
sexes := mustGetSexOptions(ctx, conn, l)
countries := form.MustGetCountryOptions(ctx, conn, l)
rows, err := conn.Query(ctx, `
select array[id_document_type_id]
, id_document_number
, coalesce(id_document_issue_date::text, '')
, given_name
, first_surname
, second_surname
, array[sex_id]
, birthdate::text
, array[guest.country_code::text]
, coalesce(guest.phone::text, '')
, guest.address
from booking_guest as guest
join booking using (booking_id)
where slug = $1
`, f.Slug)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
guest := newGuestFormWithOptions(documentTypes, sexes, countries, "", nil)
if err := guest.FillFromRow(rows); err != nil {
return err
}
f.Guests = append(f.Guests, guest)
}
if len(f.Guests) == 0 {
var numberGuests int
var address string
var country []string
row := conn.QueryRow(ctx, "select number_adults + number_teenagers, coalesce(address, ''), array[coalesce(country_code, '')] from booking where slug = $1", f.Slug)
if err = row.Scan(&numberGuests, &address, &country); err != nil {
return err
}
guests := make([]*guestForm, 0, numberGuests)
for i := 0; i < numberGuests; i++ {
guests = append(guests, newGuestFormWithOptions(documentTypes, sexes, countries, address, country))
}
f.Guests = guests
}
return nil
}
func mustGetSexOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*form.Option {
return form.MustGetOptions(ctx, conn, "select sex.sex_id::text, coalesce(i18n.name, sex.name) as l10n_name from sex left join sex_i18n as i18n on sex.sex_id = i18n.sex_id and i18n.lang_tag = $1 order by l10n_name", l.Language)
}
func (f *checkInForm) Parse(r *http.Request, user *auth.User, conn *database.Conn) error {
if err := r.ParseForm(); err != nil {
return err
}
documentTypes := form.MustGetDocumentTypeOptions(r.Context(), conn, user.Locale)
sexes := mustGetSexOptions(r.Context(), conn, user.Locale)
countries := form.MustGetCountryOptions(r.Context(), conn, user.Locale)
guest := newGuestFormWithOptions(documentTypes, sexes, countries, "", nil)
count := guest.count(r)
f.Guests = make([]*guestForm, 0, count)
guest.FillValueIndex(r, 0)
f.Guests = append(f.Guests, guest)
for i := 1; i < count; i++ {
guest = newGuestFormWithOptions(documentTypes, sexes, countries, "", nil)
guest.FillValueIndex(r, i)
f.Guests = append(f.Guests, guest)
}
return nil
}
func (f *checkInForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
allOK := true
for _, g := range f.Guests {
if ok, err := g.Valid(ctx, conn, l); err != nil {
return false, err
} else if !ok {
allOK = false
}
}
return allOK, nil
}
func (f *checkInForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminFiles(w, r, user, company, f, "booking/checkin.gohtml", "booking/guest.gohtml")
}
type guestForm struct {
IDDocumentType *form.Select
IDDocumentNumber *form.Input
IDDocumentDate *form.Input
GivenName *form.Input
FirstSurname *form.Input
SecondSurname *form.Input
Sex *form.Select
Birthdate *form.Input
Country *form.Select
Address *form.Input
Phone *form.Input
}
func newGuestForm(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) (*guestForm, error) {
documentTypes := form.MustGetDocumentTypeOptions(ctx, conn, l)
sexes := mustGetSexOptions(ctx, conn, l)
countries := form.MustGetCountryOptions(ctx, conn, l)
var address string
var country []string
row := conn.QueryRow(ctx, "select coalesce(address, ''), array[coalesce(country_code, '')] from booking where slug = $1", slug)
if err := row.Scan(&address, &country); err != nil {
return nil, err
}
return newGuestFormWithOptions(documentTypes, sexes, countries, address, country), nil
}
func newGuestFormWithOptions(documentTypes []*form.Option, sexes []*form.Option, countries []*form.Option, address string, selectedCountry []string) *guestForm {
return &guestForm{
IDDocumentType: &form.Select{
Name: "id_document_type",
Options: documentTypes,
},
IDDocumentNumber: &form.Input{
Name: "id_document_number",
},
IDDocumentDate: &form.Input{
Name: "id_document_date",
},
GivenName: &form.Input{
Name: "given_name",
},
FirstSurname: &form.Input{
Name: "first_surname",
},
SecondSurname: &form.Input{
Name: "second_surname",
},
Sex: &form.Select{
Name: "sex",
Options: sexes,
},
Birthdate: &form.Input{
Name: "birthdate",
},
Country: &form.Select{
Name: "country",
Options: countries,
Selected: selectedCountry,
},
Address: &form.Input{
Name: "address",
Val: address,
},
Phone: &form.Input{
Name: "phone",
},
}
}
func (f *guestForm) count(r *http.Request) int {
keys := []string{f.IDDocumentType.Name, f.IDDocumentNumber.Name, f.IDDocumentDate.Name, f.GivenName.Name, f.FirstSurname.Name, f.SecondSurname.Name, f.Sex.Name, f.Birthdate.Name, f.Country.Name, f.Address.Name, f.Phone.Name}
min := math.MaxInt
for _, key := range keys {
l := len(r.Form[key])
if len(r.Form[key]) < min {
min = l
}
}
return min
}
func (f *guestForm) FillValueIndex(r *http.Request, idx int) {
f.IDDocumentType.FillValueIndex(r, idx)
f.IDDocumentNumber.FillValueIndex(r, idx)
f.IDDocumentDate.FillValueIndex(r, idx)
f.GivenName.FillValueIndex(r, idx)
f.FirstSurname.FillValueIndex(r, idx)
f.SecondSurname.FillValueIndex(r, idx)
f.Sex.FillValueIndex(r, idx)
f.Birthdate.FillValueIndex(r, idx)
f.Country.FillValueIndex(r, idx)
f.Address.FillValueIndex(r, idx)
f.Phone.FillValueIndex(r, idx)
}
func (f *guestForm) FillFromRow(row pgx.Rows) error {
return row.Scan(
&f.IDDocumentType.Selected,
&f.IDDocumentNumber.Val,
&f.IDDocumentDate.Val,
&f.GivenName.Val,
&f.FirstSurname.Val,
&f.SecondSurname.Val,
&f.Sex.Selected,
&f.Birthdate.Val,
&f.Country.Selected,
&f.Phone.Val,
&f.Address.Val,
)
}
func (f *guestForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
today := time.Now()
yesterday := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
v.CheckSelectedOptions(f.IDDocumentType, l.GettextNoop("Selected ID document type is not valid."))
v.CheckRequired(f.IDDocumentNumber, l.GettextNoop("ID document number can not be empty."))
if f.IDDocumentDate.Val != "" {
if v.CheckValidDate(f.IDDocumentDate, l.GettextNoop("ID document issue date must be a valid date.")) {
v.CheckMaxDate(f.IDDocumentDate, yesterday, l.Gettext("ID document issue date must be in the past."))
}
}
v.CheckRequired(f.GivenName, l.GettextNoop("Full name can not be empty."))
v.CheckRequired(f.FirstSurname, l.GettextNoop("Full name can not be empty."))
v.CheckSelectedOptions(f.Sex, l.GettextNoop("Selected sex is not valid."))
if v.CheckRequired(f.Birthdate, l.GettextNoop("Birthdate can not be empty")) {
if v.CheckValidDate(f.Birthdate, l.GettextNoop("Birthdate must be a valid date.")) {
v.CheckMaxDate(f.Birthdate, yesterday, l.Gettext("Birthdate must be in the past."))
}
}
var country string
if v.CheckSelectedOptions(f.Country, l.GettextNoop("Selected country is not valid.")) {
country = f.Country.Selected[0]
}
if f.Phone.Val != "" && country != "" {
if _, err := v.CheckValidPhone(ctx, conn, f.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil {
return false, err
}
}
return v.AllOK, nil
}
func (f *guestForm) checkedInGuest() *database.CheckedInGuest {
birthdate, err := time.Parse(database.ISODateFormat, f.Birthdate.Val)
if err != nil {
panic(err)
}
issueDate, err := time.Parse(database.ISODateFormat, f.IDDocumentDate.Val)
if err != nil {
issueDate = time.Time{}
}
return &database.CheckedInGuest{
IDDocumentType: f.IDDocumentType.String(),
IDDocumentNumber: f.IDDocumentNumber.Val,
IDDocumentIssueDate: issueDate,
GivenName: f.GivenName.Val,
FirstSurname: f.FirstSurname.Val,
SecondSurname: f.SecondSurname.Val,
Sex: f.Sex.String(),
Birthdate: birthdate,
CountryCode: f.Country.String(),
Phone: f.Phone.Val,
Address: f.Address.Val,
}
}
func (f *guestForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminNoLayout(w, r, user, company, "booking/guest.gohtml", f)
}

View File

@ -1,130 +0,0 @@
package booking
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
"dev.tandem.ws/tandem/camper/pkg/locale"
)
type filterForm struct {
company *auth.Company
HolderName *form.Input
BookingStatus *form.Select
FromDate *form.Input
ToDate *form.Input
Cursor *form.Cursor
}
func newFilterForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *filterForm {
return &filterForm{
company: company,
HolderName: &form.Input{
Name: "holder_name",
},
BookingStatus: &form.Select{
Name: "booking_status",
Options: mustGetBookingStatusOptions(ctx, conn, locale),
},
FromDate: &form.Input{
Name: "from_date",
},
ToDate: &form.Input{
Name: "to_date",
},
Cursor: &form.Cursor{
Name: "cursor",
PerPage: 25,
},
}
}
func mustGetBookingStatusOptions(ctx context.Context, conn *database.Conn, locale *locale.Locale) []*form.Option {
return form.MustGetOptions(ctx, conn, `
select booking_status.booking_status
, isi18n.name
from booking_status
join booking_status_i18n isi18n using(booking_status)
where isi18n.lang_tag = $1
and booking_status <> 'created'
order by booking_status`, locale.Language)
}
func (f *filterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.HolderName.FillValue(r)
f.BookingStatus.FillValue(r)
f.FromDate.FillValue(r)
f.ToDate.FillValue(r)
f.Cursor.FillValue(r)
return nil
}
func (f *filterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
if value != "" {
if conv == nil {
appendWhere(expression, value)
} else {
appendWhere(expression, conv(value))
}
}
}
appendWhere("booking.company_id = $%d", f.company.ID)
maybeAppendWhere("booking.holder_name ILIKE $%d", f.HolderName.Val, func(v string) interface{} {
return "%" + v + "%"
})
if len(f.BookingStatus.Selected) == 0 {
where = append(where, "booking.booking_status <> 'created'")
} else {
maybeAppendWhere("booking.booking_status = $%d", f.BookingStatus.String(), nil)
}
maybeAppendWhere("lower(stay) >= $%d", f.FromDate.Val, nil)
maybeAppendWhere("lower(stay) <= $%d", f.ToDate.Val, nil)
if f.Paginated() {
params := f.Cursor.Params()
if len(params) == 2 {
where = append(where, fmt.Sprintf("(lower(stay), booking_id) < ($%d, $%d)", len(args)+1, len(args)+2))
args = append(args, params[0])
args = append(args, params[1])
}
}
return strings.Join(where, ") AND ("), args
}
func (f *filterForm) buildCursor(bookings []*bookingEntry) []*bookingEntry {
return form.BuildCursor(f.Cursor, bookings, func(entry *bookingEntry) []string {
return []string{entry.ArrivalDate.Format(database.ISODateFormat), strconv.Itoa(entry.ID)}
})
}
func (f *filterForm) HasValue() bool {
return f.HolderName.Val != "" ||
(len(f.BookingStatus.Selected) > 0 && f.BookingStatus.Selected[0] != "") ||
f.FromDate.Val != "" ||
f.ToDate.Val != ""
}
func (f *filterForm) PerPage() int {
return f.Cursor.PerPage
}
func (f *filterForm) Paginated() bool {
return f.Cursor.Pagination
}

View File

@ -1,10 +1,8 @@
package ods
package booking
import (
"archive/zip"
"bytes"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/template"
"encoding/xml"
"fmt"
"net/http"
@ -46,7 +44,7 @@ const (
`
)
func WriteTable[K interface{}](rows []*K, columns []string, locale *locale.Locale, writeRow func(*strings.Builder, *K) error) ([]byte, error) {
func writeTableOds[K interface{}](rows []*K, columns []string, locale *locale.Locale, writeRow func(*strings.Builder, *K) error) ([]byte, error) {
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>
@ -92,7 +90,7 @@ func WriteTable[K interface{}](rows []*K, columns []string, locale *locale.Local
sb.WriteString(` <table:table-row table:style-name="ro1">
`)
for _, t := range columns {
if err := WriteCellString(&sb, locale.GetC(t, "header")); err != nil {
if err := writeCellString(&sb, locale.GetC(t, "header")); err != nil {
return nil, err
}
}
@ -150,7 +148,7 @@ func writeOdsFile(ods *zip.Writer, name string, content string, method uint16) e
return err
}
func WriteCellString(sb *strings.Builder, s string) error {
func writeCellString(sb *strings.Builder, s string) error {
sb.WriteString(` <table:table-cell office:value-type="string" calcext:value-type="string"><text:p>`)
if err := xml.EscapeText(sb, []byte(s)); err != nil {
return err
@ -159,15 +157,11 @@ func WriteCellString(sb *strings.Builder, s string) error {
return nil
}
func WriteCellDate(sb *strings.Builder, t time.Time) {
func writeCellDate(sb *strings.Builder, t time.Time) {
sb.WriteString(fmt.Sprintf(" <table:table-cell table:style-name=\"ce1\" office:value-type=\"date\" office:date-value=\"%s\" calcext:value-type=\"date\"><text:p>%s</text:p></table:table-cell>\n", t.Format(database.ISODateFormat), t.Format("02/01/06")))
}
func WriteCellFloat(sb *strings.Builder, s string, company *auth.Company, locale *locale.Locale) {
sb.WriteString(fmt.Sprintf(" <table:table-cell office:value-type=\"float\" office:value=\"%s\" calcext:value-type=\"float\"><text:p>%s</text:p></table:table-cell>\n", s, template.FormatPrice(s, locale.Language, locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol)))
}
func MustWriteResponse(w http.ResponseWriter, ods []byte, filename string) {
func mustWriteOdsResponse(w http.ResponseWriter, ods []byte, filename string) {
w.Header().Set("Content-Type", "application/vnd.oasis.opendocument.spreadsheet")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.WriteHeader(http.StatusOK)

View File

@ -43,6 +43,7 @@ func requestPayment(w http.ResponseWriter, r *http.Request, user *auth.User, com
f.Customer.Email.Val,
f.Customer.Phone.Val,
user.Locale.Language,
f.Customer.ACSICard.Checked,
)
if err != nil {
panic(err)

View File

@ -1,67 +0,0 @@
package booking
import (
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type PrebookingHandler struct {
}
func NewPrebookingHandler() *PrebookingHandler {
return &PrebookingHandler{}
}
func (h *PrebookingHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
servePrebookingIndex(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
http.NotFound(w, r)
}
})
}
func servePrebookingIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
filters := newFilterForm(r.Context(), conn, company, user.Locale)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filters.BookingStatus.Selected = []string{"created"}
bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language, filters)
if err != nil {
panic(err)
}
page := &prebookingIndex{
Bookings: filters.buildCursor(bookings),
Filters: filters,
}
page.MustRender(w, r, user, company)
}
type prebookingIndex struct {
Bookings []*bookingEntry
Filters *filterForm
}
func (page prebookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
if httplib.IsHTMxRequest(r) && page.Filters.Paginated() {
template.MustRenderAdminNoLayout(w, r, user, company, "prebooking/results.gohtml", page)
} else {
template.MustRenderAdminFiles(w, r, user, company, page, "prebooking/index.gohtml", "prebooking/results.gohtml")
}
}

View File

@ -119,7 +119,6 @@ type bookingGuestFields struct {
NumberTeenagers *form.Input
NumberChildren *form.Input
NumberDogs *form.Input
ACSICard *form.Checkbox
Error error
}
@ -130,12 +129,11 @@ type bookingOptionFields struct {
}
type campsiteTypeOption struct {
ID int
Label string
Min int
Max int
Input *form.Input
Subtotal string
ID int
Label string
Min int
Max int
Input *form.Input
}
type bookingCustomerFields struct {
@ -146,27 +144,24 @@ type bookingCustomerFields struct {
Country *form.Select
Email *form.Input
Phone *form.Input
ACSICard *form.Checkbox
Agreement *form.Checkbox
}
func newEmptyBookingForm(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) *bookingForm {
return &bookingForm{
CampsiteType: &form.Select{
Name: "campsite_type",
Options: form.MustGetOptions(ctx, conn, "select type.slug, coalesce(i18n.name, type.name) as l10n_name from campsite_type as type left join campsite_type_i18n as i18n on type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1 where company_id = $2 and active order by position, l10n_name", l.Language, company.ID),
},
PaymentSlug: &form.Input{
Name: "payment_slug",
},
}
}
func newBookingForm(r *http.Request, company *auth.Company, conn *database.Conn, l *locale.Locale) (*bookingForm, error) {
if err := r.ParseForm(); err != nil {
return nil, err
}
f := newEmptyBookingForm(r.Context(), conn, company, l)
f := &bookingForm{
CampsiteType: &form.Select{
Name: "campsite_type",
Options: form.MustGetOptions(r.Context(), conn, "select type.slug, coalesce(i18n.name, type.name) as l10n_name from campsite_type as type left join campsite_type_i18n as i18n on type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1 where company_id = $2 and active order by position, l10n_name", l.Language, company.ID),
},
PaymentSlug: &form.Input{
Name: "payment_slug",
},
}
f.CampsiteType.FillValue(r)
f.PaymentSlug.FillValue(r)
campsiteType := f.CampsiteType.String()
@ -188,7 +183,7 @@ func newBookingForm(r *http.Request, company *auth.Company, conn *database.Conn,
return f, nil
}
f.Guests, err = newBookingGuestFields(r.Context(), conn, campsiteType, f.Dates.ArrivalDate.Val, f.Dates.DepartureDate.Val)
f.Guests, err = newBookingGuestFields(r.Context(), conn, campsiteType)
if err != nil {
return nil, err
}
@ -245,14 +240,14 @@ func NewDateFields(ctx context.Context, conn *database.Conn, campsiteType string
row := conn.QueryRow(ctx, `
select lower(bookable_nights),
upper(bookable_nights) - 1,
greatest(min(lower(season_range)), lower(operating_dates), current_timestamp::date),
least(max(upper(season_range)), upper(operating_dates))
greatest(min(lower(season_range)), current_timestamp::date),
max(upper(season_range))
from campsite_type
join campsite_type_cost using (campsite_type_id)
join season_calendar using (season_id)
where campsite_type.slug = $1
and season_range >> daterange(date_trunc('year', current_timestamp)::date, date_trunc('year', current_timestamp)::date + 1)
group by bookable_nights, operating_dates
group by bookable_nights;
`, campsiteType)
f := &DateFields{
ArrivalDate: &bookingDateInput{
@ -274,10 +269,7 @@ func NewDateFields(ctx context.Context, conn *database.Conn, campsiteType string
func (f *DateFields) FillValues(r *http.Request, l *locale.Locale) {
f.ArrivalDate.FillValue(r)
f.DepartureDate.FillValue(r)
f.AdjustValues(l)
}
func (f *DateFields) AdjustValues(l *locale.Locale) {
if f.ArrivalDate.Val != "" {
arrivalDate, err := time.Parse(database.ISODateFormat, f.ArrivalDate.Val)
if err != nil {
@ -325,7 +317,7 @@ func (f *DateFields) Valid(v *form.Validator, l *locale.Locale) {
}
}
func newBookingGuestFields(ctx context.Context, conn *database.Conn, campsiteType string, arrivalDate string, departureDate string) (*bookingGuestFields, error) {
func newBookingGuestFields(ctx context.Context, conn *database.Conn, campsiteType string) (*bookingGuestFields, error) {
f := &bookingGuestFields{
NumberAdults: &form.Input{Name: "number_adults"},
NumberTeenagers: &form.Input{Name: "number_teenagers"},
@ -335,26 +327,17 @@ func newBookingGuestFields(ctx context.Context, conn *database.Conn, campsiteTyp
select max_campers
, overflow_allowed
, pet.cost_per_night is not null as dogs_allowed
, exists (
select 1 from acsi_calendar
where acsi_calendar.campsite_type_id = campsite_type.campsite_type_id
and acsi_range && daterange($2::date, $3::date)
) as acsi_allowed
from campsite_type
left join campsite_type_pet_cost as pet using (campsite_type_id)
where slug = $1
`, campsiteType, arrivalDate, departureDate)
`, campsiteType)
var dogsAllowed bool
var ACSIAllowed bool
if err := row.Scan(&f.MaxGuests, &f.OverflowAllowed, &dogsAllowed, &ACSIAllowed); err != nil {
if err := row.Scan(&f.MaxGuests, &f.OverflowAllowed, &dogsAllowed); err != nil {
return nil, err
}
if dogsAllowed {
f.NumberDogs = &form.Input{Name: "number_dogs"}
}
if ACSIAllowed {
f.ACSICard = &form.Checkbox{Name: "acsi_card"}
}
return f, nil
}
@ -366,13 +349,6 @@ func (f *bookingGuestFields) FillValues(r *http.Request, l *locale.Locale) {
if f.NumberDogs != nil {
fillNumericField(f.NumberDogs, r, 0)
}
if f.ACSICard != nil {
f.ACSICard.FillValue(r)
}
f.AdjustValues(numGuests, l)
}
func (f *bookingGuestFields) AdjustValues(numGuests int, l *locale.Locale) {
if numGuests > f.MaxGuests {
if f.OverflowAllowed {
f.Overflow = true
@ -483,42 +459,6 @@ func (f *bookingOptionFields) FillValues(r *http.Request) {
}
}
func (f *bookingOptionFields) FillFromDatabase(ctx context.Context, conn *database.Conn, bookingID int) error {
rows, err := conn.Query(ctx, `
select campsite_type_option.campsite_type_option_id
, coalesce(units, lower(range))::text
, to_price(coalesce(subtotal, 0), decimal_digits)
from booking
join campsite_type_option using (campsite_type_id)
left join booking_option
on booking.booking_id = booking_option.booking_id
and booking_option.campsite_type_option_id = campsite_type_option.campsite_type_option_id
join currency using (currency_code)
where booking.booking_id = $1
`, bookingID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int
var units string
var subtotal string
if err = rows.Scan(&id, &units, &subtotal); err != nil {
return err
}
for _, option := range f.Options {
if option.ID == id {
option.Input.Val = units
option.Subtotal = subtotal
break
}
}
}
return nil
}
func (f *bookingOptionFields) Valid(v *form.Validator, l *locale.Locale) {
for _, option := range f.Options {
if v.CheckRequired(option.Input, fmt.Sprintf(l.Gettext("%s can not be empty"), option.Label)) {
@ -547,7 +487,7 @@ func newBookingCustomerFields(ctx context.Context, conn *database.Conn, l *local
},
Country: &form.Select{
Name: "country",
Options: form.MustGetCountryOptions(ctx, conn, l),
Options: form.MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", l.Language),
},
Email: &form.Input{
Name: "email",
@ -555,6 +495,9 @@ func newBookingCustomerFields(ctx context.Context, conn *database.Conn, l *local
Phone: &form.Input{
Name: "phone",
},
ACSICard: &form.Checkbox{
Name: "acsi_card",
},
Agreement: &form.Checkbox{
Name: "agreement",
},
@ -569,6 +512,7 @@ func (f *bookingCustomerFields) FillValues(r *http.Request) {
f.Country.FillValue(r)
f.Email.FillValue(r)
f.Phone.FillValue(r)
f.ACSICard.FillValue(r)
f.Agreement.FillValue(r)
}

View File

@ -1,3 +1,3 @@
package build
const Version = "1.8~git"
const Version = "1.5~git"

View File

@ -8,12 +8,10 @@ package campsite
import (
"context"
"net/http"
"time"
"github.com/jackc/pgx/v4"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/booking"
"dev.tandem.ws/tandem/camper/pkg/campsite/types"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
@ -90,57 +88,54 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
}
func serveCampsiteIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
page := newCampsiteIndex()
if err := page.Parse(r); err != nil {
panic(err)
}
var err error
from := page.From.Date()
to := page.To.Date().AddDate(0, 1, 0)
page.Campsites, err = booking.CollectCampsiteEntries(r.Context(), company, conn, from, to, "")
campsites, err := collectCampsiteEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
page.Months = booking.CollectMonths(from, to)
page := &campsiteIndex{
Campsites: campsites,
}
page.MustRender(w, r, user, company)
}
func collectCampsiteEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*campsiteEntry, error) {
rows, err := conn.Query(ctx, `
select campsite.label
, campsite_type.name
, campsite.active
from campsite
join campsite_type using (campsite_type_id)
where campsite.company_id = $1
order by label`, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var campsites []*campsiteEntry
for rows.Next() {
entry := &campsiteEntry{}
if err = rows.Scan(&entry.Label, &entry.Type, &entry.Active); err != nil {
return nil, err
}
campsites = append(campsites, entry)
}
return campsites, nil
}
type campsiteEntry struct {
Label string
Type string
Active bool
}
type campsiteIndex struct {
From *form.Month
To *form.Month
Campsites []*booking.CampsiteEntry
Months []*booking.Month
}
func newCampsiteIndex() *campsiteIndex {
now := time.Now()
from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
to := from.AddDate(0, 3, 0)
return &campsiteIndex{
From: &form.Month{
Name: "from",
Year: from.Year(),
Month: from.Month(),
},
To: &form.Month{
Name: "to",
Year: to.Year(),
Month: to.Month(),
},
}
}
func (page *campsiteIndex) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
page.From.FillValue(r)
page.To.FillValue(r)
return nil
Campsites []*campsiteEntry
}
func (page *campsiteIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminFiles(w, r, user, company, page, "campsite/index.gohtml", "booking/grid.gohtml")
template.MustRenderAdminFiles(w, r, user, company, page, "campsite/index.gohtml", "web/templates/campground_map.svg")
}
func addCampsite(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {

View File

@ -1,403 +0,0 @@
package customer
import (
"context"
"fmt"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type AdminHandler struct {
}
func NewAdminHandler() *AdminHandler {
return &AdminHandler{}
}
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
serveCustomerIndex(w, r, user, company, conn)
case http.MethodPost:
addCustomer(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "new":
switch r.Method {
case http.MethodGet:
f := NewContactForm(r.Context(), conn, user.Locale)
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
f := NewContactForm(r.Context(), conn, user.Locale)
if err := f.FillFromDatabase(r.Context(), conn, head); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
h.customerHandler(user, company, conn, f).ServeHTTP(w, r)
}
})
}
func (h *AdminHandler) customerHandler(user *auth.User, company *auth.Company, conn *database.Conn, f *ContactForm) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
editCustomer(w, r, user, company, conn, f)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
default:
http.NotFound(w, r)
}
})
}
func serveCustomerIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
filters := newFilterForm(company)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
customers, err := collectCustomerEntries(r.Context(), conn, company, filters)
if err != nil {
panic(err)
}
page := &customerIndex{
Customers: filters.buildCursor(customers),
Filters: filters,
}
page.MustRender(w, r, user, company)
}
func collectCustomerEntries(ctx context.Context, conn *database.Conn, company *auth.Company, filters *filterForm) ([]*customerEntry, error) {
where, args := filters.BuildQuery(nil)
rows, err := conn.Query(ctx, fmt.Sprintf(`
select contact_id
, '/admin/customers/' || slug
, name
, coalesce(email::text, '')
, coalesce(phone::text, '')
from contact
left join contact_email using (contact_id)
left join contact_phone using (contact_id)
where (%s)
order by name, contact_id
LIMIT %d
`, where, filters.PerPage()+1), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var customers []*customerEntry
for rows.Next() {
customer := &customerEntry{}
if err = rows.Scan(&customer.ID, &customer.URL, &customer.Name, &customer.Email, &customer.Phone); err != nil {
return nil, err
}
customers = append(customers, customer)
}
return customers, nil
}
type customerEntry struct {
ID int
URL string
Name string
Email string
Phone string
}
type customerIndex struct {
Customers []*customerEntry
Filters *filterForm
}
func (page *customerIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
if httplib.IsHTMxRequest(r) && page.Filters.Paginated() {
template.MustRenderAdminNoLayout(w, r, user, company, "customer/results.gohtml", page)
} else {
template.MustRenderAdminFiles(w, r, user, company, page, "customer/index.gohtml", "customer/results.gohtml")
}
}
func addCustomer(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := NewContactForm(r.Context(), conn, user.Locale)
processCustomerForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error {
var err error
f.Slug, err = tx.AddContact(ctx, company.ID, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String())
return err
})
}
func editCustomer(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *ContactForm) {
processCustomerForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error {
_, err := tx.EditContact(ctx, f.Slug, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String())
return err
})
}
func processCustomerForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *ContactForm, act func(ctx context.Context, tx *database.Tx) error) {
if err := f.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
tx := conn.MustBegin(r.Context())
if err := act(r.Context(), tx); err == nil {
if err := tx.Commit(r.Context()); err != nil {
panic(err)
}
} else {
if err := tx.Rollback(r.Context()); err != nil {
panic(err)
}
panic(err)
}
httplib.Redirect(w, r, "/admin/customers", http.StatusSeeOther)
}
type ContactForm struct {
URL string
Slug string
FullName *form.Input
IDDocumentType *form.Select
IDDocumentNumber *form.Input
Address *form.Input
City *form.Input
Province *form.Input
PostalCode *form.Input
Country *form.Select
Email *form.Input
Phone *form.Input
}
func NewContactForm(ctx context.Context, conn *database.Conn, l *locale.Locale) *ContactForm {
return &ContactForm{
FullName: &form.Input{
Name: "full_name",
},
IDDocumentType: &form.Select{
Name: "id_document_type",
Options: form.MustGetDocumentTypeOptions(ctx, conn, l),
},
IDDocumentNumber: &form.Input{
Name: "id_document_number",
},
Address: &form.Input{
Name: "address",
},
City: &form.Input{
Name: "city",
},
Province: &form.Input{
Name: "province",
},
PostalCode: &form.Input{
Name: "postal_code",
},
Country: &form.Select{
Name: "country",
Options: form.MustGetCountryOptions(ctx, conn, l),
},
Email: &form.Input{
Name: "email",
},
Phone: &form.Input{
Name: "phone",
},
}
}
func (f *ContactForm) FillFromDatabase(ctx context.Context, conn *database.Conn, slug string) error {
row := conn.QueryRow(ctx, `
select '/admin/customers/' || slug
, slug
, name
, array[id_document_type_id::text]
, id_document_number
, address
, city
, province
, postal_code
, array[country_code::text]
, coalesce(email::text, '')
, coalesce(phone::text, '')
from contact as text
left join contact_email using (contact_id)
left join contact_phone using (contact_id)
where slug = $1
`, slug)
return row.Scan(
&f.URL,
&f.Slug,
&f.FullName.Val,
&f.IDDocumentType.Selected,
&f.IDDocumentNumber.Val,
&f.Address.Val,
&f.City.Val,
&f.Province.Val,
&f.PostalCode.Val,
&f.Country.Selected,
&f.Email.Val,
&f.Phone.Val,
)
}
func (f *ContactForm) FillFromBooking(ctx context.Context, conn *database.Conn, bookingID int) error {
row := conn.QueryRow(ctx, `
select ''
, ''
, holder_name
, array[]::text[]
, ''
, coalesce(address, '')
, coalesce(city, '')
, ''
, coalesce(postal_code, '')
, array[coalesce(country_code::text, '')]
, coalesce(email::text, '')
, coalesce(phone::text, '')
from booking
where booking_id = $1
`, bookingID)
return row.Scan(
&f.URL,
&f.Slug,
&f.FullName.Val,
&f.IDDocumentType.Selected,
&f.IDDocumentNumber.Val,
&f.Address.Val,
&f.City.Val,
&f.Province.Val,
&f.PostalCode.Val,
&f.Country.Selected,
&f.Email.Val,
&f.Phone.Val,
)
}
func (f *ContactForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.FullName.FillValue(r)
f.IDDocumentType.FillValue(r)
f.IDDocumentNumber.FillValue(r)
f.Address.FillValue(r)
f.City.FillValue(r)
f.Province.FillValue(r)
f.PostalCode.FillValue(r)
f.Country.FillValue(r)
f.Email.FillValue(r)
f.Phone.FillValue(r)
return nil
}
func (f *ContactForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
var country string
if v.CheckSelectedOptions(f.Country, l.GettextNoop("Selected country is not valid.")) {
country = f.Country.Selected[0]
}
v.CheckSelectedOptions(f.IDDocumentType, l.GettextNoop("Selected ID document type is not valid."))
v.CheckRequired(f.IDDocumentNumber, l.GettextNoop("ID document number can not be empty."))
if v.CheckRequired(f.FullName, l.GettextNoop("Full name can not be empty.")) {
v.CheckMinLength(f.FullName, 1, l.GettextNoop("Full name must have at least one letter."))
}
v.CheckRequired(f.Address, l.GettextNoop("Address can not be empty."))
v.CheckRequired(f.City, l.GettextNoop("Town or village can not be empty."))
if v.CheckRequired(f.PostalCode, l.GettextNoop("Postcode can not be empty.")) && country != "" {
if _, err := v.CheckValidPostalCode(ctx, conn, f.PostalCode, country, l.GettextNoop("This postcode is not valid.")); err != nil {
return false, err
}
}
if f.Email.Val != "" {
v.CheckValidEmail(f.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com."))
}
if f.Phone.Val != "" && country != "" {
if _, err := v.CheckValidPhone(ctx, conn, f.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil {
return false, err
}
}
return v.AllOK, nil
}
func (f *ContactForm) UpdateOrCreate(ctx context.Context, company *auth.Company, tx *database.Tx) (int, error) {
var contactID int
row := tx.QueryRow(ctx, `
select contact_id, slug from contact where id_document_type_id = $1 and id_document_number = $2 and country_code = $3
`,
f.IDDocumentType.String(),
f.IDDocumentNumber.Val,
f.Country.String(),
)
if err := row.Scan(&contactID, &f.Slug); err != nil {
if database.ErrorIsNotFound(err) {
f.Slug, err = tx.AddContact(ctx, company.ID, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String())
if err != nil {
return 0, err
}
contactID, err = tx.GetInt(ctx, "select contact_id from contact where slug = $1", f.Slug)
if err != nil {
return 0, err
}
} else {
return 0, err
}
}
_, err := tx.EditContact(ctx, f.Slug, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String())
if err != nil {
return 0, err
}
return contactID, nil
}
func (f *ContactForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminFiles(w, r, user, company, f, "customer/form.gohtml", "customer/contact.gohtml")
}

View File

@ -1,99 +0,0 @@
package customer
import (
"fmt"
"net/http"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/form"
)
type filterForm struct {
company *auth.Company
Name *form.Input
Email *form.Input
Cursor *form.Cursor
}
func newFilterForm(company *auth.Company) *filterForm {
return &filterForm{
company: company,
Name: &form.Input{
Name: "name",
},
Email: &form.Input{
Name: "email",
},
Cursor: &form.Cursor{
Name: "cursor",
PerPage: 25,
},
}
}
func (f *filterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Name.FillValue(r)
f.Email.FillValue(r)
f.Cursor.FillValue(r)
return nil
}
func (f *filterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
if value != "" {
if conv == nil {
appendWhere(expression, value)
} else {
appendWhere(expression, conv(value))
}
}
}
appendWhere("company_id = $%d", f.company.ID)
maybeAppendWhere("name ILIKE $%d", f.Name.Val, func(v string) interface{} {
return "%" + v + "%"
})
maybeAppendWhere("email ILIKE $%d", f.Email.Val, func(v string) interface{} {
return "%" + v + "%"
})
if f.Paginated() {
params := f.Cursor.Params()
if len(params) == 2 {
where = append(where, fmt.Sprintf("(name, contact_id) > ($%d, $%d)", len(args)+1, len(args)+2))
args = append(args, params[0])
args = append(args, params[1])
}
}
return strings.Join(where, ") AND ("), args
}
func (f *filterForm) buildCursor(customers []*customerEntry) []*customerEntry {
return form.BuildCursor(f.Cursor, customers, func(entry *customerEntry) []string {
return []string{entry.Name, strconv.Itoa(entry.ID)}
})
}
func (f *filterForm) HasValue() bool {
return f.Name.Val != "" ||
f.Email.Val != ""
}
func (f *filterForm) PerPage() int {
return f.Cursor.PerPage
}
func (f *filterForm) Paginated() bool {
return f.Cursor.Pagination
}

View File

@ -1,84 +0,0 @@
package database
import (
"fmt"
"time"
"github.com/jackc/pgio"
"github.com/jackc/pgtype"
)
type CheckedInGuest struct {
IDDocumentType string
IDDocumentNumber string
IDDocumentIssueDate time.Time
GivenName string
FirstSurname string
SecondSurname string
Sex string
Birthdate time.Time
CountryCode string
Phone string
Address string
}
func (src CheckedInGuest) EncodeBinary(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) {
typeName := CheckedInGuestTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
var idDocumentIssueDate interface{}
var noDate time.Time
if src.IDDocumentIssueDate != noDate {
idDocumentIssueDate = src.IDDocumentIssueDate
}
values := []interface{}{
src.IDDocumentType,
src.IDDocumentNumber,
idDocumentIssueDate,
src.GivenName,
src.FirstSurname,
src.SecondSurname,
src.Sex,
src.Birthdate,
src.CountryCode,
src.Phone,
src.Address,
}
ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType)
if err := ct.Set(values); err != nil {
return nil, err
}
return ct.EncodeBinary(ci, dst)
}
type CheckedInGuestArray []*CheckedInGuest
func (src CheckedInGuestArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) {
typeName := CheckedInGuestTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
arrayHeader := pgtype.ArrayHeader{
ElementOID: int32(dt.OID),
Dimensions: []pgtype.ArrayDimension{{Length: int32(len(src)), LowerBound: 1}},
}
buf = arrayHeader.EncodeBinary(ci, buf)
for _, optionUnits := range src {
sp := len(buf)
buf = pgio.AppendInt32(buf, -1)
elemBuf, err := optionUnits.EncodeBinary(ci, buf)
if err != nil {
return nil, err
}
if elemBuf != nil {
buf = elemBuf
pgio.SetInt32(buf[sp:], int32(len(buf[sp:])-4))
}
}
return buf, nil
}

View File

@ -1,76 +0,0 @@
package database
import (
"fmt"
"github.com/jackc/pgio"
"github.com/jackc/pgtype"
)
const EditedInvoiceProductTypeName = "edited_invoice_product"
type EditedInvoiceProduct struct {
*NewInvoiceProduct
InvoiceProductId int
}
func (src EditedInvoiceProduct) EncodeBinary(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) {
typeName := EditedInvoiceProductTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
var invoiceProductId interface{}
if src.InvoiceProductId > 0 {
invoiceProductId = src.InvoiceProductId
}
var productId interface{}
if src.ProductId > 0 {
productId = src.ProductId
}
values := []interface{}{
invoiceProductId,
productId,
src.Name,
src.Description,
src.Price,
src.Quantity,
src.Discount,
src.Taxes,
}
ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType)
if err := ct.Set(values); err != nil {
return nil, err
}
return ct.EncodeBinary(ci, dst)
}
type EditedInvoiceProductArray []*EditedInvoiceProduct
func (src EditedInvoiceProductArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) {
typeName := EditedInvoiceProductTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
arrayHeader := pgtype.ArrayHeader{
ElementOID: int32(dt.OID),
Dimensions: []pgtype.ArrayDimension{{Length: int32(len(src)), LowerBound: 1}},
}
buf = arrayHeader.EncodeBinary(ci, buf)
for _, product := range src {
sp := len(buf)
buf = pgio.AppendInt32(buf, -1)
elemBuf, err := product.EncodeBinary(ci, buf)
if err != nil {
return nil, err
}
if elemBuf != nil {
buf = elemBuf
pgio.SetInt32(buf[sp:], int32(len(buf[sp:])-4))
}
}
return buf, nil
}

View File

@ -1,76 +0,0 @@
package database
import (
"fmt"
"github.com/jackc/pgio"
"github.com/jackc/pgtype"
)
const NewInvoiceProductTypeName = "new_invoice_product"
type NewInvoiceProduct struct {
ProductId int
Name string
Description string
Price string
Quantity int
Discount float64
Taxes []int
}
func (src NewInvoiceProduct) EncodeBinary(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) {
typeName := NewInvoiceProductTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
var productId interface{}
if src.ProductId > 0 {
productId = src.ProductId
}
values := []interface{}{
productId,
src.Name,
src.Description,
src.Price,
src.Quantity,
src.Discount,
src.Taxes,
}
ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType)
if err := ct.Set(values); err != nil {
return nil, err
}
return ct.EncodeBinary(ci, dst)
}
type NewInvoiceProductArray []*NewInvoiceProduct
func (src NewInvoiceProductArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) {
typeName := NewInvoiceProductTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
arrayHeader := pgtype.ArrayHeader{
ElementOID: int32(dt.OID),
Dimensions: []pgtype.ArrayDimension{{Length: int32(len(src)), LowerBound: 1}},
}
buf = arrayHeader.EncodeBinary(ci, buf)
for _, product := range src {
sp := len(buf)
buf = pgio.AppendInt32(buf, -1)
elemBuf, err := product.EncodeBinary(ci, buf)
if err != nil {
return nil, err
}
if elemBuf != nil {
buf = elemBuf
pgio.SetInt32(buf[sp:], int32(len(buf[sp:])-4))
}
}
return buf, nil
}

View File

@ -7,7 +7,6 @@ package database
import (
"context"
"github.com/jackc/pgtype/zeronull"
"golang.org/x/text/language"
)
@ -349,45 +348,6 @@ func (tx *Tx) TranslateHome(ctx context.Context, companyID int, langTag language
return err
}
func (c *Conn) ReadyPayment(ctx context.Context, paymentSlug string, customerName string, customerAddress string, customerPostCode string, customerCity string, customerCountryCode string, customerEmail string, customerPhone string, customerLangTag language.Tag) (int, error) {
return c.GetInt(ctx, "select ready_payment($1, $2, $3, $4, $5, $6, $7, $8, $9)", paymentSlug, customerName, customerAddress, customerPostCode, customerCity, customerCountryCode, customerEmail, customerPhone, customerLangTag)
}
func (tx *Tx) AddBookingFromPayment(ctx context.Context, paymentSlug string) (int, error) {
return tx.GetInt(ctx, "select add_booking_from_payment($1)", paymentSlug)
}
func (tx *Tx) EditBookingFromPayment(ctx context.Context, bookingSlug string, paymentSlug string) (int, error) {
return tx.GetInt(ctx, "select edit_booking_from_payment($1, $2)", bookingSlug, paymentSlug)
}
func (tx *Tx) EditBooking(ctx context.Context, bookingID int, customerName string, customerAddress string, customerPostCode string, customerCity string, customerCountryCode string, customerEmail string, customerPhone string, customerLangTag language.Tag, bookingStatus string, campsiteIDs []int) error {
_, err := tx.Exec(ctx, "select edit_booking($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", bookingID, customerName, zeronull.Text(customerAddress), zeronull.Text(customerPostCode), zeronull.Text(customerCity), zeronull.Text(customerCountryCode), zeronull.Text(customerEmail), zeronull.Text(customerPhone), customerLangTag, bookingStatus, campsiteIDs)
return err
}
func (c *Conn) CancelBooking(ctx context.Context, bookingID int) error {
_, err := c.Exec(ctx, "select cancel_booking($1)", bookingID)
return err
}
func (c *Conn) CheckInGuests(ctx context.Context, bookingSlug string, guests []*CheckedInGuest) error {
_, err := c.Exec(ctx, "select check_in_guests(booking_id, $2) from booking where slug = $1", bookingSlug, CheckedInGuestArray(guests))
return err
}
func (tx *Tx) AddInvoice(ctx context.Context, companyID int, date string, customerID int, notes string, paymentMethodID int, products NewInvoiceProductArray) (string, error) {
return tx.GetText(ctx, "select add_invoice($1, $2, $3, $4, $5, $6)", companyID, date, customerID, notes, paymentMethodID, products)
}
func (tx *Tx) EditInvoice(ctx context.Context, invoiceSlug string, invoiceStatus string, contactID int, notes string, paymentMethodID int, products EditedInvoiceProductArray) (string, error) {
return tx.GetText(ctx, "select edit_invoice($1, $2, $3, $4, $5, $6)", invoiceSlug, invoiceStatus, contactID, notes, paymentMethodID, products)
}
func (tx *Tx) AddContact(ctx context.Context, companyID int, name string, idDocumentType string, idDocumentNumber string, phone string, email string, address string, city string, province string, postalCode string, countryCode string) (string, error) {
return tx.GetText(ctx, "select add_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", companyID, name, idDocumentType, idDocumentNumber, phone, email, address, city, province, postalCode, countryCode)
}
func (tx *Tx) EditContact(ctx context.Context, contactSlug, name string, idDocumentType string, idDocumentNumber string, phone string, email string, address string, city string, province string, postalCode string, countryCode string) (string, error) {
return tx.GetText(ctx, "select edit_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", contactSlug, name, idDocumentType, idDocumentNumber, phone, email, address, city, province, postalCode, countryCode)
func (c *Conn) ReadyPayment(ctx context.Context, paymentSlug string, customerName string, customerAddress string, customerPostCode string, customerCity string, customerCountryCode string, customerEmail string, customerPhone string, customerLangTag language.Tag, acsiCard bool) (int, error) {
return c.GetInt(ctx, "select ready_payment($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", paymentSlug, customerName, customerAddress, customerPostCode, customerCity, customerCountryCode, customerEmail, customerPhone, customerLangTag, acsiCard)
}

View File

@ -13,7 +13,6 @@ import (
)
const (
CheckedInGuestTypeName = "checked_in_guest"
OptionUnitsTypeName = "option_units"
RedsysRequestTypeName = "redsys_request"
RedsysResponseTypeName = "redsys_response"
@ -49,11 +48,6 @@ func registerConnectionTypes(ctx context.Context, conn *pgx.Conn) error {
return err
}
discountRateOID, err := registerType(ctx, conn, &pgtype.Numeric{}, "discount_rate")
if err != nil {
return err
}
redsysRequestType, err := pgtype.NewCompositeType(
RedsysRequestTypeName,
[]pgtype.CompositeTypeField{
@ -131,71 +125,6 @@ func registerConnectionTypes(ctx context.Context, conn *pgx.Conn) error {
return err
}
checkedInGuestType, err := pgtype.NewCompositeType(
CheckedInGuestTypeName,
[]pgtype.CompositeTypeField{
{"id_document_type_id", pgtype.VarcharOID},
{"id_document_number", pgtype.TextOID},
{"id_document_issue_date", pgtype.DateOID},
{"given_name", pgtype.TextOID},
{"first_surname", pgtype.TextOID},
{"second_surname", pgtype.TextOID},
{"sex_id", pgtype.VarcharOID},
{"birthdate", pgtype.DateOID},
{"country_code", pgtype.TextOID},
{"phone", pgtype.TextOID},
{"address", pgtype.TextOID},
},
conn.ConnInfo(),
)
if err != nil {
return err
}
if _, err = registerType(ctx, conn, checkedInGuestType, checkedInGuestType.TypeName()); err != nil {
return err
}
newInvoiceProductType, err := pgtype.NewCompositeType(
NewInvoiceProductTypeName,
[]pgtype.CompositeTypeField{
{"product_id", pgtype.Int4OID},
{"name", pgtype.TextOID},
{"description", pgtype.TextOID},
{"price", pgtype.TextOID},
{"quantity", pgtype.Int4OID},
{"discount_rate", discountRateOID},
{"tax", pgtype.Int4ArrayOID},
},
conn.ConnInfo(),
)
if err != nil {
return err
}
if _, err = registerType(ctx, conn, newInvoiceProductType, newInvoiceProductType.TypeName()); err != nil {
return err
}
editedInvoiceProductType, err := pgtype.NewCompositeType(
EditedInvoiceProductTypeName,
[]pgtype.CompositeTypeField{
{"invoice_product_id", pgtype.Int4OID},
{"product_id", pgtype.Int4OID},
{"name", pgtype.TextOID},
{"description", pgtype.TextOID},
{"price", pgtype.TextOID},
{"quantity", pgtype.Int4OID},
{"discount_rate", discountRateOID},
{"tax", pgtype.Int4ArrayOID},
},
conn.ConnInfo(),
)
if err != nil {
return err
}
if _, err = registerType(ctx, conn, editedInvoiceProductType, editedInvoiceProductType.TypeName()); err != nil {
return err
}
return nil
}

View File

@ -1,33 +0,0 @@
package form
import (
"net/http"
"strings"
)
type Cursor struct {
PerPage int
Pagination bool
Name string
Val string
Colspan int
}
func (cursor *Cursor) FillValue(r *http.Request) {
cursor.Val = strings.TrimSpace(r.FormValue(cursor.Name))
cursor.Pagination = cursor.Val != ""
}
func (cursor *Cursor) Params() []string {
return strings.Split(cursor.Val, ";")
}
func BuildCursor[K interface{}](cursor *Cursor, elems []K, build func(K) []string) []K {
if len(elems) <= cursor.PerPage {
cursor.Val = ""
return elems
}
elems = elems[:cursor.PerPage]
cursor.Val = strings.Join(build(elems[cursor.PerPage-1]), ";")
return elems
}

View File

@ -28,17 +28,8 @@ func (input *Input) setError(err error) {
}
func (input *Input) FillValue(r *http.Request) {
input.FillValueIndex(r, 0)
input.Val = strings.TrimSpace(r.FormValue(input.Name))
}
func (input *Input) FillValueIndex(r *http.Request, idx int) {
var val string
if vs := r.Form[input.Name]; len(vs) > idx {
val = vs[idx]
}
input.Val = strings.TrimSpace(val)
}
func (input *Input) Value() (driver.Value, error) {
return input.Val, nil
}

View File

@ -1,32 +0,0 @@
package form
import (
"fmt"
"net/http"
"strconv"
"time"
)
type Month struct {
Name string
Year int
Month time.Month
Error error
}
func (input *Month) FillValue(r *http.Request) {
if year, err := strconv.Atoi(r.FormValue(input.Name + ".year")); err == nil {
input.Year = year
} else {
fmt.Println(err, r.FormValue(input.Name+".year"))
}
if month, err := strconv.Atoi(r.FormValue(input.Name + ".month")); err == nil && month > 0 && month < 13 {
input.Month = time.Month(month)
} else {
fmt.Println(err, r.FormValue(input.Name+".month"))
}
}
func (input *Month) Date() time.Time {
return time.Date(input.Year, input.Month, 1, 0, 0, 0, 0, time.UTC)
}

View File

@ -8,7 +8,6 @@ package form
import (
"context"
"database/sql/driver"
"dev.tandem.ws/tandem/camper/pkg/locale"
"net/http"
"strconv"
@ -49,18 +48,7 @@ func (s *Select) FillValue(r *http.Request) {
s.Selected = r.Form[s.Name]
}
func (s *Select) FillValueIndex(r *http.Request, idx int) {
if vs := r.Form[s.Name]; len(vs) > idx {
s.Selected = []string{vs[idx]}
} else {
s.Selected = nil
}
}
func (s *Select) ValidOptionsSelected() bool {
if len(s.Selected) == 0 {
return false
}
for _, selected := range s.Selected {
if !s.isValidOption(selected) {
return false
@ -114,11 +102,3 @@ func MustGetOptions(ctx context.Context, conn *database.Conn, sql string, args .
return options
}
func MustGetCountryOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*Option {
return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", l.Language)
}
func MustGetDocumentTypeOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*Option {
return MustGetOptions(ctx, conn, "select idt.id_document_type_id::text, coalesce(i18n.name, idt.name) as l10n_name from id_document_type as idt left join id_document_type_i18n as i18n on idt.id_document_type_id = i18n.id_document_type_id and i18n.lang_tag = $1 order by l10n_name", l.Language)
}

View File

@ -11,10 +11,9 @@ import (
)
const (
HxLocation = "HX-Location"
HxRedirect = "HX-Redirect"
HxRequest = "HX-Request"
HxTriggerAfterSettle = "HX-Trigger-After-Settle"
HxLocation = "HX-Location"
HxRedirect = "HX-Redirect"
HxRequest = "HX-Request"
)
func Relocate(w http.ResponseWriter, r *http.Request, url string, code int) {
@ -38,10 +37,6 @@ func Redirect(w http.ResponseWriter, r *http.Request, url string, code int) {
}
}
func TriggerAfterSettle(w http.ResponseWriter, trigger string) {
w.Header().Set(HxTriggerAfterSettle, trigger)
}
func IsHTMxRequest(r *http.Request) bool {
return r.Header.Get(HxRequest) == "true"
}

File diff suppressed because it is too large Load Diff

View File

@ -1,124 +0,0 @@
package invoice
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
"dev.tandem.ws/tandem/camper/pkg/locale"
)
type filterForm struct {
company *auth.Company
Customer *form.Select
InvoiceStatus *form.Select
InvoiceNumber *form.Input
FromDate *form.Input
ToDate *form.Input
Cursor *form.Cursor
}
func newFilterForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *filterForm {
return &filterForm{
company: company,
Customer: &form.Select{
Name: "customer",
Options: mustGetContactOptions(ctx, conn, company),
},
InvoiceStatus: &form.Select{
Name: "invoice_status",
Options: mustGetInvoiceStatusOptions(ctx, conn, locale),
},
InvoiceNumber: &form.Input{
Name: "number",
},
FromDate: &form.Input{
Name: "from_date",
},
ToDate: &form.Input{
Name: "to_date",
},
Cursor: &form.Cursor{
Name: "cursor",
PerPage: 25,
},
}
}
func (f *filterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Customer.FillValue(r)
f.InvoiceStatus.FillValue(r)
f.InvoiceNumber.FillValue(r)
f.FromDate.FillValue(r)
f.ToDate.FillValue(r)
f.Cursor.FillValue(r)
return nil
}
func (f *filterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
if value != "" {
if conv == nil {
appendWhere(expression, value)
} else {
appendWhere(expression, conv(value))
}
}
}
appendWhere("invoice.company_id = $%d", f.company.ID)
maybeAppendWhere("contact_id = $%d", f.Customer.String(), func(v string) interface{} {
customerId, _ := strconv.Atoi(f.Customer.Selected[0])
return customerId
})
maybeAppendWhere("invoice.invoice_status = $%d", f.InvoiceStatus.String(), nil)
maybeAppendWhere("invoice_number = $%d", f.InvoiceNumber.Val, nil)
maybeAppendWhere("invoice_date >= $%d", f.FromDate.Val, nil)
maybeAppendWhere("invoice_date <= $%d", f.ToDate.Val, nil)
if f.Paginated() {
params := f.Cursor.Params()
if len(params) == 2 {
where = append(where, fmt.Sprintf("(invoice_date, invoice_number) < ($%d, $%d)", len(args)+1, len(args)+2))
args = append(args, params[0])
args = append(args, params[1])
}
}
return strings.Join(where, ") AND ("), args
}
func (f *filterForm) buildCursor(customers []*IndexEntry) []*IndexEntry {
return form.BuildCursor(f.Cursor, customers, func(entry *IndexEntry) []string {
return []string{entry.Date.Format(database.ISODateFormat), entry.Number}
})
}
func (f *filterForm) HasValue() bool {
return (len(f.Customer.Selected) > 0 && f.Customer.Selected[0] != "") ||
(len(f.InvoiceStatus.Selected) > 0 && f.InvoiceStatus.Selected[0] != "") ||
f.InvoiceNumber.Val != "" ||
f.FromDate.Val != "" ||
f.ToDate.Val != ""
}
func (f *filterForm) PerPage() int {
return f.Cursor.PerPage
}
func (f *filterForm) Paginated() bool {
return f.Cursor.Pagination
}

View File

@ -1,65 +0,0 @@
package invoice
import (
"sort"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/ods"
)
func mustWriteInvoicesOds(invoices []*IndexEntry, taxes map[int]taxMap, taxColumns map[int]string, company *auth.Company, locale *locale.Locale) []byte {
taxIDs := extractTaxIDs(taxColumns)
columns := make([]string, 6+len(taxIDs))
columns[0] = "Date"
columns[1] = "Invoice Num."
columns[2] = "Customer"
columns[3] = "Status"
i := 4
for _, taxID := range taxIDs {
columns[i] = taxColumns[taxID]
i++
}
columns[i] = "Amount"
table, err := ods.WriteTable(invoices, columns, locale, func(sb *strings.Builder, invoice *IndexEntry) error {
ods.WriteCellDate(sb, invoice.Date)
if err := ods.WriteCellString(sb, invoice.Number); err != nil {
return err
}
if err := ods.WriteCellString(sb, invoice.CustomerName); err != nil {
return err
}
if err := ods.WriteCellString(sb, invoice.StatusLabel); err != nil {
return err
}
writeTaxes(sb, taxes[invoice.ID], taxIDs, company, locale)
ods.WriteCellFloat(sb, invoice.Total, company, locale)
return nil
})
if err != nil {
panic(err)
}
return table
}
func extractTaxIDs(taxColumns map[int]string) []int {
taxIDs := make([]int, len(taxColumns))
i := 0
for k := range taxColumns {
taxIDs[i] = k
i++
}
sort.Ints(taxIDs[:])
return taxIDs
}
func writeTaxes(sb *strings.Builder, taxes taxMap, taxIDs []int, company *auth.Company, locale *locale.Locale) {
for _, taxID := range taxIDs {
var amount string
if taxes != nil {
amount = taxes[taxID]
}
ods.WriteCellFloat(sb, amount, company, locale)
}
}

Some files were not shown because too many files have changed in this diff Show More