fix(taxes): correct compound tax document flows (#754)

This commit is contained in:
Darko Gjorgjijoski
2026-08-14 00:42:01 +02:00
committed by GitHub
parent 13aa087faf
commit 93dd7df48a
22 changed files with 701 additions and 57 deletions
@@ -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);
@@ -0,0 +1,44 @@
<?php
namespace App\Domains\Sales\Http\Requests\Concerns;
use Illuminate\Validation\Validator;
trait ValidatesDocumentTaxPlaceholders
{
protected function validateDocumentTaxPlaceholders(Validator $validator): void
{
$validator->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.'
);
}
}
}
});
}
}
@@ -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'));
@@ -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'));
@@ -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'));
@@ -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);
}
}
+30 -7
View File
@@ -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);
}
}
@@ -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
@@ -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()
@@ -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
@@ -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"
/>
@@ -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 =
@@ -181,6 +181,7 @@
:store-prop="storeProp"
:discount="discount"
@update="updateTax"
@tax-type-created="onTaxTypeCreated"
/>
</td>
</tr>
@@ -225,6 +226,7 @@ interface Emits {
(e: 'update', data: Record<string, unknown>): void
(e: 'remove', index: number): void
(e: 'itemValidate', valid: boolean): void
(e: 'taxTypeCreated', taxType: TaxType): void
}
const props = withDefaults(defineProps<Props>(), {
@@ -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)
@@ -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<Props>(), {
@@ -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<string, Record<string, unknown>>
const formData = store[props.storeProp] as DocumentFormData
@@ -3,6 +3,7 @@
<!-- Single shared item-create modal for the whole table (one instance, not one
per row stacked HeadlessUI dialogs would otherwise close each other). -->
<ItemModal />
<TaxTypeModal />
<!-- Tax Included Toggle -->
<div
@@ -96,6 +97,7 @@
:can-add-tax="canAddTax"
:store="store"
:store-prop="storeProp"
@tax-type-created="upsertAvailableTaxType"
/>
</template>
</draggable>
@@ -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<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
@@ -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) {
@@ -86,6 +86,14 @@ export function useDocumentCalculations(options: UseDocumentCalculationsOptions)
})
const totalSimpleTax = computed<number>(() => {
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<number>(() => {
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<number>(() => {
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<number>(() => {
@@ -117,12 +128,16 @@ export function useDocumentCalculations(options: UseDocumentCalculationsOptions)
})
const netTotal = computed<number>(() => {
return subtotalWithDiscount.value - totalTax.value
if (taxIncluded.value) {
return subtotalWithDiscount.value - totalSimpleTax.value
}
return subtotalWithDiscount.value
})
const total = computed<number>(() => {
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) {
+88
View File
@@ -1,6 +1,7 @@
<?php
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Accounts\Models\User;
use App\Domains\Sales\Http\Controllers\Company\EstimatesController;
use App\Domains\Sales\Http\Requests\DeleteEstimatesRequest;
@@ -326,6 +327,93 @@ test('create estimate with tax per item', function () {
]);
});
test('persists exclusive per-item simple and compound tax totals', function () {
$companyId = User::find(1)->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([
+76
View File
@@ -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([
@@ -1,6 +1,7 @@
<?php
use App\Domains\Accounts\Models\User;
use App\Domains\Sales\Application\RecurringInvoiceService;
use App\Domains\Sales\Http\Controllers\Company\RecurringInvoiceController;
use App\Domains\Sales\Http\Requests\RecurringInvoiceRequest;
use App\Domains\Sales\Models\InvoiceItem;
@@ -60,6 +61,60 @@ test('store recurring invoice', function () {
$this->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();
+111
View File
@@ -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,
]);
});
+49 -3
View File
@@ -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);
});