Compare commits
38 Commits
print-reso
...
v0.17.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f61823b3 | ||
|
|
1cbc1c056f | ||
|
|
4d4ef54c56 | ||
|
|
f7fcfefc78 | ||
|
|
858f347fd4 | ||
|
|
4d73b59cf3 | ||
|
|
bc67f0cca8 | ||
|
|
ef2d1ff141 | ||
|
|
dc4cdb2a8f | ||
|
|
8862810706 | ||
|
|
3dadbeac4d | ||
|
|
494d2c1fe0 | ||
|
|
d27562bd43 | ||
|
|
8b99e0938d | ||
|
|
94192bfc29 | ||
|
|
708a4dda9e | ||
|
|
10fcf94c92 | ||
|
|
fc9995c4da | ||
|
|
7dc769004d | ||
|
|
5dbfd36415 | ||
|
|
044f11ff74 | ||
|
|
6afe1a09c6 | ||
|
|
909a70e2c5 | ||
|
|
84dd0fa86b | ||
|
|
a4719fe15b | ||
|
|
fd915b503f | ||
|
|
bbba54c08e | ||
|
|
f241e2bede | ||
|
|
175bc243f3 | ||
|
|
7c06c8bb8a | ||
|
|
8fd930caac | ||
|
|
e175307da4 | ||
|
|
b1bf932e88 | ||
|
|
aa897212ab | ||
|
|
890903e08b | ||
|
|
16b2a33cf6 | ||
|
|
382d4ab028 | ||
|
|
85f26e1079 |
23
.env.example
23
.env.example
@@ -75,30 +75,9 @@ PLAID_ENV=sandbox
|
||||
# Your Plaid keys, which can be found in the Plaid Dashboard.
|
||||
# https://dashboard.plaid.com/account/keys
|
||||
PLAID_CLIENT_ID=
|
||||
PLAID_SECRET_DEVELOPMENT=
|
||||
PLAID_SECRET_SANDBOX=
|
||||
|
||||
PLAID_SECRET=
|
||||
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
|
||||
LEMONSQUEEZY_API_KEY=
|
||||
LEMONSQUEEZY_STORE_ID=
|
||||
|
||||
127
.github/workflows/build-deploy-develop-container.yaml
vendored
Normal file
127
.github/workflows/build-deploy-develop-container.yaml
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
# This workflow will build a docker container, publish it to Github Registry.
|
||||
name: Build and Deploy Develop Docker Container
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
env:
|
||||
WEBAPP_IMAGE_NAME: bigcapitalhq/webapp
|
||||
SERVER_IMAGE_NAME: bigcapitalhq/server
|
||||
|
||||
jobs:
|
||||
build-publish-webapp:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
name: Build and deploy webapp container
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login to Container registry.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
||||
with:
|
||||
images: ${{ env.WEBAPP_IMAGE_NAME }}
|
||||
|
||||
# Builds and push the Docker image.
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: ./
|
||||
file: ./packages/webapp/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: bigcapitalhq/webapp:develop
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-webapp
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
# Send notification to Slack channel.
|
||||
- name: Slack Notification built and published webapp container successfully.
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
|
||||
build-publish-server:
|
||||
name: Build and deploy server container
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login to Container registry.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# Builds and push the Docker image.
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: ./
|
||||
file: ./packages/server/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: bigcapitalhq/server:develop
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-server
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Send notification to Slack channel.
|
||||
- name: Slack Notification built and published server container successfully.
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -2,6 +2,41 @@
|
||||
|
||||
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
|
||||
|
||||
### improvements
|
||||
|
||||
@@ -22,11 +22,15 @@ services:
|
||||
- server
|
||||
- webapp
|
||||
restart: on-failure
|
||||
networks:
|
||||
- bigcapital_network
|
||||
|
||||
webapp:
|
||||
container_name: bigcapital-webapp
|
||||
image: bigcapitalhq/webapp:latest
|
||||
restart: on-failure
|
||||
networks:
|
||||
- bigcapital_network
|
||||
|
||||
server:
|
||||
container_name: bigcapital-server
|
||||
@@ -89,14 +93,17 @@ services:
|
||||
- GOTENBERG_URL=${GOTENBERG_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
|
||||
- BANKING_CONNECT=${BANKING_CONNECT}
|
||||
|
||||
# Plaid
|
||||
- PLAID_ENV=${PLAID_ENV}
|
||||
- PLAID_CLIENT_ID=${PLAID_CLIENT_ID}
|
||||
- PLAID_SECRET_DEVELOPMENT=${PLAID_SECRET_DEVELOPMENT}
|
||||
- PLAID_SECRET_SANDBOX=${b8cf42b441e110451e2f69ad7e1e9f}
|
||||
- PLAID_SECRET=${PLAID_SECRET}
|
||||
- PLAID_LINK_WEBHOOK=${PLAID_LINK_WEBHOOK}
|
||||
|
||||
# Lemon Squeez
|
||||
@@ -114,6 +121,15 @@ services:
|
||||
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY}
|
||||
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME}
|
||||
|
||||
# S3
|
||||
- S3_REGION=${S3_REGION}
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_BUCKET=${S3_BUCKET}
|
||||
networks:
|
||||
- bigcapital_network
|
||||
|
||||
database_migration:
|
||||
container_name: bigcapital-database-migration
|
||||
build:
|
||||
@@ -130,6 +146,8 @@ services:
|
||||
- TENANT_DB_NAME_PERFIX=${TENANT_DB_NAME_PERFIX}
|
||||
depends_on:
|
||||
- mysql
|
||||
networks:
|
||||
- bigcapital_network
|
||||
|
||||
mysql:
|
||||
container_name: bigcapital-mysql
|
||||
@@ -145,6 +163,8 @@ services:
|
||||
- mysql:/var/lib/mysql
|
||||
expose:
|
||||
- '3306'
|
||||
networks:
|
||||
- bigcapital_network
|
||||
|
||||
mongo:
|
||||
container_name: bigcapital-mongo
|
||||
@@ -154,6 +174,8 @@ services:
|
||||
- '27017'
|
||||
volumes:
|
||||
- mongo:/var/lib/mongodb
|
||||
networks:
|
||||
- bigcapital_network
|
||||
|
||||
redis:
|
||||
container_name: bigcapital-redis
|
||||
@@ -164,11 +186,15 @@ services:
|
||||
- '6379'
|
||||
volumes:
|
||||
- redis:/data
|
||||
networks:
|
||||
- bigcapital_network
|
||||
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:7
|
||||
expose:
|
||||
- '9000'
|
||||
networks:
|
||||
- bigcapital_network
|
||||
|
||||
# Volumes
|
||||
volumes:
|
||||
@@ -183,3 +209,8 @@ volumes:
|
||||
redis:
|
||||
name: bigcapital_prod_redis
|
||||
driver: local
|
||||
|
||||
# Networks
|
||||
networks:
|
||||
bigcapital_network:
|
||||
driver: bridge
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@casl/ability": "^5.4.3",
|
||||
"@hapi/boom": "^7.4.3",
|
||||
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
|
||||
"@supercharge/promise-pool": "^3.2.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/i18n": "^0.8.7",
|
||||
"@types/knex": "^0.16.1",
|
||||
|
||||
@@ -4,12 +4,16 @@ import { Router, Response, NextFunction, Request } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import BaseController from '@/api/controllers/BaseController';
|
||||
import { AttachmentsApplication } from '@/services/Attachments/AttachmentsApplication';
|
||||
import { AttachmentUploadPipeline } from '@/services/Attachments/S3UploadPipeline';
|
||||
|
||||
@Service()
|
||||
export class AttachmentsController extends BaseController {
|
||||
@Inject()
|
||||
private attachmentsApplication: AttachmentsApplication;
|
||||
|
||||
@Inject()
|
||||
private uploadPipelineService: AttachmentUploadPipeline;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
@@ -18,7 +22,8 @@ export class AttachmentsController extends BaseController {
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
this.attachmentsApplication.uploadPipeline.single('file'),
|
||||
this.uploadPipelineService.validateS3Configured,
|
||||
this.uploadPipelineService.uploadPipeline().single('file'),
|
||||
this.validateUploadedFileExistance,
|
||||
this.uploadAttachment.bind(this)
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication';
|
||||
import { Request, Response } from 'express';
|
||||
import { NextFunction, Router, Request, Response } from 'express';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication';
|
||||
import BaseController from '../BaseController';
|
||||
import { LemonSqueezyWebhooks } from '@/services/Subscription/LemonSqueezyWebhooks';
|
||||
import { PlaidWebhookTenantBootMiddleware } from '@/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware';
|
||||
@@ -34,7 +33,7 @@ export class Webhooks extends BaseController {
|
||||
* @param {Response} res
|
||||
* @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 signature = req.headers['x-signature'] ?? '';
|
||||
const rawBody = req.rawBody;
|
||||
@@ -57,20 +56,25 @@ export class Webhooks extends BaseController {
|
||||
* @param {Response} res
|
||||
* @returns {Response}
|
||||
*/
|
||||
public async plaidWebhooks(req: Request, res: Response) {
|
||||
public async plaidWebhooks(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const {
|
||||
webhook_type: webhookType,
|
||||
webhook_code: webhookCode,
|
||||
item_id: plaidItemId,
|
||||
} = req.body;
|
||||
|
||||
await this.plaidApp.webhooks(
|
||||
tenantId,
|
||||
plaidItemId,
|
||||
webhookType,
|
||||
webhookCode
|
||||
);
|
||||
return res.status(200).send({ code: 200, message: 'ok' });
|
||||
try {
|
||||
const {
|
||||
webhook_type: webhookType,
|
||||
webhook_code: webhookCode,
|
||||
item_id: plaidItemId,
|
||||
} = req.body;
|
||||
|
||||
await this.plaidApp.webhooks(
|
||||
tenantId,
|
||||
plaidItemId,
|
||||
webhookType,
|
||||
webhookCode
|
||||
);
|
||||
return res.status(200).send({ code: 200, message: 'ok' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import color from 'colorette';
|
||||
import argv from 'getopts';
|
||||
import Knex from 'knex';
|
||||
import { knexSnakeCaseMappers } from 'objection';
|
||||
import { PromisePool } from '@supercharge/promise-pool';
|
||||
import '../before';
|
||||
import config from '../config';
|
||||
|
||||
@@ -28,7 +29,7 @@ function initSystemKnex() {
|
||||
});
|
||||
}
|
||||
|
||||
function initTenantKnex(organizationId) {
|
||||
function initTenantKnex(organizationId: string = '') {
|
||||
return Knex({
|
||||
client: config.tenant.db_client,
|
||||
connection: {
|
||||
@@ -71,6 +72,12 @@ function getAllSystemTenants(knex) {
|
||||
return knex('tenants');
|
||||
}
|
||||
|
||||
function getAllInitializedTenants(knex) {
|
||||
return knex('tenants').whereNotNull('initializedAt');
|
||||
}
|
||||
|
||||
const MIGRATION_CONCURRENCY = 10;
|
||||
|
||||
// module.exports = {
|
||||
// log,
|
||||
// success,
|
||||
@@ -87,6 +94,7 @@ function getAllSystemTenants(knex) {
|
||||
// - bigcapital tenants:migrate:make
|
||||
// - bigcapital system:migrate:make
|
||||
// - bigcapital tenants:list
|
||||
// - bigcapital tenants:list --all
|
||||
|
||||
commander
|
||||
.command('system:migrate:rollback')
|
||||
@@ -145,10 +153,13 @@ commander
|
||||
commander
|
||||
.command('tenants:list')
|
||||
.description('Retrieve a list of all system tenants databases.')
|
||||
.option('-a, --all', 'All tenants even are not initialized.')
|
||||
.action(async (cmd) => {
|
||||
try {
|
||||
const sysKnex = await initSystemKnex();
|
||||
const tenants = await getAllSystemTenants(sysKnex);
|
||||
const tenants = cmd?.all
|
||||
? await getAllSystemTenants(sysKnex)
|
||||
: await getAllInitializedTenants(sysKnex);
|
||||
|
||||
tenants.forEach((tenant) => {
|
||||
const dbName = `${config.tenant.db_name_prefix}${tenant.organizationId}`;
|
||||
@@ -179,18 +190,20 @@ commander
|
||||
commander
|
||||
.command('tenants:migrate:latest')
|
||||
.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) => {
|
||||
try {
|
||||
const sysKnex = await initSystemKnex();
|
||||
const tenants = await getAllSystemTenants(sysKnex);
|
||||
const tenants = await getAllInitializedTenants(sysKnex);
|
||||
const tenantsOrgsIds = tenants.map((tenant) => tenant.organizationId);
|
||||
|
||||
if (cmd.tenant_id && tenantsOrgsIds.indexOf(cmd.tenant_id) === -1) {
|
||||
exit(`The given tenant id ${cmd.tenant_id} is not exists.`);
|
||||
}
|
||||
// Validate the tenant id exist first of all.
|
||||
const migrateOpers = [];
|
||||
const migrateTenant = async (organizationId) => {
|
||||
try {
|
||||
const tenantKnex = await initTenantKnex(organizationId);
|
||||
@@ -212,18 +225,17 @@ commander
|
||||
}
|
||||
};
|
||||
if (!cmd.tenant_id) {
|
||||
tenants.forEach((tenant) => {
|
||||
const oper = migrateTenant(tenant.organizationId);
|
||||
migrateOpers.push(oper);
|
||||
});
|
||||
await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
|
||||
.for(tenants)
|
||||
.process((tenant, index, pool) => {
|
||||
return migrateTenant(tenant.organizationId);
|
||||
})
|
||||
.then(() => {
|
||||
success('All tenants are migrated.');
|
||||
});
|
||||
} else {
|
||||
const oper = migrateTenant(cmd.tenant_id);
|
||||
migrateOpers.push(oper);
|
||||
await migrateTenant(cmd.tenant_id);
|
||||
}
|
||||
|
||||
Promise.all(migrateOpers).then(() => {
|
||||
success('All tenants are migrated.');
|
||||
});
|
||||
} catch (error) {
|
||||
exit(error);
|
||||
}
|
||||
@@ -232,19 +244,21 @@ commander
|
||||
commander
|
||||
.command('tenants:migrate:rollback')
|
||||
.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) => {
|
||||
try {
|
||||
const sysKnex = await initSystemKnex();
|
||||
const tenants = await getAllSystemTenants(sysKnex);
|
||||
const tenants = await getAllInitializedTenants(sysKnex);
|
||||
const tenantsOrgsIds = tenants.map((tenant) => tenant.organizationId);
|
||||
|
||||
if (cmd.tenant_id && tenantsOrgsIds.indexOf(cmd.tenant_id) === -1) {
|
||||
exit(`The given tenant id ${cmd.tenant_id} is not exists.`);
|
||||
}
|
||||
|
||||
const migrateOpers = [];
|
||||
const migrateTenant = async (organizationId) => {
|
||||
const migrateTenant = async (organizationId: string) => {
|
||||
try {
|
||||
const tenantKnex = await initTenantKnex(organizationId);
|
||||
const [batchNo, _log] = await tenantKnex.migrate.rollback();
|
||||
@@ -265,19 +279,18 @@ commander
|
||||
};
|
||||
|
||||
if (!cmd.tenant_id) {
|
||||
tenants.forEach((tenant) => {
|
||||
const oper = migrateTenant(tenant.organizationId);
|
||||
migrateOpers.push(oper);
|
||||
});
|
||||
await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
|
||||
.for(tenants)
|
||||
.process((tenant, index, pool) => {
|
||||
return migrateTenant(tenant.organizationId);
|
||||
})
|
||||
.then(() => {
|
||||
success('All tenants are rollbacked.');
|
||||
});
|
||||
} else {
|
||||
const oper = migrateTenant(cmd.tenant_id);
|
||||
migrateOpers.push(oper);
|
||||
await migrateTenant(cmd.tenant_id);
|
||||
}
|
||||
Promise.all(migrateOpers).then(() => {
|
||||
success('All tenants are rollbacked.');
|
||||
});
|
||||
} catch (error) {
|
||||
exit(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -204,10 +204,7 @@ module.exports = {
|
||||
plaid: {
|
||||
env: process.env.PLAID_ENV || 'sandbox',
|
||||
clientId: process.env.PLAID_CLIENT_ID,
|
||||
secretDevelopment: process.env.PLAID_SECRET_DEVELOPMENT,
|
||||
secretSandbox: process.env.PLAID_SECRET_SANDBOX,
|
||||
redirectSandBox: process.env.PLAID_SANDBOX_REDIRECT_URI,
|
||||
redirectDevelopment: process.env.PLAID_DEVELOPMENT_REDIRECT_URI,
|
||||
secret: process.env.PLAID_SECRET,
|
||||
linkWebhook: process.env.PLAID_LINK_WEBHOOK,
|
||||
},
|
||||
|
||||
@@ -218,6 +215,7 @@ module.exports = {
|
||||
key: process.env.LEMONSQUEEZY_API_KEY,
|
||||
storeId: process.env.LEMONSQUEEZY_STORE_ID,
|
||||
webhookSecret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET,
|
||||
redirectTo: `${process.env.BASE_URL}/setup`,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -237,6 +235,6 @@ module.exports = {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
bucket: process.env.S3_BUCKET,
|
||||
bucket: process.env.S3_BUCKET || 'bigcapital-documents',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.dropTableIfExists('storage');
|
||||
};
|
||||
|
||||
exports.down = function (knex) {};
|
||||
@@ -164,3 +164,7 @@ export enum TaxRateAction {
|
||||
DELETE = 'Delete',
|
||||
VIEW = 'View',
|
||||
}
|
||||
|
||||
export interface CreateAccountParams {
|
||||
ignoreUniqueName: boolean;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface IGeneralLedgerSheetAccount {
|
||||
transactions: IGeneralLedgerSheetAccountTransaction[];
|
||||
openingBalance: IGeneralLedgerSheetAccountBalance;
|
||||
closingBalance: IGeneralLedgerSheetAccountBalance;
|
||||
closingBalanceSubaccounts?: IGeneralLedgerSheetAccountBalance;
|
||||
children?: IGeneralLedgerSheetAccount[];
|
||||
}
|
||||
|
||||
export type IGeneralLedgerSheetData = IGeneralLedgerSheetAccount[];
|
||||
|
||||
@@ -70,10 +70,7 @@ export class PlaidClientWrapper {
|
||||
baseOptions: {
|
||||
headers: {
|
||||
'PLAID-CLIENT-ID': config.plaid.clientId,
|
||||
'PLAID-SECRET':
|
||||
config.plaid.env === 'development'
|
||||
? config.plaid.secretDevelopment
|
||||
: config.plaid.secretSandbox,
|
||||
'PLAID-SECRET': config.plaid.secret,
|
||||
'Plaid-Version': '2020-09-14',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -104,10 +104,10 @@ export default class UncategorizedCashflowTransaction extends mixin(
|
||||
*/
|
||||
private async updateUncategorizedTransactionCount(
|
||||
queryContext: QueryContext,
|
||||
increment: boolean
|
||||
increment: boolean,
|
||||
amount: number = 1
|
||||
) {
|
||||
const operation = increment ? 'increment' : 'decrement';
|
||||
const amount = increment ? 1 : -1;
|
||||
|
||||
await Account.query(queryContext.transaction)
|
||||
.findById(this.accountId)
|
||||
|
||||
@@ -51,7 +51,7 @@ export default class Ledger implements ILedger {
|
||||
|
||||
/**
|
||||
* Filters entries by the given accounts ids then returns a new ledger.
|
||||
* @param {number[]} accountIds
|
||||
* @param {number[]} accountIds
|
||||
* @returns {ILedger}
|
||||
*/
|
||||
public whereAccountsIds(accountIds: number[]): ILedger {
|
||||
@@ -274,4 +274,14 @@ export default class Ledger implements ILedger {
|
||||
const entries = Ledger.mappingTransactions(transactions);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IAccountEventCreatedPayload,
|
||||
IAccountEventCreatingPayload,
|
||||
IAccountCreateDTO,
|
||||
CreateAccountParams,
|
||||
} from '@/interfaces';
|
||||
import events from '@/subscribers/events';
|
||||
import UnitOfWork from '@/services/UnitOfWork';
|
||||
@@ -30,19 +31,22 @@ export class CreateAccount {
|
||||
|
||||
/**
|
||||
* Authorize the account creation.
|
||||
* @param {number} tenantId
|
||||
* @param {IAccountCreateDTO} accountDTO
|
||||
* @param {number} tenantId
|
||||
* @param {IAccountCreateDTO} accountDTO
|
||||
*/
|
||||
private authorize = async (
|
||||
tenantId: number,
|
||||
accountDTO: IAccountCreateDTO,
|
||||
baseCurrency: string
|
||||
baseCurrency: string,
|
||||
params?: CreateAccountParams
|
||||
) => {
|
||||
// Validate account name uniquiness.
|
||||
await this.validator.validateAccountNameUniquiness(
|
||||
tenantId,
|
||||
accountDTO.name
|
||||
);
|
||||
if (!params.ignoreUniqueName) {
|
||||
await this.validator.validateAccountNameUniquiness(
|
||||
tenantId,
|
||||
accountDTO.name
|
||||
);
|
||||
}
|
||||
// Validate the account code uniquiness.
|
||||
if (accountDTO.code) {
|
||||
await this.validator.isAccountCodeUniqueOrThrowError(
|
||||
@@ -82,7 +86,7 @@ export class CreateAccount {
|
||||
|
||||
/**
|
||||
* Transformes the create account DTO to input model.
|
||||
* @param {IAccountCreateDTO} createAccountDTO
|
||||
* @param {IAccountCreateDTO} createAccountDTO
|
||||
*/
|
||||
private transformDTOToModel = (
|
||||
createAccountDTO: IAccountCreateDTO,
|
||||
@@ -104,7 +108,8 @@ export class CreateAccount {
|
||||
public createAccount = async (
|
||||
tenantId: number,
|
||||
accountDTO: IAccountCreateDTO,
|
||||
trx?: Knex.Transaction
|
||||
trx?: Knex.Transaction,
|
||||
params: CreateAccountParams = { ignoreUniqueName: false }
|
||||
): Promise<IAccount> => {
|
||||
const { Account } = this.tenancy.models(tenantId);
|
||||
|
||||
@@ -112,8 +117,12 @@ export class CreateAccount {
|
||||
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
|
||||
|
||||
// Authorize the account creation.
|
||||
await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency);
|
||||
|
||||
await this.authorize(
|
||||
tenantId,
|
||||
accountDTO,
|
||||
tenantMeta.baseCurrency,
|
||||
params
|
||||
);
|
||||
// Transformes the DTO to model.
|
||||
const accountInputModel = this.transformDTOToModel(
|
||||
accountDTO,
|
||||
@@ -148,3 +157,4 @@ export class CreateAccount {
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@ import { Inject, Service } from 'typedi';
|
||||
import { UploadDocument } from './UploadDocument';
|
||||
import { DeleteAttachment } from './DeleteAttachment';
|
||||
import { GetAttachment } from './GetAttachment';
|
||||
import { AttachmentUploadPipeline } from './S3UploadPipeline';
|
||||
import { LinkAttachment } from './LinkAttachment';
|
||||
import { UnlinkAttachment } from './UnlinkAttachment';
|
||||
import { getAttachmentPresignedUrl } from './GetAttachmentPresignedUrl';
|
||||
import type { Multer } from 'multer';
|
||||
|
||||
@Service()
|
||||
export class AttachmentsApplication {
|
||||
@@ -19,9 +17,6 @@ export class AttachmentsApplication {
|
||||
@Inject()
|
||||
private getDocumentService: GetAttachment;
|
||||
|
||||
@Inject()
|
||||
private uploadPipelineService: AttachmentUploadPipeline;
|
||||
|
||||
@Inject()
|
||||
private linkDocumentService: LinkAttachment;
|
||||
|
||||
@@ -31,14 +26,6 @@ export class AttachmentsApplication {
|
||||
@Inject()
|
||||
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.
|
||||
* @param {number} tenantId
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
import multer from 'multer';
|
||||
import type { Multer } from 'multer'
|
||||
import type { Multer } from 'multer';
|
||||
import multerS3 from 'multer-s3';
|
||||
import { s3 } from '@/lib/S3/S3';
|
||||
import { Service } from 'typedi';
|
||||
import config from '@/config';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Service()
|
||||
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.
|
||||
* It utilizes the multer middleware for handling multipart/form-data, specifically for file uploads.
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Inject, Service } from 'typedi';
|
||||
import bluebird from 'bluebird';
|
||||
import { entries, groupBy } from 'lodash';
|
||||
import { CreateAccount } from '@/services/Accounts/CreateAccount';
|
||||
import { PlaidAccount, PlaidTransaction } from '@/interfaces';
|
||||
import {
|
||||
IAccountCreateDTO,
|
||||
PlaidAccount,
|
||||
PlaidTransaction,
|
||||
} from '@/interfaces';
|
||||
import {
|
||||
transformPlaidAccountToCreateAccount,
|
||||
transformPlaidTrxsToCashflowCreate,
|
||||
@@ -11,6 +15,7 @@ import {
|
||||
import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTransactionService';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
const CONCURRENCY_ASYNC = 10;
|
||||
|
||||
@@ -28,6 +33,35 @@ export class PlaidSyncDb {
|
||||
@Inject()
|
||||
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.
|
||||
* @param {number} tenantId Tenant ID.
|
||||
@@ -37,7 +71,8 @@ export class PlaidSyncDb {
|
||||
public async syncBankAccounts(
|
||||
tenantId: number,
|
||||
plaidAccounts: PlaidAccount[],
|
||||
institution: any
|
||||
institution: any,
|
||||
trx?: Knex.Transaction
|
||||
): Promise<void> {
|
||||
const transformToPlaidAccounts =
|
||||
transformPlaidAccountToCreateAccount(institution);
|
||||
@@ -47,7 +82,7 @@ export class PlaidSyncDb {
|
||||
await bluebird.map(
|
||||
accountCreateDTOs,
|
||||
(createAccountDTO: any) =>
|
||||
this.createAccountService.createAccount(tenantId, createAccountDTO),
|
||||
this.syncBankAccount(tenantId, createAccountDTO, trx),
|
||||
{ concurrency: CONCURRENCY_ASYNC }
|
||||
);
|
||||
}
|
||||
@@ -61,15 +96,16 @@ export class PlaidSyncDb {
|
||||
public async syncAccountTranactions(
|
||||
tenantId: number,
|
||||
plaidAccountId: number,
|
||||
plaidTranasctions: PlaidTransaction[]
|
||||
plaidTranasctions: PlaidTransaction[],
|
||||
trx?: Knex.Transaction
|
||||
): Promise<void> {
|
||||
const { Account } = this.tenancy.models(tenantId);
|
||||
|
||||
const cashflowAccount = await Account.query()
|
||||
const cashflowAccount = await Account.query(trx)
|
||||
.findOne({ plaidAccountId })
|
||||
.throwIfNotFound();
|
||||
|
||||
const openingEquityBalance = await Account.query().findOne(
|
||||
const openingEquityBalance = await Account.query(trx).findOne(
|
||||
'slug',
|
||||
'opening-balance-equity'
|
||||
);
|
||||
@@ -87,7 +123,8 @@ export class PlaidSyncDb {
|
||||
(uncategoriedDTO) =>
|
||||
this.cashflowApp.createUncategorizedTransaction(
|
||||
tenantId,
|
||||
uncategoriedDTO
|
||||
uncategoriedDTO,
|
||||
trx
|
||||
),
|
||||
{ concurrency: 1 }
|
||||
);
|
||||
@@ -100,7 +137,8 @@ export class PlaidSyncDb {
|
||||
*/
|
||||
public async syncAccountsTransactions(
|
||||
tenantId: number,
|
||||
plaidAccountsTransactions: PlaidTransaction[]
|
||||
plaidAccountsTransactions: PlaidTransaction[],
|
||||
trx?: Knex.Transaction
|
||||
): Promise<void> {
|
||||
const groupedTrnsxByAccountId = entries(
|
||||
groupBy(plaidAccountsTransactions, 'account_id')
|
||||
@@ -111,7 +149,8 @@ export class PlaidSyncDb {
|
||||
return this.syncAccountTranactions(
|
||||
tenantId,
|
||||
plaidAccountId,
|
||||
plaidTransactions
|
||||
plaidTransactions,
|
||||
trx
|
||||
);
|
||||
},
|
||||
{ concurrency: CONCURRENCY_ASYNC }
|
||||
@@ -124,11 +163,12 @@ export class PlaidSyncDb {
|
||||
*/
|
||||
public async syncRemoveTransactions(
|
||||
tenantId: number,
|
||||
plaidTransactionsIds: string[]
|
||||
plaidTransactionsIds: string[],
|
||||
trx?: Knex.Transaction
|
||||
) {
|
||||
const { CashflowTransaction } = this.tenancy.models(tenantId);
|
||||
|
||||
const cashflowTransactions = await CashflowTransaction.query().whereIn(
|
||||
const cashflowTransactions = await CashflowTransaction.query(trx).whereIn(
|
||||
'plaidTransactionId',
|
||||
plaidTransactionsIds
|
||||
);
|
||||
@@ -140,7 +180,8 @@ export class PlaidSyncDb {
|
||||
(transactionId: number) =>
|
||||
this.deleteCashflowTransactionService.deleteCashflowTransaction(
|
||||
tenantId,
|
||||
transactionId
|
||||
transactionId,
|
||||
trx
|
||||
),
|
||||
{ concurrency: CONCURRENCY_ASYNC }
|
||||
);
|
||||
@@ -155,11 +196,12 @@ export class PlaidSyncDb {
|
||||
public async syncTransactionsCursor(
|
||||
tenantId: number,
|
||||
plaidItemId: string,
|
||||
lastCursor: string
|
||||
lastCursor: string,
|
||||
trx?: Knex.Transaction
|
||||
) {
|
||||
const { PlaidItem } = this.tenancy.models(tenantId);
|
||||
|
||||
await PlaidItem.query().findOne({ plaidItemId }).patch({ lastCursor });
|
||||
await PlaidItem.query(trx).findOne({ plaidItemId }).patch({ lastCursor });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,13 +211,16 @@ export class PlaidSyncDb {
|
||||
*/
|
||||
public async updateLastFeedsUpdatedAt(
|
||||
tenantId: number,
|
||||
plaidAccountIds: string[]
|
||||
plaidAccountIds: string[],
|
||||
trx?: Knex.Transaction
|
||||
) {
|
||||
const { Account } = this.tenancy.models(tenantId);
|
||||
|
||||
await Account.query().whereIn('plaid_account_id', plaidAccountIds).patch({
|
||||
lastFeedsUpdatedAt: new Date(),
|
||||
});
|
||||
await Account.query(trx)
|
||||
.whereIn('plaid_account_id', plaidAccountIds)
|
||||
.patch({
|
||||
lastFeedsUpdatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,12 +232,15 @@ export class PlaidSyncDb {
|
||||
public async updateAccountsFeedsActive(
|
||||
tenantId: number,
|
||||
plaidAccountIds: string[],
|
||||
isFeedsActive: boolean = true
|
||||
isFeedsActive: boolean = true,
|
||||
trx?: Knex.Transaction
|
||||
) {
|
||||
const { Account } = this.tenancy.models(tenantId);
|
||||
|
||||
await Account.query().whereIn('plaid_account_id', plaidAccountIds).patch({
|
||||
isFeedsActive,
|
||||
});
|
||||
await Account.query(trx)
|
||||
.whereIn('plaid_account_id', plaidAccountIds)
|
||||
.patch({
|
||||
isFeedsActive,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Inject, Service } from 'typedi';
|
||||
import { PlaidClientWrapper } from '@/lib/Plaid/Plaid';
|
||||
import { PlaidSyncDb } from './PlaidSyncDB';
|
||||
import { PlaidFetchedTransactionsUpdates } from '@/interfaces';
|
||||
import UnitOfWork from '@/services/UnitOfWork';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
@Service()
|
||||
export class PlaidUpdateTransactions {
|
||||
@@ -12,12 +14,40 @@ export class PlaidUpdateTransactions {
|
||||
@Inject()
|
||||
private plaidSync: PlaidSyncDb;
|
||||
|
||||
@Inject()
|
||||
private uow: UnitOfWork;
|
||||
|
||||
/**
|
||||
* Handles the fetching and storing of new, modified, or removed transactions
|
||||
* @param {number} tenantId Tenant ID.
|
||||
* @param {string} plaidItemId the Plaid ID for the item.
|
||||
* Handles sync the Plaid item to Bigcaptial under UOW.
|
||||
* @param {number} tenantId
|
||||
* @param {number} plaidItemId
|
||||
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
|
||||
*/
|
||||
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.
|
||||
const { added, modified, removed, cursor, accessToken } =
|
||||
await this.fetchTransactionUpdates(tenantId, plaidItemId);
|
||||
@@ -29,28 +59,42 @@ export class PlaidUpdateTransactions {
|
||||
} = await plaidInstance.accountsGet(request);
|
||||
|
||||
const plaidAccountsIds = accounts.map((a) => a.account_id);
|
||||
|
||||
const {
|
||||
data: { institution },
|
||||
} = await plaidInstance.institutionsGetById({
|
||||
institution_id: item.institution_id,
|
||||
country_codes: ['US', 'UK'],
|
||||
});
|
||||
// Update the DB.
|
||||
await this.plaidSync.syncBankAccounts(tenantId, accounts, institution);
|
||||
// Sync bank accounts.
|
||||
await this.plaidSync.syncBankAccounts(tenantId, accounts, institution, trx);
|
||||
// Sync bank account transactions.
|
||||
await this.plaidSync.syncAccountsTransactions(
|
||||
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.
|
||||
await this.plaidSync.updateLastFeedsUpdatedAt(tenantId, plaidAccountsIds);
|
||||
|
||||
await this.plaidSync.updateLastFeedsUpdatedAt(
|
||||
tenantId,
|
||||
plaidAccountsIds,
|
||||
trx
|
||||
);
|
||||
// Turn on the accounts feeds flag.
|
||||
await this.plaidSync.updateAccountsFeedsActive(tenantId, plaidAccountsIds);
|
||||
|
||||
await this.plaidSync.updateAccountsFeedsActive(
|
||||
tenantId,
|
||||
plaidAccountsIds,
|
||||
true,
|
||||
trx
|
||||
);
|
||||
return {
|
||||
addedCount: added.length,
|
||||
modifiedCount: modified.length,
|
||||
|
||||
@@ -42,7 +42,12 @@ export const transformPlaidTrxsToCashflowCreate = R.curry(
|
||||
): CreateUncategorizedTransactionDTO => {
|
||||
return {
|
||||
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,
|
||||
payee: plaidTranasction.payment_meta?.payee,
|
||||
currencyCode: plaidTranasction.iso_currency_code,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { DeleteCashflowTransaction } from './DeleteCashflowTransactionService';
|
||||
import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction';
|
||||
@@ -119,11 +120,13 @@ export class CashflowApplication {
|
||||
*/
|
||||
public createUncategorizedTransaction(
|
||||
tenantId: number,
|
||||
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO
|
||||
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO,
|
||||
trx?: Knex.Transaction
|
||||
) {
|
||||
return this.createUncategorizedTransactionService.create(
|
||||
tenantId,
|
||||
createUncategorizedTransactionDTO
|
||||
createUncategorizedTransactionDTO,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Knex } from 'knex';
|
||||
import { transformCategorizeTransToCashflow } from './utils';
|
||||
import { CommandCashflowValidator } from './CommandCasflowValidator';
|
||||
import NewCashflowTransactionService from './NewCashflowTransactionService';
|
||||
import { TransferAuthorizationGuaranteeDecision } from 'plaid';
|
||||
|
||||
@Service()
|
||||
export class CategorizeCashflowTransaction {
|
||||
|
||||
@@ -30,7 +30,8 @@ export class DeleteCashflowTransaction {
|
||||
*/
|
||||
public deleteCashflowTransaction = async (
|
||||
tenantId: number,
|
||||
cashflowTransactionId: number
|
||||
cashflowTransactionId: number,
|
||||
trx?: Knex.Transaction
|
||||
): Promise<{ oldCashflowTransaction: ICashflowTransaction }> => {
|
||||
const { CashflowTransaction, CashflowTransactionLine } =
|
||||
this.tenancy.models(tenantId);
|
||||
@@ -43,34 +44,44 @@ export class DeleteCashflowTransaction {
|
||||
this.throwErrorIfTransactionNotFound(oldCashflowTransaction);
|
||||
|
||||
// Starting database transaction.
|
||||
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
|
||||
// Triggers `onCashflowTransactionDelete` event.
|
||||
await this.eventPublisher.emitAsync(events.cashflow.onTransactionDeleting, {
|
||||
trx,
|
||||
tenantId,
|
||||
oldCashflowTransaction,
|
||||
} as ICommandCashflowDeletingPayload);
|
||||
return this.uow.withTransaction(
|
||||
tenantId,
|
||||
async (trx: Knex.Transaction) => {
|
||||
// Triggers `onCashflowTransactionDelete` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.cashflow.onTransactionDeleting,
|
||||
{
|
||||
trx,
|
||||
tenantId,
|
||||
oldCashflowTransaction,
|
||||
} as ICommandCashflowDeletingPayload
|
||||
);
|
||||
|
||||
// Delete cashflow transaction associated lines first.
|
||||
await CashflowTransactionLine.query(trx)
|
||||
.where('cashflow_transaction_id', cashflowTransactionId)
|
||||
.delete();
|
||||
// Delete cashflow transaction associated lines first.
|
||||
await CashflowTransactionLine.query(trx)
|
||||
.where('cashflow_transaction_id', cashflowTransactionId)
|
||||
.delete();
|
||||
|
||||
// Delete cashflow transaction.
|
||||
await CashflowTransaction.query(trx)
|
||||
.findById(cashflowTransactionId)
|
||||
.delete();
|
||||
// Delete cashflow transaction.
|
||||
await CashflowTransaction.query(trx)
|
||||
.findById(cashflowTransactionId)
|
||||
.delete();
|
||||
|
||||
// Triggers `onCashflowTransactionDeleted` event.
|
||||
await this.eventPublisher.emitAsync(events.cashflow.onTransactionDeleted, {
|
||||
trx,
|
||||
tenantId,
|
||||
cashflowTransactionId,
|
||||
oldCashflowTransaction,
|
||||
} as ICommandCashflowDeletedPayload);
|
||||
// Triggers `onCashflowTransactionDeleted` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.cashflow.onTransactionDeleted,
|
||||
{
|
||||
trx,
|
||||
tenantId,
|
||||
cashflowTransactionId,
|
||||
oldCashflowTransaction,
|
||||
} as ICommandCashflowDeletedPayload
|
||||
);
|
||||
|
||||
return { oldCashflowTransaction };
|
||||
});
|
||||
return { oldCashflowTransaction };
|
||||
},
|
||||
trx
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,7 +68,11 @@ export const CASHFLOW_TRANSACTION_TYPE_META = {
|
||||
[`${CASHFLOW_TRANSACTION_TYPE.OTHER_EXPENSE}`]: {
|
||||
type: 'OtherExpense',
|
||||
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,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { upperFirst, camelCase, omit } from 'lodash';
|
||||
import { upperFirst, camelCase } from 'lodash';
|
||||
import {
|
||||
CASHFLOW_TRANSACTION_TYPE,
|
||||
CASHFLOW_TRANSACTION_TYPE_META,
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from './constants';
|
||||
import {
|
||||
ICashflowNewCommandDTO,
|
||||
ICashflowTransaction,
|
||||
ICategorizeCashflowTransactioDTO,
|
||||
IUncategorizedCashflowTransaction,
|
||||
} from '@/interfaces';
|
||||
@@ -42,8 +41,8 @@ export const getCashflowAccountTransactionsTypes = () => {
|
||||
/**
|
||||
* Tranasformes the given uncategorized transaction and categorized DTO
|
||||
* to cashflow create DTO.
|
||||
* @param {IUncategorizedCashflowTransaction} uncategorizeModel
|
||||
* @param {ICategorizeCashflowTransactioDTO} categorizeDTO
|
||||
* @param {IUncategorizedCashflowTransaction} uncategorizeModel
|
||||
* @param {ICategorizeCashflowTransactioDTO} categorizeDTO
|
||||
* @returns {ICashflowNewCommandDTO}
|
||||
*/
|
||||
export const transformCategorizeTransToCashflow = (
|
||||
@@ -62,6 +61,7 @@ export const transformCategorizeTransToCashflow = (
|
||||
transactionNumber: categorizeDTO.transactionNumber,
|
||||
transactionType: categorizeDTO.transactionType,
|
||||
uncategorizedTransactionId: uncategorizeModel.id,
|
||||
branchId: categorizeDTO?.branchId,
|
||||
publish: true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import * as R from 'ramda';
|
||||
import { FinancialPreviousPeriod } from '../FinancialPreviousPeriod';
|
||||
import { FinancialHorizTotals } from '../FinancialHorizTotals';
|
||||
import { FinancialSheetStructure } from '../FinancialSheetStructure';
|
||||
import {
|
||||
BALANCE_SHEET_SCHEMA_NODE_TYPE,
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as R from 'ramda';
|
||||
import { Knex } from 'knex';
|
||||
import { isEmpty } from 'lodash';
|
||||
import {
|
||||
IAccount,
|
||||
IAccountTransactionsGroupBy,
|
||||
IBalanceSheetQuery,
|
||||
ILedger,
|
||||
@@ -12,7 +11,6 @@ import { transformToMapBy } from 'utils';
|
||||
import Ledger from '@/services/Accounting/Ledger';
|
||||
import { BalanceSheetQuery } from './BalanceSheetQuery';
|
||||
import { FinancialDatePeriods } from '../FinancialDatePeriods';
|
||||
import { ACCOUNT_PARENT_TYPE, ACCOUNT_TYPE } from '@/data/AccountTypes';
|
||||
import { BalanceSheetRepositoryNetIncome } from './BalanceSheetRepositoryNetIncome';
|
||||
|
||||
@Service()
|
||||
|
||||
@@ -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 {
|
||||
IGeneralLedgerSheetQuery,
|
||||
IGeneralLedgerSheetAccount,
|
||||
IGeneralLedgerSheetAccountBalance,
|
||||
IGeneralLedgerSheetAccountTransaction,
|
||||
IAccount,
|
||||
IJournalPoster,
|
||||
IJournalEntry,
|
||||
IContact,
|
||||
ILedgerEntry,
|
||||
} from '@/interfaces';
|
||||
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.
|
||||
*/
|
||||
export default class GeneralLedgerSheet extends FinancialSheet {
|
||||
tenantId: number;
|
||||
accounts: IAccount[];
|
||||
query: IGeneralLedgerSheetQuery;
|
||||
openingBalancesJournal: IJournalPoster;
|
||||
transactions: IJournalPoster;
|
||||
contactsMap: Map<number, IContact>;
|
||||
baseCurrency: string;
|
||||
i18n: any;
|
||||
export default class GeneralLedgerSheet extends R.compose(
|
||||
FinancialSheetStructure
|
||||
)(FinancialSheet) {
|
||||
private query: IGeneralLedgerSheetQuery;
|
||||
private baseCurrency: string;
|
||||
private i18n: any;
|
||||
private repository: GeneralLedgerRepository;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
@@ -34,63 +36,59 @@ export default class GeneralLedgerSheet extends FinancialSheet {
|
||||
* @param {IJournalPoster} closingBalancesJournal -
|
||||
*/
|
||||
constructor(
|
||||
tenantId: number,
|
||||
query: IGeneralLedgerSheetQuery,
|
||||
accounts: IAccount[],
|
||||
contactsByIdMap: Map<number, IContact>,
|
||||
transactions: IJournalPoster,
|
||||
openingBalancesJournal: IJournalPoster,
|
||||
baseCurrency: string,
|
||||
repository: GeneralLedgerRepository,
|
||||
i18n
|
||||
) {
|
||||
super();
|
||||
|
||||
this.tenantId = tenantId;
|
||||
this.query = query;
|
||||
this.numberFormat = this.query.numberFormat;
|
||||
this.accounts = accounts;
|
||||
this.contactsMap = contactsByIdMap;
|
||||
this.transactions = transactions;
|
||||
this.openingBalancesJournal = openingBalancesJournal;
|
||||
this.baseCurrency = baseCurrency;
|
||||
this.repository = repository;
|
||||
this.baseCurrency = this.repository.tenant.metadata.currencyCode;
|
||||
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.
|
||||
* @param {IJournalEntry} entry -
|
||||
* @param {ILedgerEntry} entry -
|
||||
* @return {IGeneralLedgerSheetAccountTransaction}
|
||||
*/
|
||||
entryReducer(
|
||||
entries: IGeneralLedgerSheetAccountTransaction[],
|
||||
entry: IJournalEntry,
|
||||
openingBalance: number
|
||||
): IGeneralLedgerSheetAccountTransaction[] {
|
||||
const lastEntry = last(entries);
|
||||
private getEntryRunningBalance(
|
||||
entry: ILedgerEntry,
|
||||
openingBalance: number,
|
||||
runningBalance?: number
|
||||
): number {
|
||||
const lastRunningBalance = runningBalance || openingBalance;
|
||||
|
||||
const contact = this.contactsMap.get(entry.contactId);
|
||||
const amount = this.getAmount(
|
||||
const amount = Ledger.getAmount(
|
||||
entry.credit,
|
||||
entry.debit,
|
||||
entry.accountNormal
|
||||
);
|
||||
const runningBalance =
|
||||
amount + (!isEmpty(entries) ? lastEntry.runningBalance : openingBalance);
|
||||
return calculateRunningBalance(amount, lastRunningBalance);
|
||||
}
|
||||
|
||||
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,
|
||||
dateFormatted: moment(entry.date).format('YYYY MMM DD'),
|
||||
entryId: entry.id,
|
||||
|
||||
transactionNumber: entry.transactionNumber,
|
||||
referenceType: entry.referenceType,
|
||||
@@ -109,16 +107,15 @@ export default class GeneralLedgerSheet extends FinancialSheet {
|
||||
amount,
|
||||
runningBalance,
|
||||
|
||||
formattedAmount: this.formatNumber(amount),
|
||||
formattedCredit: this.formatNumber(entry.credit),
|
||||
formattedDebit: this.formatNumber(entry.debit),
|
||||
formattedRunningBalance: this.formatNumber(runningBalance),
|
||||
formattedAmount: this.formatNumber(amount, { excerptZero: false }),
|
||||
formattedCredit: this.formatNumber(entry.credit, { excerptZero: false }),
|
||||
formattedDebit: this.formatNumber(entry.debit, { excerptZero: false }),
|
||||
formattedRunningBalance: this.formatNumber(runningBalance, {
|
||||
excerptZero: false,
|
||||
}),
|
||||
|
||||
currencyCode: this.baseCurrency,
|
||||
};
|
||||
entries.push(newEntry);
|
||||
|
||||
return entries;
|
||||
} as IGeneralLedgerSheetAccountTransaction;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,28 +127,48 @@ export default class GeneralLedgerSheet extends FinancialSheet {
|
||||
account: IAccount,
|
||||
openingBalance: number
|
||||
): IGeneralLedgerSheetAccountTransaction[] {
|
||||
const entries = this.transactions.getAccountEntries(account.id);
|
||||
const entries = this.repository.transactionsLedger
|
||||
.whereAccountId(account.id)
|
||||
.getEntries();
|
||||
|
||||
return entries.reduce(
|
||||
(
|
||||
entries: IGeneralLedgerSheetAccountTransaction[],
|
||||
entry: IJournalEntry
|
||||
) => {
|
||||
return this.entryReducer(entries, entry, openingBalance);
|
||||
},
|
||||
[]
|
||||
);
|
||||
return entries
|
||||
.reduce((prev: Array<[number, ILedgerEntry]>, current: ILedgerEntry) => {
|
||||
const prevEntry = last(prev);
|
||||
const prevRunningBalance = head(prevEntry) as number;
|
||||
const amount = this.getEntryRunningBalance(
|
||||
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
|
||||
* @return {IGeneralLedgerSheetAccountBalance}
|
||||
*/
|
||||
private accountOpeningBalance(
|
||||
account: IAccount
|
||||
private accountOpeningBalanceTotal(
|
||||
accountId: number
|
||||
): IGeneralLedgerSheetAccountBalance {
|
||||
const amount = this.openingBalancesJournal.getAccountBalance(account.id);
|
||||
const amount = this.accountOpeningBalance(accountId);
|
||||
const formattedAmount = this.formatTotalNumber(amount);
|
||||
const currencyCode = this.baseCurrency;
|
||||
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
|
||||
* @return {IGeneralLedgerSheetAccountBalance}
|
||||
*/
|
||||
private accountClosingBalance(
|
||||
openingBalance: number,
|
||||
transactions: IGeneralLedgerSheetAccountTransaction[]
|
||||
private accountClosingBalanceTotal(
|
||||
accountId: number
|
||||
): IGeneralLedgerSheetAccountBalance {
|
||||
const amount = this.calcClosingBalance(openingBalance, transactions);
|
||||
const amount = this.accountClosingBalance(accountId);
|
||||
const formattedAmount = this.formatTotalNumber(amount);
|
||||
const currencyCode = this.baseCurrency;
|
||||
const date = this.query.toDate;
|
||||
@@ -176,31 +209,78 @@ export default class GeneralLedgerSheet extends FinancialSheet {
|
||||
return { amount, formattedAmount, currencyCode, date };
|
||||
}
|
||||
|
||||
private calcClosingBalance(
|
||||
openingBalance: number,
|
||||
transactions: IGeneralLedgerSheetAccountTransaction[]
|
||||
) {
|
||||
return openingBalance + sumBy(transactions, (trans) => trans.amount);
|
||||
}
|
||||
/**
|
||||
* Retrieves the given account closing balance with subaccounts.
|
||||
* @param {number} accountId
|
||||
* @returns {number}
|
||||
*/
|
||||
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.
|
||||
* @param {IAccount} account
|
||||
* @return {IGeneralLedgerSheetAccount}
|
||||
*/
|
||||
private accountMapper(account: IAccount): IGeneralLedgerSheetAccount {
|
||||
const openingBalance = this.accountOpeningBalance(account);
|
||||
|
||||
private accountMapper = (account: IAccount): IGeneralLedgerSheetAccount => {
|
||||
const openingBalance = this.accountOpeningBalanceTotal(account.id);
|
||||
const transactions = this.accountTransactionsMapper(
|
||||
account,
|
||||
openingBalance.amount
|
||||
);
|
||||
const closingBalance = this.accountClosingBalance(
|
||||
openingBalance.amount,
|
||||
transactions
|
||||
);
|
||||
const closingBalance = this.accountClosingBalanceTotal(account.id);
|
||||
const closingBalanceSubaccounts =
|
||||
this.accountClosingBalanceWithSubaccountsTotal(account.id);
|
||||
|
||||
return {
|
||||
const initialNode = {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
code: account.code,
|
||||
@@ -210,34 +290,90 @@ export default class GeneralLedgerSheet extends FinancialSheet {
|
||||
transactions,
|
||||
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 -
|
||||
* @return {IGeneralLedgerSheetAccount[]}
|
||||
*/
|
||||
private accountsWalker(accounts: IAccount[]): IGeneralLedgerSheetAccount[] {
|
||||
return (
|
||||
accounts
|
||||
.map((account: IAccount) => this.accountMapper(account))
|
||||
// Filter general ledger accounts that have no transactions
|
||||
// when`noneTransactions` is on.
|
||||
.filter(
|
||||
(generalLedgerAccount: IGeneralLedgerSheetAccount) =>
|
||||
!(
|
||||
generalLedgerAccount.transactions.length === 0 &&
|
||||
this.query.noneTransactions
|
||||
)
|
||||
)
|
||||
);
|
||||
return R.compose(
|
||||
R.defaultTo([]),
|
||||
this.filterAccountNodesByTransactionsFilter,
|
||||
this.accountNodesDeepMap,
|
||||
R.defaultTo([]),
|
||||
this.filterAccountNodesByAccountsFilter,
|
||||
this.nestedAccountsNode
|
||||
)(accounts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve general ledger report data.
|
||||
* Retrieves general ledger report data.
|
||||
* @return {IGeneralLedgerSheetAccount[]}
|
||||
*/
|
||||
public reportData(): IGeneralLedgerSheetAccount[] {
|
||||
return this.accountsWalker(this.accounts);
|
||||
return this.accountsWalker(this.repository.accounts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import moment from 'moment';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { difference } from 'lodash';
|
||||
import { IGeneralLedgerSheetQuery, IGeneralLedgerMeta } from '@/interfaces';
|
||||
import TenancyService from '@/services/Tenancy/TenancyService';
|
||||
import Journal from '@/services/Accounting/JournalPoster';
|
||||
import GeneralLedgerSheet from '@/services/FinancialStatements/GeneralLedger/GeneralLedger';
|
||||
import { transformToMap } from 'utils';
|
||||
import { Tenant } from '@/system/models';
|
||||
import { GeneralLedgerMeta } from './GeneralLedgerMeta';
|
||||
|
||||
const ERRORS = {
|
||||
ACCOUNTS_NOT_FOUND: 'ACCOUNTS_NOT_FOUND',
|
||||
};
|
||||
import { GeneralLedgerRepository } from './GeneralLedgerRepository';
|
||||
|
||||
@Service()
|
||||
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.
|
||||
* @param {number} tenantId
|
||||
* @param {IGeneralLedgerSheetQuery} query
|
||||
* @return {IGeneralLedgerStatement}
|
||||
* @return {Promise<IGeneralLedgerStatement>}
|
||||
*/
|
||||
async generalLedger(
|
||||
public async generalLedger(
|
||||
tenantId: number,
|
||||
query: IGeneralLedgerSheetQuery
|
||||
): Promise<{
|
||||
@@ -70,60 +46,24 @@ export class GeneralLedgerService {
|
||||
query: IGeneralLedgerSheetQuery;
|
||||
meta: IGeneralLedgerMeta;
|
||||
}> {
|
||||
const { accountRepository, transactionsRepository, contactRepository } =
|
||||
this.tenancy.repositories(tenantId);
|
||||
|
||||
const repositories = this.tenancy.repositories(tenantId);
|
||||
const i18n = this.tenancy.i18n(tenantId);
|
||||
|
||||
const tenant = await Tenant.query()
|
||||
.findById(tenantId)
|
||||
.withGraphFetched('metadata');
|
||||
|
||||
const filter = {
|
||||
...this.defaultQuery,
|
||||
...query,
|
||||
};
|
||||
// Retrieve all accounts with associated type from the storage.
|
||||
const accounts = await accountRepository.all();
|
||||
const accountsGraph = await accountRepository.getDependencyGraph();
|
||||
|
||||
// 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
|
||||
const genealLedgerRepository = new GeneralLedgerRepository(
|
||||
repositories,
|
||||
query,
|
||||
tenantId
|
||||
);
|
||||
await genealLedgerRepository.asyncInitialize();
|
||||
|
||||
// General ledger report instance.
|
||||
const generalLedgerInstance = new GeneralLedgerSheet(
|
||||
tenantId,
|
||||
filter,
|
||||
accounts,
|
||||
contactsByIdMap,
|
||||
transactionsJournal,
|
||||
openingTransJournal,
|
||||
tenant.metadata.baseCurrency,
|
||||
genealLedgerRepository,
|
||||
i18n
|
||||
);
|
||||
// Retrieve general ledger report data.
|
||||
|
||||
@@ -83,8 +83,8 @@ export class GeneralLedgerTable extends R.compose(
|
||||
*/
|
||||
private openingBalanceColumnsAccessors(): IColumnMapperMeta[] {
|
||||
return [
|
||||
{ key: 'date', value: this.meta.fromDate },
|
||||
{ key: 'account_name', value: 'Opening Balance' },
|
||||
{ key: 'date', value: 'Opening Balance' },
|
||||
{ key: 'account_name', value: '' },
|
||||
{ key: 'reference_type', accessor: '_empty_' },
|
||||
{ key: 'reference_number', accessor: '_empty_' },
|
||||
{ key: 'description', accessor: 'description' },
|
||||
@@ -97,12 +97,15 @@ export class GeneralLedgerTable extends R.compose(
|
||||
|
||||
/**
|
||||
* Closing balance row column accessors.
|
||||
* @param {IGeneralLedgerSheetAccount} account -
|
||||
* @returns {ITableColumnAccessor[]}
|
||||
*/
|
||||
private closingBalanceColumnAccessors(): IColumnMapperMeta[] {
|
||||
private closingBalanceColumnAccessors(
|
||||
account: IGeneralLedgerSheetAccount
|
||||
): IColumnMapperMeta[] {
|
||||
return [
|
||||
{ key: 'date', value: this.meta.toDate },
|
||||
{ key: 'account_name', value: 'Closing Balance' },
|
||||
{ key: 'date', value: `Closing balance for ${account.name}` },
|
||||
{ key: 'account_name', value: `` },
|
||||
{ key: 'reference_type', accessor: '_empty_' },
|
||||
{ key: 'reference_number', 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.
|
||||
* @returns {ITableColumn[]}
|
||||
@@ -184,7 +217,22 @@ export class GeneralLedgerTable extends R.compose(
|
||||
* @returns {ITableRow}
|
||||
*/
|
||||
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 = {
|
||||
rowTypes: [ROW_TYPE.CLOSING_BALANCE],
|
||||
};
|
||||
@@ -221,8 +269,27 @@ export class GeneralLedgerTable extends R.compose(
|
||||
rowTypes: [ROW_TYPE.ACCOUNT],
|
||||
};
|
||||
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 = (
|
||||
accounts: IGeneralLedgerSheetAccount[]
|
||||
): 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[] {
|
||||
const columns = this.commonColumns();
|
||||
|
||||
return R.compose(this.tableColumnsCellIndexing)(columns);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -108,17 +108,28 @@ export default class ResourceService {
|
||||
const $hasFields = (field) =>
|
||||
'undefined' !== typeof field.fields ? field : undefined;
|
||||
|
||||
const $hasColumns = (column) =>
|
||||
const $ColumnHasColumns = (column) =>
|
||||
'undefined' !== typeof column.columns ? column : undefined;
|
||||
|
||||
const $hasColumns = (columns) =>
|
||||
'undefined' !== typeof columns ? columns : undefined;
|
||||
|
||||
const naviagations = [
|
||||
['fields', qim.$each, 'name'],
|
||||
['fields', qim.$each, $enumerationType, 'options', qim.$each, 'label'],
|
||||
['fields2', qim.$each, 'name'],
|
||||
['fields2', qim.$each, $enumerationType, 'options', qim.$each, 'label'],
|
||||
['fields2', qim.$each, $hasFields, 'fields', qim.$each, 'name'],
|
||||
['columns', qim.$each, 'name'],
|
||||
['columns', qim.$each, $hasColumns, 'columns', qim.$each, 'name'],
|
||||
['columns', $hasColumns, qim.$each, 'name'],
|
||||
[
|
||||
'columns',
|
||||
$hasColumns,
|
||||
qim.$each,
|
||||
$ColumnHasColumns,
|
||||
'columns',
|
||||
qim.$each,
|
||||
'name',
|
||||
],
|
||||
];
|
||||
return this.i18nService.i18nApply(naviagations, meta, tenantId);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Service } from 'typedi';
|
||||
import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { SystemUser } from '@/system/models';
|
||||
import { configureLemonSqueezy } from './utils';
|
||||
import config from '@/config';
|
||||
|
||||
@Service()
|
||||
export class LemonSqueezyService {
|
||||
@@ -28,7 +29,7 @@ export class LemonSqueezyService {
|
||||
},
|
||||
productOptions: {
|
||||
enabledVariants: [variantId],
|
||||
redirectUrl: `http://localhost:4000/dashboard/billing/`,
|
||||
redirectUrl: config.lemonSqueezy.redirectTo,
|
||||
receiptButtonText: 'Go to Dashboard',
|
||||
receiptThankYouNote: 'Thank you for signing up to Lemon Stand!',
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,6 @@ import InvoiceMailDialog from '@/containers/Sales/Invoices/InvoiceMailDialog/Inv
|
||||
import EstimateMailDialog from '@/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog';
|
||||
import ReceiptMailDialog from '@/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog';
|
||||
import PaymentMailDialog from '@/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog';
|
||||
import { ConnectBankDialog } from '@/containers/CashFlow/ConnectBankDialog';
|
||||
import { ExportDialog } from '@/containers/Dialogs/ExportDialog';
|
||||
|
||||
/**
|
||||
@@ -97,7 +96,6 @@ export default function DialogsContainer() {
|
||||
<NotifyPaymentReceiveViaSMSDialog
|
||||
dialogName={DialogsName.NotifyPaymentViaForm}
|
||||
/>
|
||||
|
||||
<BadDebtDialog dialogName={DialogsName.BadDebtForm} />
|
||||
<SMSMessageDialog dialogName={DialogsName.SMSMessageForm} />
|
||||
<RefundCreditNoteDialog dialogName={DialogsName.RefundCreditNote} />
|
||||
@@ -148,8 +146,6 @@ export default function DialogsContainer() {
|
||||
<EstimateMailDialog dialogName={DialogsName.EstimateMail} />
|
||||
<ReceiptMailDialog dialogName={DialogsName.ReceiptMail} />
|
||||
<PaymentMailDialog dialogName={DialogsName.PaymentMail} />
|
||||
<ConnectBankDialog dialogName={DialogsName.ConnectBankCreditCard} />
|
||||
|
||||
<ExportDialog dialogName={DialogsName.Export} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FeatureCan,
|
||||
} from '@/components';
|
||||
import { useRefreshCashflowAccounts } from '@/hooks/query';
|
||||
import { useOpenPlaidConnect } from '@/hooks/utils/useOpenPlaidConnect';
|
||||
import { CashflowAction, AbilitySubject } from '@/constants/abilityOption';
|
||||
|
||||
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
||||
@@ -39,6 +40,9 @@ function CashFlowAccountsActionsBar({
|
||||
}) {
|
||||
const { refresh } = useRefreshCashflowAccounts();
|
||||
|
||||
// Opens the Plaid popup.
|
||||
const { openPlaidAsync, isPlaidLoading } = useOpenPlaidConnect();
|
||||
|
||||
// Handle refresh button click.
|
||||
const handleRefreshBtnClick = () => {
|
||||
refresh();
|
||||
@@ -64,7 +68,7 @@ function CashFlowAccountsActionsBar({
|
||||
};
|
||||
// Handle connect button click.
|
||||
const handleConnectToBank = () => {
|
||||
openDialog(DialogsName.ConnectBankCreditCard);
|
||||
openPlaidAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -116,6 +120,7 @@ function CashFlowAccountsActionsBar({
|
||||
className={Classes.MINIMAL}
|
||||
text={'Connect to Bank / Credit Card'}
|
||||
onClick={handleConnectToBank}
|
||||
disabled={isPlaidLoading}
|
||||
/>
|
||||
<NavbarDivider />
|
||||
</FeatureCan>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { first } from 'lodash';
|
||||
import { DrawerHeaderContent, DrawerLoading } from '@/components';
|
||||
import { DRAWERS } from '@/constants/drawers';
|
||||
import {
|
||||
@@ -34,6 +35,12 @@ function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) {
|
||||
isLoading: isUncategorizedTransactionLoading,
|
||||
} = useUncategorizedTransaction(uncategorizedTransactionId);
|
||||
|
||||
// Retrieves the primary branch.
|
||||
const primaryBranch = useMemo(
|
||||
() => branches?.find((b) => b.primary) || first(branches),
|
||||
[branches],
|
||||
);
|
||||
|
||||
const provider = {
|
||||
uncategorizedTransactionId,
|
||||
uncategorizedTransaction,
|
||||
@@ -42,6 +49,7 @@ function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) {
|
||||
accounts,
|
||||
isBranchesLoading,
|
||||
isAccountsLoading,
|
||||
primaryBranch,
|
||||
};
|
||||
const isLoading =
|
||||
isBranchesLoading || isUncategorizedTransactionLoading || isAccountsLoading;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -24,8 +24,11 @@ function CategorizeTransactionFormRoot({
|
||||
// #withDrawerActions
|
||||
closeDrawer,
|
||||
}) {
|
||||
const { uncategorizedTransactionId, uncategorizedTransaction } =
|
||||
useCategorizeTransactionBoot();
|
||||
const {
|
||||
uncategorizedTransactionId,
|
||||
uncategorizedTransaction,
|
||||
primaryBranch,
|
||||
} = useCategorizeTransactionBoot();
|
||||
const { mutateAsync: categorizeTransaction } = useCategorizeTransaction();
|
||||
|
||||
// Callbacks handles form submit.
|
||||
@@ -37,18 +40,28 @@ function CategorizeTransactionFormRoot({
|
||||
.then(() => {
|
||||
setSubmitting(false);
|
||||
closeDrawer(DRAWERS.CATEGORIZE_TRANSACTION);
|
||||
|
||||
|
||||
AppToaster.show({
|
||||
message: 'The uncategorized transaction has been categorized.',
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
setSubmitting(false);
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong!',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
if (
|
||||
err.response.data?.errors?.some(
|
||||
(e) => e.type === 'BRANCH_ID_REQUIRED',
|
||||
)
|
||||
) {
|
||||
setErrors({
|
||||
branchId: 'The branch is required.',
|
||||
});
|
||||
} else {
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong!',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
// Form initial values in create and edit mode.
|
||||
@@ -60,6 +73,9 @@ function CategorizeTransactionFormRoot({
|
||||
* as well.
|
||||
*/
|
||||
...transformToCategorizeForm(uncategorizedTransaction),
|
||||
|
||||
/** Assign the primary branch id as default value. */
|
||||
branchId: primaryBranch?.id || null,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FTextArea,
|
||||
} from '@/components';
|
||||
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
|
||||
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
|
||||
|
||||
export default function CategorizeTransactionOtherIncome() {
|
||||
const { accounts } = useCategorizeTransactionBoot();
|
||||
@@ -68,6 +69,8 @@ export default function CategorizeTransactionOtherIncome() {
|
||||
fill={true}
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<CategorizeTransactionBranchField />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FTextArea,
|
||||
} from '@/components';
|
||||
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
|
||||
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
|
||||
|
||||
export default function CategorizeTransactionOwnerContribution() {
|
||||
const { accounts } = useCategorizeTransactionBoot();
|
||||
@@ -63,6 +64,8 @@ export default function CategorizeTransactionOwnerContribution() {
|
||||
<FFormGroup name={'description'} label={'Description'} fastField inline>
|
||||
<FTextArea name={'description'} growVertically large fill />
|
||||
</FFormGroup>
|
||||
|
||||
<CategorizeTransactionBranchField />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FTextArea,
|
||||
} from '@/components';
|
||||
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
|
||||
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
|
||||
|
||||
export default function CategorizeTransactionTransferFrom() {
|
||||
const { accounts } = useCategorizeTransactionBoot();
|
||||
@@ -47,7 +48,7 @@ export default function CategorizeTransactionTransferFrom() {
|
||||
inline
|
||||
>
|
||||
<AccountsSelect
|
||||
name={'to_account_id'}
|
||||
name={'creditAccountId'}
|
||||
items={accounts}
|
||||
filterByRootTypes={['asset']}
|
||||
fastField
|
||||
@@ -68,6 +69,8 @@ export default function CategorizeTransactionTransferFrom() {
|
||||
fill={true}
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<CategorizeTransactionBranchField />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FTextArea,
|
||||
} from '@/components';
|
||||
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
|
||||
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
|
||||
|
||||
export default function CategorizeTransactionOtherExpense() {
|
||||
const { accounts } = useCategorizeTransactionBoot();
|
||||
@@ -68,6 +69,8 @@ export default function CategorizeTransactionOtherExpense() {
|
||||
fill={true}
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<CategorizeTransactionBranchField />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FTextArea,
|
||||
} from '@/components';
|
||||
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
|
||||
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
|
||||
|
||||
export default function CategorizeTransactionOwnerDrawings() {
|
||||
const { accounts } = useCategorizeTransactionBoot();
|
||||
@@ -68,6 +69,8 @@ export default function CategorizeTransactionOwnerDrawings() {
|
||||
fill={true}
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<CategorizeTransactionBranchField />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FTextArea,
|
||||
} from '@/components';
|
||||
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
|
||||
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
|
||||
|
||||
export default function CategorizeTransactionToAccount() {
|
||||
const { accounts } = useCategorizeTransactionBoot();
|
||||
@@ -49,7 +50,7 @@ export default function CategorizeTransactionToAccount() {
|
||||
<AccountsSelect
|
||||
name={'creditAccountId'}
|
||||
items={accounts}
|
||||
filterByRootTypes={['assset']}
|
||||
filterByRootTypes={['asset']}
|
||||
fastField
|
||||
fill
|
||||
allowCreate
|
||||
@@ -68,6 +69,8 @@ export default function CategorizeTransactionToAccount() {
|
||||
fill={true}
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<CategorizeTransactionBranchField />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const defaultInitialValues = {
|
||||
transactionType: '',
|
||||
referenceNo: '',
|
||||
description: '',
|
||||
branchId: '',
|
||||
};
|
||||
|
||||
export const transformToCategorizeForm = (uncategorizedTransaction) => {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { Dialog, DialogSuspense } from '@/components';
|
||||
import withDialogRedux from '@/components/DialogReduxConnect';
|
||||
import { compose } from '@/utils';
|
||||
|
||||
const ConnectBankDialogBody = React.lazy(
|
||||
() => import('./ConnectBankDialogBody'),
|
||||
);
|
||||
|
||||
/**
|
||||
* Connect bank dialog.
|
||||
*/
|
||||
function ConnectBankDialogRoot({ dialogName, payload = {}, isOpen }) {
|
||||
return (
|
||||
<Dialog
|
||||
name={dialogName}
|
||||
title={'Securly connect your bank or credit card.'}
|
||||
isOpen={isOpen}
|
||||
canEscapeJeyClose={true}
|
||||
autoFocus={true}
|
||||
>
|
||||
<DialogSuspense>
|
||||
<ConnectBankDialogBody dialogName={dialogName} />
|
||||
</DialogSuspense>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const ConnectBankDialog = compose(withDialogRedux())(
|
||||
ConnectBankDialogRoot,
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import * as R from 'ramda';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import classNames from 'classnames';
|
||||
import { ConnectBankDialogContent } from './ConnectBankDialogContent';
|
||||
import { useGetPlaidLinkToken } from '@/hooks/query';
|
||||
import { useSetBankingPlaidToken } from '@/hooks/state/banking';
|
||||
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
||||
import { CLASSES } from '@/constants';
|
||||
import { AppToaster } from '@/components';
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
import { DialogsName } from '@/constants/dialogs';
|
||||
|
||||
const initialValues: ConnectBankDialogForm = {
|
||||
serviceProvider: 'plaid',
|
||||
};
|
||||
|
||||
interface ConnectBankDialogForm {
|
||||
serviceProvider: 'plaid';
|
||||
}
|
||||
|
||||
function ConnectBankDialogBodyRoot({
|
||||
// #withDialogActions
|
||||
closeDialog,
|
||||
}) {
|
||||
const { mutateAsync: getPlaidLinkToken } = useGetPlaidLinkToken();
|
||||
const setPlaidId = useSetBankingPlaidToken();
|
||||
|
||||
// Handles the form submitting.
|
||||
const handleSubmit = (
|
||||
values: ConnectBankDialogForm,
|
||||
{ setSubmitting }: FormikHelpers<ConnectBankDialogForm>,
|
||||
) => {
|
||||
setSubmitting(true);
|
||||
getPlaidLinkToken()
|
||||
.then((res) => {
|
||||
setSubmitting(false);
|
||||
closeDialog(DialogsName.ConnectBankCreditCard);
|
||||
setPlaidId(res.data.link_token);
|
||||
})
|
||||
.catch(() => {
|
||||
setSubmitting(false);
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(CLASSES.DIALOG_BODY)}>
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
<Form>
|
||||
<ConnectBankDialogContent />
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default R.compose(withDialogActions)(ConnectBankDialogBodyRoot);
|
||||
@@ -1,48 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import styled from 'styled-components';
|
||||
import { Stack } from '@/components';
|
||||
import { TellerIcon } from '../Icons/TellerIcon';
|
||||
import { YodleeIcon } from '../Icons/YodleeIcon';
|
||||
import { PlaidIcon } from '../Icons/PlaidIcon';
|
||||
import { BankServiceCard } from './ConnectBankServiceCard';
|
||||
|
||||
const TopDesc = styled('p')`
|
||||
margin-bottom: 20px;
|
||||
color: #5f6b7c;
|
||||
`;
|
||||
|
||||
export function ConnectBankDialogContent() {
|
||||
return (
|
||||
<div>
|
||||
<TopDesc>
|
||||
Connect your bank accounts and fetch the bank transactions using
|
||||
one of our supported third-party service providers.
|
||||
</TopDesc>
|
||||
|
||||
<Stack>
|
||||
<BankServiceCard
|
||||
title={'Plaid (US, UK & Canada)'}
|
||||
icon={<PlaidIcon />}
|
||||
>
|
||||
Plaid gives the connection to 12,000 financial institutions across US, UK and Canada.
|
||||
</BankServiceCard>
|
||||
|
||||
<BankServiceCard
|
||||
title={'Teller (US) — Soon'}
|
||||
icon={<TellerIcon />}
|
||||
disabled
|
||||
>
|
||||
Connect instantly with more than 5,000 financial institutions across US.
|
||||
</BankServiceCard>
|
||||
|
||||
<BankServiceCard
|
||||
title={'Yodlee (Global) — Soon'}
|
||||
icon={<YodleeIcon />}
|
||||
disabled
|
||||
>
|
||||
Connect instantly with a global network of financial institutions.
|
||||
</BankServiceCard>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
import { Group } from '@/components';
|
||||
|
||||
const BankServiceIcon = styled('div')`
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border: 1px solid #c8cad0;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
|
||||
svg {
|
||||
margin: auto;
|
||||
}
|
||||
`;
|
||||
const BankServiceContent = styled(`div`)`
|
||||
flex: 1 0;
|
||||
`;
|
||||
const BankServiceCardRoot = styled('button')`
|
||||
border-radius: 3px;
|
||||
border: 1px solid #c8cad0;
|
||||
transition: all 0.1s ease-in-out;
|
||||
background: transparent;
|
||||
text-align: inherit;
|
||||
padding: 14px;
|
||||
|
||||
&:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #0153cc;
|
||||
}
|
||||
&:disabled {
|
||||
background: #f9fdff;
|
||||
}
|
||||
`;
|
||||
const BankServiceTitle = styled(`h3`)`
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #2d333d;
|
||||
`;
|
||||
const BankServiceDesc = styled('p')`
|
||||
margin-top: 4px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: #738091;
|
||||
`;
|
||||
|
||||
interface BankServiceCardProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BankServiceCard({
|
||||
title,
|
||||
children,
|
||||
icon,
|
||||
disabled,
|
||||
}: BankServiceCardProps) {
|
||||
return (
|
||||
<BankServiceCardRoot disabled={disabled}>
|
||||
<Group>
|
||||
<BankServiceIcon>{icon}</BankServiceIcon>
|
||||
<BankServiceContent>
|
||||
<BankServiceTitle>{title}</BankServiceTitle>
|
||||
<BankServiceDesc>{children}</BankServiceDesc>
|
||||
</BankServiceContent>
|
||||
</Group>
|
||||
</BankServiceCardRoot>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ConnectBankDialog';
|
||||
@@ -96,12 +96,19 @@ const GeneralLedgerDataTable = styled(ReportDataTable)`
|
||||
}
|
||||
}
|
||||
}
|
||||
&:not(:first-child).is-expanded .td {
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
}
|
||||
&--OPENING_BALANCE,
|
||||
&--CLOSING_BALANCE {
|
||||
.td {
|
||||
color: #000;
|
||||
}
|
||||
.date {
|
||||
font-weight: 500;
|
||||
|
||||
.cell-inner {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
.amount {
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -110,6 +117,9 @@ const GeneralLedgerDataTable = styled(ReportDataTable)`
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.td {
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
packages/webapp/src/hooks/utils/useOpenPlaidConnect.ts
Normal file
25
packages/webapp/src/hooks/utils/useOpenPlaidConnect.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSetBankingPlaidToken } from '../state/banking';
|
||||
import { AppToaster } from '@/components';
|
||||
import { useGetPlaidLinkToken } from '../query';
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
|
||||
export const useOpenPlaidConnect = () => {
|
||||
const { mutateAsync: getPlaidLinkToken, isLoading } = useGetPlaidLinkToken();
|
||||
const setPlaidId = useSetBankingPlaidToken();
|
||||
|
||||
const openPlaidAsync = useCallback(() => {
|
||||
return getPlaidLinkToken()
|
||||
.then((res) => {
|
||||
setPlaidId(res.data.link_token);
|
||||
})
|
||||
.catch(() => {
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
});
|
||||
}, [getPlaidLinkToken, setPlaidId]);
|
||||
|
||||
return { openPlaidAsync, isPlaidLoading: isLoading };
|
||||
};
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -50,6 +50,9 @@ importers:
|
||||
'@lemonsqueezy/lemonsqueezy.js':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
'@supercharge/promise-pool':
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
'@types/express':
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
@@ -5751,6 +5754,11 @@ packages:
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
||||
dependencies:
|
||||
@@ -17382,6 +17390,7 @@ packages:
|
||||
|
||||
/memory-pager@1.5.0:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
|
||||
/memorystream@0.3.1:
|
||||
@@ -23472,6 +23481,7 @@ packages:
|
||||
|
||||
/sparse-bitfield@3.0.3:
|
||||
resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
memory-pager: 1.5.0
|
||||
dev: false
|
||||
|
||||
Reference in New Issue
Block a user