Allow negative prices and quantities in invoices and quotes
This is necessary for credit forms, because it is “just” an invoice with a negative amount, usually the inverse of an invoice that has to be rectified. This is a requirement for electronic invoicing too. However, it is not the only use case: sometimes it is necessary to have some lines with a negative quantity, even if the total is positive, to emphasize discounts or to register expenses, like bank commissions in autoinvoices. Part of #61.
This commit is contained in:
parent
d4dc8e00e5
commit
0ee64f1905
|
@ -978,14 +978,12 @@ func (form *invoiceForm) Update() {
|
|||
products := form.Products
|
||||
form.Products = nil
|
||||
for n, product := range products {
|
||||
if product.Quantity.Val != "0" {
|
||||
product.Update()
|
||||
if n != len(form.Products) {
|
||||
product.Index = len(form.Products)
|
||||
product.Rename()
|
||||
}
|
||||
form.Products = append(form.Products, product)
|
||||
product.Update()
|
||||
if n != len(form.Products) {
|
||||
product.Index = len(form.Products)
|
||||
product.Rename()
|
||||
}
|
||||
form.Products = append(form.Products, product)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1190,7 +1188,6 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
|
|||
Required: true,
|
||||
Attributes: []template.HTMLAttr{
|
||||
triggerRecompute,
|
||||
`min="0"`,
|
||||
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
|
||||
},
|
||||
},
|
||||
|
@ -1200,7 +1197,6 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
|
|||
Required: true,
|
||||
Attributes: []template.HTMLAttr{
|
||||
triggerRecompute,
|
||||
`min="0"`,
|
||||
},
|
||||
},
|
||||
Discount: &InputField{
|
||||
|
@ -1265,10 +1261,10 @@ func (form *invoiceProductForm) Validate() bool {
|
|||
}
|
||||
validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale))
|
||||
if validator.CheckRequiredInput(form.Price, gettext("Price can not be empty.", form.locale)) {
|
||||
validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, gettext("Price must be a number greater than zero.", form.locale))
|
||||
validator.CheckValidDecimal(form.Price, -math.MaxFloat64, math.MaxFloat64, gettext("Price must be a decimal number.", form.locale))
|
||||
}
|
||||
if validator.CheckRequiredInput(form.Quantity, gettext("Quantity can not be empty.", form.locale)) {
|
||||
validator.CheckValidInteger(form.Quantity, 1, math.MaxInt32, gettext("Quantity must be a number greater than zero.", form.locale))
|
||||
validator.CheckValidInteger(form.Quantity, math.MinInt32, math.MaxInt32, gettext("Quantity must be an integer number.", form.locale))
|
||||
}
|
||||
if validator.CheckRequiredInput(form.Discount, gettext("Discount can not be empty.", form.locale)) {
|
||||
validator.CheckValidInteger(form.Discount, 0, 100, gettext("Discount must be a percentage between 0 and 100.", form.locale))
|
||||
|
@ -1280,11 +1276,11 @@ func (form *invoiceProductForm) Validate() bool {
|
|||
|
||||
func (form *invoiceProductForm) Update() {
|
||||
validator := newFormValidator()
|
||||
if !validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, "") {
|
||||
if !validator.CheckValidDecimal(form.Price, -math.MaxFloat64, math.MaxFloat64, "") {
|
||||
form.Price.Val = "0.0"
|
||||
form.Price.Errors = nil
|
||||
}
|
||||
if !validator.CheckValidInteger(form.Quantity, 0, math.MaxInt32, "") {
|
||||
if !validator.CheckValidInteger(form.Quantity, math.MinInt32, math.MaxInt32, "") {
|
||||
form.Quantity.Val = "1"
|
||||
form.Quantity.Errors = nil
|
||||
}
|
||||
|
|
22
pkg/quote.go
22
pkg/quote.go
|
@ -766,14 +766,12 @@ func (form *quoteForm) Update() {
|
|||
products := form.Products
|
||||
form.Products = nil
|
||||
for n, product := range products {
|
||||
if product.Quantity.Val != "0" {
|
||||
product.Update()
|
||||
if n != len(form.Products) {
|
||||
product.Index = len(form.Products)
|
||||
product.Rename()
|
||||
}
|
||||
form.Products = append(form.Products, product)
|
||||
product.Update()
|
||||
if n != len(form.Products) {
|
||||
product.Index = len(form.Products)
|
||||
product.Rename()
|
||||
}
|
||||
form.Products = append(form.Products, product)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -919,7 +917,6 @@ func newQuoteProductForm(index int, company *Company, locale *Locale, taxOptions
|
|||
Required: true,
|
||||
Attributes: []template.HTMLAttr{
|
||||
triggerRecompute,
|
||||
`min="0"`,
|
||||
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
|
||||
},
|
||||
},
|
||||
|
@ -929,7 +926,6 @@ func newQuoteProductForm(index int, company *Company, locale *Locale, taxOptions
|
|||
Required: true,
|
||||
Attributes: []template.HTMLAttr{
|
||||
triggerRecompute,
|
||||
`min="0"`,
|
||||
},
|
||||
},
|
||||
Discount: &InputField{
|
||||
|
@ -994,10 +990,10 @@ func (form *quoteProductForm) Validate() bool {
|
|||
}
|
||||
validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale))
|
||||
if validator.CheckRequiredInput(form.Price, gettext("Price can not be empty.", form.locale)) {
|
||||
validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, gettext("Price must be a number greater than zero.", form.locale))
|
||||
validator.CheckValidDecimal(form.Price, -math.MaxFloat64, math.MaxFloat64, gettext("Price must be a decimal number.", form.locale))
|
||||
}
|
||||
if validator.CheckRequiredInput(form.Quantity, gettext("Quantity can not be empty.", form.locale)) {
|
||||
validator.CheckValidInteger(form.Quantity, 1, math.MaxInt32, gettext("Quantity must be a number greater than zero.", form.locale))
|
||||
validator.CheckValidInteger(form.Quantity, math.MinInt32, math.MaxInt32, gettext("Quantity must be an integer.", form.locale))
|
||||
}
|
||||
if validator.CheckRequiredInput(form.Discount, gettext("Discount can not be empty.", form.locale)) {
|
||||
validator.CheckValidInteger(form.Discount, 0, 100, gettext("Discount must be a percentage between 0 and 100.", form.locale))
|
||||
|
@ -1009,11 +1005,11 @@ func (form *quoteProductForm) Validate() bool {
|
|||
|
||||
func (form *quoteProductForm) Update() {
|
||||
validator := newFormValidator()
|
||||
if !validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, "") {
|
||||
if !validator.CheckValidDecimal(form.Price, -math.MaxFloat64, math.MaxFloat64, "") {
|
||||
form.Price.Val = "0.0"
|
||||
form.Price.Errors = nil
|
||||
}
|
||||
if !validator.CheckValidInteger(form.Quantity, 0, math.MaxInt32, "") {
|
||||
if !validator.CheckValidInteger(form.Quantity, math.MinInt32, math.MaxInt32, "") {
|
||||
form.Quantity.Val = "1"
|
||||
form.Quantity.Errors = nil
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ reset client_min_messages;
|
|||
|
||||
begin;
|
||||
|
||||
select plan(16);
|
||||
select plan(17);
|
||||
|
||||
set search_path to auth, numerus, public;
|
||||
|
||||
|
@ -103,11 +103,17 @@ select lives_ok(
|
|||
'Should be able to insert an invoice for the second company with a product'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$$ select add_invoice(1, '2025-01-21', 13, '', 111, '{}','{"(7,Negative 1,Negative units,12.24,-2,0.0,\"{3,4}\")", "(8,Negative 2,Negative price,-34.56,2,0.1,{3})"}') $$,
|
||||
'Should be able to insert an invoice for the first company with products with negative units, and negative prices'
|
||||
);
|
||||
|
||||
select bag_eq(
|
||||
$$ select company_id, invoice_number, invoice_date, contact_id, invoice_status, notes, payment_method_id, currency_code, tags, created_at from invoice $$,
|
||||
$$ values (1, 'F20230006', '2023-02-15'::date, 12, 'created', 'Notes 1', 111, 'EUR', '{tag1,tag2}'::tag_name[], current_timestamp)
|
||||
, (1, 'F20230007', '2023-02-16'::date, 13, 'created', 'Notes 2', 111, 'EUR', '{}'::tag_name[], current_timestamp)
|
||||
, (2, 'INV056-23', '2023-02-14'::date, 15, 'created', 'Notes 3', 222, 'USD', '{tag3}'::tag_name[], current_timestamp)
|
||||
, (1, 'F20250001', '2025-01-21'::date, 13, 'created', '', 111, 'EUR', '{}'::tag_name[], current_timestamp)
|
||||
$$,
|
||||
'Should have created all invoices'
|
||||
);
|
||||
|
@ -119,6 +125,8 @@ select bag_eq(
|
|||
, ('F20230007', 'Product 2', 'Description 2', 2400, 3, 0.75)
|
||||
, ('INV056-23', 'Product 4.3', '', 1111, 1, 0.0)
|
||||
, ('INV056-23', 'Product 4.4', 'Description 4.4', 2222, 3, 0.05)
|
||||
, ('F20250001', 'Negative 1', 'Negative units', 1224, -2, 0.00)
|
||||
, ('F20250001', 'Negative 2', 'Negative price', -3456, 2, 0.10)
|
||||
$$,
|
||||
'Should have created all invoice products'
|
||||
);
|
||||
|
@ -130,6 +138,8 @@ select bag_eq(
|
|||
, ('F20230007', 8, 'Product 2')
|
||||
, ('INV056-23', 11, 'Product 4.3')
|
||||
, ('INV056-23', NULL, 'Product 4.4')
|
||||
, ('F20250001', 7, 'Negative 1')
|
||||
, ('F20250001', 8, 'Negative 2')
|
||||
$$,
|
||||
'Should have linked all invoice products'
|
||||
);
|
||||
|
@ -140,6 +150,9 @@ select bag_eq(
|
|||
, ('F20230007', 'Product 1 bis', 4, 0.21)
|
||||
, ('F20230007', 'Product 1 bis', 3, -0.15)
|
||||
, ('INV056-23', 'Product 4.3', 6, 0.10)
|
||||
, ('F20250001', 'Negative 1', 4, 0.21)
|
||||
, ('F20250001', 'Negative 1', 3, -0.15)
|
||||
, ('F20250001', 'Negative 2', 3, -0.15)
|
||||
$$,
|
||||
'Should have created all invoice product taxes'
|
||||
);
|
||||
|
|
|
@ -5,7 +5,7 @@ reset client_min_messages;
|
|||
|
||||
begin;
|
||||
|
||||
select plan(14);
|
||||
select plan(16);
|
||||
|
||||
set search_path to numerus, auth, public;
|
||||
|
||||
|
@ -74,6 +74,16 @@ select is(
|
|||
'(62.16,"{}",62.16)'::new_invoice_amount
|
||||
);
|
||||
|
||||
select is(
|
||||
compute_new_invoice_amount(1, '{"(6,P,D,8.88,-1,0.0,\"{2,5}\")","(6,P,D,9.99,-2,0.3,\"{2,3}\")"}'),
|
||||
'(-22.87,"{{IRPF -15 %,3.43},{IVA 4 %,-0.56},{IVA 21 %,-1.86}}",-21.86)'::new_invoice_amount
|
||||
);
|
||||
|
||||
select is(
|
||||
compute_new_invoice_amount(1, '{"(6,P,D,-1.11,3,0.4,\"{2,3}\")","(6,P,D,-2.22,4,0.0,\"{2,5}\")"}'),
|
||||
'(-10.88,"{{IRPF -15 %,1.63},{IVA 4 %,-0.08},{IVA 21 %,-1.86}}",-11.19)'::new_invoice_amount
|
||||
);
|
||||
|
||||
select *
|
||||
from finish();
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ reset client_min_messages;
|
|||
|
||||
begin;
|
||||
|
||||
select plan(14);
|
||||
select plan(16);
|
||||
|
||||
set search_path to numerus, auth, public;
|
||||
|
||||
|
@ -74,6 +74,17 @@ select is(
|
|||
'(62.16,"{}",62.16)'::new_quote_amount
|
||||
);
|
||||
|
||||
select is(
|
||||
compute_new_quote_amount(1, '{"(6,P,D,8.88,-1,0.0,\"{2,5}\")","(6,P,D,9.99,-2,0.3,\"{2,3}\")"}'),
|
||||
'(-22.87,"{{IRPF -15 %,3.43},{IVA 4 %,-0.56},{IVA 21 %,-1.86}}",-21.86)'::new_quote_amount
|
||||
);
|
||||
|
||||
select is(
|
||||
compute_new_quote_amount(1, '{"(6,P,D,-1.11,3,0.4,\"{2,3}\")","(6,P,D,-2.22,4,0.0,\"{2,5}\")"}'),
|
||||
'(-10.88,"{{IRPF -15 %,1.63},{IVA 4 %,-0.08},{IVA 21 %,-1.86}}",-11.19)'::new_quote_amount
|
||||
);
|
||||
|
||||
|
||||
select *
|
||||
from finish();
|
||||
|
||||
|
|
|
@ -73,6 +73,8 @@ values ( 8, 1, 'I1', current_date, 7, 'EUR', 111)
|
|||
, ( 9, 1, 'I2', current_date, 7, 'EUR', 111)
|
||||
, (10, 1, 'I3', current_date, 7, 'EUR', 111)
|
||||
, (11, 1, 'I4', current_date, 7, 'EUR', 111)
|
||||
, (12, 1, 'I5', current_date, 7, 'EUR', 111)
|
||||
, (13, 1, 'I6', current_date, 7, 'EUR', 111)
|
||||
;
|
||||
|
||||
insert into invoice_product (invoice_product_id, invoice_id, name, price, quantity, discount_rate)
|
||||
|
@ -83,6 +85,10 @@ values (12, 8, 'P', 100, 1, 0.0)
|
|||
, (16, 10, 'P', 444, 5, 0.0)
|
||||
, (17, 10, 'P', 555, 6, 0.1)
|
||||
, (18, 11, 'P', 777, 8, 0.0)
|
||||
, (19, 12, 'P', 888, -1, 0.0)
|
||||
, (20, 12, 'P', 999, -2, 0.3)
|
||||
, (21, 13, 'P', -111, 3, 0.4)
|
||||
, (22, 13, 'P', -222, 4, 0.0)
|
||||
;
|
||||
|
||||
insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate)
|
||||
|
@ -97,14 +103,24 @@ values (12, 2, -0.15)
|
|||
, (16, 5, 0.21)
|
||||
, (17, 5, 0.21)
|
||||
, (17, 3, 0.04)
|
||||
;
|
||||
, (19, 2, -0.15)
|
||||
, (19, 5, 0.21)
|
||||
, (20, 3, 0.04)
|
||||
, (20, 2, -0.15)
|
||||
, (21, 3, 0.04)
|
||||
, (21, 2, -0.15)
|
||||
, (22, 5, 0.21)
|
||||
, (22, 2, -0.15)
|
||||
;
|
||||
|
||||
select bag_eq(
|
||||
$$ select invoice_id, subtotal, total from invoice_amount $$,
|
||||
$$ values ( 8, 460, 480)
|
||||
, ( 9, 1732, 1999)
|
||||
, (10, 5217, 6654)
|
||||
, (11, 6216, 6216)
|
||||
$$ values ( 8, 460, 480)
|
||||
, ( 9, 1732, 1999)
|
||||
, (10, 5217, 6654)
|
||||
, (11, 6216, 6216)
|
||||
, (12, -2287, -2186)
|
||||
, (13, -1088, -1119)
|
||||
$$,
|
||||
'Should compute the amount for all taxes in the invoiced products.'
|
||||
);
|
||||
|
|
|
@ -73,6 +73,8 @@ values ( 8, 1, 'I1', current_date, 7, 'EUR', 111)
|
|||
, ( 9, 1, 'I2', current_date, 7, 'EUR', 111)
|
||||
, (10, 1, 'I3', current_date, 7, 'EUR', 111)
|
||||
, (11, 1, 'I4', current_date, 7, 'EUR', 111)
|
||||
, (12, 1, 'I5', current_date, 7, 'EUR', 111)
|
||||
, (13, 1, 'I6', current_date, 7, 'EUR', 111)
|
||||
;
|
||||
|
||||
insert into invoice_product (invoice_product_id, invoice_id, name, price, quantity, discount_rate)
|
||||
|
@ -83,6 +85,10 @@ values (12, 8, 'P', 100, 1, 0.0)
|
|||
, (16, 10, 'P', 444, 5, 0.0)
|
||||
, (17, 10, 'P', 555, 6, 0.1)
|
||||
, (18, 11, 'P', 777, 8, 0.0)
|
||||
, (19, 12, 'P', 888, -1, 0.0)
|
||||
, (20, 12, 'P', 999, -2, 0.3)
|
||||
, (21, 13, 'P', -111, 3, 0.4)
|
||||
, (22, 13, 'P', -222, 4, 0.0)
|
||||
;
|
||||
|
||||
insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate)
|
||||
|
@ -97,17 +103,29 @@ values (12, 2, -0.15)
|
|||
, (16, 5, 0.21)
|
||||
, (17, 5, 0.21)
|
||||
, (17, 3, 0.04)
|
||||
;
|
||||
, (19, 2, -0.15)
|
||||
, (19, 5, 0.21)
|
||||
, (20, 3, 0.04)
|
||||
, (20, 2, -0.15)
|
||||
, (21, 3, 0.04)
|
||||
, (21, 2, -0.15)
|
||||
, (22, 5, 0.21)
|
||||
, (22, 2, -0.15)
|
||||
;
|
||||
|
||||
select bag_eq(
|
||||
$$ select invoice_product_id, subtotal, total from invoice_product_amount $$,
|
||||
$$ values (12, 100, 106)
|
||||
, (13, 360, 374)
|
||||
, (14, 666, 826)
|
||||
, (15, 1066, 1173)
|
||||
, (16, 2220, 2908)
|
||||
, (17, 2997, 3746)
|
||||
, (18, 6216, 6216)
|
||||
$$ values (12, 100, 106)
|
||||
, (13, 360, 374)
|
||||
, (14, 666, 826)
|
||||
, (15, 1066, 1173)
|
||||
, (16, 2220, 2908)
|
||||
, (17, 2997, 3746)
|
||||
, (18, 6216, 6216)
|
||||
, (19, -888, -941)
|
||||
, (20, -1399, -1245)
|
||||
, (21, -200, -178)
|
||||
, (22, -888, -941)
|
||||
$$,
|
||||
'Should compute the subtotal and total for all products.'
|
||||
);
|
||||
|
|
|
@ -68,6 +68,8 @@ values ( 8, 1, 'I1', current_date, 'EUR')
|
|||
, ( 9, 1, 'I2', current_date, 'EUR')
|
||||
, (10, 1, 'I3', current_date, 'EUR')
|
||||
, (11, 1, 'I4', current_date, 'EUR')
|
||||
, (12, 1, 'I5', current_date, 'EUR')
|
||||
, (13, 1, 'I6', current_date, 'EUR')
|
||||
;
|
||||
|
||||
insert into quote_product (quote_product_id, quote_id, name, price, quantity, discount_rate)
|
||||
|
@ -78,6 +80,10 @@ values (12, 8, 'P', 100, 1, 0.0)
|
|||
, (16, 10, 'P', 444, 5, 0.0)
|
||||
, (17, 10, 'P', 555, 6, 0.1)
|
||||
, (18, 11, 'P', 777, 8, 0.0)
|
||||
, (19, 12, 'P', 888, -1, 0.0)
|
||||
, (20, 12, 'P', 999, -2, 0.3)
|
||||
, (21, 13, 'P', -111, 3, 0.4)
|
||||
, (22, 13, 'P', -222, 4, 0.0)
|
||||
;
|
||||
|
||||
insert into quote_product_tax (quote_product_id, tax_id, tax_rate)
|
||||
|
@ -92,7 +98,15 @@ values (12, 2, -0.15)
|
|||
, (16, 5, 0.21)
|
||||
, (17, 5, 0.21)
|
||||
, (17, 3, 0.04)
|
||||
;
|
||||
, (19, 2, -0.15)
|
||||
, (19, 5, 0.21)
|
||||
, (20, 3, 0.04)
|
||||
, (20, 2, -0.15)
|
||||
, (21, 3, 0.04)
|
||||
, (21, 2, -0.15)
|
||||
, (22, 5, 0.21)
|
||||
, (22, 2, -0.15)
|
||||
;
|
||||
|
||||
select bag_eq(
|
||||
$$ select quote_id, subtotal, total from quote_amount $$,
|
||||
|
@ -100,6 +114,8 @@ select bag_eq(
|
|||
, ( 9, 1732, 1999)
|
||||
, (10, 5217, 6654)
|
||||
, (11, 6216, 6216)
|
||||
, (12, -2287, -2186)
|
||||
, (13, -1088, -1119)
|
||||
$$,
|
||||
'Should compute the amount for all taxes in the quoted products.'
|
||||
);
|
||||
|
|
|
@ -63,6 +63,8 @@ values ( 8, 1, 'I1', current_date, 'EUR')
|
|||
, ( 9, 1, 'I2', current_date, 'EUR')
|
||||
, (10, 1, 'I3', current_date, 'EUR')
|
||||
, (11, 1, 'I4', current_date, 'EUR')
|
||||
, (12, 1, 'I5', current_date, 'EUR')
|
||||
, (13, 1, 'I6', current_date, 'EUR')
|
||||
;
|
||||
|
||||
insert into quote_product (quote_product_id, quote_id, name, price, quantity, discount_rate)
|
||||
|
@ -73,6 +75,10 @@ values (12, 8, 'P', 100, 1, 0.0)
|
|||
, (16, 10, 'P', 444, 5, 0.0)
|
||||
, (17, 10, 'P', 555, 6, 0.1)
|
||||
, (18, 11, 'P', 777, 8, 0.0)
|
||||
, (19, 12, 'P', 888, -1, 0.0)
|
||||
, (20, 12, 'P', 999, -2, 0.3)
|
||||
, (21, 13, 'P', -111, 3, 0.4)
|
||||
, (22, 13, 'P', -222, 4, 0.0)
|
||||
;
|
||||
|
||||
insert into quote_product_tax (quote_product_id, tax_id, tax_rate)
|
||||
|
@ -87,7 +93,15 @@ values (12, 2, -0.15)
|
|||
, (16, 5, 0.21)
|
||||
, (17, 5, 0.21)
|
||||
, (17, 3, 0.04)
|
||||
;
|
||||
, (19, 2, -0.15)
|
||||
, (19, 5, 0.21)
|
||||
, (20, 3, 0.04)
|
||||
, (20, 2, -0.15)
|
||||
, (21, 3, 0.04)
|
||||
, (21, 2, -0.15)
|
||||
, (22, 5, 0.21)
|
||||
, (22, 2, -0.15)
|
||||
;
|
||||
|
||||
select bag_eq(
|
||||
$$ select quote_product_id, subtotal, total from quote_product_amount $$,
|
||||
|
@ -98,6 +112,10 @@ select bag_eq(
|
|||
, (16, 2220, 2908)
|
||||
, (17, 2997, 3746)
|
||||
, (18, 6216, 6216)
|
||||
, (19, -888, -941)
|
||||
, (20, -1399, -1245)
|
||||
, (21, -200, -178)
|
||||
, (22, -888, -941)
|
||||
$$,
|
||||
'Should compute the subtotal and total for all products.'
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue