From d69a56e2d50ef183fff3cb6de676bf9ac7ec7d8d Mon Sep 17 00:00:00 2001 From: Fabio Ribeiro Date: Thu, 28 Aug 2025 10:28:24 +0200 Subject: [PATCH] feat: Tax included (#370) * feat: Tax included * Added a toggle switch in tax settings to enable the feature. * Database migration adding tax_included field into estimates, invoices and recurring invoices table. * Toggle switch to enable and store the tax_included by estimates, invoices and recurring invoices. * In case of tax included enabled, total taxes will be recalculated and the invoices, estimates and recurring invoices total won't be sum with taxes. * Apply tax included when discount_per_item/tax_per_item item is enabled. * Custom component to show the net total when tax included is enabled. * Update invoice and estimates pdfs with net total. * chore: Tax included by default A switch button inside the tax settings to enable the tax included by default in invoices, estimates and recurring invoices. --- app/Http/Resources/EstimateResource.php | 1 + app/Http/Resources/InvoiceResource.php | 1 + .../Resources/RecurringInvoiceResource.php | 1 + database/factories/EstimateFactory.php | 1 + database/factories/InvoiceFactory.php | 1 + .../factories/RecurringInvoiceFactory.php | 1 + ...04_152240_add_tax_included_to_invoices.php | 28 +++++ ...4_152522_add_tax_included_to_estimates.php | 28 +++++ ...add_tax_included_to_recurring_invoices.php | 28 +++++ lang/en.json | 8 +- lang/es.json | 8 +- lang/pt-br.json | 10 +- lang/pt.json | 4 +- .../CreateItemRowTax.vue | 14 ++- .../estimate-invoice-common/CreateItems.vue | 33 ++++++ .../estimate-invoice-common/CreateTotal.vue | 28 +++++ .../CreateTotalTaxes.vue | 18 +++ .../estimate-invoice-common/NetTotal.vue | 54 +++++++++ resources/scripts/admin/stores/estimate.js | 71 +++++++----- resources/scripts/admin/stores/invoice.js | 58 +++++++--- .../scripts/admin/stores/recurring-invoice.js | 108 +++++++++++++----- resources/scripts/admin/stub/estimate.js | 1 + resources/scripts/admin/stub/invoice.js | 1 + .../views/estimates/create/EstimateCreate.vue | 8 ++ .../views/invoices/create/InvoiceCreate.vue | 8 ++ .../create/RecurringInvoiceCreate.vue | 8 ++ .../admin/views/settings/TaxTypesSetting.vue | 70 ++++++++++++ .../app/pdf/estimate/partials/table.blade.php | 15 ++- .../app/pdf/invoice/partials/table.blade.php | 11 ++ tests/Feature/Admin/CompanySettingTest.php | 2 + tests/Feature/Admin/EstimateTest.php | 20 ++++ tests/Feature/Admin/InvoiceTest.php | 17 +++ 32 files changed, 582 insertions(+), 83 deletions(-) create mode 100644 database/migrations/2025_05_04_152240_add_tax_included_to_invoices.php create mode 100644 database/migrations/2025_05_04_152522_add_tax_included_to_estimates.php create mode 100644 database/migrations/2025_05_04_152833_add_tax_included_to_recurring_invoices.php create mode 100644 resources/scripts/admin/components/estimate-invoice-common/NetTotal.vue diff --git a/app/Http/Resources/EstimateResource.php b/app/Http/Resources/EstimateResource.php index 67766ab4..0b0eac22 100644 --- a/app/Http/Resources/EstimateResource.php +++ b/app/Http/Resources/EstimateResource.php @@ -21,6 +21,7 @@ class EstimateResource extends JsonResource 'status' => $this->status, 'reference_number' => $this->reference_number, 'tax_per_item' => $this->tax_per_item, + 'tax_included' => $this->tax_included, 'discount_per_item' => $this->discount_per_item, 'notes' => $this->getNotes(), 'discount' => $this->discount, diff --git a/app/Http/Resources/InvoiceResource.php b/app/Http/Resources/InvoiceResource.php index 5aaf3fd5..e5d76589 100644 --- a/app/Http/Resources/InvoiceResource.php +++ b/app/Http/Resources/InvoiceResource.php @@ -22,6 +22,7 @@ class InvoiceResource extends JsonResource 'status' => $this->status, 'paid_status' => $this->paid_status, 'tax_per_item' => $this->tax_per_item, + 'tax_included' => $this->tax_included, 'discount_per_item' => $this->discount_per_item, 'notes' => $this->notes, 'discount_type' => $this->discount_type, diff --git a/app/Http/Resources/RecurringInvoiceResource.php b/app/Http/Resources/RecurringInvoiceResource.php index 8aaed699..fd7352c8 100644 --- a/app/Http/Resources/RecurringInvoiceResource.php +++ b/app/Http/Resources/RecurringInvoiceResource.php @@ -32,6 +32,7 @@ class RecurringInvoiceResource extends JsonResource 'limit_date' => $this->limit_date, 'exchange_rate' => $this->exchange_rate, 'tax_per_item' => $this->tax_per_item, + 'tax_included' => $this->tax_included, 'discount_per_item' => $this->discount_per_item, 'notes' => $this->notes, 'discount_type' => $this->discount_type, diff --git a/database/factories/EstimateFactory.php b/database/factories/EstimateFactory.php index ddcdb2ed..3db92cfd 100644 --- a/database/factories/EstimateFactory.php +++ b/database/factories/EstimateFactory.php @@ -93,6 +93,7 @@ class EstimateFactory extends Factory return $estimate['discount_type'] == 'percentage' ? (($estimate['discount_val'] * $estimate['total']) / 100) : $estimate['discount_val']; }, 'tax_per_item' => 'YES', + 'tax_included' => false, 'discount_per_item' => 'No', 'tax' => $this->faker->randomDigitNotNull(), 'notes' => $this->faker->text(80), diff --git a/database/factories/InvoiceFactory.php b/database/factories/InvoiceFactory.php index 485e627e..a09a5bdc 100644 --- a/database/factories/InvoiceFactory.php +++ b/database/factories/InvoiceFactory.php @@ -93,6 +93,7 @@ class InvoiceFactory extends Factory 'template_name' => 'invoice1', 'status' => Invoice::STATUS_DRAFT, 'tax_per_item' => 'NO', + 'tax_included' => false, 'discount_per_item' => 'NO', 'paid_status' => Invoice::STATUS_UNPAID, 'company_id' => User::find(1)->companies()->first()->id, diff --git a/database/factories/RecurringInvoiceFactory.php b/database/factories/RecurringInvoiceFactory.php index 96304a6b..61e5e40d 100644 --- a/database/factories/RecurringInvoiceFactory.php +++ b/database/factories/RecurringInvoiceFactory.php @@ -26,6 +26,7 @@ class RecurringInvoiceFactory extends Factory 'send_automatically' => false, 'status' => $this->faker->randomElement(['COMPLETED', 'ON_HOLD', 'ACTIVE']), 'tax_per_item' => 'NO', + 'tax_included' => false, 'discount_per_item' => 'NO', 'sub_total' => $this->faker->randomDigitNotNull(), 'total' => $this->faker->randomDigitNotNull(), diff --git a/database/migrations/2025_05_04_152240_add_tax_included_to_invoices.php b/database/migrations/2025_05_04_152240_add_tax_included_to_invoices.php new file mode 100644 index 00000000..d95137b5 --- /dev/null +++ b/database/migrations/2025_05_04_152240_add_tax_included_to_invoices.php @@ -0,0 +1,28 @@ +boolean('tax_included')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn('tax_included'); + }); + } +}; diff --git a/database/migrations/2025_05_04_152522_add_tax_included_to_estimates.php b/database/migrations/2025_05_04_152522_add_tax_included_to_estimates.php new file mode 100644 index 00000000..dfa08c96 --- /dev/null +++ b/database/migrations/2025_05_04_152522_add_tax_included_to_estimates.php @@ -0,0 +1,28 @@ +boolean('tax_included')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('estimates', function (Blueprint $table) { + $table->dropColumn('tax_included'); + }); + } +}; diff --git a/database/migrations/2025_05_04_152833_add_tax_included_to_recurring_invoices.php b/database/migrations/2025_05_04_152833_add_tax_included_to_recurring_invoices.php new file mode 100644 index 00000000..e22691ef --- /dev/null +++ b/database/migrations/2025_05_04_152833_add_tax_included_to_recurring_invoices.php @@ -0,0 +1,28 @@ +boolean('tax_included')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('recurring_invoices', function (Blueprint $table) { + $table->dropColumn('tax_included'); + }); + } +}; diff --git a/lang/en.json b/lang/en.json index 84650b9d..cb96f9a4 100644 --- a/lang/en.json +++ b/lang/en.json @@ -306,6 +306,7 @@ "total": "Total", "discount": "Discount", "sub_total": "Sub Total", + "net_total": "Net", "estimate_number": "Estimate Number", "ref_number": "Ref Number", "contact": "Contact", @@ -1220,7 +1221,11 @@ "updated_message": "Tax type updated successfully", "deleted_message": "Tax type deleted successfully", "confirm_delete": "You will not be able to recover this Tax Type", - "already_in_use": "Tax is already in use" + "already_in_use": "Tax is already in use", + "tax_included": "Inclusive taxes", + "tax_included_description": "Enable this if you want to report that taxes are already included in the invoice items or invoice total.", + "tax_included_by_default": "Enable inclusive taxes by default", + "tax_included_by_default_description": "Enable this if you want to set inclusive taxes by default" }, "payment_modes": { "title": "Payment Modes", @@ -1616,6 +1621,7 @@ "pdf_discount_label": "Discount", "pdf_amount_label": "Amount", "pdf_subtotal": "Subtotal", + "pdf_net_total": "Net", "pdf_total": "Total", "pdf_payment_label": "Payment", "pdf_payment_receipt_label": "PAYMENT RECEIPT", diff --git a/lang/es.json b/lang/es.json index 7dbbacdd..9e60dfca 100644 --- a/lang/es.json +++ b/lang/es.json @@ -303,6 +303,7 @@ "total": "Total", "discount": "Descuento", "sub_total": "Subtotal", + "net_total": "Base Imponible", "estimate_number": "Número de Presupuesto", "ref_number": "Número de referencia", "contact": "Contacto", @@ -1212,7 +1213,11 @@ "updated_message": "Tipo de impuesto actualizado correctamente", "deleted_message": "Tipo de impuesto eliminado correctamente", "confirm_delete": "No podrá recuperar este tipo de impuesto", - "already_in_use": "El impuesto ya está en uso." + "already_in_use": "El impuesto ya está en uso.", + "tax_included": "Impuestos inclusivos", + "tax_included_description": "Habilítelo si desea informar que los impuestos ya están incluidos en los artículos de la factura o en el total de la factura.", + "tax_included_by_default": "Usar impuestos inclusivos por defecto", + "tax_included_by_default_description": "Habilítelo si desea establecer los impuestos inclusivos por defecto." }, "payment_modes": { "title": "Formas de pago", @@ -1608,6 +1613,7 @@ "pdf_discount_label": "Descuento", "pdf_amount_label": "Cantidad", "pdf_subtotal": "Subtotal", + "pdf_net_total": "Base Imponible", "pdf_total": "Total", "pdf_payment_label": "Pago", "pdf_payment_receipt_label": "RECIBO DE PAGO", diff --git a/lang/pt-br.json b/lang/pt-br.json index 0017b656..3bb99947 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -217,6 +217,7 @@ "total": "Total", "discount": "Desconto", "sub_total": "Subtotal", + "net_total": "Total Líquido", "estimate_number": "Numero do Orçamento", "ref_number": "Referência", "contact": "Contato", @@ -759,7 +760,11 @@ "updated_message": "Tipo de Imposto Atualizado com sucesso", "deleted_message": "Tipo de Imposto Deletado com sucesso", "confirm_delete": "Você não poderá recuperar este tipo de Imposto", - "already_in_use": "O Imposto já está em uso" + "already_in_use": "O Imposto já está em uso", + "tax_included": "Imposto incluído", + "tax_included_description": "Habilite isso se desejar informar que os Impostos já estão incluídos nos itens da Fatura ou no total da Fatura.", + "tax_included_by_default": "Usar imposto incluído por padrão", + "tax_included_by_default_description": "Habilite isso se desejar definir os impostos incluídos por padrão." }, "expense_category": { "title": "Categoria de Despesa", @@ -932,5 +937,6 @@ "address_maxlength": "O endereço não deve ter mais que 255 caracteres.", "ref_number_maxlength": "O número de referência não deve ter mais que 255 caracteres.", "prefix_maxlength": "O prefixo não deve ter mais que 5 caracteres." - } + }, + "pdf_net_total": "Total Líquido" } diff --git a/lang/pt.json b/lang/pt.json index 95b91aa7..db61e18c 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1212,7 +1212,9 @@ "updated_message": "Tipo de Imposto Atualizado com sucesso", "deleted_message": "Tipo de Imposto Deletado com sucesso", "confirm_delete": "Você não poderá recuperar este tipo de Imposto", - "already_in_use": "O Imposto já está em uso" + "already_in_use": "O Imposto já está em uso", + "tax_included": "Imposto incluído", + "tax_included_description": "Habilite isso se desejar informar que os Impostos já estão incluídos nos itens da Fatura ou no total da Fatura." }, "payment_modes": { "title": "Modos de Pagamento", diff --git a/resources/scripts/admin/components/estimate-invoice-common/CreateItemRowTax.vue b/resources/scripts/admin/components/estimate-invoice-common/CreateItemRowTax.vue index e212d955..222bf6dc 100644 --- a/resources/scripts/admin/components/estimate-invoice-common/CreateItemRowTax.vue +++ b/resources/scripts/admin/components/estimate-invoice-common/CreateItemRowTax.vue @@ -169,6 +169,9 @@ const taxAmount = computed(() => { if (taxPerItemEnabled && !discountPerItemEnabled){ return getTaxAmount() } + if (props.store[props.storeProp].tax_included) { + return Math.round(props.discountedTotal - (props.discountedTotal / (1 + (localTax.percent / 100)))) + } return (props.discountedTotal * localTax.percent) / 100 } return 0 @@ -261,6 +264,7 @@ function getTaxAmount() { const itemTotal = props.discountedTotal const modelDiscount = props.store[props.storeProp].discount ? props.store[props.storeProp].discount : 0 const type = props.store[props.storeProp].discount_type + let discountedTotal = props.discountedTotal if (modelDiscount > 0) { props.store[props.storeProp].items.forEach((_i) => { total += _i.total @@ -268,10 +272,14 @@ function getTaxAmount() { const proportion = (itemTotal / total).toFixed(2) discount = type === 'fixed' ? modelDiscount * 100 : (total * modelDiscount) / 100 const itemDiscount = Math.round(discount * proportion) - const discounted = itemTotal - itemDiscount - return Math.round((discounted * localTax.percent) / 100) + discountedTotal = itemTotal - itemDiscount } - return Math.round((props.discountedTotal * localTax.percent) / 100) + + if (props.store[props.storeProp].tax_included) { + return Math.round(discountedTotal - (discountedTotal / (1 + (localTax.percent / 100)))) + } + + return Math.round((discountedTotal * localTax.percent) / 100) } diff --git a/resources/scripts/admin/components/estimate-invoice-common/CreateItems.vue b/resources/scripts/admin/components/estimate-invoice-common/CreateItems.vue index 7df16cfe..8fe0d3cf 100644 --- a/resources/scripts/admin/components/estimate-invoice-common/CreateItems.vue +++ b/resources/scripts/admin/components/estimate-invoice-common/CreateItems.vue @@ -1,4 +1,27 @@ @@ -139,6 +154,61 @@ const taxPerItemField = computed({ }, }) +const taxIncludedSettings = reactive({ + tax_included: 'NO', + tax_included_by_default: 'NO', +}) + +utils.mergeSettings(taxIncludedSettings, { + ...companyStore.selectedCompanySettings, +}) + +const taxIncludedField = computed({ + get: () => { + return taxIncludedSettings.tax_included === 'YES' + }, + set: async (newValue) => { + const value = newValue ? 'YES' : 'NO' + taxIncludedSettings.tax_included = value + + if (!newValue) { + taxIncludedSettings.tax_included_by_default = 'NO' + } + + let data = { + settings: { + ...taxIncludedSettings, + }, + } + + await companyStore.updateCompanySettings({ + data, + message: 'general.setting_updated', + }) + }, +}) + +const taxIncludedByDefaultField = computed({ + get: () => { + return taxIncludedSettings.tax_included_by_default === 'YES' + }, + set: async (newValue) => { + const value = newValue ? 'YES' : 'NO' + taxIncludedSettings.tax_included_by_default = value + + let data = { + settings: { + tax_included_by_default: taxIncludedSettings.tax_included_by_default, + }, + } + + await companyStore.updateCompanySettings({ + data, + message: 'general.setting_updated', + }) + }, +}) + function hasAtleastOneAbility() { return userStore.hasAbilities([ abilities.DELETE_TAX_TYPE, diff --git a/resources/views/app/pdf/estimate/partials/table.blade.php b/resources/views/app/pdf/estimate/partials/table.blade.php index a060e0eb..30d3dd13 100644 --- a/resources/views/app/pdf/estimate/partials/table.blade.php +++ b/resources/views/app/pdf/estimate/partials/table.blade.php @@ -101,7 +101,18 @@ @endif @endif - + + @if ($estimate->tax_included) + + + @lang('pdf_net_total') + + + {!! format_money_pdf($estimate->sub_total - $estimate->discount - $estimate->tax, $estimate->customer->currency) !!} + + + @endif + @if ($estimate->tax_per_item === 'YES') @foreach ($taxes as $tax) @@ -133,7 +144,7 @@ @endforeach @endif - + diff --git a/resources/views/app/pdf/invoice/partials/table.blade.php b/resources/views/app/pdf/invoice/partials/table.blade.php index 5db0a0eb..80ae5db6 100644 --- a/resources/views/app/pdf/invoice/partials/table.blade.php +++ b/resources/views/app/pdf/invoice/partials/table.blade.php @@ -121,6 +121,17 @@ @endif @endif + @if ($invoice->tax_included) + + + @lang('pdf_net_total') + + + {!! format_money_pdf($invoice->sub_total - $invoice->discount - $invoice->tax, $invoice->customer->currency) !!} + + + @endif + @if ($invoice->tax_per_item === 'YES') @foreach ($taxes as $tax) diff --git a/tests/Feature/Admin/CompanySettingTest.php b/tests/Feature/Admin/CompanySettingTest.php index 2ed95832..d6f03199 100644 --- a/tests/Feature/Admin/CompanySettingTest.php +++ b/tests/Feature/Admin/CompanySettingTest.php @@ -105,6 +105,8 @@ test('update settings', function () { 'notify_invoice_viewed' => 'YES', 'notify_estimate_viewed' => 'YES', 'tax_per_item' => 'YES', + 'tax_included' => 'YES', + 'tax_included_by_default' => 'YES', 'discount_per_item' => 'YES', ]; diff --git a/tests/Feature/Admin/EstimateTest.php b/tests/Feature/Admin/EstimateTest.php index 4eb535a5..96bee25d 100644 --- a/tests/Feature/Admin/EstimateTest.php +++ b/tests/Feature/Admin/EstimateTest.php @@ -463,3 +463,23 @@ test('update estimate with EUR currency', function () { $response->assertStatus(200); }); + +test('create estimate with tax included', function () { + $estimate = Estimate::factory()->raw([ + 'estimate_number' => 'EST-000006', + 'items' => [ + EstimateItem::factory()->raw(), + ], + 'taxes' => [ + Tax::factory()->raw(), + ], + 'tax_included' => true, + ]); + + postJson('api/v1/estimates', $estimate) + ->assertStatus(201); + + $this->assertDatabaseHas('estimates', [ + 'tax_included' => $estimate['tax_included'], + ]); +}); diff --git a/tests/Feature/Admin/InvoiceTest.php b/tests/Feature/Admin/InvoiceTest.php index bd27652c..99d9c4dc 100644 --- a/tests/Feature/Admin/InvoiceTest.php +++ b/tests/Feature/Admin/InvoiceTest.php @@ -520,3 +520,20 @@ test('update invoice with EUR currency', function () { 'base_amount' => $invoice2['taxes'][0]['base_amount'], ]); }); + +test('create invoice with tax included', function () { + $invoice = Invoice::factory() + ->raw([ + 'taxes' => [Tax::factory()->raw()], + 'items' => [InvoiceItem::factory()->raw()], + 'tax_included' => true, + ]); + + $response = postJson('api/v1/invoices', $invoice); + + $response->assertOk(); + + $this->assertDatabaseHas('invoices', [ + 'tax_included' => true, + ]); +});