Added multiple taxes for invoice rows. Client has_vat => has_tax.

Abstracted add row button to separate component.
Abstracted invoice row headers to separate component.
Remove vat related things, now replaced with custom taxes.
Invoice tax totals are calculated per tax based on invoice rows.
This commit is contained in:
HenriT
2021-04-14 15:58:55 +03:00
parent 8bfb088f30
commit 2e57464679
21 changed files with 196 additions and 82 deletions

7
package-lock.json generated
View File

@ -7378,10 +7378,9 @@
}
},
"lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
"dev": true
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.defaultsdeep": {
"version": "4.6.1",

View File

@ -16,6 +16,7 @@
"dayjs": "^1.10.3",
"es6-promise": "^4.2.6",
"localforage": "^1.9.0",
"lodash": "^4.17.21",
"vue": "^2.6.10",
"vue-autosuggest": "^2.2.0",
"vue-multiselect": "^2.1.6",

View File

@ -41,8 +41,8 @@
label="Currency" field="currency" :errors="errors" class="col-sm-4"/>
<AppInput :value="client.rate" @change="updateProp({ rate: $event })"
label="Hourly rate" field="rate" :errors="errors" class="col-sm-4"/>
<AppCheckbox :value="client.has_vat" @input="updateProp({ has_vat: $event })"
label="Apply VAT" field="has_vat" :errors="errors" class="col-sm-4"/>
<AppCheckbox :value="client.has_tax" @input="updateProp({ has_tax: $event })"
label="Apply taxes" field="has_tax" :errors="errors" class="col-sm-4"/>
<AppSelect :value="client.bank_account"
track-by="id"
label="Bank account"

View File

@ -0,0 +1,34 @@
<template>
<tr class="d-print-none">
<td :colspan="colspan">
<button class="btn btn-sm" @click="addRow">
<i class="material-icons md-18 pointer">add</i>
</button>
<AppError :errors="errors" field="rows"/>
</td>
</tr>
</template>
<script>
import { mapGetters } from 'vuex';
import AppError from '@/components/form/AppError';
export default {
props: ['invoice', 'errors'],
components: {
AppError,
},
computed: {
...mapGetters({
taxes: 'invoiceRows/taxes',
}),
colspan() {
return 5 + this.taxes.length;
},
},
methods: {
addRow() {
this.$store.dispatch('invoiceRows/addRow', this.invoice.id);
},
},
};
</script>

View File

@ -20,26 +20,11 @@
</div>
<div class="row">
<table class="table">
<thead>
<tr>
<th>Item</th>
<th>Quantity</th>
<th>Unit</th>
<th>Price</th>
<th class="text-right">Sum</th>
</tr>
</thead>
<InvoiceRowsHeader :invoice="invoice"/>
<tbody>
<InvoiceRow v-for="(row, index) in invoice.rows" :errors="errors"
:row="row" :index="index" :key="row.id"/>
<tr class="d-print-none">
<td colspan="5">
<button class="btn btn-sm" @click="addRow">
<i class="material-icons md-18 pointer">add</i>
</button>
<AppError :errors="errors" field="rows"/>
</td>
</tr>
<InvoiceAddRowBtn :invoice="invoice" :errors="errors"/>
</tbody>
<InvoiceTotals :invoice="invoice" :errors="errors" @update="updateProp"/>
</table>
@ -64,11 +49,13 @@ import InvoiceContactDetails from '@/components/invoices/InvoiceContactDetails';
import InvoiceHeader from '@/components/invoices/InvoiceHeader';
import InvoiceTotals from '@/components/invoices/InvoiceTotals';
import AppEditable from '@/components/form/AppEditable';
import AppError from '@/components/form/AppError';
import TeamLogo from '@/components/team/TeamLogo';
import InvoiceRowsHeader from '@/components/invoices/InvoiceRowsHeader';
import InvoiceAddRowBtn from '@/components/invoices/InvoiceAddRowBtn';
export default {
components: {
InvoiceAddRowBtn,
TeamLogo,
InvoiceTotals,
InvoiceHeader,
@ -76,9 +63,9 @@ export default {
InvoiceBankDetails,
InvoiceCompanyDetails,
InvoiceRow,
InvoiceRowsHeader,
InvoiceClientDetails,
AppEditable,
AppError,
},
computed: {
...mapState({
@ -106,9 +93,6 @@ export default {
invoiceId: this.invoice.id,
});
},
addRow() {
this.$store.dispatch('invoiceRows/addRow', this.invoice.id);
},
},
};
</script>

View File

@ -28,6 +28,14 @@
placeholder="Enter price"
@change="updateProp({ price: $event })"/>
</td>
<td v-for="(tax, taxIndex) in row.taxes" :title="tax.label">
<AppEditable v-if="tax.row_id"
:value="tax.value | currency"
:errors="errors"
:field="`rows.${index}.taxes.${taxIndex}.value`"
placeholder="Enter tax"
@change="updateTaxProp({ value: $event }, tax)"/>
</td>
<td class="text-right position-relative">
{{ (row.quantity * row.price) | currency }}
<button class="btn btn-sm remove-invoice-row d-print-none" @click="removeRow(row)">
@ -58,6 +66,13 @@ export default {
invoiceId: this.row.invoice_id,
});
},
updateTaxProp(props, tax) {
this.$store.dispatch('invoiceRows/updateInvoiceRowTax', {
props,
invoiceId: this.row.invoice_id,
taxId: tax.id,
});
},
async removeRow(row) {
await this.$store.dispatch('invoiceRows/removeRow', row.id);
this.updateProp();

View File

@ -0,0 +1,27 @@
<template>
<thead>
<tr>
<th>Item</th>
<th>Quantity</th>
<th>Unit</th>
<th>Price</th>
<th v-for="tax in taxes" :key="tax.id">
{{ tax.label }} %
</th>
<th class="text-right">Sum</th>
</tr>
</thead>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
taxes: 'invoiceRows/taxes',
}),
},
};
</script>

View File

@ -1,22 +1,18 @@
<template>
<tfoot>
<tr class="text-right">
<td colspan="4">Subtotal</td>
<td :colspan="colspan">Subtotal</td>
<td>{{ invoice.subTotal | currency }}</td>
</tr>
<tr class="text-right">
<td colspan="4">
VAT
(<AppEditable :value="invoice.vat_rate | currency"
suffix="%"
placeholder="Add VAT"
@change="updateProp({ vat_rate: $event })"/>)
<AppError :errors="errors" field="vat_rate"/>
<tr class="text-right" v-for="tax in invoice.taxes" :key="tax.label">
<td :colspan="colspan">
{{ tax.label }}
<!--({{ tax.rate | currency }}%)-->
</td>
<td>{{ invoice.totalVat | currency }}</td>
<td>{{ tax.total | currency }}</td>
</tr>
<tr class="text-right">
<th colspan="4">
<th :colspan="colspan">
Total
<AppEditable :value="invoice.currency"
:errors="errors"
@ -29,7 +25,7 @@
</tfoot>
</template>
<script>
import AppError from '@/components/form/AppError';
import { mapGetters } from 'vuex';
import AppEditable from '../form/AppEditable';
import { formatDate } from '../../filters/date.filter';
import { formatCurrency } from '../../filters/currency.filter';
@ -38,12 +34,19 @@ export default {
props: ['invoice', 'errors'],
components: {
AppEditable,
AppError,
},
filters: {
date: formatDate,
currency: formatCurrency,
},
computed: {
...mapGetters({
taxes: 'invoiceRows/taxes',
}),
colspan() {
return 4 + this.taxes.length;
},
},
methods: {
updateProp(props) {
this.$emit('update', props);

View File

@ -20,8 +20,8 @@
<td>{{ invoice.client ? invoice.client.company_name : '' }}</td>
<td>{{ invoice.issued_at | date('D MMM YYYY', 'YYYY-MM-DD') }}</td>
<td>
{{ invoice.total | currency }}
<small v-if="invoice.vat_rate"><br>({{ totalWithVat(invoice) | currency }})</small>
{{ invoice.subTotal | currency }}
<small v-if="invoice.taxTotal"><br>({{ invoice.total | currency }})</small>
</td>
<td class="text-right text-capitalize">
<i class="material-icons material-icons-round md-18 mr-2 text-warning"
@ -72,9 +72,6 @@ export default {
params: { id: invoice.id },
});
},
totalWithVat(invoice) {
return (invoice.vat_rate / 100 * invoice.total) + invoice.total;
},
isOverDue(invoice) {
return invoice.status === 'sent' && invoice.due_at < dayjs()
.format();

View File

@ -32,8 +32,6 @@
<b-tab title="Invoicing" class="col-12">
<div class="row">
<AppInput :value="team.vat_rate" @change="updateProp({ vat_rate: $event })" type="number"
label="VAT rate" field="vat_rate" :errors="errors" class="col-sm-4"/>
<AppInput :value="team.invoice_late_fee" @change="updateProp({ invoice_late_fee: $event })"
type="number"
label="Late fee (%)" field="invoice_late_fee" :errors="errors" class="col-sm-4"/>

View File

@ -22,7 +22,6 @@ class InvoiceService {
async updateInvoice(invoice) {
const requiredFields = {
currency: 'Currency',
vat_rate: 'Vat Rate',
late_fee: 'Late Fee',
issued_at: 'Issued At',
due_at: 'Due At',
@ -47,7 +46,6 @@ class InvoiceService {
async bookInvoice(invoice) {
const requiredFields = {
currency: 'Currency',
vat_rate: 'Vat rate',
late_fee: 'Late fee',
issued_at: 'Issued at',
due_at: 'Due at',

View File

@ -14,7 +14,6 @@ class TeamService {
website: null,
contact_email: null,
contact_phone: null,
vat_rate: null,
invoice_late_fee: null,
invoice_due_days: null,
updated_at: null,

View File

@ -56,7 +56,7 @@ export default {
},
async updateClient({ dispatch }, payload) {
if (payload.props) {
await dispatch('clientProps', payload.props);
await dispatch('clientProps', payload);
}
return ClientService.updateClient(getClientById(payload.clientId));
},

View File

@ -1,14 +1,16 @@
import InvoiceRow from '@/store/models/invoice-row';
import InvoiceRowTax from '@/store/models/invoice-row-tax';
import { flatten, uniqBy } from 'lodash';
export default {
namespaced: true,
state: {
},
mutations: {
},
state: {},
mutations: {},
actions: {
init() {},
terminate() {},
init() {
},
terminate() {
},
invoiceRowProps(store, payload) {
return InvoiceRow.update({
where: payload.id,
@ -21,16 +23,48 @@ export default {
invoiceId: payload.invoiceId,
}, { root: true });
},
async addRow(store, invoiceId) {
async addRow({ getters, rootGetters }, invoiceId) {
const row = await InvoiceRow.createNew();
const rowCount = InvoiceRow.query().where('invoice_id', invoiceId).count();
row.$update({
await row.$update({
invoice_id: invoiceId,
order: rowCount,
});
const client = rootGetters['invoices/invoice'].client;
if (client && client.has_tax) {
const taxes = getters.taxes.length > 0
? getters.taxes
: rootGetters['taxes/all'];
taxes.forEach((tax) => {
const rowTax = new InvoiceRowTax();
rowTax.label = tax.label;
rowTax.value = tax.value;
rowTax.row_id = row.id;
rowTax.$save();
});
}
},
async removeRow(store, rowId) {
await InvoiceRow.delete(rowId);
},
async updateInvoiceRowTax({ dispatch }, payload) {
await InvoiceRowTax.update({
where: payload.taxId,
data: payload.props,
});
return dispatch('invoices/updateInvoice', {
invoiceId: payload.invoiceId,
}, { root: true });
},
},
getters: {
taxes(state, getters, rootState, rootGetters) {
let taxes = rootGetters['invoices/invoice'].rows.map(row => row.taxes);
taxes = flatten(taxes);
taxes = uniqBy(taxes, 'label');
taxes = taxes.filter(tax => !!tax.label);
return taxes;
},
},
};

View File

@ -6,7 +6,7 @@ import Errors from '@/utils/errors';
function getInvoice(invoiceId) {
return Invoice.query()
.with(['client', 'client_fields', 'team_fields'])
.with(['client', 'client_fields', 'team_fields', 'rows.taxes'])
.with('rows', query => query.orderBy('order', 'asc'))
.find(invoiceId);
}
@ -76,9 +76,6 @@ export default {
client_email: 'invoice_email',
currency: 'currency',
});
if ('vat_rate' in payload.props) {
clientProps.has_vat = payload.props.vat_rate > 0;
}
const invoice = getInvoice(payload.invoiceId);
if (Object.keys(clientProps).length > 0 && invoice.client_id) {
@ -100,7 +97,6 @@ export default {
from_website: 'website',
from_email: 'contact_email',
from_phone: 'contact_phone',
vat_rate: 'vat_rate',
});
const invoice = getInvoice(payload.invoiceId);
@ -108,13 +104,6 @@ export default {
teamProps.invoice_due_days = dayjs(invoice.due_at)
.diff(invoice.issued_at, 'days');
}
if ('vat_rate' in payload.props) {
// You can only set VAT to 0 if setting it directly under settings
// This is to avoid setting general VAT to 0, if only changing per invoice
if (parseFloat(teamProps.vat_rate) === 0) {
delete teamProps.vat_rate;
}
}
if (Object.keys(teamProps).length > 0) {
dispatch('teams/updateTeam', teamProps, { root: true });
@ -173,7 +162,6 @@ export default {
client_country: client.company_country,
client_email: client.invoice_email,
currency: client.currency || rootGetters['teams/team'].currency || 'USD',
vat_rate: client.has_vat ? rootGetters['teams/team'].vat_rate : 0,
bank_name: client.bank_account ? client.bank_account.bank_name : null,
bank_account_no: client.bank_account ? client.bank_account.account_no : null,
},
@ -213,7 +201,6 @@ export default {
from_website: team.website,
from_email: team.contact_email,
from_phone: team.contact_phone,
vat_rate: team.vat_rate || 0,
currency: team.currency || 'USD',
},
});
@ -226,7 +213,7 @@ export default {
all() {
return Invoice.query()
.where('$isNew', false)
.with(['client'])
.with(['client', 'rows.taxes'])
.with('rows', query => query.orderBy('order', 'asc')) // TODO: do we need this?
.orderBy('issued_at', 'desc')
.orderBy('number', 'desc')

View File

@ -16,7 +16,7 @@ export default class Client extends Model {
company_country: this.attr(''),
company_county: this.attr(''),
company_city: this.attr(''),
has_vat: this.attr(null),
has_tax: this.attr(true),
currency: this.attr(null),
rate: this.attr(null),
invoice_email: this.attr(''),

View File

@ -0,0 +1,16 @@
import { Model } from '@vuex-orm/core';
import { uuidv4 } from '@/utils/helpers';
export default class InvoiceRowTax extends Model {
// This is the name used as module name of the Vuex Store.
static entity = 'invoice_row_taxes';
static fields() {
return {
id: this.attr(() => uuidv4()),
row_id: this.attr(null),
label: this.attr(''),
value: this.attr(''),
};
}
}

View File

@ -1,6 +1,7 @@
import { Model } from '@vuex-orm/core';
import { uuidv4 } from '@/utils/helpers';
import Invoice from '@/store/models/invoice';
import InvoiceRowTax from '@/store/models/invoice-row-tax';
export default class InvoiceRow extends Model {
// This is the name used as module name of the Vuex Store.
@ -16,6 +17,7 @@ export default class InvoiceRow extends Model {
price: this.attr(null),
unit: this.attr(''),
order: this.attr(null),
taxes: this.hasMany(InvoiceRowTax, 'row_id'),
updated_at: this.attr(''),
created_at: this.attr(''),
};

View File

@ -17,7 +17,6 @@ export default class Invoice extends Model {
issued_at: this.attr(''),
due_at: this.attr(''),
late_fee: this.attr(''),
vat_rate: this.attr(''),
currency: this.attr(''),
from_name: this.attr(''),
from_address: this.attr(''),
@ -57,16 +56,36 @@ export default class Invoice extends Model {
set subTotal(val) {}
get total() {
return this.subTotal + this.totalVat;
return this.subTotal + this.taxTotal;
}
// eslint-disable-next-line no-empty-function
set total(val) {}
get totalVat() {
return (this.vat_rate / 100) * this.subTotal;
get taxTotal() {
return Object.values(this.taxes).reduce((carr, tax) => (tax.total + carr), 0);
}
// eslint-disable-next-line no-empty-function
set totalVat(val) {}
set taxTotal(val) {}
get taxes() {
const taxes = {};
this.rows.forEach(row => row.taxes.forEach((tax) => {
if (!taxes.hasOwnProperty(tax.label)) {
taxes[tax.label] = {
total: 0,
label: tax.label,
rate: tax.value,
};
}
taxes[tax.label].total += (row.quantity * row.price) * tax.value / 100;
}));
return taxes;
}
// eslint-disable-next-line no-empty-function
set taxes(val) {}
}

View File

@ -18,7 +18,6 @@ export default class Team extends Model {
website: this.attr(''),
contact_email: this.attr(''),
contact_phone: this.attr(''),
vat_rate: this.attr(null),
currency: this.attr(null),
invoice_late_fee: this.attr(null),
invoice_due_days: this.attr(null),

View File

@ -24,6 +24,7 @@ import TeamField from '@/store/models/team-field';
import InvoiceClientField from '@/store/models/invoice-client-field';
import InvoiceTeamField from '@/store/models/invoice-team-field';
import Tax from '@/store/models/tax';
import InvoiceRowTax from '@/store/models/invoice-row-tax';
Vue.use(Vuex);
@ -39,6 +40,7 @@ database.register(Invoice);
database.register(InvoiceClientField);
database.register(InvoiceTeamField);
database.register(InvoiceRow);
database.register(InvoiceRowTax);
database.register(BankAccount);
export default new Vuex.Store({