diff --git a/backend/database/bespoke.ts b/backend/database/bespoke.ts index b9b3af746..c5ab61b8a 100644 --- a/backend/database/bespoke.ts +++ b/backend/database/bespoke.ts @@ -8,8 +8,10 @@ import { import { ModelNameEnum } from '../../models/types'; import DatabaseCore from './core'; import { BespokeFunction } from './types'; +import { DateTime } from 'luxon'; import { DocItem, ReturnDocItem } from 'models/inventory/types'; import { safeParseFloat } from 'utils/index'; +import { Money } from 'pesa'; export class BespokeQueries { [key: string]: BespokeFunction; @@ -390,4 +392,59 @@ export class BespokeQueries { } return returnBalanceItems; } + + static async getPOSTransactedAmount( + db: DatabaseCore, + fromDate: Date, + toDate: Date, + lastShiftClosingDate?: Date + ): Promise | undefined> { + const sinvNamesQuery = db.knex!(ModelNameEnum.SalesInvoice) + .select('name') + .where('isPOS', true) + .andWhereBetween('date', [ + DateTime.fromJSDate(fromDate).toSQLDate(), + DateTime.fromJSDate(toDate).toSQLDate(), + ]); + + if (lastShiftClosingDate) { + sinvNamesQuery.andWhere( + 'created', + '>', + DateTime.fromJSDate(lastShiftClosingDate).toUTC().toString() + ); + } + + const sinvNames = (await sinvNamesQuery).map( + (row: { name: string }) => row.name + ); + + if (!sinvNames.length) { + return; + } + + const paymentEntryNames: string[] = ( + await db.knex!(ModelNameEnum.PaymentFor) + .select('parent') + .whereIn('referenceName', sinvNames) + ).map((doc: { parent: string }) => doc.parent); + + const groupedAmounts = (await db.knex!(ModelNameEnum.Payment) + .select('paymentMethod') + .whereIn('name', paymentEntryNames) + .groupBy('paymentMethod') + .sum({ amount: 'amount' })) as { paymentMethod: string; amount: Money }[]; + + const transactedAmounts = {} as { [paymentMethod: string]: Money }; + + if (!groupedAmounts) { + return; + } + + for (const row of groupedAmounts) { + transactedAmounts[row.paymentMethod] = row.amount; + } + + return transactedAmounts; + } } diff --git a/fyo/core/dbHandler.ts b/fyo/core/dbHandler.ts index cb75cc9ca..35847bc6c 100644 --- a/fyo/core/dbHandler.ts +++ b/fyo/core/dbHandler.ts @@ -27,6 +27,7 @@ import { RawValueMap, } from './types'; import { ReturnDocItem } from 'models/inventory/types'; +import { Money } from 'pesa'; type FieldMap = Record>; @@ -342,6 +343,19 @@ export class DatabaseHandler extends DatabaseBase { )) as Promise | undefined>; } + async getPOSTransactedAmount( + fromDate: Date, + toDate: Date, + lastShiftClosingDate?: Date + ): Promise | undefined> { + return (await this.#demux.callBespoke( + 'getPOSTransactedAmount', + fromDate, + toDate, + lastShiftClosingDate + )) as Promise | undefined>; + } + /** * Internal methods */ diff --git a/fyo/model/types.ts b/fyo/model/types.ts index 087ce1842..13f0bc406 100644 --- a/fyo/model/types.ts +++ b/fyo/model/types.ts @@ -10,6 +10,8 @@ import type { Defaults } from 'models/baseModels/Defaults/Defaults'; import type { PrintSettings } from 'models/baseModels/PrintSettings/PrintSettings'; import type { InventorySettings } from 'models/inventory/InventorySettings'; import type { Misc } from 'models/baseModels/Misc'; +import type { POSSettings } from 'models/inventory/Point of Sale/POSSettings'; +import type { POSShift } from 'models/inventory/Point of Sale/POSShift'; /** * The functions below are used for dynamic evaluation @@ -54,6 +56,8 @@ export interface SinglesMap { SystemSettings?: SystemSettings; AccountingSettings?: AccountingSettings; InventorySettings?: InventorySettings; + POSSettings?: POSSettings; + POSShift?: POSShift; PrintSettings?: PrintSettings; Defaults?: Defaults; Misc?: Misc; diff --git a/models/baseModels/Defaults/Defaults.ts b/models/baseModels/Defaults/Defaults.ts index bfee0ff6e..2515d35e8 100644 --- a/models/baseModels/Defaults/Defaults.ts +++ b/models/baseModels/Defaults/Defaults.ts @@ -1,6 +1,8 @@ +import { DefaultCashDenominations } from 'models/inventory/Point of Sale/DefaultCashDenominations'; import { Doc } from 'fyo/model/doc'; import { FiltersMap, HiddenMap } from 'fyo/model/types'; import { ModelNameEnum } from 'models/types'; +import { PartyRoleEnum } from '../Party/types'; export class Defaults extends Doc { // Auto Payments @@ -35,6 +37,10 @@ export class Defaults extends Doc { purchaseReceiptPrintTemplate?: string; stockMovementPrintTemplate?: string; + // Point of Sale + posCashDenominations?: DefaultCashDenominations[]; + posCustomer?: string; + static commonFilters = { // Auto Payments salesPaymentAccount: () => ({ isGroup: false, accountType: 'Cash' }), @@ -73,6 +79,7 @@ export class Defaults extends Doc { type: ModelNameEnum.PurchaseReceipt, }), stockMovementPrintTemplate: () => ({ type: ModelNameEnum.StockMovement }), + posCustomer: () => ({ role: PartyRoleEnum.Customer }), }; static filters: FiltersMap = this.commonFilters; @@ -82,6 +89,10 @@ export class Defaults extends Doc { return () => !this.fyo.singles.AccountingSettings?.enableInventory; } + getPointOfSaleHidden() { + return () => !this.fyo.singles.InventorySettings?.enablePointOfSale; + } + hidden: HiddenMap = { stockMovementNumberSeries: this.getInventoryHidden(), shipmentNumberSeries: this.getInventoryHidden(), @@ -91,6 +102,8 @@ export class Defaults extends Doc { shipmentPrintTemplate: this.getInventoryHidden(), purchaseReceiptPrintTemplate: this.getInventoryHidden(), stockMovementPrintTemplate: this.getInventoryHidden(), + posCashDenominations: this.getPointOfSaleHidden(), + posCustomer: this.getPointOfSaleHidden(), }; } diff --git a/models/index.ts b/models/index.ts index e2009b846..51c1c6723 100644 --- a/models/index.ts +++ b/models/index.ts @@ -33,6 +33,12 @@ import { ShipmentItem } from './inventory/ShipmentItem'; import { StockLedgerEntry } from './inventory/StockLedgerEntry'; import { StockMovement } from './inventory/StockMovement'; import { StockMovementItem } from './inventory/StockMovementItem'; +import { ClosingAmounts } from './inventory/Point of Sale/ClosingAmounts'; +import { ClosingCash } from './inventory/Point of Sale/ClosingCash'; +import { OpeningAmounts } from './inventory/Point of Sale/OpeningAmounts'; +import { OpeningCash } from './inventory/Point of Sale/OpeningCash'; +import { POSSettings } from './inventory/Point of Sale/POSSettings'; +import { POSShift } from './inventory/Point of Sale/POSShift'; export const models = { Account, @@ -70,6 +76,13 @@ export const models = { ShipmentItem, PurchaseReceipt, PurchaseReceiptItem, + // POS Models + ClosingAmounts, + ClosingCash, + OpeningAmounts, + OpeningCash, + POSSettings, + POSShift, } as ModelMap; export async function getRegionalModels( diff --git a/models/inventory/InventorySettings.ts b/models/inventory/InventorySettings.ts index 69b27da32..8cb64bce8 100644 --- a/models/inventory/InventorySettings.ts +++ b/models/inventory/InventorySettings.ts @@ -12,6 +12,7 @@ export class InventorySettings extends Doc { enableSerialNumber?: boolean; enableUomConversions?: boolean; enableStockReturns?: boolean; + enablePointOfSale?: boolean; static filters: FiltersMap = { stockInHand: () => ({ @@ -44,5 +45,8 @@ export class InventorySettings extends Doc { enableStockReturns: () => { return !!this.enableStockReturns; }, + enablePointOfSale: () => { + return !!this.fyo.singles.POSShift?.isShiftOpen; + }, }; } diff --git a/models/inventory/Point of Sale/CashDenominations.ts b/models/inventory/Point of Sale/CashDenominations.ts new file mode 100644 index 000000000..a13fcac6b --- /dev/null +++ b/models/inventory/Point of Sale/CashDenominations.ts @@ -0,0 +1,6 @@ +import { Doc } from 'fyo/model/doc'; +import { Money } from 'pesa'; + +export abstract class CashDenominations extends Doc { + denomination?: Money; +} diff --git a/models/inventory/Point of Sale/ClosingAmounts.ts b/models/inventory/Point of Sale/ClosingAmounts.ts new file mode 100644 index 000000000..e08898f2d --- /dev/null +++ b/models/inventory/Point of Sale/ClosingAmounts.ts @@ -0,0 +1,27 @@ +import { Doc } from 'fyo/model/doc'; +import { FormulaMap } from 'fyo/model/types'; +import { Money } from 'pesa'; + +export class ClosingAmounts extends Doc { + closingAmount?: Money; + differenceAmount?: Money; + expectedAmount?: Money; + openingAmount?: Money; + paymentMethod?: string; + + formulas: FormulaMap = { + differenceAmount: { + formula: () => { + if (!this.closingAmount) { + return this.fyo.pesa(0); + } + + if (!this.expectedAmount) { + return this.fyo.pesa(0); + } + + return this.closingAmount.sub(this.expectedAmount); + }, + }, + }; +} diff --git a/models/inventory/Point of Sale/ClosingCash.ts b/models/inventory/Point of Sale/ClosingCash.ts new file mode 100644 index 000000000..dd5c787f3 --- /dev/null +++ b/models/inventory/Point of Sale/ClosingCash.ts @@ -0,0 +1,5 @@ +import { CashDenominations } from './CashDenominations'; + +export class ClosingCash extends CashDenominations { + count?: number; +} diff --git a/models/inventory/Point of Sale/DefaultCashDenominations.ts b/models/inventory/Point of Sale/DefaultCashDenominations.ts new file mode 100644 index 000000000..0173527fa --- /dev/null +++ b/models/inventory/Point of Sale/DefaultCashDenominations.ts @@ -0,0 +1,3 @@ +import { CashDenominations } from './CashDenominations'; + +export class DefaultCashDenominations extends CashDenominations {} diff --git a/models/inventory/Point of Sale/OpeningAmounts.ts b/models/inventory/Point of Sale/OpeningAmounts.ts new file mode 100644 index 000000000..86a0ad723 --- /dev/null +++ b/models/inventory/Point of Sale/OpeningAmounts.ts @@ -0,0 +1,11 @@ +import { Doc } from 'fyo/model/doc'; +import { Money } from 'pesa'; + +export class OpeningAmounts extends Doc { + amount?: Money; + paymentMethod?: 'Cash' | 'Transfer'; + + get openingCashAmount() { + return this.parentdoc?.openingCashAmount as Money; + } +} diff --git a/models/inventory/Point of Sale/OpeningCash.ts b/models/inventory/Point of Sale/OpeningCash.ts new file mode 100644 index 000000000..d702c4185 --- /dev/null +++ b/models/inventory/Point of Sale/OpeningCash.ts @@ -0,0 +1,5 @@ +import { CashDenominations } from './CashDenominations'; + +export class OpeningCash extends CashDenominations { + count?: number; +} diff --git a/models/inventory/Point of Sale/POSSettings.ts b/models/inventory/Point of Sale/POSSettings.ts new file mode 100644 index 000000000..a36749b2f --- /dev/null +++ b/models/inventory/Point of Sale/POSSettings.ts @@ -0,0 +1,19 @@ +import { Doc } from 'fyo/model/doc'; +import { FiltersMap } from 'fyo/model/types'; +import { + AccountRootTypeEnum, + AccountTypeEnum, +} from 'models/baseModels/Account/types'; + +export class POSSettings extends Doc { + inventory?: string; + cashAccount?: string; + writeOffAccount?: string; + + static filters: FiltersMap = { + cashAccount: () => ({ + rootType: AccountRootTypeEnum.Asset, + accountType: AccountTypeEnum.Cash, + }), + }; +} diff --git a/models/inventory/Point of Sale/POSShift.ts b/models/inventory/Point of Sale/POSShift.ts new file mode 100644 index 000000000..b56fc8591 --- /dev/null +++ b/models/inventory/Point of Sale/POSShift.ts @@ -0,0 +1,61 @@ +import { ClosingAmounts } from './ClosingAmounts'; +import { ClosingCash } from './ClosingCash'; +import { Doc } from 'fyo/model/doc'; +import { OpeningAmounts } from './OpeningAmounts'; +import { OpeningCash } from './OpeningCash'; + +export class POSShift extends Doc { + closingAmounts?: ClosingAmounts[]; + closingCash?: ClosingCash[]; + closingDate?: Date; + isShiftOpen?: boolean; + openingAmounts?: OpeningAmounts[]; + openingCash?: OpeningCash[]; + openingDate?: Date; + + get openingCashAmount() { + if (!this.openingCash) { + return this.fyo.pesa(0); + } + + let openingAmount = this.fyo.pesa(0); + + this.openingCash.map((row: OpeningCash) => { + const denomination = row.denomination ?? this.fyo.pesa(0); + const count = row.count ?? 0; + + const amount = denomination.mul(count); + openingAmount = openingAmount.add(amount); + }); + return openingAmount; + } + + get closingCashAmount() { + if (!this.closingCash) { + return this.fyo.pesa(0); + } + + let closingAmount = this.fyo.pesa(0); + + this.closingCash.map((row: ClosingCash) => { + const denomination = row.denomination ?? this.fyo.pesa(0); + const count = row.count ?? 0; + + const amount = denomination.mul(count); + closingAmount = closingAmount.add(amount); + }); + return closingAmount; + } + + get openingTransferAmount() { + if (!this.openingAmounts) { + return this.fyo.pesa(0); + } + + const transferAmountRow = this.openingAmounts.filter( + (row) => row.paymentMethod === 'Transfer' + )[0]; + + return transferAmountRow.amount ?? this.fyo.pesa(0); + } +} diff --git a/models/inventory/Point of Sale/tests/testPointOfSale.spec.ts b/models/inventory/Point of Sale/tests/testPointOfSale.spec.ts new file mode 100644 index 000000000..36a6f3e05 --- /dev/null +++ b/models/inventory/Point of Sale/tests/testPointOfSale.spec.ts @@ -0,0 +1,103 @@ +import test from 'tape'; +import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; +import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice'; +import { Payment } from 'models/baseModels/Payment/Payment'; +import { Money } from 'pesa'; +import { ModelNameEnum } from 'models/types'; + +const fyo = getTestFyo(); + +setupTestFyo(fyo, __filename); + +const customer = { name: 'Someone', role: 'Both' }; +const itemMap = { + Pen: { + name: 'Pen', + rate: 700, + }, + Ink: { + name: 'Ink', + rate: 50, + }, +}; + +test('insert test docs', async (t) => { + await fyo.doc.getNewDoc(ModelNameEnum.Item, itemMap.Pen).sync(); + await fyo.doc.getNewDoc(ModelNameEnum.Item, itemMap.Ink).sync(); + await fyo.doc.getNewDoc(ModelNameEnum.Party, customer).sync(); +}); + +let sinvDocOne: SalesInvoice | undefined; + +test('check pos transacted amount', async (t) => { + const transactedAmountBeforeTxn = await fyo.db.getPOSTransactedAmount( + new Date('2023-01-01'), + new Date('2023-01-02') + ); + + t.equals(transactedAmountBeforeTxn, undefined); + + sinvDocOne = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + isPOS: true, + date: new Date('2023-01-01'), + account: 'Debtors', + party: customer.name, + }) as SalesInvoice; + + await sinvDocOne.append('items', { + item: itemMap.Pen.name, + rate: itemMap.Pen.rate, + quantity: 1, + }); + + await (await sinvDocOne.sync()).submit(); + const paymentDocOne = sinvDocOne.getPayment() as Payment; + + await paymentDocOne.sync(); + + const sinvDocTwo = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + isPOS: true, + date: new Date('2023-01-01'), + account: 'Debtors', + party: customer.name, + }) as SalesInvoice; + + await sinvDocTwo.append('items', { + item: itemMap.Pen.name, + rate: itemMap.Pen.rate, + quantity: 1, + }); + + await (await sinvDocTwo.sync()).submit(); + const paymentDocTwo = sinvDocTwo.getPayment() as Payment; + + await paymentDocTwo.setMultiple({ + paymentMethod: 'Transfer', + clearanceDate: new Date('2023-01-01'), + referenceId: 'xxxxxxxx', + }); + + await paymentDocTwo.sync(); + + const transactedAmountAfterTxn: Record | undefined = + await fyo.db.getPOSTransactedAmount( + new Date('2023-01-01'), + new Date('2023-01-02') + ); + + t.true(transactedAmountAfterTxn); + + t.equals( + transactedAmountAfterTxn?.Cash, + sinvDocOne.grandTotal?.float, + 'transacted cash amount matches' + ); + + t.equals( + transactedAmountAfterTxn?.Transfer, + sinvDocTwo.grandTotal?.float, + 'transacted transfer amount matches' + ); +}); + +closeTestFyo(fyo, __filename); diff --git a/models/types.ts b/models/types.ts index 8d63d40f1..b3fdf7307 100644 --- a/models/types.ts +++ b/models/types.ts @@ -45,7 +45,9 @@ export enum ModelNameEnum { PurchaseReceiptItem = 'PurchaseReceiptItem', Location = 'Location', CustomForm = 'CustomForm', - CustomField = 'CustomField' + CustomField = 'CustomField', + POSSettings = 'POSSettings', + POSShift = 'POSShift' } export type ModelName = keyof typeof ModelNameEnum; diff --git a/schemas/app/Defaults.json b/schemas/app/Defaults.json index 9b5e5c3d8..ba9201347 100644 --- a/schemas/app/Defaults.json +++ b/schemas/app/Defaults.json @@ -164,6 +164,21 @@ "fieldtype": "Link", "target": "PrintTemplate", "section": "Print Templates" + }, + { + "fieldname": "posCustomer", + "label": "POS Customer", + "fieldtype": "Link", + "target": "Party", + "create": true, + "section": "Point of Sale" + }, + { + "fieldname": "posCashDenominations", + "label": "Cash Denominations", + "fieldtype": "Table", + "target": "DefaultCashDenominations", + "section": "Point of Sale" } ] } diff --git a/schemas/app/SalesInvoice.json b/schemas/app/SalesInvoice.json index dcbfc5dec..d520d0e5c 100644 --- a/schemas/app/SalesInvoice.json +++ b/schemas/app/SalesInvoice.json @@ -61,6 +61,12 @@ "target": "SalesInvoice", "label": "Return Against", "section": "References" + }, + { + "fieldname": "isPOS", + "fieldtype": "Check", + "default": false, + "hidden": true } ], "keywordFields": ["name", "party"] diff --git a/schemas/app/inventory/InventorySettings.json b/schemas/app/inventory/InventorySettings.json index fd1c6ceb2..1cd3a479a 100644 --- a/schemas/app/inventory/InventorySettings.json +++ b/schemas/app/inventory/InventorySettings.json @@ -63,6 +63,13 @@ "fieldtype": "Check", "default": false, "section": "Features" + }, + { + "fieldname": "enablePointOfSale", + "label": "Enable Point of Sale", + "fieldtype": "Check", + "default": false, + "section": "Features" } ] } diff --git a/schemas/app/inventory/Point of Sale/CashDenominations.json b/schemas/app/inventory/Point of Sale/CashDenominations.json new file mode 100644 index 000000000..fac35407a --- /dev/null +++ b/schemas/app/inventory/Point of Sale/CashDenominations.json @@ -0,0 +1,14 @@ +{ + "name": "CashDenominations", + "label": "Cash Denominations", + "isAbstract": true, + "fields": [ + { + "fieldname": "denomination", + "fieldtype": "Currency", + "label": "Denomination", + "placeholder": "Denomination", + "required": true + } + ] +} diff --git a/schemas/app/inventory/Point of Sale/ClosingAmounts.json b/schemas/app/inventory/Point of Sale/ClosingAmounts.json new file mode 100644 index 000000000..ea786c13d --- /dev/null +++ b/schemas/app/inventory/Point of Sale/ClosingAmounts.json @@ -0,0 +1,42 @@ +{ + "name": "ClosingAmounts", + "label": "Closing Amount", + "isChild": true, + "extends": "POSShiftAmounts", + "fields": [ + { + "fieldname": "openingAmount", + "fieldtype": "Currency", + "label": "Opening Amount", + "placeholder": "Opening Amount", + "readOnly": true + }, + { + "fieldname": "closingAmount", + "fieldtype": "Currency", + "label": "Closing Amount", + "placeholder": "Closing Amount" + }, + { + "fieldname": "expectedAmount", + "fieldtype": "Currency", + "label": "Expected Amount", + "placeholder": "Expected Amount", + "readOnly": true + }, + { + "fieldname": "differenceAmount", + "fieldtype": "Currency", + "label": "Difference Amount", + "placeholder": "Difference Amount", + "readOnly": true + } + ], + "tableFields": [ + "paymentMethod", + "openingAmount", + "closingAmount", + "expectedAmount", + "differenceAmount" + ] +} diff --git a/schemas/app/inventory/Point of Sale/ClosingCash.json b/schemas/app/inventory/Point of Sale/ClosingCash.json new file mode 100644 index 000000000..c125edebf --- /dev/null +++ b/schemas/app/inventory/Point of Sale/ClosingCash.json @@ -0,0 +1,17 @@ +{ + "name": "ClosingCash", + "label": "Closing Cash In Denominations", + "isChild": true, + "extends": "CashDenominations", + "fields": [ + { + "fieldname": "count", + "label": "Count", + "placeholder": "Count", + "fieldtype": "Int", + "default": 0, + "required": true + } + ], + "tableFields": ["denomination", "count"] +} diff --git a/schemas/app/inventory/Point of Sale/DefaultCashDenominations.json b/schemas/app/inventory/Point of Sale/DefaultCashDenominations.json new file mode 100644 index 000000000..971992d9c --- /dev/null +++ b/schemas/app/inventory/Point of Sale/DefaultCashDenominations.json @@ -0,0 +1,7 @@ +{ + "name": "DefaultCashDenominations", + "label": "Default Cash Denominations", + "isChild": true, + "extends": "CashDenominations", + "tableFields": ["denomination"] +} diff --git a/schemas/app/inventory/Point of Sale/OpeningAmounts.json b/schemas/app/inventory/Point of Sale/OpeningAmounts.json new file mode 100644 index 000000000..4c764f637 --- /dev/null +++ b/schemas/app/inventory/Point of Sale/OpeningAmounts.json @@ -0,0 +1,15 @@ +{ + "name": "OpeningAmounts", + "label": "Opening Amount", + "isChild": true, + "extends": "POSShiftAmounts", + "fields": [ + { + "fieldname": "amount", + "label": "Amount", + "fieldtype": "Currency", + "section": "Defaults" + } + ], + "tableFields": ["paymentMethod", "amount"] +} diff --git a/schemas/app/inventory/Point of Sale/OpeningCash.json b/schemas/app/inventory/Point of Sale/OpeningCash.json new file mode 100644 index 000000000..3a8bac988 --- /dev/null +++ b/schemas/app/inventory/Point of Sale/OpeningCash.json @@ -0,0 +1,17 @@ +{ + "name": "OpeningCash", + "label": "Opening Cash In Denominations", + "isChild": true, + "extends": "CashDenominations", + "fields": [ + { + "fieldname": "count", + "label": "Count", + "placeholder": "Count", + "fieldtype": "Int", + "default": 0, + "required": true + } + ], + "tableFields": ["denomination", "count"] +} diff --git a/schemas/app/inventory/Point of Sale/POSSettings.json b/schemas/app/inventory/Point of Sale/POSSettings.json new file mode 100644 index 000000000..df5c1f678 --- /dev/null +++ b/schemas/app/inventory/Point of Sale/POSSettings.json @@ -0,0 +1,36 @@ +{ + "name": "POSSettings", + "label": "POS Settings", + "isSingle": true, + "isChild": false, + "fields": [ + { + "fieldname": "inventory", + "label": "Inventory", + "fieldtype": "Link", + "target": "Location", + "create": true, + "default": "Stores", + "section": "Default" + }, + { + "fieldname": "cashAccount", + "label": "Counter Cash Account", + "fieldtype": "Link", + "target": "Account", + "default": "Cash In Hand", + "required": true, + "create": true, + "section": "Default" + }, + { + "fieldname": "writeOffAccount", + "label": "Write Off Account", + "fieldtype": "Link", + "target": "Account", + "create": true, + "default": "Write Off", + "section": "Default" + } + ] +} diff --git a/schemas/app/inventory/Point of Sale/POSShift.json b/schemas/app/inventory/Point of Sale/POSShift.json new file mode 100644 index 000000000..c17b834fe --- /dev/null +++ b/schemas/app/inventory/Point of Sale/POSShift.json @@ -0,0 +1,43 @@ +{ + "name": "POSShift", + "isSingle": true, + "isChild": false, + "fields": [ + { + "fieldname": "isShiftOpen", + "label": "Is POS Shift Open", + "fieldtype": "Check", + "default": false + }, + { + "fieldname": "openingDate", + "label": "Opening Date", + "fieldtype": "Datetime" + }, + { + "fieldname": "closingDate", + "label": "Closing Date", + "fieldtype": "Datetime" + }, + { + "fieldname": "openingCash", + "fieldtype": "Table", + "target": "OpeningCash" + }, + { + "fieldname": "closingCash", + "fieldtype": "Table", + "target": "ClosingCash" + }, + { + "fieldname": "openingAmounts", + "fieldtype": "Table", + "target": "OpeningAmounts" + }, + { + "fieldname": "closingAmounts", + "fieldtype": "Table", + "target": "ClosingAmounts" + } + ] +} diff --git a/schemas/app/inventory/Point of Sale/POSShiftAmounts.json b/schemas/app/inventory/Point of Sale/POSShiftAmounts.json new file mode 100644 index 000000000..a891d448c --- /dev/null +++ b/schemas/app/inventory/Point of Sale/POSShiftAmounts.json @@ -0,0 +1,25 @@ +{ + "name": "POSShiftAmounts", + "label": "POS Shift Amount", + "isChild": true, + "isAbstract": true, + "fields": [ + { + "fieldname": "paymentMethod", + "label": "Payment Method", + "placeholder": "Payment Method", + "fieldtype": "Select", + "options": [ + { + "value": "Cash", + "label": "Cash" + }, + { + "value": "Transfer", + "label": "Transfer" + } + ], + "required": true + } + ] +} diff --git a/schemas/schemas.ts b/schemas/schemas.ts index 9c3ed9687..1e92f56c8 100644 --- a/schemas/schemas.ts +++ b/schemas/schemas.ts @@ -52,6 +52,15 @@ import base from './meta/base.json'; import child from './meta/child.json'; import submittable from './meta/submittable.json'; import tree from './meta/tree.json'; +import CashDenominations from './app/inventory/Point of Sale/CashDenominations.json'; +import ClosingAmounts from './app/inventory/Point of Sale/ClosingAmounts.json'; +import ClosingCash from './app/inventory/Point of Sale/ClosingCash.json'; +import DefaultCashDenominations from './app/inventory/Point of Sale/DefaultCashDenominations.json'; +import OpeningAmounts from './app/inventory/Point of Sale/OpeningAmounts.json'; +import OpeningCash from './app/inventory/Point of Sale/OpeningCash.json'; +import POSSettings from './app/inventory/Point of Sale/POSSettings.json'; +import POSShift from './app/inventory/Point of Sale/POSShift.json'; +import POSShiftAmounts from './app/inventory/Point of Sale/POSShiftAmounts.json'; import { Schema, SchemaStub } from './types'; export const coreSchemas: Schema[] = [ @@ -129,4 +138,14 @@ export const appSchemas: Schema[] | SchemaStub[] = [ CustomForm as Schema, CustomField as Schema, + + CashDenominations as Schema, + ClosingAmounts as Schema, + ClosingCash as Schema, + DefaultCashDenominations as Schema, + OpeningAmounts as Schema, + OpeningCash as Schema, + POSSettings as Schema, + POSShift as Schema, + POSShiftAmounts as Schema, ]; diff --git a/src/components/Icons/18/index.ts b/src/components/Icons/18/index.ts index b9b87c1f6..f9c88219f 100644 --- a/src/components/Icons/18/index.ts +++ b/src/components/Icons/18/index.ts @@ -9,6 +9,7 @@ import Inventory from './inventory.vue'; import Invoice from './invoice.vue'; import Item from './item.vue'; import Mail from './mail.vue'; +import POS from './pos.vue'; import OpeningAc from './opening-ac.vue'; import Percentage from './percentage.vue'; import Property from './property.vue'; @@ -36,6 +37,7 @@ export default { 'invoice': Invoice, 'item': Item, 'mail': Mail, + 'pos': POS, 'opening-ac': OpeningAc, 'percentage': Percentage, 'property': Property, diff --git a/src/components/Icons/18/pos.vue b/src/components/Icons/18/pos.vue new file mode 100644 index 000000000..796affdec --- /dev/null +++ b/src/components/Icons/18/pos.vue @@ -0,0 +1,15 @@ + + diff --git a/src/components/POS/FloatingLabelCurrencyInput.vue b/src/components/POS/FloatingLabelCurrencyInput.vue new file mode 100644 index 000000000..e7ec2c90c --- /dev/null +++ b/src/components/POS/FloatingLabelCurrencyInput.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/components/POS/FloatingLabelFloatInput.vue b/src/components/POS/FloatingLabelFloatInput.vue new file mode 100644 index 000000000..1daa98926 --- /dev/null +++ b/src/components/POS/FloatingLabelFloatInput.vue @@ -0,0 +1,14 @@ + diff --git a/src/components/POS/FloatingLabelInputBase.vue b/src/components/POS/FloatingLabelInputBase.vue new file mode 100644 index 000000000..47b9b0640 --- /dev/null +++ b/src/components/POS/FloatingLabelInputBase.vue @@ -0,0 +1,63 @@ + + diff --git a/src/components/POS/ItemsTable.vue b/src/components/POS/ItemsTable.vue new file mode 100644 index 000000000..94e367816 --- /dev/null +++ b/src/components/POS/ItemsTable.vue @@ -0,0 +1,166 @@ + + + diff --git a/src/components/POS/SelectedItemRow.vue b/src/components/POS/SelectedItemRow.vue new file mode 100644 index 000000000..6e8808930 --- /dev/null +++ b/src/components/POS/SelectedItemRow.vue @@ -0,0 +1,354 @@ + + + diff --git a/src/components/POS/SelectedItemTable.vue b/src/components/POS/SelectedItemTable.vue new file mode 100644 index 000000000..9c7ab31f8 --- /dev/null +++ b/src/components/POS/SelectedItemTable.vue @@ -0,0 +1,150 @@ + + + diff --git a/src/components/POS/types.ts b/src/components/POS/types.ts new file mode 100644 index 000000000..a1530e293 --- /dev/null +++ b/src/components/POS/types.ts @@ -0,0 +1,20 @@ +import { Money } from "pesa"; + +export type ItemQtyMap = { + [item: string]: { availableQty: number;[batch: string]: number }; +} + +export type ItemSerialNumbers = { [item: string]: string }; + +export type DiscountType = "percent" | "amount"; + +export type ModalName = 'ShiftOpen' | 'ShiftClose' | 'Payment' + +export interface POSItem { + name: string, + rate: Money, + availableQty: number, + unit: string, + hasBatch: boolean, + hasSerialNumber: boolean, +} \ No newline at end of file diff --git a/src/pages/POS/ClosePOSShiftModal.vue b/src/pages/POS/ClosePOSShiftModal.vue new file mode 100644 index 000000000..75dfd3342 --- /dev/null +++ b/src/pages/POS/ClosePOSShiftModal.vue @@ -0,0 +1,209 @@ + + + diff --git a/src/pages/POS/OpenPOSShiftModal.vue b/src/pages/POS/OpenPOSShiftModal.vue new file mode 100644 index 000000000..85c51942c --- /dev/null +++ b/src/pages/POS/OpenPOSShiftModal.vue @@ -0,0 +1,223 @@ + + + diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue new file mode 100644 index 000000000..b86cbb20b --- /dev/null +++ b/src/pages/POS/POS.vue @@ -0,0 +1,550 @@ + + + diff --git a/src/pages/POS/PaymentModal.vue b/src/pages/POS/PaymentModal.vue new file mode 100644 index 000000000..e56073f6b --- /dev/null +++ b/src/pages/POS/PaymentModal.vue @@ -0,0 +1,325 @@ + + + diff --git a/src/pages/Settings/Settings.vue b/src/pages/Settings/Settings.vue index 7973cbb24..f4551221b 100644 --- a/src/pages/Settings/Settings.vue +++ b/src/pages/Settings/Settings.vue @@ -115,6 +115,7 @@ export default defineComponent({ ModelNameEnum.AccountingSettings, ModelNameEnum.InventorySettings, ModelNameEnum.Defaults, + ModelNameEnum.POSSettings, ModelNameEnum.PrintSettings, ModelNameEnum.SystemSettings, ].some((s) => this.fyo.singles[s]?.canSave); @@ -133,6 +134,7 @@ export default defineComponent({ [ModelNameEnum.PrintSettings]: this.t`Print`, [ModelNameEnum.InventorySettings]: this.t`Inventory`, [ModelNameEnum.Defaults]: this.t`Defaults`, + [ModelNameEnum.POSSettings]: this.t`POS Settings`, [ModelNameEnum.SystemSettings]: this.t`System`, }; }, @@ -140,16 +142,26 @@ export default defineComponent({ const enableInventory = !!this.fyo.singles.AccountingSettings?.enableInventory; + const enablePOS = !!this.fyo.singles.InventorySettings?.enablePointOfSale; + return [ ModelNameEnum.AccountingSettings, ModelNameEnum.InventorySettings, ModelNameEnum.Defaults, + ModelNameEnum.POSSettings, ModelNameEnum.PrintSettings, ModelNameEnum.SystemSettings, ] - .filter((s) => - s === ModelNameEnum.InventorySettings ? enableInventory : true - ) + .filter((s) => { + if (s === ModelNameEnum.InventorySettings && !enableInventory) { + return false; + } + + if (s === ModelNameEnum.POSSettings && !enablePOS) { + return false; + } + return true; + }) .map((s) => this.fyo.schemaMap[s]!); }, activeGroup(): Map { diff --git a/src/router.ts b/src/router.ts index 5b91933f7..4e7b79326 100644 --- a/src/router.ts +++ b/src/router.ts @@ -11,6 +11,7 @@ import Report from 'src/pages/Report.vue'; import Settings from 'src/pages/Settings/Settings.vue'; import TemplateBuilder from 'src/pages/TemplateBuilder/TemplateBuilder.vue'; import CustomizeForm from 'src/pages/CustomizeForm/CustomizeForm.vue'; +import POS from 'src/pages/POS/POS.vue'; import type { HistoryState } from 'vue-router'; import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; import { historyState } from './utils/refs'; @@ -124,6 +125,18 @@ const routes: RouteRecordRaw[] = [ edit: (route) => route.query, }, }, + { + path: '/pos', + name: 'Point of Sale', + components: { + default: POS, + edit: QuickEditForm, + }, + props: { + default: true, + edit: (route) => route.query, + }, + }, ]; const router = createRouter({ routes, history: createWebHistory() }); diff --git a/src/utils/pos.ts b/src/utils/pos.ts new file mode 100644 index 000000000..250475278 --- /dev/null +++ b/src/utils/pos.ts @@ -0,0 +1,294 @@ +import { Fyo, t } from 'fyo'; +import { ValidationError } from 'fyo/utils/errors'; +import { AccountTypeEnum } from 'models/baseModels/Account/types'; +import { Item } from 'models/baseModels/Item/Item'; +import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice'; +import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem'; +import { POSShift } from 'models/inventory/Point of Sale/POSShift'; +import { ValuationMethod } from 'models/inventory/types'; +import { ModelNameEnum } from 'models/types'; +import { Money } from 'pesa'; +import { + getRawStockLedgerEntries, + getStockBalanceEntries, + getStockLedgerEntries, +} from 'reports/inventory/helpers'; +import { ItemQtyMap, ItemSerialNumbers } from 'src/components/POS/types'; +import { fyo } from 'src/initFyo'; +import { safeParseFloat } from 'utils/index'; +import { showToast } from './interactive'; + +export async function getItemQtyMap(): Promise { + const itemQtyMap: ItemQtyMap = {}; + const valuationMethod = + fyo.singles.InventorySettings?.valuationMethod ?? ValuationMethod.FIFO; + + const rawSLEs = await getRawStockLedgerEntries(fyo); + const rawData = getStockLedgerEntries(rawSLEs, valuationMethod); + const posInventory = fyo.singles.POSSettings?.inventory; + + const stockBalance = getStockBalanceEntries(rawData, { + location: posInventory, + }); + + for (const row of stockBalance) { + if (!itemQtyMap[row.item]) { + itemQtyMap[row.item] = { availableQty: 0 }; + } + + if (row.batch) { + itemQtyMap[row.item][row.batch] = row.balanceQuantity; + } + + itemQtyMap[row.item].availableQty += row.balanceQuantity; + } + return itemQtyMap; +} + +export function getTotalQuantity(items: SalesInvoiceItem[]): number { + let totalQuantity = safeParseFloat(0); + + if (!items.length) { + return totalQuantity; + } + + for (const item of items) { + const quantity = item.quantity ?? 0; + totalQuantity = safeParseFloat(totalQuantity + quantity); + } + return totalQuantity; +} + +export function getItemDiscounts(items: SalesInvoiceItem[]): Money { + let itemDiscounts = fyo.pesa(0); + + if (!items.length) { + return itemDiscounts; + } + + for (const item of items) { + if (!item.itemDiscountAmount?.isZero()) { + itemDiscounts = itemDiscounts.add(item.itemDiscountAmount as Money); + } + + if (item.amount && (item.itemDiscountPercent as number) > 1) { + itemDiscounts = itemDiscounts.add( + item.amount.percent(item.itemDiscountPercent as number) + ); + } + } + return itemDiscounts; +} + +export async function getItem(item: string): Promise { + const itemDoc = (await fyo.doc.getDoc(ModelNameEnum.Item, item)) as Item; + if (!itemDoc) { + return; + } + + return itemDoc; +} + +export function validateSinv(sinvDoc: SalesInvoice, itemQtyMap: ItemQtyMap) { + if (!sinvDoc) { + return; + } + + validateSinvItems(sinvDoc.items as SalesInvoiceItem[], itemQtyMap); +} + +function validateSinvItems( + sinvItems: SalesInvoiceItem[], + itemQtyMap: ItemQtyMap +) { + for (const item of sinvItems) { + if (!item.quantity || item.quantity < 1) { + throw new ValidationError( + t`Invalid Quantity for Item ${item.item as string}` + ); + } + + if (!itemQtyMap[item.item as string]) { + throw new ValidationError(t`Item ${item.item as string} not in Stock`); + } + + if (item.quantity > itemQtyMap[item.item as string].availableQty) { + throw new ValidationError( + t`Insufficient Quantity. Item ${item.item as string} has only ${ + itemQtyMap[item.item as string].availableQty + } quantities available. you selected ${item.quantity}` + ); + } + } +} + +export async function validateShipment(itemSerialNumbers: ItemSerialNumbers) { + if (!itemSerialNumbers) { + return; + } + + for (const idx in itemSerialNumbers) { + const serialNumbers = itemSerialNumbers[idx].split('\n'); + + for (const serialNumber of serialNumbers) { + const status = await fyo.getValue( + ModelNameEnum.SerialNumber, + serialNumber, + 'status' + ); + + if (status !== 'Active') { + throw new ValidationError( + t`Serial Number ${serialNumber} status is not Active.` + ); + } + } + } +} + +export function validateIsPosSettingsSet(fyo: Fyo) { + try { + const inventory = fyo.singles.POSSettings?.inventory; + if (!inventory) { + throw new ValidationError( + t`POS Inventory is not set. Please set it on POS Settings` + ); + } + + const cashAccount = fyo.singles.POSSettings?.cashAccount; + if (!cashAccount) { + throw new ValidationError( + t`POS Counter Cash Account is not set. Please set it on POS Settings` + ); + } + + const writeOffAccount = fyo.singles.POSSettings?.writeOffAccount; + if (!writeOffAccount) { + throw new ValidationError( + t`POS Write Off Account is not set. Please set it on POS Settings` + ); + } + } catch (error) { + showToast({ + type: 'error', + message: t`${error as string}`, + duration: 'long', + }); + } +} + +export function getTotalTaxedAmount(sinvDoc: SalesInvoice): Money { + let totalTaxedAmount = fyo.pesa(0); + if (!sinvDoc.items?.length || !sinvDoc.taxes?.length) { + return totalTaxedAmount; + } + + for (const row of sinvDoc.taxes) { + totalTaxedAmount = totalTaxedAmount.add(row.amount as Money); + } + return totalTaxedAmount; +} + +export function validateClosingAmounts(posShiftDoc: POSShift) { + try { + if (!posShiftDoc) { + throw new ValidationError( + `POS Shift Document not loaded. Please reload.` + ); + } + + posShiftDoc.closingAmounts?.map((row) => { + if (row.closingAmount?.isNegative()) { + throw new ValidationError( + t`Closing ${row.paymentMethod as string} Amount can not be negative.` + ); + } + }); + } catch (error) {} +} + +export async function transferPOSCashAndWriteOff( + fyo: Fyo, + posShiftDoc: POSShift +) { + const expectedCashAmount = posShiftDoc.closingAmounts?.find( + (row) => row.paymentMethod === 'Cash' + )?.expectedAmount as Money; + + if (expectedCashAmount.isZero()) { + return; + } + + const closingCashAmount = posShiftDoc.closingAmounts?.find( + (row) => row.paymentMethod === 'Cash' + )?.closingAmount as Money; + + const jvDoc = fyo.doc.getNewDoc(ModelNameEnum.JournalEntry, { + entryType: 'Journal Entry', + }); + + await jvDoc.append('accounts', { + account: AccountTypeEnum.Cash, + debit: closingCashAmount, + }); + + await jvDoc.append('accounts', { + account: fyo.singles.POSSettings?.cashAccount, + credit: closingCashAmount, + }); + + const differenceAmount = posShiftDoc?.closingAmounts?.find( + (row) => row.paymentMethod === 'Cash' + )?.differenceAmount as Money; + + if (differenceAmount.isNegative()) { + await jvDoc.append('accounts', { + account: AccountTypeEnum.Cash, + debit: differenceAmount.abs(), + credit: fyo.pesa(0), + }); + await jvDoc.append('accounts', { + account: fyo.singles.POSSettings?.writeOffAccount, + debit: fyo.pesa(0), + credit: differenceAmount.abs(), + }); + } + + if (!differenceAmount.isZero() && differenceAmount.isPositive()) { + await jvDoc.append('accounts', { + account: fyo.singles.POSSettings?.writeOffAccount, + debit: differenceAmount, + credit: fyo.pesa(0), + }); + await jvDoc.append('accounts', { + account: AccountTypeEnum.Cash, + debit: fyo.pesa(0), + credit: differenceAmount, + }); + } + + await (await jvDoc.sync()).submit(); +} + +export function validateSerialNumberCount( + serialNumbers: string | undefined, + quantity: number, + item: string +) { + let serialNumberCount = 0; + + if (serialNumbers) { + serialNumberCount = serialNumbers.split('\n').length; + } + + if (quantity !== serialNumberCount) { + const errorMessage = t`Need ${quantity} Serial Numbers for Item ${item}. You have provided ${serialNumberCount}`; + + showToast({ + type: 'error', + message: errorMessage, + duration: 'long', + }); + throw new ValidationError(errorMessage); + } +} diff --git a/src/utils/sidebarConfig.ts b/src/utils/sidebarConfig.ts index 7a308ebd5..5ddc8577f 100644 --- a/src/utils/sidebarConfig.ts +++ b/src/utils/sidebarConfig.ts @@ -101,6 +101,20 @@ function getInventorySidebar(): SidebarRoot[] { ]; } +function getPOSSidebar() { + const isPOSEnabled = !!fyo.singles.InventorySettings?.enablePointOfSale; + if (!isPOSEnabled) { + return []; + } + + return { + label: t`POS`, + name: 'pos', + route: '/pos', + icon: 'pos', + }; +} + function getReportSidebar() { return { label: t`Reports`, @@ -256,6 +270,7 @@ function getCompleteSidebar(): SidebarConfig { }, getReportSidebar(), getInventorySidebar(), + getPOSSidebar(), getRegionalSidebar(), { label: t`Setup`,