From 91bf6e03fa8b9f17b2c0da5d066b599bf0ee0a58 Mon Sep 17 00:00:00 2001
From: 18alantom <2.alan.tom@gmail.com>
Date: Thu, 14 Apr 2022 13:31:33 +0530
Subject: [PATCH] incr: type Transaction and Tax
---
.../{exchangeRate.js => exchangeRate.ts} | 33 +++-
.../PurchaseInvoice.ts} | 0
models/baseModels/Tax/Tax.js | 25 ---
models/baseModels/Tax/Tax.ts | 6 +
models/baseModels/Tax/TaxList.js | 7 -
models/baseModels/Transaction/Transaction.js | 78 ----------
models/baseModels/Transaction/Transaction.ts | 144 ++++++++++++++++++
.../Transaction/TransactionDocument.js | 62 --------
.../Transaction/TransactionServer.js | 62 --------
models/helpers.ts | 96 ++++++++++++
models/types.ts | 2 +
.../RegionalEntries.js => src/regional/in.ts | 12 +-
src/utils.ts | 99 ++++++++++++
13 files changed, 381 insertions(+), 245 deletions(-)
rename accounting/{exchangeRate.js => exchangeRate.ts} (55%)
rename models/baseModels/{Tax/TaxServer.js => PurchaseInvoice/PurchaseInvoice.ts} (100%)
delete mode 100644 models/baseModels/Tax/Tax.js
create mode 100644 models/baseModels/Tax/Tax.ts
delete mode 100644 models/baseModels/Tax/TaxList.js
delete mode 100644 models/baseModels/Transaction/Transaction.js
create mode 100644 models/baseModels/Transaction/Transaction.ts
delete mode 100644 models/baseModels/Transaction/TransactionDocument.js
delete mode 100644 models/baseModels/Transaction/TransactionServer.js
rename models/baseModels/Tax/RegionalEntries.js => src/regional/in.ts (69%)
create mode 100644 src/utils.ts
diff --git a/accounting/exchangeRate.js b/accounting/exchangeRate.ts
similarity index 55%
rename from accounting/exchangeRate.js
rename to accounting/exchangeRate.ts
index 8555fd0b..bdbdf78a 100644
--- a/accounting/exchangeRate.js
+++ b/accounting/exchangeRate.ts
@@ -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;
}
diff --git a/models/baseModels/Tax/TaxServer.js b/models/baseModels/PurchaseInvoice/PurchaseInvoice.ts
similarity index 100%
rename from models/baseModels/Tax/TaxServer.js
rename to models/baseModels/PurchaseInvoice/PurchaseInvoice.ts
diff --git a/models/baseModels/Tax/Tax.js b/models/baseModels/Tax/Tax.js
deleted file mode 100644
index dbef0392..00000000
--- a/models/baseModels/Tax/Tax.js
+++ /dev/null
@@ -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'],
-};
diff --git a/models/baseModels/Tax/Tax.ts b/models/baseModels/Tax/Tax.ts
new file mode 100644
index 00000000..8c148252
--- /dev/null
+++ b/models/baseModels/Tax/Tax.ts
@@ -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'] };
+}
diff --git a/models/baseModels/Tax/TaxList.js b/models/baseModels/Tax/TaxList.js
deleted file mode 100644
index ac75b530..00000000
--- a/models/baseModels/Tax/TaxList.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { t } from 'frappe';
-
-export default {
- doctype: 'Tax',
- title: t`Taxes`,
- columns: ['name'],
-};
diff --git a/models/baseModels/Transaction/Transaction.js b/models/baseModels/Transaction/Transaction.js
deleted file mode 100644
index 6e6c302f..00000000
--- a/models/baseModels/Transaction/Transaction.js
+++ /dev/null
@@ -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: `${label}`,
- 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,
-};
diff --git a/models/baseModels/Transaction/Transaction.ts b/models/baseModels/Transaction/Transaction.ts
new file mode 100644
index 00000000..e825921f
--- /dev/null
+++ b/models/baseModels/Transaction/Transaction.ts
@@ -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 = {};
+
+ 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);
+ }
+}
diff --git a/models/baseModels/Transaction/TransactionDocument.js b/models/baseModels/Transaction/TransactionDocument.js
deleted file mode 100644
index 6ca2dc4a..00000000
--- a/models/baseModels/Transaction/TransactionDocument.js
+++ /dev/null
@@ -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);
- }
-}
diff --git a/models/baseModels/Transaction/TransactionServer.js b/models/baseModels/Transaction/TransactionServer.js
deleted file mode 100644
index fe995bcc..00000000
--- a/models/baseModels/Transaction/TransactionServer.js
+++ /dev/null
@@ -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();
- },
-};
diff --git a/models/helpers.ts b/models/helpers.ts
index 3ecae1dc..82c77315 100644
--- a/models/helpers.ts
+++ b/models/helpers.ts
@@ -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: `${label}`,
+ };
+ },
+ };
+}
+
+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;
+}
diff --git a/models/types.ts b/models/types.ts
index 296d171f..2e4fcf14 100644
--- a/models/types.ts
+++ b/models/types.ts
@@ -50,3 +50,5 @@ export enum DoctypeName {
PrintSettings = 'PrintSettings',
GetStarted = 'GetStarted',
}
+
+export type InvoiceStatus = 'Draft' | 'Unpaid' | 'Cancelled' | 'Paid';
diff --git a/models/baseModels/Tax/RegionalEntries.js b/src/regional/in.ts
similarity index 69%
rename from models/baseModels/Tax/RegionalEntries.js
rename to src/regional/in.ts
index 7b33cb8d..860839ff 100644
--- a/models/baseModels/Tax/RegionalEntries.js
+++ b/src/regional/in.ts
@@ -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 [
{
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 00000000..dc3cdce8
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,99 @@
+import Doc from 'frappe/model/doc';
+
+export interface QuickEditOptions {
+ schemaName: string;
+ name: string;
+ hideFields?: string[];
+ showFields?: string[];
+ defaults?: Record;
+}
+
+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,
+ 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;
+ });
+}