From 0ee64f19054e4a3a5f98bea0017e0c11f0ce944d Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Tue, 21 Jan 2025 23:11:29 +0100 Subject: [PATCH] Allow negative prices and quantities in invoices and quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/invoices.go | 22 ++++++++----------- pkg/quote.go | 22 ++++++++----------- test/add_invoice.sql | 15 ++++++++++++- test/compute_new_invoice_amount.sql | 12 +++++++++- test/compute_new_quote_amount.sql | 13 ++++++++++- test/invoice_amount.sql | 26 +++++++++++++++++----- test/invoice_product_amount.sql | 34 ++++++++++++++++++++++------- test/quote_amount.sql | 18 ++++++++++++++- test/quote_product_amount.sql | 20 ++++++++++++++++- 9 files changed, 138 insertions(+), 44 deletions(-) diff --git a/pkg/invoices.go b/pkg/invoices.go index f379de1..a07424b 100644 --- a/pkg/invoices.go +++ b/pkg/invoices.go @@ -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 } diff --git a/pkg/quote.go b/pkg/quote.go index 3a48e92..5c4c080 100644 --- a/pkg/quote.go +++ b/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 } diff --git a/test/add_invoice.sql b/test/add_invoice.sql index eae5fd6..aa4ac17 100644 --- a/test/add_invoice.sql +++ b/test/add_invoice.sql @@ -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' ); diff --git a/test/compute_new_invoice_amount.sql b/test/compute_new_invoice_amount.sql index 6b228da..cc5fa11 100644 --- a/test/compute_new_invoice_amount.sql +++ b/test/compute_new_invoice_amount.sql @@ -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(); diff --git a/test/compute_new_quote_amount.sql b/test/compute_new_quote_amount.sql index 8645fe0..70d8b0e 100644 --- a/test/compute_new_quote_amount.sql +++ b/test/compute_new_quote_amount.sql @@ -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(); diff --git a/test/invoice_amount.sql b/test/invoice_amount.sql index 09bec1c..176d093 100644 --- a/test/invoice_amount.sql +++ b/test/invoice_amount.sql @@ -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.' ); diff --git a/test/invoice_product_amount.sql b/test/invoice_product_amount.sql index 12a97bd..94f7727 100644 --- a/test/invoice_product_amount.sql +++ b/test/invoice_product_amount.sql @@ -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.' ); diff --git a/test/quote_amount.sql b/test/quote_amount.sql index a75e448..3a2dc82 100644 --- a/test/quote_amount.sql +++ b/test/quote_amount.sql @@ -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.' ); diff --git a/test/quote_product_amount.sql b/test/quote_product_amount.sql index f5c6426..91ab8fa 100644 --- a/test/quote_product_amount.sql +++ b/test/quote_product_amount.sql @@ -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.' );