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:
parent
205d1f1e99
commit
ff9f33dfba
|
@ -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;
|
|
@ -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) {
|
||||||
|
|
|
@ -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("eId, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Revert camper:booking_invoice from pg
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
drop table if exists camper.booking_invoice;
|
||||||
|
|
||||||
|
commit;
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Verify camper:booking_invoice on pg
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
select booking_id
|
||||||
|
, invoice_id
|
||||||
|
from camper.booking_invoice
|
||||||
|
where false;
|
||||||
|
|
||||||
|
rollback;
|
|
@ -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"
|
||||||
|
|
|
@ -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">
|
||||||
|
|
Loading…
Reference in New Issue