diff --git a/deploy/edit_booking_from_payment.sql b/deploy/edit_booking_from_payment.sql new file mode 100644 index 0000000..fa592f3 --- /dev/null +++ b/deploy/edit_booking_from_payment.sql @@ -0,0 +1,92 @@ +-- 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; diff --git a/pkg/booking/admin.go b/pkg/booking/admin.go index e404cd1..554da61 100644 --- a/pkg/booking/admin.go +++ b/pkg/booking/admin.go @@ -21,6 +21,7 @@ import ( 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 { @@ -48,13 +49,69 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat case "new": switch r.Method { case http.MethodGet: - f, err := newAdminBookingForm(r, conn, company, user.Locale) - if err != nil { + 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) + httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) } default: http.NotFound(w, r) @@ -150,12 +207,19 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user type adminBookingForm struct { *bookingForm ID int + URL 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 { @@ -171,31 +235,40 @@ func newAdminBookingForm(r *http.Request, conn *database.Conn, company *auth.Com } // Dates and Campsite are valid if inner.Guests != nil { - arrivalDate, _ := time.Parse(database.ISODateFormat, inner.Dates.ArrivalDate.Val) - from := arrivalDate.AddDate(0, 0, -1) - departureDate, _ := time.Parse(database.ISODateFormat, inner.Dates.DepartureDate.Val) - to := departureDate.AddDate(0, 0, 2) - f.Months = CollectMonths(from, to) - f.Campsites, err = CollectCampsiteEntries(r.Context(), company, conn, from, to, inner.CampsiteType.String()) - if err != nil { - return nil, err - } - selected := r.Form["campsite"] - 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 - } - } + 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 { @@ -218,6 +291,65 @@ func (f *adminBookingForm) MustRender(w http.ResponseWriter, r *http.Request, us } 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) + } + 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 + } + 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) @@ -226,6 +358,7 @@ func addBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company 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 { @@ -237,42 +370,14 @@ func addBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company } tx := conn.MustBegin(r.Context()) - if err := performAddBooking(r.Context(), tx, f); err == nil { - if err := tx.Commit(r.Context()); err != nil { - panic(err) - } - } else { - if err := tx.Rollback(r.Context()); err != nil { - panic(err) - } + 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 performAddBooking(ctx context.Context, tx *database.Tx, f *adminBookingForm) error { - var bookingID int - var err error - bookingID, err = tx.AddBookingFromPayment(ctx, f.PaymentSlug.Val) - if err != nil { - return err - } - return tx.EditBooking( - ctx, - bookingID, - 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 (f *adminBookingForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) { v := form.NewValidator(l) @@ -327,7 +432,7 @@ func (f *adminBookingForm) Valid(ctx context.Context, conn *database.Conn, l *lo 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.Dates, f.selected); err != 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.")) @@ -338,6 +443,162 @@ func (f *adminBookingForm) Valid(ctx context.Context, conn *database.Conn, l *lo return v.AllOK, nil } -func datesAvailable(ctx context.Context, conn *database.Conn, dates *DateFields, selectedCampsites []int) (bool, error) { - return conn.GetBool(ctx, "select not exists (select 1 from camper.booking_campsite where campsite_id = any ($3) and stay && daterange($1::date, $2::date))", dates.ArrivalDate, dates.DepartureDate, selectedCampsites) +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 + , 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.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 } diff --git a/pkg/booking/public.go b/pkg/booking/public.go index 6ccd017..70f0175 100644 --- a/pkg/booking/public.go +++ b/pkg/booking/public.go @@ -149,20 +149,24 @@ type bookingCustomerFields struct { Agreement *form.Checkbox } -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 := &bookingForm{ +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(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), + 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.CampsiteType.FillValue(r) f.PaymentSlug.FillValue(r) campsiteType := f.CampsiteType.String() @@ -270,7 +274,10 @@ 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 { @@ -362,6 +369,10 @@ func (f *bookingGuestFields) FillValues(r *http.Request, l *locale.Locale) { 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 @@ -472,6 +483,42 @@ 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)) { diff --git a/pkg/database/funcs.go b/pkg/database/funcs.go index 2769110..b7ddd8d 100644 --- a/pkg/database/funcs.go +++ b/pkg/database/funcs.go @@ -357,6 +357,10 @@ func (tx *Tx) AddBookingFromPayment(ctx context.Context, paymentSlug string) (in 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 diff --git a/revert/edit_booking_from_payment.sql b/revert/edit_booking_from_payment.sql new file mode 100644 index 0000000..b3d1933 --- /dev/null +++ b/revert/edit_booking_from_payment.sql @@ -0,0 +1,7 @@ +-- Revert camper:edit_booking_from_payment from pg + +begin; + +drop function if exists camper.edit_booking_from_payment(uuid, uuid); + +commit; diff --git a/sqitch.plan b/sqitch.plan index 682a3fc..b9b409d 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -289,3 +289,4 @@ booking__payment_fields [booking positive_integer nonnegative_integer] 2024-04-2 booking_option [roles schema_camper booking campsite_type_option positive_integer nonnegative_integer] 2024-04-24T11:14:03Z jordi fita mas # Add booking campsite option relation add_booking_from_payment [roles schema_camper booking booking__payment_fields booking__stay booking_option payment payment__acsi_card payment_customer payment_option] 2024-04-24T11:23:22Z jordi fita mas # Add function to create a pre-booking from a payment edit_booking [roles schema_camper booking booking__payment_fields booking__stay booking_campsite] 2024-04-24T16:27:10Z jordi fita mas # Add function to update a booking +edit_booking_from_payment [roles schema_camper booking booking__payment_fields booking__stay booking_option payment payment__acsi_card payment_option] 2024-04-25T17:18:41Z jordi fita mas # Add function to edit a booking from a payment diff --git a/test/edit_booking_from_payment.sql b/test/edit_booking_from_payment.sql new file mode 100644 index 0000000..295d77c --- /dev/null +++ b/test/edit_booking_from_payment.sql @@ -0,0 +1,99 @@ +-- Test edit_booking_from_payment +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(12); + +set search_path to camper, public; + +select has_function('camper', 'edit_booking_from_payment', array['uuid', 'uuid']); +select function_lang_is('camper', 'edit_booking_from_payment', array['uuid', 'uuid'], 'plpgsql'); +select function_returns('camper', 'edit_booking_from_payment', array['uuid', 'uuid'], 'integer'); +select isnt_definer('camper', 'edit_booking_from_payment', array['uuid', 'uuid']); +select volatility_is('camper', 'edit_booking_from_payment', array['uuid', 'uuid'], 'volatile'); +select function_privs_are('camper', 'edit_booking_from_payment', array ['uuid', 'uuid'], 'guest', array[]::text[]); +select function_privs_are('camper', 'edit_booking_from_payment', array ['uuid', 'uuid'], 'employee', array['EXECUTE']); +select function_privs_are('camper', 'edit_booking_from_payment', array ['uuid', 'uuid'], 'admin', array['EXECUTE']); +select function_privs_are('camper', 'edit_booking_from_payment', array ['uuid', 'uuid'], 'authenticator', array[]::text[]); + + +set client_min_messages to warning; +truncate booking_option cascade; +truncate booking cascade; +truncate payment_option cascade; +truncate payment cascade; +truncate campsite_type_option cascade; +truncate campsite_type cascade; +truncate media cascade; +truncate media_content cascade; +truncate company cascade; +reset client_min_messages; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') +; + +insert into media_content (media_type, bytes) +values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +; + +insert into media (media_id, company_id, original_filename, content_hash) +values (6, 2, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) +; + +insert into campsite_type (campsite_type_id, company_id, name, media_id, max_campers, bookable_nights) +values (12, 2, 'Wooden lodge', 6, 7, '[1, 7]') + , (14, 2, 'Bungalow', 6, 7, '[1, 7]') + , (16, 2, 'Plot', 6, 7, '[1, 7]') +; + +insert into campsite_type_option (campsite_type_option_id, campsite_type_id, name, range, per_night) +values (18, 12, 'Big tent', '[0, 4)', true) + , (20, 12, 'Small tent', '[0, 4)', true) + , (22, 14, 'Electricity', '[0, 5)', false) + , (24, 14, 'Car', '[0, 4)', true) + , (26, 16, 'Autocaravan', '[0, 4)', true) +; + +insert into payment (payment_id, 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, zone_preferences, acsi_card, payment_status) +values (28, '4ef35e2f-ef98-42d6-a724-913bd761ca8c', 2, 12, '2024-08-28', '2024-09-04', 3200, 2, 10420, 4, 20840, 6, 25080, 3, 2450, 4900, 79160, 'EUR', 'pref I before E', true, 'draft') +; + +insert into payment_option (payment_id, campsite_type_option_id, units, subtotal) +values (28, 18, 1, 1500) +; + +insert into booking (booking_id, slug, company_id, campsite_type_id, stay, holder_name, address, postal_code, city, country_code, email, phone, lang_tag, 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, currency_code, booking_status) +values (30, 'e3c478f1-8895-4cc6-b644-b19b5a553bb9', 2, 14, daterange('2024-08-29', '2024-09-03'), 'First', 'Fake St., 123', '17800', 'Girona', 'ES', 'customer@example.com', '+34 977 97 79 77', 'ca', '', 71000, 1, 0, 2, 0, 3, 0, 0, 0, 1750, 72750, false, 'EUR', 'confirmed'); + +insert into booking_option (booking_id, campsite_type_option_id, units, subtotal) +values (30, 22, 2, 111) + , (30, 24, 3, 2222) +; + +select lives_ok( + $$ select edit_booking_from_payment('e3c478f1-8895-4cc6-b644-b19b5a553bb9', '4ef35e2f-ef98-42d6-a724-913bd761ca8c') $$, + 'Should be able to update a booking from a payment' +); + +select bag_eq( + $$ select booking_id, company_id, campsite_type_id, stay, holder_name, address, postal_code, city, country_code::text, email::text, phone::text, lang_tag, zone_preferences, subtotal_nights::integer, number_adults::integer, subtotal_adults::integer, number_teenagers::integer, subtotal_teenagers::integer, number_children::integer, subtotal_children::integer, number_dogs::integer, subtotal_dogs::integer, subtotal_tourist_tax::integer, total::integer, acsi_card, currency_code::text, booking_status from booking $$, + $$ values (30, 2, 12, daterange('2024-08-28', '2024-09-04'), 'First', 'Fake St., 123', '17800', 'Girona', 'ES', 'customer@example.com', '+34 977 97 79 77', 'ca', 'pref I before E', 3200, 2, 10420, 4, 20840, 6, 25080, 3, 2450, 4900, 79160, true, 'EUR', 'confirmed') + $$, + 'Should have updated the booking' +); + +select bag_eq ( + $$ select booking_id, campsite_type_option_id, units, subtotal from booking_option $$, + $$ values (30, 18, 1, 1500) + $$ , + 'Should have updated the booking options too' +); + +select * +from finish(); + +rollback; diff --git a/verify/edit_booking_from_payment.sql b/verify/edit_booking_from_payment.sql new file mode 100644 index 0000000..b2a4f79 --- /dev/null +++ b/verify/edit_booking_from_payment.sql @@ -0,0 +1,7 @@ +-- Verify camper:edit_booking_from_payment on pg + +begin; + +select has_function_privilege('camper.edit_booking_from_payment(uuid, uuid)', 'execute'); + +rollback; diff --git a/web/static/camper.css b/web/static/camper.css index 3f5b3a8..f7fd36c 100644 --- a/web/static/camper.css +++ b/web/static/camper.css @@ -882,7 +882,7 @@ label[x-show] > span, label[x-show] > br { grid-column: span 2; } -#booking-form #campsites-booking { +#booking-form #campsites-booking, #booking-form .error { grid-column: 1 / -1; } diff --git a/web/templates/admin/booking/fields.gohtml b/web/templates/admin/booking/fields.gohtml index 388aa03..01ef2b6 100644 --- a/web/templates/admin/booking/fields.gohtml +++ b/web/templates/admin/booking/fields.gohtml @@ -4,277 +4,271 @@ SPDX-License-Identifier: AGPL-3.0-only --> {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.adminBookingForm*/ -}} -{{ CSRFInput }} -
- - {{ with .CampsiteType -}} - - {{- end }} - {{ with .Dates -}} -
+{{ if .Error -}} +

{{ .Error }}

+{{- end }} +{{ with .CampsiteType -}} + - {{- end }} - {{ with .DepartureDate -}} - - {{- end }} -
- {{- end }} - {{ with .Options -}} - {{ with .ZonePreferences -}} - +{{- end }} +{{ with .Dates -}} +
+ {{ with .ArrivalDate -}} + {{- end }} + {{ with .DepartureDate -}} + + {{- end }} +
+{{- end }} +{{ with .Options -}} + {{ with .ZonePreferences -}} + {{- end }} - {{ with $guests := .Guests -}} - {{ $draft := $.Cart.Draft }} -
- - +{{- end }} +{{ with $guests := .Guests -}} + {{ $draft := $.Cart.Draft }} +
+
+ + + + + + + + + + + + + + {{ with .NumberAdults -}} - - - + + + - - + {{- end }} + {{ with .NumberTeenagers -}} - - - + + + - {{ with .NumberAdults -}} - - - - - - {{- end }} - {{ with .NumberTeenagers -}} + {{- end }} + {{ with .NumberChildren -}} + + + + + + {{- end }} + {{ with .NumberDogs -}} + + + + + + {{- end }} + {{ with $.Options -}} + {{ range .Options -}} - - - - {{- end }} - {{ with .NumberChildren -}} - - - - - - {{- end }} - {{ with .NumberDogs -}} - - - - - - {{- end }} - {{ with $.Options -}} - {{ range .Options -}} - - - - - - {{- end }} + + + + {{- end }} - - - - - - - - - - - - -
{{( pgettext "Units" "header" )}}{{( pgettext "Decription" "header" )}}{{( pgettext "Total" "header" )}}
{{ $draft.NumNights }}{{( pgettext "Night" "cart" )}}{{ formatPrice $draft.Nights }}
{{( pgettext "Units" "header" )}}{{( pgettext "Decription" "header" )}}{{( pgettext "Total" "header" )}} + +
+ {{ template "error-message" . }} +
{{ formatPrice $draft.Adults }}
{{ $draft.NumNights }}{{( pgettext "Night" "cart" )}}{{ formatPrice $draft.Nights }} + + +
+ {{ template "error-message" . }} +
{{ formatPrice $draft.Teenagers }}
- -
- {{ template "error-message" . }} -
{{ formatPrice $draft.Adults }}
+ + +
+ {{ template "error-message" . }} +
{{ formatPrice $draft.Children }}
+ + +
+ {{ template "error-message" . }} +
{{ formatPrice $draft.Dogs }}
- - -
- {{ template "error-message" . }} -
{{ formatPrice $draft.Teenagers }}
- - -
- {{ template "error-message" . }} -
{{ formatPrice $draft.Children }}
- - -
- {{ template "error-message" . }} -
{{ formatPrice $draft.Dogs }}
- - -
- {{ template "error-message" .Input }} -
{{ formatPrice .Subtotal }}
+
+ {{ template "error-message" .Input }} +
{{ formatPrice .Subtotal }}
{{ $draft.NumAdults }}{{( pgettext "Tourist tax" "cart" )}}{{ formatPrice $draft.TouristTax }}
{{( pgettext "Total" "header" )}}{{ formatPrice $draft.Total }}
- {{ if not .NumberDogs -}} - {{( gettext "Note: This accommodation does not allow dogs.") | raw }} {{- end }} - {{ if .Error -}} -

{{ .Error }}

- {{- end }} -
+ + {{ $draft.NumAdults }} + {{( pgettext "Tourist tax" "cart" )}} + {{ formatPrice $draft.TouristTax }} + + + + + {{( pgettext "Total" "header" )}} + {{ formatPrice $draft.Total }} + + + + {{ if not .NumberDogs -}} + {{( gettext "Note: This accommodation does not allow dogs.") | raw }} + {{- end }} + {{ if .Error -}} +

{{ .Error }}

+ {{- end }} +
+{{- end }} +{{ with .Customer -}} +
+ {{( pgettext "Customer Details" "title" )}} + {{ with .FullName -}} + + {{- end }} + {{ with .Country -}} + + {{- end }} + {{ with .Address -}} + + {{- end }} + {{ with .PostalCode -}} + + {{- end }} + {{ with .City -}} + + {{- end }} + {{ with .Email -}} + + {{- end }} + {{ with .Phone -}} + + {{- end }} + {{ with $.Guests.ACSICard -}} + + {{- end }} +
+{{- end }} +{{ if .Campsites -}} +

{{( pgettext "Campsites" "title" )}}

+ {{ template "grid.gohtml" . }} + {{ if .Error -}} +

{{ .Error }}

{{- end }} - {{ with .Customer -}} -
- {{( pgettext "Customer Details" "title" )}} - {{ with .FullName -}} - - {{- end }} - {{ with .Country -}} - - {{- end }} - {{ with .Address -}} - - {{- end }} - {{ with .PostalCode -}} - - {{- end }} - {{ with .City -}} - - {{- end }} - {{ with .Email -}} - - {{- end }} - {{ with .Phone -}} - - {{- end }} - {{ with $.Guests.ACSICard -}} - - {{- end }} -
- {{- end }} - {{ if .Campsites -}} -

{{( pgettext "Campsites" "title" )}}

- {{ template "grid.gohtml" . }} - {{- end }} - -
- -
+{{- end }} {{ define "campsite-heading" -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.CampsiteEntry*/ -}} @@ -282,6 +276,6 @@ {{ .Label }} {{- end }} diff --git a/web/templates/admin/booking/form.gohtml b/web/templates/admin/booking/form.gohtml index 7d8e851..b0f9539 100644 --- a/web/templates/admin/booking/form.gohtml +++ b/web/templates/admin/booking/form.gohtml @@ -11,6 +11,10 @@ {{ end }} {{- end }} +{{ define "head" -}} + +{{- end }} + {{ define "breadcrumb" -}}
  • {{( pgettext "Bookings" "title" )}}
  • {{- end }} @@ -18,19 +22,28 @@ {{ define "content" -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.adminBookingForm*/ -}}

    {{ template "title" .}}

    - {{ if .Error -}} -

    {{ .Error }}

    - {{- end }}
    - {{ template "fields.gohtml" . }} + {{ CSRFInput }} +
    + {{ template "fields.gohtml" . }} +
    +
    {{- end }}