Compare commits

..

67 Commits

Author SHA1 Message Date
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
Ahmed Bouhuolia
0588a30c88 fix: auth pages errors handler 2025-10-30 19:27:29 +02:00
Ahmed Bouhuolia
4a0091d3f8 Merge pull request #835 from bigcapitalhq/fix-edit-payment-transaction
fix: edit payment transaction
2025-10-29 12:57:24 +02:00
Ahmed Bouhuolia
fc89cfb14a fix: edit payment transaction 2025-10-29 12:54:12 +02:00
Ahmed Bouhuolia
98401b5a01 Merge pull request #438 from bigcapitalhq/BIG-166
feat: one-command setup script
2025-10-29 00:39:45 +02:00
Ahmed Bouhuolia
3afe4f470f Update setup.sh
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-29 00:39:26 +02:00
Ahmed Bouhuolia
b4281a71d4 Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-29 00:39:15 +02:00
Ahmed Bouhuolia
87c127fabd Merge branch 'develop' into BIG-166 2025-10-29 00:35:33 +02:00
Ahmed Bouhuolia
50d9e8d375 Merge pull request #833 from bigcapitalhq/fix-more-bugs
fix: issues related to PUT operations
2025-10-28 18:14:00 +02:00
Ahmed Bouhuolia
4839a6dea8 Update packages/server/src/modules/Bills/commands/EditBill.service.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-28 18:13:52 +02:00
Ahmed Bouhuolia
f736c3f9eb fix: issues related to PUT operations 2025-10-28 18:12:08 +02:00
Ahmed Bouhuolia
368c85a01a Merge pull request #832 from bigcapitalhq/validate-tenant-existance-in-guards
fix: validate request org id existance in guards
2025-10-25 15:20:42 +02:00
Ahmed Bouhuolia
5d792fea26 fix 2025-10-25 15:19:10 +02:00
Ahmed Bouhuolia
1bccba572a fix: validate request org id existance in guards 2025-10-25 15:15:13 +02:00
Ahmed Bouhuolia
900921e6ba Merge pull request #831 from bigcapitalhq/fix-tenant-build
fix: organization build db connection error
2025-10-25 14:59:28 +02:00
Ahmed Bouhuolia
2b4772a070 fix: organization build db connection error 2025-10-25 14:57:38 +02:00
Ahmed Bouhuolia
8852a4a0f8 Merge pull request #830 from bigcapitalhq/fix-system-tenant-migration
fix: seed migration issue
2025-10-25 14:02:19 +02:00
Ahmed Bouhuolia
8e2cd98689 fix: the menu labels 2024-05-12 18:32:19 +02:00
Ahmed Bouhuolia
f934797929 feat: one-command setup script 2024-05-12 18:07:38 +02:00
385 changed files with 11544 additions and 8299 deletions

View File

@@ -98,8 +98,18 @@ POSTHOG_API_KEY=
POSTHOG_HOST= POSTHOG_HOST=
# Stripe Payment # Stripe Payment
# Get the keys from the Stripe dashboard
# Starts with "sk_"
STRIPE_PAYMENT_SECRET_KEY= STRIPE_PAYMENT_SECRET_KEY=
# Starts with "pk_"
STRIPE_PAYMENT_PUBLISHABLE_KEY= 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= 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_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`. - 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. 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. - 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. And you should get something like that.
@@ -84,10 +84,10 @@ And you should get something like that.
Batch 1 run: 6 migrations Batch 1 run: 6 migrations
``` ```
- Next, start the webapp application. - Next, start the server.
``` ```
pnpm run dev:server pnpm run server:start
``` ```
**[`^top^`](#)** **[`^top^`](#)**
@@ -96,12 +96,6 @@ pnpm run dev:server
## Contribute to Frontend ## 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. - 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 :) 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

@@ -93,7 +93,7 @@ services:
- OPEN_EXCHANGE_RATE_APP_ID-${OPEN_EXCHANGE_RATE_APP_ID} - OPEN_EXCHANGE_RATE_APP_ID-${OPEN_EXCHANGE_RATE_APP_ID}
# Bank Sync # Bank Sync
- BANKING_CONNECT=${BANKING_CONNECT} - BANK_FEED_ENABLED=${BANK_FEED_ENABLED}
# Plaid # Plaid
- PLAID_ENV=${PLAID_ENV} - PLAID_ENV=${PLAID_ENV}
@@ -122,6 +122,13 @@ services:
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_ENDPOINT=${S3_ENDPOINT} - S3_ENDPOINT=${S3_ENDPOINT}
- S3_BUCKET=${S3_BUCKET} - S3_BUCKET=${S3_BUCKET}
# 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: database_migration:
container_name: bigcapital-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 COPY redis.conf /usr/local/etc/redis/redis.conf

View File

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

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

@@ -14,9 +14,12 @@ import jwt from './jwt';
import mail from './mail'; import mail from './mail';
import loops from './loops'; import loops from './loops';
import bankfeed from './bankfeed'; import bankfeed from './bankfeed';
import throttle from './throttle';
import cloud from './cloud';
export const config = [ export const config = [
systemDatabase, systemDatabase,
cloud,
tenantDatabase, tenantDatabase,
signup, signup,
gotenberg, gotenberg,
@@ -32,4 +35,5 @@ export const config = [
mail, mail,
loops, loops,
bankfeed, 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

@@ -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 sortDirsSeparately: boolean;
private migrationsPaths: string[]; private migrationsPaths: string[];
private loadExtensions: string[]; private loadExtensions: string[];
private seedsDirectory: string;
/** /**
* Constructor method. * Constructor method.
@@ -30,6 +31,8 @@ class FsMigrations {
} }
this.migrationsPaths = migrationDirectories; this.migrationsPaths = migrationDirectories;
this.loadExtensions = loadExtensions || DEFAULT_LOAD_EXTENSIONS; 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} * @returns {string}
*/ */
public getMigration(migration: MigrateItem): 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 // @ts-nocheck
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
/** /**
* Detarmines the module type of the given file 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} moduleName
* @param {string} seedsDirectory - The seeds directory path from config
* @returns * @returns
*/ */
export async function importWebpackSeedModule(moduleName: string): any { export async function importWebpackSeedModule(
return import(`../../database/seeds/core/${moduleName}`); 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

@@ -15,7 +15,7 @@ global.__views_dirname = path.join(global.__static_dirname, '/views');
global.__images_dirname = path.join(global.__static_dirname, '/images'); global.__images_dirname = path.join(global.__static_dirname, '/images');
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule, { rawBody: true });
app.setGlobalPrefix('/api'); app.setGlobalPrefix('/api');
// create and mount the middleware manually here // create and mount the middleware manually here

View File

@@ -7,15 +7,16 @@ import {
Get, Get,
Query, Query,
ParseIntPipe, ParseIntPipe,
Put,
} from '@nestjs/common'; } from '@nestjs/common';
import { AccountsApplication } from './AccountsApplication.service'; import { AccountsApplication } from './AccountsApplication.service';
import { CreateAccountDTO } from './CreateAccount.dto'; import { CreateAccountDTO } from './CreateAccount.dto';
import { EditAccountDTO } from './EditAccount.dto'; import { EditAccountDTO } from './EditAccount.dto';
import { IAccountsFilter } from './Accounts.types';
import { import {
ApiExtraModels, ApiExtraModels,
ApiOperation, ApiOperation,
ApiParam, ApiParam,
ApiQuery,
ApiResponse, ApiResponse,
ApiTags, ApiTags,
getSchemaPath, getSchemaPath,
@@ -24,16 +25,57 @@ import { AccountResponseDto } from './dtos/AccountResponse.dto';
import { AccountTypeResponseDto } from './dtos/AccountTypeResponse.dto'; import { AccountTypeResponseDto } from './dtos/AccountTypeResponse.dto';
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto'; import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
import { GetAccountTransactionsQueryDto } from './dtos/GetAccountTransactionsQuery.dto'; import { GetAccountTransactionsQueryDto } from './dtos/GetAccountTransactionsQuery.dto';
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('accounts') @Controller('accounts')
@ApiTags('Accounts') @ApiTags('Accounts')
@ApiExtraModels(AccountResponseDto) @ApiExtraModels(AccountResponseDto)
@ApiExtraModels(AccountTypeResponseDto) @ApiExtraModels(AccountTypeResponseDto)
@ApiExtraModels(GetAccountTransactionResponseDto) @ApiExtraModels(GetAccountTransactionResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders() @ApiCommonHeaders()
export class AccountsController { 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() @Post()
@ApiOperation({ summary: 'Create an account' }) @ApiOperation({ summary: 'Create an account' })
@@ -45,7 +87,7 @@ export class AccountsController {
return this.accountsApplication.createAccount(accountDTO); return this.accountsApplication.createAccount(accountDTO);
} }
@Post(':id') @Put(':id')
@ApiOperation({ summary: 'Edit the given account.' }) @ApiOperation({ summary: 'Edit the given account.' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -178,7 +220,19 @@ export class AccountsController {
items: { $ref: getSchemaPath(AccountResponseDto) }, 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); return this.accountsApplication.getAccounts(filter);
} }
} }

View File

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

View File

@@ -10,10 +10,14 @@ import { GetAccount } from './GetAccount.service';
import { ActivateAccount } from './ActivateAccount.service'; import { ActivateAccount } from './ActivateAccount.service';
import { GetAccountTypesService } from './GetAccountTypes.service'; import { GetAccountTypesService } from './GetAccountTypes.service';
import { GetAccountTransactionsService } from './GetAccountTransactions.service'; import { GetAccountTransactionsService } from './GetAccountTransactions.service';
import { IAccountsFilter, IAccountsTransactionsFilter } from './Accounts.types'; import { IAccountsTransactionsFilter } from './Accounts.types';
import { GetAccountsService } from './GetAccounts.service'; import { GetAccountsService } from './GetAccounts.service';
import { IFilterMeta } from '@/interfaces/Model'; import { IFilterMeta } from '@/interfaces/Model';
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto'; 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() @Injectable()
export class AccountsApplication { export class AccountsApplication {
@@ -36,7 +40,9 @@ export class AccountsApplication {
private readonly getAccountService: GetAccount, private readonly getAccountService: GetAccount,
private readonly getAccountTransactionsService: GetAccountTransactionsService, private readonly getAccountTransactionsService: GetAccountTransactionsService,
private readonly getAccountsService: GetAccountsService, private readonly getAccountsService: GetAccountsService,
) {} private readonly bulkDeleteAccountsService: BulkDeleteAccountsService,
private readonly validateBulkDeleteAccountsService: ValidateBulkDeleteAccountsService,
) { }
/** /**
* Creates a new account. * Creates a new account.
@@ -108,11 +114,11 @@ export class AccountsApplication {
/** /**
* Retrieves the accounts list. * Retrieves the accounts list.
* @param {IAccountsFilter} filterDTO - Filter DTO. * @param {GetAccountsQueryDto} filterDTO - Filter DTO.
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>} * @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
*/ */
public getAccounts = ( public getAccounts = (
filterDTO: Partial<IAccountsFilter>, filterDTO: GetAccountsQueryDto,
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> => { ): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> => {
return this.getAccountsService.getAccountsList(filterDTO); return this.getAccountsService.getAccountsList(filterDTO);
}; };
@@ -127,4 +133,28 @@ export class AccountsApplication {
): Promise<Array<GetAccountTransactionResponseDto>> => { ): Promise<Array<GetAccountTransactionResponseDto>> => {
return this.getAccountTransactionsService.getAccountsTransactions(filter); 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) @Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>, private readonly accountModel: TenantModelProxy<typeof Account>,
private readonly accountRepository: AccountRepository, private readonly accountRepository: AccountRepository,
) {} ) { }
/** /**
* Throws error if the account was prefined. * Throws error if the account was prefined.
@@ -115,7 +115,7 @@ export class CommandAccountValidators {
accountName: string, accountName: string,
notAccountId?: number, notAccountId?: number,
) { ) {
const foundAccount = await this.accountModel const foundAccount = await this.accountModel()
.query() .query()
.findOne('name', accountName) .findOne('name', accountName)
.onBuild((query) => { .onBuild((query) => {

View File

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

View File

@@ -50,8 +50,12 @@ export class DeleteAccount {
/** /**
* Deletes the account from the storage. * Deletes the account from the storage.
* @param {number} accountId * @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. // Retrieve account or not found service error.
const oldAccount = await this.accountModel().query().findById(accountId); const oldAccount = await this.accountModel().query().findById(accountId);
@@ -82,6 +86,6 @@ export class DeleteAccount {
oldAccount, oldAccount,
trx, trx,
} as IAccountEventDeletedPayload); } as IAccountEventDeletedPayload);
}); }, trx);
}; };
} }

View File

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

View File

@@ -17,7 +17,7 @@ export class EditAccount {
@Inject(Account.name) @Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>, private readonly accountModel: TenantModelProxy<typeof Account>,
) {} ) { }
/** /**
* Authorize the account editing. * Authorize the account editing.
@@ -85,8 +85,7 @@ export class EditAccount {
// Update the account on the storage. // Update the account on the storage.
const account = await this.accountModel() const account = await this.accountModel()
.query(trx) .query(trx)
.findById(accountId) .updateAndFetchById(accountId, { ...accountDTO });
.updateAndFetch({ ...accountDTO });
// Triggers `onAccountEdited` event. // Triggers `onAccountEdited` event.
// await this.eventEmitter.emitAsync(events.accounts.onEdited, { // 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 { AccountRepository } from './repositories/Account.repository';
import { IFilterMeta } from '@/interfaces/Model'; import { IFilterMeta } from '@/interfaces/Model';
import { TenantModelProxy } from '../System/models/TenantBaseModel'; import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
@Injectable() @Injectable()
export class GetAccountsService { export class GetAccountsService {
@@ -18,7 +19,7 @@ export class GetAccountsService {
@Inject(Account.name) @Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>, private readonly accountModel: TenantModelProxy<typeof Account>,
) {} ) { }
/** /**
* Retrieve accounts datatable list. * Retrieve accounts datatable list.
@@ -26,12 +27,12 @@ export class GetAccountsService {
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>} * @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
*/ */
public async getAccountsList( public async getAccountsList(
filterDto: Partial<IAccountsFilter>, filterDto: Partial<GetAccountsQueryDto>,
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> { ): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> {
const parsedFilterDto = { const parsedFilterDto = {
sortOrder: 'desc', sortOrder: 'desc',
columnSortBy: 'created_at', columnSortBy: 'created_at',
inactiveMode: false, onlyInactive: false,
structure: IAccountsStructureType.Tree, structure: IAccountsStructureType.Tree,
...filterDto, ...filterDto,
}; };
@@ -48,7 +49,7 @@ export class GetAccountsService {
.query() .query()
.onBuild((builder) => { .onBuild((builder) => {
dynamicList.buildQuery()(builder); dynamicList.buildQuery()(builder);
builder.modify('inactiveMode', filter.inactiveMode); builder.modify('inactiveMode', filterDto.onlyInactive);
}); });
const accountsGraph = await this.accountRepository.getDependencyGraph(); const accountsGraph = await this.accountRepository.getDependencyGraph();
@@ -58,7 +59,6 @@ export class GetAccountsService {
new AccountTransformer(), new AccountTransformer(),
{ accountsGraph, structure: parsedFilterDto.structure }, { accountsGraph, structure: parsedFilterDto.structure },
); );
return { return {
accounts: transformedAccounts, accounts: transformedAccounts,
filterMeta: dynamicList.getResponseMeta(), 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 { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; 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 { join } from 'path';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { RedisModule } from '@liaoliaots/nestjs-redis'; import { RedisModule } from '@liaoliaots/nestjs-redis';
@@ -95,6 +95,8 @@ import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module'; import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module'; import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
import { SocketModule } from '../Socket/Socket.module'; import { SocketModule } from '../Socket/Socket.module';
import { ThrottlerGuard } from '@nestjs/throttler';
import { AppThrottleModule } from './AppThrottle.module';
@Module({ @Module({
imports: [ imports: [
@@ -126,6 +128,7 @@ import { SocketModule } from '../Socket/Socket.module';
], ],
}), }),
PassportModule, PassportModule,
AppThrottleModule,
BullModule.forRootAsync({ BullModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({ useFactory: async (configService: ConfigService) => ({
@@ -231,6 +234,10 @@ import { SocketModule } from '../Socket/Socket.module';
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{ {
provide: APP_INTERCEPTOR, provide: APP_INTERCEPTOR,
useClass: SerializeInterceptor, 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, Request,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -28,6 +29,7 @@ import { SystemUser } from '../System/models/SystemUser';
@ApiTags('Auth') @ApiTags('Auth')
@ApiExcludeController() @ApiExcludeController()
@PublicRoute() @PublicRoute()
@Throttle({ auth: {} })
export class AuthController { export class AuthController {
constructor( constructor(
private readonly authApp: AuthenticationApplication, 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 { GenerateApiKey } from './commands/GenerateApiKey.service';
import { GetApiKeysService } from './queries/GetApiKeys.service'; import { GetApiKeysService } from './queries/GetApiKeys.service';
import { import {
@@ -8,6 +8,8 @@ import {
ApiParam, ApiParam,
ApiExtraModels, ApiExtraModels,
getSchemaPath, getSchemaPath,
ApiBody,
ApiProperty,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import { import {
@@ -16,6 +18,20 @@ import {
ApiKeyListResponseDto, ApiKeyListResponseDto,
ApiKeyListItemDto, ApiKeyListItemDto,
} from './dtos/ApiKey.dto'; } 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') @Controller('api-keys')
@ApiTags('Api keys') @ApiTags('Api keys')
@@ -29,17 +45,18 @@ export class AuthApiKeysController {
constructor( constructor(
private readonly getApiKeysService: GetApiKeysService, private readonly getApiKeysService: GetApiKeysService,
private readonly generateApiKeyService: GenerateApiKey, private readonly generateApiKeyService: GenerateApiKey,
) {} ) { }
@Post('generate') @Post('generate')
@ApiOperation({ summary: 'Generate a new API key' }) @ApiOperation({ summary: 'Generate a new API key' })
@ApiBody({ type: GenerateApiKeyDto })
@ApiResponse({ @ApiResponse({
status: 201, status: 201,
description: 'The generated API key', description: 'The generated API key',
type: ApiKeyResponseDto, type: ApiKeyResponseDto,
}) })
async generate() { async generate(@Body() body: GenerateApiKeyDto) {
return this.generateApiKeyService.generate(); return this.generateApiKeyService.generate(body.name);
} }
@Put(':id/revoke') @Put(':id/revoke')

View File

@@ -6,6 +6,7 @@ import {
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service'; import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service';
import { Controller, Get, Post } from '@nestjs/common'; import { Controller, Get, Post } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards'; import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards';
import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard'; import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard';
import { AuthenticationApplication } from './AuthApplication.sevice'; import { AuthenticationApplication } from './AuthApplication.sevice';
@@ -18,11 +19,12 @@ import { IgnoreUserVerifiedRoute } from './guards/EnsureUserVerified.guard';
@IgnoreTenantSeededRoute() @IgnoreTenantSeededRoute()
@IgnoreTenantInitializedRoute() @IgnoreTenantInitializedRoute()
@IgnoreUserVerifiedRoute() @IgnoreUserVerifiedRoute()
@Throttle({ auth: {} })
export class AuthedController { export class AuthedController {
constructor( constructor(
private readonly getAuthedAccountService: GetAuthenticatedAccount, private readonly getAuthedAccountService: GetAuthenticatedAccount,
private readonly authApp: AuthenticationApplication, private readonly authApp: AuthenticationApplication,
) {} ) { }
@Post('/signup/verify/resend') @Post('/signup/verify/resend')
@ApiOperation({ summary: 'Resend the signup confirmation message' }) @ApiOperation({ summary: 'Resend the signup confirmation message' })

View File

@@ -24,7 +24,7 @@ export class AuthSendResetPasswordService {
@Inject(SystemUser.name) @Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser, private readonly systemUserModel: typeof SystemUser,
) {} ) { }
/** /**
* Sends the given email reset password email. * Sends the given email reset password email.
@@ -33,8 +33,9 @@ export class AuthSendResetPasswordService {
async sendResetPassword(email: string): Promise<void> { async sendResetPassword(email: string): Promise<void> {
const user = await this.systemUserModel const user = await this.systemUserModel
.query() .query()
.findOne({ email }) .findOne({ email });
.throwIfNotFound();
if (!user) return;
const token: string = uniqid(); const token: string = uniqid();
@@ -48,10 +49,8 @@ export class AuthSendResetPasswordService {
this.deletePasswordResetToken(email); this.deletePasswordResetToken(email);
// Creates a new password reset row with unique token. // Creates a new password reset row with unique token.
const passwordReset = await this.resetPasswordModel.query().insert({ await this.resetPasswordModel.query().insert({ email, token });
email,
token,
});
// Triggers sent reset password event. // Triggers sent reset password event.
await this.eventPublisher.emitAsync(events.auth.sendResetPassword, { await this.eventPublisher.emitAsync(events.auth.sendResetPassword, {
user, user,

View File

@@ -1,9 +1,11 @@
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { SystemUser } from '@/modules/System/models/SystemUser'; import { SystemUser } from '@/modules/System/models/SystemUser';
import { ModelObject } from 'objection'; import { ModelObject } from 'objection';
import { JwtPayload } from '../Auth.interfaces'; import { JwtPayload } from '../Auth.interfaces';
import { InvalidEmailPasswordException } from '../exceptions/InvalidEmailPassword.exception';
import { UserNotFoundException } from '../exceptions/UserNotFound.exception';
@Injectable() @Injectable()
export class AuthSigninService { export class AuthSigninService {
@@ -12,7 +14,7 @@ export class AuthSigninService {
private readonly systemUserModel: typeof SystemUser, private readonly systemUserModel: typeof SystemUser,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly clsService: ClsService, private readonly clsService: ClsService,
) {} ) { }
/** /**
* Validates the given email and password. * Validates the given email and password.
@@ -32,14 +34,10 @@ export class AuthSigninService {
.findOne({ email }) .findOne({ email })
.throwIfNotFound(); .throwIfNotFound();
} catch (err) { } catch (err) {
throw new UnauthorizedException( throw new InvalidEmailPasswordException(email);
`There isn't any user with email: ${email}`,
);
} }
if (!(await user.checkPassword(password))) { if (!(await user.checkPassword(password))) {
throw new UnauthorizedException( throw new InvalidEmailPasswordException(email);
`Wrong password for user with email: ${email}`,
);
} }
return user; return user;
} }
@@ -61,9 +59,7 @@ export class AuthSigninService {
this.clsService.set('tenantId', user.tenantId); this.clsService.set('tenantId', user.tenantId);
this.clsService.set('userId', user.id); this.clsService.set('userId', user.id);
} catch (error) { } catch (error) {
throw new UnauthorizedException( throw new UserNotFoundException(String(payload.sub));
`There isn't any user with email: ${payload.sub}`,
);
} }
return payload; return payload;
} }

View File

@@ -16,6 +16,7 @@ import {
import { defaultTo } from 'ramda'; import { defaultTo } from 'ramda';
import { ERRORS } from '../Auth.constants'; import { ERRORS } from '../Auth.constants';
import { hashPassword } from '../Auth.utils'; import { hashPassword } from '../Auth.utils';
import { ClsService } from 'nestjs-cls';
@Injectable() @Injectable()
export class AuthSignupService { export class AuthSignupService {
@@ -29,10 +30,11 @@ export class AuthSignupService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly tenantsManager: TenantsManagerService, private readonly tenantsManager: TenantsManagerService,
private readonly clsService: ClsService,
@Inject(SystemUser.name) @Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser, private readonly systemUserModel: typeof SystemUser,
) {} ) { }
/** /**
* Registers a new tenant with user from user input. * Registers a new tenant with user from user input.
@@ -70,6 +72,11 @@ export class AuthSignupService {
tenantId: tenant.id, tenantId: tenant.id,
inviteAcceptedAt, 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. // Triggers signed up event.
await this.eventEmitter.emitAsync(events.auth.signUp, { await this.eventEmitter.emitAsync(events.auth.signUp, {
signupDTO, signupDTO,
@@ -121,7 +128,6 @@ export class AuthSignupService {
const isAllowedDomain = signupRestrictions.allowedDomains.some( const isAllowedDomain = signupRestrictions.allowedDomains.some(
(domain) => emailDomain === domain, (domain) => emailDomain === domain,
); );
if (!isAllowedEmail && !isAllowedDomain) { if (!isAllowedEmail && !isAllowedDomain) {
throw new ServiceError( throw new ServiceError(
ERRORS.SIGNUP_RESTRICTED_NOT_ALLOWED, ERRORS.SIGNUP_RESTRICTED_NOT_ALLOWED,

View File

@@ -10,14 +10,15 @@ export class GenerateApiKey {
private readonly tenancyContext: TenancyContext, private readonly tenancyContext: TenancyContext,
@Inject(ApiKeyModel.name) @Inject(ApiKeyModel.name)
private readonly apiKeyModel: typeof ApiKeyModel, private readonly apiKeyModel: typeof ApiKeyModel,
) {} ) { }
/** /**
* Generates a new secure API key for the current tenant and system user. * 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). * 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. * @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 tenant = await this.tenancyContext.getTenant();
const user = await this.tenancyContext.getSystemUser(); const user = await this.tenancyContext.getSystemUser();
@@ -26,6 +27,7 @@ export class GenerateApiKey {
// Save the API key to the database // Save the API key to the database
const apiKeyRecord = await this.apiKeyModel.query().insert({ const apiKeyRecord = await this.apiKeyModel.query().insert({
key, key,
name,
tenantId: tenant.id, tenantId: tenant.id,
userId: user.id, userId: user.id,
createdAt: new Date(), createdAt: new Date(),

View File

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

View File

@@ -0,0 +1,15 @@
import { UnauthorizedException } from '@nestjs/common';
import { ERRORS } from '../Auth.constants';
export class InvalidEmailPasswordException extends UnauthorizedException {
constructor(email: string) {
super({
statusCode: 401,
error: 'Unauthorized',
message: `Invalid email or password for ${email}`,
code: ERRORS.INVALID_DETAILS,
});
}
}

View File

@@ -0,0 +1,13 @@
import { UnauthorizedException } from '@nestjs/common';
import { ERRORS } from '../Auth.constants';
export class UserNotFoundException extends UnauthorizedException {
constructor(identifier: string) {
super({
statusCode: 401,
error: 'Unauthorized',
message: `User not found: ${identifier}`,
code: ERRORS.USER_NOT_FOUND,
});
}
}

View File

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

View File

@@ -28,7 +28,12 @@ export class GetBankRulesTransformer extends Transformer {
* @returns {string} * @returns {string}
*/ */
protected assignCategoryFormatted(bankRule: any) { protected assignCategoryFormatted(bankRule: any) {
return getCashflowTransactionFormattedType(bankRule.assignCategory); const translationKey = getCashflowTransactionFormattedType(
bankRule.assignCategory,
);
return translationKey
? this.context.i18n.t(translationKey)
: bankRule.assignCategory;
} }
/** /**

View File

@@ -19,7 +19,7 @@ export class BillPaymentsPages {
@Inject(BillPayment.name) @Inject(BillPayment.name)
private readonly billPaymentModel: TenantModelProxy<typeof BillPayment>, private readonly billPaymentModel: TenantModelProxy<typeof BillPayment>,
) {} ) { }
/** /**
* Retrieve bill payment with associated metadata. * Retrieve bill payment with associated metadata.
@@ -46,7 +46,8 @@ export class BillPaymentsPages {
paymentAmount: entry.paymentAmount, paymentAmount: entry.paymentAmount,
})); }));
const resPayableBills = await Bill.query() const resPayableBills = await this.billModel()
.query()
.modify('opened') .modify('opened')
.modify('dueBills') .modify('dueBills')
.where('vendor_id', billPayment.vendorId) .where('vendor_id', billPayment.vendorId)

View File

@@ -9,6 +9,8 @@ import { Injectable } from '@nestjs/common';
import { GetBillsService } from './queries/GetBills.service'; import { GetBillsService } from './queries/GetBills.service';
import { CreateBillDto, EditBillDto } from './dtos/Bill.dto'; import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments'; import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
import { BulkDeleteBillsService } from './BulkDeleteBills.service';
import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service';
// import { GetBillPayments } from './queries/GetBillPayments'; // import { GetBillPayments } from './queries/GetBillPayments';
// import { GetBills } from './queries/GetBills'; // import { GetBills } from './queries/GetBills';
@@ -23,7 +25,9 @@ export class BillsApplication {
private openBillService: OpenBillService, private openBillService: OpenBillService,
private getBillsService: GetBillsService, private getBillsService: GetBillsService,
private getBillPaymentTransactionsService: GetBillPaymentTransactionsService, private getBillPaymentTransactionsService: GetBillPaymentTransactionsService,
) {} private bulkDeleteBillsService: BulkDeleteBillsService,
private validateBulkDeleteBillsService: ValidateBulkDeleteBillsService,
) { }
/** /**
* Creates a new bill with associated GL entries. * Creates a new bill with associated GL entries.
@@ -53,6 +57,25 @@ export class BillsApplication {
return this.deleteBillService.deleteBill(billId); 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. * Retrieve bills data table list.
* @param {IBillsFilter} billsFilter - * @param {IBillsFilter} billsFilter -

View File

@@ -22,14 +22,51 @@ import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
import { BillResponseDto } from './dtos/BillResponse.dto'; import { BillResponseDto } from './dtos/BillResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('bills') @Controller('bills')
@ApiTags('Bills') @ApiTags('Bills')
@ApiExtraModels(BillResponseDto) @ApiExtraModels(BillResponseDto)
@ApiExtraModels(PaginatedResponseDto) @ApiExtraModels(PaginatedResponseDto)
@ApiCommonHeaders() @ApiCommonHeaders()
@ApiExtraModels(ValidateBulkDeleteResponseDto)
export class BillsController { 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() @Post()
@ApiOperation({ summary: 'Create a new bill.' }) @ApiOperation({ summary: 'Create a new bill.' })

View File

@@ -29,6 +29,8 @@ import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
import { BillsExportable } from './commands/BillsExportable'; import { BillsExportable } from './commands/BillsExportable';
import { BillsImportable } from './commands/BillsImportable'; import { BillsImportable } from './commands/BillsImportable';
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments'; import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
import { BulkDeleteBillsService } from './BulkDeleteBills.service';
import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service';
@Module({ @Module({
imports: [ imports: [
@@ -63,8 +65,10 @@ import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
BillsExportable, BillsExportable,
BillsImportable, BillsImportable,
GetBillPaymentTransactionsService, GetBillPaymentTransactionsService,
BulkDeleteBillsService,
ValidateBulkDeleteBillsService,
], ],
controllers: [BillsController], controllers: [BillsController],
exports: [BillsExportable, BillsImportable], 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. * Deletes the bill with associated entries.
* @param {number} billId * @param {number} billId
* @param {Knex.Transaction} trx - Database transaction instance.
* @return {void} * @return {void}
*/ */
public async deleteBill(billId: number) { public async deleteBill(billId: number, trx?: Knex.Transaction) {
// Retrieve the given bill or throw not found error. // Retrieve the given bill or throw not found error.
const oldBill = await this.billModel() const oldBill = await this.billModel()
.query() .query()
@@ -75,6 +76,6 @@ export class DeleteBill {
oldBill, oldBill,
trx, trx,
} as IBIllEventDeletedPayload); } as IBIllEventDeletedPayload);
}); }, trx);
} }
} }

View File

@@ -28,8 +28,8 @@ export class EditBillService {
private transformerDTO: BillDTOTransformer, private transformerDTO: BillDTOTransformer,
@Inject(Bill.name) private billModel: TenantModelProxy<typeof Bill>, @Inject(Bill.name) private billModel: TenantModelProxy<typeof Bill>,
@Inject(Vendor.name) private contactModel: TenantModelProxy<typeof Vendor>, @Inject(Vendor.name) private vendorModel: TenantModelProxy<typeof Vendor>,
) {} ) { }
/** /**
* Edits details of the given bill id with associated entries. * Edits details of the given bill id with associated entries.
@@ -58,10 +58,9 @@ export class EditBillService {
this.validators.validateBillExistance(oldBill); this.validators.validateBillExistance(oldBill);
// Retrieve vendor details or throw not found service error. // Retrieve vendor details or throw not found service error.
const vendor = await this.contactModel() const vendor = await this.vendorModel()
.query() .query()
.findById(billDTO.vendorId) .findById(billDTO.vendorId)
.modify('vendor')
.throwIfNotFound(); .throwIfNotFound();
// Validate bill number uniqiness on the storage. // Validate bill number uniqiness on the storage.

View File

@@ -1,6 +1,7 @@
import { ToNumber } from '@/common/decorators/Validators'; import { ToNumber } from '@/common/decorators/Validators';
import { ItemEntryDto } from '@/modules/TransactionItemEntry/dto/ItemEntry.dto'; import { ItemEntryDto } from '@/modules/TransactionItemEntry/dto/ItemEntry.dto';
import { Type } from 'class-transformer'; import { Type, Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { import {
ArrayMinSize, ArrayMinSize,
@@ -31,6 +32,7 @@ export class BillEntryDto extends ItemEntryDto {
}) })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@Transform(({ value }) => parseBoolean(value, false))
landedCost?: boolean; landedCost?: boolean;
} }
@@ -211,5 +213,5 @@ export class CommandBillDto {
adjustment?: number; adjustment?: number;
} }
export class CreateBillDto extends CommandBillDto {} export class CreateBillDto extends CommandBillDto { }
export class EditBillDto extends CommandBillDto {} export class EditBillDto extends CommandBillDto { }

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 { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto';
import { GetCreditNoteState } from './queries/GetCreditNoteState.service'; import { GetCreditNoteState } from './queries/GetCreditNoteState.service';
import { GetCreditNoteService } from './queries/GetCreditNote.service'; import { GetCreditNoteService } from './queries/GetCreditNote.service';
import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service';
import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service';
@Injectable() @Injectable()
export class CreditNoteApplication { export class CreditNoteApplication {
@@ -20,8 +22,10 @@ export class CreditNoteApplication {
private readonly getCreditNotePdfService: GetCreditNotePdf, private readonly getCreditNotePdfService: GetCreditNotePdf,
private readonly getCreditNotesService: GetCreditNotesService, private readonly getCreditNotesService: GetCreditNotesService,
private readonly getCreditNoteStateService: GetCreditNoteState, 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. * Creates a new credit note.
@@ -97,4 +101,30 @@ export class CreditNoteApplication {
getCreditNote(creditNoteId: number) { getCreditNote(creditNoteId: number) {
return this.getCreditNoteService.getCreditNote(creditNoteId); 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 { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -11,10 +12,12 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
Param, Param,
Post, Post,
Put, Put,
Query, Query,
Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreditNoteApplication } from './CreditNoteApplication.service'; import { CreditNoteApplication } from './CreditNoteApplication.service';
import { ICreditNotesQueryDTO } from './types/CreditNotes.types'; import { ICreditNotesQueryDTO } from './types/CreditNotes.types';
@@ -22,17 +25,23 @@ import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto';
import { CreditNoteResponseDto } from './dtos/CreditNoteResponse.dto'; import { CreditNoteResponseDto } from './dtos/CreditNoteResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
import { AcceptType } from '@/constants/accept-type';
@Controller('credit-notes') @Controller('credit-notes')
@ApiTags('Credit Notes') @ApiTags('Credit Notes')
@ApiExtraModels(CreditNoteResponseDto) @ApiExtraModels(CreditNoteResponseDto)
@ApiExtraModels(PaginatedResponseDto) @ApiExtraModels(PaginatedResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders() @ApiCommonHeaders()
export class CreditNotesController { export class CreditNotesController {
/** /**
* @param {CreditNoteApplication} creditNoteApplication - The credit note application service. * @param {CreditNoteApplication} creditNoteApplication - The credit note application service.
*/ */
constructor(private creditNoteApplication: CreditNoteApplication) {} constructor(private creditNoteApplication: CreditNoteApplication) { }
@Post() @Post()
@ApiOperation({ summary: 'Create a new credit note' }) @ApiOperation({ summary: 'Create a new credit note' })
@@ -60,8 +69,26 @@ export class CreditNotesController {
}, },
}) })
@ApiResponse({ status: 404, description: 'Credit note not found' }) @ApiResponse({ status: 404, description: 'Credit note not found' })
getCreditNote(@Param('id') creditNoteId: number) { async getCreditNote(
return this.creditNoteApplication.getCreditNote(creditNoteId); @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() @Get()
@@ -112,6 +139,39 @@ export class CreditNotesController {
return this.creditNoteApplication.openCreditNote(creditNoteId); 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') @Delete(':id')
@ApiOperation({ summary: 'Delete a credit note' }) @ApiOperation({ summary: 'Delete a credit note' })
@ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' }) @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 { CreditNoteInventoryTransactionsSubscriber } from './subscribers/CreditNoteInventoryTransactionsSubscriber';
import { RefundSyncCreditNoteBalanceSubscriber } from './subscribers/RefundSyncCreditNoteBalanceSubscriber'; import { RefundSyncCreditNoteBalanceSubscriber } from './subscribers/RefundSyncCreditNoteBalanceSubscriber';
import { DeleteCustomerLinkedCreditSubscriber } from './subscribers/DeleteCustomerLinkedCreditSubscriber'; import { DeleteCustomerLinkedCreditSubscriber } from './subscribers/DeleteCustomerLinkedCreditSubscriber';
import { CreditNoteAutoSerialSubscriber } from './subscribers/CreditNoteAutoSerialSubscriber';
import { CreditNoteInventoryTransactions } from './commands/CreditNotesInventoryTransactions'; import { CreditNoteInventoryTransactions } from './commands/CreditNotesInventoryTransactions';
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module'; import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.module'; import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.module';
import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module'; import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module';
import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service';
import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service';
@Module({ @Module({
imports: [ imports: [
@@ -71,6 +74,9 @@ import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/Credit
CreditNoteInventoryTransactionsSubscriber, CreditNoteInventoryTransactionsSubscriber,
RefundSyncCreditNoteBalanceSubscriber, RefundSyncCreditNoteBalanceSubscriber,
DeleteCustomerLinkedCreditSubscriber, DeleteCustomerLinkedCreditSubscriber,
CreditNoteAutoSerialSubscriber,
BulkDeleteCreditNotesService,
ValidateBulkDeleteCreditNotesService,
], ],
exports: [ exports: [
CreateCreditNoteService, 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

@@ -10,6 +10,7 @@ import { BranchTransactionDTOTransformer } from '@/modules/Branches/integrations
import { WarehouseTransactionDTOTransform } from '@/modules/Warehouses/Integrations/WarehouseTransactionDTOTransform'; import { WarehouseTransactionDTOTransform } from '@/modules/Warehouses/Integrations/WarehouseTransactionDTOTransform';
import { BrandingTemplateDTOTransformer } from '../../PdfTemplate/BrandingTemplateDTOTransformer'; import { BrandingTemplateDTOTransformer } from '../../PdfTemplate/BrandingTemplateDTOTransformer';
import { assocItemEntriesDefaultIndex } from '@/utils/associate-item-entries-index'; import { assocItemEntriesDefaultIndex } from '@/utils/associate-item-entries-index';
import { formatDateFields } from '@/utils/format-date-fields';
import { CreditNoteAutoIncrementService } from './CreditNoteAutoIncrement.service'; import { CreditNoteAutoIncrementService } from './CreditNoteAutoIncrement.service';
import { CreditNote } from '../models/CreditNote'; import { CreditNote } from '../models/CreditNote';
import { import {
@@ -33,7 +34,7 @@ export class CommandCreditNoteDTOTransform {
private readonly warehouseDTOTransform: WarehouseTransactionDTOTransform, private readonly warehouseDTOTransform: WarehouseTransactionDTOTransform,
private readonly brandingTemplatesTransformer: BrandingTemplateDTOTransformer, private readonly brandingTemplatesTransformer: BrandingTemplateDTOTransformer,
private readonly creditNoteAutoIncrement: CreditNoteAutoIncrementService, private readonly creditNoteAutoIncrement: CreditNoteAutoIncrementService,
) {} ) { }
/** /**
* Transforms the credit/edit DTO to model. * Transforms the credit/edit DTO to model.
@@ -70,7 +71,10 @@ export class CommandCreditNoteDTOTransform {
autoNextNumber; autoNextNumber;
const initialDTO = { const initialDTO = {
...omit(creditNoteDTO, ['open', 'attachments']), ...formatDateFields(
omit(creditNoteDTO, ['open', 'attachments']),
['creditNoteDate'],
),
creditNoteNumber, creditNoteNumber,
amount, amount,
currencyCode: customerCurrencyCode, currencyCode: customerCurrencyCode,
@@ -78,8 +82,8 @@ export class CommandCreditNoteDTOTransform {
entries, entries,
...(creditNoteDTO.open && ...(creditNoteDTO.open &&
!oldCreditNote?.openedAt && { !oldCreditNote?.openedAt && {
openedAt: moment().toMySqlDateTime(), openedAt: moment().toMySqlDateTime(),
}), }),
refundedAmount: 0, refundedAmount: 0,
invoicesAmount: 0, invoicesAmount: 0,
}; };

View File

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

View File

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

View File

@@ -10,8 +10,8 @@ export const InitialCurrencies = [
]; ];
export const ERRORS = { export const ERRORS = {
CURRENCY_NOT_FOUND: 'currency_not_found', CURRENCY_NOT_FOUND: 'CURRENCY_NOT_FOUND',
CURRENCY_CODE_EXISTS: 'currency_code_exists', CURRENCY_CODE_EXISTS: 'CURRENCY_CODE_EXISTS',
BASE_CURRENCY_INVALID: 'BASE_CURRENCY_INVALID', BASE_CURRENCY_INVALID: 'BASE_CURRENCY_INVALID',
CANNOT_DELETE_BASE_CURRENCY: 'CANNOT_DELETE_BASE_CURRENCY', 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

@@ -24,6 +24,10 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
import { EditCustomerDto } from './dtos/EditCustomer.dto'; import { EditCustomerDto } from './dtos/EditCustomer.dto';
import { CustomerResponseDto } from './dtos/CustomerResponse.dto'; import { CustomerResponseDto } from './dtos/CustomerResponse.dto';
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto'; import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
import {
BulkDeleteCustomersDto,
ValidateBulkDeleteCustomersResponseDto,
} from './dtos/BulkDeleteCustomers.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('customers') @Controller('customers')
@@ -31,7 +35,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@ApiExtraModels(CustomerResponseDto) @ApiExtraModels(CustomerResponseDto)
@ApiCommonHeaders() @ApiCommonHeaders()
export class CustomersController { export class CustomersController {
constructor(private customersApplication: CustomersApplication) {} constructor(private customersApplication: CustomersApplication) { }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Retrieves the customer details.' }) @ApiOperation({ summary: 'Retrieves the customer details.' })
@@ -109,4 +113,37 @@ export class CustomersController {
openingBalanceDTO, 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,6 +16,8 @@ import { CustomersExportable } from './CustomersExportable';
import { CustomersImportable } from './CustomersImportable'; import { CustomersImportable } from './CustomersImportable';
import { GetCustomers } from './queries/GetCustomers.service'; import { GetCustomers } from './queries/GetCustomers.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
@Module({ @Module({
imports: [TenancyDatabaseModule, DynamicListModule], imports: [TenancyDatabaseModule, DynamicListModule],
@@ -37,6 +39,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
CustomersExportable, CustomersExportable,
CustomersImportable, CustomersImportable,
GetCustomers, GetCustomers,
BulkDeleteCustomersService,
ValidateBulkDeleteCustomersService,
], ],
}) })
export class CustomersModule {} export class CustomersModule {}

View File

@@ -12,6 +12,8 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
import { EditCustomerDto } from './dtos/EditCustomer.dto'; import { EditCustomerDto } from './dtos/EditCustomer.dto';
import { GetCustomers } from './queries/GetCustomers.service'; import { GetCustomers } from './queries/GetCustomers.service';
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto'; import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
@Injectable() @Injectable()
export class CustomersApplication { export class CustomersApplication {
@@ -22,6 +24,8 @@ export class CustomersApplication {
private deleteCustomerService: DeleteCustomer, private deleteCustomerService: DeleteCustomer,
private editOpeningBalanceService: EditOpeningBalanceCustomer, private editOpeningBalanceService: EditOpeningBalanceCustomer,
private getCustomersService: GetCustomers, private getCustomersService: GetCustomers,
private readonly bulkDeleteCustomersService: BulkDeleteCustomersService,
private readonly validateBulkDeleteCustomersService: ValidateBulkDeleteCustomersService,
) {} ) {}
/** /**
@@ -83,4 +87,20 @@ export class CustomersApplication {
public getCustomers = (filterDTO: GetCustomersQueryDto) => { public getCustomers = (filterDTO: GetCustomersQueryDto) => {
return this.getCustomersService.getCustomersList(filterDTO); 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,12 +31,13 @@ export class DeleteCustomer {
* @param {number} customerId - Customer ID. * @param {number} customerId - Customer ID.
* @return {Promise<void>} * @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. // Retrieve the customer or throw not found service error.
const oldCustomer = await this.customerModel() const query = this.customerModel().query(trx);
.query() const oldCustomer = await query.findById(customerId).throwIfNotFound();
.findById(customerId)
.throwIfNotFound();
// Triggers `onCustomerDeleting` event. // Triggers `onCustomerDeleting` event.
await this.eventPublisher.emitAsync(events.customers.onDeleting, { await this.eventPublisher.emitAsync(events.customers.onDeleting, {
@@ -45,10 +46,10 @@ export class DeleteCustomer {
} as ICustomerDeletingPayload); } as ICustomerDeletingPayload);
// Deletes the customer and associated entities under UOW transaction. // 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. // Delete the customer from the storage.
await this.customerModel() await this.customerModel()
.query(trx) .query(transaction)
.findById(customerId) .findById(customerId)
.deleteIfNoRelations({ .deleteIfNoRelations({
type: ERRORS.CUSTOMER_HAS_TRANSACTIONS, type: ERRORS.CUSTOMER_HAS_TRANSACTIONS,
@@ -58,8 +59,8 @@ export class DeleteCustomer {
await this.eventPublisher.emitAsync(events.customers.onDeleted, { await this.eventPublisher.emitAsync(events.customers.onDeleted, {
customerId, customerId,
oldCustomer, oldCustomer,
trx, trx: transaction,
} as ICustomerEventDeletedPayload); } as ICustomerEventDeletedPayload);
}); }, trx);
} }
} }

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

@@ -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 { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ExpenseResponseDto } from './dtos/ExpenseResponse.dto'; import { ExpenseResponseDto } from './dtos/ExpenseResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('expenses') @Controller('expenses')
@ApiTags('Expenses') @ApiTags('Expenses')
@ApiExtraModels(PaginatedResponseDto, ExpenseResponseDto) @ApiExtraModels(
PaginatedResponseDto,
ExpenseResponseDto,
ValidateBulkDeleteResponseDto,
)
@ApiCommonHeaders() @ApiCommonHeaders()
export class ExpensesController { 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. * Create a new expense transaction.

View File

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

View File

@@ -7,6 +7,8 @@ import { GetExpenseService } from './queries/GetExpense.service';
import { IExpensesFilter } from './interfaces/Expenses.interface'; import { IExpensesFilter } from './interfaces/Expenses.interface';
import { GetExpensesService } from './queries/GetExpenses.service'; import { GetExpensesService } from './queries/GetExpenses.service';
import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto'; import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service';
import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service';
@Injectable() @Injectable()
export class ExpensesApplication { export class ExpensesApplication {
@@ -17,7 +19,9 @@ export class ExpensesApplication {
private readonly publishExpenseService: PublishExpense, private readonly publishExpenseService: PublishExpense,
private readonly getExpenseService: GetExpenseService, private readonly getExpenseService: GetExpenseService,
private readonly getExpensesService: GetExpensesService, private readonly getExpensesService: GetExpensesService,
) {} private readonly bulkDeleteExpensesService: BulkDeleteExpensesService,
private readonly validateBulkDeleteExpensesService: ValidateBulkDeleteExpensesService,
) { }
/** /**
* Create a new expense transaction. * Create a new expense transaction.
@@ -47,6 +51,30 @@ export class ExpensesApplication {
return this.deleteExpenseService.deleteExpense(expenseId); 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. * Publishes the given expense.
* @param {number} expenseId - Expense id. * @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. * Deletes the given expense.
* @param {number} expenseId * @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 // Retrieves the expense transaction with associated entries or
// throw not found error. // throw not found error.
const oldExpense = await this.expenseModel() const oldExpense = await this.expenseModel()
@@ -74,6 +77,6 @@ export class DeleteExpense {
oldExpense, oldExpense,
trx, trx,
} as IExpenseEventDeletePayload); } as IExpenseEventDeletePayload);
}); }, trx);
} }
} }

View File

@@ -185,5 +185,5 @@ export class CommandExpenseDto {
attachments?: AttachmentDto[]; attachments?: AttachmentDto[];
} }
export class CreateExpenseDto extends CommandExpenseDto {} export class CreateExpenseDto extends CommandExpenseDto { }
export class EditExpenseDto extends CommandExpenseDto {} export class EditExpenseDto extends CommandExpenseDto { }

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service';
@Injectable()
export class BulkDeleteItemCategoriesService {
constructor(
private readonly deleteItemCategoryService: DeleteItemCategoryService,
) { }
async bulkDeleteItemCategories(
itemCategoryIds: number | Array<number>,
trx?: Knex.Transaction,
): Promise<void> {
const categoriesIds = uniq(castArray(itemCategoryIds));
const results = await PromisePool.withConcurrency(1)
.for(categoriesIds)
.process(async (itemCategoryId: number) => {
await this.deleteItemCategoryService.deleteItemCategory(itemCategoryId);
});
if (results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

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 { DeleteItemCategoryService } from './commands/DeleteItemCategory.service';
@Injectable()
export class ValidateBulkDeleteItemCategoriesService {
constructor(
private readonly deleteItemCategoryService: DeleteItemCategoryService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
public async validateBulkDeleteItemCategories(itemCategoryIds: 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 itemCategoryId of itemCategoryIds) {
try {
await this.deleteItemCategoryService.deleteItemCategory(
itemCategoryId,
trx,
);
deletableIds.push(itemCategoryId);
} catch (error) {
nonDeletableIds.push(itemCategoryId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -32,9 +32,13 @@ export class DeleteItemCategoryService {
/** /**
* Deletes the given item category. * Deletes the given item category.
* @param {number} itemCategoryId - Item category id. * @param {number} itemCategoryId - Item category id.
* @param {Knex.Transaction} trx - Database transaction instance.
* @return {Promise<void>} * @return {Promise<void>}
*/ */
public async deleteItemCategory(itemCategoryId: number) { public async deleteItemCategory(
itemCategoryId: number,
trx?: Knex.Transaction,
) {
// Retrieve item category or throw not found error. // Retrieve item category or throw not found error.
const oldItemCategory = await this.itemCategoryModel() const oldItemCategory = await this.itemCategoryModel()
.query() .query()
@@ -56,7 +60,7 @@ export class DeleteItemCategoryService {
itemCategoryId, itemCategoryId,
oldItemCategory, oldItemCategory,
} as IItemCategoryDeletedPayload); } as IItemCategoryDeletedPayload);
}); }, trx);
} }
/** /**

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 { DeleteItemService } from './DeleteItem.service';
@Injectable()
export class BulkDeleteItemsService {
constructor(private readonly deleteItemService: DeleteItemService) {}
/**
* Deletes multiple items.
* @param {number | Array<number>} itemIds - The item id or ids.
* @param {Knex.Transaction} trx - The transaction.
*/
async bulkDeleteItems(
itemIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const itemsIds = uniq(castArray(itemIds));
const results = await PromisePool.withConcurrency(1)
.for(itemsIds)
.process(async (itemId: number) => {
try {
await this.deleteItemService.deleteItem(itemId, trx);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw ?? results.errors[0];
}
}
}

View File

@@ -29,6 +29,10 @@ import { ItemEstimatesResponseDto } from './dtos/ItemEstimatesResponse.dto';
import { ItemBillsResponseDto } from './dtos/ItemBillsResponse.dto'; import { ItemBillsResponseDto } from './dtos/ItemBillsResponse.dto';
import { ItemReceiptsResponseDto } from './dtos/ItemReceiptsResponse.dto'; import { ItemReceiptsResponseDto } from './dtos/ItemReceiptsResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteItemsDto,
ValidateBulkDeleteItemsResponseDto,
} from './dtos/BulkDeleteItems.dto';
@Controller('/items') @Controller('/items')
@ApiTags('Items') @ApiTags('Items')
@@ -39,6 +43,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@ApiExtraModels(ItemBillsResponseDto) @ApiExtraModels(ItemBillsResponseDto)
@ApiExtraModels(ItemEstimatesResponseDto) @ApiExtraModels(ItemEstimatesResponseDto)
@ApiExtraModels(ItemReceiptsResponseDto) @ApiExtraModels(ItemReceiptsResponseDto)
@ApiExtraModels(ValidateBulkDeleteItemsResponseDto)
@ApiCommonHeaders() @ApiCommonHeaders()
export class ItemsController extends TenantController { export class ItemsController extends TenantController {
constructor(private readonly itemsApplication: ItemsApplicationService) { constructor(private readonly itemsApplication: ItemsApplicationService) {
@@ -340,4 +345,37 @@ export class ItemsController extends TenantController {
const itemId = parseInt(id, 10); const itemId = parseInt(id, 10);
return this.itemsApplication.getItemReceiptsTransactions(itemId); return this.itemsApplication.getItemReceiptsTransactions(itemId);
} }
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which items can be deleted and returns counts of deletable and non-deletable items.',
})
@ApiResponse({
status: 200,
description:
'Validation completed. Returns counts and IDs of deletable and non-deletable items.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteItemsResponseDto),
},
})
async validateBulkDeleteItems(
@Body() bulkDeleteDto: BulkDeleteItemsDto,
): Promise<ValidateBulkDeleteItemsResponseDto> {
return this.itemsApplication.validateBulkDeleteItems(bulkDeleteDto.ids);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple items in bulk.' })
@ApiResponse({
status: 200,
description: 'The items have been successfully deleted.',
})
async bulkDeleteItems(
@Body() bulkDeleteDto: BulkDeleteItemsDto,
): Promise<void> {
return this.itemsApplication.bulkDeleteItems(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
} }

View File

@@ -18,6 +18,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { InventoryAdjustmentsModule } from '../InventoryAdjutments/InventoryAdjustments.module'; import { InventoryAdjustmentsModule } from '../InventoryAdjutments/InventoryAdjustments.module';
import { ItemsExportable } from './ItemsExportable.service'; import { ItemsExportable } from './ItemsExportable.service';
import { ItemsImportable } from './ItemsImportable.service'; import { ItemsImportable } from './ItemsImportable.service';
import { BulkDeleteItemsService } from './BulkDeleteItems.service';
import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service';
@Module({ @Module({
imports: [ imports: [
@@ -41,8 +43,10 @@ import { ItemsImportable } from './ItemsImportable.service';
TransformerInjectable, TransformerInjectable,
ItemsEntriesService, ItemsEntriesService,
ItemsExportable, ItemsExportable,
ItemsImportable ItemsImportable,
BulkDeleteItemsService,
ValidateBulkDeleteItemsService,
], ],
exports: [ItemsEntriesService, ItemsExportable, ItemsImportable], exports: [ItemsEntriesService, ItemsExportable, ItemsImportable],
}) })
export class ItemsModule {} export class ItemsModule { }

View File

@@ -13,6 +13,8 @@ import { GetItemsService } from './GetItems.service';
import { IItemsFilter } from './types/Items.types'; import { IItemsFilter } from './types/Items.types';
import { EditItemDto, CreateItemDto } from './dtos/Item.dto'; import { EditItemDto, CreateItemDto } from './dtos/Item.dto';
import { GetItemsQueryDto } from './dtos/GetItemsQuery.dto'; import { GetItemsQueryDto } from './dtos/GetItemsQuery.dto';
import { BulkDeleteItemsService } from './BulkDeleteItems.service';
import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service';
@Injectable() @Injectable()
export class ItemsApplicationService { export class ItemsApplicationService {
@@ -25,7 +27,9 @@ export class ItemsApplicationService {
private readonly getItemService: GetItemService, private readonly getItemService: GetItemService,
private readonly getItemsService: GetItemsService, private readonly getItemsService: GetItemsService,
private readonly itemTransactionsService: ItemTransactionsService, private readonly itemTransactionsService: ItemTransactionsService,
) {} private readonly bulkDeleteItemsService: BulkDeleteItemsService,
private readonly validateBulkDeleteItemsService: ValidateBulkDeleteItemsService,
) { }
/** /**
* Creates a new item. * Creates a new item.
@@ -134,4 +138,30 @@ export class ItemsApplicationService {
async getItemReceiptsTransactions(itemId: number): Promise<any> { async getItemReceiptsTransactions(itemId: number): Promise<any> {
return this.itemTransactionsService.getItemReceiptTransactions(itemId); return this.itemTransactionsService.getItemReceiptTransactions(itemId);
} }
/**
* Validates which items can be deleted in bulk.
* @param {number[]} itemIds - Array of item IDs to validate
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
*/
async validateBulkDeleteItems(itemIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
return this.validateBulkDeleteItemsService.validateBulkDeleteItems(itemIds);
}
/**
* Deletes multiple items in bulk.
* @param {number[]} itemIds - Array of item IDs to delete
* @returns {Promise<void>}
*/
async bulkDeleteItems(
itemIds: number[],
options?: { skipUndeletable?: boolean },
): Promise<void> {
return this.bulkDeleteItemsService.bulkDeleteItems(itemIds, options);
}
} }

View File

@@ -0,0 +1,74 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteItemService } from './DeleteItem.service';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
@Injectable()
export class ValidateBulkDeleteItemsService {
constructor(
private readonly deleteItemService: DeleteItemService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
/**
* Validates which items from the provided IDs can be deleted.
* Uses the actual deleteItem service to validate, ensuring the same validation logic.
* Uses a transaction that is always rolled back to ensure no database changes.
* @param {number[]} itemIds - Array of item IDs to validate
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
*/
public async validateBulkDeleteItems(itemIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
// Create a transaction that will be rolled back
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
// Check each item to see if it can be deleted by attempting deletion in transaction
for (const itemId of itemIds) {
try {
// Attempt to delete the item using the deleteItem service with the transaction
// This will use the exact same validation logic as the actual delete
await this.deleteItemService.deleteItem(itemId, trx);
// If deletion succeeds, item is deletable
deletableIds.push(itemId);
} catch (error) {
// If error occurs, check the type of error
if (error instanceof ModelHasRelationsError) {
// Item has associated transactions/relations, cannot be deleted
nonDeletableIds.push(itemId);
} else {
// Other errors (e.g., item not found), also mark as non-deletable
nonDeletableIds.push(itemId);
}
}
}
// Always rollback the transaction to ensure no changes are persisted
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
// Rollback in case of any error
await trx.rollback();
throw error;
}
}
}

View File

@@ -0,0 +1,62 @@
import {
IsArray,
IsInt,
ArrayNotEmpty,
IsOptional,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
export class BulkDeleteItemsDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({
description: 'Array of item 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 removed.',
type: Boolean,
default: false,
})
skipUndeletable?: boolean;
}
export class ValidateBulkDeleteItemsResponseDto {
@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

@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteManualJournalService } from './commands/DeleteManualJournal.service';
@Injectable()
export class BulkDeleteManualJournalsService {
constructor(
private readonly deleteManualJournalService: DeleteManualJournalService,
) { }
async bulkDeleteManualJournals(
manualJournalIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const journalsIds = uniq(castArray(manualJournalIds));
const results = await PromisePool.withConcurrency(1)
.for(journalsIds)
.process(async (manualJournalId: number) => {
try {
await this.deleteManualJournalService.deleteManualJournal(
manualJournalId,
);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -25,13 +25,54 @@ import {
import { IManualJournalsFilter } from './types/ManualJournals.types'; import { IManualJournalsFilter } from './types/ManualJournals.types';
import { ManualJournalResponseDto } from './dtos/ManualJournalResponse.dto'; import { ManualJournalResponseDto } from './dtos/ManualJournalResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('manual-journals') @Controller('manual-journals')
@ApiTags('Manual Journals') @ApiTags('Manual Journals')
@ApiExtraModels(ManualJournalResponseDto) @ApiExtraModels(ManualJournalResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders() @ApiCommonHeaders()
export class ManualJournalsController { export class ManualJournalsController {
constructor(private manualJournalsApplication: ManualJournalsApplication) {} constructor(private manualJournalsApplication: ManualJournalsApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validate which manual journals can be deleted and return the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable manual journals.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
public validateBulkDeleteManualJournals(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.manualJournalsApplication.validateBulkDeleteManualJournals(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple manual journals.' })
@ApiResponse({
status: 200,
description: 'Manual journals deleted successfully',
})
public bulkDeleteManualJournals(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.manualJournalsApplication.bulkDeleteManualJournals(
bulkDeleteDto.ids,
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
);
}
@Post() @Post()
@ApiOperation({ summary: 'Create a new manual journal.' }) @ApiOperation({ summary: 'Create a new manual journal.' })

View File

@@ -19,6 +19,8 @@ import { ManualJournalsExportable } from './commands/ManualJournalExportable';
import { ManualJournalImportable } from './commands/ManualJournalsImport'; import { ManualJournalImportable } from './commands/ManualJournalsImport';
import { GetManualJournals } from './queries/GetManualJournals.service'; import { GetManualJournals } from './queries/GetManualJournals.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { BulkDeleteManualJournalsService } from './BulkDeleteManualJournals.service';
import { ValidateBulkDeleteManualJournalsService } from './ValidateBulkDeleteManualJournals.service';
@Module({ @Module({
imports: [BranchesModule, LedgerModule, DynamicListModule], imports: [BranchesModule, LedgerModule, DynamicListModule],
@@ -41,6 +43,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
ManualJournalWriteGLSubscriber, ManualJournalWriteGLSubscriber,
ManualJournalsExportable, ManualJournalsExportable,
ManualJournalImportable, ManualJournalImportable,
BulkDeleteManualJournalsService,
ValidateBulkDeleteManualJournalsService,
], ],
exports: [ManualJournalsExportable, ManualJournalImportable], exports: [ManualJournalsExportable, ManualJournalImportable],
}) })

View File

@@ -10,6 +10,8 @@ import {
EditManualJournalDto, EditManualJournalDto,
} from './dtos/ManualJournal.dto'; } from './dtos/ManualJournal.dto';
import { GetManualJournals } from './queries/GetManualJournals.service'; import { GetManualJournals } from './queries/GetManualJournals.service';
import { BulkDeleteManualJournalsService } from './BulkDeleteManualJournals.service';
import { ValidateBulkDeleteManualJournalsService } from './ValidateBulkDeleteManualJournals.service';
// import { GetManualJournals } from './queries/GetManualJournals'; // import { GetManualJournals } from './queries/GetManualJournals';
@Injectable() @Injectable()
@@ -21,7 +23,9 @@ export class ManualJournalsApplication {
private publishManualJournalService: PublishManualJournal, private publishManualJournalService: PublishManualJournal,
private getManualJournalService: GetManualJournal, private getManualJournalService: GetManualJournal,
private getManualJournalsService: GetManualJournals, private getManualJournalsService: GetManualJournals,
) {} private bulkDeleteManualJournalsService: BulkDeleteManualJournalsService,
private validateBulkDeleteManualJournalsService: ValidateBulkDeleteManualJournalsService,
) { }
/** /**
* Make journal entries. * Make journal entries.
@@ -57,6 +61,30 @@ export class ManualJournalsApplication {
return this.deleteManualJournalService.deleteManualJournal(manualJournalId); return this.deleteManualJournalService.deleteManualJournal(manualJournalId);
}; };
/**
* Bulk deletes manual journals.
* @param {number[]} manualJournalIds
*/
public bulkDeleteManualJournals = (
manualJournalIds: number[],
options?: { skipUndeletable?: boolean },
) => {
return this.bulkDeleteManualJournalsService.bulkDeleteManualJournals(
manualJournalIds,
options,
);
};
/**
* Validates which manual journals can be deleted.
* @param {number[]} manualJournalIds
*/
public validateBulkDeleteManualJournals = (manualJournalIds: number[]) => {
return this.validateBulkDeleteManualJournalsService.validateBulkDeleteManualJournals(
manualJournalIds,
);
};
/** /**
* Publish the given manual journal. * Publish the given manual journal.
* @param {number} manualJournalId - Manual journal id. * @param {number} manualJournalId - Manual journal id.

View File

@@ -0,0 +1,56 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteManualJournalService } from './commands/DeleteManualJournal.service';
@Injectable()
export class ValidateBulkDeleteManualJournalsService {
constructor(
private readonly deleteManualJournalService: DeleteManualJournalService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
public async validateBulkDeleteManualJournals(
manualJournalIds: 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 manualJournalId of manualJournalIds) {
try {
await this.deleteManualJournalService.deleteManualJournal(
manualJournalId,
trx,
);
deletableIds.push(manualJournalId);
} catch (error) {
nonDeletableIds.push(manualJournalId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -29,10 +29,12 @@ export class DeleteManualJournalService {
/** /**
* Deletes the given manual journal * Deletes the given manual journal
* @param {number} manualJournalId * @param {number} manualJournalId
* @param {Knex.Transaction} trx - Database transaction instance.
* @return {Promise<void>} * @return {Promise<void>}
*/ */
public deleteManualJournal = async ( public deleteManualJournal = async (
manualJournalId: number, manualJournalId: number,
trx?: Knex.Transaction,
): Promise<{ ): Promise<{
oldManualJournal: ManualJournal; oldManualJournal: ManualJournal;
}> => { }> => {
@@ -70,6 +72,6 @@ export class DeleteManualJournalService {
} as IManualJournalEventDeletedPayload); } as IManualJournalEventDeletedPayload);
return { oldManualJournal }; return { oldManualJournal };
}); }, trx);
}; };
} }

View File

@@ -26,6 +26,7 @@ import { GetCurrentOrganizationService } from './queries/GetCurrentOrganization.
import { UpdateOrganizationService } from './commands/UpdateOrganization.service'; import { UpdateOrganizationService } from './commands/UpdateOrganization.service';
import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard'; import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard';
import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards'; import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards';
import { IgnoreTenantModelsInitialize } from '../Tenancy/TenancyInitializeModels.guard';
import { GetBuildOrganizationBuildJob } from './commands/GetBuildOrganizationJob.service'; import { GetBuildOrganizationBuildJob } from './commands/GetBuildOrganizationJob.service';
import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBaseCurrencyLocking.service'; import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBaseCurrencyLocking.service';
import { import {
@@ -39,6 +40,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('organization') @Controller('organization')
@IgnoreTenantInitializedRoute() @IgnoreTenantInitializedRoute()
@IgnoreTenantSeededRoute() @IgnoreTenantSeededRoute()
@IgnoreTenantModelsInitialize()
@ApiExtraModels(GetCurrentOrganizationResponseDto) @ApiExtraModels(GetCurrentOrganizationResponseDto)
@ApiCommonHeaders() @ApiCommonHeaders()
export class OrganizationController { export class OrganizationController {
@@ -48,7 +50,7 @@ export class OrganizationController {
private readonly updateOrganizationService: UpdateOrganizationService, private readonly updateOrganizationService: UpdateOrganizationService,
private readonly getBuildOrganizationJobService: GetBuildOrganizationBuildJob, private readonly getBuildOrganizationJobService: GetBuildOrganizationBuildJob,
private readonly orgBaseCurrencyLockingService: OrganizationBaseCurrencyLocking, private readonly orgBaseCurrencyLockingService: OrganizationBaseCurrencyLocking,
) {} ) { }
@Post('build') @Post('build')
@HttpCode(200) @HttpCode(200)

View File

@@ -1,5 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { Controller, Get, Param, Res } from '@nestjs/common'; import { Controller, Get, Param, Post, Res } from '@nestjs/common';
import { PaymentLinksApplication } from './PaymentLinksApplication'; import { PaymentLinksApplication } from './PaymentLinksApplication';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@@ -40,7 +40,7 @@ export class PaymentLinksController {
return { data }; return { data };
} }
@Get('/:paymentLinkId/stripe_checkout_session') @Post('/:paymentLinkId/stripe_checkout_session')
@ApiOperation({ @ApiOperation({
summary: 'Create Stripe checkout session', summary: 'Create Stripe checkout session',
description: description:

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeletePaymentReceivedService } from './commands/DeletePaymentReceived.service';
@Injectable()
export class BulkDeletePaymentReceivedService {
constructor(
private readonly deletePaymentReceivedService: DeletePaymentReceivedService,
) { }
async bulkDeletePaymentReceived(
paymentReceiveIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const paymentsIds = uniq(castArray(paymentReceiveIds));
const results = await PromisePool.withConcurrency(1)
.for(paymentsIds)
.process(async (paymentReceiveId: number) => {
try {
await this.deletePaymentReceivedService.deletePaymentReceive(
paymentReceiveId,
);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -18,6 +18,8 @@ import {
} from './dtos/PaymentReceived.dto'; } from './dtos/PaymentReceived.dto';
import { PaymentsReceivedPagesService } from './queries/PaymentsReceivedPages.service'; import { PaymentsReceivedPagesService } from './queries/PaymentsReceivedPages.service';
import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service'; import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service';
import { BulkDeletePaymentReceivedService } from './BulkDeletePaymentReceived.service';
import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePaymentReceived.service';
@Injectable() @Injectable()
export class PaymentReceivesApplication { export class PaymentReceivesApplication {
@@ -33,7 +35,9 @@ export class PaymentReceivesApplication {
private getPaymentReceivePdfService: GetPaymentReceivedPdfService, private getPaymentReceivePdfService: GetPaymentReceivedPdfService,
private getPaymentReceivedStateService: GetPaymentReceivedStateService, private getPaymentReceivedStateService: GetPaymentReceivedStateService,
private paymentsReceivedPagesService: PaymentsReceivedPagesService, private paymentsReceivedPagesService: PaymentsReceivedPagesService,
) {} private bulkDeletePaymentReceivedService: BulkDeletePaymentReceivedService,
private validateBulkDeletePaymentReceivedService: ValidateBulkDeletePaymentReceivedService,
) { }
/** /**
* Creates a new payment receive. * Creates a new payment receive.
@@ -73,6 +77,29 @@ export class PaymentReceivesApplication {
); );
} }
/**
* Deletes multiple payment receives.
* @param {number[]} paymentReceiveIds
*/
public bulkDeletePaymentReceives(
paymentReceiveIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeletePaymentReceivedService.bulkDeletePaymentReceived(
paymentReceiveIds,
options,
);
}
/**
* Validates which payment receives can be deleted.
* @param {number[]} paymentReceiveIds
*/
public validateBulkDeletePaymentReceives(paymentReceiveIds: number[]) {
return this.validateBulkDeletePaymentReceivedService
.validateBulkDeletePaymentReceived(paymentReceiveIds);
}
/** /**
* Retrieve payment receives paginated and filterable. * Retrieve payment receives paginated and filterable.
* @param {number} tenantId * @param {number} tenantId

View File

@@ -1,3 +1,4 @@
import { Response } from 'express';
import { import {
ApiExtraModels, ApiExtraModels,
ApiOperation, ApiOperation,
@@ -17,6 +18,7 @@ import {
Post, Post,
Put, Put,
Query, Query,
Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { PaymentReceivesApplication } from './PaymentReceived.application'; import { PaymentReceivesApplication } from './PaymentReceived.application';
import { import {
@@ -32,15 +34,20 @@ import { PaymentReceivedResponseDto } from './dtos/PaymentReceivedResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { PaymentReceivedStateResponseDto } from './dtos/PaymentReceivedStateResponse.dto'; import { PaymentReceivedStateResponseDto } from './dtos/PaymentReceivedStateResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('payments-received') @Controller('payments-received')
@ApiTags('Payments Received') @ApiTags('Payments Received')
@ApiExtraModels(PaymentReceivedResponseDto) @ApiExtraModels(PaymentReceivedResponseDto)
@ApiExtraModels(PaginatedResponseDto) @ApiExtraModels(PaginatedResponseDto)
@ApiExtraModels(PaymentReceivedStateResponseDto) @ApiExtraModels(PaymentReceivedStateResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders() @ApiCommonHeaders()
export class PaymentReceivesController { export class PaymentReceivesController {
constructor(private paymentReceivesApplication: PaymentReceivesApplication) {} constructor(private paymentReceivesApplication: PaymentReceivesApplication) { }
@Post(':id/mail') @Post(':id/mail')
@HttpCode(200) @HttpCode(200)
@@ -143,6 +150,42 @@ export class PaymentReceivesController {
return this.paymentReceivesApplication.getPaymentsReceived(filterDTO); return this.paymentReceivesApplication.getPaymentsReceived(filterDTO);
} }
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which payments received can be deleted and returns the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable payments received.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
public validateBulkDeletePaymentsReceived(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.paymentReceivesApplication.validateBulkDeletePaymentReceives(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple payments received.' })
@ApiResponse({
status: 200,
description: 'Payments received deleted successfully.',
})
public bulkDeletePaymentsReceived(
@Body() bulkDeleteDto: BulkDeleteDto,
) {
return this.paymentReceivesApplication.bulkDeletePaymentReceives(
bulkDeleteDto.ids,
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
);
}
@Get('state') @Get('state')
@ApiOperation({ summary: 'Retrieves the payment received state.' }) @ApiOperation({ summary: 'Retrieves the payment received state.' })
@ApiResponse({ @ApiResponse({
@@ -184,11 +227,18 @@ export class PaymentReceivesController {
public async getPaymentReceive( public async getPaymentReceive(
@Param('id', ParseIntPipe) paymentReceiveId: number, @Param('id', ParseIntPipe) paymentReceiveId: number,
@Headers('accept') acceptHeader: string, @Headers('accept') acceptHeader: string,
@Res({ passthrough: true }) res: Response,
) { ) {
if (acceptHeader.includes(AcceptType.ApplicationPdf)) { if (acceptHeader.includes(AcceptType.ApplicationPdf)) {
return this.paymentReceivesApplication.getPaymentReceivePdf( const [pdfContent, filename] = await this.paymentReceivesApplication.getPaymentReceivePdf(
paymentReceiveId, paymentReceiveId,
); );
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdfContent.length,
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.send(pdfContent);
} else if (acceptHeader.includes(AcceptType.ApplicationTextHtml)) { } else if (acceptHeader.includes(AcceptType.ApplicationTextHtml)) {
const htmlContent = const htmlContent =
await this.paymentReceivesApplication.getPaymentReceivedHtml( await this.paymentReceivesApplication.getPaymentReceivedHtml(

View File

@@ -39,6 +39,8 @@ import { PaymentsReceivedImportable } from './commands/PaymentsReceivedImportabl
import { PaymentsReceivedPagesService } from './queries/PaymentsReceivedPages.service'; import { PaymentsReceivedPagesService } from './queries/PaymentsReceivedPages.service';
import { GetPaymentReceivedMailTemplate } from './queries/GetPaymentReceivedMailTemplate.service'; import { GetPaymentReceivedMailTemplate } from './queries/GetPaymentReceivedMailTemplate.service';
import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service'; import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service';
import { BulkDeletePaymentReceivedService } from './BulkDeletePaymentReceived.service';
import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePaymentReceived.service';
@Module({ @Module({
controllers: [PaymentReceivesController], controllers: [PaymentReceivesController],
@@ -68,7 +70,9 @@ import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailSta
PaymentsReceivedImportable, PaymentsReceivedImportable,
PaymentsReceivedPagesService, PaymentsReceivedPagesService,
GetPaymentReceivedMailTemplate, GetPaymentReceivedMailTemplate,
GetPaymentReceivedMailState GetPaymentReceivedMailState,
BulkDeletePaymentReceivedService,
ValidateBulkDeletePaymentReceivedService,
], ],
exports: [ exports: [
PaymentReceivesApplication, PaymentReceivesApplication,
@@ -76,7 +80,7 @@ import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailSta
PaymentReceivedGLEntries, PaymentReceivedGLEntries,
PaymentsReceivedExportable, PaymentsReceivedExportable,
PaymentsReceivedImportable, PaymentsReceivedImportable,
PaymentReceivedValidators PaymentReceivedValidators,
], ],
imports: [ imports: [
ChromiumlyTenancyModule, ChromiumlyTenancyModule,

View File

@@ -0,0 +1,56 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeletePaymentReceivedService } from './commands/DeletePaymentReceived.service';
@Injectable()
export class ValidateBulkDeletePaymentReceivedService {
constructor(
private readonly deletePaymentReceivedService: DeletePaymentReceivedService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeletePaymentReceived(
paymentReceiveIds: 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 paymentReceiveId of paymentReceiveIds) {
try {
await this.deletePaymentReceivedService.deletePaymentReceive(
paymentReceiveId,
trx,
);
deletableIds.push(paymentReceiveId);
} catch (error) {
nonDeletableIds.push(paymentReceiveId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -30,7 +30,7 @@ export class DeletePaymentReceivedService {
private paymentReceiveEntryModel: TenantModelProxy< private paymentReceiveEntryModel: TenantModelProxy<
typeof PaymentReceivedEntry typeof PaymentReceivedEntry
>, >,
) {} ) { }
/** /**
* Deletes the given payment receive with associated entries * Deletes the given payment receive with associated entries
@@ -43,9 +43,12 @@ export class DeletePaymentReceivedService {
* - Revert the payment amount of the associated invoices. * - Revert the payment amount of the associated invoices.
* @async * @async
* @param {Integer} paymentReceiveId - Payment receive id. * @param {Integer} paymentReceiveId - Payment receive id.
* @param {IPaymentReceived} paymentReceive - Payment receive object. * @param {Knex.Transaction} trx - Database transaction instance.
*/ */
public async deletePaymentReceive(paymentReceiveId: number) { public async deletePaymentReceive(
paymentReceiveId: number,
trx?: Knex.Transaction,
) {
// Retreive payment receive or throw not found service error. // Retreive payment receive or throw not found service error.
const oldPaymentReceive = await this.paymentReceiveModel() const oldPaymentReceive = await this.paymentReceiveModel()
.query() .query()
@@ -79,6 +82,6 @@ export class DeletePaymentReceivedService {
oldPaymentReceive, oldPaymentReceive,
trx, trx,
} as IPaymentReceivedDeletedPayload); } as IPaymentReceivedDeletedPayload);
}); }, trx);
} }
} }

View File

@@ -18,7 +18,7 @@ export class PaymentsReceivedPagesService {
@Inject(PaymentReceived.name) @Inject(PaymentReceived.name)
private readonly paymentReceived: TenantModelProxy<typeof PaymentReceived>, private readonly paymentReceived: TenantModelProxy<typeof PaymentReceived>,
) {} ) { }
/** /**
* Retrive page invoices entries from the given sale invoices models. * Retrive page invoices entries from the given sale invoices models.
@@ -58,11 +58,10 @@ export class PaymentsReceivedPagesService {
/** /**
* Retrieve the payment receive details of the given id. * Retrieve the payment receive details of the given id.
* @param {number} tenantId - Tenant id. * @param {number} paymentReceiveId - Payment receive id.
* @param {Integer} paymentReceiveId - Payment receive id.
*/ */
public async getPaymentReceiveEditPage(paymentReceiveId: number): Promise<{ public async getPaymentReceiveEditPage(paymentReceiveId: number): Promise<{
paymentReceive: Omit<PaymentReceived, 'entries'>; data: Omit<PaymentReceived, 'entries'>;
entries: IPaymentReceivePageEntry[]; entries: IPaymentReceivePageEntry[];
}> { }> {
// Retrieve payment receive. // Retrieve payment receive.
@@ -100,7 +99,7 @@ export class PaymentsReceivedPagesService {
const entries = [...paymentEntries, ...restReceivableEntries]; const entries = [...paymentEntries, ...restReceivableEntries];
return { return {
paymentReceive: omit(paymentReceive, ['entries']), data: omit(paymentReceive, ['entries']),
entries, entries,
}; };
} }

View File

@@ -15,7 +15,7 @@ export class GetPaymentMethodsStateService {
private readonly paymentIntegrationModel: TenantModelProxy< private readonly paymentIntegrationModel: TenantModelProxy<
typeof PaymentIntegration typeof PaymentIntegration
>, >,
) {} ) { }
/** /**
* Retrieves the payment state provising state. * Retrieves the payment state provising state.

View File

@@ -7,6 +7,7 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { PdfTemplateApplication } from './PdfTemplate.application'; import { PdfTemplateApplication } from './PdfTemplate.application';
import { ICreateInvoicePdfTemplateDTO, IEditPdfTemplateDTO } from './types'; import { ICreateInvoicePdfTemplateDTO, IEditPdfTemplateDTO } from './types';
@@ -77,7 +78,7 @@ export class PdfTemplatesController {
status: 200, status: 200,
description: 'The PDF templates have been successfully retrieved.', description: 'The PDF templates have been successfully retrieved.',
}) })
async getPdfTemplates(@Body('resource') resource: string) { async getPdfTemplates(@Query('resource') resource?: string) {
return this.pdfTemplateApplication.getPdfTemplates({ resource }); return this.pdfTemplateApplication.getPdfTemplates({ resource });
} }

View File

@@ -17,12 +17,11 @@ import { TenantUser } from '../Tenancy/TenancyModels/models/TenantUser.model';
@Injectable() @Injectable()
export class AuthorizationGuard implements CanActivate { export class AuthorizationGuard implements CanActivate {
constructor( constructor(
private reflector: Reflector,
private readonly clsService: ClsService, private readonly clsService: ClsService,
@Inject(TenantUser.name) @Inject(TenantUser.name)
private readonly tenantUserModel: TenantModelProxy<typeof TenantUser>, private readonly tenantUserModel: TenantModelProxy<typeof TenantUser>,
) {} ) { }
/** /**
* Checks if the user has the required abilities to access the route * Checks if the user has the required abilities to access the route
@@ -31,7 +30,7 @@ export class AuthorizationGuard implements CanActivate {
*/ */
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
const { tenantId, user } = request as any; const { user } = request as any;
if (ABILITIES_CACHE.has(user.id)) { if (ABILITIES_CACHE.has(user.id)) {
(request as any).ability = ABILITIES_CACHE.get(user.id); (request as any).ability = ABILITIES_CACHE.get(user.id);
@@ -40,7 +39,6 @@ export class AuthorizationGuard implements CanActivate {
(request as any).ability = ability; (request as any).ability = ability;
ABILITIES_CACHE.set(user.id, ability); ABILITIES_CACHE.set(user.id, ability);
} }
return true; return true;
} }

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 { DeleteSaleEstimate } from './commands/DeleteSaleEstimate.service';
@Injectable()
export class BulkDeleteSaleEstimatesService {
constructor(private readonly deleteSaleEstimateService: DeleteSaleEstimate) { }
async bulkDeleteSaleEstimates(
saleEstimateIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const estimatesIds = uniq(castArray(saleEstimateIds));
const results = await PromisePool.withConcurrency(1)
.for(estimatesIds)
.process(async (saleEstimateId: number) => {
try {
await this.deleteSaleEstimateService.deleteEstimate(saleEstimateId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -19,6 +19,8 @@ import {
EditSaleEstimateDto, EditSaleEstimateDto,
} from './dtos/SaleEstimate.dto'; } from './dtos/SaleEstimate.dto';
import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service'; import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service';
import { BulkDeleteSaleEstimatesService } from './BulkDeleteSaleEstimates.service';
import { ValidateBulkDeleteSaleEstimatesService } from './ValidateBulkDeleteSaleEstimates.service';
@Injectable() @Injectable()
export class SaleEstimatesApplication { export class SaleEstimatesApplication {
@@ -35,7 +37,9 @@ export class SaleEstimatesApplication {
private readonly getSaleEstimateStateService: GetSaleEstimateState, private readonly getSaleEstimateStateService: GetSaleEstimateState,
private readonly saleEstimatesPdfService: GetSaleEstimatePdf, private readonly saleEstimatesPdfService: GetSaleEstimatePdf,
private readonly getSaleEstimateMailStateService: GetSaleEstimateMailStateService, private readonly getSaleEstimateMailStateService: GetSaleEstimateMailStateService,
) {} private readonly bulkDeleteSaleEstimatesService: BulkDeleteSaleEstimatesService,
private readonly validateBulkDeleteSaleEstimatesService: ValidateBulkDeleteSaleEstimatesService,
) { }
/** /**
* Create a sale estimate. * Create a sale estimate.
@@ -68,6 +72,31 @@ export class SaleEstimatesApplication {
return this.deleteSaleEstimateService.deleteEstimate(estimateId); return this.deleteSaleEstimateService.deleteEstimate(estimateId);
} }
/**
* Deletes multiple sale estimates.
* @param {number[]} saleEstimateIds
* @return {Promise<void>}
*/
public bulkDeleteSaleEstimates(
saleEstimateIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteSaleEstimatesService.bulkDeleteSaleEstimates(
saleEstimateIds,
options,
);
}
/**
* Validates which sale estimates can be deleted.
* @param {number[]} saleEstimateIds
*/
public validateBulkDeleteSaleEstimates(saleEstimateIds: number[]) {
return this.validateBulkDeleteSaleEstimatesService.validateBulkDeleteSaleEstimates(
saleEstimateIds,
);
}
/** /**
* Retrieves the given sale estimate. * Retrieves the given sale estimate.
* @param {number} estimateId - Sale estimate ID. * @param {number} estimateId - Sale estimate ID.

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