1
0

Merge remote-tracking branch 'refs/remotes/origin/feat/financial-audit-trail' into feat/financial-audit-trail

This commit is contained in:
Ahmed Bouhuolia
2026-05-17 20:46:03 +02:00
603 changed files with 84458 additions and 2613 deletions
+13
View File
@@ -0,0 +1,13 @@
# Claude Code Settings for Bigcapital
## Node.js Version
Always use Node.js 18.16.1 for this project. Before running any npm/pnpm/node commands:
```bash
nvm use 18.16.1
```
## Package Manager
Use `pnpm` for this project.
+114 -28
View File
@@ -1,5 +1,6 @@
name: E2E
on:
workflow_dispatch:
push:
branches:
- main
@@ -23,46 +24,131 @@ defaults:
shell: 'bash'
jobs:
test_setup:
name: Test setup
runs-on: ubuntu-latest
outputs:
preview_url: ${{ steps.waitForVercelPreviewDeployment.outputs.url }}
steps:
- name: Wait for Vercel preview deployment to be ready
uses: patrickedqvist/wait-for-vercel-preview@v1.3.1
id: waitForVercelPreviewDeployment
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 3000
test_e2e:
runs-on: ubuntu-latest
needs: test_setup
name: Playwright tests
timeout-minutes: 15
environment: ${{ vars.ENVIRONMENT_STAGE }}
timeout-minutes: 20
services:
mysql:
image: mariadb:10.6
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: bigcapital_system
MYSQL_USER: bigcapital
MYSQL_PASSWORD: bigcapital
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 14 # Need for npm >=7.7
cache: 'npm'
node-version: 18.16.1
- uses: pnpm/action-setup@v2
with:
version: 9
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
id: pnpm-cache
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: npm install
run: pnpm install --frozen-lockfile
- name: Install Playwright with deps
run: npx playwright install --with-deps
- name: Build shared packages
run: pnpm build
- name: Run tests
run: npm run test:e2e
- name: Create server .env file
run: |
cat > packages/server/.env << EOF
# Database
DB_HOST=127.0.0.1
DB_USER=bigcapital
DB_PASSWORD=bigcapital
DB_ROOT_PASSWORD=root
DB_CHARSET=utf8
# System database
SYSTEM_DB_NAME=bigcapital_system
# Tenant databases
TENANT_DB_NAME_PERFIX=bigcapital_tenant_
# Application
BASE_URL=http://localhost:3000
JWT_SECRET=test-jwt-secret-for-e2e-testing
APP_JWT_SECRET=test-app-jwt-secret
# Sign-up
SIGNUP_DISABLED=false
# API rate limit
API_RATE_LIMIT=120,60,600
# Redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# S3 (placeholder values for E2E tests)
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=test-access-key
S3_SECRET_ACCESS_KEY=test-secret-key
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=test-bucket
# Mail (placeholder values for E2E tests)
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_FROM_NAME=Bigcapital
MAIL_FROM_ADDRESS=test@bigcapital.app
EOF
- name: Run database migrations
run: pnpm system:migrate:latest
- name: Start server in background
run: |
cd packages/server && pnpm start:prod &
sleep 10
env:
PLAYWRIGHT_TEST_BASE_URL: ${{ needs.test_setup.outputs.preview_url }}
NODE_ENV: production
- uses: actions/upload-artifact@v2
- name: Start webapp in background
run: |
cd packages/webapp && pnpm dev &
sleep 10
- name: Install Playwright browsers
run: pnpm exec playwright install chromium
- name: Run E2E tests
run: pnpm run test:e2e
env:
PLAYWRIGHT_TEST_BASE_URL: http://localhost:4000
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: test-results/
retention-days: 30
retention-days: 30
+154
View File
@@ -0,0 +1,154 @@
name: Generate OpenAPI SDK Types
on:
push:
branches:
- main
- develop
paths:
- 'packages/server/src/**/*.ts'
- 'packages/server/src/**/*.dto.ts'
- '!packages/server/src/**/*.spec.ts'
- '!packages/server/src/**/*.test.ts'
workflow_dispatch:
defaults:
run:
shell: 'bash'
env:
# Database configuration
DB_HOST: 127.0.0.1
DB_USER: root
DB_PASSWORD: root
DB_CHARSET: utf8
SYSTEM_DB_NAME: bigcapital_system
TENANT_DB_NAME_PERFIX: bigcapital_tenant_
# Redis configuration
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
# Queue configuration
QUEUE_HOST: 127.0.0.1
QUEUE_PORT: 6379
# App configuration
APP_JWT_SECRET: test-jwt-secret-for-openapi-generation
JWT_SECRET: test-jwt-secret-for-openapi-generation
BASE_URL: http://localhost:3000
# Feature flags
SIGNUP_DISABLED: 'false'
SIGNUP_EMAIL_CONFIRMATION: 'false'
API_RATE_LIMIT: 120,60,600
# Optional services (empty for OpenAPI generation)
MAIL_HOST: ''
MAIL_PORT: ''
MAIL_USERNAME: ''
MAIL_PASSWORD: ''
MAIL_FROM_NAME: ''
MAIL_FROM_ADDRESS: ''
GOTENBERG_URL: ''
GOTENBERG_DOCS_URL: ''
PLAID_CLIENT_ID: ''
PLAID_SECRET: ''
LEMONSQUEEZY_API_KEY: ''
S3_ACCESS_KEY_ID: ''
S3_SECRET_ACCESS_KEY: ''
S3_BUCKET: ''
POSTHOG_API_KEY: ''
STRIPE_PAYMENT_SECRET_KEY: ''
OPEN_EXCHANGE_RATE_APP_ID: ''
BULLBOARD_ENABLED: 'false'
BULLBOARD_USERNAME: ''
BULLBOARD_PASSWORD: ''
jobs:
generate-openapi:
name: Generate OpenAPI and SDK Types
runs-on: ubuntu-latest
timeout-minutes: 15
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: bigcapital_system
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=10s
--health-timeout=5s
--health-retries=10
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=10
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared packages
run: pnpm run build --scope "@bigcapital/utils" --scope "@bigcapital/email-components" --scope "@bigcapital/pdf-templates"
- name: Generate OpenAPI spec and SDK types
run: pnpm run generate:sdk-types
- name: Check for changes
id: check-changes
run: |
if git diff --quiet shared/sdk-ts/; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.check-changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore(sdk): update OpenAPI spec and generated types'
title: 'chore(sdk): update OpenAPI spec and generated types'
body: |
## Summary
Automated update of OpenAPI specification and generated TypeScript SDK types.
This PR was automatically generated by the `generate-openapi.yml` workflow due to server code changes.
## Changes
- Updated `shared/sdk-ts/openapi.json`
- Regenerated `shared/sdk-ts/src/schema.ts`
## Test plan
- [ ] Verify the generated types compile correctly
- [ ] Check that no breaking changes were introduced to the API types
branch: chore/update-openapi-sdk-types
base: ${{ github.ref_name }}
labels: |
automated
sdk
delete-branch: true
+3
View File
@@ -8,3 +8,6 @@ node_modules/
test-results/
.qodo
.pnpm-store
+1 -1
View File
@@ -1 +1 @@
v14.20
18.16.1
+4 -1
View File
@@ -22,7 +22,10 @@
"system:migrate:latest": "lerna run cli:system:migrate:latest --scope \"@bigcapital/server\"",
"tenants:migrate:latest": "lerna run cli:tenants:migrate:latest --scope \"@bigcapital/server\"",
"system:seed:latest": "lerna run cli:system:seed:latest --scope \"@bigcapital/server\"",
"tenants:seed:latest": "lerna run cli:tenants:seed:latest --scope \"@bigcapital/server\""
"tenants:seed:latest": "lerna run cli:tenants:seed:latest --scope \"@bigcapital/server\"",
"generate:sdk-types": "lerna run openapi:export --scope \"@bigcapital/server\" && lerna run generate --scope \"@bigcapital/sdk-ts\" && lerna run build --scope \"@bigcapital/sdk-ts\"",
"format": "lerna run format",
"format:check": "lerna run format:check"
},
"devDependencies": {
"@commitlint/cli": "^17.4.2",
+6 -15
View File
@@ -4,7 +4,7 @@ FROM node:18.16.0-alpine AS builder
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@8.10.2
RUN npm install -g pnpm@9.0.5
# Install build dependencies
RUN apk add --no-cache python3 build-base chromium
@@ -15,18 +15,13 @@ ENV PYTHON=/usr/bin/python3
# Copy package files for dependency installation
COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml lerna.json ./
COPY --chown=node:node packages/server/package.json ./packages/server/
COPY --chown=node:node shared/bigcapital-utils/package.json ./shared/bigcapital-utils/
COPY --chown=node:node shared/pdf-templates/package.json ./shared/pdf-templates/
COPY --chown=node:node shared/email-components/package.json ./shared/email-components/
COPY --chown=node:node shared ./shared
# Install all dependencies (including devDependencies for build)
RUN pnpm install --frozen-lockfile
# Copy source code
COPY --chown=node:node ./packages/server ./packages/server
COPY --chown=node:node ./shared/bigcapital-utils ./shared/bigcapital-utils
COPY --chown=node:node ./shared/pdf-templates ./shared/pdf-templates
COPY --chown=node:node ./shared/email-components ./shared/email-components
# Build NestJS application
RUN pnpm run build:server --skip-nx-cache
@@ -37,7 +32,7 @@ FROM node:18.16.0-alpine AS production
WORKDIR /app
# Install pnpm for production
RUN npm install -g pnpm@8.10.2
RUN npm install -g pnpm@9.0.5
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
@@ -52,9 +47,7 @@ ENV PYTHON=/usr/bin/python3
# Copy package files for production dependency installation
COPY --chown=nodejs:nodejs package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY --chown=nodejs:nodejs packages/server/package.json ./packages/server/
COPY --chown=nodejs:nodejs shared/bigcapital-utils/package.json ./shared/bigcapital-utils/
COPY --chown=nodejs:nodejs shared/pdf-templates/package.json ./shared/pdf-templates/
COPY --chown=nodejs:nodejs shared/email-components/package.json ./shared/email-components/
COPY --chown=nodejs:nodejs shared ./shared
# Copy .husky directory (needed for husky install command)
COPY --chown=nodejs:nodejs .husky ./.husky
@@ -78,10 +71,8 @@ COPY --from=builder --chown=nodejs:nodejs /app/packages/server/static ./packages
# Copy database migration files (needed for running migrations)
COPY --from=builder --chown=nodejs:nodejs /app/packages/server/src/database ./packages/server/src/database
# Copy built shared packages (dist folders and package.json for module resolution)
COPY --from=builder --chown=nodejs:nodejs /app/shared/bigcapital-utils/dist ./shared/bigcapital-utils/dist
COPY --from=builder --chown=nodejs:nodejs /app/shared/pdf-templates/dist ./shared/pdf-templates/dist
COPY --from=builder --chown=nodejs:nodejs /app/shared/email-components/dist ./shared/email-components/dist
# Copy all shared packages from builder so newly added shared packages are included automatically
COPY --from=builder --chown=nodejs:nodejs /app/shared ./shared
# Set runtime environment variables (these should be provided at runtime via docker-compose or k8s)
ENV NODE_ENV=production
+3 -1
View File
@@ -18,7 +18,7 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json --watchAll",
"test:e2e": "jest --config ./test/jest-e2e.json --forceExit",
"cli": "ts-node -r tsconfig-paths/register src/cli.ts",
"cli:system:migrate:latest": "ts-node -r tsconfig-paths/register src/cli.ts system:migrate:latest",
"cli:system:migrate:rollback": "ts-node -r tsconfig-paths/register src/cli.ts system:migrate:rollback",
@@ -27,6 +27,7 @@
"cli:tenants:migrate:rollback": "ts-node -r tsconfig-paths/register src/cli.ts tenants:migrate:rollback",
"cli:tenants:migrate:make": "ts-node -r tsconfig-paths/register src/cli.ts tenants:migrate:make",
"cli:tenants:list": "ts-node -r tsconfig-paths/register src/cli.ts tenants:list",
"openapi:export": "ts-node -r tsconfig-paths/register src/cli.ts openapi:export",
"cli:system:seed:latest": "ts-node -r tsconfig-paths/register src/cli.ts system:seed:latest",
"cli:tenants:seed:latest": "ts-node -r tsconfig-paths/register src/cli.ts tenants:seed:latest"
},
@@ -36,6 +37,7 @@
"@bigcapital/email-components": "workspace:*",
"@bigcapital/pdf-templates": "workspace:*",
"@bigcapital/utils": "workspace:*",
"@bigcapital/sdk-ts": "workspace:*",
"@bull-board/api": "^5.22.0",
"@bull-board/express": "^5.22.0",
"@bull-board/nestjs": "^5.22.0",
+1
View File
@@ -1,3 +1,4 @@
/// <reference path="./common/types/Objection.d.ts" />
import { CommandFactory } from 'nest-commander';
import { CLIModule } from './modules/CLI/CLI.module';
+1
View File
@@ -6,4 +6,5 @@ export default registerAs('s3', () => ({
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
}));
+1 -1
View File
@@ -5,7 +5,7 @@ export const ACCOUNT_TYPE = {
INVENTORY: 'inventory',
OTHER_CURRENT_ASSET: 'other-current-asset',
FIXED_ASSET: 'fixed-asset',
NON_CURRENT_ASSET: 'none-current-asset',
NON_CURRENT_ASSET: 'non-current-asset',
ACCOUNTS_PAYABLE: 'accounts-payable',
CREDIT_CARD: 'credit-card',
@@ -0,0 +1,12 @@
exports.up = function(knex) {
return knex.schema.alterTable('contacts', table => {
table.string('code').nullable().unique();
});
};
exports.down = function(knex) {
return knex.schema.alterTable('contacts', table => {
table.dropColumn('code');
});
};
@@ -0,0 +1,19 @@
/**
* Fix account type typos in the database.
*
* This migration corrects the following typos:
* - 'none-current-asset' -> 'non-current-asset'
*
* Related GitHub issue: #1041
*/
exports.up = function (knex) {
return knex('accounts')
.where('account_type', 'none-current-asset')
.update({ account_type: 'non-current-asset' });
};
exports.down = function (knex) {
return knex('accounts')
.where('account_type', 'non-current-asset')
.update({ account_type: 'none-current-asset' });
};
@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.alterTable('documents', (table) => {
table.unique('key');
});
};
exports.down = function (knex) {
return knex.schema.alterTable('documents', (table) => {
table.dropUnique('key');
});
};
@@ -9,7 +9,7 @@
"non_current_assets": "Non-Current Assets",
"liabilities_and_equity": "Liabilities and Equity",
"liabilities": "Liabilities",
"current_liabilties": "Current Liabilties",
"current_liabilities": "Current Liabilities",
"long_term_liabilities": "Long-Term Liabilities",
"non_current_liabilities": "Non-Current Liabilities",
"equity": "Equity",
@@ -9,5 +9,10 @@
"net_cash_financing": "Net cash provided by financing activities",
"cash_beginning_period": "Cash at beginning of period",
"net_cash_increase": "NET CASH INCREASE FOR PERIOD",
"cash_end_period": "CASH AT END OF PERIOD"
"cash_end_period": "CASH AT END OF PERIOD",
"account_name": "Account name",
"total": "Total",
"sheet_name": "Statement of Cash Flow",
"from_date": "From",
"to_date": "To"
}
@@ -0,0 +1,5 @@
{
"account_name": "Account name",
"total": "Total",
"percentage_column": "% of Column"
}
@@ -1,4 +1,9 @@
{
"view.draft": "Draft",
"view.published": "Published",
"view.open": "Open",
"view.closed": "Closed",
"field.customer": "Customer",
"field.exchange_rate": "Exchange Rate",
"field.credit_note_date": "Credit Note Date",
@@ -0,0 +1,14 @@
{
"opening_balance": "Opening balance",
"closing_balance": "Closing balance",
"date": "Date",
"transaction_type": "Transaction type",
"transaction_number": "Transaction #",
"quantity": "Quantity",
"rate": "Rate",
"total": "Total",
"value": "Value",
"profit_margin": "Profit Margin",
"running_quantity": "Running quantity",
"running_value": "Running Value"
}
@@ -0,0 +1,4 @@
{
"opening_balance": "Opening balance",
"closing_balance": "Closing balance"
}
@@ -0,0 +1,6 @@
{
"account": "Account",
"debit": "Debit",
"credit": "Credit",
"total": "Total"
}
+4 -2
View File
@@ -1,5 +1,6 @@
import { QueryBuilder, Model } from 'objection';
import { QueryBuilder, Model, mixin } from 'objection';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
import { withDateSessionMixin } from './withDateSessionMixin';
interface PaginationResult<M extends Model> {
results: M[];
@@ -69,6 +70,7 @@ export class PaginationQueryBuilder<
dependentRelationNames.forEach((relationName: string) => {
recordQuery.withGraphFetched(relationName);
});
const record = await recordQuery;
const hasRelations = dependentRelationNames.some((name) => {
@@ -97,7 +99,7 @@ export class BaseQueryBuilder<
}
}
export class BaseModel extends Model {
export class BaseModel extends mixin(Model, [withDateSessionMixin]) {
public readonly id: number;
public readonly tableName: string;
@@ -0,0 +1,40 @@
import * as moment from 'moment';
import { Model } from 'objection';
type Constructor<T = {}> = new (...args: any[]) => T;
export const withDateSessionMixin = <T extends Constructor<Model>>(BaseModel: T) => {
return class DateSession extends BaseModel {
constructor(...args: any[]) {
super(...args);
}
get timestamps() {
return [];
}
$beforeUpdate(opt, context) {
const maybePromise = super.$beforeUpdate(opt, context);
return Promise.resolve(maybePromise).then(() => {
const key = this.timestamps[1];
if (key && !this[key]) {
this[key] = moment().format('YYYY/MM/DD HH:mm:ss');
}
});
}
$beforeInsert(context) {
const maybePromise = super.$beforeInsert(context);
return Promise.resolve(maybePromise).then(() => {
const key = this.timestamps[0];
if (key && !this[key]) {
this[key] = moment().format('YYYY/MM/DD HH:mm:ss');
}
});
}
}
}
@@ -412,7 +412,7 @@ export const ACCOUNT_TYPE = {
INVENTORY: 'inventory',
OTHER_CURRENT_ASSET: 'other-current-asset',
FIXED_ASSET: 'fixed-asset',
NON_CURRENT_ASSET: 'none-current-asset',
NON_CURRENT_ASSET: 'non-current-asset',
ACCOUNTS_PAYABLE: 'accounts-payable',
CREDIT_CARD: 'credit-card',
@@ -9,6 +9,7 @@ import {
ParseIntPipe,
Put,
HttpCode,
UseGuards,
} from '@nestjs/common';
import { AccountsApplication } from './AccountsApplication.service';
import { CreateAccountDTO } from './CreateAccount.dto';
@@ -32,6 +33,11 @@ import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
import { PermissionGuard } from '@/modules/Roles/Permission.guard';
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
import { AbilitySubject } from '@/modules/Roles/Roles.types';
import { AccountAction } from './Accounts.types';
@Controller('accounts')
@ApiTags('Accounts')
@@ -40,11 +46,13 @@ import {
@ApiExtraModels(GetAccountTransactionResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders()
@UseGuards(AuthorizationGuard, PermissionGuard)
export class AccountsController {
constructor(private readonly accountsApplication: AccountsApplication) { }
@Post('validate-bulk-delete')
@HttpCode(200)
@RequirePermission(AccountAction.DELETE, AbilitySubject.Account)
@ApiOperation({
summary:
'Validates which accounts can be deleted and returns counts of deletable and non-deletable accounts.',
@@ -67,6 +75,7 @@ export class AccountsController {
@Post('bulk-delete')
@HttpCode(200)
@RequirePermission(AccountAction.DELETE, AbilitySubject.Account)
@ApiOperation({ summary: 'Deletes multiple accounts in bulk.' })
@ApiResponse({
status: 200,
@@ -81,6 +90,7 @@ export class AccountsController {
}
@Post()
@RequirePermission(AccountAction.CREATE, AbilitySubject.Account)
@ApiOperation({ summary: 'Create an account' })
@ApiResponse({
status: 200,
@@ -91,6 +101,7 @@ export class AccountsController {
}
@Put(':id')
@RequirePermission(AccountAction.EDIT, AbilitySubject.Account)
@ApiOperation({ summary: 'Edit the given account.' })
@ApiResponse({
status: 200,
@@ -111,6 +122,7 @@ export class AccountsController {
}
@Delete(':id')
@RequirePermission(AccountAction.DELETE, AbilitySubject.Account)
@ApiOperation({ summary: 'Delete the given account.' })
@ApiResponse({
status: 200,
@@ -129,6 +141,7 @@ export class AccountsController {
@Post(':id/activate')
@HttpCode(200)
@RequirePermission(AccountAction.EDIT, AbilitySubject.Account)
@ApiOperation({ summary: 'Activate the given account.' })
@ApiResponse({
status: 200,
@@ -147,6 +160,7 @@ export class AccountsController {
@Post(':id/inactivate')
@HttpCode(200)
@RequirePermission(AccountAction.EDIT, AbilitySubject.Account)
@ApiOperation({ summary: 'Inactivate the given account.' })
@ApiResponse({
status: 200,
@@ -164,6 +178,7 @@ export class AccountsController {
}
@Get('types')
@RequirePermission(AccountAction.VIEW, AbilitySubject.Account)
@ApiOperation({ summary: 'Retrieves the account types.' })
@ApiResponse({
status: 200,
@@ -180,6 +195,7 @@ export class AccountsController {
}
@Get('transactions')
@RequirePermission(AccountAction.VIEW, AbilitySubject.Account)
@ApiOperation({ summary: 'Retrieves the account transactions.' })
@ApiResponse({
status: 200,
@@ -198,6 +214,7 @@ export class AccountsController {
}
@Get(':id')
@RequirePermission(AccountAction.VIEW, AbilitySubject.Account)
@ApiOperation({ summary: 'Retrieves the account details.' })
@ApiResponse({
status: 200,
@@ -216,6 +233,7 @@ export class AccountsController {
}
@Get()
@RequirePermission(AccountAction.VIEW, AbilitySubject.Account)
@ApiOperation({ summary: 'Retrieves the accounts.' })
@ApiResponse({
status: 200,
@@ -21,6 +21,7 @@ import { AccountsExportable } from './AccountsExportable.service';
import { AccountsImportable } from './AccountsImportable.service';
import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service';
import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service';
import { AccountsSettingsService } from './AccountsSettings.service';
const models = [RegisterTenancyModel(BankAccount)];
@@ -29,6 +30,7 @@ const models = [RegisterTenancyModel(BankAccount)];
controllers: [AccountsController],
providers: [
AccountsApplication,
AccountsSettingsService,
CreateAccountService,
TenancyContext,
CommandAccountValidators,
@@ -49,9 +51,10 @@ const models = [RegisterTenancyModel(BankAccount)];
exports: [
AccountRepository,
CreateAccountService,
AccountsSettingsService,
...models,
AccountsExportable,
AccountsImportable
AccountsImportable,
],
})
export class AccountsModule {}
@@ -0,0 +1,33 @@
import { Inject, Injectable } from '@nestjs/common';
import { SettingsStore } from '../Settings/SettingsStore';
import { SETTINGS_PROVIDER } from '../Settings/Settings.types';
export interface IAccountsSettings {
accountCodeRequired: boolean;
accountCodeUnique: boolean;
}
@Injectable()
export class AccountsSettingsService {
constructor(
@Inject(SETTINGS_PROVIDER)
private readonly settingsStore: () => SettingsStore,
) {}
/**
* Retrieves account settings (account code required, account code unique).
*/
public async getAccountsSettings(): Promise<IAccountsSettings> {
const settingsStore = await this.settingsStore();
return {
accountCodeRequired: settingsStore.get(
{ group: 'accounts', key: 'account_code_required' },
false,
),
accountCodeUnique: settingsStore.get(
{ group: 'accounts', key: 'account_code_unique' },
true,
),
};
}
}
@@ -106,6 +106,20 @@ export class CommandAccountValidators {
}
}
/**
* Throws error if account code is missing or blank when required.
* @param {string|undefined} code - Account code.
*/
public validateAccountCodeRequiredOrThrow(code: string | undefined) {
const trimmed = typeof code === 'string' ? code.trim() : '';
if (!trimmed) {
throw new ServiceError(
ERRORS.ACCOUNT_CODE_REQUIRED,
'Account code is required.',
);
}
}
/**
* Validates the account name uniquiness.
* @param {string} accountName - Account name.
@@ -15,6 +15,7 @@ import { events } from '@/common/events/events';
import { CreateAccountDTO } from './CreateAccount.dto';
import { PartialModelObject } from 'objection';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { AccountsSettingsService } from './AccountsSettings.service';
@Injectable()
export class CreateAccountService {
@@ -32,6 +33,7 @@ export class CreateAccountService {
private readonly uow: UnitOfWork,
private readonly validator: CommandAccountValidators,
private readonly tenancyContext: TenancyContext,
private readonly accountsSettings: AccountsSettingsService,
) {}
/**
@@ -43,14 +45,21 @@ export class CreateAccountService {
baseCurrency: string,
params?: CreateAccountParams,
) => {
const { accountCodeRequired, accountCodeUnique } =
await this.accountsSettings.getAccountsSettings();
// Validate account code required when setting is enabled.
if (accountCodeRequired) {
this.validator.validateAccountCodeRequiredOrThrow(accountDTO.code);
}
// Validate the account code uniquiness when setting is enabled.
if (accountCodeUnique && accountDTO.code?.trim()) {
await this.validator.isAccountCodeUniqueOrThrowError(accountDTO.code);
}
// Validate account name uniquiness.
if (!params.ignoreUniqueName) {
await this.validator.validateAccountNameUniquiness(accountDTO.name);
}
// Validate the account code uniquiness.
if (accountDTO.code) {
await this.validator.isAccountCodeUniqueOrThrowError(accountDTO.code);
}
// Retrieve the account type meta or throw service error if not found.
this.validator.getAccountTypeOrThrowError(accountDTO.accountType);
@@ -7,6 +7,7 @@ import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { EditAccountDTO } from './EditAccount.dto';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { AccountsSettingsService } from './AccountsSettings.service';
@Injectable()
export class EditAccount {
@@ -17,7 +18,8 @@ export class EditAccount {
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
) { }
private readonly accountsSettings: AccountsSettingsService,
) {}
/**
* Authorize the account editing.
@@ -30,6 +32,24 @@ export class EditAccount {
accountDTO: EditAccountDTO,
oldAccount: Account,
) => {
const { accountCodeRequired, accountCodeUnique } =
await this.accountsSettings.getAccountsSettings();
// Validate account code required when setting is enabled.
if (accountCodeRequired) {
this.validator.validateAccountCodeRequiredOrThrow(accountDTO.code);
}
// Validate the account code uniquiness when setting is enabled.
if (
accountCodeUnique &&
accountDTO.code?.trim() &&
accountDTO.code !== oldAccount.code
) {
await this.validator.isAccountCodeUniqueOrThrowError(
accountDTO.code,
oldAccount.id,
);
}
// Validate account name uniquiness.
await this.validator.validateAccountNameUniquiness(
accountDTO.name,
@@ -40,13 +60,6 @@ export class EditAccount {
oldAccount,
accountDTO,
);
// Validate the account code not exists on the storage.
if (accountDTO.code && accountDTO.code !== oldAccount.code) {
await this.validator.isAccountCodeUniqueOrThrowError(
accountDTO.code,
oldAccount.id,
);
}
// Retrieve the parent account of throw not found service error.
if (accountDTO.parentAccountId) {
const parentAccount = await this.validator.getParentAccountOrThrowError(
@@ -3,6 +3,7 @@ export const ERRORS = {
ACCOUNT_TYPE_NOT_FOUND: 'account_type_not_found',
PARENT_ACCOUNT_NOT_FOUND: 'parent_account_not_found',
ACCOUNT_CODE_NOT_UNIQUE: 'account_code_not_unique',
ACCOUNT_CODE_REQUIRED: 'account_code_required',
ACCOUNT_NAME_NOT_UNIQUE: 'account_name_not_unqiue',
PARENT_ACCOUNT_HAS_DIFFERENT_TYPE: 'parent_has_different_type',
ACCOUNT_TYPE_NOT_ALLOWED_TO_CHANGE: 'account_type_not_allowed_to_changed',
@@ -1,8 +1,10 @@
import { IsBoolean, IsEnum, IsOptional } from 'class-validator';
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
import { IAccountsStructureType } from '../Accounts.types';
import { IFilterRole, ISortOrder } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types';
import { ToNumber } from '@/common/decorators/Validators';
export class GetAccountsQueryDto {
@ApiPropertyOptional({
@@ -23,5 +25,93 @@ export class GetAccountsQueryDto {
@IsOptional()
@IsEnum(IAccountsStructureType)
structure?: IAccountsStructureType;
@ApiPropertyOptional({
description: 'Custom view ID',
type: Number,
example: 1,
})
@IsOptional()
@ToNumber()
@IsInt()
customViewId?: number;
@ApiPropertyOptional({
description: 'Filter roles array',
type: Array,
isArray: true,
})
@IsArray()
@IsOptional()
filterRoles?: IFilterRole[];
@ApiPropertyOptional({
description: 'Column to sort by',
type: String,
example: 'created_at',
})
@IsOptional()
@IsString()
columnSortBy?: string;
@ApiPropertyOptional({
description: 'Sort order',
enum: ISortOrder,
example: ISortOrder.DESC,
})
@IsOptional()
@IsEnum(ISortOrder)
sortOrder?: string;
@ApiPropertyOptional({
description: 'Stringified filter roles',
type: String,
example: '{"fieldKey":"root_type","value":"asset"}',
})
@IsOptional()
@IsString()
stringifiedFilterRoles?: string;
@ApiPropertyOptional({
description: 'Search keyword',
type: String,
example: 'bank account',
})
@IsOptional()
@IsString()
searchKeyword?: string;
@ApiPropertyOptional({
description: 'View slug',
type: String,
example: 'assets',
})
@IsOptional()
@IsString()
viewSlug?: string;
@ApiPropertyOptional({
description: 'Page number',
type: Number,
example: 1,
minimum: 1,
})
@IsOptional()
@ToNumber()
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({
description: 'Page size',
type: Number,
example: 25,
minimum: 1,
})
@IsOptional()
@ToNumber()
@IsInt()
@Min(1)
pageSize?: number;
}
@@ -18,7 +18,7 @@ import { createBullBoardAuthMiddleware } from '@/middleware/bull-board-auth.midd
import { BullModule } from '@nestjs/bullmq';
import { ScheduleModule } from '@nestjs/schedule';
import { PassportModule } from '@nestjs/passport';
import { ClsModule, ClsService } from 'nestjs-cls';
import { ClsModule } from 'nestjs-cls';
import { AppController } from './App.controller';
import { AppService } from './App.service';
import { ItemsModule } from '../Items/Items.module';
@@ -99,6 +99,7 @@ import { UsersModule } from '../UsersModule/Users.module';
import { ContactsModule } from '../Contacts/Contacts.module';
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module';
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
import { SocketModule } from '../Socket/Socket.module';
@@ -124,7 +125,7 @@ import { AppThrottleModule } from './AppThrottle.module';
useFactory: () => ({
fallbackLanguage: 'en',
loaderOptions: {
path: join(__dirname, '/../../i18n/'),
path: join(__dirname, '../../i18n/'),
watch: true,
},
}),
@@ -169,9 +170,6 @@ import { AppThrottleModule } from './AppThrottle.module';
global: true,
middleware: {
mount: true,
setup: (cls: ClsService, req: Request, res: Response) => {
cls.set('organizationId', req.headers['organization-id']);
},
generateId: true,
saveReq: true,
},
@@ -258,6 +256,7 @@ import { AppThrottleModule } from './AppThrottle.module';
ContactsModule,
SocketModule,
EEModule,
ExchangeRatesModule,
],
controllers: [AppController],
providers: [
@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";
import { randomUUID } from 'node:crypto';
import * as multerS3 from 'multer-s3';
import { ClsService } from 'nestjs-cls';
import { S3_CLIENT, S3Module } from "../S3/S3.module";
import { DeleteAttachment } from "./DeleteAttachment";
import { GetAttachment } from "./GetAttachment";
import { getAttachmentPresignedUrl } from "./GetAttachmentPresignedUrl";
import { GetAttachmentPresignedUrl } from "./GetAttachmentPresignedUrl";
import { LinkAttachment } from "./LinkAttachment";
import { UnlinkAttachment } from "./UnlinkAttachment";
import { ValidateAttachments } from "./ValidateAttachments";
@@ -15,6 +17,8 @@ import { AttachmentsOnPaymentsReceived } from "./events/AttachmentsOnPaymentsRec
import { AttachmentsOnManualJournals } from "./events/AttachmentsOnManualJournals";
import { AttachmentsOnVendorCredits } from "./events/AttachmentsOnVendorCredits";
import { AttachmentsOnSaleInvoiceCreated } from "./events/AttachmentsOnSaleInvoice";
import { AttachmentsOnSaleReceipt } from "./events/AttachmentsOnSaleReceipts";
import { AttachmentsOnSaleEstimates } from "./events/AttachmentsOnSaleEstimates";
import { AttachmentsController } from "./Attachments.controller";
import { RegisterTenancyModel } from "../Tenancy/TenancyModels/Tenancy.module";
import { DocumentModel } from "./models/Document.model";
@@ -33,12 +37,12 @@ const models = [
@Module({
imports: [S3Module, ...models],
exports: [...models],
exports: [...models, GetAttachmentPresignedUrl],
controllers: [AttachmentsController],
providers: [
DeleteAttachment,
GetAttachment,
getAttachmentPresignedUrl,
GetAttachmentPresignedUrl,
LinkAttachment,
UnlinkAttachment,
ValidateAttachments,
@@ -50,13 +54,19 @@ const models = [
AttachmentsOnManualJournals,
AttachmentsOnVendorCredits,
AttachmentsOnSaleInvoiceCreated,
AttachmentsOnSaleReceipt,
AttachmentsOnSaleEstimates,
AttachmentsApplication,
UploadDocument,
AttachmentUploadPipeline,
{
provide: MULTER_MODULE_OPTIONS,
inject: [ConfigService, S3_CLIENT],
useFactory: (configService: ConfigService, s3: S3Client) => ({
inject: [ConfigService, S3_CLIENT, ClsService],
useFactory: (
configService: ConfigService,
s3: S3Client,
cls: ClsService,
) => ({
storage: multerS3({
s3,
bucket: configService.get('s3.bucket'),
@@ -65,7 +75,11 @@ const models = [
cb(null, { fieldName: file.fieldname });
},
key: function (req, file, cb) {
cb(null, Date.now().toString());
const organizationId = cls.get<string>('organizationId');
if (!organizationId) {
return cb(new Error('Tenant context required for upload.'), undefined as any);
}
cb(null, `${organizationId}/${randomUUID()}`);
},
acl: function(req, file, cb) {
// Conditionally set file to public or private based on isPublic flag
@@ -31,6 +31,9 @@ import { AttachmentUploadPipeline } from './S3UploadPipeline';
import { FileInterceptor } from '@/common/interceptors/file.interceptor';
import { ConfigService } from '@nestjs/config';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
import { AbilitySubject } from '@/modules/Roles/Roles.types';
import { AttachmentAction } from './Attachments.types';
@ApiTags('Attachments')
@Controller('/attachments')
@@ -86,6 +89,7 @@ export class AttachmentsController {
@ApiOperation({ summary: 'Get attachment by ID' })
@ApiParam({ name: 'id', description: 'Attachment ID' })
@ApiResponse({ status: 200, description: 'Returns the attachment file' })
@RequirePermission(AttachmentAction.View, AbilitySubject.Attachment)
async getAttachment(
@Res() res: Response,
@Param('id') documentId: string,
@@ -93,11 +97,12 @@ export class AttachmentsController {
const data = await this.attachmentsApplication.get(documentId);
const byte = await data.Body.transformToByteArray();
const extension = mime.extension(data.ContentType);
const contentType = data.ContentType || 'application/octet-stream';
const extension = mime.extension(contentType) || 'bin';
const buffer = Buffer.from(byte);
res.set('Content-Disposition', `filename="${documentId}.${extension}"`);
res.set('Content-Type', data.ContentType);
res.set('Content-Type', contentType);
res.send(buffer);
}
@@ -111,6 +116,7 @@ export class AttachmentsController {
status: 200,
description: 'The document has been deleted successfully',
})
@RequirePermission(AttachmentAction.Delete, AbilitySubject.Attachment)
async deleteAttachment(@Param('id') documentId: string) {
await this.attachmentsApplication.delete(documentId);
@@ -184,6 +190,7 @@ export class AttachmentsController {
status: 200,
description: 'Returns the presigned URL for the attachment',
})
@RequirePermission(AttachmentAction.View, AbilitySubject.Attachment)
async getAttachmentPresignedUrl(@Param('id') documentKey: string) {
const presignedUrl =
await this.attachmentsApplication.getPresignedUrl(documentKey);
@@ -1,3 +1,8 @@
export interface AttachmentLinkDTO {
key: string;
}
export enum AttachmentAction {
View = 'View',
Delete = 'Delete',
}
@@ -4,7 +4,7 @@ import { DeleteAttachment } from './DeleteAttachment';
import { GetAttachment } from './GetAttachment';
import { LinkAttachment } from './LinkAttachment';
import { UnlinkAttachment } from './UnlinkAttachment';
import { getAttachmentPresignedUrl } from './GetAttachmentPresignedUrl';
import { GetAttachmentPresignedUrl } from './GetAttachmentPresignedUrl';
@Injectable()
export class AttachmentsApplication {
@@ -14,7 +14,7 @@ export class AttachmentsApplication {
private readonly getDocumentService: GetAttachment,
private readonly linkDocumentService: LinkAttachment,
private readonly unlinkDocumentService: UnlinkAttachment,
private readonly getPresignedUrlService: getAttachmentPresignedUrl,
private readonly getPresignedUrlService: GetAttachmentPresignedUrl,
) {}
/**
@@ -31,17 +31,17 @@ export class DeleteAttachment {
* @param {string} filekey
*/
async delete(filekey: string): Promise<void> {
const foundDocument = await this.documentModel()
.query()
.findOne('key', filekey)
.throwIfNotFound();
const params = {
Bucket: this.configService.get('s3.bucket'),
Key: filekey,
};
await this.s3Client.send(new DeleteObjectCommand(params));
const foundDocument = await this.documentModel()
.query()
.findOne('key', filekey)
.throwIfNotFound();
await this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Delete all document links
await this.documentLinkModel()
@@ -2,6 +2,8 @@ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { S3_CLIENT } from '../S3/S3.module';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { DocumentModel } from './models/Document.model';
@Injectable()
export class GetAttachment {
@@ -10,13 +12,21 @@ export class GetAttachment {
@Inject(S3_CLIENT)
private readonly s3: S3Client,
@Inject(DocumentModel.name)
private readonly documentModel: TenantModelProxy<typeof DocumentModel>,
) {}
/**
* Retrieves data of the given document key.
* @param {string} filekey
*/
async getAttachment(filekey: string) {
await this.documentModel()
.query()
.findOne('key', filekey)
.throwIfNotFound();
const params = {
Bucket: this.configService.get('s3.bucket'),
Key: filekey,
@@ -1,13 +1,13 @@
import { Inject, Injectable } from '@nestjs/common';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { DocumentModel } from './models/Document.model';
import { ConfigService } from '@nestjs/config';
import { S3_CLIENT } from '../S3/S3.module';
@Injectable()
export class getAttachmentPresignedUrl {
export class GetAttachmentPresignedUrl {
constructor(
private readonly configService: ConfigService,
@@ -24,7 +24,10 @@ export class getAttachmentPresignedUrl {
* @returns {string}
*/
async getPresignedUrl(key: string) {
const foundDocument = await this.documentModel().query().findOne({ key });
const foundDocument = await this.documentModel()
.query()
.findOne({ key })
.throwIfNotFound();
const config = this.configService.get('s3');
let ResponseContentDisposition = 'attachment';
@@ -1,5 +1,5 @@
import { ModuleRef } from '@nestjs/core';
import bluebird from 'bluebird';
import * as bluebird from 'bluebird';
import { Knex } from 'knex';
import {
validateLinkModelEntryExists,
@@ -53,7 +53,8 @@ export class LinkAttachment {
const foundLinkModel = await LinkModel().query(trx).findById(modelId);
validateLinkModelEntryExists(foundLinkModel);
const foundLinks = await this.documentLinkModel().query(trx)
const foundLinks = await this.documentLinkModel()
.query(trx)
.where('modelRef', modelRef)
.where('modelId', modelId)
.where('documentId', foundFile.id);
@@ -70,7 +71,7 @@ export class LinkAttachment {
/**
* Links the given file keys to the given model type and id.
* @param {string[]} filekeys - File keys.
* @param {string[]} filekeys - File keys.
* @param {string} modelRef - Model reference.
* @param {number} modelId - Model id.
* @param {Knex.Transaction} trx - Knex transaction.
@@ -44,7 +44,7 @@ export class UnlinkAttachment {
validateLinkModelExists(attachableModel);
const LinkModel = this.moduleRef.get(modelRef, { strict: false });
const foundLinkModel = await LinkModel.query(trx).findById(modelId);
const foundLinkModel = await LinkModel().query(trx).findById(modelId);
validateLinkModelEntryExists(foundLinkModel);
const document = await this.documentModel().query(trx).findOne('key', filekey);
@@ -55,7 +55,7 @@ export class AttachmentsOnPaymentsReceived {
);
await this.linkAttachmentService.bulkLink(
keys,
'PaymentReceive',
'PaymentReceived',
paymentReceive.id,
trx,
);
@@ -76,7 +76,7 @@ export class AttachmentsOnPaymentsReceived {
);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'PaymentReceive',
'PaymentReceived',
oldPaymentReceive.id,
trx,
);
@@ -100,7 +100,7 @@ export class AttachmentsOnPaymentsReceived {
);
await this.linkAttachmentService.bulkLink(
keys,
'PaymentReceive',
'PaymentReceived',
oldPaymentReceive.id,
trx,
);
@@ -117,7 +117,7 @@ export class AttachmentsOnPaymentsReceived {
trx,
}: IPaymentReceivedDeletingPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'PaymentReceive',
'PaymentReceived',
oldPaymentReceive.id,
trx,
);
@@ -1,10 +0,0 @@
import * as path from 'path';
// import config from '@/config';
export const getUploadedObjectUri = (objectKey: string) => {
return '';
// return new URL(
// path.join(config.s3.bucket, objectKey),
// config.s3.endpoint
// ).toString();
};
@@ -14,12 +14,19 @@ import {
ApiOperation,
ApiBody,
ApiParam,
ApiExcludeController,
ApiResponse,
ApiExtraModels,
getSchemaPath,
} from '@nestjs/swagger';
import { PublicRoute } from './guards/jwt.guard';
import { AuthenticationApplication } from './AuthApplication.sevice';
import { AuthSignupDto } from './dtos/AuthSignup.dto';
import { AuthSigninDto } from './dtos/AuthSignin.dto';
import { AuthSignupVerifyDto } from './dtos/AuthSignupVerify.dto';
import { AuthSendResetPasswordDto } from './dtos/AuthSendResetPassword.dto';
import { AuthResetPasswordDto } from './dtos/AuthResetPassword.dto';
import { AuthSigninResponseDto } from './dtos/AuthSigninResponse.dto';
import { AuthMetaResponseDto } from './dtos/AuthMetaResponse.dto';
import { LocalAuthGuard } from './guards/Local.guard';
import { AuthSigninService } from './commands/AuthSignin.service';
import { TenantModel } from '../System/models/TenantModel';
@@ -27,7 +34,7 @@ import { SystemUser } from '../System/models/SystemUser';
@Controller('/auth')
@ApiTags('Auth')
@ApiExcludeController()
@ApiExtraModels(AuthSigninResponseDto, AuthMetaResponseDto)
@PublicRoute()
@Throttle({ auth: {} })
export class AuthController {
@@ -43,10 +50,15 @@ export class AuthController {
@UseGuards(LocalAuthGuard)
@ApiOperation({ summary: 'Sign in a user' })
@ApiBody({ type: AuthSigninDto })
@ApiResponse({
status: 200,
description: 'Sign-in successful. Returns access token and tenant/organization IDs.',
schema: { $ref: getSchemaPath(AuthSigninResponseDto) },
})
async signin(
@Request() req: Request & { user: SystemUser },
@Body() signinDto: AuthSigninDto,
) {
): Promise<AuthSigninResponseDto> {
const { user } = req;
const tenant = await this.tenantModel.query().findById(user.tenantId);
@@ -61,59 +73,47 @@ export class AuthController {
@Post('/signup')
@ApiOperation({ summary: 'Sign up a new user' })
@ApiBody({ type: AuthSignupDto })
@ApiResponse({ status: 201, description: 'Sign-up initiated. Check email for confirmation.' })
signup(@Request() req: Request, @Body() signupDto: AuthSignupDto) {
return this.authApp.signUp(signupDto);
}
@Post('/signup/confirm')
@Post('/signup/verify')
@ApiOperation({ summary: 'Confirm user signup' })
@ApiBody({
schema: {
type: 'object',
properties: {
email: { type: 'string', example: 'user@example.com' },
token: { type: 'string', example: 'confirmation-token' },
},
},
})
signupConfirm(@Body('email') email: string, @Body('token') token: string) {
return this.authApp.signUpConfirm(email, token);
@ApiBody({ type: AuthSignupVerifyDto })
@ApiResponse({ status: 200, description: 'Signup confirmed successfully.' })
signupConfirm(@Body() body: AuthSignupVerifyDto) {
return this.authApp.signUpConfirm(body.email, body.token);
}
@Post('/send_reset_password')
@ApiOperation({ summary: 'Send reset password email' })
@ApiBody({
schema: {
type: 'object',
properties: {
email: { type: 'string', example: 'user@example.com' },
},
},
})
sendResetPassword(@Body('email') email: string) {
return this.authApp.sendResetPassword(email);
@ApiBody({ type: AuthSendResetPasswordDto })
@ApiResponse({ status: 200, description: 'Reset password email sent if the account exists.' })
sendResetPassword(@Body() body: AuthSendResetPasswordDto) {
return this.authApp.sendResetPassword(body.email);
}
@Post('/reset_password/:token')
@ApiOperation({ summary: 'Reset password using token' })
@ApiParam({ name: 'token', description: 'Reset password token' })
@ApiBody({
schema: {
type: 'object',
properties: {
password: { type: 'string', example: 'new-password' },
},
},
})
@ApiParam({ name: 'token', description: 'Reset password token from email link' })
@ApiBody({ type: AuthResetPasswordDto })
@ApiResponse({ status: 200, description: 'Password reset successfully.' })
resetPassword(
@Param('token') token: string,
@Body('password') password: string,
@Body() body: AuthResetPasswordDto,
) {
return this.authApp.resetPassword(token, password);
return this.authApp.resetPassword(token, body.password);
}
@Get('/meta')
meta() {
@ApiOperation({ summary: 'Get auth metadata (e.g. signup disabled)' })
@ApiResponse({
status: 200,
description: 'Auth metadata for the login/signup page.',
schema: { $ref: getSchemaPath(AuthMetaResponseDto) },
})
meta(): Promise<AuthMetaResponseDto> {
return this.authApp.getAuthMeta();
}
}
@@ -20,7 +20,7 @@ export class AuthenticationMailMesssages {
* @returns {Mail}
*/
resetPasswordMessage(user: ModelObject<SystemUser>, token: string) {
const baseURL = this.configService.get('baseURL');
const baseURL = this.configService.get('app.baseUrl');
return new Mail()
.setSubject('Bigcapital - Password Reset')
@@ -54,7 +54,7 @@ export class AuthenticationMailMesssages {
* @returns {Mail}
*/
signupVerificationMail(email: string, fullName: string, token: string) {
const baseURL = this.configService.get('baseURL');
const baseURL = this.configService.get('app.baseUrl');
const verifyUrl = `${baseURL}/auth/email_confirmation?token=${token}&email=${email}`;
return new Mail()
@@ -7,17 +7,13 @@ import {
import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service';
import { Controller, Get, Post } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards';
import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard';
import { TenantAgnosticRoute } from '../Tenancy/TenancyGlobal.guard';
import { AuthenticationApplication } from './AuthApplication.sevice';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { IgnoreUserVerifiedRoute } from './guards/EnsureUserVerified.guard';
@Controller('/auth')
@ApiTags('Auth')
@ApiExcludeController()
@IgnoreTenantSeededRoute()
@IgnoreTenantInitializedRoute()
@TenantAgnosticRoute()
@IgnoreUserVerifiedRoute()
@Throttle({ auth: {} })
export class AuthedController {
@@ -2,6 +2,7 @@ import { ClsService } from 'nestjs-cls';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { ModelObject } from 'objection';
import { JwtPayload } from '../Auth.interfaces';
import { InvalidEmailPasswordException } from '../exceptions/InvalidEmailPassword.exception';
@@ -12,6 +13,10 @@ export class AuthSigninService {
constructor(
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
private readonly jwtService: JwtService,
private readonly clsService: ClsService,
) { }
@@ -49,6 +54,7 @@ export class AuthSigninService {
*/
async verifyPayload(payload: JwtPayload): Promise<any> {
let user: SystemUser;
let tenant: TenantModel | undefined;
try {
user = await this.systemUserModel
@@ -56,8 +62,14 @@ export class AuthSigninService {
.findOne({ email: payload.sub })
.throwIfNotFound();
tenant = await this.tenantModel
.query()
.findById(user.tenantId)
.throwIfNotFound();
this.clsService.set('tenantId', user.tenantId);
this.clsService.set('userId', user.id);
this.clsService.set('organizationId', tenant.organizationId);
} catch (error) {
throw new UserNotFoundException(String(payload.sub));
}
@@ -13,7 +13,6 @@ import {
IAuthSignedUpEventPayload,
IAuthSigningUpEventPayload,
} from '../Auth.interfaces';
import { defaultTo } from 'ramda';
import { ERRORS } from '../Auth.constants';
import { hashPassword } from '../Auth.utils';
import { ClsService } from 'nestjs-cls';
@@ -51,10 +50,10 @@ export class AuthSignupService {
const signupConfirmation = this.configService.get('signupConfirmation');
const verifyTokenCrypto = crypto.randomBytes(64).toString('hex');
const verifiedEnabed = defaultTo(signupConfirmation.enabled, false);
const verifiedEnabed = signupConfirmation.enabled ?? false;
const verifyToken = verifiedEnabed ? verifyTokenCrypto : '';
const verified = !verifiedEnabed;
const inviteAcceptedAt = moment().format('YYYY-MM-DD');
// Triggers signin up event.
@@ -4,7 +4,6 @@ import { SystemUser } from '@/modules/System/models/SystemUser';
import { ServiceError } from '@/modules/Items/ServiceError';
import { ERRORS } from '../Auth.constants';
import { events } from '@/common/events/events';
import { ModelObject } from 'objection';
import { ISignUpConfigmResendedEventPayload } from '../Auth.interfaces';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class AuthMetaResponseDto {
@ApiProperty({ description: 'Whether signup is disabled' })
signupDisabled: boolean;
}
@@ -0,0 +1,12 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AuthResetPasswordDto {
@ApiProperty({
example: 'new-password',
description: 'New password',
})
@IsNotEmpty()
@IsString()
password: string;
}
@@ -0,0 +1,12 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AuthSendResetPasswordDto {
@ApiProperty({
example: 'user@example.com',
description: 'User email address to send reset link to',
})
@IsNotEmpty()
@IsString()
email: string;
}
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class AuthSigninResponseDto {
@ApiProperty({ description: 'JWT access token' })
accessToken: string;
@ApiProperty({ description: 'Organization ID' })
organizationId: string;
@ApiProperty({ description: 'Tenant ID' })
tenantId: number;
@ApiProperty({ description: 'User ID' })
userId: number;
}
@@ -0,0 +1,20 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AuthSignupVerifyDto {
@ApiProperty({
example: 'user@example.com',
description: 'User email address',
})
@IsNotEmpty()
@IsString()
email: string;
@ApiProperty({
example: 'confirmation-token',
description: 'Signup confirmation token from email',
})
@IsNotEmpty()
@IsString()
token: string;
}
@@ -16,7 +16,7 @@ import { ToNumber } from '@/common/decorators/Validators';
class BankRuleConditionDto {
@IsNotEmpty()
@IsIn(['description', 'amount'])
@IsIn(['description', 'amount', 'payee'])
field: string;
@IsNotEmpty()
@@ -11,12 +11,6 @@ export class BankAccountsController {
@Get()
@ApiOperation({ summary: 'Retrieve the bank accounts.' })
@ApiQuery({
name: 'query',
description: 'Query parameters for the bank accounts list.',
type: BankAccountsQueryDto,
required: false,
})
@ApiResponse({
status: 200,
description: 'List of bank accounts retrieved successfully.',
@@ -2,7 +2,14 @@ import { Body, Controller, Delete, Param, Post, Query } from '@nestjs/common';
import { castArray, omit } from 'lodash';
import { BankingCategorizeApplication } from './BankingCategorize.application';
import { CategorizeBankTransactionRouteDto } from './dtos/CategorizeBankTransaction.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('banking/categorize')
@@ -15,6 +22,7 @@ export class BankingCategorizeController {
@Post()
@ApiOperation({ summary: 'Categorize bank transactions.' })
@ApiBody({ type: CategorizeBankTransactionRouteDto })
@ApiResponse({
status: 200,
description: 'The bank transactions have been categorized successfully.',
@@ -29,21 +37,38 @@ export class BankingCategorizeController {
}
@Delete('/bulk')
@ApiOperation({ summary: 'Uncategorize bank transactions.' })
@ApiOperation({ summary: 'Uncategorize bank transactions in bulk.' })
@ApiQuery({
name: 'uncategorizedTransactionIds',
required: true,
type: [Number],
isArray: true,
description: 'Array of uncategorized transaction IDs to uncategorize',
})
@ApiResponse({
status: 200,
description: 'The bank transactions have been uncategorized successfully.',
})
public uncategorizeTransactionsBulk(
@Query() uncategorizedTransactionIds: number[] | number,
@Query('uncategorizedTransactionIds')
uncategorizedTransactionIds: number[] | number,
) {
const ids = castArray(uncategorizedTransactionIds).map((id) =>
Number(id),
);
return this.bankingCategorizeApplication.uncategorizeTransactionsBulk(
castArray(uncategorizedTransactionIds),
ids,
);
}
@Delete('/:id')
@ApiOperation({ summary: 'Uncategorize a bank transaction.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'Uncategorized transaction ID to uncategorize',
})
@ApiResponse({
status: 200,
description: 'The bank transaction has been uncategorized successfully.',
@@ -1,12 +1,21 @@
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiExtraModels,
ApiOperation,
ApiQuery,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { BankingMatchingApplication } from './BankingMatchingApplication';
import { GetMatchedTransactionsFilter } from './types';
import { MatchBankTransactionDto } from './dtos/MatchBankTransaction.dto';
import { GetMatchedTransactionsQueryDto } from './dtos/GetMatchedTransactionsQuery.dto';
import { GetMatchedTransactionsResponseDto } from './dtos/GetMatchedTransactionsResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('banking/matching')
@ApiTags('Banking Transactions Matching')
@ApiExtraModels(GetMatchedTransactionsResponseDto)
@ApiCommonHeaders()
export class BankingMatchingController {
constructor(
@@ -15,13 +24,25 @@ export class BankingMatchingController {
@Get('matched')
@ApiOperation({ summary: 'Retrieves the matched transactions.' })
@ApiQuery({
name: 'uncategorizedTransactionIds',
required: true,
type: [Number],
isArray: true,
description: 'Uncategorized transaction IDs to match',
})
@ApiResponse({
status: 200,
description: 'Matched transactions (perfect and possible matches).',
schema: { $ref: getSchemaPath(GetMatchedTransactionsResponseDto) },
})
async getMatchedTransactions(
@Query('uncategorizedTransactionIds') uncategorizedTransactionIds: number[],
@Query() filter: GetMatchedTransactionsFilter,
@Query() filter: GetMatchedTransactionsQueryDto,
) {
return this.bankingMatchingApplication.getMatchedTransactions(
uncategorizedTransactionIds,
filter,
uncategorizedTransactionIds ?? [],
filter as any,
);
}
@@ -0,0 +1,37 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class GetMatchedTransactionsQueryDto {
@ApiPropertyOptional({ description: 'Filter from date', example: '2024-01-01' })
@IsOptional()
@IsString()
fromDate?: string;
@ApiPropertyOptional({ description: 'Filter to date', example: '2024-12-31' })
@IsOptional()
@IsString()
toDate?: string;
@ApiPropertyOptional({ description: 'Minimum amount', example: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
minAmount?: number;
@ApiPropertyOptional({ description: 'Maximum amount', example: 10000 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Max(Number.MAX_SAFE_INTEGER)
maxAmount?: number;
@ApiPropertyOptional({
description: 'Transaction type filter',
example: 'SaleInvoice',
})
@IsOptional()
@IsString()
transactionType?: string;
}
@@ -0,0 +1,44 @@
import { ApiProperty } from '@nestjs/swagger';
export class MatchedTransactionItemDto {
@ApiProperty({ description: 'Transaction amount', example: 100.5 })
amount: number;
@ApiProperty({ description: 'Formatted amount', example: '$100.50' })
amountFormatted: string;
@ApiProperty({ description: 'Transaction date', example: '2024-01-15' })
date: string;
@ApiProperty({ description: 'Formatted date', example: 'Jan 15, 2024' })
dateFormatted: string;
@ApiProperty({ description: 'Reference number', example: 'REF-001' })
referenceNo: string;
@ApiProperty({ description: 'Transaction number', example: 'TXN-001' })
transactionNo: string;
@ApiProperty({ description: 'Transaction ID', example: 1 })
transactionId: number;
@ApiProperty({ description: 'Transaction type', example: 'SaleInvoice' })
transactionType: string;
}
export class GetMatchedTransactionsResponseDto {
@ApiProperty({
description: 'Perfect matches (amount and date match)',
type: [MatchedTransactionItemDto],
})
perfectMatches: MatchedTransactionItemDto[];
@ApiProperty({
description: 'Possible matches (candidates)',
type: [MatchedTransactionItemDto],
})
possibleMatches: MatchedTransactionItemDto[];
@ApiProperty({ description: 'Total pending amount', example: 500 })
totalPending: number;
}
@@ -30,7 +30,12 @@ export class MatchTransactionEntryDto {
export class MatchBankTransactionDto {
@IsArray()
@ArrayMinSize(1)
uncategorizedTransactions: Array<number>
@ApiProperty({
description: 'Uncategorized transaction IDs to match',
type: [Number],
example: [1, 2],
})
uncategorizedTransactions: Array<number>;
@IsArray()
@ValidateNested({ each: true })
@@ -8,13 +8,14 @@ import {
ApiExtraModels,
getSchemaPath,
} from '@nestjs/swagger';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import { RecognizedTransactionsApplication } from './RecognizedTransactions.application';
import { GetRecognizedTransactionResponseDto } from './dtos/GetRecognizedTransactionResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('banking/recognized')
@ApiTags('Banking Recognized Transactions')
@ApiExtraModels(GetRecognizedTransactionResponseDto)
@ApiExtraModels(GetRecognizedTransactionResponseDto, PaginatedResponseDto)
@ApiCommonHeaders()
export class BankingRecognizedTransactionsController {
constructor(
@@ -58,10 +59,17 @@ export class BankingRecognizedTransactionsController {
status: 200,
description: 'Returns a list of recognized transactions',
schema: {
type: 'array',
items: {
$ref: getSchemaPath(GetRecognizedTransactionResponseDto),
},
allOf: [
{ $ref: getSchemaPath(PaginatedResponseDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(GetRecognizedTransactionResponseDto) },
},
},
},
],
},
})
async getRecognizedTransactions(@Query() query: any) {
@@ -15,8 +15,13 @@ export const RecognizeUncategorizedTransactionsJob =
export const RecognizeUncategorizedTransactionsQueue =
'recognize-uncategorized-transactions-queue';
export interface RecognizeUncategorizedTransactionsJobPayload extends TenantJobPayload {
ruleId: number,
transactionsCriteria: any;
transactionsCriteria?: RecognizeTransactionsCriteria;
/**
* When true, first reverts recognized transactions before recognizing again.
* Used when a bank rule is edited to ensure transactions previously recognized
* by lower-priority rules are re-evaluated against the updated rule.
*/
shouldRevert?: boolean;
}
@@ -93,6 +93,10 @@ export class RecognizeTranasctionsService {
q.whereIn('id', rulesIds);
}
q.withGraphFetched('conditions');
// Order by the 'order' field to ensure higher priority rules (lower order values)
// are matched first.
q.orderBy('order', 'asc');
});
const bankRulesByAccountId = transformToMapBy(
@@ -69,10 +69,13 @@ export class TriggerRecognizedTransactionsSubscriber {
const tenantPayload = await this.tenancyContect.getTenantJobPayload();
const payload = {
ruleId: bankRule.id,
shouldRevert: true,
...tenantPayload,
} as RecognizeUncategorizedTransactionsJobPayload;
// Re-recognize the transactions based on the new rules.
// Setting shouldRevert to true ensures that transactions previously recognized
// by this or lower-priority rules are re-evaluated against the updated rule.
await this.recognizeTransactionsQueue.add(
RecognizeUncategorizedTransactionsJob,
payload,
@@ -3,6 +3,7 @@ import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Scope } from '@nestjs/common';
import { ClsService, UseCls } from 'nestjs-cls';
import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service';
import { RevertRecognizedTransactionsService } from '../commands/RevertRecognizedTransactions.service';
import {
RecognizeUncategorizedTransactionsJobPayload,
RecognizeUncategorizedTransactionsQueue,
@@ -15,10 +16,12 @@ import {
export class RegonizeTransactionsPrcessor extends WorkerHost {
/**
* @param {RecognizeTranasctionsService} recognizeTranasctionsService -
* @param {RevertRecognizedTransactionsService} revertRecognizedTransactionsService -
* @param {ClsService} clsService -
*/
constructor(
private readonly recognizeTranasctionsService: RecognizeTranasctionsService,
private readonly revertRecognizedTransactionsService: RevertRecognizedTransactionsService,
private readonly clsService: ClsService,
) {
super();
@@ -29,12 +32,21 @@ export class RegonizeTransactionsPrcessor extends WorkerHost {
*/
@UseCls()
async process(job: Job<RecognizeUncategorizedTransactionsJobPayload>) {
const { ruleId, transactionsCriteria } = job.data;
const { ruleId, transactionsCriteria, shouldRevert } = job.data;
this.clsService.set('organizationId', job.data.organizationId);
this.clsService.set('userId', job.data.userId);
try {
// If shouldRevert is true, first revert recognized transactions before re-recognizing.
// This is used when a bank rule is edited to ensure transactions previously recognized
// by lower-priority rules are re-evaluated against the updated rule.
if (shouldRevert) {
await this.revertRecognizedTransactionsService.revertRecognizedTransactions(
ruleId,
transactionsCriteria,
);
}
await this.recognizeTranasctionsService.recognizeTransactions(
ruleId,
transactionsCriteria,
@@ -24,6 +24,7 @@ import { GetBankAccountsService } from './queries/GetBankAccounts.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { BankAccount } from './models/BankAccount';
import { LedgerModule } from '../Ledger/Ledger.module';
import { TenancyModule } from '../Tenancy/Tenancy.module';
import { GetBankAccountTransactionsService } from './queries/GetBankAccountTransactions/GetBankAccountTransactions.service';
import { GetBankAccountTransactionsRepository } from './queries/GetBankAccountTransactions/GetBankAccountTransactionsRepo.service';
import { GetUncategorizedTransactions } from './queries/GetUncategorizedTransactions';
@@ -46,6 +47,7 @@ const models = [
LedgerModule,
BranchesModule,
DynamicListModule,
TenancyModule,
...models,
],
controllers: [
@@ -27,7 +27,7 @@ export enum CASHFLOW_DIRECTION {
}
export enum CASHFLOW_TRANSACTION_TYPE {
ONWERS_DRAWING = 'OwnerDrawing',
OWNERS_DRAWING = 'OwnerDrawing',
OWNER_CONTRIBUTION = 'OwnerContribution',
OTHER_INCOME = 'OtherIncome',
TRANSFER_FROM_ACCOUNT = 'TransferFromAccount',
@@ -36,7 +36,7 @@ export enum CASHFLOW_TRANSACTION_TYPE {
}
export const CASHFLOW_TRANSACTION_TYPE_META = {
[`${CASHFLOW_TRANSACTION_TYPE.ONWERS_DRAWING}`]: {
[`${CASHFLOW_TRANSACTION_TYPE.OWNERS_DRAWING}`]: {
type: 'OwnerDrawing',
direction: CASHFLOW_DIRECTION.OUT,
creditType: [ACCOUNT_TYPE.EQUITY],
@@ -7,14 +7,15 @@ import {
getSchemaPath,
ApiExtraModels,
} from '@nestjs/swagger';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import { BankingTransactionsApplication } from '../BankingTransactionsApplication.service';
import { GetPendingTransactionsQueryDto } from '../dtos/GetPendingTransactionsQuery.dto';
import { GetPendingTransactionResponseDto } from '../dtos/GetPendingTransactionResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('banking/pending')
@ApiTags('Banking Pending Transactions')
@ApiExtraModels(GetPendingTransactionResponseDto)
@ApiExtraModels(GetPendingTransactionResponseDto, PaginatedResponseDto)
@ApiCommonHeaders()
export class BankingPendingTransactionsController {
constructor(
@@ -27,7 +28,17 @@ export class BankingPendingTransactionsController {
status: 200,
description: 'Returns a list of pending bank account transactions',
schema: {
$ref: getSchemaPath(GetPendingTransactionResponseDto),
allOf: [
{ $ref: getSchemaPath(PaginatedResponseDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(GetPendingTransactionResponseDto) },
},
},
},
],
},
})
@ApiQuery({
@@ -5,13 +5,17 @@ import {
ApiResponse,
ApiParam,
ApiQuery,
ApiExtraModels,
getSchemaPath,
} from '@nestjs/swagger';
import { GetUncategorizedTransactionsQueryDto } from '../dtos/GetUncategorizedTransactionsQuery.dto';
import { GetAutofillCategorizeTransactionResponseDto } from '../dtos/GetAutofillCategorizeTransactionResponse.dto';
import { BankingTransactionsApplication } from '../BankingTransactionsApplication.service';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('banking/uncategorized')
@ApiTags('Banking Uncategorized Transactions')
@ApiExtraModels(GetAutofillCategorizeTransactionResponseDto)
@ApiCommonHeaders()
export class BankingUncategorizedTransactionsController {
constructor(
@@ -20,29 +24,29 @@ export class BankingUncategorizedTransactionsController {
@Get('autofill')
@ApiOperation({ summary: 'Get autofill values for categorize transactions' })
@ApiQuery({
name: 'uncategorizedTransactionIds',
required: true,
type: [Number],
isArray: true,
description: 'Uncategorized transaction IDs to get autofill for',
})
@ApiResponse({
status: 200,
description: 'Returns autofill values for categorize transactions',
})
@ApiParam({
name: 'accountId',
required: true,
type: Number,
description: 'Bank account ID',
})
@ApiQuery({
name: 'uncategorizeTransactionsId',
required: true,
type: Number,
description: 'Uncategorize transactions ID',
schema: { $ref: getSchemaPath(GetAutofillCategorizeTransactionResponseDto) },
})
async getAutofillCategorizeTransaction(
@Query('uncategorizedTransactionIds')
uncategorizedTransactionIds: Array<number> | number,
) {
console.log(uncategorizedTransactionIds);
const ids = Array.isArray(uncategorizedTransactionIds)
? uncategorizedTransactionIds
: uncategorizedTransactionIds != null
? [uncategorizedTransactionIds]
: [];
return this.bankingTransactionsApplication.getAutofillCategorizeTransaction(
uncategorizedTransactionIds,
ids,
);
}
@@ -0,0 +1,54 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class GetAutofillCategorizeTransactionResponseDto {
@ApiPropertyOptional({
description: 'Assigned credit/debit account ID from recognition',
example: 10,
})
creditAccountId?: number | null;
@ApiPropertyOptional({
description: 'Bank account ID (debit)',
example: 5,
})
debitAccountId?: number | null;
@ApiProperty({ description: 'Total amount of uncategorized transactions', example: -150.5 })
amount: number;
@ApiProperty({ description: 'Formatted amount', example: '$150.50' })
formattedAmount: string;
@ApiProperty({ description: 'Transaction date', example: '2024-01-15' })
date: string;
@ApiProperty({ description: 'Formatted date', example: 'Jan 15, 2024' })
formattedDate: string;
@ApiProperty({ description: 'Whether the transaction is recognized by a rule', example: true })
isRecognized: boolean;
@ApiPropertyOptional({ description: 'Bank rule ID that recognized the transaction', example: 1 })
recognizedByRuleId?: number | null;
@ApiPropertyOptional({ description: 'Bank rule name that recognized the transaction', example: 'Salary Rule' })
recognizedByRuleName?: string | null;
@ApiPropertyOptional({ description: 'Reference number', example: 'REF-001' })
referenceNo?: string | null;
@ApiProperty({ description: 'Transaction type (category)', example: 'other_expense' })
transactionType: string;
@ApiProperty({ description: 'Whether this is a deposit transaction', example: false })
isDepositTransaction: boolean;
@ApiProperty({ description: 'Whether this is a withdrawal transaction', example: true })
isWithdrawalTransaction: boolean;
@ApiPropertyOptional({ description: 'Assigned payee from recognition' })
payee?: string | null;
@ApiPropertyOptional({ description: 'Assigned memo from recognition' })
memo?: string | null;
}
@@ -4,12 +4,14 @@ import { GetBankAccountTransactionsRepository } from './GetBankAccountTransactio
import { GetBankAccountTransactions } from './GetBankAccountTransactions';
import { GetBankTransactionsQueryDto } from '../../dtos/GetBankTranasctionsQuery.dto';
import { I18nService } from 'nestjs-i18n';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
@Injectable()
export class GetBankAccountTransactionsService {
constructor(
private readonly getBankAccountTransactionsRepository: GetBankAccountTransactionsRepository,
private readonly i18nService: I18nService
private readonly i18nService: I18nService,
private readonly tenancyContext: TenancyContext,
) {}
/**
@@ -28,11 +30,16 @@ export class GetBankAccountTransactionsService {
await this.getBankAccountTransactionsRepository.asyncInit();
// Retrieve the tenant metadata to get the date format.
const tenantMetadata = await this.tenancyContext.getTenantMetadata();
const dateFormat = tenantMetadata?.dateFormat;
// Retrieve the computed report.
const report = new GetBankAccountTransactions(
this.getBankAccountTransactionsRepository,
parsedQuery,
this.i18nService
this.i18nService,
dateFormat,
);
const transactions = report.reportData();
const pagination = this.getBankAccountTransactionsRepository.pagination;
@@ -24,17 +24,20 @@ export class GetBankAccountTransactions extends FinancialSheet {
* @param {IAccountTransaction[]} transactions -
* @param {number} openingBalance -
* @param {ICashflowAccountTransactionsQuery} query -
* @param {string} dateFormat - The date format from organization settings.
*/
constructor(
repo: GetBankAccountTransactionsRepository,
query: ICashflowAccountTransactionsQuery,
i18n: I18nService,
dateFormat?: string,
) {
super();
this.repo = repo;
this.query = query;
this.i18n = i18n;
this.dateFormat = dateFormat || this.dateFormat;
this.runningBalance = runningBalance(this.repo.openingBalance);
}
@@ -98,7 +101,7 @@ export class GetBankAccountTransactions extends FinancialSheet {
return {
date: transaction.date,
formattedDate: moment(transaction.date).format('YYYY-MM-DD'),
formattedDate: this.getDateFormatted(transaction.date),
withdrawal: transaction.credit,
deposit: transaction.debit,
@@ -4,12 +4,10 @@ import {
Delete,
Get,
Param,
Post,
Put,
Query,
} from '@nestjs/common';
import { ExcludeBankTransactionsApplication } from './ExcludeBankTransactionsApplication';
import { ExcludedBankTransactionsQuery } from './types/BankTransactionsExclude.types';
import {
ApiExtraModels,
ApiOperation,
@@ -17,12 +15,15 @@ import {
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import { GetExcludedBankTransactionResponseDto } from './dtos/GetExcludedBankTransactionResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import { GetExcludedBankTransactionResponseDto } from './dtos/GetExcludedBankTransactionResponse.dto';
import { ExcludeBankTransactionsBulkDto } from './dtos/ExcludeBankTransactionsBulk.dto';
import { GetExcludedBankTransactionsQueryDto } from './dtos/GetExcludedBankTransactionsQuery.dto';
@Controller('banking/exclude')
@ApiTags('Banking Transactions')
@ApiExtraModels(GetExcludedBankTransactionResponseDto)
@ApiExtraModels(GetExcludedBankTransactionResponseDto, ExcludeBankTransactionsBulkDto, PaginatedResponseDto)
@ApiCommonHeaders()
export class BankingTransactionsExcludeController {
constructor(
@@ -31,15 +32,19 @@ export class BankingTransactionsExcludeController {
@Put('bulk')
@ApiOperation({ summary: 'Exclude the given bank transactions.' })
public excludeBankTransactions(@Body('ids') ids: number[]) {
return this.excludeBankTransactionsApplication.excludeBankTransactions(ids);
@ApiResponse({ status: 200, description: 'Bank transactions excluded successfully.' })
public excludeBankTransactions(@Body() body: ExcludeBankTransactionsBulkDto) {
return this.excludeBankTransactionsApplication.excludeBankTransactions(
body.ids,
);
}
@Delete('bulk')
@ApiOperation({ summary: 'Unexclude the given bank transactions.' })
public unexcludeBankTransactions(@Body('ids') ids: number[]) {
@ApiResponse({ status: 200, description: 'Bank transactions unexcluded successfully.' })
public unexcludeBankTransactions(@Body() body: ExcludeBankTransactionsBulkDto) {
return this.excludeBankTransactionsApplication.unexcludeBankTransactions(
ids,
body.ids,
);
}
@@ -50,17 +55,24 @@ export class BankingTransactionsExcludeController {
description:
'The excluded bank transactions has been retrieved successfully.',
schema: {
type: 'array',
items: {
$ref: getSchemaPath(GetExcludedBankTransactionResponseDto),
},
allOf: [
{ $ref: getSchemaPath(PaginatedResponseDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(GetExcludedBankTransactionResponseDto) },
},
},
},
],
},
})
public getExcludedBankTransactions(
@Query() query: ExcludedBankTransactionsQuery,
@Query() query: GetExcludedBankTransactionsQueryDto,
) {
return this.excludeBankTransactionsApplication.getExcludedBankTransactions(
query,
query as any,
);
}
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNumber, ArrayMinSize } from 'class-validator';
import { Type } from 'class-transformer';
export class ExcludeBankTransactionsBulkDto {
@ApiProperty({
description: 'IDs of uncategorized bank transactions to exclude or unexclude',
type: [Number],
example: [1, 2, 3],
})
@IsArray()
@ArrayMinSize(1)
@IsNumber({}, { each: true })
@Type(() => Number)
ids: number[];
}
@@ -0,0 +1,45 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsNumber, IsDateString } from 'class-validator';
import { Type } from 'class-transformer';
export class GetExcludedBankTransactionsQueryDto {
@ApiPropertyOptional({ description: 'Page number', example: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number;
@ApiPropertyOptional({ description: 'Page size', example: 25 })
@IsOptional()
@Type(() => Number)
@IsNumber()
pageSize?: number;
@ApiPropertyOptional({ description: 'Filter by bank account ID', example: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
accountId?: number;
@ApiPropertyOptional({ description: 'Minimum date (ISO)', example: '2024-01-01' })
@IsOptional()
@IsDateString()
minDate?: string;
@ApiPropertyOptional({ description: 'Maximum date (ISO)', example: '2024-12-31' })
@IsOptional()
@IsDateString()
maxDate?: string;
@ApiPropertyOptional({ description: 'Minimum amount', example: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
minAmount?: number;
@ApiPropertyOptional({ description: 'Maximum amount', example: 10000 })
@IsOptional()
@Type(() => Number)
@IsNumber()
maxAmount?: number;
}
@@ -13,6 +13,13 @@ export class BillLandedCostEntry extends BaseModel {
return 'bill_located_cost_entries';
}
/**
* Timestamps columns.
*/
get timestamps() {
return [];
}
/**
* Relationship mapping.
*/
@@ -7,12 +7,14 @@ import {
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { BillPaymentsApplication } from './BillPaymentsApplication.service';
import {
ApiExtraModels,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
getSchemaPath,
@@ -26,12 +28,18 @@ import { BillPaymentsPages } from './commands/BillPaymentsPages.service';
import { BillPaymentResponseDto } from './dtos/BillPaymentResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
import { PermissionGuard } from '@/modules/Roles/Permission.guard';
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
import { AbilitySubject } from '@/modules/Roles/Roles.types';
import { IPaymentMadeAction } from './types/BillPayments.types';
@Controller('bill-payments')
@ApiTags('Bill Payments')
@ApiExtraModels(BillPaymentResponseDto)
@ApiExtraModels(PaginatedResponseDto)
@ApiCommonHeaders()
@UseGuards(AuthorizationGuard, PermissionGuard)
export class BillPaymentsController {
constructor(
private billPaymentsApplication: BillPaymentsApplication,
@@ -39,12 +47,14 @@ export class BillPaymentsController {
) {}
@Post()
@RequirePermission(IPaymentMadeAction.Create, AbilitySubject.PaymentMade)
@ApiOperation({ summary: 'Create a new bill payment.' })
public createBillPayment(@Body() billPaymentDTO: CreateBillPaymentDto) {
return this.billPaymentsApplication.createBillPayment(billPaymentDTO);
}
@Delete(':billPaymentId')
@RequirePermission(IPaymentMadeAction.Delete, AbilitySubject.PaymentMade)
@ApiOperation({ summary: 'Delete the given bill payment.' })
@ApiParam({
name: 'billPaymentId',
@@ -59,6 +69,7 @@ export class BillPaymentsController {
}
@Put(':billPaymentId')
@RequirePermission(IPaymentMadeAction.Edit, AbilitySubject.PaymentMade)
@ApiOperation({ summary: 'Edit the given bill payment.' })
@ApiParam({
name: 'billPaymentId',
@@ -77,11 +88,12 @@ export class BillPaymentsController {
}
@Get('/new-page/entries')
@RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade)
@ApiOperation({
summary:
'Retrieves the payable entries of the new page once vendor be selected.',
})
@ApiParam({
@ApiQuery({
name: 'vendorId',
required: true,
type: Number,
@@ -95,6 +107,7 @@ export class BillPaymentsController {
}
@Get(':billPaymentId/bills')
@RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade)
@ApiOperation({ summary: 'Retrieves the bills of the given bill payment.' })
@ApiParam({
name: 'billPaymentId',
@@ -107,6 +120,7 @@ export class BillPaymentsController {
}
@Get('/:billPaymentId/edit-page')
@RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade)
@ApiOperation({
summary: 'Retrieves the edit page of the given bill payment.',
})
@@ -126,6 +140,7 @@ export class BillPaymentsController {
}
@Get(':billPaymentId')
@RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade)
@ApiOperation({ summary: 'Retrieves the bill payment details.' })
@ApiResponse({
status: 200,
@@ -145,6 +160,7 @@ export class BillPaymentsController {
}
@Get()
@RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade)
@ApiOperation({ summary: 'Retrieves the bill payments list.' })
@ApiResponse({
status: 200,
@@ -9,7 +9,9 @@ import { BillPaymentMeta } from './BillPayment.meta';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
import { BillPaymentDefaultViews } from '../constants';
import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator';
@InjectAttachable()
@ImportableModel()
@ExportableModel()
@InjectModelMeta(BillPaymentMeta)
@@ -2,7 +2,8 @@ import { CreateBill } from './commands/CreateBill.service';
import { EditBillService } from './commands/EditBill.service';
import { GetBill } from './queries/GetBill';
import { DeleteBill } from './commands/DeleteBill.service';
import { IBillDTO, IBillEditDTO, IBillsFilter } from './Bills.types';
import { IBillDTO, IBillEditDTO } from './Bills.types';
import { GetBillsQueryDto } from './dtos/GetBillsQuery.dto';
import { GetDueBills } from './queries/GetDueBills.service';
import { OpenBillService } from './commands/OpenBill.service';
import { Injectable } from '@nestjs/common';
@@ -78,9 +79,9 @@ export class BillsApplication {
/**
* Retrieve bills data table list.
* @param {IBillsFilter} billsFilter -
* @param {GetBillsQueryDto} filterDTO -
*/
public getBills(filterDTO: Partial<IBillsFilter>) {
public getBills(filterDTO: GetBillsQueryDto) {
return this.getBillsService.getBills(filterDTO);
}
@@ -2,6 +2,7 @@ import {
ApiExtraModels,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
getSchemaPath,
@@ -17,10 +18,11 @@ import {
Get,
Query,
HttpCode,
UseGuards,
} from '@nestjs/common';
import { BillsApplication } from './Bills.application';
import { IBillsFilter } from './Bills.types';
import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
import { GetBillsQueryDto } from './dtos/GetBillsQuery.dto';
import { BillResponseDto } from './dtos/BillResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@@ -28,6 +30,11 @@ import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
import { PermissionGuard } from '@/modules/Roles/Permission.guard';
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
import { AbilitySubject } from '@/modules/Roles/Roles.types';
import { BillAction } from './Bills.types';
@Controller('bills')
@ApiTags('Bills')
@@ -35,10 +42,12 @@ import {
@ApiExtraModels(PaginatedResponseDto)
@ApiCommonHeaders()
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@UseGuards(AuthorizationGuard, PermissionGuard)
export class BillsController {
constructor(private billsApplication: BillsApplication) { }
@Post('validate-bulk-delete')
@RequirePermission(BillAction.Delete, AbilitySubject.Bill)
@ApiOperation({
summary: 'Validate which bills can be deleted and return the results.',
})
@@ -58,6 +67,7 @@ export class BillsController {
}
@Post('bulk-delete')
@RequirePermission(BillAction.Delete, AbilitySubject.Bill)
@ApiOperation({ summary: 'Deletes multiple bills.' })
@HttpCode(200)
@ApiResponse({
@@ -73,12 +83,14 @@ export class BillsController {
}
@Post()
@RequirePermission(BillAction.Create, AbilitySubject.Bill)
@ApiOperation({ summary: 'Create a new bill.' })
createBill(@Body() billDTO: CreateBillDto) {
return this.billsApplication.createBill(billDTO);
}
@Put(':id')
@RequirePermission(BillAction.Edit, AbilitySubject.Bill)
@ApiOperation({ summary: 'Edit the given bill.' })
@ApiParam({
name: 'id',
@@ -91,6 +103,7 @@ export class BillsController {
}
@Delete(':id')
@RequirePermission(BillAction.Delete, AbilitySubject.Bill)
@ApiOperation({ summary: 'Delete the given bill.' })
@ApiParam({
name: 'id',
@@ -103,6 +116,7 @@ export class BillsController {
}
@Get()
@RequirePermission(BillAction.View, AbilitySubject.Bill)
@ApiOperation({ summary: 'Retrieves the bills.' })
@ApiResponse({
status: 200,
@@ -127,11 +141,12 @@ export class BillsController {
type: Number,
description: 'The bill id',
})
getBills(@Query() filterDTO: Partial<IBillsFilter>) {
getBills(@Query() filterDTO: GetBillsQueryDto) {
return this.billsApplication.getBills(filterDTO);
}
@Get(':id/payment-transactions')
@RequirePermission(BillAction.View, AbilitySubject.Bill)
@ApiOperation({
summary: 'Retrieve the specific bill associated payment transactions.',
})
@@ -141,11 +156,16 @@ export class BillsController {
type: Number,
description: 'The bill id',
})
@ApiResponse({
status: 200,
description: 'List of payment transactions for the bill.',
})
getBillPaymentTransactions(@Param('id') billId: number) {
return this.billsApplication.getBillPaymentTransactions(billId);
}
@Get(':id')
@RequirePermission(BillAction.View, AbilitySubject.Bill)
@ApiOperation({ summary: 'Retrieves the bill details.' })
@ApiResponse({
status: 200,
@@ -165,6 +185,7 @@ export class BillsController {
}
@Patch(':id/open')
@RequirePermission(BillAction.Edit, AbilitySubject.Bill)
@ApiOperation({ summary: 'Open the given bill.' })
@ApiParam({
name: 'id',
@@ -177,8 +198,19 @@ export class BillsController {
}
@Get('due')
@RequirePermission(BillAction.View, AbilitySubject.Bill)
@ApiOperation({ summary: 'Retrieves the due bills.' })
getDueBills(@Body('vendorId') vendorId?: number) {
@ApiQuery({
name: 'vendor_id',
required: false,
type: Number,
description: 'Filter due bills by vendor ID.',
})
@ApiResponse({
status: 200,
description: 'List of due bills (optionally filtered by vendor).',
})
getDueBills(@Query('vendor_id') vendorId?: number) {
return this.billsApplication.getDueBills(vendorId);
}
}
@@ -1,6 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ItemEntryDto } from '@/modules/TransactionItemEntry/dto/ItemEntry.dto';
import { AttachmentLinkDto } from '@/modules/Attachments/dtos/Attachment.dto';
import { BranchResponseDto } from '@/modules/Branches/dtos/BranchResponse.dto';
import { DiscountType } from '@/common/types/Discount';
export class BillResponseDto {
@@ -89,6 +91,14 @@ export class BillResponseDto {
})
branchId?: number;
@ApiProperty({
description: 'Branch details',
type: () => BranchResponseDto,
required: false,
})
@Type(() => BranchResponseDto)
branch?: BranchResponseDto;
@ApiProperty({
description: 'The ID of the project',
example: 301,
@@ -0,0 +1,3 @@
import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto';
export class GetBillsQueryDto extends DynamicFilterQueryDto {}
@@ -11,9 +11,12 @@ import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator';
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
import { BillMeta } from './Bill.meta';
import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection';
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
import { BillDefaultViews } from '../Bills.constants';
import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator';
@InjectAttachable()
@ExportableModel()
@InjectModelMeta(BillMeta)
@InjectModelDefaultViews(BillDefaultViews)
@@ -405,7 +408,8 @@ export class Bill extends TenantBaseModel {
* Sort the bills by full-payment bills.
*/
sortByStatus(query, order) {
query.orderByRaw(`PAYMENT_AMOUNT = AMOUNT ${order}`);
const dir = sanitizeSortDirection(order);
query.orderByRaw(`PAYMENT_AMOUNT = AMOUNT ${dir}`);
},
/**
@@ -30,6 +30,7 @@ export class BillTransformer extends Transformer {
'taxes',
'entries',
'attachments',
'branch',
];
};
@@ -5,7 +5,7 @@ import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service
import { Bill } from '../models/Bill';
import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model';
import { BillTransformer } from './Bill.transformer';
import { IBillsFilter } from '../Bills.types';
import { GetBillsQueryDto } from '../dtos/GetBillsQuery.dto';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
@@ -19,10 +19,10 @@ export class GetBillsService {
/**
* Retrieve bills data table list.
* @param {IBillsFilter} billsFilter -
* @param {GetBillsQueryDto} filterDTO -
*/
public async getBills(filterDTO: Partial<IBillsFilter>): Promise<{
bills: Bill;
public async getBills(filterDTO: GetBillsQueryDto): Promise<{
bills: Bill[];
pagination: IPaginationMeta;
filterMeta: IFilterMeta;
}> {
@@ -31,6 +31,12 @@ import { ValidateBranchExistance } from './integrations/ValidateBranchExistance'
import { ManualJournalBranchesValidator } from './integrations/ManualJournals/ManualJournalsBranchesValidator';
import { CashflowTransactionsActivateBranches } from './integrations/Cashflow/CashflowActivateBranches';
import { ExpensesActivateBranches } from './integrations/Expense/ExpensesActivateBranches';
import { BillActivateBranches } from './integrations/Purchases/BillBranchesActivate';
import { VendorCreditActivateBranches } from './integrations/Purchases/VendorCreditBranchesActivate';
import { BillPaymentsActivateBranches } from './integrations/Purchases/PaymentMadeBranchesActivate';
import { BillBranchesActivateSubscriber } from './subscribers/Activate/BillBranchesActivateSubscriber';
import { VendorCreditBranchesActivateSubscriber } from './subscribers/Activate/VendorCreditBranchesActivateSubscriber';
import { PaymentMadeActivateBranchesSubscriber } from './subscribers/Activate/PaymentMadeBranchesActivateSubscriber';
import { FeaturesModule } from '../Features/Features.module';
@Module({
@@ -66,7 +72,13 @@ import { FeaturesModule } from '../Features/Features.module';
ValidateBranchExistance,
ManualJournalBranchesValidator,
CashflowTransactionsActivateBranches,
ExpensesActivateBranches
ExpensesActivateBranches,
BillActivateBranches,
VendorCreditActivateBranches,
BillPaymentsActivateBranches,
BillBranchesActivateSubscriber,
VendorCreditBranchesActivateSubscriber,
PaymentMadeActivateBranchesSubscriber
],
exports: [
BranchesSettingsService,
@@ -1,11 +1,14 @@
import { Knex } from 'knex';
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Bill } from '@/modules/Bills/models/Bill';
@Injectable()
export class BillActivateBranches {
constructor(private readonly billModel: TenantModelProxy<typeof Bill>) {}
constructor(
@Inject(Bill.name)
private readonly billModel: TenantModelProxy<typeof Bill>,
) {}
/**
* Updates all bills transactions with the primary branch.
@@ -17,7 +20,7 @@ export class BillActivateBranches {
primaryBranchId: number,
trx?: Knex.Transaction,
) => {
// Updates the sale invoice with primary branch.
await Bill.query(trx).update({ branchId: primaryBranchId });
// Updates the bills with primary branch.
await this.billModel().query(trx).update({ branchId: primaryBranchId });
};
}
@@ -1,11 +1,12 @@
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { BillPayment } from '@/modules/BillPayments/models/BillPayment';
import { Injectable } from '@nestjs/common';
@Injectable()
export class BillPaymentsActivateBranches {
constructor(
@Inject(BillPayment.name)
private readonly billPaymentModel: TenantModelProxy<typeof BillPayment>,
) {}
@@ -1,11 +1,12 @@
import { Knex } from 'knex';
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { VendorCredit } from '@/modules/VendorCredit/models/VendorCredit';
@Injectable()
export class VendorCreditActivateBranches {
constructor(
@Inject(VendorCredit.name)
private readonly vendorCreditModel: TenantModelProxy<typeof VendorCredit>,
) {}
@@ -29,7 +29,7 @@ export class Branch extends BaseModel{
* Timestamps columns.
*/
get timestamps() {
return ['created_at', 'updated_at'];
return ['createdAt', 'updatedAt'];
}
/**
@@ -0,0 +1,28 @@
import { IBranchesActivatedPayload } from '../../Branches.types';
import { events } from '@/common/events/events';
import { Injectable } from '@nestjs/common';
import { BillActivateBranches } from '../../integrations/Purchases/BillBranchesActivate';
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class BillBranchesActivateSubscriber {
constructor(
private readonly billActivateBranches: BillActivateBranches,
) { }
/**
* Updates bills transactions with the primary branch once
* the multi-branches is activated.
* @param {IBranchesActivatedPayload}
*/
@OnEvent(events.branch.onActivated)
async updateBillsWithBranchOnActivated({
primaryBranch,
trx,
}: IBranchesActivatedPayload) {
await this.billActivateBranches.updateBillsWithBranch(
primaryBranch.id,
trx,
);
}
}
@@ -0,0 +1,28 @@
import { IBranchesActivatedPayload } from '../../Branches.types';
import { events } from '@/common/events/events';
import { Injectable } from '@nestjs/common';
import { VendorCreditActivateBranches } from '../../integrations/Purchases/VendorCreditBranchesActivate';
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class VendorCreditBranchesActivateSubscriber {
constructor(
private readonly vendorCreditActivateBranches: VendorCreditActivateBranches,
) { }
/**
* Updates vendor credits transactions with the primary branch once
* the multi-branches is activated.
* @param {IBranchesActivatedPayload}
*/
@OnEvent(events.branch.onActivated)
async updateVendorCreditsWithBranchOnActivated({
primaryBranch,
trx,
}: IBranchesActivatedPayload) {
await this.vendorCreditActivateBranches.updateVendorCreditsWithBranch(
primaryBranch.id,
trx,
);
}
}
@@ -11,6 +11,7 @@ import { TenantsMigrateMakeCommand } from './commands/TenantsMigrateMake.command
import { TenantsListCommand } from './commands/TenantsList.command';
import { SystemSeedLatestCommand } from './commands/SystemSeedLatest.command';
import { TenantsSeedLatestCommand } from './commands/TenantsSeedLatest.command';
import { OpenApiExportCommand } from './commands/OpenApiExport.command';
@Module({
imports: [
@@ -30,6 +31,7 @@ import { TenantsSeedLatestCommand } from './commands/TenantsSeedLatest.command';
TenantsListCommand,
SystemSeedLatestCommand,
TenantsSeedLatestCommand,
OpenApiExportCommand,
],
})
export class CLIModule { }
@@ -0,0 +1,44 @@
import { Command, CommandRunner } from 'nest-commander';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ClsMiddleware } from 'nestjs-cls';
import * as path from 'path';
import * as fs from 'fs';
import '@/utils/moment-mysql';
import { AppModule } from '@/modules/App/App.module';
import { NestExpressApplication } from '@nestjs/platform-express';
@Command({
name: 'openapi:export',
description: 'Export the OpenAPI document from the NestJS app to shared/sdk-ts/openapi.json',
})
export class OpenApiExportCommand extends CommandRunner {
async run(): Promise<void> {
const serverRoot = process.cwd();
global.__public_dirname = path.join(serverRoot, 'public');
global.__static_dirname = path.join(serverRoot, 'static');
global.__views_dirname = path.join(global.__static_dirname, '/views');
global.__images_dirname = path.join(global.__static_dirname, '/images');
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
rawBody: true,
});
app.set('query parser', 'extended');
app.setGlobalPrefix('/api');
app.use(new ClsMiddleware({}).use);
const config = new DocumentBuilder()
.setTitle('Bigcapital')
.setDescription('Financial accounting software')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
await app.close();
const outputPath = path.resolve(process.cwd(), '../../shared/sdk-ts/openapi.json');
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(document, null, 2), 'utf-8');
console.log(`OpenAPI spec written to ${outputPath}`);
}
}
@@ -28,7 +28,7 @@ export class DocumentLink extends BaseModel{
* Relationship mapping.
*/
static get relationMappings() {
const Document = require('./Document');
const { Document } = require('./Document');
return {
/**
@@ -36,7 +36,7 @@ export class DocumentLink extends BaseModel{
*/
document: {
relation: Model.HasOneRelation,
modelClass: Document.default,
modelClass: Document,
join: {
from: 'document_links.documentId',
to: 'documents.id',
@@ -6,9 +6,10 @@ import {
Patch,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { GetContactsAutoCompleteQuery } from './dtos/GetContactsAutoCompleteQuery.dto';
import { GetAutoCompleteContactsService } from './queries/GetAutoCompleteContacts.service';
import { GetContactService } from './queries/GetContact.service';
import { ActivateContactService } from './commands/ActivateContact.service';
import { InactivateContactService } from './commands/InactivateContact.service';
@@ -17,6 +18,7 @@ import { InactivateContactService } from './commands/InactivateContact.service';
export class ContactsController {
constructor(
private readonly getAutoCompleteService: GetAutoCompleteContactsService,
private readonly getContactService: GetContactService,
private readonly activateContactService: ActivateContactService,
private readonly inactivateContactService: InactivateContactService,
) {}
@@ -27,6 +29,14 @@ export class ContactsController {
return this.getAutoCompleteService.autocompleteContacts(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get contact by ID (customer or vendor)' })
@ApiParam({ name: 'id', type: Number, description: 'Contact ID' })
@ApiResponse({ status: 200, description: 'Contact details (under "customer" key for form/duplicate use)' })
getContact(@Param('id', ParseIntPipe) contactId: number) {
return this.getContactService.getContact(contactId);
}
@Patch(':id/activate')
@ApiOperation({ summary: 'Activate a contact' })
@ApiParam({ name: 'id', type: 'number', description: 'Contact ID' })
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { GetAutoCompleteContactsService } from './queries/GetAutoCompleteContacts.service';
import { GetContactService } from './queries/GetContact.service';
import { ContactsController } from './Contacts.controller';
import { ActivateContactService } from './commands/ActivateContact.service';
import { InactivateContactService } from './commands/InactivateContact.service';
@@ -7,6 +8,7 @@ import { InactivateContactService } from './commands/InactivateContact.service';
@Module({
providers: [
GetAutoCompleteContactsService,
GetContactService,
ActivateContactService,
InactivateContactService,
],
@@ -0,0 +1,27 @@
import { Inject, Injectable } from '@nestjs/common';
import { Contact } from '../models/Contact';
import { ContactTransfromer } from '../Contact.transformer';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetContactService {
constructor(
private readonly transformer: TransformerInjectable,
@Inject(Contact.name)
private readonly contactModel: TenantModelProxy<typeof Contact>,
) {}
/**
* Retrieve contact by id (customer or vendor).
* Returns transformed contact for duplicate/form use.
*/
async getContact(contactId: number): Promise<Record<string, unknown>> {
const contact = await this.contactModel()
.query()
.findById(contactId)
.throwIfNotFound();
return this.transformer.transform(contact, new ContactTransfromer());
}
}

Some files were not shown because too many files have changed in this diff Show More