Compare commits

...

66 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
04d065b969 wip 2026-01-24 13:59:43 +02:00
Ahmed Bouhuolia
ca910ee489 fix(server): customer/vendor opening balance: 2026-01-23 17:43:22 +02:00
Ahmed Bouhuolia
17651e0768 Merge pull request #871 from bigcapitalhq/fix-payment-link-base-url
fix: generated payment link base url
2025-12-14 13:29:09 +02:00
Ahmed Bouhuolia
151b623771 fix: generated payment link base url 2025-12-14 13:26:34 +02:00
Ahmed Bouhuolia
2d4459c2f9 fix: payment portal page 2025-12-14 13:06:44 +02:00
Ahmed Bouhuolia
3cbdc3ec96 Merge pull request #870 from bigcapitalhq/report-pdf-template
fix: reports pdf template
2025-12-12 23:42:02 +02:00
Ahmed Bouhuolia
3cfb5cdde8 fix: reports pdf template 2025-12-12 23:38:48 +02:00
Ahmed Bouhuolia
736f2c4109 Merge pull request #869 from bigcapitalhq/fix-passing-number-format-to-reports
fix: passing number format to reports
2025-12-11 00:25:57 +02:00
Ahmed Bouhuolia
2e21437056 fix: update pnpm-lock.yaml 2025-12-11 00:23:50 +02:00
Ahmed Bouhuolia
340b78d968 fix: passing number format to reports 2025-12-11 00:19:55 +02:00
Ahmed Bouhuolia
d006362be2 fix: transaction locking handling 2025-12-05 23:47:29 +02:00
Ahmed Bouhuolia
bc21dcb37e fix(webapp): add api key button 2025-12-05 15:14:31 +02:00
Ahmed Bouhuolia
578b0deb3e fix: sending mail jobs (#868) 2025-12-05 00:09:11 +02:00
Ahmed Bouhuolia
c3dc26a1e4 fix: sending mail jobs 2025-12-05 00:07:26 +02:00
Ahmed Bouhuolia
32d74b0413 feat: onboarding pages darkmode (#867) 2025-12-03 16:04:46 +02:00
allcontributors[bot]
71b1206f8a docs: add Daniel15 as a contributor for bug, and code (#865)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-12-02 01:42:54 +02:00
Ahmed Bouhuolia
cb1bcaae77 Merge pull request #864 from Daniel15/patch-3
fix: Stripe integration
2025-12-02 01:41:04 +02:00
Ahmed Bouhuolia
eb51646035 fix: stripe payment webhooks 2025-12-02 01:26:58 +02:00
Ahmed Bouhuolia
8f54754aba feat: add stripe payment webhooks controller 2025-12-01 13:24:19 +02:00
Daniel Lo Nigro
0f446f90ca Change stripe_checkout_session to POST 2025-11-30 16:01:04 -08:00
Daniel Lo Nigro
7cb67b257b Cast payment_integration_id to number 2025-11-30 15:59:29 -08:00
Daniel Lo Nigro
0a1fffb3a4 Correctly register PaymentIntegration as tenant model 2025-11-30 15:52:07 -08:00
Daniel Lo Nigro
b756f090ed Fix Stripe redirect_uri 2025-11-30 15:50:42 -08:00
Daniel Lo Nigro
f9e49727fc Fix Stripe API URLs in webapp 2025-11-30 15:35:27 -08:00
Ahmed Bouhuolia
66969753b1 fix: seeds file-system directory 2025-11-30 22:59:48 +02:00
Ahmed Bouhuolia
3648fb3ffc fix: cloud subscription flag 2025-11-30 22:38:00 +02:00
Ahmed Bouhuolia
e196d485cf fix: filter pdf templates by resource 2025-11-26 22:25:42 +02:00
Ahmed Bouhuolia
74e46364ac feat: theme preloading and dark mode 2025-11-26 21:27:42 +02:00
Ahmed Bouhuolia
8817be4813 Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2025-11-25 23:46:51 +02:00
Ahmed Bouhuolia
cd4816aa3b fix: printing sale receipts 2025-11-25 23:46:41 +02:00
Ahmed Bouhuolia
82a2c74182 Merge pull request #859 from bigcapitalhq/rate-quantity-must-be-required
fix: rate and quantity of entries must not be empty
2025-11-25 22:03:25 +02:00
Ahmed Bouhuolia
e231efb9de fix: rate and quantity of entries must not be empty 2025-11-25 22:02:09 +02:00
Ahmed Bouhuolia
65ffc31ec0 Merge pull request #858 from bigcapitalhq/migrate-from-cra-to-vite
feat: migrate from CRA to Vite for speed
2025-11-25 21:36:28 +02:00
Ahmed Bouhuolia
dc6cf13a3e fix: wdyr error with vite 2025-11-25 21:34:17 +02:00
Ahmed Bouhuolia
adfa8852db wip 2025-11-25 21:29:32 +02:00
Ahmed Bouhuolia
ff04c4b762 wip 2025-11-24 18:58:50 +02:00
Ahmed Bouhuolia
fe4bd88f9f wip 2025-11-24 14:58:58 +02:00
Ahmed Bouhuolia
caf232d928 feat: migrate from CRA to Vite for speed 2025-11-24 14:19:05 +02:00
Ahmed Bouhuolia
234b1804b3 Merge pull request #855 from Daniel15/Daniel15-patch-1
Update commands in contributing docs
2025-11-21 11:25:29 +02:00
Daniel Lo Nigro
98b3b551c1 Update commands in contributing docs 2025-11-20 21:08:40 -08:00
Ahmed Bouhuolia
ceed9e453f feat: bulk transcations delete (#844)
* feat: bulk transcations delete
2025-11-20 23:11:06 +02:00
Ahmed Bouhuolia
43faa45dcf wip 2025-11-20 23:06:35 +02:00
Ahmed Bouhuolia
56e00d254b wip 2025-11-20 17:41:16 +02:00
Ahmed Bouhuolia
d90b6ffbe7 wip 2025-11-19 23:42:06 +02:00
Ahmed Bouhuolia
5eafd23bf8 wip 2025-11-19 22:59:30 +02:00
Ahmed Bouhuolia
2b384b2f6f wip 2025-11-19 22:59:21 +02:00
Ahmed Bouhuolia
17bcc14231 wip 2025-11-17 22:26:33 +02:00
Ahmed Bouhuolia
2c64e1b8ab wip 2025-11-17 17:04:25 +02:00
Daniel Lo Nigro
6f50138260 Improve Stripe example (#851) 2025-11-17 14:23:51 +02:00
Andres Maqueo
0a7d687f91 fix: docker/redis/Dockerfile to reduce vulnerabilities (#845)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-DEBIAN10-SYSTEMD-3339153
- https://snyk.io/vuln/SNYK-DEBIAN10-SYSTEMD-3339153
- https://snyk.io/vuln/SNYK-DEBIAN10-OPENSSL-2426310
- https://snyk.io/vuln/SNYK-DEBIAN10-OPENSSL-2807585
- https://snyk.io/vuln/SNYK-DEBIAN10-OPENSSL-1569403

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2025-11-17 14:22:00 +02:00
Ahmed Bouhuolia
2383091b6e wip 2025-11-12 21:34:30 +02:00
Ahmed Bouhuolia
e2f5d4c66e Merge pull request #848 from Daniel15/patch-1
[docker] Change BANKING_CONNECT to BANK_FEED_ENABLED
2025-11-12 09:31:08 +02:00
Daniel Lo Nigro
ce70234ebd [docker] Change BANKING_CONNECT to BANK_FEED_ENABLED 2025-11-11 22:57:42 -08:00
Ahmed Bouhuolia
80abd1f66f fix: edit/create account 2025-11-07 22:20:06 +02:00
Ahmed Bouhuolia
a0bc9db9a6 feat: bulk transcations delete 2025-11-03 21:40:24 +02:00
Ahmed Bouhuolia
8161439365 Merge pull request #843 from bigcapitalhq/refactor-fast-fields
feat: refactor FastField fields to binded Formik fields
2025-11-03 00:29:26 +02:00
Ahmed Bouhuolia
46871c8113 feat: refactor FastField fields to binded Formik fields 2025-11-03 00:27:32 +02:00
Ahmed Bouhuolia
a4aee58f93 Merge pull request #842 from bigcapitalhq/fix-auto-increment-transactions
fix: auto increment serial transactions
2025-11-02 21:10:04 +02:00
Ahmed Bouhuolia
f64875404a fix: auto increment serial transactions 2025-11-02 21:08:28 +02:00
Ahmed Bouhuolia
cca116f6bb Merge pull request #841 from bigcapitalhq/only-inactive-accounts-filter
fix: only inactive accounts filter
2025-11-02 20:00:14 +02:00
Ahmed Bouhuolia
fdec94a3f7 fix: only inactive accounts filter 2025-11-02 19:58:26 +02:00
Ahmed Bouhuolia
c66299aacd feat: darkmode preferences screens (#840) 2025-11-02 17:01:52 +02:00
Ahmed Bouhuolia
77dc0778a3 feat: darkmode preferences screens 2025-11-02 16:43:47 +02:00
Ahmed Bouhuolia
a76445a6eb feat: api keys ui (#839)
* feat: api keys ui
2025-11-02 12:41:16 +02:00
Ahmed Bouhuolia
41143d8bbd feat: api endpoints throttle (#837)
* feat: api endpoints throttle
2025-10-30 22:06:05 +02:00
Ahmed Bouhuolia
844a050c9a Merge pull request #836 from bigcapitalhq/auth-pages-errors-handler
fix: auth pages errors handler
2025-10-30 19:29:41 +02:00
455 changed files with 12740 additions and 9358 deletions

View File

@@ -168,6 +168,16 @@
"contributions": [
"bug"
]
},
{
"login": "Daniel15",
"name": "Daniel Lo Nigro",
"avatar_url": "https://avatars.githubusercontent.com/u/91933?v=4",
"profile": "https://d.sb/",
"contributions": [
"bug",
"code"
]
}
],
"contributorsPerLine": 7,

View File

@@ -98,8 +98,18 @@ POSTHOG_API_KEY=
POSTHOG_HOST=
# Stripe Payment
# Get the keys from the Stripe dashboard
# Starts with "sk_"
STRIPE_PAYMENT_SECRET_KEY=
# Starts with "pk_"
STRIPE_PAYMENT_PUBLISHABLE_KEY=
# Get the client ID from https://dashboard.stripe.com/settings/connect/onboarding-options/oauth
# Starts with "ca_"
STRIPE_PAYMENT_CLIENT_ID=
# Configure the webhook here: https://dashboard.stripe.com/workbench/webhooks/
# Endpoint URL is https://example.com/api/webhooks/stripe (replace "example.com" with the correct domain)
# Select the "checkout.session.completed" and "account.updated" events
# Starts with "whsec_"
STRIPE_PAYMENT_WEBHOOKS_SECRET=
STRIPE_PAYMENT_REDIRECT_URL=
# Replace example.com with the correct domain
STRIPE_PAYMENT_REDIRECT_URL=https://example.com/preferences/payment-methods/stripe/callback

View File

@@ -54,7 +54,7 @@ pnpm install
- Run all required docker containers in the development, we already configured all containers under `docker-compose.yml`.
```
docker-compose up -d
docker compose up -d
```
Wait some seconds, and hit `docker-compose ps` and you should see the same result below.
@@ -75,7 +75,7 @@ pnpm run build:server
- Run the database migration for system database.
```
node packages/server/build/commands.js system:migrate:latest
pnpm run system:migrate:latest
```
And you should get something like that.
@@ -84,10 +84,10 @@ And you should get something like that.
Batch 1 run: 6 migrations
```
- Next, start the webapp application.
- Next, start the server.
```
pnpm run dev:server
pnpm run server:start
```
**[`^top^`](#)**
@@ -96,12 +96,6 @@ pnpm run dev:server
## Contribute to Frontend
- Clone the `bigcapital` repository and cd into `bigcapital` directory.
```
git clone https://github.com/bigcapital/bigcapital.git && cd bigcaptial
```
- Install all npm dependencies of the monorepo, you don't have to change directory to the `frontend` package. just hit that command and will install all packages across all application.
```
@@ -138,4 +132,4 @@ There are many other ways to get involved with the community and to participate
Again, Feel free to ping us on [`#contributing`](https://discord.com/invite/c8nPBJafeb) on our Discord community if you need any help on this :)
Thank You!
Thank You!

View File

@@ -135,6 +135,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://myself.vercel.app/"><img src="https://avatars.githubusercontent.com/u/42431274?v=4?s=100" width="100px;" alt="Sachin Mittal"/><br /><sub><b>Sachin Mittal</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Amittalsam98" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.camilooviedo.com/"><img src="https://avatars.githubusercontent.com/u/64604272?v=4?s=100" width="100px;" alt="Camilo Oviedo"/><br /><sub><b>Camilo Oviedo</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=Champetaman" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://nklmantey.com/"><img src="https://avatars.githubusercontent.com/u/90279429?v=4?s=100" width="100px;" alt="Mantey"/><br /><sub><b>Mantey</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Anklmantey" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://d.sb/"><img src="https://avatars.githubusercontent.com/u/91933?v=4?s=100" width="100px;" alt="Daniel Lo Nigro"/><br /><sub><b>Daniel Lo Nigro</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3ADaniel15" title="Bug reports">🐛</a> <a href="https://github.com/bigcapitalhq/bigcapital/commits?author=Daniel15" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -93,7 +93,7 @@ services:
- OPEN_EXCHANGE_RATE_APP_ID-${OPEN_EXCHANGE_RATE_APP_ID}
# Bank Sync
- BANKING_CONNECT=${BANKING_CONNECT}
- BANK_FEED_ENABLED=${BANK_FEED_ENABLED}
# Plaid
- PLAID_ENV=${PLAID_ENV}
@@ -122,6 +122,13 @@ services:
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_BUCKET=${S3_BUCKET}
# Stripe
- STRIPE_PAYMENT_SECRET_KEY=${STRIPE_PAYMENT_SECRET_KEY}
- STRIPE_PAYMENT_PUBLISHABLE_KEY=${STRIPE_PAYMENT_PUBLISHABLE_KEY}
- STRIPE_PAYMENT_CLIENT_ID=${STRIPE_PAYMENT_CLIENT_ID}
- STRIPE_PAYMENT_WEBHOOKS_SECRET=${STRIPE_PAYMENT_WEBHOOKS_SECRET}
- STRIPE_PAYMENT_REDIRECT_URL=${STRIPE_PAYMENT_REDIRECT_URL}
database_migration:
container_name: bigcapital-database-migration

View File

@@ -1,4 +1,4 @@
FROM redis:6.2.0
FROM redis:6.2.21
COPY redis.conf /usr/local/etc/redis/redis.conf

View File

@@ -39,6 +39,7 @@
"@casl/ability": "^5.4.3",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"@liaoliaots/nestjs-redis": "^10.0.0",
"@nest-lab/throttler-storage-redis": "^1.1.0",
"@nestjs/bull": "^10.2.1",
"@nestjs/bullmq": "^10.2.2",
"@nestjs/cache-manager": "^2.2.2",

View File

@@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
baseUrl: process.env.BASE_URL,
}));

View File

@@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';
export default registerAs('cloud', () => ({
hostedOnCloud: process.env.HOSTED_ON_BIGCAPITAL_CLOUD === 'true',
}));

View File

@@ -1,3 +1,4 @@
import app from './app';
import systemDatabase from './system-database';
import tenantDatabase from './tenant-database';
import signup from './signup';
@@ -14,9 +15,13 @@ import jwt from './jwt';
import mail from './mail';
import loops from './loops';
import bankfeed from './bankfeed';
import throttle from './throttle';
import cloud from './cloud';
export const config = [
app,
systemDatabase,
cloud,
tenantDatabase,
signup,
gotenberg,
@@ -32,4 +37,5 @@ export const config = [
mail,
loops,
bankfeed,
throttle,
];

View File

@@ -0,0 +1,14 @@
import { registerAs } from '@nestjs/config';
export default registerAs('throttle', () => ({
global: {
ttl: parseInt(process.env.THROTTLE_GLOBAL_TTL ?? '60000', 10),
limit: parseInt(process.env.THROTTLE_GLOBAL_LIMIT ?? '100', 10),
},
auth: {
ttl: parseInt(process.env.THROTTLE_AUTH_TTL ?? '60000', 10),
limit: parseInt(process.env.THROTTLE_AUTH_LIMIT ?? '10', 10),
},
}));

View File

@@ -0,0 +1,55 @@
import { IsArray, IsInt, ArrayNotEmpty, IsBoolean, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
export class BulkDeleteDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({
description: 'Array of IDs to delete',
type: [Number],
example: [1, 2, 3],
})
ids: number[];
@IsOptional()
@IsBoolean()
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
@ApiPropertyOptional({
description: 'When true, undeletable items will be skipped and only deletable ones will be removed.',
type: Boolean,
default: false,
})
skipUndeletable?: boolean;
}
export class ValidateBulkDeleteResponseDto {
@ApiProperty({
description: 'Number of items that can be deleted',
example: 2,
})
deletableCount: number;
@ApiProperty({
description: 'Number of items that cannot be deleted',
example: 1,
})
nonDeletableCount: number;
@ApiProperty({
description: 'IDs of items that can be deleted',
type: [Number],
example: [1, 2],
})
deletableIds: number[];
@ApiProperty({
description: 'IDs of items that cannot be deleted',
type: [Number],
example: [3],
})
nonDeletableIds: number[];
}

View File

@@ -7,53 +7,46 @@ import {
} from '@nestjs/common';
import { type Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { mapKeysDeep } from '@/utils/deepdash';
export function camelToSnake<T = any>(value: T) {
export function camelToSnake<T = any>(value: T): T {
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.map(camelToSnake);
}
if (typeof value === 'object' && !(value instanceof Date)) {
return Object.fromEntries(
Object.entries(value).map(([key, value]) => [
key
.split(/(?=[A-Z])/)
.join('_')
.toLowerCase(),
camelToSnake(value),
]),
);
}
return value;
return mapKeysDeep(
value,
(_value: string, key: any, parent: any, context: any) => {
if (Array.isArray(parent)) {
// tell mapKeysDeep to skip mapping inside this branch
context.skipChildren = true;
return key;
}
return key
.split(/(?=[A-Z])/)
.join('_')
.toLowerCase();
},
) as T;
}
export function snakeToCamel<T = any>(value: T) {
export function snakeToCamel<T = any>(value: T): T {
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.map(snakeToCamel);
}
const impl = (str: string) => {
const converted = str.replace(/([-_]\w)/g, (group) =>
group[1].toUpperCase(),
);
return converted[0].toLowerCase() + converted.slice(1);
};
if (typeof value === 'object' && !(value instanceof Date)) {
return Object.fromEntries(
Object.entries(value).map(([key, value]) => [
impl(key),
snakeToCamel(value),
]),
);
}
return value;
return mapKeysDeep(
value,
(_value: string, key: any, parent: any, context: any) => {
if (Array.isArray(parent)) {
// tell mapKeysDeep to skip mapping inside this branch
context.skipChildren = true;
return key;
}
const converted = key.replace(/([-_]\w)/g, (group) =>
group[1].toUpperCase(),
);
return converted[0].toLowerCase() + converted.slice(1);
},
) as T;
}
export const DEFAULT_STRATEGY = {
@@ -63,7 +56,7 @@ export const DEFAULT_STRATEGY = {
@Injectable()
export class SerializeInterceptor implements NestInterceptor<any, any> {
constructor(@Optional() readonly strategy = DEFAULT_STRATEGY) {}
constructor(@Optional() readonly strategy = DEFAULT_STRATEGY) { }
intercept(
context: ExecutionContext,

View File

@@ -0,0 +1,36 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('api_keys', (table) => {
table.increments();
table.string('key').notNullable().unique().index();
table.string('name');
table
.integer('user_id')
.unsigned()
.notNullable()
.index()
.references('id')
.inTable('users');
table
.bigInteger('tenant_id')
.unsigned()
.notNullable()
.index()
.references('id')
.inTable('tenants');
table.dateTime('expires_at').nullable().index();
table.dateTime('revoked_at').nullable().index();
table.timestamps();
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('api_keys');
};

View File

@@ -11,6 +11,7 @@ class FsMigrations {
private sortDirsSeparately: boolean;
private migrationsPaths: string[];
private loadExtensions: string[];
private seedsDirectory: string;
/**
* Constructor method.
@@ -30,6 +31,8 @@ class FsMigrations {
}
this.migrationsPaths = migrationDirectories;
this.loadExtensions = loadExtensions || DEFAULT_LOAD_EXTENSIONS;
// Store the seeds directory (first path is the seeds directory)
this.seedsDirectory = migrationDirectories[0] || '';
}
/**
@@ -93,7 +96,10 @@ class FsMigrations {
* @returns {string}
*/
public getMigration(migration: MigrateItem): string {
return importWebpackSeedModule(migration.file.replace('.ts', ''));
return importWebpackSeedModule(
migration.file.replace('.ts', ''),
this.seedsDirectory,
);
}
}

View File

@@ -1,5 +1,6 @@
// @ts-nocheck
import * as fs from 'fs';
import * as path from 'path';
/**
* Detarmines the module type of the given file path.
@@ -35,8 +36,32 @@ export async function importFile(filepath: string): any {
/**
*
* @param {string} moduleName
* @param {string} seedsDirectory - The seeds directory path from config
* @returns
*/
export async function importWebpackSeedModule(moduleName: string): any {
return import(`../../database/seeds/core/${moduleName}`);
export async function importWebpackSeedModule(
moduleName: string,
seedsDirectory: string,
): any {
// Convert the seeds directory to a relative path from this file's location
const utilsDir = __dirname;
const seedsDirAbsolute = path.isAbsolute(seedsDirectory)
? seedsDirectory
: path.resolve(process.cwd(), seedsDirectory);
// Get relative path from Utils.js location to seeds directory
const relativePath = path.relative(utilsDir, seedsDirAbsolute);
// Convert to forward slashes for import (works on all platforms)
const importPath = relativePath.split(path.sep).join('/');
// Construct the import path (add ./ prefix if not already present, or handle empty/current dir)
let finalPath = importPath;
if (!finalPath || finalPath === '.') {
finalPath = './';
} else if (!finalPath.startsWith('.')) {
finalPath = `./${finalPath}`;
}
return import(`${finalPath}/${moduleName}`);
}

View File

@@ -8,6 +8,7 @@ import { ServiceErrorFilter } from './common/filters/service-error.filter';
import { ModelHasRelationsFilter } from './common/filters/model-has-relations.filter';
import { ValidationPipe } from './common/pipes/ClassValidation.pipe';
import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor';
import { NestExpressApplication } from '@nestjs/platform-express';
global.__public_dirname = path.join(__dirname, '..', 'public');
global.__static_dirname = path.join(__dirname, '../static');
@@ -15,7 +16,10 @@ global.__views_dirname = path.join(global.__static_dirname, '/views');
global.__images_dirname = path.join(global.__static_dirname, '/images');
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
rawBody: true,
});
app.set('query parser', 'extended');
app.setGlobalPrefix('/api');
// create and mount the middleware manually here

View File

@@ -7,15 +7,16 @@ import {
Get,
Query,
ParseIntPipe,
Put,
} from '@nestjs/common';
import { AccountsApplication } from './AccountsApplication.service';
import { CreateAccountDTO } from './CreateAccount.dto';
import { EditAccountDTO } from './EditAccount.dto';
import { IAccountsFilter } from './Accounts.types';
import {
ApiExtraModels,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
getSchemaPath,
@@ -24,16 +25,57 @@ import { AccountResponseDto } from './dtos/AccountResponse.dto';
import { AccountTypeResponseDto } from './dtos/AccountTypeResponse.dto';
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
import { GetAccountTransactionsQueryDto } from './dtos/GetAccountTransactionsQuery.dto';
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('accounts')
@ApiTags('Accounts')
@ApiExtraModels(AccountResponseDto)
@ApiExtraModels(AccountTypeResponseDto)
@ApiExtraModels(GetAccountTransactionResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders()
export class AccountsController {
constructor(private readonly accountsApplication: AccountsApplication) {}
constructor(private readonly accountsApplication: AccountsApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which accounts can be deleted and returns counts of deletable and non-deletable accounts.',
})
@ApiResponse({
status: 200,
description:
'Validation completed. Returns counts and IDs of deletable and non-deletable accounts.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
async validateBulkDeleteAccounts(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.accountsApplication.validateBulkDeleteAccounts(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple accounts in bulk.' })
@ApiResponse({
status: 200,
description: 'The accounts have been successfully deleted.',
})
async bulkDeleteAccounts(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.accountsApplication.bulkDeleteAccounts(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
@Post()
@ApiOperation({ summary: 'Create an account' })
@@ -45,7 +87,7 @@ export class AccountsController {
return this.accountsApplication.createAccount(accountDTO);
}
@Post(':id')
@Put(':id')
@ApiOperation({ summary: 'Edit the given account.' })
@ApiResponse({
status: 200,
@@ -178,7 +220,19 @@ export class AccountsController {
items: { $ref: getSchemaPath(AccountResponseDto) },
},
})
async getAccounts(@Query() filter: Partial<IAccountsFilter>) {
@ApiQuery({
name: 'onlyInactive',
required: false,
type: Boolean,
description: 'Filter to show only inactive accounts',
})
@ApiQuery({
name: 'structure',
required: false,
type: String,
description: 'Structure type for the accounts list',
})
async getAccounts(@Query() filter: GetAccountsQueryDto) {
return this.accountsApplication.getAccounts(filter);
}
}

View File

@@ -19,6 +19,8 @@ import { GetAccountsService } from './GetAccounts.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { AccountsExportable } from './AccountsExportable.service';
import { AccountsImportable } from './AccountsImportable.service';
import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service';
import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service';
const models = [RegisterTenancyModel(BankAccount)];
@@ -40,7 +42,9 @@ const models = [RegisterTenancyModel(BankAccount)];
GetAccountTransactionsService,
GetAccountsService,
AccountsExportable,
AccountsImportable
AccountsImportable,
BulkDeleteAccountsService,
ValidateBulkDeleteAccountsService,
],
exports: [
AccountRepository,

View File

@@ -10,10 +10,14 @@ import { GetAccount } from './GetAccount.service';
import { ActivateAccount } from './ActivateAccount.service';
import { GetAccountTypesService } from './GetAccountTypes.service';
import { GetAccountTransactionsService } from './GetAccountTransactions.service';
import { IAccountsFilter, IAccountsTransactionsFilter } from './Accounts.types';
import { IAccountsTransactionsFilter } from './Accounts.types';
import { GetAccountsService } from './GetAccounts.service';
import { IFilterMeta } from '@/interfaces/Model';
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service';
import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service';
import { ValidateBulkDeleteResponseDto } from '@/common/dtos/BulkDelete.dto';
@Injectable()
export class AccountsApplication {
@@ -36,7 +40,9 @@ export class AccountsApplication {
private readonly getAccountService: GetAccount,
private readonly getAccountTransactionsService: GetAccountTransactionsService,
private readonly getAccountsService: GetAccountsService,
) {}
private readonly bulkDeleteAccountsService: BulkDeleteAccountsService,
private readonly validateBulkDeleteAccountsService: ValidateBulkDeleteAccountsService,
) { }
/**
* Creates a new account.
@@ -108,11 +114,11 @@ export class AccountsApplication {
/**
* Retrieves the accounts list.
* @param {IAccountsFilter} filterDTO - Filter DTO.
* @param {GetAccountsQueryDto} filterDTO - Filter DTO.
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
*/
public getAccounts = (
filterDTO: Partial<IAccountsFilter>,
filterDTO: GetAccountsQueryDto,
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> => {
return this.getAccountsService.getAccountsList(filterDTO);
};
@@ -127,4 +133,28 @@ export class AccountsApplication {
): Promise<Array<GetAccountTransactionResponseDto>> => {
return this.getAccountTransactionsService.getAccountsTransactions(filter);
};
/**
* Validates which accounts can be deleted in bulk.
*/
public validateBulkDeleteAccounts = (
accountIds: number[],
): Promise<ValidateBulkDeleteResponseDto> => {
return this.validateBulkDeleteAccountsService.validateBulkDeleteAccounts(
accountIds,
);
};
/**
* Deletes multiple accounts in bulk.
*/
public bulkDeleteAccounts = (
accountIds: number[],
options?: { skipUndeletable?: boolean },
): Promise<void> => {
return this.bulkDeleteAccountsService.bulkDeleteAccounts(
accountIds,
options,
);
};
}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteAccount } from './DeleteAccount.service';
@Injectable()
export class BulkDeleteAccountsService {
constructor(private readonly deleteAccountService: DeleteAccount) { }
/**
* Deletes multiple accounts.
* @param {number | Array<number>} accountIds - The account id or ids.
* @param {Knex.Transaction} trx - The transaction.
*/
async bulkDeleteAccounts(
accountIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const accountsIds = uniq(castArray(accountIds));
const results = await PromisePool.withConcurrency(1)
.for(accountsIds)
.process(async (accountId: number) => {
try {
await this.deleteAccountService.deleteAccount(accountId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -17,7 +17,7 @@ export class CommandAccountValidators {
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
private readonly accountRepository: AccountRepository,
) {}
) { }
/**
* Throws error if the account was prefined.
@@ -115,7 +115,7 @@ export class CommandAccountValidators {
accountName: string,
notAccountId?: number,
) {
const foundAccount = await this.accountModel
const foundAccount = await this.accountModel()
.query()
.findOne('name', accountName)
.onBuild((query) => {

View File

@@ -1,7 +1,7 @@
import { IsOptional, ToNumber } from '@/common/decorators/Validators';
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsInt,
MinLength,
MaxLength,
@@ -65,7 +65,7 @@ export class CreateAccountDTO {
description?: string;
@IsOptional()
@IsInt()
@ToNumber()
@ApiProperty({
description: 'ID of the parent account',
example: 1,

View File

@@ -50,8 +50,12 @@ export class DeleteAccount {
/**
* Deletes the account from the storage.
* @param {number} accountId
* @param {Knex.Transaction} trx - Database transaction instance.
*/
public deleteAccount = async (accountId: number): Promise<void> => {
public deleteAccount = async (
accountId: number,
trx?: Knex.Transaction,
): Promise<void> => {
// Retrieve account or not found service error.
const oldAccount = await this.accountModel().query().findById(accountId);
@@ -82,6 +86,6 @@ export class DeleteAccount {
oldAccount,
trx,
} as IAccountEventDeletedPayload);
});
}, trx);
};
}

View File

@@ -1,11 +1,6 @@
import { IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsInt,
MinLength,
MaxLength,
} from 'class-validator';
import { IsOptional, ToNumber } from '@/common/decorators/Validators';
export class EditAccountDTO {
@IsString()
@@ -45,7 +40,7 @@ export class EditAccountDTO {
description?: string;
@IsOptional()
@IsInt()
@ToNumber()
@ApiProperty({
description: 'The parent account ID of the account',
example: 1,

View File

@@ -17,7 +17,7 @@ export class EditAccount {
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
) {}
) { }
/**
* Authorize the account editing.
@@ -85,8 +85,7 @@ export class EditAccount {
// Update the account on the storage.
const account = await this.accountModel()
.query(trx)
.findById(accountId)
.updateAndFetch({ ...accountDTO });
.updateAndFetchById(accountId, { ...accountDTO });
// Triggers `onAccountEdited` event.
// await this.eventEmitter.emitAsync(events.accounts.onEdited, {

View File

@@ -8,6 +8,7 @@ import { Account } from './models/Account.model';
import { AccountRepository } from './repositories/Account.repository';
import { IFilterMeta } from '@/interfaces/Model';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
@Injectable()
export class GetAccountsService {
@@ -18,7 +19,7 @@ export class GetAccountsService {
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
) {}
) { }
/**
* Retrieve accounts datatable list.
@@ -26,12 +27,12 @@ export class GetAccountsService {
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
*/
public async getAccountsList(
filterDto: Partial<IAccountsFilter>,
filterDto: Partial<GetAccountsQueryDto>,
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> {
const parsedFilterDto = {
sortOrder: 'desc',
columnSortBy: 'created_at',
inactiveMode: false,
onlyInactive: false,
structure: IAccountsStructureType.Tree,
...filterDto,
};
@@ -48,7 +49,7 @@ export class GetAccountsService {
.query()
.onBuild((builder) => {
dynamicList.buildQuery()(builder);
builder.modify('inactiveMode', filter.inactiveMode);
builder.modify('inactiveMode', filterDto.onlyInactive);
});
const accountsGraph = await this.accountRepository.getDependencyGraph();
@@ -58,7 +59,6 @@ export class GetAccountsService {
new AccountTransformer(),
{ accountsGraph, structure: parsedFilterDto.structure },
);
return {
accounts: transformedAccounts,
filterMeta: dynamicList.getResponseMeta(),

View File

@@ -0,0 +1,63 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteAccount } from './DeleteAccount.service';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
@Injectable()
export class ValidateBulkDeleteAccountsService {
constructor(
private readonly deleteAccountService: DeleteAccount,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
/**
* Validates which accounts from the provided IDs can be deleted.
* Uses the actual deleteAccount service to validate, ensuring the same validation logic.
* Uses a transaction that is always rolled back to ensure no database changes.
* @param {number[]} accountIds - Array of account IDs to validate
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
*/
public async validateBulkDeleteAccounts(accountIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const accountId of accountIds) {
try {
await this.deleteAccountService.deleteAccount(accountId, trx);
deletableIds.push(accountId);
} catch (error) {
if (error instanceof ModelHasRelationsError) {
nonDeletableIds.push(accountId);
} else {
nonDeletableIds.push(accountId);
}
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -0,0 +1,27 @@
import { IsBoolean, IsEnum, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
import { IAccountsStructureType } from '../Accounts.types';
export class GetAccountsQueryDto {
@ApiPropertyOptional({
type: Boolean,
description: 'Filter to show only inactive accounts',
default: false,
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => parseBoolean(value, false))
onlyInactive?: boolean;
@ApiPropertyOptional({
enum: IAccountsStructureType,
description: 'Structure type for the accounts list',
default: IAccountsStructureType.Tree,
})
@IsOptional()
@IsEnum(IAccountsStructureType)
structure?: IAccountsStructureType;
}

View File

@@ -1,7 +1,7 @@
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { join } from 'path';
import { ServeStaticModule } from '@nestjs/serve-static';
import { RedisModule } from '@liaoliaots/nestjs-redis';
@@ -95,6 +95,8 @@ import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
import { SocketModule } from '../Socket/Socket.module';
import { ThrottlerGuard } from '@nestjs/throttler';
import { AppThrottleModule } from './AppThrottle.module';
@Module({
imports: [
@@ -126,6 +128,7 @@ import { SocketModule } from '../Socket/Socket.module';
],
}),
PassportModule,
AppThrottleModule,
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
@@ -231,6 +234,10 @@ import { SocketModule } from '../Socket/Socket.module';
],
controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: SerializeInterceptor,

View File

@@ -0,0 +1,48 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
@Module({
imports: [
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const host = configService.get<string>('redis.host') || 'localhost';
const port = Number(configService.get<number>('redis.port') || 6379);
const password = configService.get<string>('redis.password');
const db = configService.get<number>('redis.db');
const globalTtl = configService.get<number>('throttle.global.ttl');
const globalLimit = configService.get<number>('throttle.global.limit');
const authTtl = configService.get<number>('throttle.auth.ttl');
const authLimit = configService.get<number>('throttle.auth.limit');
return {
throttlers: [
{
name: 'default',
ttl: globalTtl,
limit: globalLimit,
},
{
name: 'auth',
ttl: authTtl,
limit: authLimit,
},
],
storage: new ThrottlerStorageRedisService({
host,
port,
password,
db,
}),
};
},
}),
],
})
export class AppThrottleModule { }

View File

@@ -8,6 +8,7 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import {
ApiTags,
ApiOperation,
@@ -28,6 +29,7 @@ import { SystemUser } from '../System/models/SystemUser';
@ApiTags('Auth')
@ApiExcludeController()
@PublicRoute()
@Throttle({ auth: {} })
export class AuthController {
constructor(
private readonly authApp: AuthenticationApplication,

View File

@@ -1,4 +1,4 @@
import { Controller, Post, Param, Get, Put } from '@nestjs/common';
import { Controller, Post, Param, Get, Put, Body } from '@nestjs/common';
import { GenerateApiKey } from './commands/GenerateApiKey.service';
import { GetApiKeysService } from './queries/GetApiKeys.service';
import {
@@ -8,6 +8,8 @@ import {
ApiParam,
ApiExtraModels,
getSchemaPath,
ApiBody,
ApiProperty,
} from '@nestjs/swagger';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
@@ -16,6 +18,20 @@ import {
ApiKeyListResponseDto,
ApiKeyListItemDto,
} from './dtos/ApiKey.dto';
import { IsString, MaxLength } from 'class-validator';
import { IsOptional } from '@/common/decorators/Validators';
class GenerateApiKeyDto {
@IsOptional()
@IsString()
@MaxLength(255)
@ApiProperty({
description: 'Optional name for the API key',
required: false,
example: 'My API Key',
})
name?: string;
}
@Controller('api-keys')
@ApiTags('Api keys')
@@ -29,17 +45,18 @@ export class AuthApiKeysController {
constructor(
private readonly getApiKeysService: GetApiKeysService,
private readonly generateApiKeyService: GenerateApiKey,
) {}
) { }
@Post('generate')
@ApiOperation({ summary: 'Generate a new API key' })
@ApiBody({ type: GenerateApiKeyDto })
@ApiResponse({
status: 201,
description: 'The generated API key',
type: ApiKeyResponseDto,
})
async generate() {
return this.generateApiKeyService.generate();
async generate(@Body() body: GenerateApiKeyDto) {
return this.generateApiKeyService.generate(body.name);
}
@Put(':id/revoke')

View File

@@ -6,6 +6,7 @@ import {
} from '@nestjs/swagger';
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 { AuthenticationApplication } from './AuthApplication.sevice';
@@ -18,11 +19,12 @@ import { IgnoreUserVerifiedRoute } from './guards/EnsureUserVerified.guard';
@IgnoreTenantSeededRoute()
@IgnoreTenantInitializedRoute()
@IgnoreUserVerifiedRoute()
@Throttle({ auth: {} })
export class AuthedController {
constructor(
private readonly getAuthedAccountService: GetAuthenticatedAccount,
private readonly authApp: AuthenticationApplication,
) {}
) { }
@Post('/signup/verify/resend')
@ApiOperation({ summary: 'Resend the signup confirmation message' })

View File

@@ -16,6 +16,7 @@ import {
import { defaultTo } from 'ramda';
import { ERRORS } from '../Auth.constants';
import { hashPassword } from '../Auth.utils';
import { ClsService } from 'nestjs-cls';
@Injectable()
export class AuthSignupService {
@@ -29,6 +30,7 @@ export class AuthSignupService {
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
private readonly tenantsManager: TenantsManagerService,
private readonly clsService: ClsService,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
@@ -70,6 +72,11 @@ export class AuthSignupService {
tenantId: tenant.id,
inviteAcceptedAt,
});
// Set the user in the cls service.
this.clsService.set('tenantId', user.tenantId);
this.clsService.set('userId', user.id);
this.clsService.set('organizationId', tenant.organizationId);
// Triggers signed up event.
await this.eventEmitter.emitAsync(events.auth.signUp, {
signupDTO,

View File

@@ -10,14 +10,15 @@ export class GenerateApiKey {
private readonly tenancyContext: TenancyContext,
@Inject(ApiKeyModel.name)
private readonly apiKeyModel: typeof ApiKeyModel,
) {}
) { }
/**
* Generates a new secure API key for the current tenant and system user.
* The key is saved in the database and returned (only the key and id for security).
* @param {string} name - Optional name for the API key.
* @returns {Promise<{ key: string; id: number }>} The generated API key and its database id.
*/
async generate() {
async generate(name?: string) {
const tenant = await this.tenancyContext.getTenant();
const user = await this.tenancyContext.getSystemUser();
@@ -26,6 +27,7 @@ export class GenerateApiKey {
// Save the API key to the database
const apiKeyRecord = await this.apiKeyModel.query().insert({
key,
name,
tenantId: tenant.id,
userId: user.id,
createdAt: new Date(),

View File

@@ -29,6 +29,12 @@ export class ApiKeyListItemDto {
@ApiProperty({ example: 'My API Key', description: 'API key name' })
name?: string;
@ApiProperty({
example: 'bc_1234...',
description: 'First 8 characters of the API key token',
})
token: string;
@ApiProperty({
example: '2024-01-01T00:00:00.000Z',
description: 'Creation date',

View File

@@ -1,7 +1,16 @@
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetApiKeysTransformer extends Transformer {
public includeAttributes = (): string[] => {
return ['token'];
};
public excludeAttributes = (): string[] => {
return ['tenantId'];
};
public token(apiKey) {
return apiKey.key ? `${apiKey.key.substring(0, 8)}...` : '';
}
}

View File

@@ -1,4 +1,4 @@
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
@@ -7,6 +7,7 @@ import {
IsPositive,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { parseBoolean } from '@/utils/parse-boolean';
export class NumberFormatQueryDto {
@ApiPropertyOptional({
@@ -24,6 +25,7 @@ export class NumberFormatQueryDto {
example: false,
})
@IsBoolean()
@Transform(({ value }) => parseBoolean(value, false))
@IsOptional()
readonly divideOn1000: boolean;
@@ -32,6 +34,7 @@ export class NumberFormatQueryDto {
example: true,
})
@IsBoolean()
@Transform(({ value }) => parseBoolean(value, false))
@IsOptional()
readonly showZero: boolean;

View File

@@ -9,6 +9,8 @@ import { Injectable } from '@nestjs/common';
import { GetBillsService } from './queries/GetBills.service';
import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
import { BulkDeleteBillsService } from './BulkDeleteBills.service';
import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service';
// import { GetBillPayments } from './queries/GetBillPayments';
// import { GetBills } from './queries/GetBills';
@@ -23,7 +25,9 @@ export class BillsApplication {
private openBillService: OpenBillService,
private getBillsService: GetBillsService,
private getBillPaymentTransactionsService: GetBillPaymentTransactionsService,
) {}
private bulkDeleteBillsService: BulkDeleteBillsService,
private validateBulkDeleteBillsService: ValidateBulkDeleteBillsService,
) { }
/**
* Creates a new bill with associated GL entries.
@@ -53,6 +57,25 @@ export class BillsApplication {
return this.deleteBillService.deleteBill(billId);
}
/**
* Deletes multiple bills.
* @param {number[]} billIds
*/
public bulkDeleteBills(
billIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteBillsService.bulkDeleteBills(billIds, options);
}
/**
* Validates which bills can be deleted.
* @param {number[]} billIds
*/
public validateBulkDeleteBills(billIds: number[]) {
return this.validateBulkDeleteBillsService.validateBulkDeleteBills(billIds);
}
/**
* Retrieve bills data table list.
* @param {IBillsFilter} billsFilter -

View File

@@ -22,14 +22,51 @@ import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
import { BillResponseDto } from './dtos/BillResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('bills')
@ApiTags('Bills')
@ApiExtraModels(BillResponseDto)
@ApiExtraModels(PaginatedResponseDto)
@ApiCommonHeaders()
@ApiExtraModels(ValidateBulkDeleteResponseDto)
export class BillsController {
constructor(private billsApplication: BillsApplication) {}
constructor(private billsApplication: BillsApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary: 'Validate which bills can be deleted and return the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable bills.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
validateBulkDeleteBills(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.billsApplication.validateBulkDeleteBills(bulkDeleteDto.ids);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple bills.' })
@ApiResponse({
status: 200,
description: 'Bills deleted successfully',
})
bulkDeleteBills(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.billsApplication.bulkDeleteBills(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
@Post()
@ApiOperation({ summary: 'Create a new bill.' })

View File

@@ -29,6 +29,8 @@ import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
import { BillsExportable } from './commands/BillsExportable';
import { BillsImportable } from './commands/BillsImportable';
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
import { BulkDeleteBillsService } from './BulkDeleteBills.service';
import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service';
@Module({
imports: [
@@ -63,8 +65,10 @@ import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
BillsExportable,
BillsImportable,
GetBillPaymentTransactionsService,
BulkDeleteBillsService,
ValidateBulkDeleteBillsService,
],
controllers: [BillsController],
exports: [BillsExportable, BillsImportable],
})
export class BillsModule {}
export class BillsModule { }

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteBill } from './commands/DeleteBill.service';
@Injectable()
export class BulkDeleteBillsService {
constructor(private readonly deleteBillService: DeleteBill) { }
async bulkDeleteBills(
billIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const billsIds = uniq(castArray(billIds));
const results = await PromisePool.withConcurrency(1)
.for(billsIds)
.process(async (billId: number) => {
try {
await this.deleteBillService.deleteBill(billId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteBill } from './commands/DeleteBill.service';
@Injectable()
export class ValidateBulkDeleteBillsService {
constructor(
private readonly deleteBillService: DeleteBill,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeleteBills(billIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const billId of billIds) {
try {
await this.deleteBillService.deleteBill(billId, trx);
deletableIds.push(billId);
} catch (error) {
nonDeletableIds.push(billId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -29,9 +29,10 @@ export class DeleteBill {
/**
* Deletes the bill with associated entries.
* @param {number} billId
* @param {Knex.Transaction} trx - Database transaction instance.
* @return {void}
*/
public async deleteBill(billId: number) {
public async deleteBill(billId: number, trx?: Knex.Transaction) {
// Retrieve the given bill or throw not found error.
const oldBill = await this.billModel()
.query()
@@ -75,6 +76,6 @@ export class DeleteBill {
oldBill,
trx,
} as IBIllEventDeletedPayload);
});
}, trx);
}
}

View File

@@ -17,7 +17,7 @@ export class BillBranchValidateSubscriber {
* Validate branch existance on bill creating.
* @param {IBillCreatingPayload} payload
*/
@OnEvent(events.bill.onCreating)
@OnEvent(events.bill.onCreating, { suppressErrors: false })
async validateBranchExistanceOnBillCreating({
billDTO,
}: IBillCreatingPayload) {
@@ -30,7 +30,7 @@ export class BillBranchValidateSubscriber {
* Validate branch existance once bill editing.
* @param {IBillEditingPayload} payload
*/
@OnEvent(events.bill.onEditing)
@OnEvent(events.bill.onEditing, { suppressErrors: false })
async validateBranchExistanceOnBillEditing({ billDTO }: IBillEditingPayload) {
await this.validateBranchExistance.validateTransactionBranchWhenActive(
billDTO.branchId,

View File

@@ -14,7 +14,7 @@ export class CashflowBranchDTOValidatorSubscriber {
* Validate branch existance once cashflow transaction creating.
* @param {ICommandCashflowCreatingPayload} payload
*/
@OnEvent(events.cashflow.onTransactionCreating)
@OnEvent(events.cashflow.onTransactionCreating, { suppressErrors: false })
async validateBranchExistanceOnCashflowTransactionCreating({
newTransactionDTO,
}: ICommandCashflowCreatingPayload) {

View File

@@ -15,13 +15,13 @@ import {
export class ContactBranchValidateSubscriber {
constructor(
private readonly validateBranchExistance: ValidateBranchExistance,
) { }
) {}
/**
* Validate branch existance on customer creating.
* @param {ICustomerEventCreatingPayload} payload
*/
@OnEvent(events.customers.onCreating)
@OnEvent(events.customers.onCreating, { suppressErrors: false })
async validateBranchExistanceOnCustomerCreating({
customerDTO,
}: ICustomerEventCreatingPayload) {
@@ -37,7 +37,7 @@ export class ContactBranchValidateSubscriber {
* Validate branch existance once customer opening balance editing.
* @param {ICustomerOpeningBalanceEditingPayload} payload
*/
@OnEvent(events.customers.onOpeningBalanceChanging)
@OnEvent(events.customers.onOpeningBalanceChanging, { suppressErrors: false })
async validateBranchExistanceOnCustomerOpeningBalanceEditing({
openingBalanceEditDTO,
}: ICustomerOpeningBalanceEditingPayload) {
@@ -52,7 +52,7 @@ export class ContactBranchValidateSubscriber {
* Validates the branch existance on vendor creating.
* @param {IVendorEventCreatingPayload} payload
*/
@OnEvent(events.vendors.onCreating)
@OnEvent(events.vendors.onCreating, { suppressErrors: false })
async validateBranchExistanceonVendorCreating({
vendorDTO,
}: IVendorEventCreatingPayload) {
@@ -68,7 +68,7 @@ export class ContactBranchValidateSubscriber {
* Validate branch existance once the vendor opening balance editing.
* @param {IVendorOpeningBalanceEditingPayload} payload
*/
@OnEvent(events.vendors.onOpeningBalanceChanging)
@OnEvent(events.vendors.onOpeningBalanceChanging, { suppressErrors: false })
async validateBranchExistanceOnVendorOpeningBalanceEditing({
openingBalanceEditDTO,
}: IVendorOpeningBalanceEditingPayload) {

View File

@@ -15,7 +15,7 @@ export class CreditNoteBranchValidateSubscriber {
* Validate branch existance on credit note creating.
* @param {ICreditNoteCreatingPayload} payload
*/
@OnEvent(events.creditNote.onCreating)
@OnEvent(events.creditNote.onCreating, { suppressErrors: false })
async validateBranchExistanceOnCreditCreating({
creditNoteDTO,
}: ICreditNoteCreatingPayload) {
@@ -28,7 +28,7 @@ export class CreditNoteBranchValidateSubscriber {
* Validate branch existance once credit note editing.
* @param {ICreditNoteEditingPayload} payload
*/
@OnEvent(events.creditNote.onEditing)
@OnEvent(events.creditNote.onEditing, { suppressErrors: false })
async validateBranchExistanceOnCreditEditing({
creditNoteEditDTO,
}: ICreditNoteEditingPayload) {

View File

@@ -14,7 +14,7 @@ export class CreditNoteRefundBranchValidateSubscriber {
* Validate branch existance on refund credit note creating.
* @param {IRefundCreditNoteCreatingPayload} payload
*/
@OnEvent(events.creditNote.onRefundCreating)
@OnEvent(events.creditNote.onRefundCreating, { suppressErrors: false })
async validateBranchExistanceOnCreditRefundCreating({
newCreditNoteDTO,
}: IRefundCreditNoteCreatingPayload) {

View File

@@ -16,7 +16,7 @@ export class ExpenseBranchValidateSubscriber {
* Validate branch existance once expense transaction creating.
* @param {IExpenseCreatingPayload} payload
*/
@OnEvent(events.expenses.onCreating)
@OnEvent(events.expenses.onCreating, { suppressErrors: false })
async validateBranchExistanceOnExpenseCreating({
expenseDTO,
}: IExpenseCreatingPayload) {
@@ -29,7 +29,7 @@ export class ExpenseBranchValidateSubscriber {
* Validate branch existance once expense transaction editing.
* @param {IExpenseEventEditingPayload} payload
*/
@OnEvent(events.expenses.onEditing)
@OnEvent(events.expenses.onEditing, { suppressErrors: false })
async validateBranchExistanceOnExpenseEditing({
expenseDTO,
}: IExpenseEventEditingPayload) {

View File

@@ -14,7 +14,7 @@ export class InventoryAdjustmentBranchValidateSubscriber {
* Validate branch existance on inventory adjustment creating.
* @param {IInventoryAdjustmentCreatingPayload} payload
*/
@OnEvent(events.inventoryAdjustment.onQuickCreating)
@OnEvent(events.inventoryAdjustment.onQuickCreating, { suppressErrors: false })
async validateBranchExistanceOnInventoryCreating({
quickAdjustmentDTO,
}: IInventoryAdjustmentCreatingPayload) {

View File

@@ -17,7 +17,7 @@ export class InvoiceBranchValidateSubscriber {
* Validate branch existance on invoice creating.
* @param {ISaleInvoiceCreatingPayload} payload
*/
@OnEvent(events.saleInvoice.onCreating)
@OnEvent(events.saleInvoice.onCreating, { suppressErrors: false })
async validateBranchExistanceOnInvoiceCreating({
saleInvoiceDTO,
}: ISaleInvoiceCreatingPaylaod) {
@@ -30,7 +30,7 @@ export class InvoiceBranchValidateSubscriber {
* Validate branch existance once invoice editing.
* @param {ISaleInvoiceEditingPayload} payload
*/
@OnEvent(events.saleInvoice.onEditing)
@OnEvent(events.saleInvoice.onEditing, { suppressErrors: false })
async validateBranchExistanceOnInvoiceEditing({
saleInvoiceDTO,
}: ISaleInvoiceEditingPayload) {

View File

@@ -17,7 +17,7 @@ export class PaymentMadeBranchValidateSubscriber {
* Validate branch existance on estimate creating.
* @param {ISaleEstimateCreatedPayload} payload
*/
@OnEvent(events.billPayment.onCreating)
@OnEvent(events.billPayment.onCreating, { suppressErrors: false })
async validateBranchExistanceOnPaymentCreating({
billPaymentDTO,
}: IBillPaymentCreatingPayload) {
@@ -30,7 +30,7 @@ export class PaymentMadeBranchValidateSubscriber {
* Validate branch existance once estimate editing.
* @param {ISaleEstimateEditingPayload} payload
*/
@OnEvent(events.billPayment.onEditing)
@OnEvent(events.billPayment.onEditing, { suppressErrors: false })
async validateBranchExistanceOnPaymentEditing({
billPaymentDTO,
}: IBillPaymentEditingPayload) {

View File

@@ -17,7 +17,7 @@ export class PaymentReceiveBranchValidateSubscriber {
* Validate branch existance on estimate creating.
* @param {IPaymentReceivedCreatingPayload} payload
*/
@OnEvent(events.paymentReceive.onCreating)
@OnEvent(events.paymentReceive.onCreating, { suppressErrors: false })
async validateBranchExistanceOnPaymentCreating({
paymentReceiveDTO,
}: IPaymentReceivedCreatingPayload) {
@@ -30,7 +30,7 @@ export class PaymentReceiveBranchValidateSubscriber {
* Validate branch existance once estimate editing.
* @param {IPaymentReceivedEditingPayload} payload
*/
@OnEvent(events.paymentReceive.onEditing)
@OnEvent(events.paymentReceive.onEditing, { suppressErrors: false })
async validateBranchExistanceOnPaymentEditing({
paymentReceiveDTO,
}: IPaymentReceivedEditingPayload) {

View File

@@ -17,7 +17,7 @@ export class SaleEstimateBranchValidateSubscriber {
* Validate branch existance on estimate creating.
* @param {ISaleEstimateCreatedPayload} payload
*/
@OnEvent(events.saleEstimate.onCreating)
@OnEvent(events.saleEstimate.onCreating, { suppressErrors: false })
async validateBranchExistanceOnEstimateCreating({
estimateDTO,
}: ISaleEstimateCreatingPayload) {
@@ -30,7 +30,7 @@ export class SaleEstimateBranchValidateSubscriber {
* Validate branch existance once estimate editing.
* @param {ISaleEstimateEditingPayload} payload
*/
@OnEvent(events.saleEstimate.onEditing)
@OnEvent(events.saleEstimate.onEditing, { suppressErrors: false })
async validateBranchExistanceOnEstimateEditing({
estimateDTO,
}: ISaleEstimateEditingPayload) {

View File

@@ -17,7 +17,7 @@ export class SaleReceiptBranchValidateSubscriber {
* Validate branch existance on estimate creating.
* @param {ISaleReceiptCreatingPayload} payload
*/
@OnEvent(events.saleReceipt.onCreating)
@OnEvent(events.saleReceipt.onCreating, { suppressErrors: false })
async validateBranchExistanceOnInvoiceCreating({
saleReceiptDTO,
}: ISaleReceiptCreatingPayload) {
@@ -30,7 +30,7 @@ export class SaleReceiptBranchValidateSubscriber {
* Validate branch existance once estimate editing.
* @param {ISaleReceiptEditingPayload} payload
*/
@OnEvent(events.saleReceipt.onEditing)
@OnEvent(events.saleReceipt.onEditing, { suppressErrors: false })
async validateBranchExistanceOnInvoiceEditing({
saleReceiptDTO,
}: ISaleReceiptEditingPayload) {

View File

@@ -17,7 +17,7 @@ export class VendorCreditBranchValidateSubscriber {
* Validate branch existance on estimate creating.
* @param {ISaleEstimateCreatedPayload} payload
*/
@OnEvent(events.vendorCredit.onCreating)
@OnEvent(events.vendorCredit.onCreating, { suppressErrors: false })
async validateBranchExistanceOnCreditCreating({
vendorCreditCreateDTO,
}: IVendorCreditCreatingPayload) {
@@ -30,7 +30,7 @@ export class VendorCreditBranchValidateSubscriber {
* Validate branch existance once estimate editing.
* @param {ISaleEstimateEditingPayload} payload
*/
@OnEvent(events.vendorCredit.onEditing)
@OnEvent(events.vendorCredit.onEditing, { suppressErrors: false })
async validateBranchExistanceOnCreditEditing({
vendorCreditDTO,
}: IVendorCreditEditingPayload) {

View File

@@ -14,7 +14,7 @@ export class VendorCreditRefundBranchValidateSubscriber {
* Validate branch existance on refund credit note creating.
* @param {IRefundVendorCreditCreatingPayload} payload
*/
@OnEvent(events.vendorCredit.onRefundCreating)
@OnEvent(events.vendorCredit.onRefundCreating, { suppressErrors: false })
async validateBranchExistanceOnCreditRefundCreating({
refundVendorCreditDTO,
}: IRefundVendorCreditCreatingPayload) {

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteCreditNoteService } from './commands/DeleteCreditNote.service';
@Injectable()
export class BulkDeleteCreditNotesService {
constructor(
private readonly deleteCreditNoteService: DeleteCreditNoteService,
) { }
async bulkDeleteCreditNotes(
creditNoteIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const notesIds = uniq(castArray(creditNoteIds));
const results = await PromisePool.withConcurrency(1)
.for(notesIds)
.process(async (creditNoteId: number) => {
try {
await this.deleteCreditNoteService.deleteCreditNote(creditNoteId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -9,6 +9,8 @@ import { GetCreditNotesService } from './queries/GetCreditNotes.service';
import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto';
import { GetCreditNoteState } from './queries/GetCreditNoteState.service';
import { GetCreditNoteService } from './queries/GetCreditNote.service';
import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service';
import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service';
@Injectable()
export class CreditNoteApplication {
@@ -20,8 +22,10 @@ export class CreditNoteApplication {
private readonly getCreditNotePdfService: GetCreditNotePdf,
private readonly getCreditNotesService: GetCreditNotesService,
private readonly getCreditNoteStateService: GetCreditNoteState,
private readonly getCreditNoteService: GetCreditNoteService
) {}
private readonly getCreditNoteService: GetCreditNoteService,
private readonly bulkDeleteCreditNotesService: BulkDeleteCreditNotesService,
private readonly validateBulkDeleteCreditNotesService: ValidateBulkDeleteCreditNotesService,
) { }
/**
* Creates a new credit note.
@@ -97,4 +101,30 @@ export class CreditNoteApplication {
getCreditNote(creditNoteId: number) {
return this.getCreditNoteService.getCreditNote(creditNoteId);
}
/**
* Deletes multiple credit notes.
* @param {number[]} creditNoteIds
* @returns {Promise<void>}
*/
bulkDeleteCreditNotes(
creditNoteIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteCreditNotesService.bulkDeleteCreditNotes(
creditNoteIds,
options,
);
}
/**
* Validates which credit notes can be deleted.
* @param {number[]} creditNoteIds
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
*/
validateBulkDeleteCreditNotes(creditNoteIds: number[]) {
return this.validateBulkDeleteCreditNotesService.validateBulkDeleteCreditNotes(
creditNoteIds,
);
}
}

View File

@@ -1,3 +1,4 @@
import { Response } from 'express';
import {
ApiTags,
ApiOperation,
@@ -11,10 +12,12 @@ import {
Controller,
Delete,
Get,
Headers,
Param,
Post,
Put,
Query,
Res,
} from '@nestjs/common';
import { CreditNoteApplication } from './CreditNoteApplication.service';
import { ICreditNotesQueryDTO } from './types/CreditNotes.types';
@@ -22,17 +25,23 @@ import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto';
import { CreditNoteResponseDto } from './dtos/CreditNoteResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
import { AcceptType } from '@/constants/accept-type';
@Controller('credit-notes')
@ApiTags('Credit Notes')
@ApiExtraModels(CreditNoteResponseDto)
@ApiExtraModels(PaginatedResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders()
export class CreditNotesController {
/**
* @param {CreditNoteApplication} creditNoteApplication - The credit note application service.
*/
constructor(private creditNoteApplication: CreditNoteApplication) {}
constructor(private creditNoteApplication: CreditNoteApplication) { }
@Post()
@ApiOperation({ summary: 'Create a new credit note' })
@@ -60,8 +69,26 @@ export class CreditNotesController {
},
})
@ApiResponse({ status: 404, description: 'Credit note not found' })
getCreditNote(@Param('id') creditNoteId: number) {
return this.creditNoteApplication.getCreditNote(creditNoteId);
async getCreditNote(
@Param('id') creditNoteId: number,
@Headers('accept') acceptHeader: string,
@Res({ passthrough: true }) res: Response,
) {
if (acceptHeader.includes(AcceptType.ApplicationPdf)) {
const [pdfContent, filename] =
await this.creditNoteApplication.getCreditNotePdf(creditNoteId);
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdfContent.length,
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.status(200).send(pdfContent);
} else {
const creditNote =
await this.creditNoteApplication.getCreditNote(creditNoteId);
return creditNote;
}
}
@Get()
@@ -112,6 +139,39 @@ export class CreditNotesController {
return this.creditNoteApplication.openCreditNote(creditNoteId);
}
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which credit notes can be deleted and returns the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable credit notes.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
validateBulkDeleteCreditNotes(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.creditNoteApplication.validateBulkDeleteCreditNotes(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple credit notes.' })
@ApiResponse({
status: 200,
description: 'Credit notes deleted successfully',
})
bulkDeleteCreditNotes(@Body() bulkDeleteDto: BulkDeleteDto): Promise<void> {
return this.creditNoteApplication.bulkDeleteCreditNotes(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a credit note' })
@ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' })

View File

@@ -29,10 +29,13 @@ import { CreditNotesImportable } from './commands/CreditNotesImportable';
import { CreditNoteInventoryTransactionsSubscriber } from './subscribers/CreditNoteInventoryTransactionsSubscriber';
import { RefundSyncCreditNoteBalanceSubscriber } from './subscribers/RefundSyncCreditNoteBalanceSubscriber';
import { DeleteCustomerLinkedCreditSubscriber } from './subscribers/DeleteCustomerLinkedCreditSubscriber';
import { CreditNoteAutoSerialSubscriber } from './subscribers/CreditNoteAutoSerialSubscriber';
import { CreditNoteInventoryTransactions } from './commands/CreditNotesInventoryTransactions';
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.module';
import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module';
import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service';
import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service';
@Module({
imports: [
@@ -71,6 +74,9 @@ import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/Credit
CreditNoteInventoryTransactionsSubscriber,
RefundSyncCreditNoteBalanceSubscriber,
DeleteCustomerLinkedCreditSubscriber,
CreditNoteAutoSerialSubscriber,
BulkDeleteCreditNotesService,
ValidateBulkDeleteCreditNotesService,
],
exports: [
CreateCreditNoteService,

View File

@@ -0,0 +1,54 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteCreditNoteService } from './commands/DeleteCreditNote.service';
@Injectable()
export class ValidateBulkDeleteCreditNotesService {
constructor(
private readonly deleteCreditNoteService: DeleteCreditNoteService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeleteCreditNotes(creditNoteIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const creditNoteId of creditNoteIds) {
try {
await this.deleteCreditNoteService.deleteCreditNote(
creditNoteId,
trx,
);
deletableIds.push(creditNoteId);
} catch (error) {
nonDeletableIds.push(creditNoteId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -49,9 +49,13 @@ export class DeleteCreditNoteService {
/**
* Deletes the given credit note transactions.
* @param {number} creditNoteId
* @param {Knex.Transaction} trx - Database transaction instance.
* @returns {Promise<void>}
*/
public async deleteCreditNote(creditNoteId: number): Promise<void> {
public async deleteCreditNote(
creditNoteId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve the credit note or throw not found service error.
const oldCreditNote = await this.creditNoteModel()
.query()
@@ -88,7 +92,7 @@ export class DeleteCreditNoteService {
creditNoteId,
trx,
} as ICreditNoteDeletedPayload);
});
}, trx);
}
/**

View File

@@ -5,17 +5,17 @@ import { ICreditNoteCreatedPayload } from '../types/CreditNotes.types';
import { events } from '@/common/events/events';
@Injectable()
export default class CreditNoteAutoSerialSubscriber {
export class CreditNoteAutoSerialSubscriber {
constructor(
private readonly creditNoteIncrementService: CreditNoteAutoIncrementService,
) {}
) { }
/**
* Auto serial increment once credit note created.
* @param {ICreditNoteCreatedPayload} payload -
*/
@OnEvent(events.creditNote.onCreated)
async autoSerialIncrementOnceCreated({}: ICreditNoteCreatedPayload) {
async autoSerialIncrementOnceCreated({ }: ICreditNoteCreatedPayload) {
await this.creditNoteIncrementService.incrementSerialNumber();
}
}

View File

@@ -10,8 +10,8 @@ export const InitialCurrencies = [
];
export const ERRORS = {
CURRENCY_NOT_FOUND: 'currency_not_found',
CURRENCY_CODE_EXISTS: 'currency_code_exists',
CURRENCY_NOT_FOUND: 'CURRENCY_NOT_FOUND',
CURRENCY_CODE_EXISTS: 'CURRENCY_CODE_EXISTS',
BASE_CURRENCY_INVALID: 'BASE_CURRENCY_INVALID',
CANNOT_DELETE_BASE_CURRENCY: 'CANNOT_DELETE_BASE_CURRENCY',
};

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { castArray, uniq } from 'lodash';
import { PromisePool } from '@supercharge/promise-pool';
import { DeleteCustomer } from './commands/DeleteCustomer.service';
@Injectable()
export class BulkDeleteCustomersService {
constructor(private readonly deleteCustomerService: DeleteCustomer) {}
public async bulkDeleteCustomers(
customerIds: number[],
options?: { skipUndeletable?: boolean },
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const ids = uniq(castArray(customerIds));
const results = await PromisePool.withConcurrency(1)
.for(ids)
.process(async (customerId: number) => {
try {
await this.deleteCustomerService.deleteCustomer(customerId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw ?? results.errors[0];
}
}
}

View File

@@ -1,117 +1,103 @@
// import { Service, Inject } from 'typedi';
// import { AccountNormal, ICustomer, ILedgerEntry } from '@/interfaces';
// import Ledger from '@/services/Accounting/Ledger';
import { Injectable } from '@nestjs/common';
import { AccountNormal } from '@/interfaces/Account';
import { ILedgerEntry } from '@/modules/Ledger/types/Ledger.types';
import { Ledger } from '@/modules/Ledger/Ledger';
import { Customer } from './models/Customer';
// @Service()
// export class CustomerGLEntries {
// /**
// * Retrieves the customer opening balance common entry attributes.
// * @param {ICustomer} customer
// */
// private getCustomerOpeningGLCommonEntry = (customer: ICustomer) => {
// return {
// exchangeRate: customer.openingBalanceExchangeRate,
// currencyCode: customer.currencyCode,
@Injectable()
export class CustomerGLEntries {
/**
* Retrieves the customer opening balance common entry attributes.
*/
private getCustomerOpeningGLCommonEntry = (customer: Customer) => {
return {
exchangeRate: customer.openingBalanceExchangeRate,
currencyCode: customer.currencyCode,
// transactionType: 'CustomerOpeningBalance',
// transactionId: customer.id,
transactionType: 'CustomerOpeningBalance',
transactionId: customer.id,
// date: customer.openingBalanceAt,
// userId: customer.userId,
// contactId: customer.id,
date: customer.openingBalanceAt,
contactId: customer.id,
// credit: 0,
// debit: 0,
credit: 0,
debit: 0,
// branchId: customer.openingBalanceBranchId,
// };
// };
branchId: customer.openingBalanceBranchId,
};
};
// /**
// * Retrieves the customer opening GL credit entry.
// * @param {number} ARAccountId
// * @param {ICustomer} customer
// * @returns {ILedgerEntry}
// */
// private getCustomerOpeningGLCreditEntry = (
// ARAccountId: number,
// customer: ICustomer
// ): ILedgerEntry => {
// const commonEntry = this.getCustomerOpeningGLCommonEntry(customer);
/**
* Retrieves the customer opening GL credit entry.
*/
private getCustomerOpeningGLCreditEntry = (
ARAccountId: number,
customer: Customer
): ILedgerEntry => {
const commonEntry = this.getCustomerOpeningGLCommonEntry(customer);
// return {
// ...commonEntry,
// credit: 0,
// debit: customer.localOpeningBalance,
// accountId: ARAccountId,
// accountNormal: AccountNormal.DEBIT,
// index: 1,
// };
// };
return {
...commonEntry,
credit: 0,
debit: customer.localOpeningBalance,
accountId: ARAccountId,
accountNormal: AccountNormal.DEBIT,
index: 1,
};
};
// /**
// * Retrieves the customer opening GL debit entry.
// * @param {number} incomeAccountId
// * @param {ICustomer} customer
// * @returns {ILedgerEntry}
// */
// private getCustomerOpeningGLDebitEntry = (
// incomeAccountId: number,
// customer: ICustomer
// ): ILedgerEntry => {
// const commonEntry = this.getCustomerOpeningGLCommonEntry(customer);
/**
* Retrieves the customer opening GL debit entry.
*/
private getCustomerOpeningGLDebitEntry = (
incomeAccountId: number,
customer: Customer
): ILedgerEntry => {
const commonEntry = this.getCustomerOpeningGLCommonEntry(customer);
// return {
// ...commonEntry,
// credit: customer.localOpeningBalance,
// debit: 0,
// accountId: incomeAccountId,
// accountNormal: AccountNormal.CREDIT,
return {
...commonEntry,
credit: customer.localOpeningBalance,
debit: 0,
accountId: incomeAccountId,
accountNormal: AccountNormal.CREDIT,
// index: 2,
// };
// };
index: 2,
};
};
// /**
// * Retrieves the customer opening GL entries.
// * @param {number} ARAccountId
// * @param {number} incomeAccountId
// * @param {ICustomer} customer
// * @returns {ILedgerEntry[]}
// */
// public getCustomerOpeningGLEntries = (
// ARAccountId: number,
// incomeAccountId: number,
// customer: ICustomer
// ) => {
// const debitEntry = this.getCustomerOpeningGLDebitEntry(
// incomeAccountId,
// customer
// );
// const creditEntry = this.getCustomerOpeningGLCreditEntry(
// ARAccountId,
// customer
// );
// return [debitEntry, creditEntry];
// };
/**
* Retrieves the customer opening GL entries.
*/
public getCustomerOpeningGLEntries = (
ARAccountId: number,
incomeAccountId: number,
customer: Customer
) => {
const debitEntry = this.getCustomerOpeningGLDebitEntry(
incomeAccountId,
customer
);
const creditEntry = this.getCustomerOpeningGLCreditEntry(
ARAccountId,
customer
);
return [debitEntry, creditEntry];
};
// /**
// * Retrieves the customer opening balance ledger.
// * @param {number} ARAccountId
// * @param {number} incomeAccountId
// * @param {ICustomer} customer
// * @returns {ILedger}
// */
// public getCustomerOpeningLedger = (
// ARAccountId: number,
// incomeAccountId: number,
// customer: ICustomer
// ) => {
// const entries = this.getCustomerOpeningGLEntries(
// ARAccountId,
// incomeAccountId,
// customer
// );
// return new Ledger(entries);
// };
// }
/**
* Retrieves the customer opening balance ledger.
*/
public getCustomerOpeningLedger = (
ARAccountId: number,
incomeAccountId: number,
customer: Customer
) => {
const entries = this.getCustomerOpeningGLEntries(
ARAccountId,
incomeAccountId,
customer
);
return new Ledger(entries);
};
}

View File

@@ -1,90 +1,84 @@
// import { Knex } from 'knex';
// import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
// import HasTenancyService from '@/services/Tenancy/TenancyService';
// import { Service, Inject } from 'typedi';
// import { CustomerGLEntries } from './CustomerGLEntries';
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { LedgerStorageService } from '@/modules/Ledger/LedgerStorage.service';
import { AccountRepository } from '@/modules/Accounts/repositories/Account.repository';
import { CustomerGLEntries } from './CustomerGLEntries';
import { Customer } from './models/Customer';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Account } from '../Accounts/models/Account.model';
// @Service()
// export class CustomerGLEntriesStorage {
// @Inject()
// private tenancy: HasTenancyService;
@Injectable()
export class CustomerGLEntriesStorage {
constructor(
private readonly ledgerStorage: LedgerStorageService,
private readonly accountRepository: AccountRepository,
private readonly customerGLEntries: CustomerGLEntries,
// @Inject()
// private ledegrRepository: LedgerStorageService;
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
// @Inject()
// private customerGLEntries: CustomerGLEntries;
@Inject(Customer.name)
private readonly customerModel: TenantModelProxy<typeof Customer>,
) { }
// /**
// * Customer opening balance journals.
// * @param {number} tenantId
// * @param {number} customerId
// * @param {Knex.Transaction} trx
// */
// public writeCustomerOpeningBalance = async (
// tenantId: number,
// customerId: number,
// trx?: Knex.Transaction
// ) => {
// const { Customer } = this.tenancy.models(tenantId);
// const { accountRepository } = this.tenancy.repositories(tenantId);
/**
* Customer opening balance journals.
*/
public writeCustomerOpeningBalance = async (
customerId: number,
trx?: Knex.Transaction,
) => {
const customer = await this.customerModel()
.query(trx)
.findById(customerId);
// const customer = await Customer.query(trx).findById(customerId);
// Finds the income account.
const incomeAccount = await this.accountModel()
.query(trx)
.findOne({ slug: 'other-income' });
// // Finds the income account.
// const incomeAccount = await accountRepository.findOne({
// slug: 'other-income',
// });
// // Find or create the A/R account.
// const ARAccount = await accountRepository.findOrCreateAccountReceivable(
// customer.currencyCode,
// {},
// trx
// );
// // Retrieves the customer opening balance ledger.
// const ledger = this.customerGLEntries.getCustomerOpeningLedger(
// ARAccount.id,
// incomeAccount.id,
// customer
// );
// // Commits the ledger entries to the storage.
// await this.ledegrRepository.commit(tenantId, ledger, trx);
// };
// Find or create the A/R account.
const ARAccount =
await this.accountRepository.findOrCreateAccountReceivable(
customer.currencyCode,
{},
trx,
);
// Retrieves the customer opening balance ledger.
const ledger = this.customerGLEntries.getCustomerOpeningLedger(
ARAccount.id,
incomeAccount.id,
customer,
);
// Commits the ledger entries to the storage.
await this.ledgerStorage.commit(ledger, trx);
};
// /**
// * Reverts the customer opening balance GL entries.
// * @param {number} tenantId
// * @param {number} customerId
// * @param {Knex.Transaction} trx
// */
// public revertCustomerOpeningBalance = async (
// tenantId: number,
// customerId: number,
// trx?: Knex.Transaction
// ) => {
// await this.ledegrRepository.deleteByReference(
// tenantId,
// customerId,
// 'CustomerOpeningBalance',
// trx
// );
// };
/**
* Reverts the customer opening balance GL entries.
*/
public revertCustomerOpeningBalance = async (
customerId: number,
trx?: Knex.Transaction,
) => {
await this.ledgerStorage.deleteByReference(
customerId,
'CustomerOpeningBalance',
trx,
);
};
// /**
// * Writes the customer opening balance GL entries.
// * @param {number} tenantId
// * @param {number} customerId
// * @param {Knex.Transaction} trx
// */
// public rewriteCustomerOpeningBalance = async (
// tenantId: number,
// customerId: number,
// trx?: Knex.Transaction
// ) => {
// // Reverts the customer opening balance entries.
// await this.revertCustomerOpeningBalance(tenantId, customerId, trx);
/**
* Writes the customer opening balance GL entries.
*/
public rewriteCustomerOpeningBalance = async (
customerId: number,
trx?: Knex.Transaction,
) => {
// Reverts the customer opening balance entries.
await this.revertCustomerOpeningBalance(customerId, trx);
// // Write the customer opening balance entries.
// await this.writeCustomerOpeningBalance(tenantId, customerId, trx);
// };
// }
// Write the customer opening balance entries.
await this.writeCustomerOpeningBalance(customerId, trx);
};
}

View File

@@ -9,10 +9,7 @@ import {
Query,
} from '@nestjs/common';
import { CustomersApplication } from './CustomersApplication.service';
import {
ICustomerOpeningBalanceEditDTO,
ICustomersFilter,
} from './types/Customers.types';
import { CustomerOpeningBalanceEditDto } from './dtos/CustomerOpeningBalanceEdit.dto';
import {
ApiOperation,
ApiResponse,
@@ -24,6 +21,10 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
import { EditCustomerDto } from './dtos/EditCustomer.dto';
import { CustomerResponseDto } from './dtos/CustomerResponse.dto';
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
import {
BulkDeleteCustomersDto,
ValidateBulkDeleteCustomersResponseDto,
} from './dtos/BulkDeleteCustomers.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('customers')
@@ -31,7 +32,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@ApiExtraModels(CustomerResponseDto)
@ApiCommonHeaders()
export class CustomersController {
constructor(private customersApplication: CustomersApplication) {}
constructor(private customersApplication: CustomersApplication) { }
@Get(':id')
@ApiOperation({ summary: 'Retrieves the customer details.' })
@@ -102,11 +103,44 @@ export class CustomersController {
})
editOpeningBalance(
@Param('id') customerId: number,
@Body() openingBalanceDTO: ICustomerOpeningBalanceEditDTO,
@Body() openingBalanceDTO: CustomerOpeningBalanceEditDto,
) {
return this.customersApplication.editOpeningBalance(
customerId,
openingBalanceDTO,
);
}
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which customers can be deleted and returns counts of deletable and non-deletable customers.',
})
@ApiResponse({
status: 200,
description:
'Validation completed. Returns counts and IDs of deletable and non-deletable customers.',
schema: { $ref: getSchemaPath(ValidateBulkDeleteCustomersResponseDto) },
})
validateBulkDeleteCustomers(
@Body() bulkDeleteDto: BulkDeleteCustomersDto,
): Promise<ValidateBulkDeleteCustomersResponseDto> {
return this.customersApplication.validateBulkDeleteCustomers(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple customers in bulk.' })
@ApiResponse({
status: 200,
description: 'The customers have been successfully deleted.',
})
async bulkDeleteCustomers(
@Body() bulkDeleteDto: BulkDeleteCustomersDto,
): Promise<void> {
return this.customersApplication.bulkDeleteCustomers(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
}

View File

@@ -16,9 +16,21 @@ import { CustomersExportable } from './CustomersExportable';
import { CustomersImportable } from './CustomersImportable';
import { GetCustomers } from './queries/GetCustomers.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
import { LedgerModule } from '../Ledger/Ledger.module';
import { AccountsModule } from '../Accounts/Accounts.module';
import { CustomerGLEntries } from './CustomerGLEntries';
import { CustomerGLEntriesStorage } from './CustomerGLEntriesStorage';
import { CustomerWriteGLOpeningBalanceSubscriber } from './subscribers/CustomerGLEntriesSubscriber';
@Module({
imports: [TenancyDatabaseModule, DynamicListModule],
imports: [
TenancyDatabaseModule,
DynamicListModule,
LedgerModule,
AccountsModule,
],
controllers: [CustomersController],
providers: [
ActivateCustomer,
@@ -37,6 +49,11 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
CustomersExportable,
CustomersImportable,
GetCustomers,
BulkDeleteCustomersService,
ValidateBulkDeleteCustomersService,
CustomerGLEntries,
CustomerGLEntriesStorage,
CustomerWriteGLOpeningBalanceSubscriber,
],
})
export class CustomersModule {}

View File

@@ -4,24 +4,25 @@ import { CreateCustomer } from './commands/CreateCustomer.service';
import { EditCustomer } from './commands/EditCustomer.service';
import { DeleteCustomer } from './commands/DeleteCustomer.service';
import { EditOpeningBalanceCustomer } from './commands/EditOpeningBalanceCustomer.service';
import {
ICustomerOpeningBalanceEditDTO,
ICustomersFilter,
} from './types/Customers.types';
import { CustomerOpeningBalanceEditDto } from './dtos/CustomerOpeningBalanceEdit.dto';
import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
import { EditCustomerDto } from './dtos/EditCustomer.dto';
import { GetCustomers } from './queries/GetCustomers.service';
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
@Injectable()
export class CustomersApplication {
constructor(
private getCustomerService: GetCustomerService,
private createCustomerService: CreateCustomer,
private editCustomerService: EditCustomer,
private deleteCustomerService: DeleteCustomer,
private editOpeningBalanceService: EditOpeningBalanceCustomer,
private getCustomersService: GetCustomers,
private readonly getCustomerService: GetCustomerService,
private readonly createCustomerService: CreateCustomer,
private readonly editCustomerService: EditCustomer,
private readonly deleteCustomerService: DeleteCustomer,
private readonly editOpeningBalanceService: EditOpeningBalanceCustomer,
private readonly getCustomersService: GetCustomers,
private readonly bulkDeleteCustomersService: BulkDeleteCustomersService,
private readonly validateBulkDeleteCustomersService: ValidateBulkDeleteCustomersService,
) {}
/**
@@ -68,7 +69,7 @@ export class CustomersApplication {
*/
public editOpeningBalance = (
customerId: number,
openingBalanceEditDTO: ICustomerOpeningBalanceEditDTO,
openingBalanceEditDTO: CustomerOpeningBalanceEditDto,
) => {
return this.editOpeningBalanceService.changeOpeningBalance(
customerId,
@@ -78,9 +79,25 @@ export class CustomersApplication {
/**
* Retrieve customers paginated list.
* @param {ICustomersFilter} filter - Cusotmers filter.
* @param {GetCustomersQueryDto} filter - Cusotmers filter.
*/
public getCustomers = (filterDTO: GetCustomersQueryDto) => {
return this.getCustomersService.getCustomersList(filterDTO);
};
public bulkDeleteCustomers = (
customerIds: number[],
options?: { skipUndeletable?: boolean },
) => {
return this.bulkDeleteCustomersService.bulkDeleteCustomers(
customerIds,
options,
);
};
public validateBulkDeleteCustomers = (customerIds: number[]) => {
return this.validateBulkDeleteCustomersService.validateBulkDeleteCustomers(
customerIds,
);
};
}

View File

@@ -0,0 +1,61 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteCustomer } from './commands/DeleteCustomer.service';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
import { ServiceError } from '@/modules/Items/ServiceError';
@Injectable()
export class ValidateBulkDeleteCustomersService {
constructor(
private readonly deleteCustomerService: DeleteCustomer,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
public async validateBulkDeleteCustomers(customerIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const customerId of customerIds) {
try {
await this.deleteCustomerService.deleteCustomer(customerId, trx);
deletableIds.push(customerId);
} catch (error) {
if (
error instanceof ModelHasRelationsError ||
(error instanceof ServiceError &&
error.errorType === 'CUSTOMER_HAS_TRANSACTIONS')
) {
nonDeletableIds.push(customerId);
} else {
nonDeletableIds.push(customerId);
}
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -31,7 +31,7 @@ export class CreateCustomer {
/**
* Creates a new customer.
* @param {ICustomerNewDTO} customerDTO
* @param {CreateCustomerDto} customerDTO
* @return {Promise<ICustomer>}
*/
public async createCustomer(

View File

@@ -31,12 +31,13 @@ export class DeleteCustomer {
* @param {number} customerId - Customer ID.
* @return {Promise<void>}
*/
public async deleteCustomer(customerId: number): Promise<void> {
public async deleteCustomer(
customerId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve the customer or throw not found service error.
const oldCustomer = await this.customerModel()
.query()
.findById(customerId)
.throwIfNotFound();
const query = this.customerModel().query(trx);
const oldCustomer = await query.findById(customerId).throwIfNotFound();
// Triggers `onCustomerDeleting` event.
await this.eventPublisher.emitAsync(events.customers.onDeleting, {
@@ -45,10 +46,10 @@ export class DeleteCustomer {
} as ICustomerDeletingPayload);
// Deletes the customer and associated entities under UOW transaction.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
return this.uow.withTransaction(async (transaction: Knex.Transaction) => {
// Delete the customer from the storage.
await this.customerModel()
.query(trx)
.query(transaction)
.findById(customerId)
.deleteIfNoRelations({
type: ERRORS.CUSTOMER_HAS_TRANSACTIONS,
@@ -58,8 +59,8 @@ export class DeleteCustomer {
await this.eventPublisher.emitAsync(events.customers.onDeleted, {
customerId,
oldCustomer,
trx,
trx: transaction,
} as ICustomerEventDeletedPayload);
});
}, trx);
}
}

View File

@@ -1,10 +1,10 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import {
ICustomerOpeningBalanceEditDTO,
ICustomerOpeningBalanceEditedPayload,
ICustomerOpeningBalanceEditingPayload,
} from '../types/Customers.types';
import { CustomerOpeningBalanceEditDto } from '../dtos/CustomerOpeningBalanceEdit.dto';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { Customer } from '../models/Customer';
@@ -29,11 +29,11 @@ export class EditOpeningBalanceCustomer {
/**
* Changes the opening balance of the given customer.
* @param {number} customerId - Customer ID.
* @param {ICustomerOpeningBalanceEditDTO} openingBalanceEditDTO
* @param {CustomerOpeningBalanceEditDto} openingBalanceEditDTO
*/
public async changeOpeningBalance(
customerId: number,
openingBalanceEditDTO: ICustomerOpeningBalanceEditDTO,
openingBalanceEditDTO: CustomerOpeningBalanceEditDto,
): Promise<Customer> {
// Retrieves the old customer or throw not found error.
const oldCustomer = await this.customerModel()

View File

@@ -0,0 +1,62 @@
import {
ArrayNotEmpty,
IsArray,
IsInt,
IsOptional,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
export class BulkDeleteCustomersDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({
description: 'Array of customer IDs to delete',
type: [Number],
example: [1, 2, 3],
})
ids: number[];
@IsOptional()
@IsBoolean()
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
@ApiPropertyOptional({
description:
'When true, undeletable customers will be skipped and only deletable ones removed.',
type: Boolean,
default: false,
})
skipUndeletable?: boolean;
}
export class ValidateBulkDeleteCustomersResponseDto {
@ApiProperty({
description: 'Number of customers that can be deleted',
example: 2,
})
deletableCount: number;
@ApiProperty({
description: 'Number of customers that cannot be deleted',
example: 1,
})
nonDeletableCount: number;
@ApiProperty({
description: 'IDs of customers that can be deleted',
type: [Number],
example: [1, 2],
})
deletableIds: number[];
@ApiProperty({
description: 'IDs of customers that cannot be deleted',
type: [Number],
example: [3],
})
nonDeletableIds: number[];
}

View File

@@ -4,6 +4,7 @@ import {
IsNotEmpty,
IsNumber,
IsString,
ValidateIf,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, ToNumber } from '@/common/decorators/Validators';
@@ -40,10 +41,11 @@ export class CreateCustomerDto extends ContactAddressDto {
@ApiProperty({
required: false,
description: 'Opening balance date',
description: 'Opening balance date (required when openingBalance is provided)',
example: '2024-01-01',
})
@IsOptional()
@ValidateIf((o) => o.openingBalance != null)
@IsNotEmpty({ message: 'openingBalanceAt is required when openingBalance is provided' })
@IsString()
openingBalanceAt?: string;

View File

@@ -0,0 +1,44 @@
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, ToNumber } from '@/common/decorators/Validators';
export class CustomerOpeningBalanceEditDto {
@ApiProperty({
required: true,
description: 'Opening balance',
example: 5000.0,
})
@IsNumber()
@IsNotEmpty()
@ToNumber()
openingBalance: number;
@ApiProperty({
required: false,
description: 'Opening balance date',
example: '2024-01-01',
})
@IsOptional()
@IsString()
openingBalanceAt?: string;
@ApiProperty({
required: false,
description: 'Opening balance exchange rate',
example: 1.0,
})
@IsOptional()
@IsNumber()
@ToNumber()
openingBalanceExchangeRate?: number;
@ApiProperty({
required: false,
description: 'Opening balance branch ID',
example: 101,
})
@IsOptional()
@IsNumber()
@ToNumber()
openingBalanceBranchId?: number;
}

View File

@@ -1,91 +1,63 @@
// import { Service, Inject } from 'typedi';
// import {
// ICustomerEventCreatedPayload,
// ICustomerEventDeletedPayload,
// ICustomerOpeningBalanceEditedPayload,
// } from '@/interfaces';
// import events from '@/subscribers/events';
// import { CustomerGLEntriesStorage } from '../CustomerGLEntriesStorage';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
ICustomerEventCreatedPayload,
ICustomerEventDeletedPayload,
ICustomerOpeningBalanceEditedPayload,
} from '../types/Customers.types';
import { events } from '@/common/events/events';
import { CustomerGLEntriesStorage } from '../CustomerGLEntriesStorage';
// @Service()
// export class CustomerWriteGLOpeningBalanceSubscriber {
// @Inject()
// private customerGLEntries: CustomerGLEntriesStorage;
@Injectable()
export class CustomerWriteGLOpeningBalanceSubscriber {
constructor(private readonly customerGLEntries: CustomerGLEntriesStorage) { }
// /**
// * Attaches events with handlers.
// */
// public attach(bus) {
// bus.subscribe(
// events.customers.onCreated,
// this.handleWriteOpenBalanceEntries
// );
// bus.subscribe(
// events.customers.onDeleted,
// this.handleRevertOpeningBalanceEntries
// );
// bus.subscribe(
// events.customers.onOpeningBalanceChanged,
// this.handleRewriteOpeningEntriesOnChanged
// );
// }
/**
* Handles the writing opening balance journal entries once the customer created.
*/
@OnEvent(events.customers.onCreated)
public async handleWriteOpenBalanceEntries({
customer,
trx,
}: ICustomerEventCreatedPayload) {
// Writes the customer opening balance journal entries.
if (customer.openingBalance) {
await this.customerGLEntries.writeCustomerOpeningBalance(
customer.id,
trx,
);
}
}
// /**
// * Handles the writing opening balance journal entries once the customer created.
// * @param {ICustomerEventCreatedPayload} payload -
// */
// private handleWriteOpenBalanceEntries = async ({
// tenantId,
// customer,
// trx,
// }: ICustomerEventCreatedPayload) => {
// // Writes the customer opening balance journal entries.
// if (customer.openingBalance) {
// await this.customerGLEntries.writeCustomerOpeningBalance(
// tenantId,
// customer.id,
// trx
// );
// }
// };
/**
* Handles the deleting opening balance journal entries once the customer deleted.
*/
@OnEvent(events.customers.onDeleted)
public async handleRevertOpeningBalanceEntries({
customerId,
trx,
}: ICustomerEventDeletedPayload) {
await this.customerGLEntries.revertCustomerOpeningBalance(customerId, trx);
}
// /**
// * Handles the deleting opeing balance journal entrise once the customer deleted.
// * @param {ICustomerEventDeletedPayload} payload -
// */
// private handleRevertOpeningBalanceEntries = async ({
// tenantId,
// customerId,
// trx,
// }: ICustomerEventDeletedPayload) => {
// await this.customerGLEntries.revertCustomerOpeningBalance(
// tenantId,
// customerId,
// trx
// );
// };
// /**
// * Handles the rewrite opening balance entries once opening balnace changed.
// * @param {ICustomerOpeningBalanceEditedPayload} payload -
// */
// private handleRewriteOpeningEntriesOnChanged = async ({
// tenantId,
// customer,
// trx,
// }: ICustomerOpeningBalanceEditedPayload) => {
// if (customer.openingBalance) {
// await this.customerGLEntries.rewriteCustomerOpeningBalance(
// tenantId,
// customer.id,
// trx
// );
// } else {
// await this.customerGLEntries.revertCustomerOpeningBalance(
// tenantId,
// customer.id,
// trx
// );
// }
// };
// }
/**
* Handles the rewrite opening balance entries once opening balance changed.
*/
@OnEvent(events.customers.onOpeningBalanceChanged)
public async handleRewriteOpeningEntriesOnChanged({
customer,
trx,
}: ICustomerOpeningBalanceEditedPayload) {
if (customer.openingBalance) {
await this.customerGLEntries.rewriteCustomerOpeningBalance(
customer.id,
trx,
);
} else {
await this.customerGLEntries.revertCustomerOpeningBalance(
customer.id,
trx,
);
}
}
}

View File

@@ -4,6 +4,7 @@ import { IContactAddressDTO } from '@/modules/Contacts/types/Contacts.types';
import { IDynamicListFilter } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types';
import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model';
import { CreateCustomerDto } from '../dtos/CreateCustomer.dto';
import { CustomerOpeningBalanceEditDto } from '../dtos/CustomerOpeningBalanceEdit.dto';
import { EditCustomerDto } from '../dtos/EditCustomer.dto';
// Customer Interfaces.
@@ -113,23 +114,16 @@ export enum VendorAction {
View = 'View',
}
export interface ICustomerOpeningBalanceEditDTO {
openingBalance: number;
openingBalanceAt: Date | string;
openingBalanceExchangeRate: number;
openingBalanceBranchId?: number;
}
export interface ICustomerOpeningBalanceEditingPayload {
oldCustomer: Customer;
openingBalanceEditDTO: ICustomerOpeningBalanceEditDTO;
openingBalanceEditDTO: CustomerOpeningBalanceEditDto;
trx?: Knex.Transaction;
}
export interface ICustomerOpeningBalanceEditedPayload {
customer: Customer;
oldCustomer: Customer;
openingBalanceEditDTO: ICustomerOpeningBalanceEditDTO;
openingBalanceEditDTO: CustomerOpeningBalanceEditDto;
trx: Knex.Transaction;
}

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteExpense } from './commands/DeleteExpense.service';
@Injectable()
export class BulkDeleteExpensesService {
constructor(private readonly deleteExpenseService: DeleteExpense) { }
async bulkDeleteExpenses(
expenseIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const expensesIds = uniq(castArray(expenseIds));
const results = await PromisePool.withConcurrency(1)
.for(expensesIds)
.process(async (expenseId: number) => {
try {
await this.deleteExpenseService.deleteExpense(expenseId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -21,13 +21,55 @@ import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ExpenseResponseDto } from './dtos/ExpenseResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('expenses')
@ApiTags('Expenses')
@ApiExtraModels(PaginatedResponseDto, ExpenseResponseDto)
@ApiExtraModels(
PaginatedResponseDto,
ExpenseResponseDto,
ValidateBulkDeleteResponseDto,
)
@ApiCommonHeaders()
export class ExpensesController {
constructor(private readonly expensesApplication: ExpensesApplication) {}
constructor(private readonly expensesApplication: ExpensesApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary: 'Validate which expenses can be deleted and return the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable expenses.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
public validateBulkDeleteExpenses(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.expensesApplication.validateBulkDeleteExpenses(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple expenses.' })
@ApiResponse({
status: 200,
description: 'Expenses deleted successfully',
})
public bulkDeleteExpenses(
@Body() bulkDeleteDto: BulkDeleteDto,
) {
return this.expensesApplication.bulkDeleteExpenses(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
/**
* Create a new expense transaction.

View File

@@ -19,6 +19,8 @@ import { GetExpensesService } from './queries/GetExpenses.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { ExpensesExportable } from './ExpensesExportable';
import { ExpensesImportable } from './ExpensesImportable';
import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service';
import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service';
@Module({
imports: [LedgerModule, BranchesModule, DynamicListModule],
@@ -41,6 +43,8 @@ import { ExpensesImportable } from './ExpensesImportable';
GetExpensesService,
ExpensesExportable,
ExpensesImportable,
BulkDeleteExpensesService,
ValidateBulkDeleteExpensesService,
],
})
export class ExpensesModule {}

View File

@@ -7,6 +7,8 @@ import { GetExpenseService } from './queries/GetExpense.service';
import { IExpensesFilter } from './interfaces/Expenses.interface';
import { GetExpensesService } from './queries/GetExpenses.service';
import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service';
import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service';
@Injectable()
export class ExpensesApplication {
@@ -17,7 +19,9 @@ export class ExpensesApplication {
private readonly publishExpenseService: PublishExpense,
private readonly getExpenseService: GetExpenseService,
private readonly getExpensesService: GetExpensesService,
) {}
private readonly bulkDeleteExpensesService: BulkDeleteExpensesService,
private readonly validateBulkDeleteExpensesService: ValidateBulkDeleteExpensesService,
) { }
/**
* Create a new expense transaction.
@@ -47,6 +51,30 @@ export class ExpensesApplication {
return this.deleteExpenseService.deleteExpense(expenseId);
}
/**
* Deletes expenses in bulk.
* @param {number[]} expenseIds - Expense ids.
*/
public bulkDeleteExpenses(
expenseIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteExpensesService.bulkDeleteExpenses(
expenseIds,
options,
);
}
/**
* Validates which expenses can be deleted.
* @param {number[]} expenseIds - Expense ids.
*/
public validateBulkDeleteExpenses(expenseIds: number[]) {
return this.validateBulkDeleteExpensesService.validateBulkDeleteExpenses(
expenseIds,
);
}
/**
* Publishes the given expense.
* @param {number} expenseId - Expense id.

View File

@@ -0,0 +1,51 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteExpense } from './commands/DeleteExpense.service';
@Injectable()
export class ValidateBulkDeleteExpensesService {
constructor(
private readonly deleteExpenseService: DeleteExpense,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeleteExpenses(expenseIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const expenseId of expenseIds) {
try {
await this.deleteExpenseService.deleteExpense(expenseId, trx);
deletableIds.push(expenseId);
} catch (error) {
nonDeletableIds.push(expenseId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -36,9 +36,12 @@ export class DeleteExpense {
/**
* Deletes the given expense.
* @param {number} expenseId
* @param {ISystemUser} authorizedUser
* @param {Knex.Transaction} trx - Database transaction instance.
*/
public async deleteExpense(expenseId: number): Promise<void> {
public async deleteExpense(
expenseId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieves the expense transaction with associated entries or
// throw not found error.
const oldExpense = await this.expenseModel()
@@ -74,6 +77,6 @@ export class DeleteExpense {
oldExpense,
trx,
} as IExpenseEventDeletePayload);
});
}, trx);
}
}

View File

@@ -3,29 +3,30 @@ import { ITableColumn, ITableData, ITableRow } from '../types/Table.types';
import { FinancialTableStructure } from './FinancialTableStructure';
import { tableClassNames } from '../utils';
import { Injectable } from '@nestjs/common';
import { TemplateInjectable } from '../../TemplateInjectable/TemplateInjectable.service';
import { ChromiumlyTenancy } from '../../ChromiumlyTenancy/ChromiumlyTenancy.service';
import { renderFinancialSheetTemplateHtml } from '@bigcapital/pdf-templates';
@Injectable()
export class TableSheetPdf {
/**
* @param {TemplateInjectable} templateInjectable - The template injectable service.
* @param {ChromiumlyTenancy} chromiumlyTenancy - The chromiumly tenancy service.
*/
constructor(
private readonly templateInjectable: TemplateInjectable,
private readonly chromiumlyTenancy: ChromiumlyTenancy,
) {}
) { }
/**
* Converts the table data into a PDF format.
* @param {ITableData} table - The table data to be converted.
* @param {string} organizationName - The organization name.
* @param {string} sheetName - The name of the sheet.
* @param {string} sheetDate - The date of the sheet.
* @param {string} customCSS - Optional custom CSS to inject.
* @returns A promise that resolves with the PDF conversion result.
*/
public async convertToPdf(
table: ITableData,
organizationName: string,
sheetName: string,
sheetDate: string,
customCSS?: string,
@@ -33,19 +34,26 @@ export class TableSheetPdf {
// Prepare columns and rows for PDF conversion
const columns = this.tablePdfColumns(table.columns);
const rows = this.tablePdfRows(table.rows);
const landscape = columns.length > 4;
// Generate HTML content from the template
const htmlContent = await this.templateInjectable.render(
'financial-sheet',
{
table: { rows, columns },
sheetName,
sheetDate,
customCSS,
// Generate HTML content from the React template
const htmlContent = renderFinancialSheetTemplateHtml({
organizationName,
sheetName,
sheetDate,
table: {
columns: columns.map((col) => ({
key: col.key,
label: col.label,
style: (col as any).style, // style may be added during transformation
})),
rows: rows.map((row) => ({
cells: row.cells,
classNames: (row as any).classNames,
})),
},
);
customCSS,
});
// Convert the HTML content to PDF
return this.chromiumlyTenancy.convertHtmlContent(htmlContent, {
margins: { top: 0, bottom: 0, left: 0, right: 0 },
@@ -74,7 +82,6 @@ export class TableSheetPdf {
const flatNestedTree = curriedFlatNestedTree(R.__, {
nestedPrefix: '<span style="padding-left: 15px;"></span>',
});
// @ts-ignore
return R.compose(tableClassNames, flatNestedTree)(rows);
};

View File

@@ -21,6 +21,7 @@ export class APAgingSummaryPdfInjectable {
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.organizationName,
table.meta.sheetName,
table.meta.formattedAsDate,
HtmlTableCss,

View File

@@ -21,6 +21,7 @@ export class ARAgingSummaryPdfInjectable {
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.organizationName,
table.meta.sheetName,
table.meta.formattedDateRange,
HtmlTableCss,

View File

@@ -9,7 +9,7 @@ export class BalanceSheetPdfInjectable {
constructor(
private readonly balanceSheetTable: BalanceSheetTableInjectable,
private readonly tableSheetPdf: TableSheetPdf,
) {}
) { }
/**
* Converts the given balance sheet table to pdf.
@@ -21,6 +21,7 @@ export class BalanceSheetPdfInjectable {
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.organizationName,
table.meta.sheetName,
table.meta.formattedDateRange,
HtmlTableCustomCss,

View File

@@ -81,6 +81,7 @@ export class CashFlowStatementQueryDto extends FinancialSheetBranchesQueryDto {
})
@ValidateNested()
@Type(() => NumberFormatQueryDto)
@IsOptional()
numberFormat: NumberFormatQueryDto;
@ApiProperty({

View File

@@ -22,6 +22,7 @@ export class CashflowTablePdfInjectable {
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.organizationName,
table.meta.sheetName,
table.meta.formattedDateRange,
HtmlTableCustomCss,

View File

@@ -21,6 +21,7 @@ export class CustomerBalanceSummaryPdf {
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.organizationName,
table.meta.sheetName,
table.meta.formattedDateRange,
HtmlTableCustomCss,

View File

@@ -21,6 +21,7 @@ export class GeneralLedgerPdf {
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.organizationName,
table.meta.sheetName,
table.meta.formattedDateRange,
HtmlTableCustomCss,

View File

@@ -32,7 +32,7 @@ export class InventoryItemDetailsQueryDto {
@ApiPropertyOptional({
description: 'Number format for the inventory item details',
})
numberFormat: INumberFormatQuery;
numberFormat: NumberFormatQueryDto;
@Transform(({ value }) => parseBoolean(value, false))
@IsBoolean()

View File

@@ -26,6 +26,7 @@ export class InventoryDetailsTablePdf {
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.organizationName,
table.meta.sheetName,
table.meta.formattedDateRange,
HtmlTableCustomCss,

View File

@@ -22,6 +22,7 @@ export class InventoryValuationSheetPdf {
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.organizationName,
table.meta.sheetName,
table.meta.formattedDateRange,
HtmlTableCustomCss,

View File

@@ -9,7 +9,7 @@ export class JournalSheetPdfInjectable {
constructor(
private readonly journalSheetTable: JournalSheetTableInjectable,
private readonly tableSheetPdf: TableSheetPdf,
) {}
) { }
/**
* Converts the given journal sheet table to pdf.
@@ -22,6 +22,7 @@ export class JournalSheetPdfInjectable {
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.organizationName,
table.meta.sheetName,
table.meta.formattedDateRange,
HtmlTableCustomCss,

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