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; + }); +}