diff --git a/deploy/compute_new_expense_amount.sql b/deploy/compute_new_expense_amount.sql new file mode 100644 index 0000000..5d70f59 --- /dev/null +++ b/deploy/compute_new_expense_amount.sql @@ -0,0 +1,65 @@ +-- Deploy numerus:compute_new_expense_amount to pg +-- requires: schema_numerus +-- requires: roles +-- requires: company +-- requires: tax +-- requires: new_expense_amount + +begin; + +set search_path to numerus, public; + +create or replace function compute_new_expense_amount(company_id integer, subtotal text, taxes integer[]) returns new_expense_amount as +$$ +declare + result new_expense_amount; +begin + if array_length(taxes, 1) > 0 then + with line as ( + select round(parse_price(subtotal, currency.decimal_digits)) as price + , tax_id + , decimal_digits + from unnest (taxes) as tax(tax_id) + join company on company.company_id = compute_new_expense_amount.company_id + join currency using (currency_code) + ) + , tax_amount as ( + select tax_id + , sum(round(price * tax.rate)::integer)::integer as amount + , decimal_digits + from line + join tax using (tax_id) + group by tax_id, decimal_digits + ) + , tax_total as ( + select sum(amount)::integer as amount + , array_agg(array[name, to_price(amount, decimal_digits)]) as taxes + from tax_amount + join tax using (tax_id) + ) + select coalesce(tax_total.taxes, array[]::text[][]) + , to_price(price::integer + coalesce(tax_total.amount, 0), decimal_digits) as total + from line, tax_total + into result.taxes, result.total; + else + select array[]::text[][] + , to_price(coalesce(parse_price(subtotal, decimal_digits), 0), decimal_digits) + from company + join currency using (currency_code) + where company.company_id = compute_new_expense_amount.company_id + into result.taxes, result.total + ; + end if; + + return result; +end; +$$ + language plpgsql + stable +; + +revoke execute on function compute_new_expense_amount(integer, text, integer[]) from public; +grant execute on function compute_new_expense_amount(integer, text, integer[]) to invoicer; +grant execute on function compute_new_expense_amount(integer, text, integer[]) to admin; + +commit; diff --git a/deploy/new_expense_amount.sql b/deploy/new_expense_amount.sql new file mode 100644 index 0000000..a8ddf4d --- /dev/null +++ b/deploy/new_expense_amount.sql @@ -0,0 +1,13 @@ +-- Deploy numerus:new_expense_amount to pg +-- requires: schema_numerus + +begin; + +set search_path to numerus, public; + +create type new_expense_amount as ( + taxes text[][], + total text +); + +commit; diff --git a/pkg/dashboard.go b/pkg/dashboard.go index cb0d639..a776beb 100644 --- a/pkg/dashboard.go +++ b/pkg/dashboard.go @@ -66,7 +66,7 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params) rows := conn.MustQuery(r.Context(), fmt.Sprintf(` select to_price(0, decimal_digits) as sales , to_price(coalesce(invoice.total, 0), decimal_digits) as income - , to_price(coalesce(expense.total, 0), decimal_digits) as expenses + , to_price(coalesce(expense.total, 0) + coalesce(expense_tax.vat, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as expenses , to_price(coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0), decimal_digits) as vat , to_price(coalesce(invoice_tax.irpf, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as irpf , to_price(coalesce(invoice.total, 0) - coalesce(expense.total, 0) - (coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0)) + coalesce(expense_tax.irpf, 0), decimal_digits) as net_income @@ -201,9 +201,21 @@ func buildDashboardChart(ctx context.Context, conn *Conn, locale *Locale, compan ) as invoice left join ( select to_char(date.invoice_date, '%[3]s')::integer as date - , sum(amount)::integer as total + , sum(subtotal + taxes)::integer as total from generate_series(%[1]s, %[2]s, interval '1 day') as date(invoice_date) - left join expense on expense.invoice_date = date.invoice_date and company_id = $1 + left join ( + select expense_id + , invoice_date + , expense.amount as subtotal + , coalesce(sum(tax.amount)::integer, 0) as taxes + from expense + left join expense_tax_amount as tax using (expense_id) + where company_id = $1 + group by expense_id + , invoice_date + , expense.amount + ) as expense + on expense.invoice_date = date.invoice_date group by date ) as expense using (date) order by date diff --git a/pkg/expenses.go b/pkg/expenses.go index 102f04b..049c665 100644 --- a/pkg/expenses.go +++ b/pkg/expenses.go @@ -55,7 +55,7 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, locale *Locale, select expense.slug , invoice_date , invoice_number - , to_price(amount, decimal_digits) + , to_price(expense.amount + coalesce(sum(tax.amount)::integer, 0), decimal_digits) , contact.name , coalesce(attachment.original_filename, '') , expense.tags @@ -63,10 +63,21 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, locale *Locale, , esi18n.name from expense left join expense_attachment as attachment using (expense_id) + left join expense_tax_amount as tax using (expense_id) join contact using (contact_id) join expense_status_i18n esi18n on expense.expense_status = esi18n.expense_status and esi18n.lang_tag = $1 join currency using (currency_code) where (%s) + group by expense.slug + , invoice_date + , invoice_number + , expense.amount + , decimal_digits + , contact.name + , attachment.original_filename + , expense.tags + , expense.expense_status + , esi18n.name order by invoice_date `, where), args...) defer rows.Close() @@ -114,10 +125,20 @@ func mustCollectExpenseStatuses(ctx context.Context, conn *Conn, locale *Locale) func mustComputeExpensesTotalAmount(ctx context.Context, conn *Conn, filters *expenseFilterForm) string { where, args := filters.BuildQuery(nil) return conn.MustGetText(ctx, "0", fmt.Sprintf(` - select to_price(sum(amount)::integer, decimal_digits) - from expense + select to_price(sum(subtotal + taxes)::integer, decimal_digits) + from ( + select expense_id + , expense.amount as subtotal + , coalesce(sum(tax.amount)::integer, 0) as taxes + , currency_code + from expense + left join expense_tax_amount as tax using (expense_id) + where (%s) + group by expense_id + , expense.amount + , currency_code + ) as expense join currency using (currency_code) - where (%s) group by decimal_digits `, where), args...) } @@ -145,20 +166,40 @@ func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter. func mustRenderNewExpenseForm(w http.ResponseWriter, r *http.Request, form *expenseForm) { locale := getLocale(r) form.Invoicer.EmptyLabel = gettext("Select a contact.", locale) - mustRenderMainTemplate(w, r, "expenses/new.gohtml", form) + page := newNewExpensePage(form, r) + mustRenderMainTemplate(w, r, "expenses/new.gohtml", page) +} + +type newExpensePage struct { + Form *expenseForm + Taxes [][]string + Total string +} + +func newNewExpensePage(form *expenseForm, r *http.Request) *newExpensePage { + page := &newExpensePage{ + Form: form, + } + conn := getConn(r) + company := mustGetCompany(r) + err := conn.QueryRow(r.Context(), "select taxes, total from compute_new_expense_amount($1, $2, $3)", company.Id, form.Amount, form.Tax.Selected).Scan(&page.Taxes, &page.Total) + if err != nil { + panic(err) + } + return page } func mustRenderEditExpenseForm(w http.ResponseWriter, r *http.Request, slug string, form *expenseForm) { page := &editExpensePage{ - Slug: slug, - Form: form, + newNewExpensePage(form, r), + slug, } mustRenderMainTemplate(w, r, "expenses/edit.gohtml", page) } type editExpensePage struct { + *newExpensePage Slug string - Form *expenseForm } type expenseForm struct { @@ -175,6 +216,7 @@ type expenseForm struct { } func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *expenseForm { + triggerRecompute := template.HTMLAttr(`data-hx-on="change: this.dispatchEvent(new CustomEvent('recompute', {bubbles: true}))"`) return &expenseForm{ locale: locale, company: company, @@ -200,6 +242,9 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co Label: pgettext("input", "Taxes", locale), Multiple: true, Options: mustGetTaxOptions(ctx, conn, company), + Attributes: []template.HTMLAttr{ + triggerRecompute, + }, }, Amount: &InputField{ Name: "amount", @@ -207,6 +252,7 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co Type: "number", Required: true, Attributes: []template.HTMLAttr{ + triggerRecompute, `min="0"`, template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())), }, @@ -305,34 +351,6 @@ func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s } return true } -func HandleAddExpense(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - conn := getConn(r) - locale := getLocale(r) - company := mustGetCompany(r) - form := newExpenseForm(r.Context(), conn, locale, company) - if err := form.Parse(r); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := verifyCsrfTokenValid(r); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - if !form.Validate() { - if !IsHTMxRequest(r) { - w.WriteHeader(http.StatusUnprocessableEntity) - } - mustRenderNewExpenseForm(w, r, form) - return - } - taxes := mustSliceAtoi(form.Tax.Selected) - slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7, $8)", company.Id, form.ExpenseStatus, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags) - if len(form.File.Content) > 0 { - conn.MustQuery(r.Context(), "select attach_to_expense($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content) - } - htmxRedirect(w, r, companyURI(company, "/expenses")) -} - func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprouter.Params) { conn := getConn(r) locale := getLocale(r) @@ -541,3 +559,55 @@ func ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httpr w.WriteHeader(http.StatusOK) w.Write(content) } + +func HandleEditExpenseAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + slug := params[0].Value + actionUri := fmt.Sprintf("/invoices/%s/edit", slug) + handleExpenseAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *expenseForm) { + mustRenderEditExpenseForm(w, r, slug, form) + }) +} + +func HandleNewExpenseAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + handleExpenseAction(w, r, "/expenses", mustRenderNewExpenseForm) +} + +type renderExpenseFormFunc func(w http.ResponseWriter, r *http.Request, form *expenseForm) + +func handleExpenseAction(w http.ResponseWriter, r *http.Request, action string, renderForm renderExpenseFormFunc) { + locale := getLocale(r) + conn := getConn(r) + company := mustGetCompany(r) + form := newExpenseForm(r.Context(), conn, locale, company) + if err := form.Parse(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := verifyCsrfTokenValid(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + actionField := r.Form.Get("action") + switch actionField { + case "update": + // Nothing else to do + w.WriteHeader(http.StatusOK) + renderForm(w, r, form) + case "add": + if !form.Validate() { + if !IsHTMxRequest(r) { + w.WriteHeader(http.StatusUnprocessableEntity) + } + renderForm(w, r, form) + return + } + taxes := mustSliceAtoi(form.Tax.Selected) + slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7, $8)", company.Id, form.ExpenseStatus, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags) + if len(form.File.Content) > 0 { + conn.MustQuery(r.Context(), "select attach_to_expense($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content) + } + htmxRedirect(w, r, companyURI(company, action)) + default: + http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest) + } +} diff --git a/pkg/router.go b/pkg/router.go index 75e9f75..b3e6115 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -50,8 +50,9 @@ func NewRouter(db *Db) http.Handler { companyRouter.GET("/quotes/:slug/tags/edit", ServeEditQuoteTags) companyRouter.GET("/search/products", HandleProductSearch) companyRouter.GET("/expenses", IndexExpenses) - companyRouter.POST("/expenses", HandleAddExpense) + companyRouter.POST("/expenses", HandleNewExpenseAction) companyRouter.GET("/expenses/:slug", ServeExpenseForm) + companyRouter.POST("/expenses/:slug", HandleEditExpenseAction) companyRouter.PUT("/expenses/:slug", HandleUpdateExpense) companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags) companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags) diff --git a/po/ca.po b/po/ca.po index edbe772..6ff2afc 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-07-12 20:01+0200\n" +"POT-Creation-Date: 2023-07-13 20:43+0200\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -105,12 +105,14 @@ msgstr "Subtotal" #: web/template/invoices/view.gohtml:115 web/template/invoices/edit.gohtml:75 #: web/template/quotes/new.gohtml:74 web/template/quotes/view.gohtml:82 #: web/template/quotes/view.gohtml:122 web/template/quotes/edit.gohtml:75 +#: web/template/expenses/new.gohtml:45 web/template/expenses/edit.gohtml:49 msgctxt "title" msgid "Total" msgstr "Total" #: web/template/invoices/new.gohtml:92 web/template/invoices/edit.gohtml:93 #: web/template/quotes/new.gohtml:92 web/template/quotes/edit.gohtml:93 +#: web/template/expenses/new.gohtml:55 web/template/expenses/edit.gohtml:59 msgctxt "action" msgid "Update" msgstr "Actualitza" @@ -118,7 +120,7 @@ msgstr "Actualitza" #: web/template/invoices/new.gohtml:95 web/template/invoices/edit.gohtml:96 #: web/template/quotes/new.gohtml:95 web/template/quotes/edit.gohtml:96 #: web/template/contacts/new.gohtml:49 web/template/contacts/edit.gohtml:53 -#: web/template/expenses/new.gohtml:34 web/template/expenses/edit.gohtml:40 +#: web/template/expenses/new.gohtml:58 web/template/expenses/edit.gohtml:62 #: web/template/products/new.gohtml:30 web/template/products/edit.gohtml:36 msgctxt "action" msgid "Save" @@ -719,37 +721,37 @@ msgid "Name" msgstr "Nom" #: pkg/products.go:169 pkg/products.go:290 pkg/quote.go:174 pkg/quote.go:685 -#: pkg/expenses.go:228 pkg/expenses.go:417 pkg/invoices.go:174 +#: pkg/expenses.go:274 pkg/expenses.go:433 pkg/invoices.go:174 #: pkg/invoices.go:723 pkg/invoices.go:1295 pkg/contacts.go:145 #: pkg/contacts.go:348 msgctxt "input" msgid "Tags" msgstr "Etiquetes" -#: pkg/products.go:173 pkg/quote.go:178 pkg/expenses.go:427 pkg/invoices.go:178 +#: pkg/products.go:173 pkg/quote.go:178 pkg/expenses.go:443 pkg/invoices.go:178 #: pkg/contacts.go:149 msgctxt "input" msgid "Tags Condition" msgstr "Condició de les etiquetes" -#: pkg/products.go:177 pkg/quote.go:182 pkg/expenses.go:431 pkg/invoices.go:182 +#: pkg/products.go:177 pkg/quote.go:182 pkg/expenses.go:447 pkg/invoices.go:182 #: pkg/contacts.go:153 msgctxt "tag condition" msgid "All" msgstr "Totes" -#: pkg/products.go:178 pkg/expenses.go:432 pkg/invoices.go:183 +#: pkg/products.go:178 pkg/expenses.go:448 pkg/invoices.go:183 #: pkg/contacts.go:154 msgid "Invoices must have all the specified labels." msgstr "Les factures han de tenir totes les etiquetes." -#: pkg/products.go:182 pkg/quote.go:187 pkg/expenses.go:436 pkg/invoices.go:187 +#: pkg/products.go:182 pkg/quote.go:187 pkg/expenses.go:452 pkg/invoices.go:187 #: pkg/contacts.go:158 msgctxt "tag condition" msgid "Any" msgstr "Qualsevol" -#: pkg/products.go:183 pkg/expenses.go:437 pkg/invoices.go:188 +#: pkg/products.go:183 pkg/expenses.go:453 pkg/invoices.go:188 #: pkg/contacts.go:159 msgid "Invoices must have at least one of the specified labels." msgstr "Les factures han de tenir com a mínim una de les etiquetes." @@ -764,7 +766,7 @@ msgctxt "input" msgid "Price" msgstr "Preu" -#: pkg/products.go:284 pkg/quote.go:925 pkg/expenses.go:200 +#: pkg/products.go:284 pkg/quote.go:925 pkg/expenses.go:242 #: pkg/invoices.go:1040 msgctxt "input" msgid "Taxes" @@ -783,12 +785,12 @@ msgstr "No podeu deixar el preu en blanc." msgid "Price must be a number greater than zero." msgstr "El preu ha de ser un número major a zero." -#: pkg/products.go:313 pkg/quote.go:984 pkg/expenses.go:264 pkg/expenses.go:269 +#: pkg/products.go:313 pkg/quote.go:984 pkg/expenses.go:310 #: pkg/invoices.go:1099 msgid "Selected tax is not valid." msgstr "Heu seleccionat un impost que no és vàlid." -#: pkg/products.go:314 pkg/quote.go:985 pkg/expenses.go:265 pkg/expenses.go:270 +#: pkg/products.go:314 pkg/quote.go:985 pkg/expenses.go:311 #: pkg/invoices.go:1100 msgid "You can only select a tax of each class." msgstr "Només podeu seleccionar un impost de cada classe." @@ -1012,7 +1014,7 @@ msgctxt "input" msgid "Quotation Status" msgstr "Estat del pressupost" -#: pkg/quote.go:154 pkg/expenses.go:422 pkg/invoices.go:154 +#: pkg/quote.go:154 pkg/expenses.go:438 pkg/invoices.go:154 msgid "All status" msgstr "Tots els estats" @@ -1021,12 +1023,12 @@ msgctxt "input" msgid "Quotation Number" msgstr "Número de pressupost" -#: pkg/quote.go:164 pkg/expenses.go:407 pkg/invoices.go:164 +#: pkg/quote.go:164 pkg/expenses.go:423 pkg/invoices.go:164 msgctxt "input" msgid "From Date" msgstr "A partir de la data" -#: pkg/quote.go:169 pkg/expenses.go:412 pkg/invoices.go:169 +#: pkg/quote.go:169 pkg/expenses.go:428 pkg/invoices.go:169 msgctxt "input" msgid "To Date" msgstr "Fins la data" @@ -1043,8 +1045,8 @@ msgstr "Els pressuposts han de tenir com a mínim una de les etiquetes." msgid "quotations.zip" msgstr "pressuposts.zip" -#: pkg/quote.go:611 pkg/quote.go:1140 pkg/quote.go:1148 pkg/invoices.go:654 -#: pkg/invoices.go:1270 pkg/invoices.go:1278 +#: pkg/quote.go:611 pkg/quote.go:1140 pkg/quote.go:1148 pkg/expenses.go:611 +#: pkg/invoices.go:654 pkg/invoices.go:1270 pkg/invoices.go:1278 msgid "Invalid action" msgstr "Acció invàlida." @@ -1198,65 +1200,65 @@ msgctxt "period option" msgid "Previous year" msgstr "Any anterior" -#: pkg/expenses.go:147 +#: pkg/expenses.go:168 msgid "Select a contact." msgstr "Escolliu un contacte." -#: pkg/expenses.go:183 pkg/expenses.go:396 +#: pkg/expenses.go:225 pkg/expenses.go:412 msgctxt "input" msgid "Contact" msgstr "Contacte" -#: pkg/expenses.go:189 +#: pkg/expenses.go:231 msgctxt "input" msgid "Invoice number" msgstr "Número de factura" -#: pkg/expenses.go:194 pkg/invoices.go:712 +#: pkg/expenses.go:236 pkg/invoices.go:712 msgctxt "input" msgid "Invoice Date" msgstr "Data de factura" -#: pkg/expenses.go:206 +#: pkg/expenses.go:251 msgctxt "input" msgid "Amount" msgstr "Import" -#: pkg/expenses.go:216 pkg/invoices.go:734 +#: pkg/expenses.go:262 pkg/invoices.go:734 msgctxt "input" msgid "File" msgstr "Fitxer" -#: pkg/expenses.go:222 pkg/expenses.go:421 +#: pkg/expenses.go:268 pkg/expenses.go:437 msgctxt "input" msgid "Expense Status" msgstr "Estat de la despesa" -#: pkg/expenses.go:262 +#: pkg/expenses.go:308 msgid "Selected contact is not valid." msgstr "Heu seleccionat un contacte que no és vàlid." -#: pkg/expenses.go:263 pkg/invoices.go:785 +#: pkg/expenses.go:309 pkg/invoices.go:785 msgid "Invoice date must be a valid date." msgstr "La data de facturació ha de ser vàlida." -#: pkg/expenses.go:266 +#: pkg/expenses.go:312 msgid "Amount can not be empty." msgstr "No podeu deixar l’import en blanc." -#: pkg/expenses.go:267 +#: pkg/expenses.go:313 msgid "Amount must be a number greater than zero." msgstr "L’import ha de ser un número major a zero." -#: pkg/expenses.go:271 +#: pkg/expenses.go:315 msgid "Selected expense status is not valid." msgstr "Heu seleccionat un estat de despesa que no és vàlid." -#: pkg/expenses.go:397 +#: pkg/expenses.go:413 msgid "All contacts" msgstr "Tots els contactes" -#: pkg/expenses.go:402 pkg/invoices.go:159 +#: pkg/expenses.go:418 pkg/invoices.go:159 msgctxt "input" msgid "Invoice Number" msgstr "Número de factura" diff --git a/po/es.po b/po/es.po index 6759b72..849dd9e 100644 --- a/po/es.po +++ b/po/es.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-07-12 20:01+0200\n" +"POT-Creation-Date: 2023-07-13 20:43+0200\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -105,12 +105,14 @@ msgstr "Subtotal" #: web/template/invoices/view.gohtml:115 web/template/invoices/edit.gohtml:75 #: web/template/quotes/new.gohtml:74 web/template/quotes/view.gohtml:82 #: web/template/quotes/view.gohtml:122 web/template/quotes/edit.gohtml:75 +#: web/template/expenses/new.gohtml:45 web/template/expenses/edit.gohtml:49 msgctxt "title" msgid "Total" msgstr "Total" #: web/template/invoices/new.gohtml:92 web/template/invoices/edit.gohtml:93 #: web/template/quotes/new.gohtml:92 web/template/quotes/edit.gohtml:93 +#: web/template/expenses/new.gohtml:55 web/template/expenses/edit.gohtml:59 msgctxt "action" msgid "Update" msgstr "Actualizar" @@ -118,7 +120,7 @@ msgstr "Actualizar" #: web/template/invoices/new.gohtml:95 web/template/invoices/edit.gohtml:96 #: web/template/quotes/new.gohtml:95 web/template/quotes/edit.gohtml:96 #: web/template/contacts/new.gohtml:49 web/template/contacts/edit.gohtml:53 -#: web/template/expenses/new.gohtml:34 web/template/expenses/edit.gohtml:40 +#: web/template/expenses/new.gohtml:58 web/template/expenses/edit.gohtml:62 #: web/template/products/new.gohtml:30 web/template/products/edit.gohtml:36 msgctxt "action" msgid "Save" @@ -719,37 +721,37 @@ msgid "Name" msgstr "Nombre" #: pkg/products.go:169 pkg/products.go:290 pkg/quote.go:174 pkg/quote.go:685 -#: pkg/expenses.go:228 pkg/expenses.go:417 pkg/invoices.go:174 +#: pkg/expenses.go:274 pkg/expenses.go:433 pkg/invoices.go:174 #: pkg/invoices.go:723 pkg/invoices.go:1295 pkg/contacts.go:145 #: pkg/contacts.go:348 msgctxt "input" msgid "Tags" msgstr "Etiquetes" -#: pkg/products.go:173 pkg/quote.go:178 pkg/expenses.go:427 pkg/invoices.go:178 +#: pkg/products.go:173 pkg/quote.go:178 pkg/expenses.go:443 pkg/invoices.go:178 #: pkg/contacts.go:149 msgctxt "input" msgid "Tags Condition" msgstr "Condición de las etiquetas" -#: pkg/products.go:177 pkg/quote.go:182 pkg/expenses.go:431 pkg/invoices.go:182 +#: pkg/products.go:177 pkg/quote.go:182 pkg/expenses.go:447 pkg/invoices.go:182 #: pkg/contacts.go:153 msgctxt "tag condition" msgid "All" msgstr "Todas" -#: pkg/products.go:178 pkg/expenses.go:432 pkg/invoices.go:183 +#: pkg/products.go:178 pkg/expenses.go:448 pkg/invoices.go:183 #: pkg/contacts.go:154 msgid "Invoices must have all the specified labels." msgstr "Las facturas deben tener todas las etiquetas." -#: pkg/products.go:182 pkg/quote.go:187 pkg/expenses.go:436 pkg/invoices.go:187 +#: pkg/products.go:182 pkg/quote.go:187 pkg/expenses.go:452 pkg/invoices.go:187 #: pkg/contacts.go:158 msgctxt "tag condition" msgid "Any" msgstr "Cualquiera" -#: pkg/products.go:183 pkg/expenses.go:437 pkg/invoices.go:188 +#: pkg/products.go:183 pkg/expenses.go:453 pkg/invoices.go:188 #: pkg/contacts.go:159 msgid "Invoices must have at least one of the specified labels." msgstr "Las facturas deben tener como mínimo una de las etiquetas." @@ -764,7 +766,7 @@ msgctxt "input" msgid "Price" msgstr "Precio" -#: pkg/products.go:284 pkg/quote.go:925 pkg/expenses.go:200 +#: pkg/products.go:284 pkg/quote.go:925 pkg/expenses.go:242 #: pkg/invoices.go:1040 msgctxt "input" msgid "Taxes" @@ -783,12 +785,12 @@ msgstr "No podéis dejar el precio en blanco." msgid "Price must be a number greater than zero." msgstr "El precio tiene que ser un número mayor a cero." -#: pkg/products.go:313 pkg/quote.go:984 pkg/expenses.go:264 pkg/expenses.go:269 +#: pkg/products.go:313 pkg/quote.go:984 pkg/expenses.go:310 #: pkg/invoices.go:1099 msgid "Selected tax is not valid." msgstr "Habéis escogido un impuesto que no es válido." -#: pkg/products.go:314 pkg/quote.go:985 pkg/expenses.go:265 pkg/expenses.go:270 +#: pkg/products.go:314 pkg/quote.go:985 pkg/expenses.go:311 #: pkg/invoices.go:1100 msgid "You can only select a tax of each class." msgstr "Solo podéis escoger un impuesto de cada clase." @@ -1012,7 +1014,7 @@ msgctxt "input" msgid "Quotation Status" msgstr "Estado del presupuesto" -#: pkg/quote.go:154 pkg/expenses.go:422 pkg/invoices.go:154 +#: pkg/quote.go:154 pkg/expenses.go:438 pkg/invoices.go:154 msgid "All status" msgstr "Todos los estados" @@ -1021,12 +1023,12 @@ msgctxt "input" msgid "Quotation Number" msgstr "Número de presupuesto" -#: pkg/quote.go:164 pkg/expenses.go:407 pkg/invoices.go:164 +#: pkg/quote.go:164 pkg/expenses.go:423 pkg/invoices.go:164 msgctxt "input" msgid "From Date" msgstr "A partir de la fecha" -#: pkg/quote.go:169 pkg/expenses.go:412 pkg/invoices.go:169 +#: pkg/quote.go:169 pkg/expenses.go:428 pkg/invoices.go:169 msgctxt "input" msgid "To Date" msgstr "Hasta la fecha" @@ -1043,8 +1045,8 @@ msgstr "Los presupuestos deben tener como mínimo una de las etiquetas." msgid "quotations.zip" msgstr "presupuestos.zip" -#: pkg/quote.go:611 pkg/quote.go:1140 pkg/quote.go:1148 pkg/invoices.go:654 -#: pkg/invoices.go:1270 pkg/invoices.go:1278 +#: pkg/quote.go:611 pkg/quote.go:1140 pkg/quote.go:1148 pkg/expenses.go:611 +#: pkg/invoices.go:654 pkg/invoices.go:1270 pkg/invoices.go:1278 msgid "Invalid action" msgstr "Acción inválida." @@ -1198,65 +1200,65 @@ msgctxt "period option" msgid "Previous year" msgstr "Año anterior" -#: pkg/expenses.go:147 +#: pkg/expenses.go:168 msgid "Select a contact." msgstr "Escoged un contacto" -#: pkg/expenses.go:183 pkg/expenses.go:396 +#: pkg/expenses.go:225 pkg/expenses.go:412 msgctxt "input" msgid "Contact" msgstr "Contacto" -#: pkg/expenses.go:189 +#: pkg/expenses.go:231 msgctxt "input" msgid "Invoice number" msgstr "Número de factura" -#: pkg/expenses.go:194 pkg/invoices.go:712 +#: pkg/expenses.go:236 pkg/invoices.go:712 msgctxt "input" msgid "Invoice Date" msgstr "Fecha de factura" -#: pkg/expenses.go:206 +#: pkg/expenses.go:251 msgctxt "input" msgid "Amount" msgstr "Importe" -#: pkg/expenses.go:216 pkg/invoices.go:734 +#: pkg/expenses.go:262 pkg/invoices.go:734 msgctxt "input" msgid "File" msgstr "Archivo" -#: pkg/expenses.go:222 pkg/expenses.go:421 +#: pkg/expenses.go:268 pkg/expenses.go:437 msgctxt "input" msgid "Expense Status" msgstr "Estado del gasto" -#: pkg/expenses.go:262 +#: pkg/expenses.go:308 msgid "Selected contact is not valid." msgstr "Habéis escogido un contacto que no es válido." -#: pkg/expenses.go:263 pkg/invoices.go:785 +#: pkg/expenses.go:309 pkg/invoices.go:785 msgid "Invoice date must be a valid date." msgstr "La fecha de factura debe ser válida." -#: pkg/expenses.go:266 +#: pkg/expenses.go:312 msgid "Amount can not be empty." msgstr "No podéis dejar el importe en blanco." -#: pkg/expenses.go:267 +#: pkg/expenses.go:313 msgid "Amount must be a number greater than zero." msgstr "El importe tiene que ser un número mayor a cero." -#: pkg/expenses.go:271 +#: pkg/expenses.go:315 msgid "Selected expense status is not valid." msgstr "Habéis escogido un estado de gasto que no es válido." -#: pkg/expenses.go:397 +#: pkg/expenses.go:413 msgid "All contacts" msgstr "Todos los contactos" -#: pkg/expenses.go:402 pkg/invoices.go:159 +#: pkg/expenses.go:418 pkg/invoices.go:159 msgctxt "input" msgid "Invoice Number" msgstr "Número de factura" diff --git a/revert/compute_new_expense_amount.sql b/revert/compute_new_expense_amount.sql new file mode 100644 index 0000000..7f857c6 --- /dev/null +++ b/revert/compute_new_expense_amount.sql @@ -0,0 +1,7 @@ +-- Revert numerus:compute_new_expense_amount from pg + +begin; + +drop function if exists numerus.compute_new_expense_amount(integer, text, integer[]); + +commit; diff --git a/revert/new_expense_amount.sql b/revert/new_expense_amount.sql new file mode 100644 index 0000000..1ad0a83 --- /dev/null +++ b/revert/new_expense_amount.sql @@ -0,0 +1,7 @@ +-- Revert numerus:new_expense_amount from pg + +begin; + +drop type if exists numerus.new_expense_amount; + +commit; diff --git a/sqitch.plan b/sqitch.plan index 2345800..1a4cff4 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -124,3 +124,5 @@ add_expense [add_expense@v1 expense_status expense_expense_status] 2023-07-11T13 edit_expense [edit_expense@v1 expense_status expense_expense_status] 2023-07-11T13:21:17Z jordi fita mas # Add expense_status parameter to edit_expense invoice_attachment [schema_numerus roles invoice] 2023-07-12T17:10:58Z jordi fita mas # Add relation for invoice attachment attach_to_invoice [schema_numerus roles invoice invoice_attachment] 2023-07-12T17:21:19Z jordi fita mas # Add function to attachment a document to invoices +new_expense_amount [schema_numerus] 2023-07-13T17:45:33Z jordi fita mas # Add type to return when computing new expense amounts +compute_new_expense_amount [schema_numerus roles company tax new_expense_amount] 2023-07-13T17:34:12Z jordi fita mas # Add function to compute the taxes and total for a new expense diff --git a/test/compute_new_expense_amount.sql b/test/compute_new_expense_amount.sql new file mode 100644 index 0000000..5a63387 --- /dev/null +++ b/test/compute_new_expense_amount.sql @@ -0,0 +1,80 @@ +-- Test compute_new_expense_amount +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(14); + +set search_path to numerus, auth, public; + +select has_function('numerus', 'compute_new_expense_amount', array ['integer', 'text', 'integer[]']); +select function_lang_is('numerus', 'compute_new_expense_amount', array ['integer', 'text', 'integer[]'], 'plpgsql'); +select function_returns('numerus', 'compute_new_expense_amount', array ['integer', 'text', 'integer[]'], 'new_expense_amount'); +select isnt_definer('numerus', 'compute_new_expense_amount', array ['integer', 'text', 'integer[]']); +select volatility_is('numerus', 'compute_new_expense_amount', array ['integer', 'text', 'integer[]'], 'stable'); +select function_privs_are('numerus', 'compute_new_expense_amount', array ['integer', 'text', 'integer[]'], 'guest', array []::text[]); +select function_privs_are('numerus', 'compute_new_expense_amount', array ['integer', 'text', 'integer[]'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'compute_new_expense_amount', array ['integer', 'text', 'integer[]'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'compute_new_expense_amount', array ['integer', 'text', 'integer[]'], 'authenticator', array []::text[]); + +set client_min_messages to warning; +truncate tax cascade; +truncate tax_class cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + +set constraints "company_default_payment_method_id_fkey" deferred; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id) +values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111) +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') +; + +set constraints "company_default_payment_method_id_fkey" immediate; + +insert into tax_class (tax_class_id, company_id, name) +values (11, 1, 'tax') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (2, 1, 11, 'IRPF -15 %', -0.15) + , (3, 1, 11, 'IVA 4 %', 0.04) + , (4, 1, 11, 'IVA 10 %', 0.10) + , (5, 1, 11, 'IVA 21 %', 0.21) +; + +select is( + compute_new_expense_amount(1, '', array[]::integer[]), + '("{}",0.00)'::new_expense_amount +); + +select is( + compute_new_expense_amount(1, '4.60', array[2,5,3]), + '("{{IRPF -15 %,-0.69},{IVA 4 %,0.18},{IVA 21 %,0.97}}",5.06)'::new_expense_amount +); + +select is( + compute_new_expense_amount(1, '17.32', array[2,4,5]), + '("{{IRPF -15 %,-2.60},{IVA 10 %,1.73},{IVA 21 %,3.64}}",20.09)'::new_expense_amount +); + +select is( + compute_new_expense_amount(1, '52.17', array[3,4,5]), + '("{{IVA 4 %,2.09},{IVA 10 %,5.22},{IVA 21 %,10.96}}",70.44)'::new_expense_amount +); + +select is( + compute_new_expense_amount(1, '62.16', array[]::integer[]), + '("{}",62.16)'::new_expense_amount +); + +select * +from finish(); + +rollback; diff --git a/test/new_expense_amount.sql b/test/new_expense_amount.sql new file mode 100644 index 0000000..ab797a0 --- /dev/null +++ b/test/new_expense_amount.sql @@ -0,0 +1,20 @@ +-- Test new_expense_amount +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(4); + +set search_path to numerus, public; + +select has_composite('numerus', 'new_expense_amount', 'Composite type numerus.new_expense_amount should exist'); +select columns_are('numerus', 'new_expense_amount', array['taxes', 'total']); +select col_type_is('numerus'::name, 'new_expense_amount'::name, 'taxes'::name, 'text[]'); +select col_type_is('numerus'::name, 'new_expense_amount'::name, 'total'::name, 'text'); + +select * +from finish(); + +rollback; diff --git a/verify/compute_new_expense_amount.sql b/verify/compute_new_expense_amount.sql new file mode 100644 index 0000000..0c4b4c9 --- /dev/null +++ b/verify/compute_new_expense_amount.sql @@ -0,0 +1,7 @@ +-- Verify numerus:compute_new_expense_amount on pg + +begin; + +select has_function_privilege('numerus.compute_new_expense_amount(integer, text, integer[])', 'execute'); + +rollback; diff --git a/verify/new_expense_amount.sql b/verify/new_expense_amount.sql new file mode 100644 index 0000000..eadaf73 --- /dev/null +++ b/verify/new_expense_amount.sql @@ -0,0 +1,7 @@ +-- Verify numerus:new_expense_amount on pg + +begin; + +select pg_catalog.has_type_privilege('numerus.new_expense_amount', 'usage'); + +rollback; diff --git a/web/template/expenses/edit.gohtml b/web/template/expenses/edit.gohtml index 6f00255..f6727db 100644 --- a/web/template/expenses/edit.gohtml +++ b/web/template/expenses/edit.gohtml @@ -19,9 +19,10 @@

{{ printf (pgettext "Edit Expense “%s”" "title") .Slug }}

+ data-hx-boost="true" + data-hx-swap="innerHTML show:false" + > {{ csrfToken }} - {{ putMethod }} {{ with .Form -}}
@@ -36,9 +37,36 @@
{{- end }} -
- + + + {{- range $tax := .Taxes }} + + + + + {{- end }} + + + + + +
{{ index . 0 }}{{ index . 1 | formatPrice }}
{{(pgettext "Total" "title")}}{{ .Total | formatPrice }}
+ +
+ +
+ + {{- end }} diff --git a/web/template/expenses/new.gohtml b/web/template/expenses/new.gohtml index a66a495..851c2c9 100644 --- a/web/template/expenses/new.gohtml +++ b/web/template/expenses/new.gohtml @@ -18,21 +18,53 @@ {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.expenseForm*/ -}}

{{(pgettext "New Expense" "title")}}

-
+ {{ csrfToken }} -
- {{ template "select-field" .Invoicer }} - {{ template "input-field" .InvoiceNumber }} - {{ template "input-field" .InvoiceDate }} - {{ template "input-field" .Amount }} - {{ template "select-field" .Tax }} - {{ template "tags-field" .Tags }} - {{ template "select-field" .ExpenseStatus }} - {{ template "file-field" .File }} -
-
- + {{ with .Form }} +
+ {{ template "select-field" .Invoicer }} + {{ template "input-field" .InvoiceNumber }} + {{ template "input-field" .InvoiceDate }} + {{ template "input-field" .Amount }} + {{ template "select-field" .Tax }} + {{ template "tags-field" .Tags }} + {{ template "select-field" .ExpenseStatus }} + {{ template "file-field" .File }} +
+ {{ end }} + + + + {{- range $tax := .Taxes }} + + + + + {{- end }} + + + + + +
{{ index . 0 }}{{ index . 1 | formatPrice }}
{{(pgettext "Total" "title")}}{{ .Total | formatPrice }}
+ +
+ +
+ + {{- end }}