2
0
mirror of https://github.com/frappe/books.git synced 2024-11-09 23:30:56 +00:00

incr: type Payment

This commit is contained in:
18alantom 2022-04-14 12:13:58 +05:30
parent b08a3b9d52
commit 1cc05d218f
8 changed files with 411 additions and 511 deletions

View File

@ -424,19 +424,22 @@ export default class DatabaseCore extends DatabaseBase {
for (const field in filters) {
const value = filters[field];
let operator = '=';
let comparisonValue = value;
let operator: string | number = '=';
let comparisonValue = value as string | number | (string | number)[];
if (Array.isArray(value)) {
operator = value[0];
comparisonValue = value[1];
operator = value[0] as string;
comparisonValue = value[1] as string | number | (string | number)[];
operator = operator.toLowerCase();
if (operator === 'includes') {
operator = 'like';
}
if (operator === 'like' && !comparisonValue.includes('%')) {
if (
operator === 'like' &&
!(comparisonValue as (string | number)[]).includes('%')
) {
comparisonValue = `%${comparisonValue}%`;
}
}
@ -452,11 +455,14 @@ export default class DatabaseCore extends DatabaseBase {
}
filtersArray.map((filter) => {
const [field, operator, comparisonValue] = filter;
const field = filter[0] as string;
const operator = filter[1];
const comparisonValue = filter[2];
if (operator === '=') {
builder.where(field as string, comparisonValue);
builder.where(field, comparisonValue);
} else {
builder.where(field as string, operator as string, comparisonValue);
builder.where(field, operator as string, comparisonValue as string);
}
});
}

View File

@ -9,7 +9,7 @@ import {
import { PartyRole } from './types';
export class Party extends Doc {
async updateOutstandingAmounts() {
async updateOutstandingAmount() {
const role = this.role as PartyRole;
switch (role) {
case 'Customer':
@ -72,7 +72,10 @@ export class Party extends Doc {
defaultAccount: (doc: Doc) => {
const role = doc.role as PartyRole;
if (role === 'Both') {
return { isGroup: false, accountType: ['Payable', 'Receivable'] };
return {
isGroup: false,
accountType: ['in', ['Payable', 'Receivable']],
};
}
return {

View File

@ -1,256 +0,0 @@
import frappe, { t } from 'frappe';
import utils from '../../../accounting/utils';
import { DEFAULT_NUMBER_SERIES } from '../../../frappe/utils/consts';
const paymentTypeMap = {
Receive: t`Receive`,
Pay: t`Pay`,
};
const paymentMethodMap = {
Cash: t`Cash`,
Cheque: t`Cheque`,
Transfer: t`Transfer`,
};
export default {
name: 'Payment',
label: t`Payment`,
isSingle: 0,
isChild: 0,
isSubmittable: 1,
keywordFields: [],
settings: 'PaymentSettings',
fields: [
{
label: t`Payment No`,
fieldname: 'name',
fieldtype: 'Data',
required: 1,
readOnly: 1,
},
{
fieldname: 'party',
label: t`Party`,
fieldtype: 'Link',
target: 'Party',
required: 1,
},
{
fieldname: 'date',
label: t`Posting Date`,
fieldtype: 'Date',
default: () => new Date().toISOString(),
},
{
fieldname: 'account',
label: t`From Account`,
fieldtype: 'Link',
target: 'Account',
required: 1,
getFilters: (query, doc) => {
if (doc.paymentType === 'Pay') {
if (doc.paymentMethod === 'Cash') {
return { accountType: 'Cash', isGroup: 0 };
} else {
return { accountType: ['in', ['Bank', 'Cash']], isGroup: 0 };
}
}
},
formula: (doc) => {
if (doc.paymentMethod === 'Cash' && doc.paymentType === 'Pay') {
return 'Cash';
}
},
},
{
fieldname: 'paymentType',
label: t`Payment Type`,
fieldtype: 'Select',
placeholder: t`Payment Type`,
options: Object.keys(paymentTypeMap),
map: paymentTypeMap,
required: 1,
},
{
fieldname: 'paymentAccount',
label: t`To Account`,
placeholder: t`To Account`,
fieldtype: 'Link',
target: 'Account',
required: 1,
getFilters: (query, doc) => {
if (doc.paymentType === 'Receive') {
if (doc.paymentMethod === 'Cash') {
return { accountType: 'Cash', isGroup: 0 };
} else {
return { accountType: ['in', ['Bank', 'Cash']], isGroup: 0 };
}
}
},
formula: (doc) => {
if (doc.paymentMethod === 'Cash' && doc.paymentType === 'Receive') {
return 'Cash';
}
},
},
{
fieldname: 'numberSeries',
label: t`Number Series`,
fieldtype: 'Link',
target: 'NumberSeries',
required: 1,
getFilters: () => {
return { referenceType: 'Payment' };
},
default: DEFAULT_NUMBER_SERIES['Payment'],
},
{
fieldname: 'paymentMethod',
label: t`Payment Method`,
placeholder: t`Payment Method`,
fieldtype: 'Select',
options: Object.keys(paymentMethodMap),
map: paymentMethodMap,
default: 'Cash',
required: 1,
},
{
fieldname: 'referenceId',
label: t`Ref. / Cheque No.`,
placeholder: t`Ref. / Cheque No.`,
fieldtype: 'Data',
required: (doc) => doc.paymentMethod !== 'Cash', // TODO: UNIQUE
hidden: (doc) => doc.paymentMethod === 'Cash',
},
{
fieldname: 'referenceDate',
label: t`Ref. Date`,
placeholder: t`Ref. Date`,
fieldtype: 'Date',
},
{
fieldname: 'clearanceDate',
label: t`Clearance Date`,
placeholder: t`Clearance Date`,
fieldtype: 'Date',
hidden: (doc) => doc.paymentMethod === 'Cash',
},
{
fieldname: 'amount',
label: t`Amount`,
fieldtype: 'Currency',
required: 1,
formula: (doc) => doc.getSum('for', 'amount', false),
validate(value, doc) {
if (value.isNegative()) {
throw new frappe.errors.ValidationError(
frappe.t`Payment amount cannot be less than zero.`
);
}
if (doc.for.length === 0) return;
const amount = doc.getSum('for', 'amount', false);
if (value.gt(amount)) {
throw new frappe.errors.ValidationError(
frappe.t`Payment amount cannot
exceed ${frappe.format(amount, 'Currency')}.`
);
} else if (value.isZero()) {
throw new frappe.errors.ValidationError(
frappe.t`Payment amount cannot
be ${frappe.format(value, 'Currency')}.`
);
}
},
},
{
fieldname: 'writeoff',
label: t`Write Off / Refund`,
fieldtype: 'Currency',
},
{
fieldname: 'for',
label: t`Payment Reference`,
fieldtype: 'Table',
childtype: 'PaymentFor',
required: 0,
},
{
fieldname: 'cancelled',
label: t`Cancelled`,
fieldtype: 'Check',
default: 0,
readOnly: 1,
},
],
quickEditFields: [
'numberSeries',
'party',
'date',
'paymentMethod',
'account',
'paymentType',
'paymentAccount',
'referenceId',
'referenceDate',
'clearanceDate',
'amount',
'writeoff',
'for',
],
layout: [
{
columns: [
{
fields: ['party', 'account'],
},
{
fields: ['date', 'paymentAccount'],
},
],
},
{
columns: [
{
fields: ['paymentMethod'],
},
{
fields: ['paymentType'],
},
{
fields: ['referenceId'],
},
],
},
{
columns: [
{
fields: ['referenceDate'],
},
{
fields: ['clearanceDate'],
},
],
},
{
columns: [
{
fields: ['for'],
},
],
},
{
columns: [
{
fields: ['amount', 'writeoff'],
},
],
},
],
actions: [utils.ledgerLink],
links: [utils.ledgerLink],
};

View File

@ -0,0 +1,389 @@
import { LedgerPosting } from 'accounting/ledgerPosting';
import frappe from 'frappe';
import { DocValue } from 'frappe/core/types';
import Doc from 'frappe/model/doc';
import {
Action,
DefaultMap,
FiltersMap,
FormulaMap,
HiddenMap,
ListViewSettings,
RequiredMap,
ValidationMap,
} from 'frappe/model/types';
import { ValidationError } from 'frappe/utils/errors';
import { getLedgerLinkAction } from 'models/helpers';
import Money from 'pesa/dist/types/src/money';
import { getIsNullOrUndef } from 'utils';
import { Party } from '../Party/Party';
import { PaymentMethod, PaymentType } from './types';
export class Payment extends Doc {
async change({ changed }: { changed: string }) {
switch (changed) {
case 'for': {
this.updateAmountOnReferenceUpdate();
await this.updateDetailsOnReferenceUpdate();
}
case 'amount': {
this.updateReferenceOnAmountUpdate();
}
}
}
async updateDetailsOnReferenceUpdate() {
const forReferences = (this.for ?? []) as Doc[];
const { referenceType, referenceName } = forReferences[0];
if (
forReferences.length !== 1 ||
this.party ||
this.paymentType ||
!referenceName ||
!referenceType
) {
return;
}
const schemaName = referenceType as string;
const doc = await frappe.doc.getDoc(schemaName, referenceName as string);
let party;
let paymentType: PaymentType;
if (schemaName === 'SalesInvoice') {
party = doc.customer;
paymentType = 'Receive';
} else {
party = doc.supplier;
paymentType = 'Pay';
}
this.party = party;
this.paymentType = paymentType;
}
updateAmountOnReferenceUpdate() {
this.amount = frappe.pesa(0);
for (const paymentReference of this.for as Doc[]) {
this.amount = (this.amount as Money).add(
paymentReference.amount as Money
);
}
}
updateReferenceOnAmountUpdate() {
const forReferences = (this.for ?? []) as Doc[];
if (forReferences.length !== 1) {
return;
}
forReferences[0].amount = this.amount;
}
async validate() {
this.validateAccounts();
this.validateReferenceAmount();
this.validateWriteOff();
}
validateAccounts() {
if (this.paymentAccount !== this.account || !this.account) {
return;
}
throw new frappe.errors.ValidationError(
`To Account and From Account can't be the same: ${this.account}`
);
}
validateReferenceAmount() {
const forReferences = (this.for ?? []) as Doc[];
if (forReferences.length === 0) {
return;
}
const referenceAmountTotal = forReferences
.map(({ amount }) => amount as Money)
.reduce((a, b) => a.add(b), frappe.pesa(0));
if (
(this.amount as Money)
.add((this.writeoff as Money) ?? 0)
.gte(referenceAmountTotal)
) {
return;
}
const writeoff = frappe.format(this.writeoff, 'Currency');
const payment = frappe.format(this.amount, 'Currency');
const refAmount = frappe.format(referenceAmountTotal, 'Currency');
if ((this.writeoff as Money).gt(0)) {
throw new frappe.errors.ValidationError(
frappe.t`Amount: ${payment} and writeoff: ${writeoff}
is less than the total amount allocated to
references: ${refAmount}.`
);
}
throw new frappe.errors.ValidationError(
frappe.t`Amount: ${payment} is less than the total
amount allocated to references: ${refAmount}.`
);
}
validateWriteOff() {
if ((this.writeoff as Money).isZero()) {
return;
}
if (!frappe.singles.AccountingSettings!.writeOffAccount) {
throw new frappe.errors.ValidationError(
frappe.t`Write Off Account not set.
Please set Write Off Account in General Settings`
);
}
}
async getPosting() {
const account = this.account as string;
const paymentAccount = this.paymentAccount as string;
const amount = this.amount as Money;
const writeoff = this.writeoff as Money;
const entries = new LedgerPosting({
reference: this,
party: this.party as string,
});
await entries.debit(paymentAccount as string, amount.sub(writeoff));
await entries.credit(account as string, amount.sub(writeoff));
if (writeoff.isZero()) {
return [entries];
}
const writeoffEntry = new LedgerPosting({
reference: this,
party: this.party as string,
});
const writeOffAccount = frappe.singles.AccountingSettings!
.writeOffAccount as string;
if (this.paymentType === 'Pay') {
await writeoffEntry.debit(account, writeoff);
await writeoffEntry.credit(writeOffAccount, writeoff);
} else {
await writeoffEntry.debit(writeOffAccount, writeoff);
await writeoffEntry.credit(account, writeoff);
}
return [entries, writeoffEntry];
}
async beforeSubmit() {
const forReferences = (this.for ?? []) as Doc[];
if (forReferences.length === 0) {
return;
}
for (const row of forReferences) {
if (
!['SalesInvoice', 'PurchaseInvoice'].includes(
row.referenceType as string
)
) {
continue;
}
const referenceDoc = await frappe.doc.getDoc(
row.referenceType as string,
row.referenceName as string
);
let outstandingAmount = referenceDoc.outstandingAmount as Money;
const baseGrandTotal = referenceDoc.baseGrandTotal as Money;
const amount = this.amount as Money;
if (getIsNullOrUndef(outstandingAmount)) {
outstandingAmount = baseGrandTotal;
}
if (amount.lte(0) || amount.gt(outstandingAmount)) {
let message = frappe.t`Payment amount: ${frappe.format(
this.amount,
'Currency'
)} should be less than Outstanding amount: ${frappe.format(
outstandingAmount,
'Currency'
)}.`;
if (amount.lte(0)) {
const amt = frappe.format(this.amount, 'Currency');
message = frappe.t`Payment amount: ${amt} should be greater than 0.`;
}
throw new frappe.errors.ValidationError(message);
} else {
// update outstanding amounts in invoice and party
const newOutstanding = outstandingAmount.sub(amount);
await referenceDoc.set('outstandingAmount', newOutstanding);
await referenceDoc.update();
const party = (await frappe.doc.getDoc(
'Party',
this.party as string
)) as Party;
await party.updateOutstandingAmount();
}
}
}
async afterSubmit() {
const entryList = await this.getPosting();
for (const entry of entryList) {
await entry.post();
}
}
async afterRevert() {
this.updateReferenceOutstandingAmount();
const entryList = await this.getPosting();
for (const entry of entryList) {
await entry.postReverse();
}
}
async updateReferenceOutstandingAmount() {
await (this.for as Doc[]).forEach(
async ({ amount, referenceType, referenceName }) => {
const refDoc = await frappe.doc.getDoc(
referenceType as string,
referenceName as string
);
refDoc.setMultiple({
outstandingAmount: (refDoc.outstandingAmount as Money).add(
amount as Money
),
});
refDoc.update();
}
);
}
defaults: DefaultMap = { date: () => new Date().toISOString() };
formulas: FormulaMap = {
account: async () => {
if (this.paymentMethod === 'Cash' && this.paymentType === 'Pay') {
return 'Cash';
}
},
paymentAccount: async () => {
if (this.paymentMethod === 'Cash' && this.paymentType === 'Receive') {
return 'Cash';
}
},
amount: async () => this.getSum('for', 'amount', false),
};
validations: ValidationMap = {
amount: async (value: DocValue) => {
if ((value as Money).isNegative()) {
throw new ValidationError(
frappe.t`Payment amount cannot be less than zero.`
);
}
if ((this.for as Doc[]).length === 0) return;
const amount = this.getSum('for', 'amount', false);
if ((value as Money).gt(amount)) {
throw new frappe.errors.ValidationError(
frappe.t`Payment amount cannot
exceed ${frappe.format(amount, 'Currency')}.`
);
} else if ((value as Money).isZero()) {
throw new frappe.errors.ValidationError(
frappe.t`Payment amount cannot
be ${frappe.format(value, 'Currency')}.`
);
}
},
};
required: RequiredMap = {
referenceId: () => this.paymentMethod !== 'Cash',
clearanceDate: () => this.paymentMethod === 'Cash',
};
hidden: HiddenMap = {
referenceId: () => this.paymentMethod !== 'Cash',
};
static filters: FiltersMap = {
numberSeries: () => {
return { referenceType: 'Payment' };
},
account: (doc: Doc) => {
const paymentType = doc.paymentType as PaymentType;
const paymentMethod = doc.paymentMethod as PaymentMethod;
if (paymentType === 'Receive') {
return { accountType: 'Receivable', isGroup: false };
}
if (paymentMethod === 'Cash') {
return { accountType: 'Cash', isGroup: false };
} else {
return { accountType: ['in', ['Bank', 'Cash']], isGroup: false };
}
},
paymentAccount: (doc: Doc) => {
const paymentType = doc.paymentType as PaymentType;
const paymentMethod = doc.paymentMethod as PaymentMethod;
if (paymentType === 'Pay') {
return { accountType: 'Payable', isGroup: false };
}
if (paymentMethod === 'Cash') {
return { accountType: 'Cash', isGroup: false };
} else {
return { accountType: ['in', ['Bank', 'Cash']], isGroup: false };
}
},
};
static actions: Action[] = [getLedgerLinkAction()];
static listSettings: ListViewSettings = {
columns: [
'party',
{
label: frappe.t`Status`,
fieldname: 'status',
fieldtype: 'Select',
size: 'small',
render(doc) {
let status = 'Draft';
let color = 'gray';
if (doc.submitted === 1) {
color = 'green';
status = 'Submitted';
}
if (doc.cancelled === 1) {
color = 'red';
status = 'Cancelled';
}
return {
template: `<Badge class="text-xs" color="${color}">${status}</Badge>`,
};
},
},
'paymentType',
'date',
'amount',
],
};
}

View File

@ -1,36 +0,0 @@
import Badge from '@/components/Badge';
import { t } from 'frappe';
export default {
doctype: 'Payment',
title: t`Payments`,
columns: [
'party',
{
label: t`Status`,
fieldname: 'status',
fieldtype: 'Select',
size: 'small',
render(doc) {
let status = 'Draft';
let color = 'gray';
if (doc.submitted === 1) {
color = 'green';
status = 'Submitted';
}
if (doc.cancelled === 1) {
color = 'red';
status = 'Cancelled';
}
return {
template: `<Badge class="text-xs" color="${color}">${status}</Badge>`,
components: { Badge },
};
},
},
'paymentType',
'date',
'amount',
],
};

View File

@ -1,208 +0,0 @@
import frappe from 'frappe';
import Document from 'frappe/model/document';
import LedgerPosting from '../../../accounting/ledgerPosting';
export default class PaymentServer extends Document {
async change({ changed }) {
switch (changed) {
case 'for': {
this.updateAmountOnReferenceUpdate();
await this.updateDetailsOnReferenceUpdate();
}
case 'amount': {
this.updateReferenceOnAmountUpdate();
}
}
}
async updateDetailsOnReferenceUpdate() {
const { referenceType, referenceName } = this.for[0];
if (
this.for?.length !== 1 ||
this.party ||
this.paymentType ||
!referenceName ||
!referenceType
) {
return;
}
const doctype = referenceType;
const doc = await frappe.doc.getDoc(doctype, referenceName);
let party;
let paymentType;
if (doctype === 'SalesInvoice') {
party = doc.customer;
paymentType = 'Receive';
} else if (doctype === 'PurchaseInvoice') {
party = doc.supplier;
paymentType = 'Pay';
}
this.party = party;
this.paymentType = paymentType;
}
updateAmountOnReferenceUpdate() {
this.amount = frappe.pesa(0);
for (let paymentReference of this.for) {
this.amount = this.amount.add(paymentReference.amount);
}
}
updateReferenceOnAmountUpdate() {
if (this.for?.length !== 1) return;
this.for[0].amount = this.amount;
}
async validate() {
this.validateAccounts();
this.validateReferenceAmount();
this.validateWriteOff();
}
validateAccounts() {
if (this.paymentAccount !== this.account || !this.account) return;
throw new frappe.errors.ValidationError(
`To Account and From Account can't be the same: ${this.account}`
);
}
validateReferenceAmount() {
if (!this.for?.length) return;
const referenceAmountTotal = this.for
.map(({ amount }) => amount)
.reduce((a, b) => a.add(b), frappe.pesa(0));
if (this.amount.add(this.writeoff ?? 0).gte(referenceAmountTotal)) {
return;
}
const writeoff = frappe.format(this.writeoff, 'Currency');
const payment = frappe.format(this.amount, 'Currency');
const refAmount = frappe.format(referenceAmountTotal, 'Currency');
if (this.writeoff.gt(0)) {
throw new frappe.errors.ValidationError(
frappe.t`Amount: ${payment} and writeoff: ${writeoff}
is less than the total amount allocated to
references: ${refAmount}.`
);
}
throw new frappe.errors.ValidationError(
frappe.t`Amount: ${payment} is less than the total
amount allocated to references: ${refAmount}.`
);
}
validateWriteOff() {
if (this.writeoff.isZero()) {
return;
}
if (!frappe.AccountingSettings.writeOffAccount) {
throw new frappe.errors.ValidationError(
frappe.t`Write Off Account not set.
Please set Write Off Account in General Settings`
);
}
}
async getPosting() {
let entries = new LedgerPosting({ reference: this, party: this.party });
await entries.debit(this.paymentAccount, this.amount.sub(this.writeoff));
await entries.credit(this.account, this.amount.sub(this.writeoff));
if (this.writeoff.isZero()) {
return [entries];
}
const writeoffEntry = new LedgerPosting({
reference: this,
party: this.party,
});
const { writeOffAccount } = frappe.AccountingSettings;
if (this.paymentType === 'Pay') {
await writeoffEntry.debit(this.account, this.writeoff);
await writeoffEntry.credit(writeOffAccount, this.writeoff);
} else {
await writeoffEntry.debit(writeOffAccount, this.writeoff);
await writeoffEntry.credit(this.account, this.writeoff);
}
return [entries, writeoffEntry];
}
async beforeSubmit() {
if (!this.for || !this.for.length) {
return;
}
for (let row of this.for) {
if (!['SalesInvoice', 'PurchaseInvoice'].includes(row.referenceType)) {
continue;
}
let referenceDoc = await frappe.doc.getDoc(
row.referenceType,
row.referenceName
);
let { outstandingAmount, baseGrandTotal } = referenceDoc;
if (outstandingAmount == null) {
outstandingAmount = baseGrandTotal;
}
if (this.amount.lte(0) || this.amount.gt(outstandingAmount)) {
let message = frappe.t`Payment amount: ${frappe.format(
this.amount,
'Currency'
)} should be less than Outstanding amount: ${frappe.format(
outstandingAmount,
'Currency'
)}.`;
if (this.amount.lte(0)) {
const amt = frappe.format(this.amount, 'Currency');
message = frappe.t`Payment amount: ${amt} should be greater than 0.`;
}
throw new frappe.errors.ValidationError(message);
} else {
// update outstanding amounts in invoice and party
let newOutstanding = outstandingAmount.sub(this.amount);
await referenceDoc.set('outstandingAmount', newOutstanding);
await referenceDoc.update();
let party = await frappe.doc.getDoc('Party', this.party);
await party.updateOutstandingAmount();
}
}
}
async afterSubmit() {
const entryList = await this.getPosting();
for (const entry of entryList) {
await entry.post();
}
}
async afterRevert() {
this.updateReferenceOutstandingAmount();
const entryList = await this.getPosting();
for (const entry of entryList) {
await entry.postReverse();
}
}
async updateReferenceOutstandingAmount() {
await this.for.forEach(async ({ amount, referenceType, referenceName }) => {
const refDoc = await frappe.doc.getDoc(referenceType, referenceName);
refDoc.setMultiple({
outstandingAmount: refDoc.outstandingAmount.add(amount),
});
refDoc.update();
});
}
}

View File

@ -0,0 +1,2 @@
export type PaymentType = 'Receive' | 'Pay';
export type PaymentMethod = 'Cash' | 'Cheque' | 'Transfer';

View File

@ -64,7 +64,7 @@ export interface GetAllOptions {
export type QueryFilter = Record<
string,
boolean | string | (string | number)[]
boolean | string | (string | number | (string | number)[])[]
>;
/**