Add invoicing of bookings

It is a bit user-hostile because you have to create a new customer prior
to create the invoice, but that’s what it is for now.
This commit is contained in:
jordi fita mas 2024-04-28 21:56:51 +02:00
parent 205d1f1e99
commit ff9f33dfba
9 changed files with 150 additions and 20 deletions

View File

@ -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;

View File

@ -371,8 +371,8 @@ func (c *Conn) CheckInGuests(ctx context.Context, bookingSlug string, guests []*
return err return err
} }
func (c *Conn) AddInvoice(ctx context.Context, companyID int, date string, customerID int, notes string, paymentMethodID int, products NewInvoiceProductArray) (string, error) { func (tx *Tx) 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) 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) { func (c *Conn) EditInvoice(ctx context.Context, invoiceSlug string, invoiceStatus string, contactID int, notes string, paymentMethodID int, products EditedInvoiceProductArray) (string, error) {

View File

@ -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.MustFillFromDatabase(r.Context(), conn, user.Locale, invoiceToDuplicate)
f.Slug = "" f.Slug = ""
f.InvoiceStatus.Selected = []string{"created"} f.InvoiceStatus.Selected = []string{"created"}
} else if quoteToInvoice := r.URL.Query().Get("quote"); uuid.Valid(quoteToInvoice) { } else if bookingToInvoice, err := strconv.Atoi(r.URL.Query().Get("booking")); err == nil {
f.MustFillFromQuote(r.Context(), conn, user.Locale, quoteToInvoice) f.MustFillFromBooking(r.Context(), conn, user.Locale, bookingToInvoice)
} }
f.Date.Val = time.Now().Format("2006-01-02") f.Date.Val = time.Now().Format("2006-01-02")
f.MustRender(w, r, user, company, conn) 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) f.MustRender(w, r, user, company, conn)
return 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 { if err != nil {
panic(err) 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) 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 { type invoiceForm struct {
Slug string Slug string
Number string Number string
BookingID *form.Input
company *auth.Company company *auth.Company
InvoiceStatus *form.Select InvoiceStatus *form.Select
Customer *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 { func newInvoiceForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *invoiceForm {
return &invoiceForm{ return &invoiceForm{
company: company, company: company,
BookingID: &form.Input{
Name: "booking_id",
},
InvoiceStatus: &form.Select{ InvoiceStatus: &form.Select{
Name: "invoice_status", Name: "invoice_status",
Selected: []string{"created"}, 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 { if err := r.ParseForm(); err != nil {
return err return err
} }
f.BookingID.FillValue(r)
f.InvoiceStatus.FillValue(r) f.InvoiceStatus.FillValue(r)
f.Customer.FillValue(r) f.Customer.FillValue(r)
f.Date.FillValue(r) f.Date.FillValue(r)
@ -975,29 +1000,54 @@ func (f *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *database.C
return true return true
} }
func (f *invoiceForm) MustFillFromQuote(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) bool { func (f *invoiceForm) MustFillFromBooking(ctx context.Context, conn *database.Conn, l *locale.Locale, bookingID int) bool {
var quoteId int note := l.Gettext("Re: booking #%s of %s%s")
note := l.Gettext("Re: quotation #%s of %s")
dateFormat := l.Pgettext("MM/DD/YYYY", "to_char") dateFormat := l.Pgettext("MM/DD/YYYY", "to_char")
err := conn.QueryRow(ctx, ` err := conn.QueryRow(ctx, `
select quote_id select format($2, left(slug::text, 10), to_char(lower(stay), $3), to_char(upper(stay), $3))
, coalesce(contact_id::text, '') from booking
, (case when length(trim(notes)) = 0 then '' else notes || E'\n\n' end) || format($2, quote_number, to_char(quote_date, $3)) where booking_id = $1
, coalesce(payment_method_id::text, $4) `, bookingID, note, dateFormat).Scan(&f.Notes.Val)
, 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(&quoteId, f.Customer, f.Notes)
if err != nil { if err != nil {
if database.ErrorIsNotFound(err) { if database.ErrorIsNotFound(err) {
return false return false
} }
panic(err) panic(err)
} }
f.BookingID.Val = strconv.Itoa(bookingID)
f.Products = []*invoiceProductForm{} 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 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.")) 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 return v.AllOK
} }

View File

@ -0,0 +1,7 @@
-- Revert camper:booking_invoice from pg
begin;
drop table if exists camper.booking_invoice;
commit;

View File

@ -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 <jordi@tandem.blog> # Add function to edit invoices 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 <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add function to create new contacts add_contact [roles schema_camper email extension_pg_libphonenumber country_code contact contact_email contact_phone] 2024-04-28T14:21:37Z jordi fita mas <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add function to edit 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 <jordi@tandem.blog> # Add function to edit contacts
booking_invoice [roles schema_camper booking invoice] 2024-04-28T19:45:05Z jordi fita mas <jordi@tandem.blog> # Add relation of booking invoices

39
test/booking_invoice.sql Normal file
View File

@ -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;

View File

@ -0,0 +1,10 @@
-- Verify camper:booking_invoice on pg
begin;
select booking_id
, invoice_id
from camper.booking_invoice
where false;
rollback;

View File

@ -24,6 +24,9 @@
{{ if .ID -}} {{ if .ID -}}
<a href="{{ .URL }}/check-in">{{( pgettext "Check-in Booking" "action" )}}</a> <a href="{{ .URL }}/check-in">{{( pgettext "Check-in Booking" "action" )}}</a>
{{- end }} {{- end }}
{{ if .ID -}}
<a href="/admin/invoices/new?booking={{ .ID }}">{{( pgettext "Invoice Booking" "action" )}}</a>
{{- end }}
<h2>{{ template "title" .}}</h2> <h2>{{ template "title" .}}</h2>
<form id="booking-form" <form id="booking-form"
data-hx-ext="morph" data-hx-ext="morph"

View File

@ -26,6 +26,7 @@
data-hx-swap="morph:innerHTML show:false" data-hx-swap="morph:innerHTML show:false"
> >
{{ CSRFInput }} {{ CSRFInput }}
{{ with .BookingID }}<input type="hidden" name="{{ .Name }}" value="{{ .Val }}">{{ end }}
{{ with .RemovedProduct -}} {{ with .RemovedProduct -}}
<div role="alert"> <div role="alert">