This is to handle refunds, which are invoices with negative amounts, that can be both issued or received (i.e., an “expense”). The API provided by PostgreSQL is mostly the same, and internally it deals with negatives, so the Go package only had to change selects of collection.
735 lines
22 KiB
Go
735 lines
22 KiB
Go
package pkg
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"github.com/julienschmidt/httprouter"
|
|
"html/template"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type ExpenseEntry struct {
|
|
ID int
|
|
Slug string
|
|
InvoiceDate time.Time
|
|
InvoiceNumber string
|
|
Amount string
|
|
Taxes map[string]string
|
|
Total string
|
|
InvoicerName string
|
|
OriginalFileName string
|
|
Tags []string
|
|
Status string
|
|
StatusLabel string
|
|
}
|
|
|
|
type expensesIndexPage struct {
|
|
Expenses []*ExpenseEntry
|
|
SumAmount string
|
|
SumTaxes map[string]string
|
|
SumTotal string
|
|
Filters *expenseFilterForm
|
|
TaxClasses []string
|
|
ExpenseStatuses map[string]string
|
|
}
|
|
|
|
func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|
conn := getConn(r)
|
|
locale := getLocale(r)
|
|
company := mustGetCompany(r)
|
|
filters := newExpenseFilterForm(r.Context(), conn, locale, company)
|
|
if err := filters.Parse(r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
page := &expensesIndexPage{
|
|
Expenses: mustCollectExpenseEntries(r.Context(), conn, locale, filters),
|
|
ExpenseStatuses: mustCollectExpenseStatuses(r.Context(), conn, locale),
|
|
TaxClasses: mustCollectTaxClasses(r.Context(), conn, company),
|
|
Filters: filters,
|
|
}
|
|
page.mustComputeExpensesTotalAmount(r.Context(), conn, filters)
|
|
mustRenderMainTemplate(w, r, "expenses/index.gohtml", page)
|
|
}
|
|
|
|
func mustCollectExpenseEntries(ctx context.Context, conn *Conn, locale *Locale, filters *expenseFilterForm) []*ExpenseEntry {
|
|
where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
|
|
rows := conn.MustQuery(ctx, fmt.Sprintf(`
|
|
select expense_id
|
|
, expense.slug
|
|
, invoice_date
|
|
, invoice_number
|
|
, to_price(expense.amount, decimal_digits) as amount
|
|
, array_agg(array[tax_class.name, to_price(coalesce(expense_tax.amount, 0), decimal_digits)]) filter (where tax_class.name is not null)
|
|
, to_price(expense.amount + coalesce(sum(expense_tax.amount)::integer, 0), decimal_digits) as total
|
|
, contact.name
|
|
, coalesce(attachment.original_filename, '')
|
|
, expense.tags
|
|
, expense.expense_status
|
|
, esi18n.name
|
|
from expense
|
|
left join expense_attachment as attachment using (expense_id)
|
|
left join expense_tax_amount as expense_tax using (expense_id)
|
|
left join tax using (tax_id)
|
|
left join tax_class using (tax_class_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_id
|
|
, 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 desc, contact.name, total desc
|
|
`, where), args...)
|
|
defer rows.Close()
|
|
|
|
var entries []*ExpenseEntry
|
|
for rows.Next() {
|
|
entry := &ExpenseEntry{
|
|
Taxes: make(map[string]string),
|
|
}
|
|
var taxes [][]string
|
|
if err := rows.Scan(&entry.ID, &entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &taxes, &entry.Total, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags, &entry.Status, &entry.StatusLabel); err != nil {
|
|
panic(err)
|
|
}
|
|
for _, tax := range taxes {
|
|
entry.Taxes[tax[0]] = tax[1]
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
if rows.Err() != nil {
|
|
panic(rows.Err())
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
func mustCollectExpenseStatuses(ctx context.Context, conn *Conn, locale *Locale) map[string]string {
|
|
rows := conn.MustQuery(ctx, `
|
|
select expense_status.expense_status
|
|
, esi18n.name
|
|
from expense_status
|
|
join expense_status_i18n esi18n using(expense_status)
|
|
where esi18n.lang_tag = $1
|
|
order by expense_status`, locale.Language.String())
|
|
defer rows.Close()
|
|
|
|
statuses := map[string]string{}
|
|
for rows.Next() {
|
|
var key, name string
|
|
if err := rows.Scan(&key, &name); err != nil {
|
|
panic(err)
|
|
}
|
|
statuses[key] = name
|
|
}
|
|
if rows.Err() != nil {
|
|
panic(rows.Err())
|
|
}
|
|
|
|
return statuses
|
|
}
|
|
|
|
func (page *expensesIndexPage) mustComputeExpensesTotalAmount(ctx context.Context, conn *Conn, filters *expenseFilterForm) {
|
|
where, args := filters.BuildQuery(nil)
|
|
row := conn.QueryRow(ctx, fmt.Sprintf(`
|
|
select to_price(sum(subtotal)::integer, decimal_digits)
|
|
, 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)
|
|
group by decimal_digits
|
|
`, where), args...)
|
|
if notFoundErrorOrPanic(row.Scan(&page.SumAmount, &page.SumTotal)) {
|
|
page.SumAmount = "0.0"
|
|
page.SumTotal = "0.0"
|
|
}
|
|
|
|
row = conn.QueryRow(ctx, fmt.Sprintf(`
|
|
select array_agg(array[tax_class_name, to_price(coalesce(tax_amount, 0), decimal_digits)]) filter (where tax_class_name is not null)
|
|
from (
|
|
select tax_class.name as tax_class_name
|
|
, coalesce(sum(expense_tax.amount)::integer, 0) as tax_amount
|
|
, currency_code
|
|
from expense
|
|
left join expense_tax_amount as expense_tax using (expense_id)
|
|
left join tax using (tax_id)
|
|
left join tax_class using (tax_class_id)
|
|
where (%s)
|
|
group by tax_class.name
|
|
, currency_code
|
|
) as tax
|
|
join currency using (currency_code)
|
|
group by decimal_digits
|
|
`, where), args...)
|
|
var taxes [][]string
|
|
if notFoundErrorOrPanic(row.Scan(&taxes)) {
|
|
// well, nothing to do
|
|
}
|
|
page.SumTaxes = make(map[string]string)
|
|
for _, tax := range taxes {
|
|
page.SumTaxes[tax[0]] = tax[1]
|
|
}
|
|
}
|
|
|
|
func mustCollectTaxClasses(ctx context.Context, conn *Conn, company *Company) []string {
|
|
rows := conn.MustQuery(ctx, "select name from tax_class where company_id = $1", company.Id)
|
|
defer rows.Close()
|
|
|
|
var taxClasses []string
|
|
for rows.Next() {
|
|
var taxClass string
|
|
if err := rows.Scan(&taxClass); err != nil {
|
|
panic(err)
|
|
}
|
|
taxClasses = append(taxClasses, taxClass)
|
|
}
|
|
return taxClasses
|
|
}
|
|
|
|
func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
locale := getLocale(r)
|
|
conn := getConn(r)
|
|
company := mustGetCompany(r)
|
|
form := newExpenseForm(r.Context(), conn, locale, company)
|
|
slug := params[0].Value
|
|
if slug == "new" {
|
|
w.WriteHeader(http.StatusOK)
|
|
form.InvoiceDate.Val = time.Now().Format("2006-01-02")
|
|
mustRenderNewExpenseForm(w, r, form)
|
|
return
|
|
}
|
|
if !ValidUuid(slug) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
mustRenderEditExpenseForm(w, r, slug, form)
|
|
}
|
|
|
|
func mustRenderNewExpenseForm(w http.ResponseWriter, r *http.Request, form *expenseForm) {
|
|
locale := getLocale(r)
|
|
form.Invoicer.EmptyLabel = gettext("Select a contact.", locale)
|
|
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{
|
|
newNewExpensePage(form, r),
|
|
slug,
|
|
}
|
|
mustRenderMainTemplate(w, r, "expenses/edit.gohtml", page)
|
|
}
|
|
|
|
type editExpensePage struct {
|
|
*newExpensePage
|
|
Slug string
|
|
}
|
|
|
|
type expenseForm struct {
|
|
locale *Locale
|
|
company *Company
|
|
Invoicer *SelectField
|
|
InvoiceNumber *InputField
|
|
InvoiceDate *InputField
|
|
Tax *SelectField
|
|
Amount *InputField
|
|
File *FileField
|
|
Tags *TagsField
|
|
}
|
|
|
|
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,
|
|
Invoicer: &SelectField{
|
|
Name: "invoicer",
|
|
Label: pgettext("input", "Contact", locale),
|
|
Required: true,
|
|
Options: mustGetContactOptions(ctx, conn, company),
|
|
},
|
|
InvoiceNumber: &InputField{
|
|
Name: "invoice_number",
|
|
Label: pgettext("input", "Invoice number", locale),
|
|
Type: "text",
|
|
},
|
|
InvoiceDate: &InputField{
|
|
Name: "invoice_date",
|
|
Label: pgettext("input", "Invoice Date", locale),
|
|
Required: true,
|
|
Type: "date",
|
|
},
|
|
Tax: &SelectField{
|
|
Name: "tax",
|
|
Label: pgettext("input", "Taxes", locale),
|
|
Multiple: true,
|
|
Options: mustGetTaxOptions(ctx, conn, company),
|
|
Attributes: []template.HTMLAttr{
|
|
triggerRecompute,
|
|
},
|
|
},
|
|
Amount: &InputField{
|
|
Name: "amount",
|
|
Label: pgettext("input", "Amount", locale),
|
|
Type: "number",
|
|
Required: true,
|
|
Attributes: []template.HTMLAttr{
|
|
triggerRecompute,
|
|
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
|
|
},
|
|
},
|
|
File: &FileField{
|
|
Name: "file",
|
|
Label: pgettext("input", "File", locale),
|
|
MaxSize: 1 << 20,
|
|
},
|
|
Tags: &TagsField{
|
|
Name: "tags",
|
|
Label: pgettext("input", "Tags", locale),
|
|
},
|
|
}
|
|
}
|
|
|
|
func mustGetExpenseStatusOptions(ctx context.Context, conn *Conn, locale *Locale) []*SelectOption {
|
|
return MustGetOptions(ctx, conn, `
|
|
select expense_status.expense_status
|
|
, esi18n.name
|
|
from expense_status
|
|
join expense_status_i18n esi18n using(expense_status)
|
|
where esi18n.lang_tag = $1
|
|
order by expense_status`, locale.Language.String())
|
|
}
|
|
|
|
func (form *expenseForm) Parse(r *http.Request) error {
|
|
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
|
|
return err
|
|
}
|
|
form.Invoicer.FillValue(r)
|
|
form.InvoiceNumber.FillValue(r)
|
|
form.InvoiceDate.FillValue(r)
|
|
form.Tax.FillValue(r)
|
|
form.Amount.FillValue(r)
|
|
if err := form.File.FillValue(r); err != nil {
|
|
return err
|
|
}
|
|
form.Tags.FillValue(r)
|
|
return nil
|
|
}
|
|
|
|
func (form *expenseForm) Validate() bool {
|
|
validator := newFormValidator()
|
|
validator.CheckValidSelectOption(form.Invoicer, gettext("Selected contact is not valid.", form.locale))
|
|
validator.CheckValidDate(form.InvoiceDate, gettext("Invoice date must be a valid date.", form.locale))
|
|
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", form.locale))
|
|
validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale))
|
|
if validator.CheckRequiredInput(form.Amount, gettext("Amount can not be empty.", form.locale)) {
|
|
validator.CheckValidDecimal(form.Amount, -math.MaxFloat64, math.MaxFloat64, gettext("Amount must be a decimal number.", form.locale))
|
|
}
|
|
return validator.AllOK()
|
|
}
|
|
|
|
func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
|
|
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
|
|
select contact_id
|
|
, invoice_number
|
|
, invoice_date
|
|
, to_price(amount, decimal_digits)
|
|
, array_agg(tax_id) filter ( where tax_id is not null )
|
|
, tags
|
|
from expense
|
|
left join expense_tax using (expense_id)
|
|
join currency using (currency_code)
|
|
where expense.slug = $1
|
|
group by contact_id
|
|
, invoice_number
|
|
, invoice_date
|
|
, amount
|
|
, decimal_digits
|
|
, tags
|
|
`, slug).Scan(
|
|
form.Invoicer,
|
|
form.InvoiceNumber,
|
|
form.InvoiceDate,
|
|
form.Amount,
|
|
form.Tax,
|
|
form.Tags)) {
|
|
return false
|
|
}
|
|
if len(form.Tax.Selected) == 1 && form.Tax.Selected[0] == "" {
|
|
form.Tax.Selected = nil
|
|
}
|
|
return true
|
|
}
|
|
func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params 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
|
|
}
|
|
slug := params[0].Value
|
|
if !ValidUuid(slug) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !form.Validate() {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
mustRenderEditExpenseForm(w, r, slug, form)
|
|
return
|
|
}
|
|
taxes := mustSliceAtoi(form.Tax.Selected)
|
|
if found := conn.MustGetText(r.Context(), "", "select edit_expense($1, $2, $3, $4, $5, $6, $7)", slug, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags); found == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
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"))
|
|
}
|
|
|
|
type expenseFilterForm struct {
|
|
locale *Locale
|
|
company *Company
|
|
Contact *SelectField
|
|
InvoiceNumber *InputField
|
|
FromDate *InputField
|
|
ToDate *InputField
|
|
ExpenseStatus *SelectField
|
|
Tags *TagsField
|
|
TagsCondition *ToggleField
|
|
}
|
|
|
|
func newExpenseFilterForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *expenseFilterForm {
|
|
return &expenseFilterForm{
|
|
locale: locale,
|
|
company: company,
|
|
Contact: &SelectField{
|
|
Name: "contact",
|
|
Label: pgettext("input", "Contact", locale),
|
|
EmptyLabel: gettext("All contacts", locale),
|
|
Options: mustGetContactOptions(ctx, conn, company),
|
|
},
|
|
InvoiceNumber: &InputField{
|
|
Name: "number",
|
|
Label: pgettext("input", "Invoice Number", locale),
|
|
Type: "search",
|
|
},
|
|
FromDate: &InputField{
|
|
Name: "from_date",
|
|
Label: pgettext("input", "From Date", locale),
|
|
Type: "date",
|
|
},
|
|
ToDate: &InputField{
|
|
Name: "to_date",
|
|
Label: pgettext("input", "To Date", locale),
|
|
Type: "date",
|
|
},
|
|
Tags: &TagsField{
|
|
Name: "tags",
|
|
Label: pgettext("input", "Tags", locale),
|
|
},
|
|
ExpenseStatus: &SelectField{
|
|
Name: "expense_status",
|
|
Label: pgettext("input", "Expense Status", locale),
|
|
EmptyLabel: gettext("All status", locale),
|
|
Options: mustGetExpenseStatusOptions(ctx, conn, locale),
|
|
},
|
|
TagsCondition: &ToggleField{
|
|
Name: "tags_condition",
|
|
Label: pgettext("input", "Tags Condition", locale),
|
|
Selected: "and",
|
|
FirstOption: &ToggleOption{
|
|
Value: "and",
|
|
Label: pgettext("tag condition", "All", locale),
|
|
Description: gettext("Invoices must have all the specified labels.", locale),
|
|
},
|
|
SecondOption: &ToggleOption{
|
|
Value: "or",
|
|
Label: pgettext("tag condition", "Any", locale),
|
|
Description: gettext("Invoices must have at least one of the specified labels.", locale),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (form *expenseFilterForm) Parse(r *http.Request) error {
|
|
if err := r.ParseForm(); err != nil {
|
|
return err
|
|
}
|
|
form.Contact.FillValue(r)
|
|
form.InvoiceNumber.FillValue(r)
|
|
form.FromDate.FillValue(r)
|
|
form.ToDate.FillValue(r)
|
|
form.ExpenseStatus.FillValue(r)
|
|
form.Tags.FillValue(r)
|
|
form.TagsCondition.FillValue(r)
|
|
return nil
|
|
}
|
|
|
|
func (form *expenseFilterForm) HasValue() bool {
|
|
return form.Contact.HasValue() ||
|
|
form.InvoiceNumber.HasValue() ||
|
|
form.FromDate.HasValue() ||
|
|
form.ToDate.HasValue() ||
|
|
form.ExpenseStatus.HasValue() ||
|
|
form.Tags.HasValue()
|
|
}
|
|
|
|
func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
|
|
var where []string
|
|
appendWhere := func(expression string, value interface{}) {
|
|
args = append(args, value)
|
|
where = append(where, fmt.Sprintf(expression, len(args)))
|
|
}
|
|
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
|
|
if value != "" {
|
|
if conv == nil {
|
|
appendWhere(expression, value)
|
|
} else {
|
|
appendWhere(expression, conv(value))
|
|
}
|
|
}
|
|
}
|
|
|
|
appendWhere("expense.company_id = $%d", form.company.Id)
|
|
maybeAppendWhere("contact_id = $%d", form.Contact.String(), func(v string) interface{} {
|
|
customerId, _ := strconv.Atoi(form.Contact.Selected[0])
|
|
return customerId
|
|
})
|
|
maybeAppendWhere("expense.expense_status = $%d", form.ExpenseStatus.String(), nil)
|
|
maybeAppendWhere("invoice_number = $%d", form.InvoiceNumber.String(), nil)
|
|
maybeAppendWhere("invoice_date >= $%d", form.FromDate.String(), nil)
|
|
maybeAppendWhere("invoice_date <= $%d", form.ToDate.String(), nil)
|
|
if len(form.Tags.Tags) > 0 {
|
|
if form.TagsCondition.Selected == "and" {
|
|
appendWhere("expense.tags @> $%d", form.Tags)
|
|
} else {
|
|
appendWhere("expense.tags && $%d", form.Tags)
|
|
}
|
|
}
|
|
|
|
return strings.Join(where, ") AND ("), args
|
|
}
|
|
|
|
func ServeEditExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
serveTagsEditForm(w, r, params, "/expenses/", "select tags from expense where slug = $1")
|
|
}
|
|
|
|
func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
handleUpdateTags(w, r, params, "/expenses/", "update expense set tags = $1 where slug = $2 returning slug")
|
|
}
|
|
|
|
func ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
serveAttachment(w, r, params, `
|
|
select mime_type
|
|
, content
|
|
from expense
|
|
join expense_attachment using (expense_id)
|
|
where slug = $1
|
|
`)
|
|
}
|
|
|
|
func HandleEditExpenseAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
slug := params[0].Value
|
|
switch slug {
|
|
case "batch":
|
|
HandleBatchExpenseAction(w, r, params)
|
|
default:
|
|
if !ValidUuid(slug) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
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() {
|
|
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)", company.Id, 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)
|
|
}
|
|
}
|
|
|
|
func HandleBatchExpenseAction(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := verifyCsrfTokenValid(r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
locale := getLocale(r)
|
|
switch r.Form.Get("action") {
|
|
case "export":
|
|
conn := getConn(r)
|
|
company := getCompany(r)
|
|
filters := newExpenseFilterForm(r.Context(), conn, locale, company)
|
|
if err := filters.Parse(r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
entries := mustCollectExpenseEntries(r.Context(), conn, locale, filters)
|
|
vatin := mustCollectExpenseEntriesVATIN(r.Context(), conn, entries)
|
|
lastPaymentDate := mustCollectExpenseEntriesLastPaymentDate(r.Context(), conn, entries)
|
|
taxes := mustCollectExpenseEntriesTaxes(r.Context(), conn, entries)
|
|
taxColumns := mustCollectTaxColumns(r.Context(), conn, company)
|
|
ods := mustWriteExpensesOds(entries, vatin, lastPaymentDate, taxes, taxColumns, locale, company)
|
|
writeOdsResponse(w, ods, gettext("expenses.ods", locale))
|
|
default:
|
|
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func mustCollectExpenseEntriesTaxes(ctx context.Context, conn *Conn, entries []*ExpenseEntry) map[int]taxMap {
|
|
ids := mustMakeIDArray(entries, func(entry *ExpenseEntry) int {
|
|
return entry.ID
|
|
})
|
|
return mustMakeTaxMap(ctx, conn, ids, `
|
|
select expense_id
|
|
, tax_id
|
|
, to_price(tax.amount, decimal_digits)
|
|
from expense_tax_amount as tax
|
|
join expense using (expense_id)
|
|
join currency using (currency_code)
|
|
where expense_id = any ($1)
|
|
`)
|
|
}
|
|
|
|
func mustCollectExpenseEntriesVATIN(ctx context.Context, conn *Conn, entries []*ExpenseEntry) map[int]string {
|
|
ids := mustMakeIDArray(entries, func(entry *ExpenseEntry) int {
|
|
return entry.ID
|
|
})
|
|
return mustMakeVATINMap(ctx, conn, ids, `
|
|
select expense_id
|
|
, vatin::text
|
|
from contact_tax_details as tax
|
|
join expense using (contact_id)
|
|
where expense_id = any ($1)
|
|
`)
|
|
}
|
|
|
|
func mustCollectExpenseEntriesLastPaymentDate(ctx context.Context, conn *Conn, entries []*ExpenseEntry) map[int]time.Time {
|
|
ids := mustMakeIDArray(entries, func(entry *ExpenseEntry) int {
|
|
return entry.ID
|
|
})
|
|
return mustMakeDateMap(ctx, conn, ids, `
|
|
select expense_id
|
|
, max(payment_date)
|
|
from expense_payment
|
|
join payment using (payment_id)
|
|
where expense_id = any ($1)
|
|
group by expense_id
|
|
`)
|
|
}
|
|
|
|
func handleRemoveExpense(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
slug := params[0].Value
|
|
if !ValidUuid(slug) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
if err := verifyCsrfTokenValid(r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
conn := getConn(r)
|
|
conn.MustExec(r.Context(), "select remove_expense($1)", slug)
|
|
|
|
company := mustGetCompany(r)
|
|
htmxRedirect(w, r, companyURI(company, "/expenses"))
|
|
}
|