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
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
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
|
||||
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 -}}
|
||||
<a href="{{ .URL }}/check-in">{{( pgettext "Check-in Booking" "action" )}}</a>
|
||||
{{- end }}
|
||||
{{ if .ID -}}
|
||||
<a href="/admin/invoices/new?booking={{ .ID }}">{{( pgettext "Invoice Booking" "action" )}}</a>
|
||||
{{- end }}
|
||||
<h2>{{ template "title" .}}</h2>
|
||||
<form id="booking-form"
|
||||
data-hx-ext="morph"
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
data-hx-swap="morph:innerHTML show:false"
|
||||
>
|
||||
{{ CSRFInput }}
|
||||
{{ with .BookingID }}<input type="hidden" name="{{ .Name }}" value="{{ .Val }}">{{ end }}
|
||||
|
||||
{{ with .RemovedProduct -}}
|
||||
<div role="alert">
|
||||
|
|
Loading…
Reference in New Issue