diff --git a/app/Domains/Sales/Application/RecurringInvoiceService.php b/app/Domains/Sales/Application/RecurringInvoiceService.php index 7a6d5a93..c1dcd85d 100644 --- a/app/Domains/Sales/Application/RecurringInvoiceService.php +++ b/app/Domains/Sales/Application/RecurringInvoiceService.php @@ -162,6 +162,7 @@ class RecurringInvoiceService $newInvoice['paid_status'] = Invoice::STATUS_UNPAID; $newInvoice['sub_total'] = $recurringInvoice->sub_total; $newInvoice['tax_per_item'] = $recurringInvoice->tax_per_item; + $newInvoice['tax_included'] = $recurringInvoice->tax_included; $newInvoice['discount_per_item'] = $recurringInvoice->discount_per_item; $newInvoice['tax'] = $recurringInvoice->tax; $newInvoice['total'] = $recurringInvoice->total; @@ -231,6 +232,10 @@ class RecurringInvoiceService $createdItem = $recurringInvoice->items()->create($item); if (array_key_exists('taxes', $item) && $item['taxes']) { foreach ($item['taxes'] as $tax) { + if (empty($tax['tax_type_id'])) { + continue; + } + $tax['company_id'] = $recurringInvoice->company_id; if (gettype($tax['amount']) !== 'NULL') { $createdItem->taxes()->create($tax); diff --git a/app/Domains/Sales/Http/Requests/Concerns/ValidatesDocumentTaxPlaceholders.php b/app/Domains/Sales/Http/Requests/Concerns/ValidatesDocumentTaxPlaceholders.php new file mode 100644 index 00000000..7eceec74 --- /dev/null +++ b/app/Domains/Sales/Http/Requests/Concerns/ValidatesDocumentTaxPlaceholders.php @@ -0,0 +1,44 @@ +after(function (Validator $validator): void { + $items = $this->input('items', []); + + if (! is_array($items)) { + return; + } + + foreach ($items as $itemIndex => $item) { + if (! is_array($item)) { + continue; + } + + $taxes = $item['taxes'] ?? []; + + if (! is_array($taxes)) { + continue; + } + + foreach ($taxes as $taxIndex => $tax) { + if (! is_array($tax) || ! empty($tax['tax_type_id'])) { + continue; + } + + if ((float) ($tax['amount'] ?? 0) !== 0.0) { + $validator->errors()->add( + "items.{$itemIndex}.taxes.{$taxIndex}.amount", + 'A tax amount requires a tax type.' + ); + } + } + } + }); + } +} diff --git a/app/Domains/Sales/Http/Requests/EstimatesRequest.php b/app/Domains/Sales/Http/Requests/EstimatesRequest.php index 69742064..f6e703d9 100644 --- a/app/Domains/Sales/Http/Requests/EstimatesRequest.php +++ b/app/Domains/Sales/Http/Requests/EstimatesRequest.php @@ -9,9 +9,12 @@ use App\Platform\Pdf\Rules\PdfTemplateExists; use App\Support\DocumentTotals; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class EstimatesRequest extends FormRequest { + use Concerns\ValidatesDocumentTaxPlaceholders; + /** * Determine if the user is authorized to make this request. */ @@ -115,6 +118,11 @@ class EstimatesRequest extends FormRequest return $rules; } + public function withValidator(Validator $validator): void + { + $this->validateDocumentTaxPlaceholders($validator); + } + public function getEstimatePayload() { $company_currency = CompanySetting::getSetting('currency', $this->header('company')); diff --git a/app/Domains/Sales/Http/Requests/InvoicesRequest.php b/app/Domains/Sales/Http/Requests/InvoicesRequest.php index 73524e6c..536cab0c 100644 --- a/app/Domains/Sales/Http/Requests/InvoicesRequest.php +++ b/app/Domains/Sales/Http/Requests/InvoicesRequest.php @@ -9,9 +9,12 @@ use App\Platform\Pdf\Rules\PdfTemplateExists; use App\Support\DocumentTotals; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class InvoicesRequest extends FormRequest { + use Concerns\ValidatesDocumentTaxPlaceholders; + /** * Determine if the user is authorized to make this request. */ @@ -114,6 +117,11 @@ class InvoicesRequest extends FormRequest return $rules; } + public function withValidator(Validator $validator): void + { + $this->validateDocumentTaxPlaceholders($validator); + } + public function getInvoicePayload(): array { $company_currency = CompanySetting::getSetting('currency', $this->header('company')); diff --git a/app/Domains/Sales/Http/Requests/RecurringInvoiceRequest.php b/app/Domains/Sales/Http/Requests/RecurringInvoiceRequest.php index fa6c1e8b..c4f513ff 100644 --- a/app/Domains/Sales/Http/Requests/RecurringInvoiceRequest.php +++ b/app/Domains/Sales/Http/Requests/RecurringInvoiceRequest.php @@ -7,9 +7,12 @@ use App\Domains\Contacts\Models\Customer; use App\Domains\Sales\Models\RecurringInvoice; use App\Support\DocumentTotals; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; class RecurringInvoiceRequest extends FormRequest { + use Concerns\ValidatesDocumentTaxPlaceholders; + /** * Determine if the user is authorized to make this request. */ @@ -101,6 +104,11 @@ class RecurringInvoiceRequest extends FormRequest return $rules; } + public function withValidator(Validator $validator): void + { + $this->validateDocumentTaxPlaceholders($validator); + } + public function getRecurringInvoicePayload() { $company_currency = CompanySetting::getSetting('currency', $this->header('company')); diff --git a/app/Domains/Taxation/Http/Requests/TaxTypeRequest.php b/app/Domains/Taxation/Http/Requests/TaxTypeRequest.php index 8421d120..43103989 100644 --- a/app/Domains/Taxation/Http/Requests/TaxTypeRequest.php +++ b/app/Domains/Taxation/Http/Requests/TaxTypeRequest.php @@ -5,6 +5,7 @@ namespace App\Domains\Taxation\Http\Requests; use App\Domains\Taxation\Models\TaxType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class TaxTypeRequest extends FormRequest { @@ -44,7 +45,7 @@ class TaxTypeRequest extends FormRequest 'nullable', ], 'compound_tax' => [ - 'nullable', + 'sometimes', 'boolean', ], 'collective_tax' => [ @@ -72,6 +73,25 @@ class TaxTypeRequest extends FormRequest return $rules; } + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if ($validator->errors()->isNotEmpty() || ! $this->effectiveCompoundTax()) { + return; + } + + if ( + $this->effectiveCalculationType() !== 'percentage' + || $this->effectiveTransactionType() !== TaxType::TRANSACTION_TYPE_SALES + ) { + $validator->errors()->add( + 'compound_tax', + 'Compound tax is only available for percentage sales taxes.' + ); + } + }); + } + public function getTaxTypePayload() { $payload = collect($this->validated()); @@ -85,6 +105,10 @@ class TaxTypeRequest extends FormRequest ); } + if (! $payload->has('compound_tax') && ! ($this->isMethod('PUT') || $this->isMethod('PATCH'))) { + $payload->put('compound_tax', false); + } + return $payload ->merge([ 'company_id' => $this->header('company'), @@ -92,4 +116,34 @@ class TaxTypeRequest extends FormRequest ]) ->toArray(); } + + private function effectiveCompoundTax(): bool + { + if ($this->has('compound_tax')) { + return $this->boolean('compound_tax'); + } + + return $this->isMethod('PUT') || $this->isMethod('PATCH') + ? $this->route('tax_type')->compound_tax + : false; + } + + private function effectiveCalculationType(): string + { + if ($this->has('calculation_type')) { + return $this->input('calculation_type'); + } + + return $this->isMethod('PUT') || $this->isMethod('PATCH') + ? $this->route('tax_type')->calculation_type + : 'percentage'; + } + + private function effectiveTransactionType(): string + { + return $this->input('transaction_type') + ?? ($this->isMethod('PUT') || $this->isMethod('PATCH') + ? $this->route('tax_type')->transaction_type + : TaxType::TRANSACTION_TYPE_SALES); + } } diff --git a/app/Support/DocumentTotals.php b/app/Support/DocumentTotals.php index 6fa02194..69519040 100644 --- a/app/Support/DocumentTotals.php +++ b/app/Support/DocumentTotals.php @@ -15,8 +15,8 @@ namespace App\Support; class DocumentTotals { /** - * @param array $items each item: price, quantity, discount_val?, taxes?[{amount}] - * @param array $taxes document-level taxes: [{amount}, ...] + * @param array $items each item: price, quantity, discount_val?, taxes?[{tax_type_id, amount, compound_tax?}] + * @param array $taxes document-level taxes: [{amount, compound_tax?}, ...] * @return array{sub_total:int, tax:int, total:int} */ public static function compute(array $items, array $taxes, $discountVal, $taxPerItem, bool $taxIncluded, $discountPerItem = 'NO'): array @@ -25,18 +25,28 @@ class DocumentTotals $perItemTax = is_string($taxPerItem) && strtoupper(trim($taxPerItem)) === 'YES'; $subTotal = 0; - $itemTaxTotal = 0; + $itemSimpleTaxTotal = 0; + $itemCompoundTaxTotal = 0; foreach ($items as $item) { $subTotal += self::itemTotal($item, $perItemDiscount); - $itemTaxTotal += self::sumTaxAmounts($item['taxes'] ?? []); + $itemSimpleTaxTotal += self::sumTaxAmounts($item['taxes'] ?? [], false, true); + $itemCompoundTaxTotal += self::sumTaxAmounts($item['taxes'] ?? [], true, true); } $subtotalWithDiscount = $subTotal - (int) round((float) $discountVal); - $totalTax = $perItemTax ? $itemTaxTotal : self::sumTaxAmounts($taxes); + $simpleTaxTotal = $perItemTax + ? $itemSimpleTaxTotal + : self::sumTaxAmounts($taxes, false); + $compoundTaxTotal = $perItemTax + ? $itemCompoundTaxTotal + : self::sumTaxAmounts($taxes, true); + $totalTax = $simpleTaxTotal + $compoundTaxTotal; - $total = $taxIncluded ? $subtotalWithDiscount : $subtotalWithDiscount + $totalTax; + $total = $taxIncluded + ? $subtotalWithDiscount + $compoundTaxTotal + : $subtotalWithDiscount + $totalTax; return [ 'sub_total' => $subTotal, @@ -58,13 +68,26 @@ class DocumentTotals return (int) round($price * $quantity) - $discount; } - protected static function sumTaxAmounts(array $taxes): int + protected static function sumTaxAmounts(array $taxes, bool $compoundTax, bool $ignorePlaceholders = false): int { $sum = 0; foreach ($taxes as $tax) { + if ($ignorePlaceholders && empty($tax['tax_type_id'])) { + continue; + } + + if (self::isCompoundTax($tax) !== $compoundTax) { + continue; + } + $sum += (int) round((float) ($tax['amount'] ?? 0)); } return $sum; } + + protected static function isCompoundTax(array $tax): bool + { + return filter_var($tax['compound_tax'] ?? false, FILTER_VALIDATE_BOOLEAN); + } } diff --git a/resources/scripts/features/company/estimates/store.ts b/resources/scripts/features/company/estimates/store.ts index 9f91be9b..bd420323 100644 --- a/resources/scripts/features/company/estimates/store.ts +++ b/resources/scripts/features/company/estimates/store.ts @@ -160,10 +160,22 @@ export const useEstimateStore = defineStore('estimate', { }, getNetTotal(): number { - return this.getSubtotalWithDiscount - this.getTotalTax + if (this.newEstimate.tax_included) { + return this.getSubtotalWithDiscount - this.getTotalSimpleTax + } + + return this.getSubtotalWithDiscount }, getTotalSimpleTax(state): number { + if (state.newEstimate.tax_per_item === 'YES') { + return state.newEstimate.items.reduce((sum: number, item: DocumentItem) => { + return sum + (item.taxes ?? []).reduce((itemSum, tax) => { + return tax.compound_tax ? itemSum : itemSum + (tax.amount ?? 0) + }, 0) + }, 0) + } + return state.newEstimate.taxes.reduce( (sum: number, tax: DocumentTax) => { if (!tax.compound_tax) return sum + (tax.amount ?? 0) @@ -174,6 +186,14 @@ export const useEstimateStore = defineStore('estimate', { }, getTotalCompoundTax(state): number { + if (state.newEstimate.tax_per_item === 'YES') { + return state.newEstimate.items.reduce((sum: number, item: DocumentItem) => { + return sum + (item.taxes ?? []).reduce((itemSum, tax) => { + return tax.compound_tax ? itemSum + (tax.amount ?? 0) : itemSum + }, 0) + }, 0) + } + return state.newEstimate.taxes.reduce( (sum: number, tax: DocumentTax) => { if (tax.compound_tax) return sum + (tax.amount ?? 0) @@ -184,16 +204,7 @@ export const useEstimateStore = defineStore('estimate', { }, getTotalTax(): number { - if ( - this.newEstimate.tax_per_item === 'NO' || - this.newEstimate.tax_per_item === null - ) { - return this.getTotalSimpleTax + this.getTotalCompoundTax - } - return this.newEstimate.items.reduce( - (sum: number, item: DocumentItem) => sum + (item.tax ?? 0), - 0, - ) + return this.getTotalSimpleTax + this.getTotalCompoundTax }, getSubtotalWithDiscount(): number { @@ -202,7 +213,7 @@ export const useEstimateStore = defineStore('estimate', { getTotal(): number { if (this.newEstimate.tax_included) { - return this.getSubtotalWithDiscount + return this.getSubtotalWithDiscount + this.getTotalCompoundTax } return this.getSubtotalWithDiscount + this.getTotalTax }, @@ -508,6 +519,9 @@ export const useEstimateStore = defineStore('estimate', { if (!isEdit && companySettings) { this.newEstimate.tax_per_item = companySettings.tax_per_item ?? null + this.newEstimate.tax_included = + companySettings.tax_included === 'YES' && + companySettings.tax_included_by_default === 'YES' this.newEstimate.sales_tax_type = companySettings.sales_tax_type ?? null this.newEstimate.sales_tax_address_type = companySettings.sales_tax_address_type ?? null diff --git a/resources/scripts/features/company/estimates/views/EstimateCreateView.vue b/resources/scripts/features/company/estimates/views/EstimateCreateView.vue index 69cf6d24..1b473934 100644 --- a/resources/scripts/features/company/estimates/views/EstimateCreateView.vue +++ b/resources/scripts/features/company/estimates/views/EstimateCreateView.vue @@ -62,6 +62,7 @@ :currency="estimateStore.newEstimate.selectedCurrency" :is-loading="isLoadingContent" :item-validation-scope="estimateValidationScope" + :tax-included-setting="companyStore.selectedCompanySettings.tax_included" :store="estimateStore" store-prop="newEstimate" /> @@ -115,6 +116,7 @@ import { } from '@vuelidate/validators' import useVuelidate from '@vuelidate/core' import { useEstimateStore } from '../store' +import { useCompanyStore } from '@/scripts/stores/company.store' import { useNotificationStore } from '@/scripts/stores/notification.store' import { handleApiError, @@ -130,6 +132,7 @@ import { } from '../../../shared/document-form' const estimateStore = useEstimateStore() +const companyStore = useCompanyStore() const notificationStore = useNotificationStore() const { t } = useI18n() const route = useRoute() diff --git a/resources/scripts/features/company/invoices/store.ts b/resources/scripts/features/company/invoices/store.ts index 4e841b30..ec7c310c 100644 --- a/resources/scripts/features/company/invoices/store.ts +++ b/resources/scripts/features/company/invoices/store.ts @@ -171,10 +171,22 @@ export const useInvoiceStore = defineStore('invoice', { }, getNetTotal(): number { - return this.getSubtotalWithDiscount - this.getTotalTax + if (this.newInvoice.tax_included) { + return this.getSubtotalWithDiscount - this.getTotalSimpleTax + } + + return this.getSubtotalWithDiscount }, getTotalSimpleTax(state): number { + if (state.newInvoice.tax_per_item === 'YES') { + return state.newInvoice.items.reduce((sum: number, item: DocumentItem) => { + return sum + (item.taxes ?? []).reduce((itemSum, tax) => { + return tax.compound_tax ? itemSum : itemSum + (tax.amount ?? 0) + }, 0) + }, 0) + } + return state.newInvoice.taxes.reduce( (sum: number, tax: DocumentTax) => { if (!tax.compound_tax) return sum + (tax.amount ?? 0) @@ -185,6 +197,14 @@ export const useInvoiceStore = defineStore('invoice', { }, getTotalCompoundTax(state): number { + if (state.newInvoice.tax_per_item === 'YES') { + return state.newInvoice.items.reduce((sum: number, item: DocumentItem) => { + return sum + (item.taxes ?? []).reduce((itemSum, tax) => { + return tax.compound_tax ? itemSum + (tax.amount ?? 0) : itemSum + }, 0) + }, 0) + } + return state.newInvoice.taxes.reduce( (sum: number, tax: DocumentTax) => { if (tax.compound_tax) return sum + (tax.amount ?? 0) @@ -195,16 +215,7 @@ export const useInvoiceStore = defineStore('invoice', { }, getTotalTax(): number { - if ( - this.newInvoice.tax_per_item === 'NO' || - this.newInvoice.tax_per_item === null - ) { - return this.getTotalSimpleTax + this.getTotalCompoundTax - } - return this.newInvoice.items.reduce( - (sum: number, item: DocumentItem) => sum + (item.tax ?? 0), - 0, - ) + return this.getTotalSimpleTax + this.getTotalCompoundTax }, getSubtotalWithDiscount(): number { @@ -213,7 +224,7 @@ export const useInvoiceStore = defineStore('invoice', { getTotal(): number { if (this.newInvoice.tax_included) { - return this.getSubtotalWithDiscount + return this.getSubtotalWithDiscount + this.getTotalCompoundTax } return this.getSubtotalWithDiscount + this.getTotalTax }, @@ -504,6 +515,9 @@ export const useInvoiceStore = defineStore('invoice', { if (!isEdit && companySettings) { this.newInvoice.tax_per_item = companySettings.tax_per_item ?? null + this.newInvoice.tax_included = + companySettings.tax_included === 'YES' && + companySettings.tax_included_by_default === 'YES' this.newInvoice.sales_tax_type = companySettings.sales_tax_type ?? null this.newInvoice.sales_tax_address_type = companySettings.sales_tax_address_type ?? null diff --git a/resources/scripts/features/company/invoices/views/InvoiceCreateView.vue b/resources/scripts/features/company/invoices/views/InvoiceCreateView.vue index db30c89b..5db5e5a7 100644 --- a/resources/scripts/features/company/invoices/views/InvoiceCreateView.vue +++ b/resources/scripts/features/company/invoices/views/InvoiceCreateView.vue @@ -65,6 +65,7 @@ :currency="invoiceStore.newInvoice.selectedCurrency" :is-loading="isLoadingContent" :item-validation-scope="invoiceValidationScope" + :tax-included-setting="companyStore.selectedCompanySettings.tax_included" :store="invoiceStore" store-prop="newInvoice" /> diff --git a/resources/scripts/features/company/recurring-invoices/store.ts b/resources/scripts/features/company/recurring-invoices/store.ts index 00ee042e..d9f5b576 100644 --- a/resources/scripts/features/company/recurring-invoices/store.ts +++ b/resources/scripts/features/company/recurring-invoices/store.ts @@ -192,10 +192,22 @@ export const useRecurringInvoiceStore = defineStore('recurring-invoice', { }, getNetTotal(): number { - return this.getSubtotalWithDiscount - this.getTotalTax + if (this.newRecurringInvoice.tax_included) { + return this.getSubtotalWithDiscount - this.getTotalSimpleTax + } + + return this.getSubtotalWithDiscount }, getTotalSimpleTax(state): number { + if (state.newRecurringInvoice.tax_per_item === 'YES') { + return state.newRecurringInvoice.items.reduce((sum: number, item: DocumentItem) => { + return sum + (item.taxes ?? []).reduce((itemSum, tax) => { + return tax.compound_tax ? itemSum : itemSum + (tax.amount ?? 0) + }, 0) + }, 0) + } + return state.newRecurringInvoice.taxes.reduce( (sum: number, tax: DocumentTax) => { if (!tax.compound_tax) return sum + (tax.amount ?? 0) @@ -206,6 +218,14 @@ export const useRecurringInvoiceStore = defineStore('recurring-invoice', { }, getTotalCompoundTax(state): number { + if (state.newRecurringInvoice.tax_per_item === 'YES') { + return state.newRecurringInvoice.items.reduce((sum: number, item: DocumentItem) => { + return sum + (item.taxes ?? []).reduce((itemSum, tax) => { + return tax.compound_tax ? itemSum + (tax.amount ?? 0) : itemSum + }, 0) + }, 0) + } + return state.newRecurringInvoice.taxes.reduce( (sum: number, tax: DocumentTax) => { if (tax.compound_tax) return sum + (tax.amount ?? 0) @@ -216,16 +236,7 @@ export const useRecurringInvoiceStore = defineStore('recurring-invoice', { }, getTotalTax(): number { - if ( - this.newRecurringInvoice.tax_per_item === 'NO' || - this.newRecurringInvoice.tax_per_item === null - ) { - return this.getTotalSimpleTax + this.getTotalCompoundTax - } - return this.newRecurringInvoice.items.reduce( - (sum: number, item: DocumentItem) => sum + (item.tax ?? 0), - 0, - ) + return this.getTotalSimpleTax + this.getTotalCompoundTax }, getSubtotalWithDiscount(): number { @@ -234,7 +245,7 @@ export const useRecurringInvoiceStore = defineStore('recurring-invoice', { getTotal(): number { if (this.newRecurringInvoice.tax_included) { - return this.getSubtotalWithDiscount + return this.getSubtotalWithDiscount + this.getTotalCompoundTax } return this.getSubtotalWithDiscount + this.getTotalTax }, @@ -511,6 +522,9 @@ export const useRecurringInvoiceStore = defineStore('recurring-invoice', { if (!isEdit && companySettings) { this.newRecurringInvoice.tax_per_item = companySettings.tax_per_item ?? null + this.newRecurringInvoice.tax_included = + companySettings.tax_included === 'YES' && + companySettings.tax_included_by_default === 'YES' this.newRecurringInvoice.discount_per_item = companySettings.discount_per_item ?? null this.newRecurringInvoice.sales_tax_type = diff --git a/resources/scripts/features/shared/document-form/DocumentItemRow.vue b/resources/scripts/features/shared/document-form/DocumentItemRow.vue index 36740d15..85d8597c 100644 --- a/resources/scripts/features/shared/document-form/DocumentItemRow.vue +++ b/resources/scripts/features/shared/document-form/DocumentItemRow.vue @@ -181,6 +181,7 @@ :store-prop="storeProp" :discount="discount" @update="updateTax" + @tax-type-created="onTaxTypeCreated" /> @@ -225,6 +226,7 @@ interface Emits { (e: 'update', data: Record): void (e: 'remove', index: number): void (e: 'itemValidate', valid: boolean): void + (e: 'taxTypeCreated', taxType: TaxType): void } const props = withDefaults(defineProps(), { @@ -391,6 +393,10 @@ function updateTax(data: { index: number; item: DocumentTax }): void { syncItemToStore() } +function onTaxTypeCreated(taxType: TaxType): void { + emit('taxTypeCreated', taxType) +} + function setDiscount(): void { const newValue = formData.value.items[props.index].discount const absoluteSubtotal = Math.abs(subtotal.value) diff --git a/resources/scripts/features/shared/document-form/DocumentItemRowTax.vue b/resources/scripts/features/shared/document-form/DocumentItemRowTax.vue index 82c6eece..99d5ffdb 100644 --- a/resources/scripts/features/shared/document-form/DocumentItemRowTax.vue +++ b/resources/scripts/features/shared/document-form/DocumentItemRowTax.vue @@ -103,6 +103,7 @@ interface Props { interface Emits { (e: 'remove', index: number): void (e: 'update', payload: { index: number; item: DocumentTax }): void + (e: 'taxTypeCreated', taxType: TaxType): void } const props = withDefaults(defineProps(), { @@ -246,9 +247,28 @@ function openTaxModal(): void { transaction_type: 'sales', }, size: 'sm', + refreshData: (...args: unknown[]) => { + const taxType = args[0] + if (isTaxType(taxType)) { + selectedTax.value = taxType + onSelectTax(taxType) + emit('taxTypeCreated', taxType) + } + }, }) } +function isTaxType(value: unknown): value is TaxType { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + typeof value.id === 'number' && + 'name' in value && + typeof value.name === 'string' + ) +} + function removeTax(index: number): void { const store = props.store as Record> const formData = store[props.storeProp] as DocumentFormData diff --git a/resources/scripts/features/shared/document-form/DocumentItemsTable.vue b/resources/scripts/features/shared/document-form/DocumentItemsTable.vue index 2963d3b6..d6e8c551 100644 --- a/resources/scripts/features/shared/document-form/DocumentItemsTable.vue +++ b/resources/scripts/features/shared/document-form/DocumentItemsTable.vue @@ -3,6 +3,7 @@ +
@@ -116,6 +118,7 @@ import { computed, onMounted, ref } from 'vue' import draggable from 'vuedraggable' import DocumentItemRow from './DocumentItemRow.vue' import ItemModal from '@/scripts/features/company/items/components/ItemModal.vue' +import TaxTypeModal from '@/scripts/features/company/settings/components/TaxTypeModal.vue' import { useUserStore } from '../../../stores/user.store' import { taxTypeService } from '../../../api/services/tax-type.service' import { ABILITIES } from '../../../config/abilities' @@ -159,6 +162,17 @@ onMounted(async () => { } }) +function upsertAvailableTaxType(taxType: TaxType): void { + const index = availableTaxTypes.value.findIndex(({ id }) => id === taxType.id) + + if (index === -1) { + availableTaxTypes.value.push(taxType) + return + } + + availableTaxTypes.value.splice(index, 1, taxType) +} + const formData = computed(() => { return props.store[props.storeProp] as DocumentFormData }) diff --git a/resources/scripts/features/shared/document-form/DocumentTotals.vue b/resources/scripts/features/shared/document-form/DocumentTotals.vue index 9ffa5604..c6590e0c 100644 --- a/resources/scripts/features/shared/document-form/DocumentTotals.vue +++ b/resources/scripts/features/shared/document-form/DocumentTotals.vue @@ -442,6 +442,8 @@ function selectPercentage(): void { } function onSelectTax(selectedTax: TaxType): void { + upsertAvailableTaxType(selectedTax) + const amount = calcTaxAmount( props.store.getSubtotalWithDiscount, selectedTax.percent, @@ -472,6 +474,17 @@ function onSelectTax(selectedTax: TaxType): void { recalculateGlobalTaxes() } +function upsertAvailableTaxType(taxType: TaxType): void { + const index = availableTaxTypes.value.findIndex(({ id }) => id === taxType.id) + + if (index === -1) { + availableTaxTypes.value.push(taxType) + return + } + + availableTaxTypes.value.splice(index, 1, taxType) +} + function updateTax(data: DocumentTax): void { const tax = formData.value.taxes.find((t: DocumentTax) => t.id === data.id) if (tax) { diff --git a/resources/scripts/features/shared/document-form/use-document-calculations.ts b/resources/scripts/features/shared/document-form/use-document-calculations.ts index ad297eeb..cceedf30 100644 --- a/resources/scripts/features/shared/document-form/use-document-calculations.ts +++ b/resources/scripts/features/shared/document-form/use-document-calculations.ts @@ -86,6 +86,14 @@ export function useDocumentCalculations(options: UseDocumentCalculationsOptions) }) const totalSimpleTax = computed(() => { + if (taxPerItem.value === 'YES') { + return items.value.reduce((sum: number, item: DocumentItem) => { + return sum + (item.taxes ?? []).reduce((itemSum, tax) => { + return tax.compound_tax ? itemSum : itemSum + (tax.amount ?? 0) + }, 0) + }, 0) + } + return taxes.value.reduce((sum: number, tax: DocumentTax) => { if (!tax.compound_tax) { return sum + (tax.amount ?? 0) @@ -95,6 +103,14 @@ export function useDocumentCalculations(options: UseDocumentCalculationsOptions) }) const totalCompoundTax = computed(() => { + if (taxPerItem.value === 'YES') { + return items.value.reduce((sum: number, item: DocumentItem) => { + return sum + (item.taxes ?? []).reduce((itemSum, tax) => { + return tax.compound_tax ? itemSum + (tax.amount ?? 0) : itemSum + }, 0) + }, 0) + } + return taxes.value.reduce((sum: number, tax: DocumentTax) => { if (tax.compound_tax) { return sum + (tax.amount ?? 0) @@ -104,12 +120,7 @@ export function useDocumentCalculations(options: UseDocumentCalculationsOptions) }) const totalTax = computed(() => { - if (taxPerItem.value === 'NO' || taxPerItem.value === null) { - return totalSimpleTax.value + totalCompoundTax.value - } - return items.value.reduce((sum: number, item: DocumentItem) => { - return sum + (item.tax ?? 0) - }, 0) + return totalSimpleTax.value + totalCompoundTax.value }) const subtotalWithDiscount = computed(() => { @@ -117,12 +128,16 @@ export function useDocumentCalculations(options: UseDocumentCalculationsOptions) }) const netTotal = computed(() => { - return subtotalWithDiscount.value - totalTax.value + if (taxIncluded.value) { + return subtotalWithDiscount.value - totalSimpleTax.value + } + + return subtotalWithDiscount.value }) const total = computed(() => { if (taxIncluded.value) { - return subtotalWithDiscount.value + return subtotalWithDiscount.value + totalCompoundTax.value } return subtotalWithDiscount.value + totalTax.value }) @@ -164,8 +179,8 @@ export function calcItemTotal(subtotal: number, discountVal: number): number { /** * Calculate tax amount for a given total and tax config. * - * A compound tax is charged on the base plus every simple (non-compound) tax - * already applied, and is never backed out of a tax-inclusive total. + * A compound tax is charged on top of an inclusive amount, or on the base plus + * every simple (non-compound) tax when the amount is tax-exclusive. * * @param total Base amount in cents (document subtotal after discount, or an item total after discount) * @param percent Percentage rate, when the tax is percentage based @@ -189,6 +204,10 @@ export function calcTaxAmount( } if (!total || !percent) return 0 if (compoundTax) { + if (taxIncluded) { + return Math.round((total * percent) / 100) + } + return Math.round(((total + simpleTaxTotal) * percent) / 100) } if (taxIncluded) { diff --git a/tests/Feature/Admin/EstimateTest.php b/tests/Feature/Admin/EstimateTest.php index 3092da2a..57716146 100644 --- a/tests/Feature/Admin/EstimateTest.php +++ b/tests/Feature/Admin/EstimateTest.php @@ -1,6 +1,7 @@ companies()->first()->id; + CompanySetting::setSettings(['tax_per_item' => 'YES'], $companyId); + + $simpleTax = Tax::factory()->raw([ + 'name' => 'VAT 19%', + 'percent' => 19, + 'amount' => 1900, + 'compound_tax' => false, + ]); + $compoundTax = Tax::factory()->raw([ + 'name' => 'Cash levy 1%', + 'percent' => 1, + 'amount' => 119, + 'compound_tax' => true, + ]); + $placeholderTax = [ + 'tax_type_id' => 0, + 'name' => '', + 'amount' => 0, + 'percent' => null, + 'calculation_type' => null, + 'fixed_amount' => 0, + 'compound_tax' => false, + ]; + $item = EstimateItem::factory()->raw([ + 'price' => 10000, + 'quantity' => 1, + 'discount' => 0, + 'discount_val' => 0, + 'tax' => 2019, + 'taxes' => [$simpleTax, $compoundTax, $placeholderTax], + ]); + $estimate = Estimate::factory()->raw([ + 'items' => [$item], + 'taxes' => [], + 'discount' => 0, + 'discount_val' => 0, + 'tax_included' => false, + 'sub_total' => 1, + 'tax' => 1, + 'total' => 1, + ]); + + postJson('api/v1/estimates', $estimate)->assertCreated(); + + $savedEstimate = Estimate::query() + ->where('estimate_number', $estimate['estimate_number']) + ->firstOrFail(); + $savedItem = $savedEstimate->items()->firstOrFail(); + + expect($savedEstimate->sub_total)->toBe(10000) + ->and($savedEstimate->tax)->toBe(2019) + ->and($savedEstimate->total)->toBe(12019) + ->and($savedItem->taxes()->count())->toBe(2); + + $this->assertDatabaseHas('taxes', [ + 'estimate_item_id' => $savedItem->id, + 'tax_type_id' => $simpleTax['tax_type_id'], + 'amount' => 1900, + 'compound_tax' => 0, + ]); + $this->assertDatabaseHas('taxes', [ + 'estimate_item_id' => $savedItem->id, + 'tax_type_id' => $compoundTax['tax_type_id'], + 'amount' => 119, + 'compound_tax' => 1, + ]); +}); + +test('rejects a nonzero per-item placeholder tax row', function () { + $estimate = Estimate::factory()->raw([ + 'estimate_number' => 'EST-PLACEHOLDER', + 'items' => [ + EstimateItem::factory()->raw([ + 'taxes' => [[ + 'amount' => 1, + ]], + ]), + ], + ]); + + postJson('api/v1/estimates', $estimate) + ->assertUnprocessable() + ->assertJsonValidationErrors('items.0.taxes.0.amount'); +}); + test('create estimate with EUR currency', function () { $estimate = Estimate::factory() ->raw([ diff --git a/tests/Feature/Admin/InvoiceTest.php b/tests/Feature/Admin/InvoiceTest.php index c5af29a3..07cbda80 100644 --- a/tests/Feature/Admin/InvoiceTest.php +++ b/tests/Feature/Admin/InvoiceTest.php @@ -122,6 +122,65 @@ test('server recomputes invoice totals and ignores client-supplied amounts', fun ]); }); +test('persists an inclusive compound tax on top of the entered gross total', function () { + $simpleTax = Tax::factory()->raw([ + 'name' => 'VAT 19%', + 'percent' => 19, + 'amount' => 1900, + 'compound_tax' => false, + ]); + $compoundTax = Tax::factory()->raw([ + 'name' => 'Cash levy 1%', + 'percent' => 1, + 'amount' => 119, + 'compound_tax' => true, + ]); + $item = InvoiceItem::factory()->raw([ + 'price' => 11900, + 'quantity' => 1, + 'discount' => 0, + 'discount_val' => 0, + 'tax' => 0, + 'taxes' => [], + ]); + $invoice = Invoice::factory()->raw([ + 'items' => [$item], + 'taxes' => [$simpleTax, $compoundTax], + 'discount' => 0, + 'discount_val' => 0, + 'tax_included' => true, + 'sub_total' => 1, + 'tax' => 1, + 'total' => 1, + 'due_amount' => 1, + ]); + + postJson('api/v1/invoices', $invoice)->assertOk(); + + $savedInvoice = Invoice::query() + ->where('invoice_number', $invoice['invoice_number']) + ->firstOrFail(); + + expect($savedInvoice->sub_total)->toBe(11900) + ->and($savedInvoice->tax)->toBe(2019) + ->and($savedInvoice->total)->toBe(12019) + ->and($savedInvoice->due_amount)->toBe(12019) + ->and((bool) $savedInvoice->tax_included)->toBeTrue(); + + $this->assertDatabaseHas('taxes', [ + 'invoice_id' => $savedInvoice->id, + 'tax_type_id' => $simpleTax['tax_type_id'], + 'amount' => 1900, + 'compound_tax' => 0, + ]); + $this->assertDatabaseHas('taxes', [ + 'invoice_id' => $savedInvoice->id, + 'tax_type_id' => $compoundTax['tax_type_id'], + 'amount' => 119, + 'compound_tax' => 1, + ]); +}); + test('create invoice with negative and zero item quantities', function () { $invoice = Invoice::factory()->raw([ 'items' => [ @@ -573,6 +632,23 @@ test('create invoice with tax per item ignores empty placeholder tax row', funct ]); }); +test('rejects a nonzero per-item placeholder tax row', function () { + $invoice = Invoice::factory()->raw([ + 'items' => [ + InvoiceItem::factory()->raw([ + 'taxes' => [[ + 'tax_type_id' => 0, + 'amount' => 1, + ]], + ]), + ], + ]); + + postJson('api/v1/invoices', $invoice) + ->assertUnprocessable() + ->assertJsonValidationErrors('items.0.taxes.0.amount'); +}); + test('create invoice with EUR currency', function () { $invoice = Invoice::factory() ->raw([ diff --git a/tests/Feature/Admin/RecurringInvoiceTest.php b/tests/Feature/Admin/RecurringInvoiceTest.php index d3827b06..37a2cfb2 100644 --- a/tests/Feature/Admin/RecurringInvoiceTest.php +++ b/tests/Feature/Admin/RecurringInvoiceTest.php @@ -1,6 +1,7 @@ assertDatabaseHas('recurring_invoices', $recurringInvoice); }); +test('rejects a nonzero per-item placeholder tax row', function () { + $recurringInvoice = RecurringInvoice::factory()->raw([ + 'items' => [ + InvoiceItem::factory()->raw([ + 'taxes' => [[ + 'tax_type_id' => 0, + 'amount' => 1, + ]], + ]), + ], + ]); + + postJson('api/v1/recurring-invoices', $recurringInvoice) + ->assertUnprocessable() + ->assertJsonValidationErrors('items.0.taxes.0.amount'); +}); + +test('allows a zero-valued per-item placeholder tax row', function () { + $recurringInvoice = RecurringInvoice::factory()->raw([ + 'items' => [ + InvoiceItem::factory()->raw([ + 'taxes' => [[ + 'tax_type_id' => 0, + 'amount' => 0, + ]], + ]), + ], + ]); + + postJson('api/v1/recurring-invoices', $recurringInvoice) + ->assertCreated(); +}); + +test('generated invoices retain the recurring template tax-included semantics', function () { + $recurringInvoice = RecurringInvoice::factory()->create([ + 'starts_at' => Carbon::yesterday(), + 'limit_by' => RecurringInvoice::NONE, + 'tax_included' => true, + 'sub_total' => 10000, + 'tax' => 2019, + 'total' => 10119, + 'due_amount' => 10119, + ]); + + app(RecurringInvoiceService::class)->generateInvoice($recurringInvoice); + + $this->assertDatabaseHas('invoices', [ + 'recurring_invoice_id' => $recurringInvoice->id, + 'tax_included' => 1, + 'tax' => 2019, + 'total' => 10119, + ]); +}); + test('get recurring invoice', function () { $recurringInvoice = RecurringInvoice::factory()->create(); diff --git a/tests/Feature/Admin/TaxTypeTest.php b/tests/Feature/Admin/TaxTypeTest.php index c5e74dc9..d6bb206b 100644 --- a/tests/Feature/Admin/TaxTypeTest.php +++ b/tests/Feature/Admin/TaxTypeTest.php @@ -210,6 +210,20 @@ test('creates a non-compound tax type when compound_tax is explicitly false', fu ]); }); +test('creates a non-compound tax type when compound_tax is omitted', function () { + $taxType = TaxType::factory()->raw(); + unset($taxType['compound_tax']); + + postJson('api/v1/tax-types', $taxType) + ->assertStatus(201) + ->assertJsonPath('data.compound_tax', false); + + $this->assertDatabaseHas('tax_types', [ + 'name' => $taxType['name'], + 'compound_tax' => 0, + ]); +}); + test('updates a tax type to explicitly disable compound tax', function () { $taxType = TaxType::factory()->create([ 'compound_tax' => true, @@ -258,3 +272,100 @@ test('rejects non-boolean compound_tax values', function () { ->assertUnprocessable() ->assertJsonValidationErrors('compound_tax'); }); + +test('rejects null compound_tax values', function () { + $taxType = TaxType::factory()->raw([ + 'compound_tax' => null, + ]); + + postJson('api/v1/tax-types', $taxType) + ->assertUnprocessable() + ->assertJsonValidationErrors('compound_tax'); +}); + +test('rejects null compound_tax values on update', function () { + $taxType = TaxType::factory()->create([ + 'compound_tax' => true, + ]); + + $payload = TaxType::factory()->raw([ + 'compound_tax' => null, + ]); + + putJson("api/v1/tax-types/{$taxType->id}", $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors('compound_tax'); + + $this->assertDatabaseHas('tax_types', [ + 'id' => $taxType->id, + 'compound_tax' => 1, + ]); +}); + +test('rejects compound fixed tax types', function () { + $taxType = TaxType::factory()->raw([ + 'calculation_type' => 'fixed', + 'percent' => null, + 'fixed_amount' => 500, + 'compound_tax' => true, + ]); + + postJson('api/v1/tax-types', $taxType) + ->assertUnprocessable() + ->assertJsonValidationErrors('compound_tax'); +}); + +test('rejects compound purchase tax types', function () { + $taxType = TaxType::factory()->raw([ + 'transaction_type' => TaxType::TRANSACTION_TYPE_PURCHASES, + 'compound_tax' => true, + ]); + + postJson('api/v1/tax-types', $taxType) + ->assertUnprocessable() + ->assertJsonValidationErrors('compound_tax'); +}); + +test('rejects a type change that leaves compound tax enabled', function () { + $taxType = TaxType::factory()->create([ + 'compound_tax' => true, + 'calculation_type' => 'percentage', + 'transaction_type' => TaxType::TRANSACTION_TYPE_SALES, + ]); + + $payload = TaxType::factory()->raw([ + 'calculation_type' => 'fixed', + 'percent' => null, + 'fixed_amount' => 500, + ]); + unset($payload['compound_tax']); + + putJson("api/v1/tax-types/{$taxType->id}", $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors('compound_tax'); +}); + +test('allows clearing compound tax while changing its type', function () { + $taxType = TaxType::factory()->create([ + 'compound_tax' => true, + 'calculation_type' => 'percentage', + 'transaction_type' => TaxType::TRANSACTION_TYPE_SALES, + ]); + + $payload = TaxType::factory()->raw([ + 'calculation_type' => 'fixed', + 'percent' => null, + 'fixed_amount' => 500, + 'compound_tax' => false, + ]); + + putJson("api/v1/tax-types/{$taxType->id}", $payload) + ->assertOk() + ->assertJsonPath('data.compound_tax', false); + + $this->assertDatabaseHas('tax_types', [ + 'id' => $taxType->id, + 'calculation_type' => 'fixed', + 'compound_tax' => 0, + ]); +}); diff --git a/tests/Unit/DocumentTotalsTest.php b/tests/Unit/DocumentTotalsTest.php index f105266c..3ebe8f7f 100644 --- a/tests/Unit/DocumentTotalsTest.php +++ b/tests/Unit/DocumentTotalsTest.php @@ -28,8 +28,8 @@ test('applies document discount and document-level tax', function () { test('uses per-item taxes (not document taxes) when tax_per_item is YES', function () { $totals = DocumentTotals::compute( [ - ['price' => 1000, 'quantity' => 1, 'taxes' => [['amount' => 100]]], - ['price' => 2000, 'quantity' => 1, 'taxes' => [['amount' => 200]]], + ['price' => 1000, 'quantity' => 1, 'taxes' => [['tax_type_id' => 1, 'amount' => 100]]], + ['price' => 2000, 'quantity' => 1, 'taxes' => [['tax_type_id' => 2, 'amount' => 200]]], ], [['amount' => 9999]], 0, 'YES', false, 'YES' @@ -77,7 +77,7 @@ test('supports negative quantities', function () { expect($totals['sub_total'])->toBe(-150)->and($totals['total'])->toBe(-150); }); -test('sums a compound tax line just like any other document-level tax line', function () { +test('calculates exclusive document-level compound tax totals', function () { $totals = DocumentTotals::compute( [['price' => 10000, 'quantity' => 1]], [['amount' => 1900], ['amount' => 119, 'compound_tax' => true]], @@ -87,3 +87,49 @@ test('sums a compound tax line just like any other document-level tax line', fun expect($totals['tax'])->toBe(2019) ->and($totals['total'])->toBe(12019); }); + +test('keeps simple taxes inside an inclusive document total while adding compound taxes', function () { + $totals = DocumentTotals::compute( + [['price' => 11900, 'quantity' => 1]], + [['amount' => 1900], ['amount' => 119, 'compound_tax' => true]], + 0, 'NO', true, 'NO' + ); + + expect($totals['tax'])->toBe(2019) + ->and($totals['total'])->toBe(12019); +}); + +test('calculates exclusive per-item compound tax totals and ignores the UI placeholder', function () { + $totals = DocumentTotals::compute( + [[ + 'price' => 10000, + 'quantity' => 1, + 'taxes' => [ + ['tax_type_id' => 1, 'amount' => 1900], + ['tax_type_id' => 2, 'amount' => 119, 'compound_tax' => true], + ['tax_type_id' => 0, 'amount' => 0], + ], + ]], + [], 0, 'YES', false, 'NO' + ); + + expect($totals['tax'])->toBe(2019) + ->and($totals['total'])->toBe(12019); +}); + +test('keeps simple per-item taxes inside an inclusive total while adding compound taxes', function () { + $totals = DocumentTotals::compute( + [[ + 'price' => 11900, + 'quantity' => 1, + 'taxes' => [ + ['tax_type_id' => 1, 'amount' => 1900], + ['tax_type_id' => 2, 'amount' => 119, 'compound_tax' => true], + ], + ]], + [], 0, 'YES', true, 'NO' + ); + + expect($totals['tax'])->toBe(2019) + ->and($totals['total'])->toBe(12019); +});