Compare commits

..

32 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
e4f61823b3 Merge pull request #485 from bigcapitalhq/BIG-186
fix: Closing balance in general ledger report does not sum the negative figures
2024-06-10 08:17:01 +02:00
Ahmed Bouhuolia
1cbc1c056f feat: general ledger filter nodes 2024-06-10 08:08:47 +02:00
Ahmed Bouhuolia
4d4ef54c56 Merge pull request #494 from bigcapitalhq/BIG-192
fix: Concurrency controlling multiple processes in Bigcapital CLI commands
2024-06-09 22:54:02 +02:00
Ahmed Bouhuolia
f7fcfefc78 fix: Concurrency controlling multiple processes in Bigcapital CLI commands 2024-06-09 22:52:56 +02:00
Ahmed Bouhuolia
858f347fd4 Merge pull request #493 from bigcapitalhq/BIG-198
fix: Something wrong in uploading uncategorized bank transactions
2024-06-09 21:30:32 +02:00
Ahmed Bouhuolia
4d73b59cf3 fix: Something wrong in uploading uncategorized bank transactions 2024-06-09 21:30:07 +02:00
Ahmed Bouhuolia
bc67f0cca8 fix: increment/decrement the uncategorized transactios on accounts 2024-06-09 21:05:43 +02:00
Ahmed Bouhuolia
ef2d1ff141 feat: Add COGS type to cash transactions categorization 2024-06-09 21:05:19 +02:00
Ahmed Bouhuolia
dc4cdb2a8f fix: Assign branch in categorize bank transaction 2024-06-09 20:05:15 +02:00
Ahmed Bouhuolia
8862810706 Merge pull request #489 from bigcapitalhq/fix-plaid-syncing
fix: Plaid data available syncing
2024-06-07 01:31:34 +02:00
Ahmed Bouhuolia
3dadbeac4d fix: all sql queries should be under one transaction 2024-06-07 01:30:08 +02:00
Ahmed Bouhuolia
494d2c1fe0 fix: TS typing 2024-06-07 01:11:19 +02:00
Ahmed Bouhuolia
d27562bd43 fix: Plaid data available syncing 2024-06-07 01:07:17 +02:00
Ahmed Bouhuolia
8b99e0938d fix: remove un-used code 2024-06-06 18:50:24 +02:00
Ahmed Bouhuolia
94192bfc29 fix: doctype general ledger 2024-06-06 18:48:33 +02:00
Ahmed Bouhuolia
708a4dda9e chore: remove the console.log 2024-06-06 18:44:19 +02:00
Ahmed Bouhuolia
10fcf94c92 feat: general ledger closing balance with accounts row 2024-06-06 18:42:07 +02:00
Ahmed Bouhuolia
fc9995c4da chore: dump CHANGELOG.md 2024-06-06 12:32:31 +02:00
Ahmed Bouhuolia
7dc769004d fix: billing variant id 2024-06-06 11:19:19 +02:00
Ahmed Bouhuolia
5dbfd36415 feat: optimize the style of general ledger sub-accounts rows 2024-06-05 22:42:12 +02:00
Ahmed Bouhuolia
044f11ff74 feat: general ledger sub-accounts 2024-06-05 21:45:01 +02:00
Ahmed Bouhuolia
6afe1a09c6 fix: Closing balance in general ledger report does not sum the negative figures. 2024-06-04 21:26:46 +02:00
Ahmed Bouhuolia
909a70e2c5 feat: correct the migration files 2024-06-04 17:42:29 +02:00
Ahmed Bouhuolia
84dd0fa86b Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2024-06-04 16:22:07 +02:00
Ahmed Bouhuolia
a4719fe15b fix: add Plaid env variables to docker-compose.prod file 2024-06-04 16:21:49 +02:00
Ahmed Bouhuolia
fd915b503f fix: Run migrations only for initialized tenants (#484) 2024-06-04 16:13:18 +02:00
Ahmed Bouhuolia
bbba54c08e fix: validate the s3 configures exist (#482) 2024-06-04 15:11:21 +02:00
Ahmed Bouhuolia
f241e2bede fix: Plaid syncs deposit imports as withdrawals (#481) 2024-06-03 21:56:29 +02:00
Ahmed Bouhuolia
175bc243f3 fix: Organize Plaid env variables for development and sandbox envs (#480) 2024-06-03 20:50:02 +02:00
Ahmed Bouhuolia
7c06c8bb8a fix: Lemon Squeezy redirect to base url (#479)
fix: Lemon Squeezy redirect to base url
2024-06-03 19:54:40 +02:00
Ahmed Bouhuolia
8fd930caac Merge pull request #478 from bigcapitalhq/virtual-docker-internal-network
feat: Internal docker virtual network
2024-06-02 21:25:55 +02:00
Ahmed Bouhuolia
e175307da4 feat: internal docker virtual network 2024-06-02 21:25:15 +02:00
49 changed files with 1057 additions and 379 deletions

View File

@@ -75,30 +75,9 @@ PLAID_ENV=sandbox
# Your Plaid keys, which can be found in the Plaid Dashboard. # Your Plaid keys, which can be found in the Plaid Dashboard.
# https://dashboard.plaid.com/account/keys # https://dashboard.plaid.com/account/keys
PLAID_CLIENT_ID= PLAID_CLIENT_ID=
PLAID_SECRET_DEVELOPMENT= PLAID_SECRET=
PLAID_SECRET_SANDBOX=
PLAID_LINK_WEBHOOK= PLAID_LINK_WEBHOOK=
# (Optional) Redirect URI settings section
# Only required for OAuth redirect URI testing (not common on desktop):
# Sandbox Mode:
# Set the PLAID_SANDBOX_REDIRECT_URI below to 'http://localhost:3001/oauth-link'.
# The OAuth redirect flow requires an endpoint on the developer's website
# that the bank website should redirect to. You will also need to configure
# this redirect URI for your client ID through the Plaid developer dashboard
# at https://dashboard.plaid.com/team/api.
# Development mode:
# When running in development mode, you must use an https:// url.
# You will need to configure this https:// redirect URI in the Plaid developer dashboard.
# Instructions to create a self-signed certificate for localhost can be found at
# https://github.com/plaid/pattern/blob/master/README.md#testing-oauth.
# If your system is not set up to run localhost with https://, you will be unable to test
# the OAuth in development and should leave the PLAID_DEVELOPMENT_REDIRECT_URI blank.
PLAID_SANDBOX_REDIRECT_URI=
PLAID_DEVELOPMENT_REDIRECT_URI=
# https://docs.lemonsqueezy.com/guides/developer-guide/getting-started#create-an-api-key # https://docs.lemonsqueezy.com/guides/developer-guide/getting-started#create-an-api-key
LEMONSQUEEZY_API_KEY= LEMONSQUEEZY_API_KEY=
LEMONSQUEEZY_STORE_ID= LEMONSQUEEZY_STORE_ID=

View File

@@ -2,6 +2,41 @@
All notable changes to Bigcapital server-side will be in this file. All notable changes to Bigcapital server-side will be in this file.
## [0.17.0] - 04-06-2024
### New
* feat: Upload and attach documents by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/461
* feat: Export resource tables to pdf by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/460
* feat: Build and deploy develop Docker container by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/476
* feat: Internal docker virtual network by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/478
### Fixes
* fix: Skip send confirmation email if disabled by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/459
* fix: Lemon Squeezy redirect to base url by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/479
* fix: Organize Plaid env variables for development and sandbox envs by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/480
* fix: Plaid syncs deposit imports as withdrawals by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/481
* fix: Validate the s3 configures exist by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/482
* fix: Run migrations only for initialized tenants by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/484
## [0.16.16] -
* feat: handle http exceptions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/456
* feat: add the missing Newrelic env vars to docker-compose.prod file by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/457
* fix: add the signup email confirmation env var by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/458
## [0.16.14] -
* fix: Typo in setup wizard by @ccantrell72 in https://github.com/bigcapitalhq/bigcapital/pull/440
* fix: Showing the real mail address on email confirmation view by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/445
* fix: Auto-increment setting parsing by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/453
## [0.16.12] -
* feat: Create a manifest list for `webapp` Docker image and push it to DockerHub. by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/436
* feat: Combine arm64 and amd64 in one Github action runner by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/437
## [0.16.11] - 06-05-2024 ## [0.16.11] - 06-05-2024
### improvements ### improvements

View File

@@ -22,11 +22,15 @@ services:
- server - server
- webapp - webapp
restart: on-failure restart: on-failure
networks:
- bigcapital_network
webapp: webapp:
container_name: bigcapital-webapp container_name: bigcapital-webapp
image: bigcapitalhq/webapp:latest image: bigcapitalhq/webapp:latest
restart: on-failure restart: on-failure
networks:
- bigcapital_network
server: server:
container_name: bigcapital-server container_name: bigcapital-server
@@ -89,14 +93,17 @@ services:
- GOTENBERG_URL=${GOTENBERG_URL} - GOTENBERG_URL=${GOTENBERG_URL}
- GOTENBERG_DOCS_URL=${GOTENBERG_DOCS_URL} - GOTENBERG_DOCS_URL=${GOTENBERG_DOCS_URL}
# Exchange Rate
- EXCHANGE_RATE_SERVICE=${EXCHANGE_RATE_SERVICE}
- OPEN_EXCHANGE_RATE_APP_ID-${OPEN_EXCHANGE_RATE_APP_ID}
# Bank Sync # Bank Sync
- BANKING_CONNECT=${BANKING_CONNECT} - BANKING_CONNECT=${BANKING_CONNECT}
# Plaid # Plaid
- PLAID_ENV=${PLAID_ENV} - PLAID_ENV=${PLAID_ENV}
- PLAID_CLIENT_ID=${PLAID_CLIENT_ID} - PLAID_CLIENT_ID=${PLAID_CLIENT_ID}
- PLAID_SECRET_DEVELOPMENT=${PLAID_SECRET_DEVELOPMENT} - PLAID_SECRET=${PLAID_SECRET}
- PLAID_SECRET_SANDBOX=${b8cf42b441e110451e2f69ad7e1e9f}
- PLAID_LINK_WEBHOOK=${PLAID_LINK_WEBHOOK} - PLAID_LINK_WEBHOOK=${PLAID_LINK_WEBHOOK}
# Lemon Squeez # Lemon Squeez
@@ -120,6 +127,8 @@ services:
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_ENDPOINT=${S3_ENDPOINT} - S3_ENDPOINT=${S3_ENDPOINT}
- S3_BUCKET=${S3_BUCKET} - S3_BUCKET=${S3_BUCKET}
networks:
- bigcapital_network
database_migration: database_migration:
container_name: bigcapital-database-migration container_name: bigcapital-database-migration
@@ -137,6 +146,8 @@ services:
- TENANT_DB_NAME_PERFIX=${TENANT_DB_NAME_PERFIX} - TENANT_DB_NAME_PERFIX=${TENANT_DB_NAME_PERFIX}
depends_on: depends_on:
- mysql - mysql
networks:
- bigcapital_network
mysql: mysql:
container_name: bigcapital-mysql container_name: bigcapital-mysql
@@ -152,6 +163,8 @@ services:
- mysql:/var/lib/mysql - mysql:/var/lib/mysql
expose: expose:
- '3306' - '3306'
networks:
- bigcapital_network
mongo: mongo:
container_name: bigcapital-mongo container_name: bigcapital-mongo
@@ -161,6 +174,8 @@ services:
- '27017' - '27017'
volumes: volumes:
- mongo:/var/lib/mongodb - mongo:/var/lib/mongodb
networks:
- bigcapital_network
redis: redis:
container_name: bigcapital-redis container_name: bigcapital-redis
@@ -171,11 +186,15 @@ services:
- '6379' - '6379'
volumes: volumes:
- redis:/data - redis:/data
networks:
- bigcapital_network
gotenberg: gotenberg:
image: gotenberg/gotenberg:7 image: gotenberg/gotenberg:7
expose: expose:
- '9000' - '9000'
networks:
- bigcapital_network
# Volumes # Volumes
volumes: volumes:
@@ -190,3 +209,8 @@ volumes:
redis: redis:
name: bigcapital_prod_redis name: bigcapital_prod_redis
driver: local driver: local
# Networks
networks:
bigcapital_network:
driver: bridge

View File

@@ -25,6 +25,7 @@
"@casl/ability": "^5.4.3", "@casl/ability": "^5.4.3",
"@hapi/boom": "^7.4.3", "@hapi/boom": "^7.4.3",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0", "@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"@supercharge/promise-pool": "^3.2.0",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/i18n": "^0.8.7", "@types/i18n": "^0.8.7",
"@types/knex": "^0.16.1", "@types/knex": "^0.16.1",

View File

@@ -4,12 +4,16 @@ import { Router, Response, NextFunction, Request } from 'express';
import { body, param } from 'express-validator'; import { body, param } from 'express-validator';
import BaseController from '@/api/controllers/BaseController'; import BaseController from '@/api/controllers/BaseController';
import { AttachmentsApplication } from '@/services/Attachments/AttachmentsApplication'; import { AttachmentsApplication } from '@/services/Attachments/AttachmentsApplication';
import { AttachmentUploadPipeline } from '@/services/Attachments/S3UploadPipeline';
@Service() @Service()
export class AttachmentsController extends BaseController { export class AttachmentsController extends BaseController {
@Inject() @Inject()
private attachmentsApplication: AttachmentsApplication; private attachmentsApplication: AttachmentsApplication;
@Inject()
private uploadPipelineService: AttachmentUploadPipeline;
/** /**
* Router constructor. * Router constructor.
*/ */
@@ -18,7 +22,8 @@ export class AttachmentsController extends BaseController {
router.post( router.post(
'/', '/',
this.attachmentsApplication.uploadPipeline.single('file'), this.uploadPipelineService.validateS3Configured,
this.uploadPipelineService.uploadPipeline().single('file'),
this.validateUploadedFileExistance, this.validateUploadedFileExistance,
this.uploadAttachment.bind(this) this.uploadAttachment.bind(this)
); );

View File

@@ -1,7 +1,6 @@
import { Router } from 'express'; import { NextFunction, Router, Request, Response } from 'express';
import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication';
import { Request, Response } from 'express';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication';
import BaseController from '../BaseController'; import BaseController from '../BaseController';
import { LemonSqueezyWebhooks } from '@/services/Subscription/LemonSqueezyWebhooks'; import { LemonSqueezyWebhooks } from '@/services/Subscription/LemonSqueezyWebhooks';
import { PlaidWebhookTenantBootMiddleware } from '@/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware'; import { PlaidWebhookTenantBootMiddleware } from '@/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware';
@@ -34,7 +33,7 @@ export class Webhooks extends BaseController {
* @param {Response} res * @param {Response} res
* @returns {Response} * @returns {Response}
*/ */
public async lemonWebhooks(req: Request, res: Response, next: any) { public async lemonWebhooks(req: Request, res: Response, next: NextFunction) {
const data = req.body; const data = req.body;
const signature = req.headers['x-signature'] ?? ''; const signature = req.headers['x-signature'] ?? '';
const rawBody = req.rawBody; const rawBody = req.rawBody;
@@ -57,8 +56,10 @@ export class Webhooks extends BaseController {
* @param {Response} res * @param {Response} res
* @returns {Response} * @returns {Response}
*/ */
public async plaidWebhooks(req: Request, res: Response) { public async plaidWebhooks(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req; const { tenantId } = req;
try {
const { const {
webhook_type: webhookType, webhook_type: webhookType,
webhook_code: webhookCode, webhook_code: webhookCode,
@@ -72,5 +73,8 @@ export class Webhooks extends BaseController {
webhookCode webhookCode
); );
return res.status(200).send({ code: 200, message: 'ok' }); return res.status(200).send({ code: 200, message: 'ok' });
} catch (error) {
next(error);
}
} }
} }

View File

@@ -4,6 +4,7 @@ import color from 'colorette';
import argv from 'getopts'; import argv from 'getopts';
import Knex from 'knex'; import Knex from 'knex';
import { knexSnakeCaseMappers } from 'objection'; import { knexSnakeCaseMappers } from 'objection';
import { PromisePool } from '@supercharge/promise-pool';
import '../before'; import '../before';
import config from '../config'; import config from '../config';
@@ -28,7 +29,7 @@ function initSystemKnex() {
}); });
} }
function initTenantKnex(organizationId) { function initTenantKnex(organizationId: string = '') {
return Knex({ return Knex({
client: config.tenant.db_client, client: config.tenant.db_client,
connection: { connection: {
@@ -71,6 +72,12 @@ function getAllSystemTenants(knex) {
return knex('tenants'); return knex('tenants');
} }
function getAllInitializedTenants(knex) {
return knex('tenants').whereNotNull('initializedAt');
}
const MIGRATION_CONCURRENCY = 10;
// module.exports = { // module.exports = {
// log, // log,
// success, // success,
@@ -87,6 +94,7 @@ function getAllSystemTenants(knex) {
// - bigcapital tenants:migrate:make // - bigcapital tenants:migrate:make
// - bigcapital system:migrate:make // - bigcapital system:migrate:make
// - bigcapital tenants:list // - bigcapital tenants:list
// - bigcapital tenants:list --all
commander commander
.command('system:migrate:rollback') .command('system:migrate:rollback')
@@ -145,10 +153,13 @@ commander
commander commander
.command('tenants:list') .command('tenants:list')
.description('Retrieve a list of all system tenants databases.') .description('Retrieve a list of all system tenants databases.')
.option('-a, --all', 'All tenants even are not initialized.')
.action(async (cmd) => { .action(async (cmd) => {
try { try {
const sysKnex = await initSystemKnex(); const sysKnex = await initSystemKnex();
const tenants = await getAllSystemTenants(sysKnex); const tenants = cmd?.all
? await getAllSystemTenants(sysKnex)
: await getAllInitializedTenants(sysKnex);
tenants.forEach((tenant) => { tenants.forEach((tenant) => {
const dbName = `${config.tenant.db_name_prefix}${tenant.organizationId}`; const dbName = `${config.tenant.db_name_prefix}${tenant.organizationId}`;
@@ -179,18 +190,20 @@ commander
commander commander
.command('tenants:migrate:latest') .command('tenants:migrate:latest')
.description('Migrate all tenants or the given tenant id.') .description('Migrate all tenants or the given tenant id.')
.option('-t, --tenant_id [tenant_id]', 'Which tenant id do you migrate.') .option(
'-t, --tenant_id [tenant_id]',
'Which organization id do you migrate.'
)
.action(async (cmd) => { .action(async (cmd) => {
try { try {
const sysKnex = await initSystemKnex(); const sysKnex = await initSystemKnex();
const tenants = await getAllSystemTenants(sysKnex); const tenants = await getAllInitializedTenants(sysKnex);
const tenantsOrgsIds = tenants.map((tenant) => tenant.organizationId); const tenantsOrgsIds = tenants.map((tenant) => tenant.organizationId);
if (cmd.tenant_id && tenantsOrgsIds.indexOf(cmd.tenant_id) === -1) { if (cmd.tenant_id && tenantsOrgsIds.indexOf(cmd.tenant_id) === -1) {
exit(`The given tenant id ${cmd.tenant_id} is not exists.`); exit(`The given tenant id ${cmd.tenant_id} is not exists.`);
} }
// Validate the tenant id exist first of all. // Validate the tenant id exist first of all.
const migrateOpers = [];
const migrateTenant = async (organizationId) => { const migrateTenant = async (organizationId) => {
try { try {
const tenantKnex = await initTenantKnex(organizationId); const tenantKnex = await initTenantKnex(organizationId);
@@ -212,18 +225,17 @@ commander
} }
}; };
if (!cmd.tenant_id) { if (!cmd.tenant_id) {
tenants.forEach((tenant) => { await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
const oper = migrateTenant(tenant.organizationId); .for(tenants)
migrateOpers.push(oper); .process((tenant, index, pool) => {
}); return migrateTenant(tenant.organizationId);
} else { })
const oper = migrateTenant(cmd.tenant_id); .then(() => {
migrateOpers.push(oper);
}
Promise.all(migrateOpers).then(() => {
success('All tenants are migrated.'); success('All tenants are migrated.');
}); });
} else {
await migrateTenant(cmd.tenant_id);
}
} catch (error) { } catch (error) {
exit(error); exit(error);
} }
@@ -232,19 +244,21 @@ commander
commander commander
.command('tenants:migrate:rollback') .command('tenants:migrate:rollback')
.description('Rollback the last batch of tenants migrations.') .description('Rollback the last batch of tenants migrations.')
.option('-t, --tenant_id [tenant_id]', 'Which tenant id do you migrate.') .option(
'-t, --tenant_id [tenant_id]',
'Which organization id do you migrate.'
)
.action(async (cmd) => { .action(async (cmd) => {
try { try {
const sysKnex = await initSystemKnex(); const sysKnex = await initSystemKnex();
const tenants = await getAllSystemTenants(sysKnex); const tenants = await getAllInitializedTenants(sysKnex);
const tenantsOrgsIds = tenants.map((tenant) => tenant.organizationId); const tenantsOrgsIds = tenants.map((tenant) => tenant.organizationId);
if (cmd.tenant_id && tenantsOrgsIds.indexOf(cmd.tenant_id) === -1) { if (cmd.tenant_id && tenantsOrgsIds.indexOf(cmd.tenant_id) === -1) {
exit(`The given tenant id ${cmd.tenant_id} is not exists.`); exit(`The given tenant id ${cmd.tenant_id} is not exists.`);
} }
const migrateOpers = []; const migrateTenant = async (organizationId: string) => {
const migrateTenant = async (organizationId) => {
try { try {
const tenantKnex = await initTenantKnex(organizationId); const tenantKnex = await initTenantKnex(organizationId);
const [batchNo, _log] = await tenantKnex.migrate.rollback(); const [batchNo, _log] = await tenantKnex.migrate.rollback();
@@ -265,19 +279,18 @@ commander
}; };
if (!cmd.tenant_id) { if (!cmd.tenant_id) {
tenants.forEach((tenant) => { await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
const oper = migrateTenant(tenant.organizationId); .for(tenants)
migrateOpers.push(oper); .process((tenant, index, pool) => {
}); return migrateTenant(tenant.organizationId);
} else { })
const oper = migrateTenant(cmd.tenant_id); .then(() => {
migrateOpers.push(oper);
}
Promise.all(migrateOpers).then(() => {
success('All tenants are rollbacked.'); success('All tenants are rollbacked.');
}); });
} else {
await migrateTenant(cmd.tenant_id);
}
} catch (error) { } catch (error) {
exit(error); exit(error);
} }
}); });

View File

@@ -235,6 +235,6 @@ module.exports = {
accessKeyId: process.env.S3_ACCESS_KEY_ID, accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
endpoint: process.env.S3_ENDPOINT, endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET, bucket: process.env.S3_BUCKET || 'bigcapital-documents',
}, },
}; };

View File

@@ -0,0 +1,14 @@
exports.up = function (knex) {
return knex.schema.createTable('storage', (table) => {
table.increments('id').primary();
table.string('key').notNullable();
table.string('path').notNullable();
table.string('extension').notNullable();
table.integer('expire_in');
table.timestamps();
});
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('storage');
};

View File

@@ -0,0 +1,5 @@
exports.up = function (knex) {
return knex.schema.dropTableIfExists('storage');
};
exports.down = function (knex) {};

View File

@@ -164,3 +164,7 @@ export enum TaxRateAction {
DELETE = 'Delete', DELETE = 'Delete',
VIEW = 'View', VIEW = 'View',
} }
export interface CreateAccountParams {
ignoreUniqueName: boolean;
}

View File

@@ -56,6 +56,8 @@ export interface IGeneralLedgerSheetAccount {
transactions: IGeneralLedgerSheetAccountTransaction[]; transactions: IGeneralLedgerSheetAccountTransaction[];
openingBalance: IGeneralLedgerSheetAccountBalance; openingBalance: IGeneralLedgerSheetAccountBalance;
closingBalance: IGeneralLedgerSheetAccountBalance; closingBalance: IGeneralLedgerSheetAccountBalance;
closingBalanceSubaccounts?: IGeneralLedgerSheetAccountBalance;
children?: IGeneralLedgerSheetAccount[];
} }
export type IGeneralLedgerSheetData = IGeneralLedgerSheetAccount[]; export type IGeneralLedgerSheetData = IGeneralLedgerSheetAccount[];

View File

@@ -104,10 +104,10 @@ export default class UncategorizedCashflowTransaction extends mixin(
*/ */
private async updateUncategorizedTransactionCount( private async updateUncategorizedTransactionCount(
queryContext: QueryContext, queryContext: QueryContext,
increment: boolean increment: boolean,
amount: number = 1
) { ) {
const operation = increment ? 'increment' : 'decrement'; const operation = increment ? 'increment' : 'decrement';
const amount = increment ? 1 : -1;
await Account.query(queryContext.transaction) await Account.query(queryContext.transaction)
.findById(this.accountId) .findById(this.accountId)

View File

@@ -274,4 +274,14 @@ export default class Ledger implements ILedger {
const entries = Ledger.mappingTransactions(transactions); const entries = Ledger.mappingTransactions(transactions);
return new Ledger(entries); return new Ledger(entries);
} }
/**
* Retrieve the transaction amount.
* @param {number} credit - Credit amount.
* @param {number} debit - Debit amount.
* @param {string} normal - Credit or debit.
*/
static getAmount(credit: number, debit: number, normal: string) {
return normal === 'credit' ? credit - debit : debit - credit;
}
} }

View File

@@ -7,6 +7,7 @@ import {
IAccountEventCreatedPayload, IAccountEventCreatedPayload,
IAccountEventCreatingPayload, IAccountEventCreatingPayload,
IAccountCreateDTO, IAccountCreateDTO,
CreateAccountParams,
} from '@/interfaces'; } from '@/interfaces';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import UnitOfWork from '@/services/UnitOfWork'; import UnitOfWork from '@/services/UnitOfWork';
@@ -36,13 +37,16 @@ export class CreateAccount {
private authorize = async ( private authorize = async (
tenantId: number, tenantId: number,
accountDTO: IAccountCreateDTO, accountDTO: IAccountCreateDTO,
baseCurrency: string baseCurrency: string,
params?: CreateAccountParams
) => { ) => {
// Validate account name uniquiness. // Validate account name uniquiness.
if (!params.ignoreUniqueName) {
await this.validator.validateAccountNameUniquiness( await this.validator.validateAccountNameUniquiness(
tenantId, tenantId,
accountDTO.name accountDTO.name
); );
}
// Validate the account code uniquiness. // Validate the account code uniquiness.
if (accountDTO.code) { if (accountDTO.code) {
await this.validator.isAccountCodeUniqueOrThrowError( await this.validator.isAccountCodeUniqueOrThrowError(
@@ -104,7 +108,8 @@ export class CreateAccount {
public createAccount = async ( public createAccount = async (
tenantId: number, tenantId: number,
accountDTO: IAccountCreateDTO, accountDTO: IAccountCreateDTO,
trx?: Knex.Transaction trx?: Knex.Transaction,
params: CreateAccountParams = { ignoreUniqueName: false }
): Promise<IAccount> => { ): Promise<IAccount> => {
const { Account } = this.tenancy.models(tenantId); const { Account } = this.tenancy.models(tenantId);
@@ -112,8 +117,12 @@ export class CreateAccount {
const tenantMeta = await TenantMetadata.query().findOne({ tenantId }); const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
// Authorize the account creation. // Authorize the account creation.
await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency); await this.authorize(
tenantId,
accountDTO,
tenantMeta.baseCurrency,
params
);
// Transformes the DTO to model. // Transformes the DTO to model.
const accountInputModel = this.transformDTOToModel( const accountInputModel = this.transformDTOToModel(
accountDTO, accountDTO,
@@ -148,3 +157,4 @@ export class CreateAccount {
); );
}; };
} }

View File

@@ -2,11 +2,9 @@ import { Inject, Service } from 'typedi';
import { UploadDocument } from './UploadDocument'; import { UploadDocument } from './UploadDocument';
import { DeleteAttachment } from './DeleteAttachment'; import { DeleteAttachment } from './DeleteAttachment';
import { GetAttachment } from './GetAttachment'; import { GetAttachment } from './GetAttachment';
import { AttachmentUploadPipeline } from './S3UploadPipeline';
import { LinkAttachment } from './LinkAttachment'; import { LinkAttachment } from './LinkAttachment';
import { UnlinkAttachment } from './UnlinkAttachment'; import { UnlinkAttachment } from './UnlinkAttachment';
import { getAttachmentPresignedUrl } from './GetAttachmentPresignedUrl'; import { getAttachmentPresignedUrl } from './GetAttachmentPresignedUrl';
import type { Multer } from 'multer';
@Service() @Service()
export class AttachmentsApplication { export class AttachmentsApplication {
@@ -19,9 +17,6 @@ export class AttachmentsApplication {
@Inject() @Inject()
private getDocumentService: GetAttachment; private getDocumentService: GetAttachment;
@Inject()
private uploadPipelineService: AttachmentUploadPipeline;
@Inject() @Inject()
private linkDocumentService: LinkAttachment; private linkDocumentService: LinkAttachment;
@@ -31,14 +26,6 @@ export class AttachmentsApplication {
@Inject() @Inject()
private getPresignedUrlService: getAttachmentPresignedUrl; private getPresignedUrlService: getAttachmentPresignedUrl;
/**
* Express middleware for uploading attachments to an S3 bucket.
* @returns {Multer}
*/
get uploadPipeline(): Multer {
return this.uploadPipelineService.uploadPipeline();
}
/** /**
* Saves the metadata of uploaded document to S3 on database. * Saves the metadata of uploaded document to S3 on database.
* @param {number} tenantId * @param {number} tenantId

View File

@@ -1,12 +1,38 @@
import multer from 'multer'; import multer from 'multer';
import type { Multer } from 'multer' import type { Multer } from 'multer';
import multerS3 from 'multer-s3'; import multerS3 from 'multer-s3';
import { s3 } from '@/lib/S3/S3'; import { s3 } from '@/lib/S3/S3';
import { Service } from 'typedi'; import { Service } from 'typedi';
import config from '@/config'; import config from '@/config';
import { NextFunction, Request, Response } from 'express';
@Service() @Service()
export class AttachmentUploadPipeline { export class AttachmentUploadPipeline {
/**
* Middleware to ensure that S3 configuration is properly set before proceeding.
* This function checks if the necessary S3 configuration keys are present and throws an error if any are missing.
*
* @param req The HTTP request object.
* @param res The HTTP response object.
* @param next The callback to pass control to the next middleware function.
*/
public validateS3Configured(req: Request, res: Response, next: NextFunction) {
if (
!config.s3.region ||
!config.s3.accessKeyId ||
!config.s3.secretAccessKey
) {
const missingKeys = [];
if (!config.s3.region) missingKeys.push('region');
if (!config.s3.accessKeyId) missingKeys.push('accessKeyId');
if (!config.s3.secretAccessKey) missingKeys.push('secretAccessKey');
const missing = missingKeys.join(', ');
throw new Error(`S3 configuration error: Missing ${missing}`);
}
next();
}
/** /**
* Express middleware for uploading attachments to an S3 bucket. * Express middleware for uploading attachments to an S3 bucket.
* It utilizes the multer middleware for handling multipart/form-data, specifically for file uploads. * It utilizes the multer middleware for handling multipart/form-data, specifically for file uploads.

View File

@@ -3,7 +3,11 @@ import { Inject, Service } from 'typedi';
import bluebird from 'bluebird'; import bluebird from 'bluebird';
import { entries, groupBy } from 'lodash'; import { entries, groupBy } from 'lodash';
import { CreateAccount } from '@/services/Accounts/CreateAccount'; import { CreateAccount } from '@/services/Accounts/CreateAccount';
import { PlaidAccount, PlaidTransaction } from '@/interfaces'; import {
IAccountCreateDTO,
PlaidAccount,
PlaidTransaction,
} from '@/interfaces';
import { import {
transformPlaidAccountToCreateAccount, transformPlaidAccountToCreateAccount,
transformPlaidTrxsToCashflowCreate, transformPlaidTrxsToCashflowCreate,
@@ -11,6 +15,7 @@ import {
import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTransactionService'; import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTransactionService';
import HasTenancyService from '@/services/Tenancy/TenancyService'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { Knex } from 'knex';
const CONCURRENCY_ASYNC = 10; const CONCURRENCY_ASYNC = 10;
@@ -28,6 +33,35 @@ export class PlaidSyncDb {
@Inject() @Inject()
private deleteCashflowTransactionService: DeleteCashflowTransaction; private deleteCashflowTransactionService: DeleteCashflowTransaction;
/**
* Syncs the Plaid bank account.
* @param {number} tenantId
* @param {IAccountCreateDTO} createBankAccountDTO
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public async syncBankAccount(
tenantId: number,
createBankAccountDTO: IAccountCreateDTO,
trx?: Knex.Transaction
) {
const { Account } = this.tenancy.models(tenantId);
const plaidAccount = await Account.query().findOne(
'plaidAccountId',
createBankAccountDTO.plaidAccountId
);
// Can't continue if the Plaid account is already created.
if (plaidAccount) {
return;
}
await this.createAccountService.createAccount(
tenantId,
createBankAccountDTO,
trx,
{ ignoreUniqueName: true }
);
}
/** /**
* Syncs the plaid accounts to the system accounts. * Syncs the plaid accounts to the system accounts.
* @param {number} tenantId Tenant ID. * @param {number} tenantId Tenant ID.
@@ -37,7 +71,8 @@ export class PlaidSyncDb {
public async syncBankAccounts( public async syncBankAccounts(
tenantId: number, tenantId: number,
plaidAccounts: PlaidAccount[], plaidAccounts: PlaidAccount[],
institution: any institution: any,
trx?: Knex.Transaction
): Promise<void> { ): Promise<void> {
const transformToPlaidAccounts = const transformToPlaidAccounts =
transformPlaidAccountToCreateAccount(institution); transformPlaidAccountToCreateAccount(institution);
@@ -47,7 +82,7 @@ export class PlaidSyncDb {
await bluebird.map( await bluebird.map(
accountCreateDTOs, accountCreateDTOs,
(createAccountDTO: any) => (createAccountDTO: any) =>
this.createAccountService.createAccount(tenantId, createAccountDTO), this.syncBankAccount(tenantId, createAccountDTO, trx),
{ concurrency: CONCURRENCY_ASYNC } { concurrency: CONCURRENCY_ASYNC }
); );
} }
@@ -61,15 +96,16 @@ export class PlaidSyncDb {
public async syncAccountTranactions( public async syncAccountTranactions(
tenantId: number, tenantId: number,
plaidAccountId: number, plaidAccountId: number,
plaidTranasctions: PlaidTransaction[] plaidTranasctions: PlaidTransaction[],
trx?: Knex.Transaction
): Promise<void> { ): Promise<void> {
const { Account } = this.tenancy.models(tenantId); const { Account } = this.tenancy.models(tenantId);
const cashflowAccount = await Account.query() const cashflowAccount = await Account.query(trx)
.findOne({ plaidAccountId }) .findOne({ plaidAccountId })
.throwIfNotFound(); .throwIfNotFound();
const openingEquityBalance = await Account.query().findOne( const openingEquityBalance = await Account.query(trx).findOne(
'slug', 'slug',
'opening-balance-equity' 'opening-balance-equity'
); );
@@ -87,7 +123,8 @@ export class PlaidSyncDb {
(uncategoriedDTO) => (uncategoriedDTO) =>
this.cashflowApp.createUncategorizedTransaction( this.cashflowApp.createUncategorizedTransaction(
tenantId, tenantId,
uncategoriedDTO uncategoriedDTO,
trx
), ),
{ concurrency: 1 } { concurrency: 1 }
); );
@@ -100,7 +137,8 @@ export class PlaidSyncDb {
*/ */
public async syncAccountsTransactions( public async syncAccountsTransactions(
tenantId: number, tenantId: number,
plaidAccountsTransactions: PlaidTransaction[] plaidAccountsTransactions: PlaidTransaction[],
trx?: Knex.Transaction
): Promise<void> { ): Promise<void> {
const groupedTrnsxByAccountId = entries( const groupedTrnsxByAccountId = entries(
groupBy(plaidAccountsTransactions, 'account_id') groupBy(plaidAccountsTransactions, 'account_id')
@@ -111,7 +149,8 @@ export class PlaidSyncDb {
return this.syncAccountTranactions( return this.syncAccountTranactions(
tenantId, tenantId,
plaidAccountId, plaidAccountId,
plaidTransactions plaidTransactions,
trx
); );
}, },
{ concurrency: CONCURRENCY_ASYNC } { concurrency: CONCURRENCY_ASYNC }
@@ -124,11 +163,12 @@ export class PlaidSyncDb {
*/ */
public async syncRemoveTransactions( public async syncRemoveTransactions(
tenantId: number, tenantId: number,
plaidTransactionsIds: string[] plaidTransactionsIds: string[],
trx?: Knex.Transaction
) { ) {
const { CashflowTransaction } = this.tenancy.models(tenantId); const { CashflowTransaction } = this.tenancy.models(tenantId);
const cashflowTransactions = await CashflowTransaction.query().whereIn( const cashflowTransactions = await CashflowTransaction.query(trx).whereIn(
'plaidTransactionId', 'plaidTransactionId',
plaidTransactionsIds plaidTransactionsIds
); );
@@ -140,7 +180,8 @@ export class PlaidSyncDb {
(transactionId: number) => (transactionId: number) =>
this.deleteCashflowTransactionService.deleteCashflowTransaction( this.deleteCashflowTransactionService.deleteCashflowTransaction(
tenantId, tenantId,
transactionId transactionId,
trx
), ),
{ concurrency: CONCURRENCY_ASYNC } { concurrency: CONCURRENCY_ASYNC }
); );
@@ -155,11 +196,12 @@ export class PlaidSyncDb {
public async syncTransactionsCursor( public async syncTransactionsCursor(
tenantId: number, tenantId: number,
plaidItemId: string, plaidItemId: string,
lastCursor: string lastCursor: string,
trx?: Knex.Transaction
) { ) {
const { PlaidItem } = this.tenancy.models(tenantId); const { PlaidItem } = this.tenancy.models(tenantId);
await PlaidItem.query().findOne({ plaidItemId }).patch({ lastCursor }); await PlaidItem.query(trx).findOne({ plaidItemId }).patch({ lastCursor });
} }
/** /**
@@ -169,11 +211,14 @@ export class PlaidSyncDb {
*/ */
public async updateLastFeedsUpdatedAt( public async updateLastFeedsUpdatedAt(
tenantId: number, tenantId: number,
plaidAccountIds: string[] plaidAccountIds: string[],
trx?: Knex.Transaction
) { ) {
const { Account } = this.tenancy.models(tenantId); const { Account } = this.tenancy.models(tenantId);
await Account.query().whereIn('plaid_account_id', plaidAccountIds).patch({ await Account.query(trx)
.whereIn('plaid_account_id', plaidAccountIds)
.patch({
lastFeedsUpdatedAt: new Date(), lastFeedsUpdatedAt: new Date(),
}); });
} }
@@ -187,11 +232,14 @@ export class PlaidSyncDb {
public async updateAccountsFeedsActive( public async updateAccountsFeedsActive(
tenantId: number, tenantId: number,
plaidAccountIds: string[], plaidAccountIds: string[],
isFeedsActive: boolean = true isFeedsActive: boolean = true,
trx?: Knex.Transaction
) { ) {
const { Account } = this.tenancy.models(tenantId); const { Account } = this.tenancy.models(tenantId);
await Account.query().whereIn('plaid_account_id', plaidAccountIds).patch({ await Account.query(trx)
.whereIn('plaid_account_id', plaidAccountIds)
.patch({
isFeedsActive, isFeedsActive,
}); });
} }

View File

@@ -3,6 +3,8 @@ import { Inject, Service } from 'typedi';
import { PlaidClientWrapper } from '@/lib/Plaid/Plaid'; import { PlaidClientWrapper } from '@/lib/Plaid/Plaid';
import { PlaidSyncDb } from './PlaidSyncDB'; import { PlaidSyncDb } from './PlaidSyncDB';
import { PlaidFetchedTransactionsUpdates } from '@/interfaces'; import { PlaidFetchedTransactionsUpdates } from '@/interfaces';
import UnitOfWork from '@/services/UnitOfWork';
import { Knex } from 'knex';
@Service() @Service()
export class PlaidUpdateTransactions { export class PlaidUpdateTransactions {
@@ -12,12 +14,40 @@ export class PlaidUpdateTransactions {
@Inject() @Inject()
private plaidSync: PlaidSyncDb; private plaidSync: PlaidSyncDb;
@Inject()
private uow: UnitOfWork;
/** /**
* Handles the fetching and storing of new, modified, or removed transactions * Handles sync the Plaid item to Bigcaptial under UOW.
* @param {number} tenantId Tenant ID. * @param {number} tenantId
* @param {string} plaidItemId the Plaid ID for the item. * @param {number} plaidItemId
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
*/ */
public async updateTransactions(tenantId: number, plaidItemId: string) { public async updateTransactions(tenantId: number, plaidItemId: string) {
return this.uow.withTransaction(tenantId, (trx: Knex.Transaction) => {
return this.updateTransactionsWork(tenantId, plaidItemId, trx);
});
}
/**
* Handles the fetching and storing the following:
* - New, modified, or removed transactions.
* - New bank accounts.
* - Last accounts feeds updated at.
* - Turn on the accounts feed flag.
* @param {number} tenantId - Tenant ID.
* @param {string} plaidItemId - The Plaid ID for the item.
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
*/
public async updateTransactionsWork(
tenantId: number,
plaidItemId: string,
trx?: Knex.Transaction
): Promise<{
addedCount: number;
modifiedCount: number;
removedCount: number;
}> {
// Fetch new transactions from plaid api. // Fetch new transactions from plaid api.
const { added, modified, removed, cursor, accessToken } = const { added, modified, removed, cursor, accessToken } =
await this.fetchTransactionUpdates(tenantId, plaidItemId); await this.fetchTransactionUpdates(tenantId, plaidItemId);
@@ -29,28 +59,42 @@ export class PlaidUpdateTransactions {
} = await plaidInstance.accountsGet(request); } = await plaidInstance.accountsGet(request);
const plaidAccountsIds = accounts.map((a) => a.account_id); const plaidAccountsIds = accounts.map((a) => a.account_id);
const { const {
data: { institution }, data: { institution },
} = await plaidInstance.institutionsGetById({ } = await plaidInstance.institutionsGetById({
institution_id: item.institution_id, institution_id: item.institution_id,
country_codes: ['US', 'UK'], country_codes: ['US', 'UK'],
}); });
// Update the DB. // Sync bank accounts.
await this.plaidSync.syncBankAccounts(tenantId, accounts, institution); await this.plaidSync.syncBankAccounts(tenantId, accounts, institution, trx);
// Sync bank account transactions.
await this.plaidSync.syncAccountsTransactions( await this.plaidSync.syncAccountsTransactions(
tenantId, tenantId,
added.concat(modified) added.concat(modified),
trx
);
// Sync removed transactions.
await this.plaidSync.syncRemoveTransactions(tenantId, removed, trx);
// Sync transactions cursor.
await this.plaidSync.syncTransactionsCursor(
tenantId,
plaidItemId,
cursor,
trx
); );
await this.plaidSync.syncRemoveTransactions(tenantId, removed);
await this.plaidSync.syncTransactionsCursor(tenantId, plaidItemId, cursor);
// Update the last feeds updated at of the updated accounts. // Update the last feeds updated at of the updated accounts.
await this.plaidSync.updateLastFeedsUpdatedAt(tenantId, plaidAccountsIds); await this.plaidSync.updateLastFeedsUpdatedAt(
tenantId,
plaidAccountsIds,
trx
);
// Turn on the accounts feeds flag. // Turn on the accounts feeds flag.
await this.plaidSync.updateAccountsFeedsActive(tenantId, plaidAccountsIds); await this.plaidSync.updateAccountsFeedsActive(
tenantId,
plaidAccountsIds,
true,
trx
);
return { return {
addedCount: added.length, addedCount: added.length,
modifiedCount: modified.length, modifiedCount: modified.length,

View File

@@ -42,7 +42,12 @@ export const transformPlaidTrxsToCashflowCreate = R.curry(
): CreateUncategorizedTransactionDTO => { ): CreateUncategorizedTransactionDTO => {
return { return {
date: plaidTranasction.date, date: plaidTranasction.date,
amount: plaidTranasction.amount,
// Plaid: Positive values when money moves out of the account; negative values
// when money moves in. For example, debit card purchases are positive;
// credit card payments, direct deposits, and refunds are negative.
amount: -1 * plaidTranasction.amount,
description: plaidTranasction.name, description: plaidTranasction.name,
payee: plaidTranasction.payment_meta?.payee, payee: plaidTranasction.payment_meta?.payee,
currencyCode: plaidTranasction.iso_currency_code, currencyCode: plaidTranasction.iso_currency_code,

View File

@@ -1,3 +1,4 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { DeleteCashflowTransaction } from './DeleteCashflowTransactionService'; import { DeleteCashflowTransaction } from './DeleteCashflowTransactionService';
import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction'; import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction';
@@ -119,11 +120,13 @@ export class CashflowApplication {
*/ */
public createUncategorizedTransaction( public createUncategorizedTransaction(
tenantId: number, tenantId: number,
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO,
trx?: Knex.Transaction
) { ) {
return this.createUncategorizedTransactionService.create( return this.createUncategorizedTransactionService.create(
tenantId, tenantId,
createUncategorizedTransactionDTO createUncategorizedTransactionDTO,
trx
); );
} }

View File

@@ -12,7 +12,6 @@ import { Knex } from 'knex';
import { transformCategorizeTransToCashflow } from './utils'; import { transformCategorizeTransToCashflow } from './utils';
import { CommandCashflowValidator } from './CommandCasflowValidator'; import { CommandCashflowValidator } from './CommandCasflowValidator';
import NewCashflowTransactionService from './NewCashflowTransactionService'; import NewCashflowTransactionService from './NewCashflowTransactionService';
import { TransferAuthorizationGuaranteeDecision } from 'plaid';
@Service() @Service()
export class CategorizeCashflowTransaction { export class CategorizeCashflowTransaction {

View File

@@ -30,7 +30,8 @@ export class DeleteCashflowTransaction {
*/ */
public deleteCashflowTransaction = async ( public deleteCashflowTransaction = async (
tenantId: number, tenantId: number,
cashflowTransactionId: number cashflowTransactionId: number,
trx?: Knex.Transaction
): Promise<{ oldCashflowTransaction: ICashflowTransaction }> => { ): Promise<{ oldCashflowTransaction: ICashflowTransaction }> => {
const { CashflowTransaction, CashflowTransactionLine } = const { CashflowTransaction, CashflowTransactionLine } =
this.tenancy.models(tenantId); this.tenancy.models(tenantId);
@@ -43,13 +44,18 @@ export class DeleteCashflowTransaction {
this.throwErrorIfTransactionNotFound(oldCashflowTransaction); this.throwErrorIfTransactionNotFound(oldCashflowTransaction);
// Starting database transaction. // Starting database transaction.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
// Triggers `onCashflowTransactionDelete` event. // Triggers `onCashflowTransactionDelete` event.
await this.eventPublisher.emitAsync(events.cashflow.onTransactionDeleting, { await this.eventPublisher.emitAsync(
events.cashflow.onTransactionDeleting,
{
trx, trx,
tenantId, tenantId,
oldCashflowTransaction, oldCashflowTransaction,
} as ICommandCashflowDeletingPayload); } as ICommandCashflowDeletingPayload
);
// Delete cashflow transaction associated lines first. // Delete cashflow transaction associated lines first.
await CashflowTransactionLine.query(trx) await CashflowTransactionLine.query(trx)
@@ -62,15 +68,20 @@ export class DeleteCashflowTransaction {
.delete(); .delete();
// Triggers `onCashflowTransactionDeleted` event. // Triggers `onCashflowTransactionDeleted` event.
await this.eventPublisher.emitAsync(events.cashflow.onTransactionDeleted, { await this.eventPublisher.emitAsync(
events.cashflow.onTransactionDeleted,
{
trx, trx,
tenantId, tenantId,
cashflowTransactionId, cashflowTransactionId,
oldCashflowTransaction, oldCashflowTransaction,
} as ICommandCashflowDeletedPayload); } as ICommandCashflowDeletedPayload
);
return { oldCashflowTransaction }; return { oldCashflowTransaction };
}); },
trx
);
}; };
/** /**

View File

@@ -68,7 +68,11 @@ export const CASHFLOW_TRANSACTION_TYPE_META = {
[`${CASHFLOW_TRANSACTION_TYPE.OTHER_EXPENSE}`]: { [`${CASHFLOW_TRANSACTION_TYPE.OTHER_EXPENSE}`]: {
type: 'OtherExpense', type: 'OtherExpense',
direction: CASHFLOW_DIRECTION.OUT, direction: CASHFLOW_DIRECTION.OUT,
creditType: [ACCOUNT_TYPE.EXPENSE, ACCOUNT_TYPE.OTHER_EXPENSE], creditType: [
ACCOUNT_TYPE.EXPENSE,
ACCOUNT_TYPE.OTHER_EXPENSE,
ACCOUNT_TYPE.COST_OF_GOODS_SOLD,
],
}, },
}; };

View File

@@ -1,4 +1,4 @@
import { upperFirst, camelCase, omit } from 'lodash'; import { upperFirst, camelCase } from 'lodash';
import { import {
CASHFLOW_TRANSACTION_TYPE, CASHFLOW_TRANSACTION_TYPE,
CASHFLOW_TRANSACTION_TYPE_META, CASHFLOW_TRANSACTION_TYPE_META,
@@ -6,7 +6,6 @@ import {
} from './constants'; } from './constants';
import { import {
ICashflowNewCommandDTO, ICashflowNewCommandDTO,
ICashflowTransaction,
ICategorizeCashflowTransactioDTO, ICategorizeCashflowTransactioDTO,
IUncategorizedCashflowTransaction, IUncategorizedCashflowTransaction,
} from '@/interfaces'; } from '@/interfaces';
@@ -62,6 +61,7 @@ export const transformCategorizeTransToCashflow = (
transactionNumber: categorizeDTO.transactionNumber, transactionNumber: categorizeDTO.transactionNumber,
transactionType: categorizeDTO.transactionType, transactionType: categorizeDTO.transactionType,
uncategorizedTransactionId: uncategorizeModel.id, uncategorizedTransactionId: uncategorizeModel.id,
branchId: categorizeDTO?.branchId,
publish: true, publish: true,
}; };
}; };

View File

@@ -1,6 +1,4 @@
import * as R from 'ramda'; import * as R from 'ramda';
import { FinancialPreviousPeriod } from '../FinancialPreviousPeriod';
import { FinancialHorizTotals } from '../FinancialHorizTotals';
import { FinancialSheetStructure } from '../FinancialSheetStructure'; import { FinancialSheetStructure } from '../FinancialSheetStructure';
import { import {
BALANCE_SHEET_SCHEMA_NODE_TYPE, BALANCE_SHEET_SCHEMA_NODE_TYPE,

View File

@@ -3,7 +3,6 @@ import * as R from 'ramda';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { import {
IAccount,
IAccountTransactionsGroupBy, IAccountTransactionsGroupBy,
IBalanceSheetQuery, IBalanceSheetQuery,
ILedger, ILedger,
@@ -12,7 +11,6 @@ import { transformToMapBy } from 'utils';
import Ledger from '@/services/Accounting/Ledger'; import Ledger from '@/services/Accounting/Ledger';
import { BalanceSheetQuery } from './BalanceSheetQuery'; import { BalanceSheetQuery } from './BalanceSheetQuery';
import { FinancialDatePeriods } from '../FinancialDatePeriods'; import { FinancialDatePeriods } from '../FinancialDatePeriods';
import { ACCOUNT_PARENT_TYPE, ACCOUNT_TYPE } from '@/data/AccountTypes';
import { BalanceSheetRepositoryNetIncome } from './BalanceSheetRepositoryNetIncome'; import { BalanceSheetRepositoryNetIncome } from './BalanceSheetRepositoryNetIncome';
@Service() @Service()

View File

@@ -1,29 +1,31 @@
import { isEmpty, get, last, sumBy } from 'lodash'; import { isEmpty, get, last, sumBy, first, head } from 'lodash';
import moment from 'moment';
import * as R from 'ramda';
import { import {
IGeneralLedgerSheetQuery, IGeneralLedgerSheetQuery,
IGeneralLedgerSheetAccount, IGeneralLedgerSheetAccount,
IGeneralLedgerSheetAccountBalance, IGeneralLedgerSheetAccountBalance,
IGeneralLedgerSheetAccountTransaction, IGeneralLedgerSheetAccountTransaction,
IAccount, IAccount,
IJournalPoster, ILedgerEntry,
IJournalEntry,
IContact,
} from '@/interfaces'; } from '@/interfaces';
import FinancialSheet from '../FinancialSheet'; import FinancialSheet from '../FinancialSheet';
import moment from 'moment'; import { GeneralLedgerRepository } from './GeneralLedgerRepository';
import { FinancialSheetStructure } from '../FinancialSheetStructure';
import { flatToNestedArray } from '@/utils';
import Ledger from '@/services/Accounting/Ledger';
import { calculateRunningBalance } from './_utils';
/** /**
* General ledger sheet. * General ledger sheet.
*/ */
export default class GeneralLedgerSheet extends FinancialSheet { export default class GeneralLedgerSheet extends R.compose(
tenantId: number; FinancialSheetStructure
accounts: IAccount[]; )(FinancialSheet) {
query: IGeneralLedgerSheetQuery; private query: IGeneralLedgerSheetQuery;
openingBalancesJournal: IJournalPoster; private baseCurrency: string;
transactions: IJournalPoster; private i18n: any;
contactsMap: Map<number, IContact>; private repository: GeneralLedgerRepository;
baseCurrency: string;
i18n: any;
/** /**
* Constructor method. * Constructor method.
@@ -34,63 +36,59 @@ export default class GeneralLedgerSheet extends FinancialSheet {
* @param {IJournalPoster} closingBalancesJournal - * @param {IJournalPoster} closingBalancesJournal -
*/ */
constructor( constructor(
tenantId: number,
query: IGeneralLedgerSheetQuery, query: IGeneralLedgerSheetQuery,
accounts: IAccount[], repository: GeneralLedgerRepository,
contactsByIdMap: Map<number, IContact>,
transactions: IJournalPoster,
openingBalancesJournal: IJournalPoster,
baseCurrency: string,
i18n i18n
) { ) {
super(); super();
this.tenantId = tenantId;
this.query = query; this.query = query;
this.numberFormat = this.query.numberFormat; this.numberFormat = this.query.numberFormat;
this.accounts = accounts; this.repository = repository;
this.contactsMap = contactsByIdMap; this.baseCurrency = this.repository.tenant.metadata.currencyCode;
this.transactions = transactions;
this.openingBalancesJournal = openingBalancesJournal;
this.baseCurrency = baseCurrency;
this.i18n = i18n; this.i18n = i18n;
} }
/**
* Retrieve the transaction amount.
* @param {number} credit - Credit amount.
* @param {number} debit - Debit amount.
* @param {string} normal - Credit or debit.
*/
getAmount(credit: number, debit: number, normal: string) {
return normal === 'credit' ? credit - debit : debit - credit;
}
/** /**
* Entry mapper. * Entry mapper.
* @param {IJournalEntry} entry - * @param {ILedgerEntry} entry -
* @return {IGeneralLedgerSheetAccountTransaction} * @return {IGeneralLedgerSheetAccountTransaction}
*/ */
entryReducer( private getEntryRunningBalance(
entries: IGeneralLedgerSheetAccountTransaction[], entry: ILedgerEntry,
entry: IJournalEntry, openingBalance: number,
openingBalance: number runningBalance?: number
): IGeneralLedgerSheetAccountTransaction[] { ): number {
const lastEntry = last(entries); const lastRunningBalance = runningBalance || openingBalance;
const contact = this.contactsMap.get(entry.contactId); const amount = Ledger.getAmount(
const amount = this.getAmount(
entry.credit, entry.credit,
entry.debit, entry.debit,
entry.accountNormal entry.accountNormal
); );
const runningBalance = return calculateRunningBalance(amount, lastRunningBalance);
amount + (!isEmpty(entries) ? lastEntry.runningBalance : openingBalance); }
const newEntry = { /**
* Maps the given ledger entry to G/L transaction.
* @param {ILedgerEntry} entry
* @param {number} runningBalance
* @returns {IGeneralLedgerSheetAccountTransaction}
*/
private transactionMapper(
entry: ILedgerEntry,
runningBalance: number
): IGeneralLedgerSheetAccountTransaction {
const contact = this.repository.contactsById.get(entry.contactId);
const amount = Ledger.getAmount(
entry.credit,
entry.debit,
entry.accountNormal
);
return {
id: entry.id,
date: entry.date, date: entry.date,
dateFormatted: moment(entry.date).format('YYYY MMM DD'), dateFormatted: moment(entry.date).format('YYYY MMM DD'),
entryId: entry.id,
transactionNumber: entry.transactionNumber, transactionNumber: entry.transactionNumber,
referenceType: entry.referenceType, referenceType: entry.referenceType,
@@ -109,16 +107,15 @@ export default class GeneralLedgerSheet extends FinancialSheet {
amount, amount,
runningBalance, runningBalance,
formattedAmount: this.formatNumber(amount), formattedAmount: this.formatNumber(amount, { excerptZero: false }),
formattedCredit: this.formatNumber(entry.credit), formattedCredit: this.formatNumber(entry.credit, { excerptZero: false }),
formattedDebit: this.formatNumber(entry.debit), formattedDebit: this.formatNumber(entry.debit, { excerptZero: false }),
formattedRunningBalance: this.formatNumber(runningBalance), formattedRunningBalance: this.formatNumber(runningBalance, {
excerptZero: false,
}),
currencyCode: this.baseCurrency, currencyCode: this.baseCurrency,
}; } as IGeneralLedgerSheetAccountTransaction;
entries.push(newEntry);
return entries;
} }
/** /**
@@ -130,28 +127,48 @@ export default class GeneralLedgerSheet extends FinancialSheet {
account: IAccount, account: IAccount,
openingBalance: number openingBalance: number
): IGeneralLedgerSheetAccountTransaction[] { ): IGeneralLedgerSheetAccountTransaction[] {
const entries = this.transactions.getAccountEntries(account.id); const entries = this.repository.transactionsLedger
.whereAccountId(account.id)
.getEntries();
return entries.reduce( return entries
( .reduce((prev: Array<[number, ILedgerEntry]>, current: ILedgerEntry) => {
entries: IGeneralLedgerSheetAccountTransaction[], const prevEntry = last(prev);
entry: IJournalEntry const prevRunningBalance = head(prevEntry) as number;
) => { const amount = this.getEntryRunningBalance(
return this.entryReducer(entries, entry, openingBalance); current,
}, openingBalance,
[] prevRunningBalance
); );
return [...prev, [amount, current]];
}, [])
.map((entryPair: [number, ILedgerEntry]) => {
const [runningBalance, entry] = entryPair;
return this.transactionMapper(entry, runningBalance);
});
} }
/** /**
* Retrieve account opening balance. * Retrieves the given account opening balance.
* @param {number} accountId
* @returns {number}
*/
private accountOpeningBalance(accountId: number): number {
return this.repository.openingBalanceTransactionsLedger
.whereAccountId(accountId)
.getClosingBalance();
}
/**
* Retrieve the given account opening balance.
* @param {IAccount} account * @param {IAccount} account
* @return {IGeneralLedgerSheetAccountBalance} * @return {IGeneralLedgerSheetAccountBalance}
*/ */
private accountOpeningBalance( private accountOpeningBalanceTotal(
account: IAccount accountId: number
): IGeneralLedgerSheetAccountBalance { ): IGeneralLedgerSheetAccountBalance {
const amount = this.openingBalancesJournal.getAccountBalance(account.id); const amount = this.accountOpeningBalance(accountId);
const formattedAmount = this.formatTotalNumber(amount); const formattedAmount = this.formatTotalNumber(amount);
const currencyCode = this.baseCurrency; const currencyCode = this.baseCurrency;
const date = this.query.fromDate; const date = this.query.fromDate;
@@ -160,15 +177,31 @@ export default class GeneralLedgerSheet extends FinancialSheet {
} }
/** /**
* Retrieve account closing balance. * Retrieves the given account closing balance.
* @param {number} accountId
* @returns {number}
*/
private accountClosingBalance(accountId: number): number {
const openingBalance = this.repository.openingBalanceTransactionsLedger
.whereAccountId(accountId)
.getClosingBalance();
const transactionsBalance = this.repository.transactionsLedger
.whereAccountId(accountId)
.getClosingBalance();
return openingBalance + transactionsBalance;
}
/**
* Retrieves the given account closing balance.
* @param {IAccount} account * @param {IAccount} account
* @return {IGeneralLedgerSheetAccountBalance} * @return {IGeneralLedgerSheetAccountBalance}
*/ */
private accountClosingBalance( private accountClosingBalanceTotal(
openingBalance: number, accountId: number
transactions: IGeneralLedgerSheetAccountTransaction[]
): IGeneralLedgerSheetAccountBalance { ): IGeneralLedgerSheetAccountBalance {
const amount = this.calcClosingBalance(openingBalance, transactions); const amount = this.accountClosingBalance(accountId);
const formattedAmount = this.formatTotalNumber(amount); const formattedAmount = this.formatTotalNumber(amount);
const currencyCode = this.baseCurrency; const currencyCode = this.baseCurrency;
const date = this.query.toDate; const date = this.query.toDate;
@@ -176,31 +209,78 @@ export default class GeneralLedgerSheet extends FinancialSheet {
return { amount, formattedAmount, currencyCode, date }; return { amount, formattedAmount, currencyCode, date };
} }
private calcClosingBalance( /**
openingBalance: number, * Retrieves the given account closing balance with subaccounts.
transactions: IGeneralLedgerSheetAccountTransaction[] * @param {number} accountId
) { * @returns {number}
return openingBalance + sumBy(transactions, (trans) => trans.amount); */
private accountClosingBalanceWithSubaccounts = (
accountId: number
): number => {
const depsAccountsIds =
this.repository.accountsGraph.dependenciesOf(accountId);
const openingBalance = this.repository.openingBalanceTransactionsLedger
.whereAccountsIds([...depsAccountsIds, accountId])
.getClosingBalance();
const transactionsBalanceWithSubAccounts =
this.repository.transactionsLedger
.whereAccountsIds([...depsAccountsIds, accountId])
.getClosingBalance();
const closingBalance = openingBalance + transactionsBalanceWithSubAccounts;
return closingBalance;
};
/**
* Retrieves the closing balance with subaccounts total node.
* @param {number} accountId
* @returns {IGeneralLedgerSheetAccountBalance}
*/
private accountClosingBalanceWithSubaccountsTotal = (
accountId: number
): IGeneralLedgerSheetAccountBalance => {
const amount = this.accountClosingBalanceWithSubaccounts(accountId);
const formattedAmount = this.formatTotalNumber(amount);
const currencyCode = this.baseCurrency;
const date = this.query.toDate;
return { amount, formattedAmount, currencyCode, date };
};
/**
* Detarmines whether the closing balance subaccounts node should be exist.
* @param {number} accountId
* @returns {boolean}
*/
private isAccountNodeIncludesClosingSubaccounts = (accountId: number) => {
// Retrun early if there is no accounts in the filter so
// return closing subaccounts in all cases.
if (isEmpty(this.query.accountsIds)) {
return true;
} }
// Returns true if the given account id includes transactions.
return this.repository.accountNodesIncludeTransactions.includes(accountId);
};
/** /**
* Retreive general ledger accounts sections. * Retreive general ledger accounts sections.
* @param {IAccount} account * @param {IAccount} account
* @return {IGeneralLedgerSheetAccount} * @return {IGeneralLedgerSheetAccount}
*/ */
private accountMapper(account: IAccount): IGeneralLedgerSheetAccount { private accountMapper = (account: IAccount): IGeneralLedgerSheetAccount => {
const openingBalance = this.accountOpeningBalance(account); const openingBalance = this.accountOpeningBalanceTotal(account.id);
const transactions = this.accountTransactionsMapper( const transactions = this.accountTransactionsMapper(
account, account,
openingBalance.amount openingBalance.amount
); );
const closingBalance = this.accountClosingBalance( const closingBalance = this.accountClosingBalanceTotal(account.id);
openingBalance.amount, const closingBalanceSubaccounts =
transactions this.accountClosingBalanceWithSubaccountsTotal(account.id);
);
return { const initialNode = {
id: account.id, id: account.id,
name: account.name, name: account.name,
code: account.code, code: account.code,
@@ -210,34 +290,90 @@ export default class GeneralLedgerSheet extends FinancialSheet {
transactions, transactions,
closingBalance, closingBalance,
}; };
}
return R.compose(
R.when(
() => this.isAccountNodeIncludesClosingSubaccounts(account.id),
R.assoc('closingBalanceSubaccounts', closingBalanceSubaccounts)
)
)(initialNode);
};
/** /**
* Retrieve mapped accounts with general ledger transactions and opeing/closing balance. * Maps over deep nodes to retrieve the G/L account node.
* @param {IAccount[]} accounts
* @returns {IGeneralLedgerSheetAccount[]}
*/
private accountNodesDeepMap = (
accounts: IAccount[]
): IGeneralLedgerSheetAccount[] => {
return this.mapNodesDeep(accounts, this.accountMapper);
};
/**
* Transformes the flatten nodes to nested nodes.
*/
private nestedAccountsNode = (flattenAccounts: IAccount[]): IAccount[] => {
return flatToNestedArray(flattenAccounts, {
id: 'id',
parentId: 'parentAccountId',
});
};
/**
* Filters account nodes.
* @param {IGeneralLedgerSheetAccount[]} nodes
* @returns {IGeneralLedgerSheetAccount[]}
*/
private filterAccountNodesByTransactionsFilter = (
nodes: IGeneralLedgerSheetAccount[]
): IGeneralLedgerSheetAccount[] => {
return this.filterNodesDeep(
nodes,
(account: IGeneralLedgerSheetAccount) =>
!(account.transactions.length === 0 && this.query.noneTransactions)
);
};
/**
* Filters account nodes by the acounts filter.
* @param {IAccount[]} nodes
* @returns {IAccount[]}
*/
private filterAccountNodesByAccountsFilter = (
nodes: IAccount[]
): IAccount[] => {
return this.filterNodesDeep(nodes, (node: IGeneralLedgerSheetAccount) => {
if (R.isEmpty(this.query.accountsIds)) {
return true;
}
// Returns true if the given account id exists in the filter.
return this.repository.accountNodeInclude?.includes(node.id);
});
};
/**
* Retrieves mapped accounts with general ledger transactions and
* opeing/closing balance.
* @param {IAccount[]} accounts - * @param {IAccount[]} accounts -
* @return {IGeneralLedgerSheetAccount[]} * @return {IGeneralLedgerSheetAccount[]}
*/ */
private accountsWalker(accounts: IAccount[]): IGeneralLedgerSheetAccount[] { private accountsWalker(accounts: IAccount[]): IGeneralLedgerSheetAccount[] {
return ( return R.compose(
accounts R.defaultTo([]),
.map((account: IAccount) => this.accountMapper(account)) this.filterAccountNodesByTransactionsFilter,
// Filter general ledger accounts that have no transactions this.accountNodesDeepMap,
// when`noneTransactions` is on. R.defaultTo([]),
.filter( this.filterAccountNodesByAccountsFilter,
(generalLedgerAccount: IGeneralLedgerSheetAccount) => this.nestedAccountsNode
!( )(accounts);
generalLedgerAccount.transactions.length === 0 &&
this.query.noneTransactions
)
)
);
} }
/** /**
* Retrieve general ledger report data. * Retrieves general ledger report data.
* @return {IGeneralLedgerSheetAccount[]} * @return {IGeneralLedgerSheetAccount[]}
*/ */
public reportData(): IGeneralLedgerSheetAccount[] { public reportData(): IGeneralLedgerSheetAccount[] {
return this.accountsWalker(this.accounts); return this.accountsWalker(this.repository.accounts);
} }
} }

View File

@@ -0,0 +1,180 @@
import moment from 'moment';
import * as R from 'ramda';
import {
IAccount,
IAccountTransaction,
IContact,
IGeneralLedgerSheetQuery,
ITenant,
} from '@/interfaces';
import Ledger from '@/services/Accounting/Ledger';
import { transformToMap } from '@/utils';
import { Tenant } from '@/system/models';
import { flatten, isEmpty, uniq } from 'lodash';
export class GeneralLedgerRepository {
public filter: IGeneralLedgerSheetQuery;
public accounts: IAccount[];
public transactions: IAccountTransaction[];
public openingBalanceTransactions: IAccountTransaction[];
public transactionsLedger: Ledger;
public openingBalanceTransactionsLedger: Ledger;
public repositories: any;
public models: any;
public accountsGraph: any;
public contacts: IContact;
public contactsById: Map<number, IContact>;
public tenantId: number;
public tenant: ITenant;
public accountNodesIncludeTransactions: Array<number> = [];
public accountNodeInclude: Array<number> = [];
/**
* Constructor method.
* @param models
* @param repositories
* @param filter
*/
constructor(
repositories: any,
filter: IGeneralLedgerSheetQuery,
tenantId: number
) {
this.filter = filter;
this.repositories = repositories;
this.tenantId = tenantId;
}
/**
* Initialize the G/L report.
*/
public async asyncInitialize() {
await this.initTenant();
await this.initAccounts();
await this.initAccountsGraph();
await this.initContacts();
await this.initAccountsOpeningBalance();
this.initAccountNodesIncludeTransactions();
await this.initTransactions();
this.initAccountNodesIncluded();
}
/**
* Initialize the tenant.
*/
public async initTenant() {
this.tenant = await Tenant.query()
.findById(this.tenantId)
.withGraphFetched('metadata');
}
/**
* Initialize the accounts.
*/
public async initAccounts() {
this.accounts = await this.repositories.accountRepository
.all()
.orderBy('name', 'ASC');
}
/**
* Initialize the accounts graph.
*/
public async initAccountsGraph() {
this.accountsGraph =
await this.repositories.accountRepository.getDependencyGraph();
}
/**
* Initialize the contacts.
*/
public async initContacts() {
this.contacts = await this.repositories.contactRepository.all();
this.contactsById = transformToMap(this.contacts, 'id');
}
/**
* Initialize the G/L transactions from/to the given date.
*/
public async initTransactions() {
this.transactions = await this.repositories.transactionsRepository
.journal({
fromDate: this.filter.fromDate,
toDate: this.filter.toDate,
branchesIds: this.filter.branchesIds,
})
.orderBy('date', 'ASC')
.onBuild((query) => {
if (this.filter.accountsIds?.length > 0) {
query.whereIn('accountId', this.accountNodesIncludeTransactions);
}
});
// Transform array transactions to journal collection.
this.transactionsLedger = Ledger.fromTransactions(this.transactions);
}
/**
* Initialize the G/L accounts opening balance.
*/
public async initAccountsOpeningBalance() {
// Retreive opening balance credit/debit sumation.
this.openingBalanceTransactions =
await this.repositories.transactionsRepository.journal({
toDate: moment(this.filter.fromDate).subtract(1, 'day'),
sumationCreditDebit: true,
branchesIds: this.filter.branchesIds,
});
// Accounts opening transactions.
this.openingBalanceTransactionsLedger = Ledger.fromTransactions(
this.openingBalanceTransactions
);
}
/**
* Initialize the account nodes that should include transactions.
* @returns {void}
*/
public initAccountNodesIncludeTransactions() {
if (isEmpty(this.filter.accountsIds)) {
return;
}
const childrenNodeIds = this.filter.accountsIds?.map(
(accountId: number) => {
return this.accountsGraph.dependenciesOf(accountId);
}
);
const nodeIds = R.concat(this.filter.accountsIds, childrenNodeIds);
this.accountNodesIncludeTransactions = uniq(flatten(nodeIds));
}
/**
* Initialize the account node ids should be included,
* if the filter by acounts is presented.
* @returns {void}
*/
public initAccountNodesIncluded() {
if (isEmpty(this.filter.accountsIds)) {
return;
}
const nodeIds = this.filter.accountsIds.map((accountId) => {
const childrenIds = this.accountsGraph.dependenciesOf(accountId);
const parentIds = this.accountsGraph.dependantsOf(accountId);
return R.concat(childrenIds, parentIds);
});
this.accountNodeInclude = R.compose(
R.uniq,
R.flatten,
R.concat(this.filter.accountsIds)
)(nodeIds);
}
}

View File

@@ -1,18 +1,10 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import moment from 'moment'; import moment from 'moment';
import { ServiceError } from '@/exceptions';
import { difference } from 'lodash';
import { IGeneralLedgerSheetQuery, IGeneralLedgerMeta } from '@/interfaces'; import { IGeneralLedgerSheetQuery, IGeneralLedgerMeta } from '@/interfaces';
import TenancyService from '@/services/Tenancy/TenancyService'; import TenancyService from '@/services/Tenancy/TenancyService';
import Journal from '@/services/Accounting/JournalPoster';
import GeneralLedgerSheet from '@/services/FinancialStatements/GeneralLedger/GeneralLedger'; import GeneralLedgerSheet from '@/services/FinancialStatements/GeneralLedger/GeneralLedger';
import { transformToMap } from 'utils';
import { Tenant } from '@/system/models';
import { GeneralLedgerMeta } from './GeneralLedgerMeta'; import { GeneralLedgerMeta } from './GeneralLedgerMeta';
import { GeneralLedgerRepository } from './GeneralLedgerRepository';
const ERRORS = {
ACCOUNTS_NOT_FOUND: 'ACCOUNTS_NOT_FOUND',
};
@Service() @Service()
export class GeneralLedgerService { export class GeneralLedgerService {
@@ -40,29 +32,13 @@ export class GeneralLedgerService {
}; };
} }
/**
* Validates accounts existance on the storage.
* @param {number} tenantId
* @param {number[]} accountsIds
*/
async validateAccountsExistance(tenantId: number, accountsIds: number[]) {
const { Account } = this.tenancy.models(tenantId);
const storedAccounts = await Account.query().whereIn('id', accountsIds);
const storedAccountsIds = storedAccounts.map((a) => a.id);
if (difference(accountsIds, storedAccountsIds).length > 0) {
throw new ServiceError(ERRORS.ACCOUNTS_NOT_FOUND);
}
}
/** /**
* Retrieve general ledger report statement. * Retrieve general ledger report statement.
* @param {number} tenantId * @param {number} tenantId
* @param {IGeneralLedgerSheetQuery} query * @param {IGeneralLedgerSheetQuery} query
* @return {IGeneralLedgerStatement} * @return {Promise<IGeneralLedgerStatement>}
*/ */
async generalLedger( public async generalLedger(
tenantId: number, tenantId: number,
query: IGeneralLedgerSheetQuery query: IGeneralLedgerSheetQuery
): Promise<{ ): Promise<{
@@ -70,60 +46,24 @@ export class GeneralLedgerService {
query: IGeneralLedgerSheetQuery; query: IGeneralLedgerSheetQuery;
meta: IGeneralLedgerMeta; meta: IGeneralLedgerMeta;
}> { }> {
const { accountRepository, transactionsRepository, contactRepository } = const repositories = this.tenancy.repositories(tenantId);
this.tenancy.repositories(tenantId);
const i18n = this.tenancy.i18n(tenantId); const i18n = this.tenancy.i18n(tenantId);
const tenant = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const filter = { const filter = {
...this.defaultQuery, ...this.defaultQuery,
...query, ...query,
}; };
// Retrieve all accounts with associated type from the storage. const genealLedgerRepository = new GeneralLedgerRepository(
const accounts = await accountRepository.all(); repositories,
const accountsGraph = await accountRepository.getDependencyGraph(); query,
tenantId
// Retrieve all contacts on the storage.
const contacts = await contactRepository.all();
const contactsByIdMap = transformToMap(contacts, 'id');
// Retreive journal transactions from/to the given date.
const transactions = await transactionsRepository.journal({
fromDate: filter.fromDate,
toDate: filter.toDate,
branchesIds: filter.branchesIds,
});
// Retreive opening balance credit/debit sumation.
const openingBalanceTrans = await transactionsRepository.journal({
toDate: moment(filter.fromDate).subtract(1, 'day'),
sumationCreditDebit: true,
branchesIds: filter.branchesIds,
});
// Transform array transactions to journal collection.
const transactionsJournal = Journal.fromTransactions(
transactions,
tenantId,
accountsGraph
);
// Accounts opening transactions.
const openingTransJournal = Journal.fromTransactions(
openingBalanceTrans,
tenantId,
accountsGraph
); );
await genealLedgerRepository.asyncInitialize();
// General ledger report instance. // General ledger report instance.
const generalLedgerInstance = new GeneralLedgerSheet( const generalLedgerInstance = new GeneralLedgerSheet(
tenantId,
filter, filter,
accounts, genealLedgerRepository,
contactsByIdMap,
transactionsJournal,
openingTransJournal,
tenant.metadata.baseCurrency,
i18n i18n
); );
// Retrieve general ledger report data. // Retrieve general ledger report data.

View File

@@ -83,8 +83,8 @@ export class GeneralLedgerTable extends R.compose(
*/ */
private openingBalanceColumnsAccessors(): IColumnMapperMeta[] { private openingBalanceColumnsAccessors(): IColumnMapperMeta[] {
return [ return [
{ key: 'date', value: this.meta.fromDate }, { key: 'date', value: 'Opening Balance' },
{ key: 'account_name', value: 'Opening Balance' }, { key: 'account_name', value: '' },
{ key: 'reference_type', accessor: '_empty_' }, { key: 'reference_type', accessor: '_empty_' },
{ key: 'reference_number', accessor: '_empty_' }, { key: 'reference_number', accessor: '_empty_' },
{ key: 'description', accessor: 'description' }, { key: 'description', accessor: 'description' },
@@ -97,12 +97,15 @@ export class GeneralLedgerTable extends R.compose(
/** /**
* Closing balance row column accessors. * Closing balance row column accessors.
* @param {IGeneralLedgerSheetAccount} account -
* @returns {ITableColumnAccessor[]} * @returns {ITableColumnAccessor[]}
*/ */
private closingBalanceColumnAccessors(): IColumnMapperMeta[] { private closingBalanceColumnAccessors(
account: IGeneralLedgerSheetAccount
): IColumnMapperMeta[] {
return [ return [
{ key: 'date', value: this.meta.toDate }, { key: 'date', value: `Closing balance for ${account.name}` },
{ key: 'account_name', value: 'Closing Balance' }, { key: 'account_name', value: `` },
{ key: 'reference_type', accessor: '_empty_' }, { key: 'reference_type', accessor: '_empty_' },
{ key: 'reference_number', accessor: '_empty_' }, { key: 'reference_number', accessor: '_empty_' },
{ key: 'description', accessor: '_empty_' }, { key: 'description', accessor: '_empty_' },
@@ -113,6 +116,36 @@ export class GeneralLedgerTable extends R.compose(
]; ];
} }
/**
* Closing balance row column accessors.
* @param {IGeneralLedgerSheetAccount} account -
* @returns {ITableColumnAccessor[]}
*/
private closingBalanceWithSubaccountsColumnAccessors(
account: IGeneralLedgerSheetAccount
): IColumnMapperMeta[] {
return [
{
key: 'date',
value: `Closing Balance for ${account.name} with sub-accounts`,
},
{
key: 'account_name',
value: ``,
},
{ key: 'reference_type', accessor: '_empty_' },
{ key: 'reference_number', accessor: '_empty_' },
{ key: 'description', accessor: '_empty_' },
{ key: 'credit', accessor: '_empty_' },
{ key: 'debit', accessor: '_empty_' },
{ key: 'amount', accessor: 'closingBalanceSubaccounts.formattedAmount' },
{
key: 'running_balance',
accessor: 'closingBalanceSubaccounts.formattedAmount',
},
];
}
/** /**
* Retrieves the common table columns. * Retrieves the common table columns.
* @returns {ITableColumn[]} * @returns {ITableColumn[]}
@@ -184,7 +217,22 @@ export class GeneralLedgerTable extends R.compose(
* @returns {ITableRow} * @returns {ITableRow}
*/ */
private closingBalanceMapper = (account: IGeneralLedgerSheetAccount) => { private closingBalanceMapper = (account: IGeneralLedgerSheetAccount) => {
const columns = this.closingBalanceColumnAccessors(); const columns = this.closingBalanceColumnAccessors(account);
const meta = {
rowTypes: [ROW_TYPE.CLOSING_BALANCE],
};
return tableRowMapper(account, columns, meta);
};
/**
* Maps the given account node to opening balance table row.
* @param {IGeneralLedgerSheetAccount} account
* @returns {ITableRow}
*/
private closingBalanceWithSubaccountsMapper = (
account: IGeneralLedgerSheetAccount
): ITableRow => {
const columns = this.closingBalanceWithSubaccountsColumnAccessors(account);
const meta = { const meta = {
rowTypes: [ROW_TYPE.CLOSING_BALANCE], rowTypes: [ROW_TYPE.CLOSING_BALANCE],
}; };
@@ -221,8 +269,27 @@ export class GeneralLedgerTable extends R.compose(
rowTypes: [ROW_TYPE.ACCOUNT], rowTypes: [ROW_TYPE.ACCOUNT],
}; };
const row = tableRowMapper(account, columns, meta); const row = tableRowMapper(account, columns, meta);
const closingBalanceWithSubaccounts =
this.closingBalanceWithSubaccountsMapper(account);
return R.assoc('children', transactions)(row); // Appends the closing balance with sub-accounts row if the account
// has children accounts and the node is define.
const isAppendClosingSubaccounts = () =>
account.children?.length > 0 && !!account.closingBalanceSubaccounts;
const children = R.compose(
R.when(
isAppendClosingSubaccounts,
R.append(closingBalanceWithSubaccounts)
),
R.concat(R.defaultTo([], transactions)),
R.when(
() => account?.children?.length > 0,
R.concat(R.defaultTo([], account.children))
)
)([]);
return R.assoc('children', children)(row);
}; };
/** /**
@@ -233,7 +300,7 @@ export class GeneralLedgerTable extends R.compose(
private accountsMapper = ( private accountsMapper = (
accounts: IGeneralLedgerSheetAccount[] accounts: IGeneralLedgerSheetAccount[]
): ITableRow[] => { ): ITableRow[] => {
return this.mapNodesDeep(accounts, this.accountMapper); return this.mapNodesDeepReverse(accounts, this.accountMapper);
}; };
/** /**
@@ -250,7 +317,6 @@ export class GeneralLedgerTable extends R.compose(
*/ */
public tableColumns(): ITableColumn[] { public tableColumns(): ITableColumn[] {
const columns = this.commonColumns(); const columns = this.commonColumns();
return R.compose(this.tableColumnsCellIndexing)(columns); return R.compose(this.tableColumnsCellIndexing)(columns);
} }
} }

View File

@@ -0,0 +1,13 @@
/**
* Calculate the running balance.
* @param {number} amount - Transaction amount.
* @param {number} lastRunningBalance - Last running balance.
* @param {number} openingBalance - Opening balance.
* @return {number} Running balance.
*/
export function calculateRunningBalance(
amount: number,
lastRunningBalance: number
): number {
return amount + lastRunningBalance;
}

View File

@@ -108,17 +108,28 @@ export default class ResourceService {
const $hasFields = (field) => const $hasFields = (field) =>
'undefined' !== typeof field.fields ? field : undefined; 'undefined' !== typeof field.fields ? field : undefined;
const $hasColumns = (column) => const $ColumnHasColumns = (column) =>
'undefined' !== typeof column.columns ? column : undefined; 'undefined' !== typeof column.columns ? column : undefined;
const $hasColumns = (columns) =>
'undefined' !== typeof columns ? columns : undefined;
const naviagations = [ const naviagations = [
['fields', qim.$each, 'name'], ['fields', qim.$each, 'name'],
['fields', qim.$each, $enumerationType, 'options', qim.$each, 'label'], ['fields', qim.$each, $enumerationType, 'options', qim.$each, 'label'],
['fields2', qim.$each, 'name'], ['fields2', qim.$each, 'name'],
['fields2', qim.$each, $enumerationType, 'options', qim.$each, 'label'], ['fields2', qim.$each, $enumerationType, 'options', qim.$each, 'label'],
['fields2', qim.$each, $hasFields, 'fields', qim.$each, 'name'], ['fields2', qim.$each, $hasFields, 'fields', qim.$each, 'name'],
['columns', qim.$each, 'name'], ['columns', $hasColumns, qim.$each, 'name'],
['columns', qim.$each, $hasColumns, 'columns', qim.$each, 'name'], [
'columns',
$hasColumns,
qim.$each,
$ColumnHasColumns,
'columns',
qim.$each,
'name',
],
]; ];
return this.i18nService.i18nApply(naviagations, meta, tenantId); return this.i18nService.i18nApply(naviagations, meta, tenantId);
} }

View File

@@ -0,0 +1,18 @@
// @ts-nocheck
import { FSuggest } from '../Forms';
interface BranchSuggestFieldProps {
items: any[];
}
export function BranchSuggestField({ ...props }: BranchSuggestFieldProps) {
return (
<FSuggest
valueAccessor={'id'}
labelAccessor={'code'}
textAccessor={'name'}
inputProps={{ placeholder: 'Select a branch' }}
{...props}
/>
);
}

View File

@@ -1,5 +1,6 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React, { useMemo } from 'react';
import { first } from 'lodash';
import { DrawerHeaderContent, DrawerLoading } from '@/components'; import { DrawerHeaderContent, DrawerLoading } from '@/components';
import { DRAWERS } from '@/constants/drawers'; import { DRAWERS } from '@/constants/drawers';
import { import {
@@ -34,6 +35,12 @@ function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) {
isLoading: isUncategorizedTransactionLoading, isLoading: isUncategorizedTransactionLoading,
} = useUncategorizedTransaction(uncategorizedTransactionId); } = useUncategorizedTransaction(uncategorizedTransactionId);
// Retrieves the primary branch.
const primaryBranch = useMemo(
() => branches?.find((b) => b.primary) || first(branches),
[branches],
);
const provider = { const provider = {
uncategorizedTransactionId, uncategorizedTransactionId,
uncategorizedTransaction, uncategorizedTransaction,
@@ -42,6 +49,7 @@ function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) {
accounts, accounts,
isBranchesLoading, isBranchesLoading,
isAccountsLoading, isAccountsLoading,
primaryBranch,
}; };
const isLoading = const isLoading =
isBranchesLoading || isUncategorizedTransactionLoading || isAccountsLoading; isBranchesLoading || isUncategorizedTransactionLoading || isAccountsLoading;

View File

@@ -0,0 +1,22 @@
// @ts-nocheck
import { FFormGroup, FeatureCan } from '@/components';
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
import { Features } from '@/constants';
import { BranchSuggestField } from '@/components/Branches/BranchSuggestField_';
export function CategorizeTransactionBranchField() {
const { branches } = useCategorizeTransactionBoot();
return (
<FFormGroup name={'branchId'} label={'Branch'} fastField inline>
<FeatureCan feature={Features.Branches}>
<BranchSuggestField
name={'branchId'}
items={branches}
popoverProps={{ minimal: true }}
fill
/>
</FeatureCan>
</FFormGroup>
);
}

View File

@@ -24,8 +24,11 @@ function CategorizeTransactionFormRoot({
// #withDrawerActions // #withDrawerActions
closeDrawer, closeDrawer,
}) { }) {
const { uncategorizedTransactionId, uncategorizedTransaction } = const {
useCategorizeTransactionBoot(); uncategorizedTransactionId,
uncategorizedTransaction,
primaryBranch,
} = useCategorizeTransactionBoot();
const { mutateAsync: categorizeTransaction } = useCategorizeTransaction(); const { mutateAsync: categorizeTransaction } = useCategorizeTransaction();
// Callbacks handles form submit. // Callbacks handles form submit.
@@ -43,12 +46,22 @@ function CategorizeTransactionFormRoot({
intent: Intent.SUCCESS, intent: Intent.SUCCESS,
}); });
}) })
.catch(() => { .catch((err) => {
setSubmitting(false); setSubmitting(false);
if (
err.response.data?.errors?.some(
(e) => e.type === 'BRANCH_ID_REQUIRED',
)
) {
setErrors({
branchId: 'The branch is required.',
});
} else {
AppToaster.show({ AppToaster.show({
message: 'Something went wrong!', message: 'Something went wrong!',
intent: Intent.DANGER, intent: Intent.DANGER,
}); });
}
}); });
}; };
// Form initial values in create and edit mode. // Form initial values in create and edit mode.
@@ -60,6 +73,9 @@ function CategorizeTransactionFormRoot({
* as well. * as well.
*/ */
...transformToCategorizeForm(uncategorizedTransaction), ...transformToCategorizeForm(uncategorizedTransaction),
/** Assign the primary branch id as default value. */
branchId: primaryBranch?.id || null,
}; };
return ( return (

View File

@@ -8,6 +8,7 @@ import {
FTextArea, FTextArea,
} from '@/components'; } from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
export default function CategorizeTransactionOtherIncome() { export default function CategorizeTransactionOtherIncome() {
const { accounts } = useCategorizeTransactionBoot(); const { accounts } = useCategorizeTransactionBoot();
@@ -68,6 +69,8 @@ export default function CategorizeTransactionOtherIncome() {
fill={true} fill={true}
/> />
</FFormGroup> </FFormGroup>
<CategorizeTransactionBranchField />
</> </>
); );
} }

View File

@@ -8,6 +8,7 @@ import {
FTextArea, FTextArea,
} from '@/components'; } from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
export default function CategorizeTransactionOwnerContribution() { export default function CategorizeTransactionOwnerContribution() {
const { accounts } = useCategorizeTransactionBoot(); const { accounts } = useCategorizeTransactionBoot();
@@ -63,6 +64,8 @@ export default function CategorizeTransactionOwnerContribution() {
<FFormGroup name={'description'} label={'Description'} fastField inline> <FFormGroup name={'description'} label={'Description'} fastField inline>
<FTextArea name={'description'} growVertically large fill /> <FTextArea name={'description'} growVertically large fill />
</FFormGroup> </FFormGroup>
<CategorizeTransactionBranchField />
</> </>
); );
} }

View File

@@ -8,6 +8,7 @@ import {
FTextArea, FTextArea,
} from '@/components'; } from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
export default function CategorizeTransactionTransferFrom() { export default function CategorizeTransactionTransferFrom() {
const { accounts } = useCategorizeTransactionBoot(); const { accounts } = useCategorizeTransactionBoot();
@@ -47,7 +48,7 @@ export default function CategorizeTransactionTransferFrom() {
inline inline
> >
<AccountsSelect <AccountsSelect
name={'to_account_id'} name={'creditAccountId'}
items={accounts} items={accounts}
filterByRootTypes={['asset']} filterByRootTypes={['asset']}
fastField fastField
@@ -68,6 +69,8 @@ export default function CategorizeTransactionTransferFrom() {
fill={true} fill={true}
/> />
</FFormGroup> </FFormGroup>
<CategorizeTransactionBranchField />
</> </>
); );
} }

View File

@@ -8,6 +8,7 @@ import {
FTextArea, FTextArea,
} from '@/components'; } from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
export default function CategorizeTransactionOtherExpense() { export default function CategorizeTransactionOtherExpense() {
const { accounts } = useCategorizeTransactionBoot(); const { accounts } = useCategorizeTransactionBoot();
@@ -68,6 +69,8 @@ export default function CategorizeTransactionOtherExpense() {
fill={true} fill={true}
/> />
</FFormGroup> </FFormGroup>
<CategorizeTransactionBranchField />
</> </>
); );
} }

View File

@@ -8,6 +8,7 @@ import {
FTextArea, FTextArea,
} from '@/components'; } from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
export default function CategorizeTransactionOwnerDrawings() { export default function CategorizeTransactionOwnerDrawings() {
const { accounts } = useCategorizeTransactionBoot(); const { accounts } = useCategorizeTransactionBoot();
@@ -68,6 +69,8 @@ export default function CategorizeTransactionOwnerDrawings() {
fill={true} fill={true}
/> />
</FFormGroup> </FFormGroup>
<CategorizeTransactionBranchField />
</> </>
); );
} }

View File

@@ -8,6 +8,7 @@ import {
FTextArea, FTextArea,
} from '@/components'; } from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
export default function CategorizeTransactionToAccount() { export default function CategorizeTransactionToAccount() {
const { accounts } = useCategorizeTransactionBoot(); const { accounts } = useCategorizeTransactionBoot();
@@ -49,7 +50,7 @@ export default function CategorizeTransactionToAccount() {
<AccountsSelect <AccountsSelect
name={'creditAccountId'} name={'creditAccountId'}
items={accounts} items={accounts}
filterByRootTypes={['assset']} filterByRootTypes={['asset']}
fastField fastField
fill fill
allowCreate allowCreate
@@ -68,6 +69,8 @@ export default function CategorizeTransactionToAccount() {
fill={true} fill={true}
/> />
</FFormGroup> </FFormGroup>
<CategorizeTransactionBranchField />
</> </>
); );
} }

View File

@@ -11,6 +11,7 @@ export const defaultInitialValues = {
transactionType: '', transactionType: '',
referenceNo: '', referenceNo: '',
description: '', description: '',
branchId: '',
}; };
export const transformToCategorizeForm = (uncategorizedTransaction) => { export const transformToCategorizeForm = (uncategorizedTransaction) => {

View File

@@ -96,12 +96,19 @@ const GeneralLedgerDataTable = styled(ReportDataTable)`
} }
} }
} }
&:not(:first-child).is-expanded .td {
border-top: 1px solid #ddd;
}
} }
&--OPENING_BALANCE, &--OPENING_BALANCE,
&--CLOSING_BALANCE { &--CLOSING_BALANCE {
.td {
color: #000;
}
.date {
font-weight: 500;
.cell-inner {
position: absolute;
}
}
.amount { .amount {
font-weight: 500; font-weight: 500;
} }
@@ -110,6 +117,9 @@ const GeneralLedgerDataTable = styled(ReportDataTable)`
.name { .name {
font-weight: 500; font-weight: 500;
} }
.td {
border-top: 1px solid #ddd;
}
} }
} }
} }

View File

@@ -26,7 +26,7 @@ function SubscriptionPricing({
useGetLemonSqueezyCheckout(); useGetLemonSqueezyCheckout();
const handleClick = () => { const handleClick = () => {
getLemonCheckout({ variantId: '337977' }) getLemonCheckout({ variantId: '338516' })
.then((res) => { .then((res) => {
const checkoutUrl = res.data.data.attributes.url; const checkoutUrl = res.data.data.attributes.url;
window.LemonSqueezy.Url.Open(checkoutUrl); window.LemonSqueezy.Url.Open(checkoutUrl);

10
pnpm-lock.yaml generated
View File

@@ -50,6 +50,9 @@ importers:
'@lemonsqueezy/lemonsqueezy.js': '@lemonsqueezy/lemonsqueezy.js':
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0 version: 2.2.0
'@supercharge/promise-pool':
specifier: ^3.2.0
version: 3.2.0
'@types/express': '@types/express':
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
@@ -5751,6 +5754,11 @@ packages:
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
dev: false dev: false
/@supercharge/promise-pool@3.2.0:
resolution: {integrity: sha512-pj0cAALblTZBPtMltWOlZTQSLT07jIaFNeM8TWoJD1cQMgDB9mcMlVMoetiB35OzNJpqQ2b+QEtwiR9f20mADg==}
engines: {node: '>=8'}
dev: false
/@surma/rollup-plugin-off-main-thread@2.2.3: /@surma/rollup-plugin-off-main-thread@2.2.3:
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
dependencies: dependencies:
@@ -17382,6 +17390,7 @@ packages:
/memory-pager@1.5.0: /memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
requiresBuild: true
dev: false dev: false
/memorystream@0.3.1: /memorystream@0.3.1:
@@ -23472,6 +23481,7 @@ packages:
/sparse-bitfield@3.0.3: /sparse-bitfield@3.0.3:
resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==}
requiresBuild: true
dependencies: dependencies:
memory-pager: 1.5.0 memory-pager: 1.5.0
dev: false dev: false