diff --git a/deploy/booking_invoice.sql b/deploy/booking_invoice.sql new file mode 100644 index 0000000..fca7827 --- /dev/null +++ b/deploy/booking_invoice.sql @@ -0,0 +1,20 @@ +-- 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; diff --git a/pkg/database/funcs.go b/pkg/database/funcs.go index 00f3691..0eb2f6c 100644 --- a/pkg/database/funcs.go +++ b/pkg/database/funcs.go @@ -371,8 +371,8 @@ func (c *Conn) CheckInGuests(ctx context.Context, bookingSlug string, guests []* return err } -func (c *Conn) AddInvoice(ctx context.Context, companyID int, date string, customerID int, notes string, paymentMethodID int, products NewInvoiceProductArray) (string, error) { - return c.GetText(ctx, "select add_invoice($1, $2, $3, $4, $5, $6)", companyID, date, customerID, notes, paymentMethodID, products) +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 (c *Conn) EditInvoice(ctx context.Context, invoiceSlug string, invoiceStatus string, contactID int, notes string, paymentMethodID int, products EditedInvoiceProductArray) (string, error) { diff --git a/pkg/invoice/admin.go b/pkg/invoice/admin.go index 6698950..bda8f60 100644 --- a/pkg/invoice/admin.go +++ b/pkg/invoice/admin.go @@ -63,8 +63,8 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat f.MustFillFromDatabase(r.Context(), conn, user.Locale, invoiceToDuplicate) f.Slug = "" f.InvoiceStatus.Selected = []string{"created"} - } else if quoteToInvoice := r.URL.Query().Get("quote"); uuid.Valid(quoteToInvoice) { - f.MustFillFromQuote(r.Context(), conn, user.Locale, quoteToInvoice) + } else if bookingToInvoice, err := strconv.Atoi(r.URL.Query().Get("booking")); err == nil { + f.MustFillFromBooking(r.Context(), conn, user.Locale, bookingToInvoice) } f.Date.Val = time.Now().Format("2006-01-02") f.MustRender(w, r, user, company, conn) @@ -605,10 +605,30 @@ func addInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company f.MustRender(w, r, user, company, conn) return } - slug, err := conn.AddInvoice(r.Context(), company.ID, f.Date.Val, f.Customer.Int(), f.Notes.Val, defaultPaymentMethod, newInvoiceProducts(f.Products)) + tx := conn.MustBegin(r.Context()) + defer tx.Rollback(r.Context()) + slug, err := tx.AddInvoice(r.Context(), company.ID, f.Date.Val, f.Customer.Int(), f.Notes.Val, defaultPaymentMethod, newInvoiceProducts(f.Products)) if err != nil { panic(err) } + if bookingID, err := strconv.Atoi(f.BookingID.Val); err == nil { + if _, err := tx.Exec(r.Context(), + "insert into booking_invoice (booking_id, invoice_id) select $1, invoice_id from invoice where slug = $2", + bookingID, + slug, + ); err != nil { + panic(err) + } + if _, err := tx.Exec(r.Context(), + "update booking set booking_status = 'invoiced' where booking_id = $1", + bookingID, + ); err != nil { + panic(err) + } + } + tx.MustCommit(r.Context()) + httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther) + httplib.Redirect(w, r, "/admin/invoices/"+slug, http.StatusSeeOther) } @@ -757,6 +777,7 @@ func mustMakeTaxMap(ctx context.Context, conn *database.Conn, ids *pgtype.Int4Ar type invoiceForm struct { Slug string Number string + BookingID *form.Input company *auth.Company InvoiceStatus *form.Select Customer *form.Select @@ -772,6 +793,9 @@ type invoiceForm struct { func newInvoiceForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *invoiceForm { return &invoiceForm{ company: company, + BookingID: &form.Input{ + Name: "booking_id", + }, InvoiceStatus: &form.Select{ Name: "invoice_status", Selected: []string{"created"}, @@ -804,6 +828,7 @@ func (f *invoiceForm) Parse(r *http.Request, conn *database.Conn, l *locale.Loca if err := r.ParseForm(); err != nil { return err } + f.BookingID.FillValue(r) f.InvoiceStatus.FillValue(r) f.Customer.FillValue(r) f.Date.FillValue(r) @@ -975,29 +1000,54 @@ func (f *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *database.C return true } -func (f *invoiceForm) MustFillFromQuote(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) bool { - var quoteId int - note := l.Gettext("Re: quotation #%s of %s") +func (f *invoiceForm) MustFillFromBooking(ctx context.Context, conn *database.Conn, l *locale.Locale, bookingID int) bool { + note := l.Gettext("Re: booking #%s of %s–%s") dateFormat := l.Pgettext("MM/DD/YYYY", "to_char") err := conn.QueryRow(ctx, ` - select quote_id - , coalesce(contact_id::text, '') - , (case when length(trim(notes)) = 0 then '' else notes || E'\n\n' end) || format($2, quote_number, to_char(quote_date, $3)) - , coalesce(payment_method_id::text, $4) - , tags - from quote - left join quote_contact using (quote_id) - left join quote_payment_method using (quote_id) - where slug = $1 - `, slug, note, dateFormat).Scan("eId, f.Customer, f.Notes) + select format($2, left(slug::text, 10), to_char(lower(stay), $3), to_char(upper(stay), $3)) + from booking + where booking_id = $1 + `, bookingID, note, dateFormat).Scan(&f.Notes.Val) if err != nil { if database.ErrorIsNotFound(err) { return false } panic(err) } + f.BookingID.Val = strconv.Itoa(bookingID) f.Products = []*invoiceProductForm{} - f.mustAddProductsFromQuery(ctx, conn, l, "select '', coalesce(product_id::text, ''), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id::text, ''), name, description, discount_rate, price, quantity", quoteId, f.company.DecimalDigits) + f.mustAddProductsFromQuery(ctx, conn, l, ` + select '', '', quantity || ' × ' || product.name, '', to_price(round(price / (1 + rate))::integer, $2), '1', '0', array[tax_id::text] from ( + select $4 as name, subtotal_nights as price, upper(stay) - lower(stay) as quantity, 2 as tax_id from booking where booking_id = $1 + union all + select $5, subtotal_adults, number_adults, 2 from booking where booking_id = $1 + union all + select $6, subtotal_teenagers, number_teenagers, 2 from booking where booking_id = $1 + union all + select $7, subtotal_children, number_children, 2 from booking where booking_id = $1 + union all + select $8, subtotal_dogs, number_dogs, 2 from booking where booking_id = $1 + union all + select coalesce(i18n.name, type_option.name), subtotal, units, 2 + from booking_option + join campsite_type_option as type_option using (campsite_type_option_id) + left join campsite_type_option_i18n as i18n on i18n.campsite_type_option_id = type_option.campsite_type_option_id and lang_tag = $3 + union all + select $9, subtotal_tourist_tax, number_adults, 4 from booking where booking_id = $1 + ) as product + join tax using (tax_id) + where quantity > 0 + `, + bookingID, + f.company.DecimalDigits, + l.Language, + l.Pgettext("Night", "cart"), + l.Pgettext("Adults aged 17 or older", "input"), + l.Pgettext("Teenagers from 11 to 16 years old", "input"), + l.Pgettext("Children from 2 to 10 years old", "input"), + l.Pgettext("Dogs", "input"), + l.Pgettext("Tourist tax", "cart"), + ) return true } @@ -1108,7 +1158,6 @@ func (f *invoiceProductForm) Validate(l *locale.Locale) bool { } } v.CheckSelectedOptions(f.Tax, l.GettextNoop("Selected tax is not valid.")) - // TODO v.CheckAtMostOneOfEachGroup(f.Tax, l.GettextNoop("You can only select a tax of each class.")) return v.AllOK } diff --git a/revert/booking_invoice.sql b/revert/booking_invoice.sql new file mode 100644 index 0000000..48a6a57 --- /dev/null +++ b/revert/booking_invoice.sql @@ -0,0 +1,7 @@ +-- Revert camper:booking_invoice from pg + +begin; + +drop table if exists camper.booking_invoice; + +commit; diff --git a/sqitch.plan b/sqitch.plan index 11d27cf..9fcf71e 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -329,3 +329,4 @@ edited_invoice_product [roles schema_camper discount_rate] 2024-04-27T23:54:24Z edit_invoice [roles schema_camper invoice currency parse_price edited_invoice_product tax invoice_product invoice_product_product invoice_product_tax] 2024-04-27T23:54:50Z jordi fita mas # Add function to edit invoices add_contact [roles schema_camper email extension_pg_libphonenumber country_code contact contact_email contact_phone] 2024-04-28T14:21:37Z jordi fita mas # Add function to create new contacts edit_contact [roles schema_camper email country_code contact extension_pg_libphonenumber contact_email contact_phone] 2024-04-28T14:21:27Z jordi fita mas # Add function to edit contacts +booking_invoice [roles schema_camper booking invoice] 2024-04-28T19:45:05Z jordi fita mas # Add relation of booking invoices diff --git a/test/booking_invoice.sql b/test/booking_invoice.sql new file mode 100644 index 0000000..1ed0319 --- /dev/null +++ b/test/booking_invoice.sql @@ -0,0 +1,39 @@ +-- Test booking_invoice +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(19); + +set search_path to camper, public; + +select has_table('booking_invoice'); +select has_pk('booking_invoice'); +select col_is_pk('booking_invoice', array['booking_id', 'invoice_id']); +select table_privs_are('booking_invoice', 'guest', array[]::text[]); +select table_privs_are('booking_invoice', 'employee', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('booking_invoice', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('booking_invoice', 'authenticator', array[]::text[]); + +select has_column('booking_invoice', 'booking_id'); +select col_is_fk('booking_invoice', 'booking_id'); +select fk_ok('booking_invoice', 'booking_id', 'booking', 'booking_id'); +select col_type_is('booking_invoice', 'booking_id', 'integer'); +select col_not_null('booking_invoice', 'booking_id'); +select col_hasnt_default('booking_invoice', 'booking_id'); + +select has_column('booking_invoice', 'invoice_id'); +select col_is_fk('booking_invoice', 'invoice_id'); +select fk_ok('booking_invoice', 'invoice_id', 'invoice', 'invoice_id'); +select col_type_is('booking_invoice', 'invoice_id', 'integer'); +select col_not_null('booking_invoice', 'invoice_id'); +select col_hasnt_default('booking_invoice', 'invoice_id'); + + +select * +from finish(); + +rollback; + diff --git a/verify/booking_invoice.sql b/verify/booking_invoice.sql new file mode 100644 index 0000000..5bbc329 --- /dev/null +++ b/verify/booking_invoice.sql @@ -0,0 +1,10 @@ +-- Verify camper:booking_invoice on pg + +begin; + +select booking_id + , invoice_id +from camper.booking_invoice +where false; + +rollback; diff --git a/web/templates/admin/booking/form.gohtml b/web/templates/admin/booking/form.gohtml index f086dc5..00827c6 100644 --- a/web/templates/admin/booking/form.gohtml +++ b/web/templates/admin/booking/form.gohtml @@ -24,6 +24,9 @@ {{ if .ID -}} {{( pgettext "Check-in Booking" "action" )}} {{- end }} + {{ if .ID -}} + {{( pgettext "Invoice Booking" "action" )}} + {{- end }}

{{ template "title" .}}

{{ CSRFInput }} + {{ with .BookingID }}{{ end }} {{ with .RemovedProduct -}}