2
0
mirror of https://github.com/frappe/books.git synced 2024-11-08 14:50:56 +00:00

incr: type Transaction and Tax

This commit is contained in:
18alantom 2022-04-14 13:31:33 +05:30
parent 1cc05d218f
commit 91bf6e03fa
13 changed files with 381 additions and 245 deletions

View File

@ -1,25 +1,45 @@
import frappe from 'frappe';
import { DateTime } from 'luxon';
export async function getExchangeRate({ fromCurrency, toCurrency, date }) {
export async function getExchangeRate({
fromCurrency,
toCurrency,
date,
}: {
fromCurrency: string;
toCurrency: string;
date?: string;
}) {
if (!date) {
date = DateTime.local().toISODate();
}
if (!fromCurrency || !toCurrency) {
throw new frappe.errors.NotFoundError(
'Please provide `fromCurrency` and `toCurrency` to get exchange rate.'
);
}
let cacheKey = `currencyExchangeRate:${date}:${fromCurrency}:${toCurrency}`;
let exchangeRate = parseFloat(localStorage.getItem(cacheKey));
const cacheKey = `currencyExchangeRate:${date}:${fromCurrency}:${toCurrency}`;
let exchangeRate = 0;
if (localStorage) {
exchangeRate = parseFloat(
localStorage.getItem(cacheKey as string) as string
);
}
if (!exchangeRate) {
try {
let res = await fetch(
const res = await fetch(
` https://api.vatcomply.com/rates?date=${date}&base=${fromCurrency}&symbols=${toCurrency}`
);
let data = await res.json();
const data = await res.json();
exchangeRate = data.rates[toCurrency];
localStorage.setItem(cacheKey, exchangeRate);
if (localStorage) {
localStorage.setItem(cacheKey, String(exchangeRate));
}
} catch (error) {
console.error(error);
throw new Error(
@ -27,5 +47,6 @@ export async function getExchangeRate({ fromCurrency, toCurrency, date }) {
);
}
}
return exchangeRate;
}

View File

@ -1,25 +0,0 @@
import { t } from 'frappe';
export default {
name: 'Tax',
label: t`Tax`,
doctype: 'DocType',
isSingle: 0,
isChild: 0,
keywordFields: ['name'],
fields: [
{
fieldname: 'name',
label: t`Name`,
fieldtype: 'Data',
required: 1,
},
{
fieldname: 'details',
label: t`Details`,
fieldtype: 'Table',
childtype: 'TaxDetail',
required: 1,
},
],
quickEditFields: ['details'],
};

View File

@ -0,0 +1,6 @@
import Doc from 'frappe/model/doc';
import { ListViewSettings } from 'frappe/model/types';
export class Tax extends Doc {
static listSettings: ListViewSettings = { columns: ['name'] };
}

View File

@ -1,7 +0,0 @@
import { t } from 'frappe';
export default {
doctype: 'Tax',
title: t`Taxes`,
columns: ['name'],
};

View File

@ -1,78 +0,0 @@
import Badge from '@/components/Badge';
import { getInvoiceStatus, openQuickEdit, routeTo } from '@/utils';
import frappe, { t } from 'frappe';
import utils from '../../../accounting/utils';
import { statusColor } from '../../../src/colors';
export function getStatusColumn() {
const statusMap = {
Unpaid: t`Unpaid`,
Paid: t`Paid`,
Draft: t`Draft`,
Cancelled: t`Cancelled`,
};
return {
label: t`Status`,
fieldname: 'status',
fieldtype: 'Select',
render(doc) {
const status = getInvoiceStatus(doc);
const color = statusColor[status];
const label = statusMap[status];
return {
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
components: { Badge },
};
},
};
}
export function getActions(doctype) {
return [
{
label: t`Make Payment`,
condition: (doc) => doc.submitted && doc.outstandingAmount > 0,
action: async function makePayment(doc) {
let payment = await frappe.getEmptyDoc('Payment');
payment.once('afterInsert', async () => {
await payment.submit();
});
let isSales = doctype === 'SalesInvoice';
let party = isSales ? doc.customer : doc.supplier;
let paymentType = isSales ? 'Receive' : 'Pay';
let hideAccountField = isSales ? 'account' : 'paymentAccount';
openQuickEdit({
doctype: 'Payment',
name: payment.name,
hideFields: ['party', 'date', hideAccountField, 'paymentType', 'for'],
defaults: {
party,
[hideAccountField]: doc.account,
date: new Date().toISOString().slice(0, 10),
paymentType,
for: [
{
referenceType: doc.doctype,
referenceName: doc.name,
amount: doc.outstandingAmount,
},
],
},
});
},
},
{
label: t`Print`,
condition: (doc) => doc.submitted,
action(doc) {
routeTo(`/print/${doc.doctype}/${doc.name}`);
},
},
utils.ledgerLink,
];
}
export default {
getStatusColumn,
getActions,
};

View File

@ -0,0 +1,144 @@
import { LedgerPosting } from 'accounting/ledgerPosting';
import frappe from 'frappe';
import Doc from 'frappe/model/doc';
import Money from 'pesa/dist/types/src/money';
import { getExchangeRate } from '../../../accounting/exchangeRate';
import { Party } from '../Party/Party';
import { Payment } from '../Payment/Payment';
import { Tax } from '../Tax/Tax';
export abstract class Transaction extends Doc {
_taxes: Record<string, Tax> = {};
abstract getPosting(): LedgerPosting;
async getPayments() {
const payments = await frappe.db.getAll('PaymentFor', {
fields: ['parent'],
filters: { referenceName: this.name as string },
orderBy: 'name',
});
if (payments.length != 0) {
return payments;
}
return [];
}
async beforeUpdate() {
const entries = await this.getPosting();
await entries.validateEntries();
}
async beforeInsert() {
const entries = await this.getPosting();
await entries.validateEntries();
}
async afterSubmit() {
// post ledger entries
const entries = await this.getPosting();
await entries.post();
// update outstanding amounts
await frappe.db.update(this.schemaName, {
name: this.name as string,
outstandingAmount: this.baseGrandTotal as Money,
});
const party = (await frappe.doc.getDoc(
'Party',
this.party as string
)) as Party;
await party.updateOutstandingAmount();
}
async afterRevert() {
const paymentRefList = await this.getPayments();
for (const paymentFor of paymentRefList) {
const paymentReference = paymentFor.parent;
const payment = (await frappe.doc.getDoc(
'Payment',
paymentReference as string
)) as Payment;
const paymentEntries = await payment.getPosting();
for (const entry of paymentEntries) {
await entry.postReverse();
}
// To set the payment status as unsubmitted.
await frappe.db.update('Payment', {
name: paymentReference,
submitted: false,
cancelled: true,
});
}
const entries = await this.getPosting();
await entries.postReverse();
}
async getExchangeRate() {
if (!this.currency) return 1.0;
const accountingSettings = await frappe.doc.getSingle('AccountingSettings');
const companyCurrency = accountingSettings.currency;
if (this.currency === companyCurrency) {
return 1.0;
}
return await getExchangeRate({
fromCurrency: this.currency as string,
toCurrency: companyCurrency as string,
});
}
async getTaxSummary() {
const taxes: Record<
string,
{ account: string; rate: number; amount: Money; baseAmount?: Money }
> = {};
for (const row of this.items as Doc[]) {
if (!row.tax) {
continue;
}
const tax = await this.getTax(row.tax as string);
for (const d of tax.details as Doc[]) {
const account = d.account as string;
const rate = d.rate as number;
taxes[account] = taxes[account] || {
account,
rate,
amount: frappe.pesa(0),
};
const amount = (row.amount as Money).mul(rate).div(100);
taxes[account].amount = taxes[account].amount.add(amount);
}
}
return Object.keys(taxes)
.map((account) => {
const tax = taxes[account];
tax.baseAmount = tax.amount.mul(this.exchangeRate as number);
return tax;
})
.filter((tax) => !tax.amount.isZero());
}
async getTax(tax: string) {
if (!this._taxes![tax]) {
this._taxes[tax] = await frappe.doc.getDoc('Tax', tax);
}
return this._taxes[tax];
}
async getGrandTotal() {
return ((this.taxes ?? []) as Doc[])
.map((doc) => doc.amount as Money)
.reduce((a, b) => a.add(b), this.netTotal as Money);
}
}

View File

@ -1,62 +0,0 @@
import frappe from 'frappe';
import Document from 'frappe/model/document';
import { getExchangeRate } from '../../../accounting/exchangeRate';
export default class TransactionDocument extends Document {
async getExchangeRate() {
if (!this.currency) return 1.0;
let accountingSettings = await frappe.getSingle('AccountingSettings');
const companyCurrency = accountingSettings.currency;
if (this.currency === companyCurrency) {
return 1.0;
}
return await getExchangeRate({
fromCurrency: this.currency,
toCurrency: companyCurrency,
});
}
async getTaxSummary() {
let taxes = {};
for (let row of this.items) {
if (!row.tax) {
continue;
}
const tax = await this.getTax(row.tax);
for (let d of tax.details) {
taxes[d.account] = taxes[d.account] || {
account: d.account,
rate: d.rate,
amount: frappe.pesa(0),
};
const amount = row.amount.mul(d.rate).div(100);
taxes[d.account].amount = taxes[d.account].amount.add(amount);
}
}
return Object.keys(taxes)
.map((account) => {
const tax = taxes[account];
tax.baseAmount = tax.amount.mul(this.exchangeRate);
return tax;
})
.filter((tax) => !tax.amount.isZero());
}
async getTax(tax) {
if (!this._taxes) this._taxes = {};
if (!this._taxes[tax])
this._taxes[tax] = await frappe.doc.getDoc('Tax', tax);
return this._taxes[tax];
}
async getGrandTotal() {
return (this.taxes || [])
.map(({ amount }) => amount)
.reduce((a, b) => a.add(b), this.netTotal);
}
}

View File

@ -1,62 +0,0 @@
import frappe from 'frappe';
export default {
async getPayments() {
let payments = await frappe.db.getAll({
doctype: 'PaymentFor',
fields: ['parent'],
filters: { referenceName: this.name },
orderBy: 'name',
});
if (payments.length != 0) {
return payments;
}
return [];
},
async beforeUpdate() {
const entries = await this.getPosting();
await entries.validateEntries();
},
async beforeInsert() {
const entries = await this.getPosting();
await entries.validateEntries();
},
async afterSubmit() {
// post ledger entries
const entries = await this.getPosting();
await entries.post();
// update outstanding amounts
await frappe.db.update(this.doctype, {
name: this.name,
outstandingAmount: this.baseGrandTotal,
});
let party = await frappe.doc.getDoc(
'Party',
this.customer || this.supplier
);
await party.updateOutstandingAmount();
},
async afterRevert() {
let paymentRefList = await this.getPayments();
for (let paymentFor of paymentRefList) {
const paymentReference = paymentFor.parent;
const payment = await frappe.doc.getDoc('Payment', paymentReference);
const paymentEntries = await payment.getPosting();
await paymentEntries.postReverse();
// To set the payment status as unsubmitted.
await frappe.db.update('Payment', {
name: paymentReference,
submitted: 0,
cancelled: 1,
});
}
const entries = await this.getPosting();
await entries.postReverse();
},
};

View File

@ -1,7 +1,10 @@
import { openQuickEdit } from '@/utils';
import frappe from 'frappe';
import Doc from 'frappe/model/doc';
import { Action } from 'frappe/model/types';
import Money from 'pesa/dist/types/src/money';
import { Router } from 'vue-router';
import { InvoiceStatus } from './types';
export function getLedgerLinkAction(): Action {
return {
@ -22,3 +25,96 @@ export function getLedgerLinkAction(): Action {
},
};
}
export function getTransactionActions(schemaName: string) {
return [
{
label: frappe.t`Make Payment`,
condition: (doc: Doc) =>
doc.submitted && (doc.outstandingAmount as Money).gt(0),
action: async function makePayment(doc: Doc) {
const payment = await frappe.doc.getEmptyDoc('Payment');
payment.once('afterInsert', async () => {
await payment.submit();
});
const isSales = schemaName === 'SalesInvoice';
const party = isSales ? doc.customer : doc.supplier;
const paymentType = isSales ? 'Receive' : 'Pay';
const hideAccountField = isSales ? 'account' : 'paymentAccount';
await openQuickEdit({
schemaName: 'Payment',
name: payment.name as string,
hideFields: ['party', 'date', hideAccountField, 'paymentType', 'for'],
defaults: {
party,
[hideAccountField]: doc.account,
date: new Date().toISOString().slice(0, 10),
paymentType,
for: [
{
referenceType: doc.doctype,
referenceName: doc.name,
amount: doc.outstandingAmount,
},
],
},
});
},
},
{
label: frappe.t`Print`,
condition: (doc: Doc) => doc.submitted,
action(doc: Doc, router: Router) {
router.push({ path: `/print/${doc.doctype}/${doc.name}` });
},
},
getLedgerLinkAction(),
];
}
export function getTransactionStatusColumn() {
const statusMap = {
Unpaid: frappe.t`Unpaid`,
Paid: frappe.t`Paid`,
Draft: frappe.t`Draft`,
Cancelled: frappe.t`Cancelled`,
};
return {
label: frappe.t`Status`,
fieldname: 'status',
fieldtype: 'Select',
render(doc: Doc) {
const status = getInvoiceStatus(doc) as InvoiceStatus;
const color = statusColor[status];
const label = statusMap[status];
return {
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
};
},
};
}
export const statusColor = {
Draft: 'gray',
Unpaid: 'orange',
Paid: 'green',
Cancelled: 'red',
};
export function getInvoiceStatus(doc: Doc) {
let status = `Unpaid`;
if (!doc.submitted) {
status = 'Draft';
}
if (doc.submitted && (doc.outstandingAmount as Money).isZero()) {
status = 'Paid';
}
if (doc.cancelled) {
status = 'Cancelled';
}
return status;
}

View File

@ -50,3 +50,5 @@ export enum DoctypeName {
PrintSettings = 'PrintSettings',
GetStarted = 'GetStarted',
}
export type InvoiceStatus = 'Draft' | 'Unpaid' | 'Cancelled' | 'Paid';

View File

@ -1,6 +1,8 @@
import frappe from 'frappe';
export default async function generateTaxes(country) {
export type TaxType = 'GST' | 'IGST' | 'Exempt-GST' | 'Exempt-IGST';
export default async function generateTaxes(country: string) {
if (country === 'India') {
const GSTs = {
GST: [28, 18, 12, 6, 5, 3, 0.25, 0],
@ -8,16 +10,16 @@ export default async function generateTaxes(country) {
'Exempt-GST': [0],
'Exempt-IGST': [0],
};
let newTax = await frappe.getEmptyDoc('Tax');
const newTax = await frappe.doc.getEmptyDoc('Tax');
for (const type of Object.keys(GSTs)) {
for (const percent of GSTs[type]) {
for (const percent of GSTs[type as TaxType]) {
const name = `${type}-${percent}`;
// Not cross checking cause hardcoded values.
await frappe.db.delete('Tax', name);
const details = getTaxDetails(type, percent);
const details = getTaxDetails(type as TaxType, percent);
await newTax.set({ name, details });
await newTax.insert();
}
@ -25,7 +27,7 @@ export default async function generateTaxes(country) {
}
}
function getTaxDetails(type, percent) {
function getTaxDetails(type: TaxType, percent: number) {
if (type === 'GST') {
return [
{

99
src/utils.ts Normal file
View File

@ -0,0 +1,99 @@
import Doc from 'frappe/model/doc';
export interface QuickEditOptions {
schemaName: string;
name: string;
hideFields?: string[];
showFields?: string[];
defaults?: Record<string, unknown>;
}
export async function openQuickEdit({
schemaName,
name,
hideFields,
showFields,
defaults = {},
}: QuickEditOptions) {
const router = (await import('./router')).default;
const currentRoute = router.currentRoute.value;
const query = currentRoute.query;
let method: 'push' | 'replace' = 'push';
if (query.edit && query.doctype === schemaName) {
// replace the current route if we are
// editing another document of the same doctype
method = 'replace';
}
if (query.name === name) return;
const forWhat = (defaults?.for ?? []) as string[];
if (forWhat[0] === 'not in') {
const purpose = forWhat[1]?.[0];
defaults = Object.assign({
for:
purpose === 'sales'
? 'purchases'
: purpose === 'purchases'
? 'sales'
: 'both',
});
}
if (forWhat[0] === 'not in' && forWhat[1] === 'sales') {
defaults = Object.assign({ for: 'purchases' });
}
router[method]({
query: {
edit: 1,
doctype: schemaName,
name,
showFields: showFields ?? getShowFields(schemaName),
hideFields,
valueJSON: stringifyCircular(defaults),
// @ts-ignore
lastRoute: currentRoute,
},
});
}
function getShowFields(schemaName: string) {
if (schemaName === 'Party') {
return ['customer'];
}
return [];
}
export function stringifyCircular(
obj: Record<string, unknown>,
ignoreCircular = false,
convertDocument = false
) {
const cacheKey: string[] = [];
const cacheValue: unknown[] = [];
return JSON.stringify(obj, (key, value) => {
if (typeof value !== 'object' || value === null) {
cacheKey.push(key);
cacheValue.push(value);
return value;
}
if (cacheValue.includes(value)) {
const circularKey = cacheKey[cacheValue.indexOf(value)] || '{self}';
return ignoreCircular ? undefined : `[Circular:${circularKey}]`;
}
cacheKey.push(key);
cacheValue.push(value);
if (convertDocument && value instanceof Doc) {
return value.getValidDict();
}
return value;
});
}