diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..d7ca338e1 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,13 @@ +# Claude Code Settings for Bigcapital + +## Node.js Version + +Always use Node.js 18.16.1 for this project. Before running any npm/pnpm/node commands: + +```bash +nvm use 18.16.1 +``` + +## Package Manager + +Use `pnpm` for this project. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index eeaf089ad..6c814f123 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,5 +1,6 @@ name: E2E on: + workflow_dispatch: push: branches: - main @@ -23,46 +24,131 @@ defaults: shell: 'bash' jobs: - test_setup: - name: Test setup - runs-on: ubuntu-latest - outputs: - preview_url: ${{ steps.waitForVercelPreviewDeployment.outputs.url }} - steps: - - name: Wait for Vercel preview deployment to be ready - uses: patrickedqvist/wait-for-vercel-preview@v1.3.1 - id: waitForVercelPreviewDeployment - with: - token: ${{ secrets.GITHUB_TOKEN }} - max_timeout: 3000 - test_e2e: runs-on: ubuntu-latest - needs: test_setup name: Playwright tests - timeout-minutes: 15 - environment: ${{ vars.ENVIRONMENT_STAGE }} + timeout-minutes: 20 + + services: + mysql: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: bigcapital_system + MYSQL_USER: bigcapital + MYSQL_PASSWORD: bigcapital + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=5 + steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 with: - node-version: 14 # Need for npm >=7.7 - cache: 'npm' + node-version: 18.16.1 + + - uses: pnpm/action-setup@v2 + with: + version: 9 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + id: pnpm-cache + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: npm install + run: pnpm install --frozen-lockfile - - name: Install Playwright with deps - run: npx playwright install --with-deps + - name: Build shared packages + run: pnpm build - - name: Run tests - run: npm run test:e2e + - name: Create server .env file + run: | + cat > packages/server/.env << EOF + # Database + DB_HOST=127.0.0.1 + DB_USER=bigcapital + DB_PASSWORD=bigcapital + DB_ROOT_PASSWORD=root + DB_CHARSET=utf8 + + # System database + SYSTEM_DB_NAME=bigcapital_system + + # Tenant databases + TENANT_DB_NAME_PERFIX=bigcapital_tenant_ + + # Application + BASE_URL=http://localhost:3000 + JWT_SECRET=test-jwt-secret-for-e2e-testing + APP_JWT_SECRET=test-app-jwt-secret + + # Sign-up + SIGNUP_DISABLED=false + + # API rate limit + API_RATE_LIMIT=120,60,600 + + # Redis + REDIS_HOST=127.0.0.1 + REDIS_PORT=6379 + + # S3 (placeholder values for E2E tests) + S3_REGION=us-east-1 + S3_ACCESS_KEY_ID=test-access-key + S3_SECRET_ACCESS_KEY=test-secret-key + S3_ENDPOINT=http://localhost:9000 + S3_BUCKET=test-bucket + + # Mail (placeholder values for E2E tests) + MAIL_HOST=localhost + MAIL_PORT=1025 + MAIL_FROM_NAME=Bigcapital + MAIL_FROM_ADDRESS=test@bigcapital.app + EOF + + - name: Run database migrations + run: pnpm system:migrate:latest + + - name: Start server in background + run: | + cd packages/server && pnpm start:prod & + sleep 10 env: - PLAYWRIGHT_TEST_BASE_URL: ${{ needs.test_setup.outputs.preview_url }} + NODE_ENV: production - - uses: actions/upload-artifact@v2 + - name: Start webapp in background + run: | + cd packages/webapp && pnpm dev & + sleep 10 + + - name: Install Playwright browsers + run: pnpm exec playwright install chromium + + - name: Run E2E tests + run: pnpm run test:e2e + env: + PLAYWRIGHT_TEST_BASE_URL: http://localhost:4000 + + - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: test-results/ - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/.github/workflows/generate-openapi.yml b/.github/workflows/generate-openapi.yml new file mode 100644 index 000000000..7b5dc2066 --- /dev/null +++ b/.github/workflows/generate-openapi.yml @@ -0,0 +1,154 @@ +name: Generate OpenAPI SDK Types + +on: + push: + branches: + - main + - develop + paths: + - 'packages/server/src/**/*.ts' + - 'packages/server/src/**/*.dto.ts' + - '!packages/server/src/**/*.spec.ts' + - '!packages/server/src/**/*.test.ts' + workflow_dispatch: + +defaults: + run: + shell: 'bash' + +env: + # Database configuration + DB_HOST: 127.0.0.1 + DB_USER: root + DB_PASSWORD: root + DB_CHARSET: utf8 + SYSTEM_DB_NAME: bigcapital_system + TENANT_DB_NAME_PERFIX: bigcapital_tenant_ + # Redis configuration + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + # Queue configuration + QUEUE_HOST: 127.0.0.1 + QUEUE_PORT: 6379 + # App configuration + APP_JWT_SECRET: test-jwt-secret-for-openapi-generation + JWT_SECRET: test-jwt-secret-for-openapi-generation + BASE_URL: http://localhost:3000 + # Feature flags + SIGNUP_DISABLED: 'false' + SIGNUP_EMAIL_CONFIRMATION: 'false' + API_RATE_LIMIT: 120,60,600 + # Optional services (empty for OpenAPI generation) + MAIL_HOST: '' + MAIL_PORT: '' + MAIL_USERNAME: '' + MAIL_PASSWORD: '' + MAIL_FROM_NAME: '' + MAIL_FROM_ADDRESS: '' + GOTENBERG_URL: '' + GOTENBERG_DOCS_URL: '' + PLAID_CLIENT_ID: '' + PLAID_SECRET: '' + LEMONSQUEEZY_API_KEY: '' + S3_ACCESS_KEY_ID: '' + S3_SECRET_ACCESS_KEY: '' + S3_BUCKET: '' + POSTHOG_API_KEY: '' + STRIPE_PAYMENT_SECRET_KEY: '' + OPEN_EXCHANGE_RATE_APP_ID: '' + BULLBOARD_ENABLED: 'false' + BULLBOARD_USERNAME: '' + BULLBOARD_PASSWORD: '' + +jobs: + generate-openapi: + name: Generate OpenAPI and SDK Types + runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: bigcapital_system + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared packages + run: pnpm run build --scope "@bigcapital/utils" --scope "@bigcapital/email-components" --scope "@bigcapital/pdf-templates" + + - name: Generate OpenAPI spec and SDK types + run: pnpm run generate:sdk-types + + - name: Check for changes + id: check-changes + run: | + if git diff --quiet shared/sdk-ts/; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Create Pull Request + if: steps.check-changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore(sdk): update OpenAPI spec and generated types' + title: 'chore(sdk): update OpenAPI spec and generated types' + body: | + ## Summary + Automated update of OpenAPI specification and generated TypeScript SDK types. + + This PR was automatically generated by the `generate-openapi.yml` workflow due to server code changes. + + ## Changes + - Updated `shared/sdk-ts/openapi.json` + - Regenerated `shared/sdk-ts/src/schema.ts` + + ## Test plan + - [ ] Verify the generated types compile correctly + - [ ] Check that no breaking changes were introduced to the API types + branch: chore/update-openapi-sdk-types + base: ${{ github.ref_name }} + labels: | + automated + sdk + delete-branch: true diff --git a/.gitignore b/.gitignore index 53db44a24..a23a218c7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ node_modules/ test-results/ .qodo + + +.pnpm-store \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 559d9a7d1..3876fd498 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v14.20 \ No newline at end of file +18.16.1 diff --git a/package.json b/package.json index a4512cc60..586710bc5 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,10 @@ "system:migrate:latest": "lerna run cli:system:migrate:latest --scope \"@bigcapital/server\"", "tenants:migrate:latest": "lerna run cli:tenants:migrate:latest --scope \"@bigcapital/server\"", "system:seed:latest": "lerna run cli:system:seed:latest --scope \"@bigcapital/server\"", - "tenants:seed:latest": "lerna run cli:tenants:seed:latest --scope \"@bigcapital/server\"" + "tenants:seed:latest": "lerna run cli:tenants:seed:latest --scope \"@bigcapital/server\"", + "generate:sdk-types": "lerna run openapi:export --scope \"@bigcapital/server\" && lerna run generate --scope \"@bigcapital/sdk-ts\" && lerna run build --scope \"@bigcapital/sdk-ts\"", + "format": "lerna run format", + "format:check": "lerna run format:check" }, "devDependencies": { "@commitlint/cli": "^17.4.2", diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index 9a10f2a27..d15969209 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -4,7 +4,7 @@ FROM node:18.16.0-alpine AS builder WORKDIR /app # Install pnpm -RUN npm install -g pnpm@8.10.2 +RUN npm install -g pnpm@9.0.5 # Install build dependencies RUN apk add --no-cache python3 build-base chromium @@ -15,18 +15,13 @@ ENV PYTHON=/usr/bin/python3 # Copy package files for dependency installation COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml lerna.json ./ COPY --chown=node:node packages/server/package.json ./packages/server/ -COPY --chown=node:node shared/bigcapital-utils/package.json ./shared/bigcapital-utils/ -COPY --chown=node:node shared/pdf-templates/package.json ./shared/pdf-templates/ -COPY --chown=node:node shared/email-components/package.json ./shared/email-components/ +COPY --chown=node:node shared ./shared # Install all dependencies (including devDependencies for build) RUN pnpm install --frozen-lockfile # Copy source code COPY --chown=node:node ./packages/server ./packages/server -COPY --chown=node:node ./shared/bigcapital-utils ./shared/bigcapital-utils -COPY --chown=node:node ./shared/pdf-templates ./shared/pdf-templates -COPY --chown=node:node ./shared/email-components ./shared/email-components # Build NestJS application RUN pnpm run build:server --skip-nx-cache @@ -37,7 +32,7 @@ FROM node:18.16.0-alpine AS production WORKDIR /app # Install pnpm for production -RUN npm install -g pnpm@8.10.2 +RUN npm install -g pnpm@9.0.5 # Create non-root user RUN addgroup -g 1001 -S nodejs && \ @@ -52,9 +47,7 @@ ENV PYTHON=/usr/bin/python3 # Copy package files for production dependency installation COPY --chown=nodejs:nodejs package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY --chown=nodejs:nodejs packages/server/package.json ./packages/server/ -COPY --chown=nodejs:nodejs shared/bigcapital-utils/package.json ./shared/bigcapital-utils/ -COPY --chown=nodejs:nodejs shared/pdf-templates/package.json ./shared/pdf-templates/ -COPY --chown=nodejs:nodejs shared/email-components/package.json ./shared/email-components/ +COPY --chown=nodejs:nodejs shared ./shared # Copy .husky directory (needed for husky install command) COPY --chown=nodejs:nodejs .husky ./.husky @@ -78,10 +71,8 @@ COPY --from=builder --chown=nodejs:nodejs /app/packages/server/static ./packages # Copy database migration files (needed for running migrations) COPY --from=builder --chown=nodejs:nodejs /app/packages/server/src/database ./packages/server/src/database -# Copy built shared packages (dist folders and package.json for module resolution) -COPY --from=builder --chown=nodejs:nodejs /app/shared/bigcapital-utils/dist ./shared/bigcapital-utils/dist -COPY --from=builder --chown=nodejs:nodejs /app/shared/pdf-templates/dist ./shared/pdf-templates/dist -COPY --from=builder --chown=nodejs:nodejs /app/shared/email-components/dist ./shared/email-components/dist +# Copy all shared packages from builder so newly added shared packages are included automatically +COPY --from=builder --chown=nodejs:nodejs /app/shared ./shared # Set runtime environment variables (these should be provided at runtime via docker-compose or k8s) ENV NODE_ENV=production diff --git a/packages/server/package.json b/packages/server/package.json index 5d0ce3cd4..c67877fd2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -18,7 +18,7 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json --watchAll", + "test:e2e": "jest --config ./test/jest-e2e.json --forceExit", "cli": "ts-node -r tsconfig-paths/register src/cli.ts", "cli:system:migrate:latest": "ts-node -r tsconfig-paths/register src/cli.ts system:migrate:latest", "cli:system:migrate:rollback": "ts-node -r tsconfig-paths/register src/cli.ts system:migrate:rollback", @@ -27,6 +27,7 @@ "cli:tenants:migrate:rollback": "ts-node -r tsconfig-paths/register src/cli.ts tenants:migrate:rollback", "cli:tenants:migrate:make": "ts-node -r tsconfig-paths/register src/cli.ts tenants:migrate:make", "cli:tenants:list": "ts-node -r tsconfig-paths/register src/cli.ts tenants:list", + "openapi:export": "ts-node -r tsconfig-paths/register src/cli.ts openapi:export", "cli:system:seed:latest": "ts-node -r tsconfig-paths/register src/cli.ts system:seed:latest", "cli:tenants:seed:latest": "ts-node -r tsconfig-paths/register src/cli.ts tenants:seed:latest" }, @@ -36,6 +37,7 @@ "@bigcapital/email-components": "workspace:*", "@bigcapital/pdf-templates": "workspace:*", "@bigcapital/utils": "workspace:*", + "@bigcapital/sdk-ts": "workspace:*", "@bull-board/api": "^5.22.0", "@bull-board/express": "^5.22.0", "@bull-board/nestjs": "^5.22.0", diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts index 7d96b896b..08d913c86 100644 --- a/packages/server/src/cli.ts +++ b/packages/server/src/cli.ts @@ -1,3 +1,4 @@ +/// import { CommandFactory } from 'nest-commander'; import { CLIModule } from './modules/CLI/CLI.module'; diff --git a/packages/server/src/common/config/s3.ts b/packages/server/src/common/config/s3.ts index dd6ac7ed6..b74ffd6ef 100644 --- a/packages/server/src/common/config/s3.ts +++ b/packages/server/src/common/config/s3.ts @@ -6,4 +6,5 @@ export default registerAs('s3', () => ({ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, endpoint: process.env.S3_ENDPOINT, bucket: process.env.S3_BUCKET, + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true', })); diff --git a/packages/server/src/constants/accounts.ts b/packages/server/src/constants/accounts.ts index b6055e852..43cd994e5 100644 --- a/packages/server/src/constants/accounts.ts +++ b/packages/server/src/constants/accounts.ts @@ -5,7 +5,7 @@ export const ACCOUNT_TYPE = { INVENTORY: 'inventory', OTHER_CURRENT_ASSET: 'other-current-asset', FIXED_ASSET: 'fixed-asset', - NON_CURRENT_ASSET: 'none-current-asset', + NON_CURRENT_ASSET: 'non-current-asset', ACCOUNTS_PAYABLE: 'accounts-payable', CREDIT_CARD: 'credit-card', diff --git a/packages/server/src/database/tenant/migrations/20250326120000_add_contact_code_to_contacts.ts b/packages/server/src/database/tenant/migrations/20250326120000_add_contact_code_to_contacts.ts new file mode 100644 index 000000000..28caa2762 --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20250326120000_add_contact_code_to_contacts.ts @@ -0,0 +1,12 @@ + +exports.up = function(knex) { + return knex.schema.alterTable('contacts', table => { + table.string('code').nullable().unique(); + }); +}; + +exports.down = function(knex) { + return knex.schema.alterTable('contacts', table => { + table.dropColumn('code'); + }); +}; diff --git a/packages/server/src/database/tenant/migrations/20260316000000_fix_account_type_typos.ts b/packages/server/src/database/tenant/migrations/20260316000000_fix_account_type_typos.ts new file mode 100644 index 000000000..81d8b345c --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20260316000000_fix_account_type_typos.ts @@ -0,0 +1,19 @@ +/** + * Fix account type typos in the database. + * + * This migration corrects the following typos: + * - 'none-current-asset' -> 'non-current-asset' + * + * Related GitHub issue: #1041 + */ +exports.up = function (knex) { + return knex('accounts') + .where('account_type', 'none-current-asset') + .update({ account_type: 'non-current-asset' }); +}; + +exports.down = function (knex) { + return knex('accounts') + .where('account_type', 'non-current-asset') + .update({ account_type: 'none-current-asset' }); +}; diff --git a/packages/server/src/database/tenant/migrations/20260516120000_add_unique_constraint_to_documents_key.ts b/packages/server/src/database/tenant/migrations/20260516120000_add_unique_constraint_to_documents_key.ts new file mode 100644 index 000000000..a4fda5c35 --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20260516120000_add_unique_constraint_to_documents_key.ts @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.alterTable('documents', (table) => { + table.unique('key'); + }); +}; + +exports.down = function (knex) { + return knex.schema.alterTable('documents', (table) => { + table.dropUnique('key'); + }); +}; diff --git a/packages/server/src/i18n/en/balance_sheet.json b/packages/server/src/i18n/en/balance_sheet.json index e8fc2ecea..a6059669c 100644 --- a/packages/server/src/i18n/en/balance_sheet.json +++ b/packages/server/src/i18n/en/balance_sheet.json @@ -9,7 +9,7 @@ "non_current_assets": "Non-Current Assets", "liabilities_and_equity": "Liabilities and Equity", "liabilities": "Liabilities", - "current_liabilties": "Current Liabilties", + "current_liabilities": "Current Liabilities", "long_term_liabilities": "Long-Term Liabilities", "non_current_liabilities": "Non-Current Liabilities", "equity": "Equity", diff --git a/packages/server/src/i18n/en/cash_flow_statement.json b/packages/server/src/i18n/en/cash_flow_statement.json index e8b772618..914dd7e12 100644 --- a/packages/server/src/i18n/en/cash_flow_statement.json +++ b/packages/server/src/i18n/en/cash_flow_statement.json @@ -9,5 +9,10 @@ "net_cash_financing": "Net cash provided by financing activities", "cash_beginning_period": "Cash at beginning of period", "net_cash_increase": "NET CASH INCREASE FOR PERIOD", - "cash_end_period": "CASH AT END OF PERIOD" + "cash_end_period": "CASH AT END OF PERIOD", + "account_name": "Account name", + "total": "Total", + "sheet_name": "Statement of Cash Flow", + "from_date": "From", + "to_date": "To" } diff --git a/packages/server/src/i18n/en/contact_summary_balance.json b/packages/server/src/i18n/en/contact_summary_balance.json new file mode 100644 index 000000000..90c49a83e --- /dev/null +++ b/packages/server/src/i18n/en/contact_summary_balance.json @@ -0,0 +1,5 @@ +{ + "account_name": "Account name", + "total": "Total", + "percentage_column": "% of Column" +} diff --git a/packages/server/src/i18n/en/credit_note.json b/packages/server/src/i18n/en/credit_note.json index f424384f8..ec5160ed0 100644 --- a/packages/server/src/i18n/en/credit_note.json +++ b/packages/server/src/i18n/en/credit_note.json @@ -1,4 +1,9 @@ { + "view.draft": "Draft", + "view.published": "Published", + "view.open": "Open", + "view.closed": "Closed", + "field.customer": "Customer", "field.exchange_rate": "Exchange Rate", "field.credit_note_date": "Credit Note Date", diff --git a/packages/server/src/i18n/en/inventory_item_details.json b/packages/server/src/i18n/en/inventory_item_details.json new file mode 100644 index 000000000..aa958ee4f --- /dev/null +++ b/packages/server/src/i18n/en/inventory_item_details.json @@ -0,0 +1,14 @@ +{ + "opening_balance": "Opening balance", + "closing_balance": "Closing balance", + "date": "Date", + "transaction_type": "Transaction type", + "transaction_number": "Transaction #", + "quantity": "Quantity", + "rate": "Rate", + "total": "Total", + "value": "Value", + "profit_margin": "Profit Margin", + "running_quantity": "Running quantity", + "running_value": "Running Value" +} diff --git a/packages/server/src/i18n/en/transactions_by_contact.json b/packages/server/src/i18n/en/transactions_by_contact.json new file mode 100644 index 000000000..5de72d0cd --- /dev/null +++ b/packages/server/src/i18n/en/transactions_by_contact.json @@ -0,0 +1,4 @@ +{ + "opening_balance": "Opening balance", + "closing_balance": "Closing balance" +} diff --git a/packages/server/src/i18n/en/trial_balance_sheet.json b/packages/server/src/i18n/en/trial_balance_sheet.json new file mode 100644 index 000000000..b5a9c2ac4 --- /dev/null +++ b/packages/server/src/i18n/en/trial_balance_sheet.json @@ -0,0 +1,6 @@ +{ + "account": "Account", + "debit": "Debit", + "credit": "Credit", + "total": "Total" +} diff --git a/packages/server/src/models/Model.ts b/packages/server/src/models/Model.ts index 28d3ebb3b..83ef76c63 100644 --- a/packages/server/src/models/Model.ts +++ b/packages/server/src/models/Model.ts @@ -1,5 +1,6 @@ -import { QueryBuilder, Model } from 'objection'; +import { QueryBuilder, Model, mixin } from 'objection'; import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception'; +import { withDateSessionMixin } from './withDateSessionMixin'; interface PaginationResult { results: M[]; @@ -69,6 +70,7 @@ export class PaginationQueryBuilder< dependentRelationNames.forEach((relationName: string) => { recordQuery.withGraphFetched(relationName); }); + const record = await recordQuery; const hasRelations = dependentRelationNames.some((name) => { @@ -97,7 +99,7 @@ export class BaseQueryBuilder< } } -export class BaseModel extends Model { +export class BaseModel extends mixin(Model, [withDateSessionMixin]) { public readonly id: number; public readonly tableName: string; diff --git a/packages/server/src/models/withDateSessionMixin.ts b/packages/server/src/models/withDateSessionMixin.ts new file mode 100644 index 000000000..772ea7676 --- /dev/null +++ b/packages/server/src/models/withDateSessionMixin.ts @@ -0,0 +1,40 @@ +import * as moment from 'moment'; +import { Model } from 'objection'; + +type Constructor = new (...args: any[]) => T; + +export const withDateSessionMixin = >(BaseModel: T) => { + return class DateSession extends BaseModel { + constructor(...args: any[]) { + super(...args); + } + + get timestamps() { + return []; + } + + $beforeUpdate(opt, context) { + const maybePromise = super.$beforeUpdate(opt, context); + + return Promise.resolve(maybePromise).then(() => { + const key = this.timestamps[1]; + + if (key && !this[key]) { + this[key] = moment().format('YYYY/MM/DD HH:mm:ss'); + } + }); + } + + $beforeInsert(context) { + const maybePromise = super.$beforeInsert(context); + + return Promise.resolve(maybePromise).then(() => { + const key = this.timestamps[0]; + + if (key && !this[key]) { + this[key] = moment().format('YYYY/MM/DD HH:mm:ss'); + } + }); + } + } +} \ No newline at end of file diff --git a/packages/server/src/modules/Accounts/Accounts.constants.ts b/packages/server/src/modules/Accounts/Accounts.constants.ts index f35aecef0..f116353de 100644 --- a/packages/server/src/modules/Accounts/Accounts.constants.ts +++ b/packages/server/src/modules/Accounts/Accounts.constants.ts @@ -412,7 +412,7 @@ export const ACCOUNT_TYPE = { INVENTORY: 'inventory', OTHER_CURRENT_ASSET: 'other-current-asset', FIXED_ASSET: 'fixed-asset', - NON_CURRENT_ASSET: 'none-current-asset', + NON_CURRENT_ASSET: 'non-current-asset', ACCOUNTS_PAYABLE: 'accounts-payable', CREDIT_CARD: 'credit-card', diff --git a/packages/server/src/modules/Accounts/Accounts.controller.ts b/packages/server/src/modules/Accounts/Accounts.controller.ts index 462165550..0a58bce0f 100644 --- a/packages/server/src/modules/Accounts/Accounts.controller.ts +++ b/packages/server/src/modules/Accounts/Accounts.controller.ts @@ -9,6 +9,7 @@ import { ParseIntPipe, Put, HttpCode, + UseGuards, } from '@nestjs/common'; import { AccountsApplication } from './AccountsApplication.service'; import { CreateAccountDTO } from './CreateAccount.dto'; @@ -32,6 +33,11 @@ import { BulkDeleteDto, ValidateBulkDeleteResponseDto, } from '@/common/dtos/BulkDelete.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { AccountAction } from './Accounts.types'; @Controller('accounts') @ApiTags('Accounts') @@ -40,11 +46,13 @@ import { @ApiExtraModels(GetAccountTransactionResponseDto) @ApiExtraModels(ValidateBulkDeleteResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class AccountsController { constructor(private readonly accountsApplication: AccountsApplication) { } @Post('validate-bulk-delete') @HttpCode(200) + @RequirePermission(AccountAction.DELETE, AbilitySubject.Account) @ApiOperation({ summary: 'Validates which accounts can be deleted and returns counts of deletable and non-deletable accounts.', @@ -67,6 +75,7 @@ export class AccountsController { @Post('bulk-delete') @HttpCode(200) + @RequirePermission(AccountAction.DELETE, AbilitySubject.Account) @ApiOperation({ summary: 'Deletes multiple accounts in bulk.' }) @ApiResponse({ status: 200, @@ -81,6 +90,7 @@ export class AccountsController { } @Post() + @RequirePermission(AccountAction.CREATE, AbilitySubject.Account) @ApiOperation({ summary: 'Create an account' }) @ApiResponse({ status: 200, @@ -91,6 +101,7 @@ export class AccountsController { } @Put(':id') + @RequirePermission(AccountAction.EDIT, AbilitySubject.Account) @ApiOperation({ summary: 'Edit the given account.' }) @ApiResponse({ status: 200, @@ -111,6 +122,7 @@ export class AccountsController { } @Delete(':id') + @RequirePermission(AccountAction.DELETE, AbilitySubject.Account) @ApiOperation({ summary: 'Delete the given account.' }) @ApiResponse({ status: 200, @@ -129,6 +141,7 @@ export class AccountsController { @Post(':id/activate') @HttpCode(200) + @RequirePermission(AccountAction.EDIT, AbilitySubject.Account) @ApiOperation({ summary: 'Activate the given account.' }) @ApiResponse({ status: 200, @@ -147,6 +160,7 @@ export class AccountsController { @Post(':id/inactivate') @HttpCode(200) + @RequirePermission(AccountAction.EDIT, AbilitySubject.Account) @ApiOperation({ summary: 'Inactivate the given account.' }) @ApiResponse({ status: 200, @@ -164,6 +178,7 @@ export class AccountsController { } @Get('types') + @RequirePermission(AccountAction.VIEW, AbilitySubject.Account) @ApiOperation({ summary: 'Retrieves the account types.' }) @ApiResponse({ status: 200, @@ -180,6 +195,7 @@ export class AccountsController { } @Get('transactions') + @RequirePermission(AccountAction.VIEW, AbilitySubject.Account) @ApiOperation({ summary: 'Retrieves the account transactions.' }) @ApiResponse({ status: 200, @@ -198,6 +214,7 @@ export class AccountsController { } @Get(':id') + @RequirePermission(AccountAction.VIEW, AbilitySubject.Account) @ApiOperation({ summary: 'Retrieves the account details.' }) @ApiResponse({ status: 200, @@ -216,6 +233,7 @@ export class AccountsController { } @Get() + @RequirePermission(AccountAction.VIEW, AbilitySubject.Account) @ApiOperation({ summary: 'Retrieves the accounts.' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/Accounts/Accounts.module.ts b/packages/server/src/modules/Accounts/Accounts.module.ts index 85f0c6488..b43e72f8b 100644 --- a/packages/server/src/modules/Accounts/Accounts.module.ts +++ b/packages/server/src/modules/Accounts/Accounts.module.ts @@ -21,6 +21,7 @@ import { AccountsExportable } from './AccountsExportable.service'; import { AccountsImportable } from './AccountsImportable.service'; import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service'; import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service'; +import { AccountsSettingsService } from './AccountsSettings.service'; const models = [RegisterTenancyModel(BankAccount)]; @@ -29,6 +30,7 @@ const models = [RegisterTenancyModel(BankAccount)]; controllers: [AccountsController], providers: [ AccountsApplication, + AccountsSettingsService, CreateAccountService, TenancyContext, CommandAccountValidators, @@ -49,9 +51,10 @@ const models = [RegisterTenancyModel(BankAccount)]; exports: [ AccountRepository, CreateAccountService, + AccountsSettingsService, ...models, AccountsExportable, - AccountsImportable + AccountsImportable, ], }) export class AccountsModule {} diff --git a/packages/server/src/modules/Accounts/AccountsSettings.service.ts b/packages/server/src/modules/Accounts/AccountsSettings.service.ts new file mode 100644 index 000000000..da81bb2bc --- /dev/null +++ b/packages/server/src/modules/Accounts/AccountsSettings.service.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { SettingsStore } from '../Settings/SettingsStore'; +import { SETTINGS_PROVIDER } from '../Settings/Settings.types'; + +export interface IAccountsSettings { + accountCodeRequired: boolean; + accountCodeUnique: boolean; +} + +@Injectable() +export class AccountsSettingsService { + constructor( + @Inject(SETTINGS_PROVIDER) + private readonly settingsStore: () => SettingsStore, + ) {} + + /** + * Retrieves account settings (account code required, account code unique). + */ + public async getAccountsSettings(): Promise { + const settingsStore = await this.settingsStore(); + return { + accountCodeRequired: settingsStore.get( + { group: 'accounts', key: 'account_code_required' }, + false, + ), + accountCodeUnique: settingsStore.get( + { group: 'accounts', key: 'account_code_unique' }, + true, + ), + }; + } +} diff --git a/packages/server/src/modules/Accounts/CommandAccountValidators.service.ts b/packages/server/src/modules/Accounts/CommandAccountValidators.service.ts index ef6cdf3f8..f1499ac45 100644 --- a/packages/server/src/modules/Accounts/CommandAccountValidators.service.ts +++ b/packages/server/src/modules/Accounts/CommandAccountValidators.service.ts @@ -106,6 +106,20 @@ export class CommandAccountValidators { } } + /** + * Throws error if account code is missing or blank when required. + * @param {string|undefined} code - Account code. + */ + public validateAccountCodeRequiredOrThrow(code: string | undefined) { + const trimmed = typeof code === 'string' ? code.trim() : ''; + if (!trimmed) { + throw new ServiceError( + ERRORS.ACCOUNT_CODE_REQUIRED, + 'Account code is required.', + ); + } + } + /** * Validates the account name uniquiness. * @param {string} accountName - Account name. diff --git a/packages/server/src/modules/Accounts/CreateAccount.service.ts b/packages/server/src/modules/Accounts/CreateAccount.service.ts index c435bdbd4..ef4f7028f 100644 --- a/packages/server/src/modules/Accounts/CreateAccount.service.ts +++ b/packages/server/src/modules/Accounts/CreateAccount.service.ts @@ -15,6 +15,7 @@ import { events } from '@/common/events/events'; import { CreateAccountDTO } from './CreateAccount.dto'; import { PartialModelObject } from 'objection'; import { TenantModelProxy } from '../System/models/TenantBaseModel'; +import { AccountsSettingsService } from './AccountsSettings.service'; @Injectable() export class CreateAccountService { @@ -32,6 +33,7 @@ export class CreateAccountService { private readonly uow: UnitOfWork, private readonly validator: CommandAccountValidators, private readonly tenancyContext: TenancyContext, + private readonly accountsSettings: AccountsSettingsService, ) {} /** @@ -43,14 +45,21 @@ export class CreateAccountService { baseCurrency: string, params?: CreateAccountParams, ) => { + const { accountCodeRequired, accountCodeUnique } = + await this.accountsSettings.getAccountsSettings(); + + // Validate account code required when setting is enabled. + if (accountCodeRequired) { + this.validator.validateAccountCodeRequiredOrThrow(accountDTO.code); + } + // Validate the account code uniquiness when setting is enabled. + if (accountCodeUnique && accountDTO.code?.trim()) { + await this.validator.isAccountCodeUniqueOrThrowError(accountDTO.code); + } // Validate account name uniquiness. if (!params.ignoreUniqueName) { await this.validator.validateAccountNameUniquiness(accountDTO.name); } - // Validate the account code uniquiness. - if (accountDTO.code) { - await this.validator.isAccountCodeUniqueOrThrowError(accountDTO.code); - } // Retrieve the account type meta or throw service error if not found. this.validator.getAccountTypeOrThrowError(accountDTO.accountType); diff --git a/packages/server/src/modules/Accounts/EditAccount.service.ts b/packages/server/src/modules/Accounts/EditAccount.service.ts index 22c0d6bcb..536b9ef87 100644 --- a/packages/server/src/modules/Accounts/EditAccount.service.ts +++ b/packages/server/src/modules/Accounts/EditAccount.service.ts @@ -7,6 +7,7 @@ import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { events } from '@/common/events/events'; import { EditAccountDTO } from './EditAccount.dto'; import { TenantModelProxy } from '../System/models/TenantBaseModel'; +import { AccountsSettingsService } from './AccountsSettings.service'; @Injectable() export class EditAccount { @@ -17,7 +18,8 @@ export class EditAccount { @Inject(Account.name) private readonly accountModel: TenantModelProxy, - ) { } + private readonly accountsSettings: AccountsSettingsService, + ) {} /** * Authorize the account editing. @@ -30,6 +32,24 @@ export class EditAccount { accountDTO: EditAccountDTO, oldAccount: Account, ) => { + const { accountCodeRequired, accountCodeUnique } = + await this.accountsSettings.getAccountsSettings(); + + // Validate account code required when setting is enabled. + if (accountCodeRequired) { + this.validator.validateAccountCodeRequiredOrThrow(accountDTO.code); + } + // Validate the account code uniquiness when setting is enabled. + if ( + accountCodeUnique && + accountDTO.code?.trim() && + accountDTO.code !== oldAccount.code + ) { + await this.validator.isAccountCodeUniqueOrThrowError( + accountDTO.code, + oldAccount.id, + ); + } // Validate account name uniquiness. await this.validator.validateAccountNameUniquiness( accountDTO.name, @@ -40,13 +60,6 @@ export class EditAccount { oldAccount, accountDTO, ); - // Validate the account code not exists on the storage. - if (accountDTO.code && accountDTO.code !== oldAccount.code) { - await this.validator.isAccountCodeUniqueOrThrowError( - accountDTO.code, - oldAccount.id, - ); - } // Retrieve the parent account of throw not found service error. if (accountDTO.parentAccountId) { const parentAccount = await this.validator.getParentAccountOrThrowError( diff --git a/packages/server/src/modules/Accounts/constants.ts b/packages/server/src/modules/Accounts/constants.ts index c06fe95df..080e7b9dc 100644 --- a/packages/server/src/modules/Accounts/constants.ts +++ b/packages/server/src/modules/Accounts/constants.ts @@ -3,6 +3,7 @@ export const ERRORS = { ACCOUNT_TYPE_NOT_FOUND: 'account_type_not_found', PARENT_ACCOUNT_NOT_FOUND: 'parent_account_not_found', ACCOUNT_CODE_NOT_UNIQUE: 'account_code_not_unique', + ACCOUNT_CODE_REQUIRED: 'account_code_required', ACCOUNT_NAME_NOT_UNIQUE: 'account_name_not_unqiue', PARENT_ACCOUNT_HAS_DIFFERENT_TYPE: 'parent_has_different_type', ACCOUNT_TYPE_NOT_ALLOWED_TO_CHANGE: 'account_type_not_allowed_to_changed', diff --git a/packages/server/src/modules/Accounts/dtos/GetAccountsQuery.dto.ts b/packages/server/src/modules/Accounts/dtos/GetAccountsQuery.dto.ts index 669792246..b07b7264f 100644 --- a/packages/server/src/modules/Accounts/dtos/GetAccountsQuery.dto.ts +++ b/packages/server/src/modules/Accounts/dtos/GetAccountsQuery.dto.ts @@ -1,8 +1,10 @@ -import { IsBoolean, IsEnum, IsOptional } from 'class-validator'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { parseBoolean } from '@/utils/parse-boolean'; import { IAccountsStructureType } from '../Accounts.types'; +import { IFilterRole, ISortOrder } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types'; +import { ToNumber } from '@/common/decorators/Validators'; export class GetAccountsQueryDto { @ApiPropertyOptional({ @@ -23,5 +25,93 @@ export class GetAccountsQueryDto { @IsOptional() @IsEnum(IAccountsStructureType) structure?: IAccountsStructureType; + + @ApiPropertyOptional({ + description: 'Custom view ID', + type: Number, + example: 1, + }) + @IsOptional() + @ToNumber() + @IsInt() + customViewId?: number; + + @ApiPropertyOptional({ + description: 'Filter roles array', + type: Array, + isArray: true, + }) + @IsArray() + @IsOptional() + filterRoles?: IFilterRole[]; + + @ApiPropertyOptional({ + description: 'Column to sort by', + type: String, + example: 'created_at', + }) + @IsOptional() + @IsString() + columnSortBy?: string; + + @ApiPropertyOptional({ + description: 'Sort order', + enum: ISortOrder, + example: ISortOrder.DESC, + }) + @IsOptional() + @IsEnum(ISortOrder) + sortOrder?: string; + + @ApiPropertyOptional({ + description: 'Stringified filter roles', + type: String, + example: '{"fieldKey":"root_type","value":"asset"}', + }) + @IsOptional() + @IsString() + stringifiedFilterRoles?: string; + + @ApiPropertyOptional({ + description: 'Search keyword', + type: String, + example: 'bank account', + }) + @IsOptional() + @IsString() + searchKeyword?: string; + + @ApiPropertyOptional({ + description: 'View slug', + type: String, + example: 'assets', + }) + @IsOptional() + @IsString() + viewSlug?: string; + + @ApiPropertyOptional({ + description: 'Page number', + type: Number, + example: 1, + minimum: 1, + }) + @IsOptional() + @ToNumber() + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ + description: 'Page size', + type: Number, + example: 25, + minimum: 1, + }) + @IsOptional() + @ToNumber() + @IsInt() + @Min(1) + pageSize?: number; } diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 906b1e7ec..7639c7770 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -18,7 +18,7 @@ import { createBullBoardAuthMiddleware } from '@/middleware/bull-board-auth.midd import { BullModule } from '@nestjs/bullmq'; import { ScheduleModule } from '@nestjs/schedule'; import { PassportModule } from '@nestjs/passport'; -import { ClsModule, ClsService } from 'nestjs-cls'; +import { ClsModule } from 'nestjs-cls'; import { AppController } from './App.controller'; import { AppService } from './App.service'; import { ItemsModule } from '../Items/Items.module'; @@ -99,6 +99,7 @@ import { UsersModule } from '../UsersModule/Users.module'; import { ContactsModule } from '../Contacts/Contacts.module'; import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module'; import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module'; +import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module'; import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module'; import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module'; import { SocketModule } from '../Socket/Socket.module'; @@ -124,7 +125,7 @@ import { AppThrottleModule } from './AppThrottle.module'; useFactory: () => ({ fallbackLanguage: 'en', loaderOptions: { - path: join(__dirname, '/../../i18n/'), + path: join(__dirname, '../../i18n/'), watch: true, }, }), @@ -169,9 +170,6 @@ import { AppThrottleModule } from './AppThrottle.module'; global: true, middleware: { mount: true, - setup: (cls: ClsService, req: Request, res: Response) => { - cls.set('organizationId', req.headers['organization-id']); - }, generateId: true, saveReq: true, }, @@ -258,6 +256,7 @@ import { AppThrottleModule } from './AppThrottle.module'; ContactsModule, SocketModule, EEModule, + ExchangeRatesModule, ], controllers: [AppController], providers: [ diff --git a/packages/server/src/modules/Attachments/Attachment.module.ts b/packages/server/src/modules/Attachments/Attachment.module.ts index 638e32e01..74f55f205 100644 --- a/packages/server/src/modules/Attachments/Attachment.module.ts +++ b/packages/server/src/modules/Attachments/Attachment.module.ts @@ -1,9 +1,11 @@ import { Module } from "@nestjs/common"; +import { randomUUID } from 'node:crypto'; import * as multerS3 from 'multer-s3'; +import { ClsService } from 'nestjs-cls'; import { S3_CLIENT, S3Module } from "../S3/S3.module"; import { DeleteAttachment } from "./DeleteAttachment"; import { GetAttachment } from "./GetAttachment"; -import { getAttachmentPresignedUrl } from "./GetAttachmentPresignedUrl"; +import { GetAttachmentPresignedUrl } from "./GetAttachmentPresignedUrl"; import { LinkAttachment } from "./LinkAttachment"; import { UnlinkAttachment } from "./UnlinkAttachment"; import { ValidateAttachments } from "./ValidateAttachments"; @@ -15,6 +17,8 @@ import { AttachmentsOnPaymentsReceived } from "./events/AttachmentsOnPaymentsRec import { AttachmentsOnManualJournals } from "./events/AttachmentsOnManualJournals"; import { AttachmentsOnVendorCredits } from "./events/AttachmentsOnVendorCredits"; import { AttachmentsOnSaleInvoiceCreated } from "./events/AttachmentsOnSaleInvoice"; +import { AttachmentsOnSaleReceipt } from "./events/AttachmentsOnSaleReceipts"; +import { AttachmentsOnSaleEstimates } from "./events/AttachmentsOnSaleEstimates"; import { AttachmentsController } from "./Attachments.controller"; import { RegisterTenancyModel } from "../Tenancy/TenancyModels/Tenancy.module"; import { DocumentModel } from "./models/Document.model"; @@ -33,12 +37,12 @@ const models = [ @Module({ imports: [S3Module, ...models], - exports: [...models], + exports: [...models, GetAttachmentPresignedUrl], controllers: [AttachmentsController], providers: [ DeleteAttachment, GetAttachment, - getAttachmentPresignedUrl, + GetAttachmentPresignedUrl, LinkAttachment, UnlinkAttachment, ValidateAttachments, @@ -50,13 +54,19 @@ const models = [ AttachmentsOnManualJournals, AttachmentsOnVendorCredits, AttachmentsOnSaleInvoiceCreated, + AttachmentsOnSaleReceipt, + AttachmentsOnSaleEstimates, AttachmentsApplication, UploadDocument, AttachmentUploadPipeline, { provide: MULTER_MODULE_OPTIONS, - inject: [ConfigService, S3_CLIENT], - useFactory: (configService: ConfigService, s3: S3Client) => ({ + inject: [ConfigService, S3_CLIENT, ClsService], + useFactory: ( + configService: ConfigService, + s3: S3Client, + cls: ClsService, + ) => ({ storage: multerS3({ s3, bucket: configService.get('s3.bucket'), @@ -65,7 +75,11 @@ const models = [ cb(null, { fieldName: file.fieldname }); }, key: function (req, file, cb) { - cb(null, Date.now().toString()); + const organizationId = cls.get('organizationId'); + if (!organizationId) { + return cb(new Error('Tenant context required for upload.'), undefined as any); + } + cb(null, `${organizationId}/${randomUUID()}`); }, acl: function(req, file, cb) { // Conditionally set file to public or private based on isPublic flag diff --git a/packages/server/src/modules/Attachments/Attachments.controller.ts b/packages/server/src/modules/Attachments/Attachments.controller.ts index f94f46715..29a643f18 100644 --- a/packages/server/src/modules/Attachments/Attachments.controller.ts +++ b/packages/server/src/modules/Attachments/Attachments.controller.ts @@ -31,6 +31,9 @@ import { AttachmentUploadPipeline } from './S3UploadPipeline'; import { FileInterceptor } from '@/common/interceptors/file.interceptor'; import { ConfigService } from '@nestjs/config'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { AttachmentAction } from './Attachments.types'; @ApiTags('Attachments') @Controller('/attachments') @@ -86,6 +89,7 @@ export class AttachmentsController { @ApiOperation({ summary: 'Get attachment by ID' }) @ApiParam({ name: 'id', description: 'Attachment ID' }) @ApiResponse({ status: 200, description: 'Returns the attachment file' }) + @RequirePermission(AttachmentAction.View, AbilitySubject.Attachment) async getAttachment( @Res() res: Response, @Param('id') documentId: string, @@ -93,11 +97,12 @@ export class AttachmentsController { const data = await this.attachmentsApplication.get(documentId); const byte = await data.Body.transformToByteArray(); - const extension = mime.extension(data.ContentType); + const contentType = data.ContentType || 'application/octet-stream'; + const extension = mime.extension(contentType) || 'bin'; const buffer = Buffer.from(byte); res.set('Content-Disposition', `filename="${documentId}.${extension}"`); - res.set('Content-Type', data.ContentType); + res.set('Content-Type', contentType); res.send(buffer); } @@ -111,6 +116,7 @@ export class AttachmentsController { status: 200, description: 'The document has been deleted successfully', }) + @RequirePermission(AttachmentAction.Delete, AbilitySubject.Attachment) async deleteAttachment(@Param('id') documentId: string) { await this.attachmentsApplication.delete(documentId); @@ -184,6 +190,7 @@ export class AttachmentsController { status: 200, description: 'Returns the presigned URL for the attachment', }) + @RequirePermission(AttachmentAction.View, AbilitySubject.Attachment) async getAttachmentPresignedUrl(@Param('id') documentKey: string) { const presignedUrl = await this.attachmentsApplication.getPresignedUrl(documentKey); diff --git a/packages/server/src/modules/Attachments/Attachments.types.ts b/packages/server/src/modules/Attachments/Attachments.types.ts index d1699ed12..a9d9644c2 100644 --- a/packages/server/src/modules/Attachments/Attachments.types.ts +++ b/packages/server/src/modules/Attachments/Attachments.types.ts @@ -1,3 +1,8 @@ export interface AttachmentLinkDTO { key: string; } + +export enum AttachmentAction { + View = 'View', + Delete = 'Delete', +} diff --git a/packages/server/src/modules/Attachments/AttachmentsApplication.ts b/packages/server/src/modules/Attachments/AttachmentsApplication.ts index baee3775e..bb52ec564 100644 --- a/packages/server/src/modules/Attachments/AttachmentsApplication.ts +++ b/packages/server/src/modules/Attachments/AttachmentsApplication.ts @@ -4,7 +4,7 @@ import { DeleteAttachment } from './DeleteAttachment'; import { GetAttachment } from './GetAttachment'; import { LinkAttachment } from './LinkAttachment'; import { UnlinkAttachment } from './UnlinkAttachment'; -import { getAttachmentPresignedUrl } from './GetAttachmentPresignedUrl'; +import { GetAttachmentPresignedUrl } from './GetAttachmentPresignedUrl'; @Injectable() export class AttachmentsApplication { @@ -14,7 +14,7 @@ export class AttachmentsApplication { private readonly getDocumentService: GetAttachment, private readonly linkDocumentService: LinkAttachment, private readonly unlinkDocumentService: UnlinkAttachment, - private readonly getPresignedUrlService: getAttachmentPresignedUrl, + private readonly getPresignedUrlService: GetAttachmentPresignedUrl, ) {} /** diff --git a/packages/server/src/modules/Attachments/DeleteAttachment.ts b/packages/server/src/modules/Attachments/DeleteAttachment.ts index dea4adb35..e278578da 100644 --- a/packages/server/src/modules/Attachments/DeleteAttachment.ts +++ b/packages/server/src/modules/Attachments/DeleteAttachment.ts @@ -31,17 +31,17 @@ export class DeleteAttachment { * @param {string} filekey */ async delete(filekey: string): Promise { + const foundDocument = await this.documentModel() + .query() + .findOne('key', filekey) + .throwIfNotFound(); + const params = { Bucket: this.configService.get('s3.bucket'), Key: filekey, }; await this.s3Client.send(new DeleteObjectCommand(params)); - const foundDocument = await this.documentModel() - .query() - .findOne('key', filekey) - .throwIfNotFound(); - await this.uow.withTransaction(async (trx: Knex.Transaction) => { // Delete all document links await this.documentLinkModel() diff --git a/packages/server/src/modules/Attachments/GetAttachment.ts b/packages/server/src/modules/Attachments/GetAttachment.ts index 5c448200a..6aff2624d 100644 --- a/packages/server/src/modules/Attachments/GetAttachment.ts +++ b/packages/server/src/modules/Attachments/GetAttachment.ts @@ -2,6 +2,8 @@ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { S3_CLIENT } from '../S3/S3.module'; +import { TenantModelProxy } from '../System/models/TenantBaseModel'; +import { DocumentModel } from './models/Document.model'; @Injectable() export class GetAttachment { @@ -10,13 +12,21 @@ export class GetAttachment { @Inject(S3_CLIENT) private readonly s3: S3Client, + + @Inject(DocumentModel.name) + private readonly documentModel: TenantModelProxy, ) {} - + /** * Retrieves data of the given document key. * @param {string} filekey */ async getAttachment(filekey: string) { + await this.documentModel() + .query() + .findOne('key', filekey) + .throwIfNotFound(); + const params = { Bucket: this.configService.get('s3.bucket'), Key: filekey, diff --git a/packages/server/src/modules/Attachments/GetAttachmentPresignedUrl.ts b/packages/server/src/modules/Attachments/GetAttachmentPresignedUrl.ts index 3d30bbd24..ae4b97de9 100644 --- a/packages/server/src/modules/Attachments/GetAttachmentPresignedUrl.ts +++ b/packages/server/src/modules/Attachments/GetAttachmentPresignedUrl.ts @@ -1,13 +1,13 @@ +import { Inject, Injectable } from '@nestjs/common'; import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { TenantModelProxy } from '../System/models/TenantBaseModel'; import { DocumentModel } from './models/Document.model'; -import { ConfigService } from '@nestjs/config'; import { S3_CLIENT } from '../S3/S3.module'; @Injectable() -export class getAttachmentPresignedUrl { +export class GetAttachmentPresignedUrl { constructor( private readonly configService: ConfigService, @@ -24,7 +24,10 @@ export class getAttachmentPresignedUrl { * @returns {string} */ async getPresignedUrl(key: string) { - const foundDocument = await this.documentModel().query().findOne({ key }); + const foundDocument = await this.documentModel() + .query() + .findOne({ key }) + .throwIfNotFound(); const config = this.configService.get('s3'); let ResponseContentDisposition = 'attachment'; diff --git a/packages/server/src/modules/Attachments/LinkAttachment.ts b/packages/server/src/modules/Attachments/LinkAttachment.ts index 4bffeadd5..e1015ac73 100644 --- a/packages/server/src/modules/Attachments/LinkAttachment.ts +++ b/packages/server/src/modules/Attachments/LinkAttachment.ts @@ -1,5 +1,5 @@ import { ModuleRef } from '@nestjs/core'; -import bluebird from 'bluebird'; +import * as bluebird from 'bluebird'; import { Knex } from 'knex'; import { validateLinkModelEntryExists, @@ -53,7 +53,8 @@ export class LinkAttachment { const foundLinkModel = await LinkModel().query(trx).findById(modelId); validateLinkModelEntryExists(foundLinkModel); - const foundLinks = await this.documentLinkModel().query(trx) + const foundLinks = await this.documentLinkModel() + .query(trx) .where('modelRef', modelRef) .where('modelId', modelId) .where('documentId', foundFile.id); @@ -70,7 +71,7 @@ export class LinkAttachment { /** * Links the given file keys to the given model type and id. - * @param {string[]} filekeys - File keys. + * @param {string[]} filekeys - File keys. * @param {string} modelRef - Model reference. * @param {number} modelId - Model id. * @param {Knex.Transaction} trx - Knex transaction. diff --git a/packages/server/src/modules/Attachments/UnlinkAttachment.ts b/packages/server/src/modules/Attachments/UnlinkAttachment.ts index c52aa9a26..f3073ddab 100644 --- a/packages/server/src/modules/Attachments/UnlinkAttachment.ts +++ b/packages/server/src/modules/Attachments/UnlinkAttachment.ts @@ -44,7 +44,7 @@ export class UnlinkAttachment { validateLinkModelExists(attachableModel); const LinkModel = this.moduleRef.get(modelRef, { strict: false }); - const foundLinkModel = await LinkModel.query(trx).findById(modelId); + const foundLinkModel = await LinkModel().query(trx).findById(modelId); validateLinkModelEntryExists(foundLinkModel); const document = await this.documentModel().query(trx).findOne('key', filekey); diff --git a/packages/server/src/modules/Attachments/events/AttachmentsOnPaymentsReceived.ts b/packages/server/src/modules/Attachments/events/AttachmentsOnPaymentsReceived.ts index b73f692e9..0e8e726d6 100644 --- a/packages/server/src/modules/Attachments/events/AttachmentsOnPaymentsReceived.ts +++ b/packages/server/src/modules/Attachments/events/AttachmentsOnPaymentsReceived.ts @@ -55,7 +55,7 @@ export class AttachmentsOnPaymentsReceived { ); await this.linkAttachmentService.bulkLink( keys, - 'PaymentReceive', + 'PaymentReceived', paymentReceive.id, trx, ); @@ -76,7 +76,7 @@ export class AttachmentsOnPaymentsReceived { ); await this.unlinkAttachmentService.unlinkUnpresentedKeys( keys, - 'PaymentReceive', + 'PaymentReceived', oldPaymentReceive.id, trx, ); @@ -100,7 +100,7 @@ export class AttachmentsOnPaymentsReceived { ); await this.linkAttachmentService.bulkLink( keys, - 'PaymentReceive', + 'PaymentReceived', oldPaymentReceive.id, trx, ); @@ -117,7 +117,7 @@ export class AttachmentsOnPaymentsReceived { trx, }: IPaymentReceivedDeletingPayload) { await this.unlinkAttachmentService.unlinkAllModelKeys( - 'PaymentReceive', + 'PaymentReceived', oldPaymentReceive.id, trx, ); diff --git a/packages/server/src/modules/Attachments/utils.ts b/packages/server/src/modules/Attachments/utils.ts deleted file mode 100644 index ee5a34740..000000000 --- a/packages/server/src/modules/Attachments/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as path from 'path'; -// import config from '@/config'; - -export const getUploadedObjectUri = (objectKey: string) => { - return ''; - // return new URL( - // path.join(config.s3.bucket, objectKey), - // config.s3.endpoint - // ).toString(); -}; diff --git a/packages/server/src/modules/Auth/Auth.controller.ts b/packages/server/src/modules/Auth/Auth.controller.ts index 9a12562ee..b8b18554a 100644 --- a/packages/server/src/modules/Auth/Auth.controller.ts +++ b/packages/server/src/modules/Auth/Auth.controller.ts @@ -14,12 +14,19 @@ import { ApiOperation, ApiBody, ApiParam, - ApiExcludeController, + ApiResponse, + ApiExtraModels, + getSchemaPath, } from '@nestjs/swagger'; import { PublicRoute } from './guards/jwt.guard'; import { AuthenticationApplication } from './AuthApplication.sevice'; import { AuthSignupDto } from './dtos/AuthSignup.dto'; import { AuthSigninDto } from './dtos/AuthSignin.dto'; +import { AuthSignupVerifyDto } from './dtos/AuthSignupVerify.dto'; +import { AuthSendResetPasswordDto } from './dtos/AuthSendResetPassword.dto'; +import { AuthResetPasswordDto } from './dtos/AuthResetPassword.dto'; +import { AuthSigninResponseDto } from './dtos/AuthSigninResponse.dto'; +import { AuthMetaResponseDto } from './dtos/AuthMetaResponse.dto'; import { LocalAuthGuard } from './guards/Local.guard'; import { AuthSigninService } from './commands/AuthSignin.service'; import { TenantModel } from '../System/models/TenantModel'; @@ -27,7 +34,7 @@ import { SystemUser } from '../System/models/SystemUser'; @Controller('/auth') @ApiTags('Auth') -@ApiExcludeController() +@ApiExtraModels(AuthSigninResponseDto, AuthMetaResponseDto) @PublicRoute() @Throttle({ auth: {} }) export class AuthController { @@ -43,10 +50,15 @@ export class AuthController { @UseGuards(LocalAuthGuard) @ApiOperation({ summary: 'Sign in a user' }) @ApiBody({ type: AuthSigninDto }) + @ApiResponse({ + status: 200, + description: 'Sign-in successful. Returns access token and tenant/organization IDs.', + schema: { $ref: getSchemaPath(AuthSigninResponseDto) }, + }) async signin( @Request() req: Request & { user: SystemUser }, @Body() signinDto: AuthSigninDto, - ) { + ): Promise { const { user } = req; const tenant = await this.tenantModel.query().findById(user.tenantId); @@ -61,59 +73,47 @@ export class AuthController { @Post('/signup') @ApiOperation({ summary: 'Sign up a new user' }) @ApiBody({ type: AuthSignupDto }) + @ApiResponse({ status: 201, description: 'Sign-up initiated. Check email for confirmation.' }) signup(@Request() req: Request, @Body() signupDto: AuthSignupDto) { return this.authApp.signUp(signupDto); } - @Post('/signup/confirm') + @Post('/signup/verify') @ApiOperation({ summary: 'Confirm user signup' }) - @ApiBody({ - schema: { - type: 'object', - properties: { - email: { type: 'string', example: 'user@example.com' }, - token: { type: 'string', example: 'confirmation-token' }, - }, - }, - }) - signupConfirm(@Body('email') email: string, @Body('token') token: string) { - return this.authApp.signUpConfirm(email, token); + @ApiBody({ type: AuthSignupVerifyDto }) + @ApiResponse({ status: 200, description: 'Signup confirmed successfully.' }) + signupConfirm(@Body() body: AuthSignupVerifyDto) { + return this.authApp.signUpConfirm(body.email, body.token); } @Post('/send_reset_password') @ApiOperation({ summary: 'Send reset password email' }) - @ApiBody({ - schema: { - type: 'object', - properties: { - email: { type: 'string', example: 'user@example.com' }, - }, - }, - }) - sendResetPassword(@Body('email') email: string) { - return this.authApp.sendResetPassword(email); + @ApiBody({ type: AuthSendResetPasswordDto }) + @ApiResponse({ status: 200, description: 'Reset password email sent if the account exists.' }) + sendResetPassword(@Body() body: AuthSendResetPasswordDto) { + return this.authApp.sendResetPassword(body.email); } @Post('/reset_password/:token') @ApiOperation({ summary: 'Reset password using token' }) - @ApiParam({ name: 'token', description: 'Reset password token' }) - @ApiBody({ - schema: { - type: 'object', - properties: { - password: { type: 'string', example: 'new-password' }, - }, - }, - }) + @ApiParam({ name: 'token', description: 'Reset password token from email link' }) + @ApiBody({ type: AuthResetPasswordDto }) + @ApiResponse({ status: 200, description: 'Password reset successfully.' }) resetPassword( @Param('token') token: string, - @Body('password') password: string, + @Body() body: AuthResetPasswordDto, ) { - return this.authApp.resetPassword(token, password); + return this.authApp.resetPassword(token, body.password); } @Get('/meta') - meta() { + @ApiOperation({ summary: 'Get auth metadata (e.g. signup disabled)' }) + @ApiResponse({ + status: 200, + description: 'Auth metadata for the login/signup page.', + schema: { $ref: getSchemaPath(AuthMetaResponseDto) }, + }) + meta(): Promise { return this.authApp.getAuthMeta(); } } diff --git a/packages/server/src/modules/Auth/AuthMailMessages.esrvice.ts b/packages/server/src/modules/Auth/AuthMailMessages.esrvice.ts index 02a311b6a..7aaf4003b 100644 --- a/packages/server/src/modules/Auth/AuthMailMessages.esrvice.ts +++ b/packages/server/src/modules/Auth/AuthMailMessages.esrvice.ts @@ -20,7 +20,7 @@ export class AuthenticationMailMesssages { * @returns {Mail} */ resetPasswordMessage(user: ModelObject, token: string) { - const baseURL = this.configService.get('baseURL'); + const baseURL = this.configService.get('app.baseUrl'); return new Mail() .setSubject('Bigcapital - Password Reset') @@ -54,7 +54,7 @@ export class AuthenticationMailMesssages { * @returns {Mail} */ signupVerificationMail(email: string, fullName: string, token: string) { - const baseURL = this.configService.get('baseURL'); + const baseURL = this.configService.get('app.baseUrl'); const verifyUrl = `${baseURL}/auth/email_confirmation?token=${token}&email=${email}`; return new Mail() diff --git a/packages/server/src/modules/Auth/Authed.controller.ts b/packages/server/src/modules/Auth/Authed.controller.ts index 79c4a27da..e18b3635c 100644 --- a/packages/server/src/modules/Auth/Authed.controller.ts +++ b/packages/server/src/modules/Auth/Authed.controller.ts @@ -7,17 +7,13 @@ import { import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service'; import { Controller, Get, Post } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; -import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards'; -import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard'; +import { TenantAgnosticRoute } from '../Tenancy/TenancyGlobal.guard'; import { AuthenticationApplication } from './AuthApplication.sevice'; -import { TenancyContext } from '../Tenancy/TenancyContext.service'; import { IgnoreUserVerifiedRoute } from './guards/EnsureUserVerified.guard'; @Controller('/auth') @ApiTags('Auth') -@ApiExcludeController() -@IgnoreTenantSeededRoute() -@IgnoreTenantInitializedRoute() +@TenantAgnosticRoute() @IgnoreUserVerifiedRoute() @Throttle({ auth: {} }) export class AuthedController { diff --git a/packages/server/src/modules/Auth/commands/AuthSignin.service.ts b/packages/server/src/modules/Auth/commands/AuthSignin.service.ts index 48694d3ea..73439c79a 100644 --- a/packages/server/src/modules/Auth/commands/AuthSignin.service.ts +++ b/packages/server/src/modules/Auth/commands/AuthSignin.service.ts @@ -2,6 +2,7 @@ import { ClsService } from 'nestjs-cls'; import { Inject, Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { SystemUser } from '@/modules/System/models/SystemUser'; +import { TenantModel } from '@/modules/System/models/TenantModel'; import { ModelObject } from 'objection'; import { JwtPayload } from '../Auth.interfaces'; import { InvalidEmailPasswordException } from '../exceptions/InvalidEmailPassword.exception'; @@ -12,6 +13,10 @@ export class AuthSigninService { constructor( @Inject(SystemUser.name) private readonly systemUserModel: typeof SystemUser, + + @Inject(TenantModel.name) + private readonly tenantModel: typeof TenantModel, + private readonly jwtService: JwtService, private readonly clsService: ClsService, ) { } @@ -49,6 +54,7 @@ export class AuthSigninService { */ async verifyPayload(payload: JwtPayload): Promise { let user: SystemUser; + let tenant: TenantModel | undefined; try { user = await this.systemUserModel @@ -56,8 +62,14 @@ export class AuthSigninService { .findOne({ email: payload.sub }) .throwIfNotFound(); + tenant = await this.tenantModel + .query() + .findById(user.tenantId) + .throwIfNotFound(); + this.clsService.set('tenantId', user.tenantId); this.clsService.set('userId', user.id); + this.clsService.set('organizationId', tenant.organizationId); } catch (error) { throw new UserNotFoundException(String(payload.sub)); } diff --git a/packages/server/src/modules/Auth/commands/AuthSignup.service.ts b/packages/server/src/modules/Auth/commands/AuthSignup.service.ts index 90188c77d..8cf42390a 100644 --- a/packages/server/src/modules/Auth/commands/AuthSignup.service.ts +++ b/packages/server/src/modules/Auth/commands/AuthSignup.service.ts @@ -13,7 +13,6 @@ import { IAuthSignedUpEventPayload, IAuthSigningUpEventPayload, } from '../Auth.interfaces'; -import { defaultTo } from 'ramda'; import { ERRORS } from '../Auth.constants'; import { hashPassword } from '../Auth.utils'; import { ClsService } from 'nestjs-cls'; @@ -51,10 +50,10 @@ export class AuthSignupService { const signupConfirmation = this.configService.get('signupConfirmation'); const verifyTokenCrypto = crypto.randomBytes(64).toString('hex'); - const verifiedEnabed = defaultTo(signupConfirmation.enabled, false); + const verifiedEnabed = signupConfirmation.enabled ?? false; const verifyToken = verifiedEnabed ? verifyTokenCrypto : ''; const verified = !verifiedEnabed; - + const inviteAcceptedAt = moment().format('YYYY-MM-DD'); // Triggers signin up event. diff --git a/packages/server/src/modules/Auth/commands/AuthSignupConfirmResend.service.ts b/packages/server/src/modules/Auth/commands/AuthSignupConfirmResend.service.ts index 3f923a032..7e0721bab 100644 --- a/packages/server/src/modules/Auth/commands/AuthSignupConfirmResend.service.ts +++ b/packages/server/src/modules/Auth/commands/AuthSignupConfirmResend.service.ts @@ -4,7 +4,6 @@ import { SystemUser } from '@/modules/System/models/SystemUser'; import { ServiceError } from '@/modules/Items/ServiceError'; import { ERRORS } from '../Auth.constants'; import { events } from '@/common/events/events'; -import { ModelObject } from 'objection'; import { ISignUpConfigmResendedEventPayload } from '../Auth.interfaces'; import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; diff --git a/packages/server/src/modules/Auth/dtos/AuthMetaResponse.dto.ts b/packages/server/src/modules/Auth/dtos/AuthMetaResponse.dto.ts new file mode 100644 index 000000000..b41624025 --- /dev/null +++ b/packages/server/src/modules/Auth/dtos/AuthMetaResponse.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthMetaResponseDto { + @ApiProperty({ description: 'Whether signup is disabled' }) + signupDisabled: boolean; +} diff --git a/packages/server/src/modules/Auth/dtos/AuthResetPassword.dto.ts b/packages/server/src/modules/Auth/dtos/AuthResetPassword.dto.ts new file mode 100644 index 000000000..273ac7396 --- /dev/null +++ b/packages/server/src/modules/Auth/dtos/AuthResetPassword.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthResetPasswordDto { + @ApiProperty({ + example: 'new-password', + description: 'New password', + }) + @IsNotEmpty() + @IsString() + password: string; +} diff --git a/packages/server/src/modules/Auth/dtos/AuthSendResetPassword.dto.ts b/packages/server/src/modules/Auth/dtos/AuthSendResetPassword.dto.ts new file mode 100644 index 000000000..dfad60c1b --- /dev/null +++ b/packages/server/src/modules/Auth/dtos/AuthSendResetPassword.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthSendResetPasswordDto { + @ApiProperty({ + example: 'user@example.com', + description: 'User email address to send reset link to', + }) + @IsNotEmpty() + @IsString() + email: string; +} diff --git a/packages/server/src/modules/Auth/dtos/AuthSigninResponse.dto.ts b/packages/server/src/modules/Auth/dtos/AuthSigninResponse.dto.ts new file mode 100644 index 000000000..615dc6eb6 --- /dev/null +++ b/packages/server/src/modules/Auth/dtos/AuthSigninResponse.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthSigninResponseDto { + @ApiProperty({ description: 'JWT access token' }) + accessToken: string; + + @ApiProperty({ description: 'Organization ID' }) + organizationId: string; + + @ApiProperty({ description: 'Tenant ID' }) + tenantId: number; + + @ApiProperty({ description: 'User ID' }) + userId: number; +} diff --git a/packages/server/src/modules/Auth/dtos/AuthSignupVerify.dto.ts b/packages/server/src/modules/Auth/dtos/AuthSignupVerify.dto.ts new file mode 100644 index 000000000..07774e0a5 --- /dev/null +++ b/packages/server/src/modules/Auth/dtos/AuthSignupVerify.dto.ts @@ -0,0 +1,20 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthSignupVerifyDto { + @ApiProperty({ + example: 'user@example.com', + description: 'User email address', + }) + @IsNotEmpty() + @IsString() + email: string; + + @ApiProperty({ + example: 'confirmation-token', + description: 'Signup confirmation token from email', + }) + @IsNotEmpty() + @IsString() + token: string; +} diff --git a/packages/server/src/modules/BankRules/dtos/BankRule.dto.ts b/packages/server/src/modules/BankRules/dtos/BankRule.dto.ts index 455caa36d..0619a2dc9 100644 --- a/packages/server/src/modules/BankRules/dtos/BankRule.dto.ts +++ b/packages/server/src/modules/BankRules/dtos/BankRule.dto.ts @@ -16,7 +16,7 @@ import { ToNumber } from '@/common/decorators/Validators'; class BankRuleConditionDto { @IsNotEmpty() - @IsIn(['description', 'amount']) + @IsIn(['description', 'amount', 'payee']) field: string; @IsNotEmpty() diff --git a/packages/server/src/modules/BankingAccounts/BankAccounts.controller.ts b/packages/server/src/modules/BankingAccounts/BankAccounts.controller.ts index 2bfeb35ab..f77fe0285 100644 --- a/packages/server/src/modules/BankingAccounts/BankAccounts.controller.ts +++ b/packages/server/src/modules/BankingAccounts/BankAccounts.controller.ts @@ -11,12 +11,6 @@ export class BankAccountsController { @Get() @ApiOperation({ summary: 'Retrieve the bank accounts.' }) - @ApiQuery({ - name: 'query', - description: 'Query parameters for the bank accounts list.', - type: BankAccountsQueryDto, - required: false, - }) @ApiResponse({ status: 200, description: 'List of bank accounts retrieved successfully.', diff --git a/packages/server/src/modules/BankingCategorize/BankingCategorize.controller.ts b/packages/server/src/modules/BankingCategorize/BankingCategorize.controller.ts index 400208851..e650b1cbe 100644 --- a/packages/server/src/modules/BankingCategorize/BankingCategorize.controller.ts +++ b/packages/server/src/modules/BankingCategorize/BankingCategorize.controller.ts @@ -2,7 +2,14 @@ import { Body, Controller, Delete, Param, Post, Query } from '@nestjs/common'; import { castArray, omit } from 'lodash'; import { BankingCategorizeApplication } from './BankingCategorize.application'; import { CategorizeBankTransactionRouteDto } from './dtos/CategorizeBankTransaction.dto'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('banking/categorize') @@ -15,6 +22,7 @@ export class BankingCategorizeController { @Post() @ApiOperation({ summary: 'Categorize bank transactions.' }) + @ApiBody({ type: CategorizeBankTransactionRouteDto }) @ApiResponse({ status: 200, description: 'The bank transactions have been categorized successfully.', @@ -29,21 +37,38 @@ export class BankingCategorizeController { } @Delete('/bulk') - @ApiOperation({ summary: 'Uncategorize bank transactions.' }) + @ApiOperation({ summary: 'Uncategorize bank transactions in bulk.' }) + @ApiQuery({ + name: 'uncategorizedTransactionIds', + required: true, + type: [Number], + isArray: true, + description: 'Array of uncategorized transaction IDs to uncategorize', + }) @ApiResponse({ status: 200, description: 'The bank transactions have been uncategorized successfully.', }) public uncategorizeTransactionsBulk( - @Query() uncategorizedTransactionIds: number[] | number, + @Query('uncategorizedTransactionIds') + uncategorizedTransactionIds: number[] | number, ) { + const ids = castArray(uncategorizedTransactionIds).map((id) => + Number(id), + ); return this.bankingCategorizeApplication.uncategorizeTransactionsBulk( - castArray(uncategorizedTransactionIds), + ids, ); } @Delete('/:id') @ApiOperation({ summary: 'Uncategorize a bank transaction.' }) + @ApiParam({ + name: 'id', + required: true, + type: Number, + description: 'Uncategorized transaction ID to uncategorize', + }) @ApiResponse({ status: 200, description: 'The bank transaction has been uncategorized successfully.', diff --git a/packages/server/src/modules/BankingMatching/BankingMatching.controller.ts b/packages/server/src/modules/BankingMatching/BankingMatching.controller.ts index d4d0f34b0..51f674934 100644 --- a/packages/server/src/modules/BankingMatching/BankingMatching.controller.ts +++ b/packages/server/src/modules/BankingMatching/BankingMatching.controller.ts @@ -1,12 +1,21 @@ -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiExtraModels, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { BankingMatchingApplication } from './BankingMatchingApplication'; -import { GetMatchedTransactionsFilter } from './types'; import { MatchBankTransactionDto } from './dtos/MatchBankTransaction.dto'; +import { GetMatchedTransactionsQueryDto } from './dtos/GetMatchedTransactionsQuery.dto'; +import { GetMatchedTransactionsResponseDto } from './dtos/GetMatchedTransactionsResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('banking/matching') @ApiTags('Banking Transactions Matching') +@ApiExtraModels(GetMatchedTransactionsResponseDto) @ApiCommonHeaders() export class BankingMatchingController { constructor( @@ -15,13 +24,25 @@ export class BankingMatchingController { @Get('matched') @ApiOperation({ summary: 'Retrieves the matched transactions.' }) + @ApiQuery({ + name: 'uncategorizedTransactionIds', + required: true, + type: [Number], + isArray: true, + description: 'Uncategorized transaction IDs to match', + }) + @ApiResponse({ + status: 200, + description: 'Matched transactions (perfect and possible matches).', + schema: { $ref: getSchemaPath(GetMatchedTransactionsResponseDto) }, + }) async getMatchedTransactions( @Query('uncategorizedTransactionIds') uncategorizedTransactionIds: number[], - @Query() filter: GetMatchedTransactionsFilter, + @Query() filter: GetMatchedTransactionsQueryDto, ) { return this.bankingMatchingApplication.getMatchedTransactions( - uncategorizedTransactionIds, - filter, + uncategorizedTransactionIds ?? [], + filter as any, ); } diff --git a/packages/server/src/modules/BankingMatching/dtos/GetMatchedTransactionsQuery.dto.ts b/packages/server/src/modules/BankingMatching/dtos/GetMatchedTransactionsQuery.dto.ts new file mode 100644 index 000000000..9c6163807 --- /dev/null +++ b/packages/server/src/modules/BankingMatching/dtos/GetMatchedTransactionsQuery.dto.ts @@ -0,0 +1,37 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsNumber, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GetMatchedTransactionsQueryDto { + @ApiPropertyOptional({ description: 'Filter from date', example: '2024-01-01' }) + @IsOptional() + @IsString() + fromDate?: string; + + @ApiPropertyOptional({ description: 'Filter to date', example: '2024-12-31' }) + @IsOptional() + @IsString() + toDate?: string; + + @ApiPropertyOptional({ description: 'Minimum amount', example: 0 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + minAmount?: number; + + @ApiPropertyOptional({ description: 'Maximum amount', example: 10000 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Max(Number.MAX_SAFE_INTEGER) + maxAmount?: number; + + @ApiPropertyOptional({ + description: 'Transaction type filter', + example: 'SaleInvoice', + }) + @IsOptional() + @IsString() + transactionType?: string; +} diff --git a/packages/server/src/modules/BankingMatching/dtos/GetMatchedTransactionsResponse.dto.ts b/packages/server/src/modules/BankingMatching/dtos/GetMatchedTransactionsResponse.dto.ts new file mode 100644 index 000000000..861e05658 --- /dev/null +++ b/packages/server/src/modules/BankingMatching/dtos/GetMatchedTransactionsResponse.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class MatchedTransactionItemDto { + @ApiProperty({ description: 'Transaction amount', example: 100.5 }) + amount: number; + + @ApiProperty({ description: 'Formatted amount', example: '$100.50' }) + amountFormatted: string; + + @ApiProperty({ description: 'Transaction date', example: '2024-01-15' }) + date: string; + + @ApiProperty({ description: 'Formatted date', example: 'Jan 15, 2024' }) + dateFormatted: string; + + @ApiProperty({ description: 'Reference number', example: 'REF-001' }) + referenceNo: string; + + @ApiProperty({ description: 'Transaction number', example: 'TXN-001' }) + transactionNo: string; + + @ApiProperty({ description: 'Transaction ID', example: 1 }) + transactionId: number; + + @ApiProperty({ description: 'Transaction type', example: 'SaleInvoice' }) + transactionType: string; +} + +export class GetMatchedTransactionsResponseDto { + @ApiProperty({ + description: 'Perfect matches (amount and date match)', + type: [MatchedTransactionItemDto], + }) + perfectMatches: MatchedTransactionItemDto[]; + + @ApiProperty({ + description: 'Possible matches (candidates)', + type: [MatchedTransactionItemDto], + }) + possibleMatches: MatchedTransactionItemDto[]; + + @ApiProperty({ description: 'Total pending amount', example: 500 }) + totalPending: number; +} diff --git a/packages/server/src/modules/BankingMatching/dtos/MatchBankTransaction.dto.ts b/packages/server/src/modules/BankingMatching/dtos/MatchBankTransaction.dto.ts index 0a9e973cf..306073d14 100644 --- a/packages/server/src/modules/BankingMatching/dtos/MatchBankTransaction.dto.ts +++ b/packages/server/src/modules/BankingMatching/dtos/MatchBankTransaction.dto.ts @@ -30,7 +30,12 @@ export class MatchTransactionEntryDto { export class MatchBankTransactionDto { @IsArray() @ArrayMinSize(1) - uncategorizedTransactions: Array + @ApiProperty({ + description: 'Uncategorized transaction IDs to match', + type: [Number], + example: [1, 2], + }) + uncategorizedTransactions: Array; @IsArray() @ValidateNested({ each: true }) diff --git a/packages/server/src/modules/BankingTranasctionsRegonize/BankingRecognizedTransactions.controller.ts b/packages/server/src/modules/BankingTranasctionsRegonize/BankingRecognizedTransactions.controller.ts index f4072ee7a..2ee21edee 100644 --- a/packages/server/src/modules/BankingTranasctionsRegonize/BankingRecognizedTransactions.controller.ts +++ b/packages/server/src/modules/BankingTranasctionsRegonize/BankingRecognizedTransactions.controller.ts @@ -8,13 +8,14 @@ import { ApiExtraModels, getSchemaPath, } from '@nestjs/swagger'; +import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; +import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { RecognizedTransactionsApplication } from './RecognizedTransactions.application'; import { GetRecognizedTransactionResponseDto } from './dtos/GetRecognizedTransactionResponse.dto'; -import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('banking/recognized') @ApiTags('Banking Recognized Transactions') -@ApiExtraModels(GetRecognizedTransactionResponseDto) +@ApiExtraModels(GetRecognizedTransactionResponseDto, PaginatedResponseDto) @ApiCommonHeaders() export class BankingRecognizedTransactionsController { constructor( @@ -58,10 +59,17 @@ export class BankingRecognizedTransactionsController { status: 200, description: 'Returns a list of recognized transactions', schema: { - type: 'array', - items: { - $ref: getSchemaPath(GetRecognizedTransactionResponseDto), - }, + allOf: [ + { $ref: getSchemaPath(PaginatedResponseDto) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(GetRecognizedTransactionResponseDto) }, + }, + }, + }, + ], }, }) async getRecognizedTransactions(@Query() query: any) { diff --git a/packages/server/src/modules/BankingTranasctionsRegonize/_types.ts b/packages/server/src/modules/BankingTranasctionsRegonize/_types.ts index 71a18e07e..5e5770bd3 100644 --- a/packages/server/src/modules/BankingTranasctionsRegonize/_types.ts +++ b/packages/server/src/modules/BankingTranasctionsRegonize/_types.ts @@ -15,8 +15,13 @@ export const RecognizeUncategorizedTransactionsJob = export const RecognizeUncategorizedTransactionsQueue = 'recognize-uncategorized-transactions-queue'; - export interface RecognizeUncategorizedTransactionsJobPayload extends TenantJobPayload { ruleId: number, - transactionsCriteria: any; + transactionsCriteria?: RecognizeTransactionsCriteria; + /** + * When true, first reverts recognized transactions before recognizing again. + * Used when a bank rule is edited to ensure transactions previously recognized + * by lower-priority rules are re-evaluated against the updated rule. + */ + shouldRevert?: boolean; } \ No newline at end of file diff --git a/packages/server/src/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service.ts b/packages/server/src/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service.ts index 9d79678c0..9fe353530 100644 --- a/packages/server/src/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service.ts +++ b/packages/server/src/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service.ts @@ -93,6 +93,10 @@ export class RecognizeTranasctionsService { q.whereIn('id', rulesIds); } q.withGraphFetched('conditions'); + + // Order by the 'order' field to ensure higher priority rules (lower order values) + // are matched first. + q.orderBy('order', 'asc'); }); const bankRulesByAccountId = transformToMapBy( diff --git a/packages/server/src/modules/BankingTranasctionsRegonize/events/TriggerRecognizedTransactions.ts b/packages/server/src/modules/BankingTranasctionsRegonize/events/TriggerRecognizedTransactions.ts index 791d7d8cd..7a3ce7286 100644 --- a/packages/server/src/modules/BankingTranasctionsRegonize/events/TriggerRecognizedTransactions.ts +++ b/packages/server/src/modules/BankingTranasctionsRegonize/events/TriggerRecognizedTransactions.ts @@ -69,10 +69,13 @@ export class TriggerRecognizedTransactionsSubscriber { const tenantPayload = await this.tenancyContect.getTenantJobPayload(); const payload = { ruleId: bankRule.id, + shouldRevert: true, ...tenantPayload, } as RecognizeUncategorizedTransactionsJobPayload; // Re-recognize the transactions based on the new rules. + // Setting shouldRevert to true ensures that transactions previously recognized + // by this or lower-priority rules are re-evaluated against the updated rule. await this.recognizeTransactionsQueue.add( RecognizeUncategorizedTransactionsJob, payload, diff --git a/packages/server/src/modules/BankingTranasctionsRegonize/jobs/RecognizeTransactionsJob.ts b/packages/server/src/modules/BankingTranasctionsRegonize/jobs/RecognizeTransactionsJob.ts index 3e4c81a85..eb72773f0 100644 --- a/packages/server/src/modules/BankingTranasctionsRegonize/jobs/RecognizeTransactionsJob.ts +++ b/packages/server/src/modules/BankingTranasctionsRegonize/jobs/RecognizeTransactionsJob.ts @@ -3,6 +3,7 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Scope } from '@nestjs/common'; import { ClsService, UseCls } from 'nestjs-cls'; import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service'; +import { RevertRecognizedTransactionsService } from '../commands/RevertRecognizedTransactions.service'; import { RecognizeUncategorizedTransactionsJobPayload, RecognizeUncategorizedTransactionsQueue, @@ -15,10 +16,12 @@ import { export class RegonizeTransactionsPrcessor extends WorkerHost { /** * @param {RecognizeTranasctionsService} recognizeTranasctionsService - + * @param {RevertRecognizedTransactionsService} revertRecognizedTransactionsService - * @param {ClsService} clsService - */ constructor( private readonly recognizeTranasctionsService: RecognizeTranasctionsService, + private readonly revertRecognizedTransactionsService: RevertRecognizedTransactionsService, private readonly clsService: ClsService, ) { super(); @@ -29,12 +32,21 @@ export class RegonizeTransactionsPrcessor extends WorkerHost { */ @UseCls() async process(job: Job) { - const { ruleId, transactionsCriteria } = job.data; + const { ruleId, transactionsCriteria, shouldRevert } = job.data; this.clsService.set('organizationId', job.data.organizationId); this.clsService.set('userId', job.data.userId); try { + // If shouldRevert is true, first revert recognized transactions before re-recognizing. + // This is used when a bank rule is edited to ensure transactions previously recognized + // by lower-priority rules are re-evaluated against the updated rule. + if (shouldRevert) { + await this.revertRecognizedTransactionsService.revertRecognizedTransactions( + ruleId, + transactionsCriteria, + ); + } await this.recognizeTranasctionsService.recognizeTransactions( ruleId, transactionsCriteria, diff --git a/packages/server/src/modules/BankingTransactions/BankingTransactions.module.ts b/packages/server/src/modules/BankingTransactions/BankingTransactions.module.ts index 17521edff..eaf517811 100644 --- a/packages/server/src/modules/BankingTransactions/BankingTransactions.module.ts +++ b/packages/server/src/modules/BankingTransactions/BankingTransactions.module.ts @@ -24,6 +24,7 @@ import { GetBankAccountsService } from './queries/GetBankAccounts.service'; import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { BankAccount } from './models/BankAccount'; import { LedgerModule } from '../Ledger/Ledger.module'; +import { TenancyModule } from '../Tenancy/Tenancy.module'; import { GetBankAccountTransactionsService } from './queries/GetBankAccountTransactions/GetBankAccountTransactions.service'; import { GetBankAccountTransactionsRepository } from './queries/GetBankAccountTransactions/GetBankAccountTransactionsRepo.service'; import { GetUncategorizedTransactions } from './queries/GetUncategorizedTransactions'; @@ -46,6 +47,7 @@ const models = [ LedgerModule, BranchesModule, DynamicListModule, + TenancyModule, ...models, ], controllers: [ diff --git a/packages/server/src/modules/BankingTransactions/constants.ts b/packages/server/src/modules/BankingTransactions/constants.ts index 5f7f219b9..5e2238032 100644 --- a/packages/server/src/modules/BankingTransactions/constants.ts +++ b/packages/server/src/modules/BankingTransactions/constants.ts @@ -27,7 +27,7 @@ export enum CASHFLOW_DIRECTION { } export enum CASHFLOW_TRANSACTION_TYPE { - ONWERS_DRAWING = 'OwnerDrawing', + OWNERS_DRAWING = 'OwnerDrawing', OWNER_CONTRIBUTION = 'OwnerContribution', OTHER_INCOME = 'OtherIncome', TRANSFER_FROM_ACCOUNT = 'TransferFromAccount', @@ -36,7 +36,7 @@ export enum CASHFLOW_TRANSACTION_TYPE { } export const CASHFLOW_TRANSACTION_TYPE_META = { - [`${CASHFLOW_TRANSACTION_TYPE.ONWERS_DRAWING}`]: { + [`${CASHFLOW_TRANSACTION_TYPE.OWNERS_DRAWING}`]: { type: 'OwnerDrawing', direction: CASHFLOW_DIRECTION.OUT, creditType: [ACCOUNT_TYPE.EQUITY], diff --git a/packages/server/src/modules/BankingTransactions/controllers/BankingPendingTransactions.controller.ts b/packages/server/src/modules/BankingTransactions/controllers/BankingPendingTransactions.controller.ts index 2a5bd8fcc..0c75dc471 100644 --- a/packages/server/src/modules/BankingTransactions/controllers/BankingPendingTransactions.controller.ts +++ b/packages/server/src/modules/BankingTransactions/controllers/BankingPendingTransactions.controller.ts @@ -7,14 +7,15 @@ import { getSchemaPath, ApiExtraModels, } from '@nestjs/swagger'; +import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; +import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { BankingTransactionsApplication } from '../BankingTransactionsApplication.service'; import { GetPendingTransactionsQueryDto } from '../dtos/GetPendingTransactionsQuery.dto'; import { GetPendingTransactionResponseDto } from '../dtos/GetPendingTransactionResponse.dto'; -import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('banking/pending') @ApiTags('Banking Pending Transactions') -@ApiExtraModels(GetPendingTransactionResponseDto) +@ApiExtraModels(GetPendingTransactionResponseDto, PaginatedResponseDto) @ApiCommonHeaders() export class BankingPendingTransactionsController { constructor( @@ -27,7 +28,17 @@ export class BankingPendingTransactionsController { status: 200, description: 'Returns a list of pending bank account transactions', schema: { - $ref: getSchemaPath(GetPendingTransactionResponseDto), + allOf: [ + { $ref: getSchemaPath(PaginatedResponseDto) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(GetPendingTransactionResponseDto) }, + }, + }, + }, + ], }, }) @ApiQuery({ diff --git a/packages/server/src/modules/BankingTransactions/controllers/BankingUncategorizedTransactions.controller.ts b/packages/server/src/modules/BankingTransactions/controllers/BankingUncategorizedTransactions.controller.ts index 2d50bf66b..3a6e4b727 100644 --- a/packages/server/src/modules/BankingTransactions/controllers/BankingUncategorizedTransactions.controller.ts +++ b/packages/server/src/modules/BankingTransactions/controllers/BankingUncategorizedTransactions.controller.ts @@ -5,13 +5,17 @@ import { ApiResponse, ApiParam, ApiQuery, + ApiExtraModels, + getSchemaPath, } from '@nestjs/swagger'; import { GetUncategorizedTransactionsQueryDto } from '../dtos/GetUncategorizedTransactionsQuery.dto'; +import { GetAutofillCategorizeTransactionResponseDto } from '../dtos/GetAutofillCategorizeTransactionResponse.dto'; import { BankingTransactionsApplication } from '../BankingTransactionsApplication.service'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('banking/uncategorized') @ApiTags('Banking Uncategorized Transactions') +@ApiExtraModels(GetAutofillCategorizeTransactionResponseDto) @ApiCommonHeaders() export class BankingUncategorizedTransactionsController { constructor( @@ -20,29 +24,29 @@ export class BankingUncategorizedTransactionsController { @Get('autofill') @ApiOperation({ summary: 'Get autofill values for categorize transactions' }) + @ApiQuery({ + name: 'uncategorizedTransactionIds', + required: true, + type: [Number], + isArray: true, + description: 'Uncategorized transaction IDs to get autofill for', + }) @ApiResponse({ status: 200, description: 'Returns autofill values for categorize transactions', - }) - @ApiParam({ - name: 'accountId', - required: true, - type: Number, - description: 'Bank account ID', - }) - @ApiQuery({ - name: 'uncategorizeTransactionsId', - required: true, - type: Number, - description: 'Uncategorize transactions ID', + schema: { $ref: getSchemaPath(GetAutofillCategorizeTransactionResponseDto) }, }) async getAutofillCategorizeTransaction( @Query('uncategorizedTransactionIds') uncategorizedTransactionIds: Array | number, ) { - console.log(uncategorizedTransactionIds); + const ids = Array.isArray(uncategorizedTransactionIds) + ? uncategorizedTransactionIds + : uncategorizedTransactionIds != null + ? [uncategorizedTransactionIds] + : []; return this.bankingTransactionsApplication.getAutofillCategorizeTransaction( - uncategorizedTransactionIds, + ids, ); } diff --git a/packages/server/src/modules/BankingTransactions/dtos/GetAutofillCategorizeTransactionResponse.dto.ts b/packages/server/src/modules/BankingTransactions/dtos/GetAutofillCategorizeTransactionResponse.dto.ts new file mode 100644 index 000000000..031f1017e --- /dev/null +++ b/packages/server/src/modules/BankingTransactions/dtos/GetAutofillCategorizeTransactionResponse.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class GetAutofillCategorizeTransactionResponseDto { + @ApiPropertyOptional({ + description: 'Assigned credit/debit account ID from recognition', + example: 10, + }) + creditAccountId?: number | null; + + @ApiPropertyOptional({ + description: 'Bank account ID (debit)', + example: 5, + }) + debitAccountId?: number | null; + + @ApiProperty({ description: 'Total amount of uncategorized transactions', example: -150.5 }) + amount: number; + + @ApiProperty({ description: 'Formatted amount', example: '$150.50' }) + formattedAmount: string; + + @ApiProperty({ description: 'Transaction date', example: '2024-01-15' }) + date: string; + + @ApiProperty({ description: 'Formatted date', example: 'Jan 15, 2024' }) + formattedDate: string; + + @ApiProperty({ description: 'Whether the transaction is recognized by a rule', example: true }) + isRecognized: boolean; + + @ApiPropertyOptional({ description: 'Bank rule ID that recognized the transaction', example: 1 }) + recognizedByRuleId?: number | null; + + @ApiPropertyOptional({ description: 'Bank rule name that recognized the transaction', example: 'Salary Rule' }) + recognizedByRuleName?: string | null; + + @ApiPropertyOptional({ description: 'Reference number', example: 'REF-001' }) + referenceNo?: string | null; + + @ApiProperty({ description: 'Transaction type (category)', example: 'other_expense' }) + transactionType: string; + + @ApiProperty({ description: 'Whether this is a deposit transaction', example: false }) + isDepositTransaction: boolean; + + @ApiProperty({ description: 'Whether this is a withdrawal transaction', example: true }) + isWithdrawalTransaction: boolean; + + @ApiPropertyOptional({ description: 'Assigned payee from recognition' }) + payee?: string | null; + + @ApiPropertyOptional({ description: 'Assigned memo from recognition' }) + memo?: string | null; +} diff --git a/packages/server/src/modules/BankingTransactions/queries/GetBankAccountTransactions/GetBankAccountTransactions.service.ts b/packages/server/src/modules/BankingTransactions/queries/GetBankAccountTransactions/GetBankAccountTransactions.service.ts index d3f943588..510001435 100644 --- a/packages/server/src/modules/BankingTransactions/queries/GetBankAccountTransactions/GetBankAccountTransactions.service.ts +++ b/packages/server/src/modules/BankingTransactions/queries/GetBankAccountTransactions/GetBankAccountTransactions.service.ts @@ -4,12 +4,14 @@ import { GetBankAccountTransactionsRepository } from './GetBankAccountTransactio import { GetBankAccountTransactions } from './GetBankAccountTransactions'; import { GetBankTransactionsQueryDto } from '../../dtos/GetBankTranasctionsQuery.dto'; import { I18nService } from 'nestjs-i18n'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; @Injectable() export class GetBankAccountTransactionsService { constructor( private readonly getBankAccountTransactionsRepository: GetBankAccountTransactionsRepository, - private readonly i18nService: I18nService + private readonly i18nService: I18nService, + private readonly tenancyContext: TenancyContext, ) {} /** @@ -28,11 +30,16 @@ export class GetBankAccountTransactionsService { await this.getBankAccountTransactionsRepository.asyncInit(); + // Retrieve the tenant metadata to get the date format. + const tenantMetadata = await this.tenancyContext.getTenantMetadata(); + const dateFormat = tenantMetadata?.dateFormat; + // Retrieve the computed report. const report = new GetBankAccountTransactions( this.getBankAccountTransactionsRepository, parsedQuery, - this.i18nService + this.i18nService, + dateFormat, ); const transactions = report.reportData(); const pagination = this.getBankAccountTransactionsRepository.pagination; diff --git a/packages/server/src/modules/BankingTransactions/queries/GetBankAccountTransactions/GetBankAccountTransactions.ts b/packages/server/src/modules/BankingTransactions/queries/GetBankAccountTransactions/GetBankAccountTransactions.ts index 5af75485b..2df7c0527 100644 --- a/packages/server/src/modules/BankingTransactions/queries/GetBankAccountTransactions/GetBankAccountTransactions.ts +++ b/packages/server/src/modules/BankingTransactions/queries/GetBankAccountTransactions/GetBankAccountTransactions.ts @@ -24,17 +24,20 @@ export class GetBankAccountTransactions extends FinancialSheet { * @param {IAccountTransaction[]} transactions - * @param {number} openingBalance - * @param {ICashflowAccountTransactionsQuery} query - + * @param {string} dateFormat - The date format from organization settings. */ constructor( repo: GetBankAccountTransactionsRepository, query: ICashflowAccountTransactionsQuery, i18n: I18nService, + dateFormat?: string, ) { super(); this.repo = repo; this.query = query; this.i18n = i18n; + this.dateFormat = dateFormat || this.dateFormat; this.runningBalance = runningBalance(this.repo.openingBalance); } @@ -98,7 +101,7 @@ export class GetBankAccountTransactions extends FinancialSheet { return { date: transaction.date, - formattedDate: moment(transaction.date).format('YYYY-MM-DD'), + formattedDate: this.getDateFormatted(transaction.date), withdrawal: transaction.credit, deposit: transaction.debit, diff --git a/packages/server/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.controller.ts b/packages/server/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.controller.ts index f0a040995..c3d3c27f2 100644 --- a/packages/server/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.controller.ts +++ b/packages/server/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.controller.ts @@ -4,12 +4,10 @@ import { Delete, Get, Param, - Post, Put, Query, } from '@nestjs/common'; import { ExcludeBankTransactionsApplication } from './ExcludeBankTransactionsApplication'; -import { ExcludedBankTransactionsQuery } from './types/BankTransactionsExclude.types'; import { ApiExtraModels, ApiOperation, @@ -17,12 +15,15 @@ import { ApiTags, getSchemaPath, } from '@nestjs/swagger'; -import { GetExcludedBankTransactionResponseDto } from './dtos/GetExcludedBankTransactionResponse.dto'; +import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { GetExcludedBankTransactionResponseDto } from './dtos/GetExcludedBankTransactionResponse.dto'; +import { ExcludeBankTransactionsBulkDto } from './dtos/ExcludeBankTransactionsBulk.dto'; +import { GetExcludedBankTransactionsQueryDto } from './dtos/GetExcludedBankTransactionsQuery.dto'; @Controller('banking/exclude') @ApiTags('Banking Transactions') -@ApiExtraModels(GetExcludedBankTransactionResponseDto) +@ApiExtraModels(GetExcludedBankTransactionResponseDto, ExcludeBankTransactionsBulkDto, PaginatedResponseDto) @ApiCommonHeaders() export class BankingTransactionsExcludeController { constructor( @@ -31,15 +32,19 @@ export class BankingTransactionsExcludeController { @Put('bulk') @ApiOperation({ summary: 'Exclude the given bank transactions.' }) - public excludeBankTransactions(@Body('ids') ids: number[]) { - return this.excludeBankTransactionsApplication.excludeBankTransactions(ids); + @ApiResponse({ status: 200, description: 'Bank transactions excluded successfully.' }) + public excludeBankTransactions(@Body() body: ExcludeBankTransactionsBulkDto) { + return this.excludeBankTransactionsApplication.excludeBankTransactions( + body.ids, + ); } @Delete('bulk') @ApiOperation({ summary: 'Unexclude the given bank transactions.' }) - public unexcludeBankTransactions(@Body('ids') ids: number[]) { + @ApiResponse({ status: 200, description: 'Bank transactions unexcluded successfully.' }) + public unexcludeBankTransactions(@Body() body: ExcludeBankTransactionsBulkDto) { return this.excludeBankTransactionsApplication.unexcludeBankTransactions( - ids, + body.ids, ); } @@ -50,17 +55,24 @@ export class BankingTransactionsExcludeController { description: 'The excluded bank transactions has been retrieved successfully.', schema: { - type: 'array', - items: { - $ref: getSchemaPath(GetExcludedBankTransactionResponseDto), - }, + allOf: [ + { $ref: getSchemaPath(PaginatedResponseDto) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(GetExcludedBankTransactionResponseDto) }, + }, + }, + }, + ], }, }) public getExcludedBankTransactions( - @Query() query: ExcludedBankTransactionsQuery, + @Query() query: GetExcludedBankTransactionsQueryDto, ) { return this.excludeBankTransactionsApplication.getExcludedBankTransactions( - query, + query as any, ); } diff --git a/packages/server/src/modules/BankingTransactionsExclude/dtos/ExcludeBankTransactionsBulk.dto.ts b/packages/server/src/modules/BankingTransactionsExclude/dtos/ExcludeBankTransactionsBulk.dto.ts new file mode 100644 index 000000000..85671632f --- /dev/null +++ b/packages/server/src/modules/BankingTransactionsExclude/dtos/ExcludeBankTransactionsBulk.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsNumber, ArrayMinSize } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ExcludeBankTransactionsBulkDto { + @ApiProperty({ + description: 'IDs of uncategorized bank transactions to exclude or unexclude', + type: [Number], + example: [1, 2, 3], + }) + @IsArray() + @ArrayMinSize(1) + @IsNumber({}, { each: true }) + @Type(() => Number) + ids: number[]; +} diff --git a/packages/server/src/modules/BankingTransactionsExclude/dtos/GetExcludedBankTransactionsQuery.dto.ts b/packages/server/src/modules/BankingTransactionsExclude/dtos/GetExcludedBankTransactionsQuery.dto.ts new file mode 100644 index 000000000..a6abd165b --- /dev/null +++ b/packages/server/src/modules/BankingTransactionsExclude/dtos/GetExcludedBankTransactionsQuery.dto.ts @@ -0,0 +1,45 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsNumber, IsDateString } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GetExcludedBankTransactionsQueryDto { + @ApiPropertyOptional({ description: 'Page number', example: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + page?: number; + + @ApiPropertyOptional({ description: 'Page size', example: 25 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + pageSize?: number; + + @ApiPropertyOptional({ description: 'Filter by bank account ID', example: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + accountId?: number; + + @ApiPropertyOptional({ description: 'Minimum date (ISO)', example: '2024-01-01' }) + @IsOptional() + @IsDateString() + minDate?: string; + + @ApiPropertyOptional({ description: 'Maximum date (ISO)', example: '2024-12-31' }) + @IsOptional() + @IsDateString() + maxDate?: string; + + @ApiPropertyOptional({ description: 'Minimum amount', example: 0 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + minAmount?: number; + + @ApiPropertyOptional({ description: 'Maximum amount', example: 10000 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + maxAmount?: number; +} diff --git a/packages/server/src/modules/BillLandedCosts/models/BillLandedCostEntry.ts b/packages/server/src/modules/BillLandedCosts/models/BillLandedCostEntry.ts index 02a4cf233..f4e87ef4c 100644 --- a/packages/server/src/modules/BillLandedCosts/models/BillLandedCostEntry.ts +++ b/packages/server/src/modules/BillLandedCosts/models/BillLandedCostEntry.ts @@ -13,6 +13,13 @@ export class BillLandedCostEntry extends BaseModel { return 'bill_located_cost_entries'; } + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/modules/BillPayments/BillPayments.controller.ts b/packages/server/src/modules/BillPayments/BillPayments.controller.ts index 36d9c5785..4b0aba2c2 100644 --- a/packages/server/src/modules/BillPayments/BillPayments.controller.ts +++ b/packages/server/src/modules/BillPayments/BillPayments.controller.ts @@ -7,12 +7,14 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { BillPaymentsApplication } from './BillPaymentsApplication.service'; import { ApiExtraModels, ApiOperation, ApiParam, + ApiQuery, ApiResponse, ApiTags, getSchemaPath, @@ -26,12 +28,18 @@ import { BillPaymentsPages } from './commands/BillPaymentsPages.service'; import { BillPaymentResponseDto } from './dtos/BillPaymentResponse.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { IPaymentMadeAction } from './types/BillPayments.types'; @Controller('bill-payments') @ApiTags('Bill Payments') @ApiExtraModels(BillPaymentResponseDto) @ApiExtraModels(PaginatedResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class BillPaymentsController { constructor( private billPaymentsApplication: BillPaymentsApplication, @@ -39,12 +47,14 @@ export class BillPaymentsController { ) {} @Post() + @RequirePermission(IPaymentMadeAction.Create, AbilitySubject.PaymentMade) @ApiOperation({ summary: 'Create a new bill payment.' }) public createBillPayment(@Body() billPaymentDTO: CreateBillPaymentDto) { return this.billPaymentsApplication.createBillPayment(billPaymentDTO); } @Delete(':billPaymentId') + @RequirePermission(IPaymentMadeAction.Delete, AbilitySubject.PaymentMade) @ApiOperation({ summary: 'Delete the given bill payment.' }) @ApiParam({ name: 'billPaymentId', @@ -59,6 +69,7 @@ export class BillPaymentsController { } @Put(':billPaymentId') + @RequirePermission(IPaymentMadeAction.Edit, AbilitySubject.PaymentMade) @ApiOperation({ summary: 'Edit the given bill payment.' }) @ApiParam({ name: 'billPaymentId', @@ -77,11 +88,12 @@ export class BillPaymentsController { } @Get('/new-page/entries') + @RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade) @ApiOperation({ summary: 'Retrieves the payable entries of the new page once vendor be selected.', }) - @ApiParam({ + @ApiQuery({ name: 'vendorId', required: true, type: Number, @@ -95,6 +107,7 @@ export class BillPaymentsController { } @Get(':billPaymentId/bills') + @RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade) @ApiOperation({ summary: 'Retrieves the bills of the given bill payment.' }) @ApiParam({ name: 'billPaymentId', @@ -107,6 +120,7 @@ export class BillPaymentsController { } @Get('/:billPaymentId/edit-page') + @RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade) @ApiOperation({ summary: 'Retrieves the edit page of the given bill payment.', }) @@ -126,6 +140,7 @@ export class BillPaymentsController { } @Get(':billPaymentId') + @RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade) @ApiOperation({ summary: 'Retrieves the bill payment details.' }) @ApiResponse({ status: 200, @@ -145,6 +160,7 @@ export class BillPaymentsController { } @Get() + @RequirePermission(IPaymentMadeAction.View, AbilitySubject.PaymentMade) @ApiOperation({ summary: 'Retrieves the bill payments list.' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/BillPayments/models/BillPayment.ts b/packages/server/src/modules/BillPayments/models/BillPayment.ts index 5f05d9e4b..2910095de 100644 --- a/packages/server/src/modules/BillPayments/models/BillPayment.ts +++ b/packages/server/src/modules/BillPayments/models/BillPayment.ts @@ -9,7 +9,9 @@ import { BillPaymentMeta } from './BillPayment.meta'; import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { BillPaymentDefaultViews } from '../constants'; +import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; +@InjectAttachable() @ImportableModel() @ExportableModel() @InjectModelMeta(BillPaymentMeta) diff --git a/packages/server/src/modules/Bills/Bills.application.ts b/packages/server/src/modules/Bills/Bills.application.ts index 207b400ee..9a99e49ed 100644 --- a/packages/server/src/modules/Bills/Bills.application.ts +++ b/packages/server/src/modules/Bills/Bills.application.ts @@ -2,7 +2,8 @@ import { CreateBill } from './commands/CreateBill.service'; import { EditBillService } from './commands/EditBill.service'; import { GetBill } from './queries/GetBill'; import { DeleteBill } from './commands/DeleteBill.service'; -import { IBillDTO, IBillEditDTO, IBillsFilter } from './Bills.types'; +import { IBillDTO, IBillEditDTO } from './Bills.types'; +import { GetBillsQueryDto } from './dtos/GetBillsQuery.dto'; import { GetDueBills } from './queries/GetDueBills.service'; import { OpenBillService } from './commands/OpenBill.service'; import { Injectable } from '@nestjs/common'; @@ -78,9 +79,9 @@ export class BillsApplication { /** * Retrieve bills data table list. - * @param {IBillsFilter} billsFilter - + * @param {GetBillsQueryDto} filterDTO - */ - public getBills(filterDTO: Partial) { + public getBills(filterDTO: GetBillsQueryDto) { return this.getBillsService.getBills(filterDTO); } diff --git a/packages/server/src/modules/Bills/Bills.controller.ts b/packages/server/src/modules/Bills/Bills.controller.ts index e81f97393..92f684a90 100644 --- a/packages/server/src/modules/Bills/Bills.controller.ts +++ b/packages/server/src/modules/Bills/Bills.controller.ts @@ -2,6 +2,7 @@ import { ApiExtraModels, ApiOperation, ApiParam, + ApiQuery, ApiResponse, ApiTags, getSchemaPath, @@ -17,10 +18,11 @@ import { Get, Query, HttpCode, + UseGuards, } from '@nestjs/common'; import { BillsApplication } from './Bills.application'; -import { IBillsFilter } from './Bills.types'; import { CreateBillDto, EditBillDto } from './dtos/Bill.dto'; +import { GetBillsQueryDto } from './dtos/GetBillsQuery.dto'; import { BillResponseDto } from './dtos/BillResponse.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @@ -28,6 +30,11 @@ import { BulkDeleteDto, ValidateBulkDeleteResponseDto, } from '@/common/dtos/BulkDelete.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { BillAction } from './Bills.types'; @Controller('bills') @ApiTags('Bills') @@ -35,10 +42,12 @@ import { @ApiExtraModels(PaginatedResponseDto) @ApiCommonHeaders() @ApiExtraModels(ValidateBulkDeleteResponseDto) +@UseGuards(AuthorizationGuard, PermissionGuard) export class BillsController { constructor(private billsApplication: BillsApplication) { } @Post('validate-bulk-delete') + @RequirePermission(BillAction.Delete, AbilitySubject.Bill) @ApiOperation({ summary: 'Validate which bills can be deleted and return the results.', }) @@ -58,6 +67,7 @@ export class BillsController { } @Post('bulk-delete') + @RequirePermission(BillAction.Delete, AbilitySubject.Bill) @ApiOperation({ summary: 'Deletes multiple bills.' }) @HttpCode(200) @ApiResponse({ @@ -73,12 +83,14 @@ export class BillsController { } @Post() + @RequirePermission(BillAction.Create, AbilitySubject.Bill) @ApiOperation({ summary: 'Create a new bill.' }) createBill(@Body() billDTO: CreateBillDto) { return this.billsApplication.createBill(billDTO); } @Put(':id') + @RequirePermission(BillAction.Edit, AbilitySubject.Bill) @ApiOperation({ summary: 'Edit the given bill.' }) @ApiParam({ name: 'id', @@ -91,6 +103,7 @@ export class BillsController { } @Delete(':id') + @RequirePermission(BillAction.Delete, AbilitySubject.Bill) @ApiOperation({ summary: 'Delete the given bill.' }) @ApiParam({ name: 'id', @@ -103,6 +116,7 @@ export class BillsController { } @Get() + @RequirePermission(BillAction.View, AbilitySubject.Bill) @ApiOperation({ summary: 'Retrieves the bills.' }) @ApiResponse({ status: 200, @@ -127,11 +141,12 @@ export class BillsController { type: Number, description: 'The bill id', }) - getBills(@Query() filterDTO: Partial) { + getBills(@Query() filterDTO: GetBillsQueryDto) { return this.billsApplication.getBills(filterDTO); } @Get(':id/payment-transactions') + @RequirePermission(BillAction.View, AbilitySubject.Bill) @ApiOperation({ summary: 'Retrieve the specific bill associated payment transactions.', }) @@ -141,11 +156,16 @@ export class BillsController { type: Number, description: 'The bill id', }) + @ApiResponse({ + status: 200, + description: 'List of payment transactions for the bill.', + }) getBillPaymentTransactions(@Param('id') billId: number) { return this.billsApplication.getBillPaymentTransactions(billId); } @Get(':id') + @RequirePermission(BillAction.View, AbilitySubject.Bill) @ApiOperation({ summary: 'Retrieves the bill details.' }) @ApiResponse({ status: 200, @@ -165,6 +185,7 @@ export class BillsController { } @Patch(':id/open') + @RequirePermission(BillAction.Edit, AbilitySubject.Bill) @ApiOperation({ summary: 'Open the given bill.' }) @ApiParam({ name: 'id', @@ -177,8 +198,19 @@ export class BillsController { } @Get('due') + @RequirePermission(BillAction.View, AbilitySubject.Bill) @ApiOperation({ summary: 'Retrieves the due bills.' }) - getDueBills(@Body('vendorId') vendorId?: number) { + @ApiQuery({ + name: 'vendor_id', + required: false, + type: Number, + description: 'Filter due bills by vendor ID.', + }) + @ApiResponse({ + status: 200, + description: 'List of due bills (optionally filtered by vendor).', + }) + getDueBills(@Query('vendor_id') vendorId?: number) { return this.billsApplication.getDueBills(vendorId); } } diff --git a/packages/server/src/modules/Bills/dtos/BillResponse.dto.ts b/packages/server/src/modules/Bills/dtos/BillResponse.dto.ts index b082b1496..990070fc9 100644 --- a/packages/server/src/modules/Bills/dtos/BillResponse.dto.ts +++ b/packages/server/src/modules/Bills/dtos/BillResponse.dto.ts @@ -1,6 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { ItemEntryDto } from '@/modules/TransactionItemEntry/dto/ItemEntry.dto'; import { AttachmentLinkDto } from '@/modules/Attachments/dtos/Attachment.dto'; +import { BranchResponseDto } from '@/modules/Branches/dtos/BranchResponse.dto'; import { DiscountType } from '@/common/types/Discount'; export class BillResponseDto { @@ -89,6 +91,14 @@ export class BillResponseDto { }) branchId?: number; + @ApiProperty({ + description: 'Branch details', + type: () => BranchResponseDto, + required: false, + }) + @Type(() => BranchResponseDto) + branch?: BranchResponseDto; + @ApiProperty({ description: 'The ID of the project', example: 301, diff --git a/packages/server/src/modules/Bills/dtos/GetBillsQuery.dto.ts b/packages/server/src/modules/Bills/dtos/GetBillsQuery.dto.ts new file mode 100644 index 000000000..063fc3001 --- /dev/null +++ b/packages/server/src/modules/Bills/dtos/GetBillsQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetBillsQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/Bills/models/Bill.ts b/packages/server/src/modules/Bills/models/Bill.ts index 807c746e0..1f3f46241 100644 --- a/packages/server/src/modules/Bills/models/Bill.ts +++ b/packages/server/src/modules/Bills/models/Bill.ts @@ -11,9 +11,12 @@ import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { BillMeta } from './Bill.meta'; +import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { BillDefaultViews } from '../Bills.constants'; +import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; +@InjectAttachable() @ExportableModel() @InjectModelMeta(BillMeta) @InjectModelDefaultViews(BillDefaultViews) @@ -405,7 +408,8 @@ export class Bill extends TenantBaseModel { * Sort the bills by full-payment bills. */ sortByStatus(query, order) { - query.orderByRaw(`PAYMENT_AMOUNT = AMOUNT ${order}`); + const dir = sanitizeSortDirection(order); + query.orderByRaw(`PAYMENT_AMOUNT = AMOUNT ${dir}`); }, /** diff --git a/packages/server/src/modules/Bills/queries/Bill.transformer.ts b/packages/server/src/modules/Bills/queries/Bill.transformer.ts index 851a284a4..b264d251a 100644 --- a/packages/server/src/modules/Bills/queries/Bill.transformer.ts +++ b/packages/server/src/modules/Bills/queries/Bill.transformer.ts @@ -30,6 +30,7 @@ export class BillTransformer extends Transformer { 'taxes', 'entries', 'attachments', + 'branch', ]; }; diff --git a/packages/server/src/modules/Bills/queries/GetBills.service.ts b/packages/server/src/modules/Bills/queries/GetBills.service.ts index 961ecf6df..594793c44 100644 --- a/packages/server/src/modules/Bills/queries/GetBills.service.ts +++ b/packages/server/src/modules/Bills/queries/GetBills.service.ts @@ -5,7 +5,7 @@ import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service import { Bill } from '../models/Bill'; import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model'; import { BillTransformer } from './Bill.transformer'; -import { IBillsFilter } from '../Bills.types'; +import { GetBillsQueryDto } from '../dtos/GetBillsQuery.dto'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @Injectable() @@ -19,10 +19,10 @@ export class GetBillsService { /** * Retrieve bills data table list. - * @param {IBillsFilter} billsFilter - + * @param {GetBillsQueryDto} filterDTO - */ - public async getBills(filterDTO: Partial): Promise<{ - bills: Bill; + public async getBills(filterDTO: GetBillsQueryDto): Promise<{ + bills: Bill[]; pagination: IPaginationMeta; filterMeta: IFilterMeta; }> { diff --git a/packages/server/src/modules/Branches/Branches.module.ts b/packages/server/src/modules/Branches/Branches.module.ts index d53e1d4c9..a6c661d45 100644 --- a/packages/server/src/modules/Branches/Branches.module.ts +++ b/packages/server/src/modules/Branches/Branches.module.ts @@ -31,6 +31,12 @@ import { ValidateBranchExistance } from './integrations/ValidateBranchExistance' import { ManualJournalBranchesValidator } from './integrations/ManualJournals/ManualJournalsBranchesValidator'; import { CashflowTransactionsActivateBranches } from './integrations/Cashflow/CashflowActivateBranches'; import { ExpensesActivateBranches } from './integrations/Expense/ExpensesActivateBranches'; +import { BillActivateBranches } from './integrations/Purchases/BillBranchesActivate'; +import { VendorCreditActivateBranches } from './integrations/Purchases/VendorCreditBranchesActivate'; +import { BillPaymentsActivateBranches } from './integrations/Purchases/PaymentMadeBranchesActivate'; +import { BillBranchesActivateSubscriber } from './subscribers/Activate/BillBranchesActivateSubscriber'; +import { VendorCreditBranchesActivateSubscriber } from './subscribers/Activate/VendorCreditBranchesActivateSubscriber'; +import { PaymentMadeActivateBranchesSubscriber } from './subscribers/Activate/PaymentMadeBranchesActivateSubscriber'; import { FeaturesModule } from '../Features/Features.module'; @Module({ @@ -66,7 +72,13 @@ import { FeaturesModule } from '../Features/Features.module'; ValidateBranchExistance, ManualJournalBranchesValidator, CashflowTransactionsActivateBranches, - ExpensesActivateBranches + ExpensesActivateBranches, + BillActivateBranches, + VendorCreditActivateBranches, + BillPaymentsActivateBranches, + BillBranchesActivateSubscriber, + VendorCreditBranchesActivateSubscriber, + PaymentMadeActivateBranchesSubscriber ], exports: [ BranchesSettingsService, diff --git a/packages/server/src/modules/Branches/integrations/Purchases/BillBranchesActivate.ts b/packages/server/src/modules/Branches/integrations/Purchases/BillBranchesActivate.ts index e05dd7619..e12af7615 100644 --- a/packages/server/src/modules/Branches/integrations/Purchases/BillBranchesActivate.ts +++ b/packages/server/src/modules/Branches/integrations/Purchases/BillBranchesActivate.ts @@ -1,11 +1,14 @@ import { Knex } from 'knex'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { Bill } from '@/modules/Bills/models/Bill'; @Injectable() export class BillActivateBranches { - constructor(private readonly billModel: TenantModelProxy) {} + constructor( + @Inject(Bill.name) + private readonly billModel: TenantModelProxy, + ) {} /** * Updates all bills transactions with the primary branch. @@ -17,7 +20,7 @@ export class BillActivateBranches { primaryBranchId: number, trx?: Knex.Transaction, ) => { - // Updates the sale invoice with primary branch. - await Bill.query(trx).update({ branchId: primaryBranchId }); + // Updates the bills with primary branch. + await this.billModel().query(trx).update({ branchId: primaryBranchId }); }; } diff --git a/packages/server/src/modules/Branches/integrations/Purchases/PaymentMadeBranchesActivate.ts b/packages/server/src/modules/Branches/integrations/Purchases/PaymentMadeBranchesActivate.ts index 32645f612..dcfc1c15d 100644 --- a/packages/server/src/modules/Branches/integrations/Purchases/PaymentMadeBranchesActivate.ts +++ b/packages/server/src/modules/Branches/integrations/Purchases/PaymentMadeBranchesActivate.ts @@ -1,11 +1,12 @@ import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { BillPayment } from '@/modules/BillPayments/models/BillPayment'; -import { Injectable } from '@nestjs/common'; @Injectable() export class BillPaymentsActivateBranches { constructor( + @Inject(BillPayment.name) private readonly billPaymentModel: TenantModelProxy, ) {} diff --git a/packages/server/src/modules/Branches/integrations/Purchases/VendorCreditBranchesActivate.ts b/packages/server/src/modules/Branches/integrations/Purchases/VendorCreditBranchesActivate.ts index 3dfb98174..0b33f2441 100644 --- a/packages/server/src/modules/Branches/integrations/Purchases/VendorCreditBranchesActivate.ts +++ b/packages/server/src/modules/Branches/integrations/Purchases/VendorCreditBranchesActivate.ts @@ -1,11 +1,12 @@ import { Knex } from 'knex'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { VendorCredit } from '@/modules/VendorCredit/models/VendorCredit'; @Injectable() export class VendorCreditActivateBranches { constructor( + @Inject(VendorCredit.name) private readonly vendorCreditModel: TenantModelProxy, ) {} diff --git a/packages/server/src/modules/Branches/models/Branch.model.ts b/packages/server/src/modules/Branches/models/Branch.model.ts index d09d30845..e60750176 100644 --- a/packages/server/src/modules/Branches/models/Branch.model.ts +++ b/packages/server/src/modules/Branches/models/Branch.model.ts @@ -29,7 +29,7 @@ export class Branch extends BaseModel{ * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** diff --git a/packages/server/src/modules/Branches/subscribers/Activate/BillBranchesActivateSubscriber.ts b/packages/server/src/modules/Branches/subscribers/Activate/BillBranchesActivateSubscriber.ts new file mode 100644 index 000000000..c819932f4 --- /dev/null +++ b/packages/server/src/modules/Branches/subscribers/Activate/BillBranchesActivateSubscriber.ts @@ -0,0 +1,28 @@ +import { IBranchesActivatedPayload } from '../../Branches.types'; +import { events } from '@/common/events/events'; +import { Injectable } from '@nestjs/common'; +import { BillActivateBranches } from '../../integrations/Purchases/BillBranchesActivate'; +import { OnEvent } from '@nestjs/event-emitter'; + +@Injectable() +export class BillBranchesActivateSubscriber { + constructor( + private readonly billActivateBranches: BillActivateBranches, + ) { } + + /** + * Updates bills transactions with the primary branch once + * the multi-branches is activated. + * @param {IBranchesActivatedPayload} + */ + @OnEvent(events.branch.onActivated) + async updateBillsWithBranchOnActivated({ + primaryBranch, + trx, + }: IBranchesActivatedPayload) { + await this.billActivateBranches.updateBillsWithBranch( + primaryBranch.id, + trx, + ); + } +} diff --git a/packages/server/src/modules/Branches/subscribers/Activate/VendorCreditBranchesActivateSubscriber.ts b/packages/server/src/modules/Branches/subscribers/Activate/VendorCreditBranchesActivateSubscriber.ts new file mode 100644 index 000000000..3d2991092 --- /dev/null +++ b/packages/server/src/modules/Branches/subscribers/Activate/VendorCreditBranchesActivateSubscriber.ts @@ -0,0 +1,28 @@ +import { IBranchesActivatedPayload } from '../../Branches.types'; +import { events } from '@/common/events/events'; +import { Injectable } from '@nestjs/common'; +import { VendorCreditActivateBranches } from '../../integrations/Purchases/VendorCreditBranchesActivate'; +import { OnEvent } from '@nestjs/event-emitter'; + +@Injectable() +export class VendorCreditBranchesActivateSubscriber { + constructor( + private readonly vendorCreditActivateBranches: VendorCreditActivateBranches, + ) { } + + /** + * Updates vendor credits transactions with the primary branch once + * the multi-branches is activated. + * @param {IBranchesActivatedPayload} + */ + @OnEvent(events.branch.onActivated) + async updateVendorCreditsWithBranchOnActivated({ + primaryBranch, + trx, + }: IBranchesActivatedPayload) { + await this.vendorCreditActivateBranches.updateVendorCreditsWithBranch( + primaryBranch.id, + trx, + ); + } +} diff --git a/packages/server/src/modules/CLI/CLI.module.ts b/packages/server/src/modules/CLI/CLI.module.ts index d62b9f13a..df491b23e 100644 --- a/packages/server/src/modules/CLI/CLI.module.ts +++ b/packages/server/src/modules/CLI/CLI.module.ts @@ -11,6 +11,7 @@ import { TenantsMigrateMakeCommand } from './commands/TenantsMigrateMake.command import { TenantsListCommand } from './commands/TenantsList.command'; import { SystemSeedLatestCommand } from './commands/SystemSeedLatest.command'; import { TenantsSeedLatestCommand } from './commands/TenantsSeedLatest.command'; +import { OpenApiExportCommand } from './commands/OpenApiExport.command'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { TenantsSeedLatestCommand } from './commands/TenantsSeedLatest.command'; TenantsListCommand, SystemSeedLatestCommand, TenantsSeedLatestCommand, + OpenApiExportCommand, ], }) export class CLIModule { } diff --git a/packages/server/src/modules/CLI/commands/OpenApiExport.command.ts b/packages/server/src/modules/CLI/commands/OpenApiExport.command.ts new file mode 100644 index 000000000..981d417e5 --- /dev/null +++ b/packages/server/src/modules/CLI/commands/OpenApiExport.command.ts @@ -0,0 +1,44 @@ +import { Command, CommandRunner } from 'nest-commander'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { ClsMiddleware } from 'nestjs-cls'; +import * as path from 'path'; +import * as fs from 'fs'; +import '@/utils/moment-mysql'; +import { AppModule } from '@/modules/App/App.module'; +import { NestExpressApplication } from '@nestjs/platform-express'; + +@Command({ + name: 'openapi:export', + description: 'Export the OpenAPI document from the NestJS app to shared/sdk-ts/openapi.json', +}) +export class OpenApiExportCommand extends CommandRunner { + async run(): Promise { + const serverRoot = process.cwd(); + global.__public_dirname = path.join(serverRoot, 'public'); + global.__static_dirname = path.join(serverRoot, 'static'); + global.__views_dirname = path.join(global.__static_dirname, '/views'); + global.__images_dirname = path.join(global.__static_dirname, '/images'); + + const app = await NestFactory.create(AppModule, { + rawBody: true, + }); + app.set('query parser', 'extended'); + app.setGlobalPrefix('/api'); + app.use(new ClsMiddleware({}).use); + + const config = new DocumentBuilder() + .setTitle('Bigcapital') + .setDescription('Financial accounting software') + .setVersion('1.0') + .build(); + + const document = SwaggerModule.createDocument(app, config); + await app.close(); + + const outputPath = path.resolve(process.cwd(), '../../shared/sdk-ts/openapi.json'); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(document, null, 2), 'utf-8'); + console.log(`OpenAPI spec written to ${outputPath}`); + } +} diff --git a/packages/server/src/modules/ChromiumlyTenancy/models/DocumentLink.ts b/packages/server/src/modules/ChromiumlyTenancy/models/DocumentLink.ts index bbde542a5..5f851003e 100644 --- a/packages/server/src/modules/ChromiumlyTenancy/models/DocumentLink.ts +++ b/packages/server/src/modules/ChromiumlyTenancy/models/DocumentLink.ts @@ -28,7 +28,7 @@ export class DocumentLink extends BaseModel{ * Relationship mapping. */ static get relationMappings() { - const Document = require('./Document'); + const { Document } = require('./Document'); return { /** @@ -36,7 +36,7 @@ export class DocumentLink extends BaseModel{ */ document: { relation: Model.HasOneRelation, - modelClass: Document.default, + modelClass: Document, join: { from: 'document_links.documentId', to: 'documents.id', diff --git a/packages/server/src/modules/Contacts/Contacts.controller.ts b/packages/server/src/modules/Contacts/Contacts.controller.ts index 6d035cacd..6078a25bf 100644 --- a/packages/server/src/modules/Contacts/Contacts.controller.ts +++ b/packages/server/src/modules/Contacts/Contacts.controller.ts @@ -6,9 +6,10 @@ import { Patch, ParseIntPipe, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { GetContactsAutoCompleteQuery } from './dtos/GetContactsAutoCompleteQuery.dto'; import { GetAutoCompleteContactsService } from './queries/GetAutoCompleteContacts.service'; +import { GetContactService } from './queries/GetContact.service'; import { ActivateContactService } from './commands/ActivateContact.service'; import { InactivateContactService } from './commands/InactivateContact.service'; @@ -17,6 +18,7 @@ import { InactivateContactService } from './commands/InactivateContact.service'; export class ContactsController { constructor( private readonly getAutoCompleteService: GetAutoCompleteContactsService, + private readonly getContactService: GetContactService, private readonly activateContactService: ActivateContactService, private readonly inactivateContactService: InactivateContactService, ) {} @@ -27,6 +29,14 @@ export class ContactsController { return this.getAutoCompleteService.autocompleteContacts(query); } + @Get(':id') + @ApiOperation({ summary: 'Get contact by ID (customer or vendor)' }) + @ApiParam({ name: 'id', type: Number, description: 'Contact ID' }) + @ApiResponse({ status: 200, description: 'Contact details (under "customer" key for form/duplicate use)' }) + getContact(@Param('id', ParseIntPipe) contactId: number) { + return this.getContactService.getContact(contactId); + } + @Patch(':id/activate') @ApiOperation({ summary: 'Activate a contact' }) @ApiParam({ name: 'id', type: 'number', description: 'Contact ID' }) diff --git a/packages/server/src/modules/Contacts/Contacts.module.ts b/packages/server/src/modules/Contacts/Contacts.module.ts index ce7690319..34d5c2043 100644 --- a/packages/server/src/modules/Contacts/Contacts.module.ts +++ b/packages/server/src/modules/Contacts/Contacts.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { GetAutoCompleteContactsService } from './queries/GetAutoCompleteContacts.service'; +import { GetContactService } from './queries/GetContact.service'; import { ContactsController } from './Contacts.controller'; import { ActivateContactService } from './commands/ActivateContact.service'; import { InactivateContactService } from './commands/InactivateContact.service'; @@ -7,6 +8,7 @@ import { InactivateContactService } from './commands/InactivateContact.service'; @Module({ providers: [ GetAutoCompleteContactsService, + GetContactService, ActivateContactService, InactivateContactService, ], diff --git a/packages/server/src/modules/Contacts/queries/GetContact.service.ts b/packages/server/src/modules/Contacts/queries/GetContact.service.ts new file mode 100644 index 000000000..d82aa26df --- /dev/null +++ b/packages/server/src/modules/Contacts/queries/GetContact.service.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Contact } from '../models/Contact'; +import { ContactTransfromer } from '../Contact.transformer'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; + +@Injectable() +export class GetContactService { + constructor( + private readonly transformer: TransformerInjectable, + @Inject(Contact.name) + private readonly contactModel: TenantModelProxy, + ) {} + + /** + * Retrieve contact by id (customer or vendor). + * Returns transformed contact for duplicate/form use. + */ + async getContact(contactId: number): Promise> { + const contact = await this.contactModel() + .query() + .findById(contactId) + .throwIfNotFound(); + + return this.transformer.transform(contact, new ContactTransfromer()); + } +} diff --git a/packages/server/src/modules/Contacts/types/Contacts.types.ts b/packages/server/src/modules/Contacts/types/Contacts.types.ts index 1d5fd1016..352d69e15 100644 --- a/packages/server/src/modules/Contacts/types/Contacts.types.ts +++ b/packages/server/src/modules/Contacts/types/Contacts.types.ts @@ -10,7 +10,7 @@ export interface IContactAddress { billingAddressCity: string; billingAddressCountry: string; billingAddressEmail: string; - billingAddressZipcode: string; + billingAddressPostcode: string; billingAddressPhone: string; billingAddressState: string; @@ -19,7 +19,7 @@ export interface IContactAddress { shippingAddressCity: string; shippingAddressCountry: string; shippingAddressEmail: string; - shippingAddressZipcode: string; + shippingAddressPostcode: string; shippingAddressPhone: string; shippingAddressState: string; } @@ -29,7 +29,7 @@ export interface IContactAddressDTO { billingAddressCity?: string; billingAddressCountry?: string; billingAddressEmail?: string; - billingAddressZipcode?: string; + billingAddressPostcode?: string; billingAddressPhone?: string; billingAddressState?: string; @@ -38,7 +38,7 @@ export interface IContactAddressDTO { shippingAddressCity?: string; shippingAddressCountry?: string; shippingAddressEmail?: string; - shippingAddressZipcode?: string; + shippingAddressPostcode?: string; shippingAddressPhone?: string; shippingAddressState?: string; } diff --git a/packages/server/src/modules/CreditNoteRefunds/CreditNoteRefunds.controller.ts b/packages/server/src/modules/CreditNoteRefunds/CreditNoteRefunds.controller.ts index 5945a0a5e..4bdd7f0ee 100644 --- a/packages/server/src/modules/CreditNoteRefunds/CreditNoteRefunds.controller.ts +++ b/packages/server/src/modules/CreditNoteRefunds/CreditNoteRefunds.controller.ts @@ -1,27 +1,76 @@ -import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { + ApiExtraModels, + ApiOperation, + ApiResponse, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + UseGuards, +} from '@nestjs/common'; import { ICreditNoteRefundDTO } from '../CreditNotes/types/CreditNotes.types'; import { CreditNotesRefundsApplication } from './CreditNotesRefundsApplication.service'; import { RefundCreditNote } from './models/RefundCreditNote'; import { CreditNoteRefundDto } from './dto/CreditNoteRefund.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { CreditNoteAction } from '../CreditNotes/types/CreditNotes.types'; +import { RefundCreditNoteResponseDto } from './dto/RefundCreditNoteResponse.dto'; @Controller('credit-notes') @ApiTags('Credit Note Refunds') +@ApiExtraModels(RefundCreditNoteResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class CreditNoteRefundsController { constructor( private readonly creditNotesRefundsApplication: CreditNotesRefundsApplication, ) {} @Get(':creditNoteId/refunds') + @RequirePermission(CreditNoteAction.View, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Retrieve the credit note graph.' }) + @ApiResponse({ + status: 200, + description: 'Credit note refunds retrieved successfully.', + schema: { + type: 'array', + items: { $ref: getSchemaPath(RefundCreditNoteResponseDto) }, + }, + }) getCreditNoteRefunds(@Param('creditNoteId') creditNoteId: number) { return this.creditNotesRefundsApplication.getCreditNoteRefunds( creditNoteId, ); } + @Get('refunds/:refundCreditId') + @RequirePermission(CreditNoteAction.View, AbilitySubject.CreditNote) + @ApiOperation({ summary: 'Retrieve a refund transaction for the given credit note.' }) + @ApiResponse({ + status: 200, + description: 'Refund credit note transaction retrieved successfully.', + schema: { + $ref: getSchemaPath(RefundCreditNoteResponseDto), + }, + }) + getRefundCreditNoteTransaction( + @Param('refundCreditId') refundCreditId: number, + ) { + return this.creditNotesRefundsApplication.getRefundCreditNoteTransaction( + refundCreditId, + ); + } + /** * Create a refund credit note. * @param {number} creditNoteId - The credit note ID. @@ -29,6 +78,7 @@ export class CreditNoteRefundsController { * @returns {Promise} */ @Post(':creditNoteId/refunds') + @RequirePermission(CreditNoteAction.Refund, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Create a refund for the given credit note.' }) createRefundCreditNote( @Param('creditNoteId') creditNoteId: number, @@ -46,6 +96,7 @@ export class CreditNoteRefundsController { * @returns {Promise} */ @Delete('refunds/:refundCreditId') + @RequirePermission(CreditNoteAction.Refund, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Delete a refund for the given credit note.' }) deleteRefundCreditNote( @Param('refundCreditId') refundCreditId: number, diff --git a/packages/server/src/modules/CreditNoteRefunds/CreditNoteRefunds.module.ts b/packages/server/src/modules/CreditNoteRefunds/CreditNoteRefunds.module.ts index 66cd4730b..cd5fc564f 100644 --- a/packages/server/src/modules/CreditNoteRefunds/CreditNoteRefunds.module.ts +++ b/packages/server/src/modules/CreditNoteRefunds/CreditNoteRefunds.module.ts @@ -7,6 +7,7 @@ import { CreditNotesRefundsApplication } from './CreditNotesRefundsApplication.s import { CreditNoteRefundsController } from './CreditNoteRefunds.controller'; import { CreditNotesModule } from '../CreditNotes/CreditNotes.module'; import { GetCreditNoteRefundsService } from './queries/GetCreditNoteRefunds.service'; +import { GetRefundCreditNoteTransaction } from './queries/GetRefundCreditNoteTransaction.service'; import { RefundCreditNoteGLEntries } from './commands/RefundCreditNoteGLEntries'; import { RefundCreditNoteGLEntriesSubscriber } from '../CreditNotes/subscribers/RefundCreditNoteGLEntriesSubscriber'; import { LedgerModule } from '../Ledger/Ledger.module'; @@ -21,6 +22,7 @@ import { AccountsModule } from '../Accounts/Accounts.module'; RefundSyncCreditNoteBalanceService, CreditNotesRefundsApplication, GetCreditNoteRefundsService, + GetRefundCreditNoteTransaction, RefundCreditNoteGLEntries, RefundCreditNoteGLEntriesSubscriber, ], diff --git a/packages/server/src/modules/CreditNoteRefunds/CreditNotesRefundsApplication.service.ts b/packages/server/src/modules/CreditNoteRefunds/CreditNotesRefundsApplication.service.ts index d7c02a933..027d20b3c 100644 --- a/packages/server/src/modules/CreditNoteRefunds/CreditNotesRefundsApplication.service.ts +++ b/packages/server/src/modules/CreditNoteRefunds/CreditNotesRefundsApplication.service.ts @@ -6,6 +6,7 @@ import { RefundCreditNoteService } from './commands/RefundCreditNote.service'; import { RefundSyncCreditNoteBalanceService } from './commands/RefundSyncCreditNoteBalance'; import { CreditNoteRefundDto } from './dto/CreditNoteRefund.dto'; import { GetCreditNoteRefundsService } from './queries/GetCreditNoteRefunds.service'; +import { GetRefundCreditNoteTransaction } from './queries/GetRefundCreditNoteTransaction.service'; @Injectable() export class CreditNotesRefundsApplication { @@ -13,6 +14,7 @@ export class CreditNotesRefundsApplication { private readonly createRefundCreditNoteService: CreateRefundCreditNoteService, private readonly deleteRefundCreditNoteService: DeleteRefundCreditNoteService, private readonly getCreditNoteRefundsService: GetCreditNoteRefundsService, + private readonly getRefundCreditNoteTransactionService: GetRefundCreditNoteTransaction, private readonly refundCreditNoteService: RefundCreditNoteService, private readonly refundSyncCreditNoteBalanceService: RefundSyncCreditNoteBalanceService, ) {} @@ -26,6 +28,12 @@ export class CreditNotesRefundsApplication { return this.getCreditNoteRefundsService.getCreditNoteRefunds(creditNoteId); } + public getRefundCreditNoteTransaction(refundCreditId: number) { + return this.getRefundCreditNoteTransactionService.getRefundCreditTransaction( + refundCreditId, + ); + } + /** * Create a refund credit note. * @param {number} creditNoteId - The credit note ID. diff --git a/packages/server/src/modules/CreditNoteRefunds/dto/RefundCreditNoteResponse.dto.ts b/packages/server/src/modules/CreditNoteRefunds/dto/RefundCreditNoteResponse.dto.ts new file mode 100644 index 000000000..cf3d72aa1 --- /dev/null +++ b/packages/server/src/modules/CreditNoteRefunds/dto/RefundCreditNoteResponse.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from '@nestjs/swagger'; + +class RefundCreditNoteSummaryDto { + @ApiProperty({ example: 1 }) + id: number; + + @ApiProperty({ example: 'CN-0001' }) + creditNoteNumber: string; +} + +class RefundCreditAccountDto { + @ApiProperty({ example: 10 }) + id: number; + + @ApiProperty({ example: 'Cash on Hand' }) + name: string; +} + +export class RefundCreditNoteResponseDto { + @ApiProperty({ example: 100 }) + id: number; + + @ApiProperty({ example: '2024-01-15' }) + date: string; + + @ApiProperty({ example: '2024-01-15' }) + formattedDate: string; + + @ApiProperty({ example: 250 }) + amount: number; + + @ApiProperty({ example: '$250.00' }) + formttedAmount: string; + + @ApiProperty({ example: 'REF-001', required: false, nullable: true }) + referenceNo?: string | null; + + @ApiProperty({ example: 'Refund issued to customer', required: false, nullable: true }) + description?: string | null; + + @ApiProperty({ type: RefundCreditAccountDto }) + fromAccount: RefundCreditAccountDto; + + @ApiProperty({ type: RefundCreditNoteSummaryDto }) + creditNote: RefundCreditNoteSummaryDto; +} diff --git a/packages/server/src/modules/CreditNoteRefunds/models/RefundCreditNote.ts b/packages/server/src/modules/CreditNoteRefunds/models/RefundCreditNote.ts index 3155d3683..fa1120167 100644 --- a/packages/server/src/modules/CreditNoteRefunds/models/RefundCreditNote.ts +++ b/packages/server/src/modules/CreditNoteRefunds/models/RefundCreditNote.ts @@ -34,7 +34,7 @@ export class RefundCreditNote extends BaseModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** diff --git a/packages/server/src/modules/CreditNoteRefunds/queries/GetRefundCreditNoteTransaction.service.ts b/packages/server/src/modules/CreditNoteRefunds/queries/GetRefundCreditNoteTransaction.service.ts index a8df1f92c..2b7b80ee6 100644 --- a/packages/server/src/modules/CreditNoteRefunds/queries/GetRefundCreditNoteTransaction.service.ts +++ b/packages/server/src/modules/CreditNoteRefunds/queries/GetRefundCreditNoteTransaction.service.ts @@ -1,16 +1,13 @@ import { Inject, Injectable } from '@nestjs/common'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { RefundCreditNote } from '../models/RefundCreditNote'; import { RefundCreditNoteTransformer } from '../../CreditNotes/queries/RefundCreditNoteTransformer'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @Injectable() export class GetRefundCreditNoteTransaction { - /** - * @param {RefundCreditNoteTransformer} transformer - * @param {typeof RefundCreditNote} refundCreditNoteModel - */ constructor( - private readonly transformer: RefundCreditNoteTransformer, + private readonly transformer: TransformerInjectable, @Inject(RefundCreditNote.name) private readonly refundCreditNoteModel: TenantModelProxy< @@ -33,6 +30,9 @@ export class GetRefundCreditNoteTransaction { .withGraphFetched('creditNote') .throwIfNotFound(); - return this.transformer.transform(refundCreditNote); + return this.transformer.transform( + refundCreditNote, + new RefundCreditNoteTransformer(), + ); } } diff --git a/packages/server/src/modules/CreditNotes/CreditNoteApplication.service.ts b/packages/server/src/modules/CreditNotes/CreditNoteApplication.service.ts index 472508f92..b19119191 100644 --- a/packages/server/src/modules/CreditNotes/CreditNoteApplication.service.ts +++ b/packages/server/src/modules/CreditNotes/CreditNoteApplication.service.ts @@ -4,9 +4,9 @@ import { DeleteCreditNoteService } from './commands/DeleteCreditNote.service'; import { EditCreditNoteService } from './commands/EditCreditNote.service'; import { OpenCreditNoteService } from './commands/OpenCreditNote.service'; import { GetCreditNotePdf } from './queries/GetCreditNotePdf.serivce'; -import { ICreditNotesQueryDTO } from './types/CreditNotes.types'; import { GetCreditNotesService } from './queries/GetCreditNotes.service'; import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto'; +import { GetCreditNotesQueryDto } from './dtos/GetCreditNotesQuery.dto'; import { GetCreditNoteState } from './queries/GetCreditNoteState.service'; import { GetCreditNoteService } from './queries/GetCreditNote.service'; import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service'; @@ -78,10 +78,10 @@ export class CreditNoteApplication { /** * Retrieves the credit notes list. - * @param {ICreditNotesQueryDTO} creditNotesQuery + * @param {GetCreditNotesQueryDto} creditNotesQuery * @returns {Promise} */ - getCreditNotes(creditNotesQuery: ICreditNotesQueryDTO) { + getCreditNotes(creditNotesQuery: GetCreditNotesQueryDto) { return this.getCreditNotesService.getCreditNotesList(creditNotesQuery); } diff --git a/packages/server/src/modules/CreditNotes/CreditNotes.controller.ts b/packages/server/src/modules/CreditNotes/CreditNotes.controller.ts index eec6c4e28..ddc34a6a3 100644 --- a/packages/server/src/modules/CreditNotes/CreditNotes.controller.ts +++ b/packages/server/src/modules/CreditNotes/CreditNotes.controller.ts @@ -18,11 +18,13 @@ import { Put, Query, Res, + UseGuards, } from '@nestjs/common'; import { CreditNoteApplication } from './CreditNoteApplication.service'; -import { ICreditNotesQueryDTO } from './types/CreditNotes.types'; import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto'; +import { GetCreditNotesQueryDto } from './dtos/GetCreditNotesQuery.dto'; import { CreditNoteResponseDto } from './dtos/CreditNoteResponse.dto'; +import { CreditNoteStateResponseDto } from './dtos/CreditNoteStateResponse.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { @@ -30,6 +32,11 @@ import { ValidateBulkDeleteResponseDto, } from '@/common/dtos/BulkDelete.dto'; import { AcceptType } from '@/constants/accept-type'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { CreditNoteAction } from './types/CreditNotes.types'; @Controller('credit-notes') @ApiTags('Credit Notes') @@ -37,6 +44,7 @@ import { AcceptType } from '@/constants/accept-type'; @ApiExtraModels(PaginatedResponseDto) @ApiExtraModels(ValidateBulkDeleteResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class CreditNotesController { /** * @param {CreditNoteApplication} creditNoteApplication - The credit note application service. @@ -44,6 +52,7 @@ export class CreditNotesController { constructor(private creditNoteApplication: CreditNoteApplication) { } @Post() + @RequirePermission(CreditNoteAction.Create, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Create a new credit note' }) @ApiResponse({ status: 201, description: 'Credit note successfully created' }) @ApiResponse({ status: 400, description: 'Invalid input data' }) @@ -52,13 +61,19 @@ export class CreditNotesController { } @Get('state') + @RequirePermission(CreditNoteAction.View, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Get credit note state' }) - @ApiResponse({ status: 200, description: 'Returns the credit note state' }) + @ApiResponse({ + status: 200, + description: 'Returns the credit note state', + type: CreditNoteStateResponseDto, + }) getCreditNoteState() { return this.creditNoteApplication.getCreditNoteState(); } @Get(':id') + @RequirePermission(CreditNoteAction.View, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Get a specific credit note by ID' }) @ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' }) @ApiResponse({ @@ -92,6 +107,7 @@ export class CreditNotesController { } @Get() + @RequirePermission(CreditNoteAction.View, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Get all credit notes' }) @ApiResponse({ status: 200, @@ -110,11 +126,12 @@ export class CreditNotesController { ], }, }) - getCreditNotes(@Query() creditNotesQuery: ICreditNotesQueryDTO) { + getCreditNotes(@Query() creditNotesQuery: GetCreditNotesQueryDto) { return this.creditNoteApplication.getCreditNotes(creditNotesQuery); } @Put(':id') + @RequirePermission(CreditNoteAction.Edit, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Update a credit note' }) @ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' }) @ApiResponse({ status: 200, description: 'Credit note successfully updated' }) @@ -131,6 +148,7 @@ export class CreditNotesController { } @Put(':id/open') + @RequirePermission(CreditNoteAction.Edit, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Open a credit note' }) @ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' }) @ApiResponse({ status: 200, description: 'Credit note successfully opened' }) @@ -140,6 +158,7 @@ export class CreditNotesController { } @Post('validate-bulk-delete') + @RequirePermission(CreditNoteAction.Delete, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Validates which credit notes can be deleted and returns the results.', @@ -161,6 +180,7 @@ export class CreditNotesController { } @Post('bulk-delete') + @RequirePermission(CreditNoteAction.Delete, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Deletes multiple credit notes.' }) @ApiResponse({ status: 200, @@ -173,6 +193,7 @@ export class CreditNotesController { } @Delete(':id') + @RequirePermission(CreditNoteAction.Delete, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Delete a credit note' }) @ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' }) @ApiResponse({ status: 200, description: 'Credit note successfully deleted' }) diff --git a/packages/server/src/modules/CreditNotes/dtos/CreditNoteStateResponse.dto.ts b/packages/server/src/modules/CreditNotes/dtos/CreditNoteStateResponse.dto.ts new file mode 100644 index 000000000..36fdd8ea1 --- /dev/null +++ b/packages/server/src/modules/CreditNotes/dtos/CreditNoteStateResponse.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreditNoteStateResponseDto { + @ApiProperty({ + description: 'Default PDF template ID for credit notes', + example: 1, + }) + defaultTemplateId: number; +} diff --git a/packages/server/src/modules/CreditNotes/dtos/GetCreditNotesQuery.dto.ts b/packages/server/src/modules/CreditNotes/dtos/GetCreditNotesQuery.dto.ts new file mode 100644 index 000000000..fa18a6059 --- /dev/null +++ b/packages/server/src/modules/CreditNotes/dtos/GetCreditNotesQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetCreditNotesQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/CreditNotes/models/CreditNote.ts b/packages/server/src/modules/CreditNotes/models/CreditNote.ts index cf969a5b1..c56e64fdc 100644 --- a/packages/server/src/modules/CreditNotes/models/CreditNote.ts +++ b/packages/server/src/modules/CreditNotes/models/CreditNote.ts @@ -9,9 +9,12 @@ import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/Inje import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry'; import { Warehouse } from '@/modules/Warehouses/models/Warehouse.model'; import { CreditNoteMeta } from './CreditNote.meta'; +import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { CreditNoteDefaultViews } from '../constants'; +import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; +@InjectAttachable() @ExportableModel() @ImportableModel() @InjectModelMeta(CreditNoteMeta) @@ -56,7 +59,7 @@ export class CreditNote extends TenantBaseModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** @@ -275,8 +278,9 @@ export class CreditNote extends TenantBaseModel { * */ sortByStatus(query, order) { + const dir = sanitizeSortDirection(order); query.orderByRaw( - `COALESCE(REFUNDED_AMOUNT) + COALESCE(INVOICES_AMOUNT) = COALESCE(AMOUNT) ${order}`, + `COALESCE(REFUNDED_AMOUNT) + COALESCE(INVOICES_AMOUNT) = COALESCE(AMOUNT) ${dir}`, ); }, }; diff --git a/packages/server/src/modules/CreditNotes/queries/GetCreditNotes.service.ts b/packages/server/src/modules/CreditNotes/queries/GetCreditNotes.service.ts index c4534e64b..50e5c0f9a 100644 --- a/packages/server/src/modules/CreditNotes/queries/GetCreditNotes.service.ts +++ b/packages/server/src/modules/CreditNotes/queries/GetCreditNotes.service.ts @@ -2,10 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import * as R from 'ramda'; import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service'; -import { - GetCreditNotesResponse, - ICreditNotesQueryDTO, -} from '../types/CreditNotes.types'; +import { GetCreditNotesResponse } from '../types/CreditNotes.types'; +import { GetCreditNotesQueryDto } from '../dtos/GetCreditNotesQuery.dto'; import { CreditNote } from '../models/CreditNote'; import { CreditNoteTransformer } from './CreditNoteTransformer'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @@ -32,10 +30,10 @@ export class GetCreditNotesService { /** * Retrieves the paginated and filterable credit notes list. * @param {number} tenantId - - * @param {ICreditNotesQueryDTO} creditNotesQuery - + * @param {GetCreditNotesQueryDto} creditNotesQuery - */ public async getCreditNotesList( - filterDto: ICreditNotesQueryDTO, + filterDto: GetCreditNotesQueryDto, ): Promise { const _filterDto = { sortOrder: 'desc', diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/CreditNotesApplyInvoice.controller.ts b/packages/server/src/modules/CreditNotesApplyInvoice/CreditNotesApplyInvoice.controller.ts index e963c3d0d..97cfeb803 100644 --- a/packages/server/src/modules/CreditNotesApplyInvoice/CreditNotesApplyInvoice.controller.ts +++ b/packages/server/src/modules/CreditNotesApplyInvoice/CreditNotesApplyInvoice.controller.ts @@ -1,19 +1,56 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOperation, + ApiResponse, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; import { GetCreditNoteAssociatedAppliedInvoices } from './queries/GetCreditNoteAssociatedAppliedInvoices.service'; +import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { CreditNoteAction } from '../CreditNotes/types/CreditNotes.types'; +import { GetCreditNoteAssociatedInvoicesToApply } from './queries/GetCreditNoteAssociatedInvoicesToApply.service'; +import { CreditNoteApplyToInvoices } from './commands/CreditNoteApplyToInvoices.service'; +import { DeleteCreditNoteApplyToInvoices } from './commands/DeleteCreditNoteApplyToInvoices.service'; +import { ApplyCreditNoteToInvoicesDto } from './dtos/ApplyCreditNoteToInvoices.dto'; +import { AppliedCreditNoteInvoiceResponseDto } from './dtos/AppliedCreditNoteInvoiceResponse.dto'; +import { CreditNoteInvoiceToApplyResponseDto } from './dtos/CreditNoteInvoiceToApplyResponse.dto'; @Controller('credit-notes') @ApiTags('Credit Notes Apply Invoice') +@ApiExtraModels(AppliedCreditNoteInvoiceResponseDto, CreditNoteInvoiceToApplyResponseDto) +@ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class CreditNotesApplyInvoiceController { constructor( private readonly getCreditNoteAssociatedAppliedInvoicesService: GetCreditNoteAssociatedAppliedInvoices, + private readonly getCreditNoteAssociatedInvoicesToApplyService: GetCreditNoteAssociatedInvoicesToApply, + private readonly creditNoteApplyToInvoicesService: CreditNoteApplyToInvoices, + private readonly deleteCreditNoteApplyToInvoicesService: DeleteCreditNoteApplyToInvoices, ) {} @Get(':creditNoteId/applied-invoices') + @RequirePermission(CreditNoteAction.View, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Applied credit note to invoices' }) @ApiResponse({ status: 200, description: 'Credit note successfully applied to invoices', + schema: { + type: 'array', + items: { $ref: getSchemaPath(AppliedCreditNoteInvoiceResponseDto) }, + }, }) @ApiResponse({ status: 404, description: 'Credit note not found' }) @ApiResponse({ status: 400, description: 'Invalid input data' }) @@ -23,7 +60,29 @@ export class CreditNotesApplyInvoiceController { ); } + @Get(':creditNoteId/apply-invoices') + @RequirePermission(CreditNoteAction.View, AbilitySubject.CreditNote) + @ApiOperation({ summary: 'Get credit note associated invoices to apply' }) + @ApiResponse({ + status: 200, + description: 'Credit note associated invoices to apply', + schema: { + type: 'array', + items: { $ref: getSchemaPath(CreditNoteInvoiceToApplyResponseDto) }, + }, + }) + @ApiResponse({ status: 404, description: 'Credit note not found' }) + @ApiResponse({ status: 400, description: 'Invalid input data' }) + getCreditNoteAssociatedInvoicesToApply( + @Param('creditNoteId') creditNoteId: number, + ) { + return this.getCreditNoteAssociatedInvoicesToApplyService.getCreditAssociatedInvoicesToApply( + creditNoteId, + ); + } + @Post(':creditNoteId/apply-invoices') + @RequirePermission(CreditNoteAction.Edit, AbilitySubject.CreditNote) @ApiOperation({ summary: 'Apply credit note to invoices' }) @ApiResponse({ status: 200, @@ -31,9 +90,32 @@ export class CreditNotesApplyInvoiceController { }) @ApiResponse({ status: 404, description: 'Credit note not found' }) @ApiResponse({ status: 400, description: 'Invalid input data' }) - applyCreditNoteToInvoices(@Param('creditNoteId') creditNoteId: number) { - return this.getCreditNoteAssociatedAppliedInvoicesService.getCreditAssociatedAppliedInvoices( + applyCreditNoteToInvoices( + @Param('creditNoteId') creditNoteId: number, + @Body() applyDto: ApplyCreditNoteToInvoicesDto, + ) { + return this.creditNoteApplyToInvoicesService.applyCreditNoteToInvoices( creditNoteId, + applyDto, + ); + } + + @Delete('applied-invoices/:applyCreditToInvoicesId') + @RequirePermission(CreditNoteAction.Edit, AbilitySubject.CreditNote) + @ApiOperation({ summary: 'Delete applied credit note to invoice' }) + @ApiResponse({ + status: 200, + description: 'Credit note application successfully deleted', + }) + @ApiResponse({ + status: 404, + description: 'Credit note application not found', + }) + deleteApplyCreditNoteToInvoices( + @Param('applyCreditToInvoicesId') applyCreditToInvoicesId: number, + ) { + return this.deleteCreditNoteApplyToInvoicesService.deleteApplyCreditNoteToInvoices( + applyCreditToInvoicesId, ); } } diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/CreditNotesApplyInvoice.module.ts b/packages/server/src/modules/CreditNotesApplyInvoice/CreditNotesApplyInvoice.module.ts index 7998e3e63..5dab07c49 100644 --- a/packages/server/src/modules/CreditNotesApplyInvoice/CreditNotesApplyInvoice.module.ts +++ b/packages/server/src/modules/CreditNotesApplyInvoice/CreditNotesApplyInvoice.module.ts @@ -9,6 +9,8 @@ import { CreditNotesModule } from '../CreditNotes/CreditNotes.module'; import { GetCreditNoteAssociatedAppliedInvoices } from './queries/GetCreditNoteAssociatedAppliedInvoices.service'; import { GetCreditNoteAssociatedInvoicesToApply } from './queries/GetCreditNoteAssociatedInvoicesToApply.service'; import { CreditNotesApplyInvoiceController } from './CreditNotesApplyInvoice.controller'; +import { CreditNoteApplySyncCreditSubscriber } from './subscribers/CreditNoteApplySyncCreditSubscriber'; +import { CreditNoteApplySyncInvoicesCreditedAmountSubscriber } from './subscribers/CreditNoteApplySyncInvoicesSubscriber'; @Module({ providers: [ @@ -19,6 +21,8 @@ import { CreditNotesApplyInvoiceController } from './CreditNotesApplyInvoice.con CreditNoteApplySyncCredit, GetCreditNoteAssociatedAppliedInvoices, GetCreditNoteAssociatedInvoicesToApply, + CreditNoteApplySyncCreditSubscriber, + CreditNoteApplySyncInvoicesCreditedAmountSubscriber, ], exports: [DeleteCustomerLinkedCreditNoteService], imports: [PaymentsReceivedModule, forwardRef(() => CreditNotesModule)], diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/commands/CreditNoteApplySyncInvoices.service.ts b/packages/server/src/modules/CreditNotesApplyInvoice/commands/CreditNoteApplySyncInvoices.service.ts index efc14b72f..2629c2a02 100644 --- a/packages/server/src/modules/CreditNotesApplyInvoice/commands/CreditNoteApplySyncInvoices.service.ts +++ b/packages/server/src/modules/CreditNotesApplyInvoice/commands/CreditNoteApplySyncInvoices.service.ts @@ -1,6 +1,6 @@ import { Knex } from 'knex'; import { Injectable, Inject } from '@nestjs/common'; -import Bluebird from 'bluebird'; +import * as Bluebird from 'bluebird'; import { ICreditNoteAppliedToInvoice } from '../types/CreditNoteApplyInvoice.types'; import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice'; import { CreditNoteAppliedInvoice } from '../models/CreditNoteAppliedInvoice'; diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/commands/CreditNoteApplyToInvoices.service.ts b/packages/server/src/modules/CreditNotesApplyInvoice/commands/CreditNoteApplyToInvoices.service.ts index c58254bc7..dc042647a 100644 --- a/packages/server/src/modules/CreditNotesApplyInvoice/commands/CreditNoteApplyToInvoices.service.ts +++ b/packages/server/src/modules/CreditNotesApplyInvoice/commands/CreditNoteApplyToInvoices.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Knex } from 'knex'; import { sumBy } from 'lodash'; import { + ICreditNoteAppliedToInvoice, ICreditNoteAppliedToInvoiceModel, IApplyCreditToInvoicesDTO, IApplyCreditToInvoicesCreatedPayload, @@ -17,6 +18,7 @@ import { CreditNote } from '@/modules/CreditNotes/models/CreditNote'; import { CreditNoteAppliedInvoice } from '../models/CreditNoteAppliedInvoice'; import { CommandCreditNoteDTOTransform } from '@/modules/CreditNotes/commands/CommandCreditNoteDTOTransform.service'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { ApplyCreditNoteToInvoicesDto } from '../dtos/ApplyCreditNoteToInvoices.dto'; @Injectable() export class CreditNoteApplyToInvoices { @@ -48,7 +50,7 @@ export class CreditNoteApplyToInvoices { */ public async applyCreditNoteToInvoices( creditNoteId: number, - applyCreditToInvoicesDTO: IApplyCreditToInvoicesDTO, + applyCreditToInvoicesDTO: ApplyCreditNoteToInvoicesDto, ): Promise { // Saves the credit note or throw not found service error. const creditNote = await this.creditNoteModel() @@ -71,7 +73,7 @@ export class CreditNoteApplyToInvoices { // Validate invoices has remaining amount to apply. this.validateInvoicesRemainingAmount( appliedInvoicesEntries, - creditNoteAppliedModel.amount, + creditNoteAppliedModel.entries, ); // Validate the credit note remaining amount. this.creditNoteDTOTransform.validateCreditRemainingAmount( @@ -122,18 +124,20 @@ export class CreditNoteApplyToInvoices { }; /** - * Validate the invoice remaining amount. + * Validate each invoice has sufficient remaining amount for the applied credit. * @param {ISaleInvoice[]} invoices - * @param {number} amount + * @param {ICreditNoteAppliedToInvoice[]} entries */ private validateInvoicesRemainingAmount = ( invoices: SaleInvoice[], - amount: number, + entries: ICreditNoteAppliedToInvoice[], ) => { - const invalidInvoices = invoices.filter( - (invoice) => invoice.dueAmount < amount, - ); - if (invalidInvoices.length > 0) { + const invoiceMap = new Map(invoices.map((inv) => [inv.id, inv])); + const invalidEntries = entries.filter((entry) => { + const invoice = invoiceMap.get(entry.invoiceId); + return invoice != null && invoice.dueAmount < entry.amount; + }); + if (invalidEntries.length > 0) { throw new ServiceError(ERRORS.INVOICES_HAS_NO_REMAINING_AMOUNT); } }; diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/dtos/AppliedCreditNoteInvoiceResponse.dto.ts b/packages/server/src/modules/CreditNotesApplyInvoice/dtos/AppliedCreditNoteInvoiceResponse.dto.ts new file mode 100644 index 000000000..17de46f4f --- /dev/null +++ b/packages/server/src/modules/CreditNotesApplyInvoice/dtos/AppliedCreditNoteInvoiceResponse.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AppliedCreditNoteInvoiceResponseDto { + @ApiProperty({ example: 1 }) + id: number; + + @ApiProperty({ example: 200 }) + amount: number; + + @ApiProperty({ example: '$200.00' }) + formttedAmount: string; + + @ApiProperty({ example: 'CN-0001' }) + creditNoteNumber: string; + + @ApiProperty({ example: '2024-01-10' }) + creditNoteDate: string; + + @ApiProperty({ example: '2024-01-10' }) + formattedCreditNoteDate: string; + + @ApiProperty({ example: 'INV-0001' }) + invoiceNumber: string; + + @ApiProperty({ example: 'REF-001', required: false, nullable: true }) + invoiceReferenceNo?: string | null; +} diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/dtos/ApplyCreditNoteToInvoices.dto.ts b/packages/server/src/modules/CreditNotesApplyInvoice/dtos/ApplyCreditNoteToInvoices.dto.ts new file mode 100644 index 000000000..e193b164b --- /dev/null +++ b/packages/server/src/modules/CreditNotesApplyInvoice/dtos/ApplyCreditNoteToInvoices.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsInt, + IsNotEmpty, + IsNumber, + ValidateNested, +} from 'class-validator'; + +export class ApplyCreditNoteInvoiceEntryDto { + @IsNotEmpty() + @IsInt() + @ApiProperty({ description: 'Invoice ID to apply credit to', example: 1 }) + invoiceId: number; + + @IsNotEmpty() + @IsNumber() + @ApiProperty({ description: 'Amount to apply', example: 100.5 }) + amount: number; +} + +export class ApplyCreditNoteToInvoicesDto { + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => ApplyCreditNoteInvoiceEntryDto) + @ApiProperty({ + description: 'Entries of invoice ID and amount to apply', + type: [ApplyCreditNoteInvoiceEntryDto], + example: [ + { invoice_id: 1, amount: 100.5 }, + { invoice_id: 2, amount: 50 }, + ], + }) + entries: ApplyCreditNoteInvoiceEntryDto[]; +} diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/dtos/CreditNoteInvoiceToApplyResponse.dto.ts b/packages/server/src/modules/CreditNotesApplyInvoice/dtos/CreditNoteInvoiceToApplyResponse.dto.ts new file mode 100644 index 000000000..cbd3946cc --- /dev/null +++ b/packages/server/src/modules/CreditNotesApplyInvoice/dtos/CreditNoteInvoiceToApplyResponse.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreditNoteInvoiceToApplyResponseDto { + @ApiProperty({ example: 1 }) + id: number; + + @ApiProperty({ example: 'INV-0001' }) + invoiceNo: string; + + @ApiProperty({ example: 'REF-001', required: false, nullable: true }) + referenceNo?: string | null; + + @ApiProperty({ example: '2024-01-10' }) + invoiceDate: string; + + @ApiProperty({ example: '2024-01-20' }) + dueDate: string; + + @ApiProperty({ example: 'USD', required: false, nullable: true }) + currencyCode?: string | null; + + @ApiProperty({ example: 500 }) + balance: number; + + @ApiProperty({ example: 500 }) + dueAmount: number; + + @ApiProperty({ example: 0 }) + paymentAmount: number; + + @ApiProperty({ example: '2024-01-10' }) + formattedInvoiceDate: string; + + @ApiProperty({ example: '2024-01-20' }) + formattedDueDate: string; + + @ApiProperty({ example: '$500.00' }) + formatted_amount: string; + + @ApiProperty({ example: '$500.00' }) + formattedDueAmount: string; + + @ApiProperty({ example: '$0.00' }) + formattedPaymentAmount: string; +} diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/models/CreditNoteAppliedInvoice.ts b/packages/server/src/modules/CreditNotesApplyInvoice/models/CreditNoteAppliedInvoice.ts index def5940dd..abaeaa337 100644 --- a/packages/server/src/modules/CreditNotesApplyInvoice/models/CreditNoteAppliedInvoice.ts +++ b/packages/server/src/modules/CreditNotesApplyInvoice/models/CreditNoteAppliedInvoice.ts @@ -26,7 +26,7 @@ export class CreditNoteAppliedInvoice extends BaseModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/subscribers/CreditNoteApplySyncInvoicesSubscriber.ts b/packages/server/src/modules/CreditNotesApplyInvoice/subscribers/CreditNoteApplySyncInvoicesSubscriber.ts index 5169364a8..ac653c19e 100644 --- a/packages/server/src/modules/CreditNotesApplyInvoice/subscribers/CreditNoteApplySyncInvoicesSubscriber.ts +++ b/packages/server/src/modules/CreditNotesApplyInvoice/subscribers/CreditNoteApplySyncInvoicesSubscriber.ts @@ -8,7 +8,7 @@ import { CreditNoteApplySyncInvoicesCreditedAmount } from '../commands/CreditNot import { events } from '@/common/events/events'; @Injectable() -export default class CreditNoteApplySyncInvoicesCreditedAmountSubscriber { +export class CreditNoteApplySyncInvoicesCreditedAmountSubscriber { constructor( private readonly syncInvoicesWithCreditNote: CreditNoteApplySyncInvoicesCreditedAmount, ) {} diff --git a/packages/server/src/modules/CreditNotesApplyInvoice/types/CreditNoteApplyInvoice.types.ts b/packages/server/src/modules/CreditNotesApplyInvoice/types/CreditNoteApplyInvoice.types.ts index 7172811ae..d65ef2af3 100644 --- a/packages/server/src/modules/CreditNotesApplyInvoice/types/CreditNoteApplyInvoice.types.ts +++ b/packages/server/src/modules/CreditNotesApplyInvoice/types/CreditNoteApplyInvoice.types.ts @@ -29,6 +29,7 @@ export interface IApplyCreditToInvoicesDeletedPayload { export interface ICreditNoteAppliedToInvoice { amount: number; creditNoteId: number; + invoiceId: number; } export interface ICreditNoteAppliedToInvoiceModel { amount: number; diff --git a/packages/server/src/modules/Customers/Customers.controller.ts b/packages/server/src/modules/Customers/Customers.controller.ts index 910fda307..577919258 100644 --- a/packages/server/src/modules/Customers/Customers.controller.ts +++ b/packages/server/src/modules/Customers/Customers.controller.ts @@ -7,6 +7,7 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { CustomersApplication } from './CustomersApplication.service'; import { CustomerOpeningBalanceEditDto } from './dtos/CustomerOpeningBalanceEdit.dto'; @@ -26,15 +27,23 @@ import { ValidateBulkDeleteCustomersResponseDto, } from './dtos/BulkDeleteCustomers.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { CustomerAction } from './types/Customers.types'; @Controller('customers') @ApiTags('Customers') @ApiExtraModels(CustomerResponseDto) +@ApiExtraModels(ValidateBulkDeleteCustomersResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class CustomersController { constructor(private customersApplication: CustomersApplication) { } @Get(':id') + @RequirePermission(CustomerAction.View, AbilitySubject.Customer) @ApiOperation({ summary: 'Retrieves the customer details.' }) @ApiResponse({ status: 200, @@ -46,6 +55,7 @@ export class CustomersController { } @Get() + @RequirePermission(CustomerAction.View, AbilitySubject.Customer) @ApiOperation({ summary: 'Retrieves the customers paginated list.' }) @ApiResponse({ status: 200, @@ -60,6 +70,7 @@ export class CustomersController { } @Post() + @RequirePermission(CustomerAction.Create, AbilitySubject.Customer) @ApiOperation({ summary: 'Create a new customer.' }) @ApiResponse({ status: 201, @@ -71,6 +82,7 @@ export class CustomersController { } @Put(':id') + @RequirePermission(CustomerAction.Edit, AbilitySubject.Customer) @ApiOperation({ summary: 'Edit the given customer.' }) @ApiResponse({ status: 200, @@ -85,6 +97,7 @@ export class CustomersController { } @Delete(':id') + @RequirePermission(CustomerAction.Delete, AbilitySubject.Customer) @ApiOperation({ summary: 'Delete the given customer.' }) @ApiResponse({ status: 200, @@ -95,6 +108,7 @@ export class CustomersController { } @Put(':id/opening-balance') + @RequirePermission(CustomerAction.Edit, AbilitySubject.Customer) @ApiOperation({ summary: 'Edit the opening balance of the given customer.' }) @ApiResponse({ status: 200, @@ -112,6 +126,7 @@ export class CustomersController { } @Post('validate-bulk-delete') + @RequirePermission(CustomerAction.Delete, AbilitySubject.Customer) @ApiOperation({ summary: 'Validates which customers can be deleted and returns counts of deletable and non-deletable customers.', @@ -131,6 +146,7 @@ export class CustomersController { } @Post('bulk-delete') + @RequirePermission(CustomerAction.Delete, AbilitySubject.Customer) @ApiOperation({ summary: 'Deletes multiple customers in bulk.' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/Customers/dtos/ContactAddress.dto.ts b/packages/server/src/modules/Customers/dtos/ContactAddress.dto.ts index 8e4224eb6..da963c9a6 100644 --- a/packages/server/src/modules/Customers/dtos/ContactAddress.dto.ts +++ b/packages/server/src/modules/Customers/dtos/ContactAddress.dto.ts @@ -1,5 +1,6 @@ -import { IsEmail, IsOptional, IsString } from 'class-validator'; +import { IsEmail, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional } from '@/common/decorators/Validators'; export class ContactAddressDto { @ApiProperty({ required: false, description: 'Billing address line 1' }) @@ -27,10 +28,10 @@ export class ContactAddressDto { @IsEmail() billingAddressEmail?: string; - @ApiProperty({ required: false, description: 'Billing address zipcode' }) + @ApiProperty({ required: false, description: 'Billing address postcode' }) @IsOptional() @IsString() - billingAddressZipcode?: string; + billingAddressPostcode?: string; @ApiProperty({ required: false, description: 'Billing address phone' }) @IsOptional() @@ -67,10 +68,10 @@ export class ContactAddressDto { @IsEmail() shippingAddressEmail?: string; - @ApiProperty({ required: false, description: 'Shipping address zipcode' }) + @ApiProperty({ required: false, description: 'Shipping address postcode' }) @IsOptional() @IsString() - shippingAddressZipcode?: string; + shippingAddressPostcode?: string; @ApiProperty({ required: false, description: 'Shipping address phone' }) @IsOptional() diff --git a/packages/server/src/modules/Customers/dtos/CreateCustomer.dto.ts b/packages/server/src/modules/Customers/dtos/CreateCustomer.dto.ts index 9569d9da4..fa1032e56 100644 --- a/packages/server/src/modules/Customers/dtos/CreateCustomer.dto.ts +++ b/packages/server/src/modules/Customers/dtos/CreateCustomer.dto.ts @@ -155,4 +155,13 @@ export class CreateCustomerDto extends ContactAddressDto { @IsOptional() @IsBoolean() active?: boolean; + + @ApiProperty({ + required: false, + description: 'Customer code', + example: 'CUST-001', + }) + @IsOptional() + @IsString() + code?: string; } diff --git a/packages/server/src/modules/Customers/dtos/EditCustomer.dto.ts b/packages/server/src/modules/Customers/dtos/EditCustomer.dto.ts index 4e633bed4..f94ea4032 100644 --- a/packages/server/src/modules/Customers/dtos/EditCustomer.dto.ts +++ b/packages/server/src/modules/Customers/dtos/EditCustomer.dto.ts @@ -1,6 +1,7 @@ -import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsEmail, IsNotEmpty, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { ContactAddressDto } from './ContactAddress.dto'; +import { IsOptional } from '@/common/decorators/Validators'; export class EditCustomerDto extends ContactAddressDto { @ApiProperty({ required: true, description: 'Customer type' }) @@ -62,4 +63,9 @@ export class EditCustomerDto extends ContactAddressDto { @IsOptional() @IsBoolean() active?: boolean; + + @ApiProperty({ required: false, description: 'Customer code' }) + @IsOptional() + @IsString() + code?: string; } diff --git a/packages/server/src/modules/Customers/models/Customer.ts b/packages/server/src/modules/Customers/models/Customer.ts index 47aa6f59e..3c478c07f 100644 --- a/packages/server/src/modules/Customers/models/Customer.ts +++ b/packages/server/src/modules/Customers/models/Customer.ts @@ -70,6 +70,8 @@ export class Customer extends TenantBaseModel { note: string; active: boolean; + code?: string; + /** * Query builder. */ diff --git a/packages/server/src/modules/Customers/types/Customers.types.ts b/packages/server/src/modules/Customers/types/Customers.types.ts index 5bb0b85f2..3ac45a3a7 100644 --- a/packages/server/src/modules/Customers/types/Customers.types.ts +++ b/packages/server/src/modules/Customers/types/Customers.types.ts @@ -32,6 +32,7 @@ export interface ICustomerNewDTO extends IContactAddressDTO { note?: string; active?: boolean; + code?: string; } export interface ICustomerEditDTO extends IContactAddressDTO { @@ -50,6 +51,7 @@ export interface ICustomerEditDTO extends IContactAddressDTO { note?: string; active?: boolean; + code?: string; } export interface ICustomersFilter extends IDynamicListFilter { diff --git a/packages/server/src/modules/DynamicListing/DynamicFilter/DynamicFilterSortBy.ts b/packages/server/src/modules/DynamicListing/DynamicFilter/DynamicFilterSortBy.ts index 23d4ea47c..60443e6fc 100644 --- a/packages/server/src/modules/DynamicListing/DynamicFilter/DynamicFilterSortBy.ts +++ b/packages/server/src/modules/DynamicListing/DynamicFilter/DynamicFilterSortBy.ts @@ -1,5 +1,6 @@ import { FIELD_TYPE } from './constants'; import { DynamicFilterRoleAbstractor } from './DynamicFilterRoleAbstractor'; +import { sanitizeSortDirection } from './sanitizeSortDirection'; interface ISortRole { fieldKey: string; @@ -67,17 +68,18 @@ export class DynamicFilterSortBy extends DynamicFilterRoleAbstractor { public buildQuery = () => { const field = this.model.getField(this.sortRole.fieldKey); const comparatorColumn = this.getFieldComparatorColumn(field); + const safeOrder = sanitizeSortDirection(this.sortRole.order); // Sort custom query. if (typeof field.sortCustomQuery !== 'undefined') { return (builder) => { - field.sortCustomQuery(builder, this.sortRole); + field.sortCustomQuery(builder, { ...this.sortRole, order: safeOrder }); }; } return (builder) => { if (this.sortRole.fieldKey) { - builder.orderBy(`${comparatorColumn}`, this.sortRole.order); + builder.orderBy(`${comparatorColumn}`, safeOrder); } }; }; diff --git a/packages/server/src/modules/DynamicListing/DynamicFilter/sanitizeSortDirection.ts b/packages/server/src/modules/DynamicListing/DynamicFilter/sanitizeSortDirection.ts new file mode 100644 index 000000000..233463d47 --- /dev/null +++ b/packages/server/src/modules/DynamicListing/DynamicFilter/sanitizeSortDirection.ts @@ -0,0 +1,8 @@ +/** + * Normalises an arbitrary `sortOrder` value to a SQL-safe direction. + * Returns 'DESC' only on an explicit case-insensitive match; otherwise 'ASC'. + * Used to defuse `orderByRaw` interpolation in dynamic listing modifiers. + */ +export function sanitizeSortDirection(order: unknown): 'ASC' | 'DESC' { + return String(order ?? '').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; +} diff --git a/packages/server/src/modules/DynamicListing/dtos/DynamicFilterQuery.dto.ts b/packages/server/src/modules/DynamicListing/dtos/DynamicFilterQuery.dto.ts index 54f26fcb2..07245ad30 100644 --- a/packages/server/src/modules/DynamicListing/dtos/DynamicFilterQuery.dto.ts +++ b/packages/server/src/modules/DynamicListing/dtos/DynamicFilterQuery.dto.ts @@ -1,33 +1,58 @@ import { ToNumber } from '@/common/decorators/Validators'; -import { IsArray, IsOptional, IsString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsIn, IsInt, IsOptional, IsString } from 'class-validator'; import { IFilterRole, ISortOrder } from '../DynamicFilter/DynamicFilter.types'; export class DynamicFilterQueryDto { + @ApiPropertyOptional({ description: 'Page number (1-based)', type: Number }) + @IsOptional() + @IsInt() + @ToNumber() + page?: number; + + @ApiPropertyOptional({ description: 'Page size', type: Number }) + @IsOptional() + @IsInt() + @ToNumber() + pageSize?: number; + + @ApiPropertyOptional({ description: 'Custom view ID', type: Number }) @IsOptional() @ToNumber() customViewId?: number; + @ApiPropertyOptional({ description: 'Filter roles', type: Array }) @IsArray() @IsOptional() filterRoles?: IFilterRole[]; + @ApiPropertyOptional({ description: 'Column to sort by', type: String }) @IsOptional() @IsString() columnSortBy: string; - @IsString() + @ApiPropertyOptional({ description: 'Sort order (asc/desc)', type: String }) + @IsIn(['ASC', 'DESC', 'asc', 'desc']) @IsOptional() sortOrder: ISortOrder; + @ApiPropertyOptional({ + description: 'Stringified filter roles', + type: String, + }) @IsString() @IsOptional() stringifiedFilterRoles?: string; + @ApiPropertyOptional({ description: 'Search keyword', type: String }) @IsString() @IsOptional() searchKeyword?: string; + @ApiPropertyOptional({ description: 'View slug', type: String }) @IsString() @IsOptional() viewSlug?: string; + + filterQuery?: (query: any) => void; } diff --git a/packages/server/src/modules/EventsTracker/events/ReportsEventsTracker.ts b/packages/server/src/modules/EventsTracker/events/ReportsEventsTracker.ts index 76f371397..bc8998676 100644 --- a/packages/server/src/modules/EventsTracker/events/ReportsEventsTracker.ts +++ b/packages/server/src/modules/EventsTracker/events/ReportsEventsTracker.ts @@ -25,7 +25,7 @@ export class ReportsEventsTracker { constructor(private readonly posthog: EventTrackerService) {} @OnEvent(events.reports.onBalanceSheetViewed) - handleTrackBalanceSheetViewedEvent({ tenantId }: ReportsEvents) { + handleTrackBalanceSheetViewedEvent() { this.posthog.trackEvent({ event: BALANCE_SHEET_VIEWED, properties: {}, @@ -33,7 +33,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onTrialBalanceSheetView) - handleTrackTrialBalanceSheetViewedEvent({ tenantId }: ReportsEvents) { + handleTrackTrialBalanceSheetViewedEvent() { this.posthog.trackEvent({ event: TRIAL_BALANCE_SHEET_VIEWED, properties: {}, @@ -41,7 +41,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onProfitLossSheetViewed) - handleTrackProfitLossSheetViewedEvent({ tenantId }: ReportsEvents) { + handleTrackProfitLossSheetViewedEvent() { this.posthog.trackEvent({ event: PROFIT_LOSS_SHEET_VIEWED, properties: {}, @@ -49,7 +49,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onCashflowStatementViewed) - handleTrackCashflowStatementViewedEvent({ tenantId }: ReportsEvents) { + handleTrackCashflowStatementViewedEvent() { this.posthog.trackEvent({ event: CASHFLOW_STATEMENT_VIEWED, properties: {}, @@ -57,7 +57,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onGeneralLedgerViewed) - handleTrackGeneralLedgerViewedEvent({ tenantId }: ReportsEvents) { + handleTrackGeneralLedgerViewedEvent() { this.posthog.trackEvent({ event: GENERAL_LEDGER_VIEWED, properties: {}, @@ -65,7 +65,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onJournalViewed) - handleTrackJournalViewedEvent({ tenantId }: ReportsEvents) { + handleTrackJournalViewedEvent() { this.posthog.trackEvent({ event: JOURNAL_VIEWED, properties: {}, @@ -73,7 +73,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onReceivableAgingViewed) - handleTrackReceivableAgingViewedEvent({ tenantId }: ReportsEvents) { + handleTrackReceivableAgingViewedEvent() { this.posthog.trackEvent({ event: RECEIVABLE_AGING_VIEWED, properties: {}, @@ -81,7 +81,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onPayableAgingViewed) - handleTrackPayableAgingViewedEvent({ tenantId }: ReportsEvents) { + handleTrackPayableAgingViewedEvent() { this.posthog.trackEvent({ event: PAYABLE_AGING_VIEWED, properties: {}, @@ -89,7 +89,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onCustomerBalanceSummaryViewed) - handleTrackCustomerBalanceSummaryViewedEvent({ tenantId }: ReportsEvents) { + handleTrackCustomerBalanceSummaryViewedEvent() { this.posthog.trackEvent({ event: CUSTOMER_BALANCE_SUMMARY_VIEWED, properties: {}, @@ -97,7 +97,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onVendorBalanceSummaryViewed) - handleTrackVendorBalanceSummaryViewedEvent({ tenantId }: ReportsEvents) { + handleTrackVendorBalanceSummaryViewedEvent() { this.posthog.trackEvent({ event: VENDOR_BALANCE_SUMMARY_VIEWED, properties: {}, @@ -105,7 +105,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onInventoryValuationViewed) - handleTrackInventoryValuationViewedEvent({ tenantId }: ReportsEvents) { + handleTrackInventoryValuationViewedEvent() { this.posthog.trackEvent({ event: INVENTORY_VALUATION_VIEWED, properties: {}, @@ -113,7 +113,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onCustomerTransactionsViewed) - handleTrackCustomerTransactionsViewedEvent({ tenantId }: ReportsEvents) { + handleTrackCustomerTransactionsViewedEvent() { this.posthog.trackEvent({ event: CUSTOMER_TRANSACTIONS_VIEWED, properties: {}, @@ -121,7 +121,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onVendorTransactionsViewed) - handleTrackVendorTransactionsViewedEvent({ tenantId }: ReportsEvents) { + handleTrackVendorTransactionsViewedEvent() { this.posthog.trackEvent({ event: VENDOR_TRANSACTIONS_VIEWED, properties: {}, @@ -129,7 +129,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onSalesByItemViewed) - handleTrackSalesByItemViewedEvent({ tenantId }: ReportsEvents) { + handleTrackSalesByItemViewedEvent() { this.posthog.trackEvent({ event: SALES_BY_ITEM_VIEWED, properties: {}, @@ -137,7 +137,7 @@ export class ReportsEventsTracker { } @OnEvent(events.reports.onPurchasesByItemViewed) - handleTrackPurchasesByItemViewedEvent({ tenantId }: ReportsEvents) { + handleTrackPurchasesByItemViewedEvent() { this.posthog.trackEvent({ event: PURCHASES_BY_ITEM_VIEWED, properties: {}, diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.application.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.application.ts new file mode 100644 index 000000000..ff12bca40 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.application.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { ExchangeRatesService } from './ExchangeRates.service'; +import { ExchangeRateLatestDTO, EchangeRateLatestPOJO } from './ExchangeRates.types'; + +@Injectable() +export class ExchangeRateApplication { + constructor(private readonly exchangeRateService: ExchangeRatesService) {} + + /** + * Gets the latest exchange rate. + * @param {number} tenantId + * @param {ExchangeRateLatestDTO} exchangeRateLatestDTO + * @returns {Promise} + */ + public latest( + tenantId: number, + exchangeRateLatestDTO: ExchangeRateLatestDTO, + ): Promise { + return this.exchangeRateService.latest(tenantId, exchangeRateLatestDTO); + } +} diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.controller.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.controller.ts new file mode 100644 index 000000000..00f33e011 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.controller.ts @@ -0,0 +1,73 @@ +import { + Controller, + Get, + Query, + Req, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { Request } from 'express'; +import { + ApiOperation, + ApiTags, + ApiResponse, + ApiQuery, +} from '@nestjs/swagger'; +import { ExchangeRateApplication } from './ExchangeRates.application'; +import { ExchangeRateLatestQueryDto } from './dtos/ExchangeRateLatestQuery.dto'; +import { ExchangeRateLatestResponseDto } from './dtos/ExchangeRateLatestResponse.dto'; + +interface RequestWithTenantId extends Request { + tenantId: number; +} + +@Controller('exchange-rates') +@ApiTags('Exchange Rates') +export class ExchangeRatesController { + constructor(private readonly exchangeRateApp: ExchangeRateApplication) {} + + @Get('/latest') + @UsePipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ) + @ApiOperation({ summary: 'Get the latest exchange rate' }) + @ApiQuery({ + name: 'from_currency', + description: 'Source currency code (ISO 4217)', + required: false, + type: String, + example: 'USD', + }) + @ApiQuery({ + name: 'to_currency', + description: 'Target currency code (ISO 4217)', + required: false, + type: String, + example: 'EUR', + }) + @ApiResponse({ + status: 200, + description: 'Successfully retrieved exchange rate', + type: ExchangeRateLatestResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid currency code or service error', + }) + async getLatestExchangeRate( + @Query() query: ExchangeRateLatestQueryDto, + @Req() req: RequestWithTenantId, + ): Promise { + const tenantId = req.tenantId; + + const exchangeRate = await this.exchangeRateApp.latest(tenantId, { + fromCurrency: query.from_currency, + toCurrency: query.to_currency, + }); + return exchangeRate; + } +} diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.module.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.module.ts new file mode 100644 index 000000000..e9689199e --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ExchangeRatesController } from './ExchangeRates.controller'; +import { ExchangeRatesService } from './ExchangeRates.service'; +import { ExchangeRateApplication } from './ExchangeRates.application'; + +@Module({ + providers: [ExchangeRatesService, ExchangeRateApplication], + controllers: [ExchangeRatesController], + exports: [ExchangeRatesService, ExchangeRateApplication], +}) +export class ExchangeRatesModule {} diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.service.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.service.ts new file mode 100644 index 000000000..074f587b7 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ExchangeRate } from './lib/ExchangeRate'; +import { ExchangeRateServiceType } from './lib/types'; +import { TenantMetadata } from '@/modules/System/models/TenantMetadataModel'; +import { ExchangeRateLatestDTO, EchangeRateLatestPOJO } from './ExchangeRates.types'; + +@Injectable() +export class ExchangeRatesService { + /** + * Gets the latest exchange rate. + * @param {number} tenantId + * @param {ExchangeRateLatestDTO} exchangeRateLatestDTO + * @returns {EchangeRateLatestPOJO} + */ + public async latest( + tenantId: number, + exchangeRateLatestDTO: ExchangeRateLatestDTO, + ): Promise { + const organization = await TenantMetadata.query().findOne({ tenantId }); + + // Assign the organization base currency as a default currency + // if no currency is provided + const fromCurrency = + exchangeRateLatestDTO.fromCurrency || organization.baseCurrency; + const toCurrency = + exchangeRateLatestDTO.toCurrency || organization.baseCurrency; + + const exchange = new ExchangeRate(ExchangeRateServiceType.OpenExchangeRate); + const exchangeRate = await exchange.latest(fromCurrency, toCurrency); + + return { + baseCurrency: fromCurrency, + toCurrency: exchangeRateLatestDTO.toCurrency || toCurrency, + exchangeRate, + }; + } +} diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.types.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.types.ts new file mode 100644 index 000000000..2a5be04e0 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.types.ts @@ -0,0 +1,10 @@ +export interface ExchangeRateLatestDTO { + fromCurrency?: string; + toCurrency?: string; +} + +export interface EchangeRateLatestPOJO { + baseCurrency: string; + toCurrency: string; + exchangeRate: number; +} diff --git a/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestQuery.dto.ts b/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestQuery.dto.ts new file mode 100644 index 000000000..0a9d12f1d --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestQuery.dto.ts @@ -0,0 +1,22 @@ +import { IsOptional, IsString, Length } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class ExchangeRateLatestQueryDto { + @ApiPropertyOptional({ + description: 'The source currency code (ISO 4217)', + example: 'USD', + }) + @IsOptional() + @IsString() + @Length(3, 3, { message: 'Currency code must be 3 characters (ISO 4217)' }) + from_currency?: string; + + @ApiPropertyOptional({ + description: 'The target currency code (ISO 4217)', + example: 'EUR', + }) + @IsOptional() + @IsString() + @Length(3, 3, { message: 'Currency code must be 3 characters (ISO 4217)' }) + to_currency?: string; +} diff --git a/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestResponse.dto.ts b/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestResponse.dto.ts new file mode 100644 index 000000000..9ff87d739 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestResponse.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ExchangeRateLatestResponseDto { + @ApiProperty({ + description: 'The base currency code', + example: 'USD', + }) + baseCurrency: string; + + @ApiProperty({ + description: 'The target currency code', + example: 'EUR', + }) + toCurrency: string; + + @ApiProperty({ + description: 'The exchange rate value', + example: 0.85, + }) + exchangeRate: number; +} diff --git a/packages/server/src/modules/ExchangeRates/index.ts b/packages/server/src/modules/ExchangeRates/index.ts new file mode 100644 index 000000000..4281d9ece --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/index.ts @@ -0,0 +1,7 @@ +export * from './ExchangeRates.module'; +export * from './ExchangeRates.controller'; +export * from './ExchangeRates.service'; +export * from './ExchangeRates.application'; +export * from './dtos/ExchangeRateLatestQuery.dto'; +export * from './dtos/ExchangeRateLatestResponse.dto'; +export * from './lib/types'; diff --git a/packages/server/src/modules/ExchangeRates/lib/ExchangeRate.ts b/packages/server/src/modules/ExchangeRates/lib/ExchangeRate.ts new file mode 100644 index 000000000..d0dcae12e --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/lib/ExchangeRate.ts @@ -0,0 +1,45 @@ +import { OpenExchangeRate } from './OpenExchangeRate'; +import { ExchangeRateServiceType, IExchangeRateService } from './types'; + +export class ExchangeRate { + private exchangeRateService: IExchangeRateService; + private exchangeRateServiceType: ExchangeRateServiceType; + + /** + * Constructor method. + * @param {ExchangeRateServiceType} service + */ + constructor(service: ExchangeRateServiceType) { + this.exchangeRateServiceType = service; + this.initService(); + } + + /** + * Initialize the exchange rate service based on the service type. + */ + private initService() { + if ( + this.exchangeRateServiceType === ExchangeRateServiceType.OpenExchangeRate + ) { + this.setExchangeRateService(new OpenExchangeRate()); + } + } + + /** + * Sets the exchange rate service. + * @param {IExchangeRateService} service + */ + private setExchangeRateService(service: IExchangeRateService) { + this.exchangeRateService = service; + } + + /** + * Gets the latest exchange rate. + * @param {string} baseCurrency + * @param {string} toCurrency + * @returns {number} + */ + public latest(baseCurrency: string, toCurrency: string): Promise { + return this.exchangeRateService.latest(baseCurrency, toCurrency); + } +} \ No newline at end of file diff --git a/packages/server/src/modules/ExchangeRates/lib/OpenExchangeRate.ts b/packages/server/src/modules/ExchangeRates/lib/OpenExchangeRate.ts new file mode 100644 index 000000000..be8d2da2d --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/lib/OpenExchangeRate.ts @@ -0,0 +1,85 @@ +import Axios from 'axios'; +import { + EchangeRateErrors, + IExchangeRateService, + OPEN_EXCHANGE_RATE_LATEST_URL, +} from './types'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +export class OpenExchangeRate implements IExchangeRateService { + private appId: string; + + constructor(appId?: string) { + this.appId = appId || process.env.OPEN_EXCHANGE_RATE_APP_ID || ''; + } + + /** + * Gets the latest exchange rate. + * @param {string} baseCurrency + * @param {string} toCurrency + * @returns {Promise} + */ + public async latest( + baseCurrency: string, + toCurrency: string + ): Promise { + // Validates the Open Exchange Rate api id early. + this.validateApiIdExistance(); + + try { + const result = await Axios.get(OPEN_EXCHANGE_RATE_LATEST_URL, { + params: { + app_id: this.appId, + base: baseCurrency, + symbols: toCurrency, + }, + }); + return result.data.rates[toCurrency] || (1 as number); + } catch (error) { + this.handleLatestErrors(error); + } + } + + /** + * Validates the Open Exchange Rate api id. + * @throws {ServiceError} + */ + private validateApiIdExistance() { + if (!this.appId) { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + 'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.' + ); + } + } + + /** + * Handles the latest errors. + * @param {any} error + * @throws {ServiceError} + */ + private handleLatestErrors(error: any) { + if (error.response?.data?.message === 'missing_app_id') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + 'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.' + ); + } else if (error.response?.data?.message === 'invalid_app_id') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + 'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.' + ); + } else if (error.response?.data?.message === 'not_allowed') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED, + 'Getting the exchange rate from the given base currency to the given currency is not allowed.' + ); + } else if (error.response?.data?.message === 'invalid_base') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY, + 'The given base currency is invalid.' + ); + } + throw error; + } +} diff --git a/packages/server/src/modules/ExchangeRates/lib/types.ts b/packages/server/src/modules/ExchangeRates/lib/types.ts new file mode 100644 index 000000000..aa1f45481 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/lib/types.ts @@ -0,0 +1,17 @@ +export interface IExchangeRateService { + latest(baseCurrency: string, toCurrency: string): Promise; +} + +export enum ExchangeRateServiceType { + OpenExchangeRate = 'OpenExchangeRate', +} + +export enum EchangeRateErrors { + EX_RATE_SERVICE_NOT_ALLOWED = 'EX_RATE_SERVICE_NOT_ALLOWED', + EX_RATE_LIMIT_EXCEEDED = 'EX_RATE_LIMIT_EXCEEDED', + EX_RATE_SERVICE_API_KEY_REQUIRED = 'EX_RATE_SERVICE_API_KEY_REQUIRED', + EX_RATE_INVALID_BASE_CURRENCY = 'EX_RATE_INVALID_BASE_CURRENCY', +} + +export const OPEN_EXCHANGE_RATE_LATEST_URL = + 'https://openexchangerates.org/api/latest.json'; \ No newline at end of file diff --git a/packages/server/src/modules/Expenses/Expenses.controller.ts b/packages/server/src/modules/Expenses/Expenses.controller.ts index 2f80404a2..a55982f11 100644 --- a/packages/server/src/modules/Expenses/Expenses.controller.ts +++ b/packages/server/src/modules/Expenses/Expenses.controller.ts @@ -7,9 +7,10 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { ExpensesApplication } from './ExpensesApplication.service'; -import { IExpensesFilter } from './Expenses.types'; +import { GetExpensesQueryDto } from './dtos/GetExpensesQuery.dto'; import { ApiExtraModels, ApiOperation, @@ -25,6 +26,11 @@ import { BulkDeleteDto, ValidateBulkDeleteResponseDto, } from '@/common/dtos/BulkDelete.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ExpenseAction } from './Expenses.types'; @Controller('expenses') @ApiTags('Expenses') @@ -34,10 +40,12 @@ import { ValidateBulkDeleteResponseDto, ) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class ExpensesController { constructor(private readonly expensesApplication: ExpensesApplication) { } @Post('validate-bulk-delete') + @RequirePermission(ExpenseAction.Delete, AbilitySubject.Expense) @ApiOperation({ summary: 'Validate which expenses can be deleted and return the results.', }) @@ -58,6 +66,7 @@ export class ExpensesController { } @Post('bulk-delete') + @RequirePermission(ExpenseAction.Delete, AbilitySubject.Expense) @ApiOperation({ summary: 'Deletes multiple expenses.' }) @ApiResponse({ status: 200, @@ -76,6 +85,7 @@ export class ExpensesController { * @param {IExpenseCreateDTO} expenseDTO */ @Post() + @RequirePermission(ExpenseAction.Create, AbilitySubject.Expense) @ApiOperation({ summary: 'Create a new expense transaction.' }) public createExpense(@Body() expenseDTO: CreateExpenseDto) { return this.expensesApplication.createExpense(expenseDTO); @@ -87,6 +97,7 @@ export class ExpensesController { * @param {IExpenseEditDTO} expenseDTO */ @Put(':id') + @RequirePermission(ExpenseAction.Edit, AbilitySubject.Expense) @ApiOperation({ summary: 'Edit the given expense transaction.' }) public editExpense( @Param('id') expenseId: number, @@ -100,6 +111,7 @@ export class ExpensesController { * @param {number} expenseId */ @Delete(':id') + @RequirePermission(ExpenseAction.Delete, AbilitySubject.Expense) @ApiOperation({ summary: 'Delete the given expense transaction.' }) public deleteExpense(@Param('id') expenseId: number) { return this.expensesApplication.deleteExpense(expenseId); @@ -110,6 +122,7 @@ export class ExpensesController { * @param {number} expenseId */ @Post(':id/publish') + @RequirePermission(ExpenseAction.Edit, AbilitySubject.Expense) @ApiOperation({ summary: 'Publish the given expense transaction.' }) public publishExpense(@Param('id') expenseId: number) { return this.expensesApplication.publishExpense(expenseId); @@ -119,6 +132,7 @@ export class ExpensesController { * Get the expense transaction details. */ @Get() + @RequirePermission(ExpenseAction.View, AbilitySubject.Expense) @ApiOperation({ summary: 'Get the expense transactions.' }) @ApiResponse({ status: 200, @@ -137,7 +151,7 @@ export class ExpensesController { ], }, }) - public getExpenses(@Query() filterDTO: IExpensesFilter) { + public getExpenses(@Query() filterDTO: GetExpensesQueryDto) { return this.expensesApplication.getExpenses(filterDTO); } @@ -146,6 +160,7 @@ export class ExpensesController { * @param {number} expenseId */ @Get(':id') + @RequirePermission(ExpenseAction.View, AbilitySubject.Expense) @ApiOperation({ summary: 'Get the expense transaction details.' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/Expenses/ExpensesApplication.service.ts b/packages/server/src/modules/Expenses/ExpensesApplication.service.ts index aa89c99a5..a8dc8b39f 100644 --- a/packages/server/src/modules/Expenses/ExpensesApplication.service.ts +++ b/packages/server/src/modules/Expenses/ExpensesApplication.service.ts @@ -7,6 +7,7 @@ import { GetExpenseService } from './queries/GetExpense.service'; import { IExpensesFilter } from './interfaces/Expenses.interface'; import { GetExpensesService } from './queries/GetExpenses.service'; import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto'; +import { GetExpensesQueryDto } from './dtos/GetExpensesQuery.dto'; import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service'; import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service'; @@ -95,9 +96,9 @@ export class ExpensesApplication { /** * Retrieve expenses paginated list. - * @param {IExpensesFilter} expensesFilter + * @param {GetExpensesQueryDto} filterDTO */ - public getExpenses(filterDTO: Partial) { + public getExpenses(filterDTO: GetExpensesQueryDto) { return this.getExpensesService.getExpensesList(filterDTO); } } diff --git a/packages/server/src/modules/Expenses/ExpensesExportable.ts b/packages/server/src/modules/Expenses/ExpensesExportable.ts index 9207101ba..5d2899a99 100644 --- a/packages/server/src/modules/Expenses/ExpensesExportable.ts +++ b/packages/server/src/modules/Expenses/ExpensesExportable.ts @@ -2,9 +2,10 @@ import { Exportable } from '../Export/Exportable'; import { ExpensesApplication } from './ExpensesApplication.service'; import { EXPORT_SIZE_LIMIT } from '../Export/constants'; import { Injectable } from '@nestjs/common'; -import { IExpensesFilter } from './Expenses.types'; import { ExportableService } from '../Export/decorators/ExportableModel.decorator'; import { Expense } from './models/Expense.model'; +import { GetExpensesQueryDto } from './dtos/GetExpensesQuery.dto'; +import { ISortOrder } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types'; @Injectable() @ExportableService({ name: Expense.name }) @@ -17,20 +18,20 @@ export class ExpensesExportable extends Exportable { /** * Retrieves the accounts data to exportable sheet. - * @param {IExpensesFilter} + * @param {GetExpensesQueryDto} query */ - public exportable(query: IExpensesFilter) { + public exportable(query: GetExpensesQueryDto) { const filterQuery = (query) => { query.withGraphFetched('branch'); }; const parsedQuery = { - sortOrder: 'desc', + sortOrder: 'desc' as ISortOrder, columnSortBy: 'created_at', ...query, page: 1, pageSize: EXPORT_SIZE_LIMIT, filterQuery, - } as IExpensesFilter; + } as GetExpensesQueryDto; return this.expensesApplication .getExpenses(parsedQuery) diff --git a/packages/server/src/modules/Expenses/dtos/GetExpensesQuery.dto.ts b/packages/server/src/modules/Expenses/dtos/GetExpensesQuery.dto.ts new file mode 100644 index 000000000..db8a2d708 --- /dev/null +++ b/packages/server/src/modules/Expenses/dtos/GetExpensesQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetExpensesQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/Expenses/models/Expense.model.ts b/packages/server/src/modules/Expenses/models/Expense.model.ts index 63433d510..6d6458cae 100644 --- a/packages/server/src/modules/Expenses/models/Expense.model.ts +++ b/packages/server/src/modules/Expenses/models/Expense.model.ts @@ -9,7 +9,9 @@ import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/Inje import { ExpenseMeta } from './Expense.meta'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { ExpenseDefaultViews } from '../constants'; +import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; +@InjectAttachable() @ExportableModel() @ImportableModel() @InjectModelMeta(ExpenseMeta) diff --git a/packages/server/src/modules/Expenses/queries/GetExpenses.service.ts b/packages/server/src/modules/Expenses/queries/GetExpenses.service.ts index 81170e5f8..a5f3c9191 100644 --- a/packages/server/src/modules/Expenses/queries/GetExpenses.service.ts +++ b/packages/server/src/modules/Expenses/queries/GetExpenses.service.ts @@ -3,7 +3,8 @@ import { ExpenseTransfromer } from './Expense.transformer'; import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service'; import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { Inject, Injectable } from '@nestjs/common'; -import { IExpensesFilter, IPaginationMeta } from '../Expenses.types'; +import { IPaginationMeta } from '../Expenses.types'; +import { GetExpensesQueryDto } from '../dtos/GetExpensesQuery.dto'; import { Expense } from '../models/Expense.model'; import { IFilterMeta } from '@/interfaces/Model'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @@ -20,10 +21,10 @@ export class GetExpensesService { /** * Retrieve expenses paginated list. - * @param {IExpensesFilter} expensesFilter + * @param {GetExpensesQueryDto} filterDTO * @return {IExpense[]} */ - public async getExpensesList(filterDto: Partial): Promise<{ + public async getExpensesList(filterDTO: GetExpensesQueryDto): Promise<{ expenses: Expense[]; pagination: IPaginationMeta; filterMeta: IFilterMeta; @@ -33,7 +34,7 @@ export class GetExpensesService { columnSortBy: 'created_at', page: 1, pageSize: 12, - ...filterDto, + ...filterDTO, }; // Parses list filter DTO. const filter = this.parseListFilterDTO(_filterDto); diff --git a/packages/server/src/modules/Export/ExportService.ts b/packages/server/src/modules/Export/ExportService.ts index db7f6ed3f..f516d26b0 100644 --- a/packages/server/src/modules/Export/ExportService.ts +++ b/packages/server/src/modules/Export/ExportService.ts @@ -12,6 +12,7 @@ import { ServiceError } from '../Items/ServiceError'; import { ResourceService } from '../Resource/ResourceService'; import { getExportableService } from './decorators/ExportableModel.decorator'; import { ContextIdFactory, ModuleRef } from '@nestjs/core'; +import { I18nService } from 'nestjs-i18n'; @Injectable() export class ExportResourceService { @@ -20,6 +21,7 @@ export class ExportResourceService { private readonly exportPdf: ExportPdf, private readonly resourceService: ResourceService, private readonly moduleRef: ModuleRef, + private readonly i18nService: I18nService, ) {} /** @@ -147,7 +149,7 @@ export class ExportResourceService { const group = parent; return [ { - name: value.name, + name: this.i18nService.t(value.name, { defaultValue: value.name }), type: value.type || 'text', accessor: value.accessor || key, group, @@ -174,7 +176,7 @@ export class ExportResourceService { const group = parent; return [ { - name: value.name, + name: this.i18nService.t(value.name, { defaultValue: value.name }), type: value.type || 'text', accessor: value.accessor || key, group, diff --git a/packages/server/src/modules/FinancialStatements/common/FinancialSheet.ts b/packages/server/src/modules/FinancialStatements/common/FinancialSheet.ts index 27e8e09a9..6dc28597c 100644 --- a/packages/server/src/modules/FinancialStatements/common/FinancialSheet.ts +++ b/packages/server/src/modules/FinancialStatements/common/FinancialSheet.ts @@ -15,6 +15,7 @@ export class FinancialSheet { negativeFormat: 'mines', }; public baseCurrency: string; + public dateFormat: string = 'YYYY MMM DD'; /** * Transformes the number format query to settings @@ -140,13 +141,19 @@ export class FinancialSheet { * @param {string} format * @returns */ - protected getDateMeta(date: moment.MomentInput, format = 'YYYY-MM-DD') { + protected getDateMeta(date: moment.MomentInput, format?: string) { + const dateFormat = format || this.dateFormat || 'YYYY MMM DD'; return { - formattedDate: moment(date).format(format), + formattedDate: moment(date).format(dateFormat), date: moment(date).toDate(), }; } + protected getDateFormatted(date: moment.MomentInput, format?: string) { + const dateFormat = format || this.dateFormat || 'YYYY MMM DD'; + return moment(date).format(dateFormat); + } + getPercentageBasis = (base, amount) => { return base ? amount / base : 0; }; diff --git a/packages/server/src/modules/FinancialStatements/dtos/FinancialReportResponse.dto.ts b/packages/server/src/modules/FinancialStatements/dtos/FinancialReportResponse.dto.ts new file mode 100644 index 000000000..7ae89100d --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/dtos/FinancialReportResponse.dto.ts @@ -0,0 +1,103 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +// ============== Common DTOs ============== + +export class FinancialReportTotalDto { + @ApiProperty({ description: 'Numeric amount', type: Number }) + amount: number; + + @ApiProperty({ description: 'Formatted amount string' }) + formattedAmount: string; + + @ApiProperty({ description: 'Currency code' }) + currencyCode: string; + + @ApiPropertyOptional({ description: 'Date associated with the total' }) + date?: string | Date; +} + +export class FinancialReportPercentageDto { + @ApiProperty({ description: 'Percentage amount', type: Number }) + amount: number; + + @ApiProperty({ description: 'Formatted percentage string' }) + formattedAmount: string; +} + +export class FinancialReportMetaDto { + @ApiProperty({ description: 'Organization name' }) + organizationName: string; + + @ApiProperty({ description: 'Base currency code' }) + baseCurrency: string; + + @ApiProperty({ description: 'Date format string' }) + dateFormat: string; + + @ApiProperty({ description: 'Whether cost computation is running' }) + isCostComputeRunning: boolean; + + @ApiProperty({ description: 'Sheet name' }) + sheetName: string; +} + +// ============== Table DTOs ============== + +export class FinancialTableCellDto { + @ApiProperty({ description: 'Cell key' }) + key: string; + + @ApiProperty({ description: 'Cell value' }) + value: string; +} + +export class FinancialTableRowDto { + @ApiProperty({ description: 'Cell data for this row', type: [FinancialTableCellDto] }) + cells: FinancialTableCellDto[]; + + @ApiProperty({ description: 'Row type classifications', type: [String] }) + rowTypes: string[]; + + @ApiProperty({ description: 'Row identifier' }) + id: string | number; + + @ApiPropertyOptional({ description: 'Child rows', type: () => [FinancialTableRowDto] }) + children?: FinancialTableRowDto[]; +} + +export class FinancialTableColumnDto { + @ApiProperty({ description: 'Column key' }) + key: string; + + @ApiProperty({ description: 'Column header label' }) + label: string; + + @ApiPropertyOptional({ description: 'Cell position index', type: Number }) + cellIndex?: number; + + @ApiPropertyOptional({ description: 'Nested column definitions', type: () => [FinancialTableColumnDto] }) + children?: FinancialTableColumnDto[]; +} + +export class FinancialTableDataDto { + @ApiProperty({ description: 'Table column definitions', type: [FinancialTableColumnDto] }) + columns: FinancialTableColumnDto[]; + + @ApiProperty({ description: 'Table row data', type: [FinancialTableRowDto] }) + rows: FinancialTableRowDto[]; +} + +// ============== Base Report Response DTOs ============== + +export class BaseFinancialReportResponseDto { + @ApiProperty({ description: 'Report metadata', type: FinancialReportMetaDto }) + meta: FinancialReportMetaDto; +} + +export class BaseFinancialTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Report metadata', type: FinancialReportMetaDto }) + meta: FinancialReportMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummary.controller.ts b/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummary.controller.ts index 18c3c2441..9c87bbdfb 100644 --- a/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummary.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummary.controller.ts @@ -1,29 +1,51 @@ import { Response } from 'express'; -import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; +import { Controller, Get, Headers, Query, Res, UseGuards } from '@nestjs/common'; import { APAgingSummaryApplication } from './APAgingSummaryApplication'; import { AcceptType } from '@/constants/accept-type'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { APAgingSummaryQueryDto } from './APAgingSummaryQuery.dto'; import { APAgingSummaryResponseExample } from './APAgingSummary.swagger'; +import { + APAgingSummaryResponseDto, + APAgingSummaryTableResponseDto, +} from './APAgingSummaryResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ReportsAction } from '../../types/Report.types'; @Controller('reports/payable-aging-summary') @ApiTags('Reports') @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +@ApiExtraModels(APAgingSummaryResponseDto, APAgingSummaryTableResponseDto) export class APAgingSummaryController { constructor(private readonly APAgingSummaryApp: APAgingSummaryApplication) { } @Get() + @RequirePermission(ReportsAction.READ_AP_AGING_SUMMARY, AbilitySubject.Report) @ApiOperation({ summary: 'Get payable aging summary' }) @ApiResponse({ status: 200, description: 'A/P aging summary response', - example: APAgingSummaryResponseExample, + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(APAgingSummaryResponseDto) }, + example: APAgingSummaryResponseExample, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(APAgingSummaryTableResponseDto) }, + }, + }, }) @ApiProduces( AcceptType.ApplicationJson, diff --git a/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryResponse.dto.ts new file mode 100644 index 000000000..4c2f0e518 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryResponse.dto.ts @@ -0,0 +1,109 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class APAgingPeriodDto { + @ApiProperty({ description: 'From period date' }) + fromPeriod: string; + + @ApiPropertyOptional({ description: 'To period date' }) + toPeriod: string | null; + + @ApiProperty({ description: 'Before days', type: Number }) + beforeDays: number; + + @ApiPropertyOptional({ description: 'To days', type: Number }) + toDays: number | null; +} + +export class APAgingPeriodTotalDto extends APAgingPeriodDto { + @ApiProperty({ description: 'Period total', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; +} + +export class APAgingVendorDto { + @ApiProperty({ description: 'Vendor name' }) + vendorName: string; + + @ApiProperty({ description: 'Current balance', type: FinancialReportTotalDto }) + current: FinancialReportTotalDto; + + @ApiProperty({ description: 'Aging periods', type: [APAgingPeriodTotalDto] }) + aging: APAgingPeriodTotalDto[]; + + @ApiProperty({ description: 'Vendor total', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; +} + +export class APAgingSummaryDataDto { + @ApiProperty({ description: 'Vendors aging data', type: [APAgingVendorDto] }) + vendors: APAgingVendorDto[]; + + @ApiProperty({ description: 'Current total', type: FinancialReportTotalDto }) + current: FinancialReportTotalDto; + + @ApiProperty({ description: 'Aging totals', type: [APAgingPeriodTotalDto] }) + aging: APAgingPeriodTotalDto[]; + + @ApiProperty({ description: 'Grand total', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; +} + +export class APAgingSummaryMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted as-of date' }) + formattedAsDate: string; +} + +export class APAgingSummaryQueryResponseDto { + @ApiProperty({ description: 'As-of date' }) + asDate: string; + + @ApiProperty({ description: 'Aging days before', type: Number }) + agingDaysBefore: number; + + @ApiProperty({ description: 'Number of aging periods', type: Number }) + agingPeriods: number; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Vendor IDs to include', type: [Number] }) + vendorsIds: number[]; + + @ApiProperty({ description: 'Branch IDs to include', type: [Number] }) + branchesIds: number[]; + + @ApiProperty({ description: 'Exclude zero balance accounts' }) + noneZero: boolean; +} + +export class APAgingSummaryResponseDto { + @ApiProperty({ description: 'Aging summary data', type: APAgingSummaryDataDto }) + data: APAgingSummaryDataDto; + + @ApiProperty({ description: 'Report metadata', type: APAgingSummaryMetaDto }) + meta: APAgingSummaryMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as APAgingSummaryTableCellDto, + FinancialTableRowDto as APAgingSummaryTableRowDto, + FinancialTableColumnDto as APAgingSummaryTableColumnDto, + FinancialTableDataDto as APAgingSummaryTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class APAgingSummaryTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: APAgingSummaryQueryResponseDto }) + query: APAgingSummaryQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: APAgingSummaryMetaDto }) + meta: APAgingSummaryMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryService.ts b/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryService.ts index 9e330b58a..bc97b9225 100644 --- a/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryService.ts @@ -32,18 +32,19 @@ export class APAgingSummaryService { this.APAgingSummaryRepository.setFilter(filter); await this.APAgingSummaryRepository.load(); + // Retrieve the aging summary report meta first to get date format. + const meta = await this.APAgingSummaryMeta.meta(filter); + // A/P aging summary report instance. const APAgingSummaryReport = new APAgingSummarySheet( filter, this.APAgingSummaryRepository, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); // A/P aging summary report data and columns. const data = APAgingSummaryReport.reportData(); const columns = APAgingSummaryReport.reportColumns(); - // Retrieve the aging summary report meta. - const meta = await this.APAgingSummaryMeta.meta(filter); - // Triggers `onPayableAgingViewed` event. await this.eventPublisher.emitAsync(events.reports.onPayableAgingViewed, { query, diff --git a/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummarySheet.ts b/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummarySheet.ts index 1e0089731..754d91889 100644 --- a/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummarySheet.ts +++ b/packages/server/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummarySheet.ts @@ -14,6 +14,7 @@ import { allPassedConditionsPass } from '@/utils/all-conditions-passed'; import { APAgingSummaryRepository } from './APAgingSummaryRepository'; import { Bill } from '@/modules/Bills/models/Bill'; import { APAgingSummaryQueryDto } from './APAgingSummaryQuery.dto'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export class APAgingSummarySheet extends AgingSummaryReport { readonly repository: APAgingSummaryRepository; @@ -31,12 +32,15 @@ export class APAgingSummarySheet extends AgingSummaryReport { constructor( query: APAgingSummaryQueryDto, repository: APAgingSummaryRepository, + meta: IFinancialReportMeta, ) { super(); this.query = query; this.repository = repository; + this.baseCurrency = meta.baseCurrency; this.numberFormat = this.query.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.overdueInvoicesByContactId = this.repository.overdueBillsByVendorId; this.currentInvoicesByContactId = this.repository.dueBillsByVendorId; diff --git a/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummary.controller.ts b/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummary.controller.ts index ec5dc24c4..19d7d2ed0 100644 --- a/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummary.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummary.controller.ts @@ -1,30 +1,51 @@ -import { Controller, Get, Headers } from '@nestjs/common'; -import { Query, Res } from '@nestjs/common'; +import { Controller, Get, Headers, Query, Res, UseGuards } from '@nestjs/common'; import { ARAgingSummaryApplication } from './ARAgingSummaryApplication'; import { AcceptType } from '@/constants/accept-type'; import { Response } from 'express'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { ARAgingSummaryQueryDto } from './ARAgingSummaryQuery.dto'; import { ARAgingSummaryResponseExample } from './ARAgingSummary.swagger'; +import { + ARAgingSummaryResponseDto, + ARAgingSummaryTableResponseDto, +} from './ARAgingSummaryResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ReportsAction } from '../../types/Report.types'; @Controller('reports/receivable-aging-summary') @ApiTags('Reports') @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +@ApiExtraModels(ARAgingSummaryResponseDto, ARAgingSummaryTableResponseDto) export class ARAgingSummaryController { constructor(private readonly ARAgingSummaryApp: ARAgingSummaryApplication) {} @Get() + @RequirePermission(ReportsAction.READ_AR_AGING_SUMMARY, AbilitySubject.Report) @ApiOperation({ summary: 'Get receivable aging summary' }) @ApiResponse({ status: 200, description: 'Receivable aging summary response', - example: ARAgingSummaryResponseExample, + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(ARAgingSummaryResponseDto) }, + example: ARAgingSummaryResponseExample, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(ARAgingSummaryTableResponseDto) }, + }, + }, }) @ApiProduces( AcceptType.ApplicationJson, diff --git a/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummaryResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummaryResponse.dto.ts new file mode 100644 index 000000000..3c1a32fe1 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummaryResponse.dto.ts @@ -0,0 +1,115 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class ARAgingPeriodDto { + @ApiProperty({ description: 'From period date' }) + fromPeriod: string; + + @ApiPropertyOptional({ description: 'To period date' }) + toPeriod: string | null; + + @ApiProperty({ description: 'Before days', type: Number }) + beforeDays: number; + + @ApiPropertyOptional({ description: 'To days', type: Number }) + toDays: number | null; +} + +export class ARAgingPeriodTotalDto extends ARAgingPeriodDto { + @ApiProperty({ description: 'Period total', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; +} + +export class ARAgingCustomerDto { + @ApiProperty({ description: 'Customer name' }) + customerName: string; + + @ApiProperty({ description: 'Current balance', type: FinancialReportTotalDto }) + current: FinancialReportTotalDto; + + @ApiProperty({ description: 'Aging periods', type: [ARAgingPeriodTotalDto] }) + aging: ARAgingPeriodTotalDto[]; + + @ApiProperty({ description: 'Customer total', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; +} + +export class ARAgingSummaryDataDto { + @ApiProperty({ description: 'Customers aging data', type: [ARAgingCustomerDto] }) + customers: ARAgingCustomerDto[]; + + @ApiProperty({ description: 'Current total', type: FinancialReportTotalDto }) + current: FinancialReportTotalDto; + + @ApiProperty({ description: 'Aging totals', type: [ARAgingPeriodTotalDto] }) + aging: ARAgingPeriodTotalDto[]; + + @ApiProperty({ description: 'Grand total', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; +} + +export class ARAgingSummaryMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted as-of date' }) + formattedAsDate: string; +} + +export class ARAgingSummaryQueryResponseDto { + @ApiProperty({ description: 'As-of date' }) + asDate: string; + + @ApiProperty({ description: 'Aging days before', type: Number }) + agingDaysBefore: number; + + @ApiProperty({ description: 'Number of aging periods', type: Number }) + agingPeriods: number; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Customer IDs to include', type: [Number] }) + customersIds: number[]; + + @ApiProperty({ description: 'Branch IDs to include', type: [Number] }) + branchesIds: number[]; + + @ApiProperty({ description: 'Exclude zero balance accounts' }) + noneZero: boolean; +} + +export class ARAgingSummaryResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: ARAgingSummaryQueryResponseDto }) + query: ARAgingSummaryQueryResponseDto; + + @ApiProperty({ description: 'Aging columns definitions', type: [ARAgingPeriodDto] }) + columns: ARAgingPeriodDto[]; + + @ApiProperty({ description: 'Aging summary data', type: ARAgingSummaryDataDto }) + data: ARAgingSummaryDataDto; + + @ApiProperty({ description: 'Report metadata', type: ARAgingSummaryMetaDto }) + meta: ARAgingSummaryMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as ARAgingSummaryTableCellDto, + FinancialTableRowDto as ARAgingSummaryTableRowDto, + FinancialTableColumnDto as ARAgingSummaryTableColumnDto, + FinancialTableDataDto as ARAgingSummaryTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class ARAgingSummaryTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: ARAgingSummaryQueryResponseDto }) + query: ARAgingSummaryQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: ARAgingSummaryMetaDto }) + meta: ARAgingSummaryMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummaryService.ts b/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummaryService.ts index 3e3b6cd71..8a1d429d3 100644 --- a/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummaryService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummaryService.ts @@ -28,18 +28,19 @@ export class ARAgingSummaryService { this.ARAgingSummaryRepository.setFilter(filter); await this.ARAgingSummaryRepository.load(); + // Retrieve the aging summary report meta first to get date format. + const meta = await this.ARAgingSummaryMeta.meta(filter); + // A/R aging summary report instance. const ARAgingSummaryReport = new ARAgingSummarySheet( filter, this.ARAgingSummaryRepository, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); // A/R aging summary report data and columns. const data = ARAgingSummaryReport.reportData(); const columns = ARAgingSummaryReport.reportColumns(); - // Retrieve the aging summary report meta. - const meta = await this.ARAgingSummaryMeta.meta(filter); - // Triggers `onReceivableAgingViewed` event. await this.eventPublisher.emitAsync( events.reports.onReceivableAgingViewed, diff --git a/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummarySheet.ts b/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummarySheet.ts index a6c52f313..e506b4645 100644 --- a/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummarySheet.ts +++ b/packages/server/src/modules/FinancialStatements/modules/ARAgingSummary/ARAgingSummarySheet.ts @@ -14,6 +14,7 @@ import { ARAgingSummaryRepository } from './ARAgingSummaryRepository'; import { Customer } from '@/modules/Customers/models/Customer'; import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice'; import { ARAgingSummaryQueryDto } from './ARAgingSummaryQuery.dto'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export class ARAgingSummarySheet extends AgingSummaryReport { readonly query: ARAgingSummaryQueryDto; @@ -32,16 +33,20 @@ export class ARAgingSummarySheet extends AgingSummaryReport { * Constructor method. * @param {ARAgingSummaryQueryDto} query - Query * @param {ARAgingSummaryRepository} repository - Repository. + * @param {IFinancialReportMeta} meta - Report meta. */ constructor( query: ARAgingSummaryQueryDto, repository: ARAgingSummaryRepository, + meta: IFinancialReportMeta, ) { super(); this.query = query; this.repository = repository; + this.baseCurrency = meta.baseCurrency; this.numberFormat = this.query.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.overdueInvoicesByContactId = this.repository.overdueInvoicesByContactId; diff --git a/packages/server/src/modules/FinancialStatements/modules/AgingSummary/AgingSummary.ts b/packages/server/src/modules/FinancialStatements/modules/AgingSummary/AgingSummary.ts index 137de738c..bd44959eb 100644 --- a/packages/server/src/modules/FinancialStatements/modules/AgingSummary/AgingSummary.ts +++ b/packages/server/src/modules/FinancialStatements/modules/AgingSummary/AgingSummary.ts @@ -18,7 +18,7 @@ import { IARAgingSummaryCustomer } from '../ARAgingSummary/ARAgingSummary.types' export abstract class AgingSummaryReport extends AgingReport { readonly contacts: ModelObject[]; readonly agingPeriods: IAgingPeriod[] = []; - readonly baseCurrency: string; + public baseCurrency: string; readonly query: IAgingSummaryQuery; readonly overdueInvoicesByContactId: Record< number, diff --git a/packages/server/src/modules/FinancialStatements/modules/AgingSummary/AgingSummaryMeta.ts b/packages/server/src/modules/FinancialStatements/modules/AgingSummary/AgingSummaryMeta.ts index e6c2b1a11..bf7a89718 100644 --- a/packages/server/src/modules/FinancialStatements/modules/AgingSummary/AgingSummaryMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/AgingSummary/AgingSummaryMeta.ts @@ -13,7 +13,7 @@ export class AgingSummaryMeta { */ public async meta(query: IAgingSummaryQuery): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedAsDate = moment(query.asDate).format('YYYY/MM/DD'); + const formattedAsDate = moment(query.asDate).format(commonMeta.dateFormat); const formattedDateRange = `As ${formattedAsDate}`; return { diff --git a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.controller.ts b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.controller.ts index 41775b737..c85acfbd5 100644 --- a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.controller.ts @@ -1,20 +1,36 @@ import { Response } from 'express'; -import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; +import { Controller, Get, Headers, Query, Res, UseGuards } from '@nestjs/common'; import { AcceptType } from '@/constants/accept-type'; import { BalanceSheetApplication } from './BalanceSheetApplication'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { BalanceSheetQueryDto } from './BalanceSheet.dto'; -import { BalanceSheetResponseExample } from './BalanceSheet.swagger'; +import { + BalanceSheetResponseExample, + BalanceSheetTableResponseExample, +} from './BalanceSheet.swagger'; +import { + BalanceSheetResponseDto, + BalanceSheetTableResponseDto, +} from './BalanceSheetResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ReportsAction } from '../../types/Report.types'; @Controller('/reports/balance-sheet') @ApiTags('Reports') @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +@ApiExtraModels(BalanceSheetResponseDto, BalanceSheetTableResponseDto) export class BalanceSheetStatementController { constructor(private readonly balanceSheetApp: BalanceSheetApplication) {} @@ -25,11 +41,21 @@ export class BalanceSheetStatementController { * @param {string} acceptHeader - Accept header. */ @Get('') + @RequirePermission(ReportsAction.READ_BALANCE_SHEET, AbilitySubject.Report) @ApiOperation({ summary: 'Get balance sheet statement' }) @ApiResponse({ status: 200, description: 'Balance sheet statement', - example: BalanceSheetResponseExample, + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(BalanceSheetResponseDto) }, + example: BalanceSheetResponseExample, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(BalanceSheetTableResponseDto) }, + example: BalanceSheetTableResponseExample, + }, + }, }) @ApiProduces( AcceptType.ApplicationJson, diff --git a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.swagger.ts b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.swagger.ts index 119f8c9b7..23717f4ad 100644 --- a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.swagger.ts +++ b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.swagger.ts @@ -281,7 +281,7 @@ export const BalanceSheetResponseExample = { }, children: [ { - name: 'Current Liabilties', + name: 'Current Liabilities', id: 'CURRENT_LIABILITY', node_type: 'AGGREGATE', type: 'AGGREGATE', @@ -912,7 +912,7 @@ export const BalanceSheetTableResponseExample = { cells: [ { key: 'name', - value: 'Current Liabilties', + value: 'Current Liabilities', }, { key: 'total', @@ -1024,7 +1024,7 @@ export const BalanceSheetTableResponseExample = { cells: [ { key: 'name', - value: 'Total Current Liabilties', + value: 'Total Current Liabilities', }, { key: 'total', diff --git a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.ts b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.ts index 66383482f..d44d6d61d 100644 --- a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.ts +++ b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheet.ts @@ -19,7 +19,7 @@ import { BalanceSheetFiltering } from './BalanceSheetFiltering'; import { BalanceSheetNetIncome } from './BalanceSheetNetIncome'; import { BalanceSheetAggregators } from './BalanceSheetAggregators'; import { BalanceSheetAccounts } from './BalanceSheetAccounts'; -import { INumberFormatQuery } from '../../types/Report.types'; +import { INumberFormatQuery, IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; import { FinancialSheet } from '../../common/FinancialSheet'; export class BalanceSheet extends R.pipe( @@ -66,21 +66,23 @@ export class BalanceSheet extends R.pipe( /** * Constructor method. * @param {IBalanceSheetQuery} query - - * @param {IAccount[]} accounts - - * @param {string} baseCurrency - + * @param {BalanceSheetRepository} repository - + * @param {I18nService} i18n - + * @param {IFinancialReportMeta} meta - */ constructor( query: IBalanceSheetQuery, repository: BalanceSheetRepository, - baseCurrency: string, i18n: I18nService, + meta: IFinancialReportMeta, ) { super(); this.query = new BalanceSheetQuery(query); this.repository = repository; - this.baseCurrency = baseCurrency; + this.baseCurrency = meta.baseCurrency; this.numberFormat = this.query.query.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.i18n = i18n; } diff --git a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetInjectable.ts b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetInjectable.ts index d96a226d1..8b7e802bf 100644 --- a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetInjectable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetInjectable.ts @@ -40,19 +40,19 @@ export class BalanceSheetInjectable { // Loads all resources. await this.balanceSheetRepository.asyncInitialize(filter); + // Balance sheet meta first to get date format. + const meta = await this.balanceSheetMeta.meta(filter); + // Balance sheet report instance. const balanceSheetInstanace = new BalanceSheet( filter, this.balanceSheetRepository, - tenantMetadata.baseCurrency, this.i18n, + { baseCurrency: tenantMetadata.baseCurrency, dateFormat: meta.dateFormat }, ); // Balance sheet data. const data = balanceSheetInstanace.reportData(); - // Balance sheet meta. - const meta = await this.balanceSheetMeta.meta(filter); - // Triggers `onBalanceSheetViewed` event. await this.eventPublisher.emitAsync(events.reports.onBalanceSheetViewed, { query, diff --git a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetMeta.ts b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetMeta.ts index 13cc755d3..bb7614133 100644 --- a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetMeta.ts @@ -13,7 +13,7 @@ export class BalanceSheetMetaInjectable { */ public async meta(query: IBalanceSheetQuery): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedAsDate = moment(query.toDate).format('YYYY/MM/DD'); + const formattedAsDate = moment(query.toDate).format(commonMeta.dateFormat); const formattedDateRange = `As ${formattedAsDate}`; const sheetName = 'Balance Sheet Statement'; diff --git a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetResponse.dto.ts new file mode 100644 index 000000000..04f905969 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetResponse.dto.ts @@ -0,0 +1,158 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportPercentageDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class BalanceSheetDataNodeDto { + @ApiProperty({ description: 'Node identifier (string for aggregates, number for accounts)' }) + id: string | number; + + @ApiProperty({ description: 'Account or category name' }) + name: string; + + @ApiProperty({ + description: 'Type of node', + enum: ['AGGREGATE', 'ACCOUNTS', 'ACCOUNT', 'NET_INCOME'], + }) + nodeType: string; + + @ApiPropertyOptional({ description: 'Node type alias' }) + type?: string; + + @ApiProperty({ description: 'Total amount information', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Horizontal totals for date periods', type: [FinancialReportTotalDto] }) + horizontalTotals?: FinancialReportTotalDto[]; + + @ApiPropertyOptional({ description: 'Percentage of row', type: FinancialReportPercentageDto }) + percentageRow?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Percentage of column', type: FinancialReportPercentageDto }) + percentageColumn?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Previous period total', type: FinancialReportTotalDto }) + previousPeriod?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Previous period change', type: FinancialReportTotalDto }) + previousPeriodChange?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Previous period percentage', type: FinancialReportPercentageDto }) + previousPeriodPercentage?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Previous year total', type: FinancialReportTotalDto }) + previousYear?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Previous year change', type: FinancialReportTotalDto }) + previousYearChange?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Previous year percentage', type: FinancialReportPercentageDto }) + previousYearPercentage?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Account code' }) + code?: string; + + @ApiPropertyOptional({ description: 'Display index', type: Number }) + index?: number; + + @ApiPropertyOptional({ description: 'Parent account ID', type: Number }) + parentAccountId?: number; + + @ApiPropertyOptional({ description: 'Child nodes', type: () => [BalanceSheetDataNodeDto] }) + children?: BalanceSheetDataNodeDto[]; +} + +export class BalanceSheetMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted as-of date' }) + formattedAsDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class BalanceSheetQueryResponseDto { + @ApiProperty({ description: 'Column display type', enum: ['total', 'date_periods'] }) + displayColumnsType: string; + + @ApiProperty({ description: 'Column grouping', enum: ['day', 'month', 'year', 'quarter'] }) + displayColumnsBy: string; + + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Exclude zero balance accounts' }) + noneZero: boolean; + + @ApiProperty({ description: 'Exclude accounts with no transactions' }) + noneTransactions: boolean; + + @ApiProperty({ description: 'Accounting basis', enum: ['cash', 'accrual'] }) + basis: string; + + @ApiProperty({ description: 'Account IDs to include', type: [Number] }) + accountIds: number[]; + + @ApiProperty({ description: 'Show percentage of column' }) + percentageOfColumn: boolean; + + @ApiProperty({ description: 'Show percentage of row' }) + percentageOfRow: boolean; + + @ApiProperty({ description: 'Include previous period' }) + previousPeriod: boolean; + + @ApiProperty({ description: 'Show previous period amount change' }) + previousPeriodAmountChange: boolean; + + @ApiProperty({ description: 'Show previous period percentage change' }) + previousPeriodPercentageChange: boolean; + + @ApiProperty({ description: 'Include previous year' }) + previousYear: boolean; + + @ApiProperty({ description: 'Show previous year amount change' }) + previousYearAmountChange: boolean; + + @ApiProperty({ description: 'Show previous year percentage change' }) + previousYearPercentageChange: boolean; +} + +export class BalanceSheetResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: BalanceSheetQueryResponseDto }) + query: BalanceSheetQueryResponseDto; + + @ApiProperty({ description: 'Hierarchical balance sheet data', type: [BalanceSheetDataNodeDto] }) + data: BalanceSheetDataNodeDto[]; + + @ApiProperty({ description: 'Report metadata', type: BalanceSheetMetaDto }) + meta: BalanceSheetMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as BalanceSheetTableCellDto, + FinancialTableRowDto as BalanceSheetTableRowDto, + FinancialTableColumnDto as BalanceSheetTableColumnDto, + FinancialTableDataDto as BalanceSheetTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class BalanceSheetTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: BalanceSheetQueryResponseDto }) + query: BalanceSheetQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: BalanceSheetMetaDto }) + meta: BalanceSheetMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetSchema.ts b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetSchema.ts index 1056d9e9f..bee566e52 100644 --- a/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetSchema.ts +++ b/packages/server/src/modules/FinancialStatements/modules/BalanceSheet/BalanceSheetSchema.ts @@ -88,7 +88,7 @@ export const getBalanceSheetSchema = () => [ type: BALANCE_SHEET_SCHEMA_NODE_TYPE.AGGREGATE, children: [ { - name: 'balance_sheet.current_liabilties', + name: 'balance_sheet.current_liabilities', id: BALANCE_SHEET_SCHEMA_NODE_ID.CURRENT_LIABILITY, type: BALANCE_SHEET_SCHEMA_NODE_TYPE.ACCOUNTS, accountsTypes: [ diff --git a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlow.ts b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlow.ts index 880ee0a34..1b5ed0af2 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlow.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlow.ts @@ -27,7 +27,7 @@ import { DISPLAY_COLUMNS_BY } from './constants'; import { FinancialSheetStructure } from '../../common/FinancialSheetStructure'; import { Account } from '@/modules/Accounts/models/Account.model'; import { ILedger } from '@/modules/Ledger/types/Ledger.types'; -import { INumberFormatQuery } from '../../types/Report.types'; +import { INumberFormatQuery, IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; import { transformToMapBy } from '@/utils/transform-to-map-by'; import { accumSum } from '@/utils/accum-sum'; import { ModelObject } from 'objection'; @@ -62,12 +62,12 @@ export class CashFlowStatement extends R.pipe( cashLedger: ILedger, netIncomeLedger: ILedger, query: ICashFlowStatementQuery, - baseCurrency: string, i18n: I18nService, + meta: IFinancialReportMeta, ) { super(); - this.baseCurrency = baseCurrency; + this.baseCurrency = meta.baseCurrency; this.i18n = i18n; this.ledger = ledger; this.cashLedger = cashLedger; @@ -76,6 +76,7 @@ export class CashFlowStatement extends R.pipe( this.accountsByRootType = transformToMapBy(accounts, 'accountRootType'); this.query = query; this.numberFormat = this.query.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.dateRangeSet = []; this.comparatorDateType = query.displayColumnsType === 'total' ? 'day' : query.displayColumnsBy; diff --git a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlowService.ts b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlowService.ts index 6131f3f75..cfee2d7ef 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlowService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlowService.ts @@ -85,6 +85,9 @@ export class CashFlowStatementService { const cashLedger = Ledger.fromTransactions(cashAtBeginningTransactions); const netIncomeLedger = Ledger.fromTransactions(netIncome); + // Retrieve the cashflow sheet meta first to get date format. + const meta = await this.cashflowSheetMeta.meta(filter); + // Cash flow statement. const cashFlowInstance = new CashFlowStatement( accounts, @@ -92,11 +95,9 @@ export class CashFlowStatementService { cashLedger, netIncomeLedger, filter, - tenant.metadata.baseCurrency, this.i18n, + { baseCurrency: tenant.metadata.baseCurrency, dateFormat: meta.dateFormat }, ); - // Retrieve the cashflow sheet meta. - const meta = await this.cashflowSheetMeta.meta(filter); return { data: cashFlowInstance.reportData(), diff --git a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlowTable.ts b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlowTable.ts index 6b30583be..41d425428 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlowTable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashFlowTable.ts @@ -239,7 +239,7 @@ export class CashFlowTable { section: ICashFlowStatementSection, ): ICashFlowStatementSection => { const label = section.footerLabel - ? section.footerLabel + ? this.i18n.t(section.footerLabel) : this.i18n.t('financial_sheet.total_row', { args: { value: section.label }, }); @@ -302,7 +302,7 @@ export class CashFlowTable { * @returns {ITableColumn} */ private totalColumns = (): ITableColumn[] => { - return [{ key: 'total', label: this.i18n.t('Total') }]; + return [{ key: 'total', label: this.i18n.t('cash_flow_statement.total') }]; }; /** @@ -366,7 +366,7 @@ export class CashFlowTable { */ public tableColumns = (): ITableColumn[] => { return R.compose( - R.concat([{ key: 'name', label: this.i18n.t('Account name') }]), + R.concat([{ key: 'name', label: this.i18n.t('cash_flow_statement.account_name') }]), R.when( R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), R.concat(this.datePeriodsColumns()), diff --git a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/Cashflow.controller.ts b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/Cashflow.controller.ts index 870c5e4dd..967f3e5f2 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/Cashflow.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/Cashflow.controller.ts @@ -1,28 +1,50 @@ import { Response } from 'express'; -import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; +import { Controller, Get, Headers, Query, Res, UseGuards } from '@nestjs/common'; import { AcceptType } from '@/constants/accept-type'; import { CashflowSheetApplication } from './CashflowSheetApplication'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { CashFlowStatementQueryDto } from './CashFlowStatementQuery.dto'; import { CashflowStatementResponseExample } from './CashflowStatement.swagger'; +import { + CashflowStatementResponseDto, + CashflowStatementTableResponseDto, +} from './CashflowStatementResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ReportsAction } from '../../types/Report.types'; @Controller('reports/cashflow-statement') @ApiTags('Reports') @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +@ApiExtraModels(CashflowStatementResponseDto, CashflowStatementTableResponseDto) export class CashflowController { constructor(private readonly cashflowSheetApp: CashflowSheetApplication) { } @Get() + @RequirePermission(ReportsAction.READ_CASHFLOW, AbilitySubject.Report) @ApiResponse({ status: 200, description: 'Cashflow statement report', - example: CashflowStatementResponseExample, + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(CashflowStatementResponseDto) }, + example: CashflowStatementResponseExample, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(CashflowStatementTableResponseDto) }, + }, + }, }) @ApiOperation({ summary: 'Get cashflow statement report' }) @ApiProduces( diff --git a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashflowSheetMeta.ts b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashflowSheetMeta.ts index 1b8f3d796..663ba6b23 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashflowSheetMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashflowSheetMeta.ts @@ -1,5 +1,6 @@ import * as moment from 'moment'; import { Injectable } from '@nestjs/common'; +import { I18nService } from 'nestjs-i18n'; import { FinancialSheetMeta } from '../../common/FinancialSheetMeta'; import { ICashFlowStatementMeta, @@ -8,7 +9,10 @@ import { @Injectable() export class CashflowSheetMeta { - constructor(private readonly financialSheetMeta: FinancialSheetMeta) {} + constructor( + private readonly financialSheetMeta: FinancialSheetMeta, + private readonly i18n: I18nService, + ) {} /** * Cashflow sheet meta. @@ -19,11 +23,13 @@ export class CashflowSheetMeta { query: ICashFlowStatementQuery, ): Promise { const meta = await this.financialSheetMeta.meta(); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); - const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`; + const formattedToDate = moment(query.toDate).format(meta.dateFormat); + const formattedFromDate = moment(query.fromDate).format(meta.dateFormat); + const fromLabel = this.i18n.t('cash_flow_statement.from_date'); + const toLabel = this.i18n.t('cash_flow_statement.to_date'); + const formattedDateRange = `${fromLabel} ${formattedFromDate} | ${toLabel} ${formattedToDate}`; - const sheetName = 'Statement of Cash Flow'; + const sheetName = this.i18n.t('cash_flow_statement.sheet_name'); return { ...meta, diff --git a/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashflowStatementResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashflowStatementResponse.dto.ts new file mode 100644 index 000000000..d110874c9 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/CashFlowStatement/CashflowStatementResponse.dto.ts @@ -0,0 +1,109 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class CashflowStatementDataNodeDto { + @ApiProperty({ description: 'Node identifier (string for aggregates, number for accounts)' }) + id: string | number; + + @ApiProperty({ description: 'Account or category name' }) + name: string; + + @ApiProperty({ + description: 'Type of node', + enum: ['AGGREGATE', 'ACCOUNT', 'NET_INCOME', 'TOTAL'], + }) + nodeType: string; + + @ApiPropertyOptional({ description: 'Node type alias' }) + type?: string; + + @ApiProperty({ description: 'Total amount information', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Horizontal totals for date periods', type: [FinancialReportTotalDto] }) + horizontalTotals?: FinancialReportTotalDto[]; + + @ApiPropertyOptional({ description: 'Account code' }) + code?: string; + + @ApiPropertyOptional({ description: 'Display index', type: Number }) + index?: number; + + @ApiPropertyOptional({ description: 'Child nodes', type: () => [CashflowStatementDataNodeDto] }) + children?: CashflowStatementDataNodeDto[]; +} + +export class CashflowStatementMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class CashflowStatementQueryResponseDto { + @ApiProperty({ description: 'Column display type', enum: ['total', 'date_periods'] }) + displayColumnsType: string; + + @ApiProperty({ description: 'Column grouping', enum: ['day', 'month', 'year', 'quarter'] }) + displayColumnsBy: string; + + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Exclude zero balance accounts' }) + noneZero: boolean; + + @ApiProperty({ description: 'Exclude accounts with no transactions' }) + noneTransactions: boolean; + + @ApiProperty({ description: 'Accounting basis', enum: ['cash', 'accrual'] }) + basis: string; + + @ApiProperty({ description: 'Account IDs to include', type: [Number] }) + accountIds: number[]; +} + +export class CashflowStatementResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: CashflowStatementQueryResponseDto }) + query: CashflowStatementQueryResponseDto; + + @ApiProperty({ description: 'Hierarchical cashflow data', type: [CashflowStatementDataNodeDto] }) + data: CashflowStatementDataNodeDto[]; + + @ApiProperty({ description: 'Report metadata', type: CashflowStatementMetaDto }) + meta: CashflowStatementMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as CashflowStatementTableCellDto, + FinancialTableRowDto as CashflowStatementTableRowDto, + FinancialTableColumnDto as CashflowStatementTableColumnDto, + FinancialTableDataDto as CashflowStatementTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class CashflowStatementTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: CashflowStatementQueryResponseDto }) + query: CashflowStatementQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: CashflowStatementMetaDto }) + meta: CashflowStatementMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummary.controller.ts b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummary.controller.ts index 3601a2818..20ff6aa01 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummary.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummary.controller.ts @@ -1,26 +1,44 @@ import { Response } from 'express'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; import { CustomerBalanceSummaryApplication } from './CustomerBalanceSummaryApplication'; import { CustomerBalanceSummaryQueryDto } from './CustomerBalanceSummaryQuery.dto'; import { AcceptType } from '@/constants/accept-type'; +import { + CustomerBalanceSummaryResponseDto, + CustomerBalanceSummaryTableResponseDto, +} from './CustomerBalanceSummaryResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('/reports/customer-balance-summary') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(CustomerBalanceSummaryResponseDto, CustomerBalanceSummaryTableResponseDto) export class CustomerBalanceSummaryController { constructor( private readonly customerBalanceSummaryApp: CustomerBalanceSummaryApplication, ) {} @Get() - @ApiResponse({ status: 200, description: 'Customer balance summary report' }) + @ApiResponse({ + status: 200, + description: 'Customer balance summary report', + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(CustomerBalanceSummaryResponseDto) }, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(CustomerBalanceSummaryTableResponseDto) }, + }, + }, + }) @ApiOperation({ summary: 'Get customer balance summary report' }) @ApiProduces( AcceptType.ApplicationJson, diff --git a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummary.ts b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummary.ts index 3363b6dac..25d0854f9 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummary.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummary.ts @@ -8,7 +8,7 @@ import { import { ContactBalanceSummaryReport } from '../ContactBalanceSummary/ContactBalanceSummary'; import { ILedger } from '@/modules/Ledger/types/Ledger.types'; import { ModelObject } from 'objection'; -import { INumberFormatQuery } from '../../types/Report.types'; +import { INumberFormatQuery, IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; import { Customer } from '@/modules/Customers/models/Customer'; export class CustomerBalanceSummaryReport extends ContactBalanceSummaryReport { @@ -23,21 +23,22 @@ export class CustomerBalanceSummaryReport extends ContactBalanceSummaryReport { * @param {IJournalPoster} receivableLedger * @param {ICustomer[]} customers * @param {ICustomerBalanceSummaryQuery} filter - * @param {string} baseCurrency + * @param {IFinancialReportMeta} meta */ constructor( ledger: ILedger, customers: ModelObject[], filter: ICustomerBalanceSummaryQuery, - baseCurrency: string + meta: IFinancialReportMeta, ) { super(); this.ledger = ledger; - this.baseCurrency = baseCurrency; + this.baseCurrency = meta.baseCurrency; this.customers = customers; this.filter = filter; this.numberFormat = this.filter.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } /** diff --git a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryMeta.ts b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryMeta.ts index 0d3018fab..5ddc2535c 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryMeta.ts @@ -19,7 +19,7 @@ export class CustomerBalanceSummaryMeta { query: ICustomerBalanceSummaryQuery, ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedAsDate = moment(query.asDate).format('YYYY/MM/DD'); + const formattedAsDate = moment(query.asDate).format(commonMeta.dateFormat); const formattedDateRange = `As ${formattedAsDate}`; return { diff --git a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryResponse.dto.ts new file mode 100644 index 000000000..a1dbb929f --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryResponse.dto.ts @@ -0,0 +1,82 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class CustomerBalanceDto { + @ApiProperty({ description: 'Customer ID', type: Number }) + customerId: number; + + @ApiProperty({ description: 'Customer name' }) + customerName: string; + + @ApiPropertyOptional({ description: 'Opening balance', type: FinancialReportTotalDto }) + openingBalance?: FinancialReportTotalDto; + + @ApiProperty({ description: 'Closing balance', type: FinancialReportTotalDto }) + closingBalance: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Total debit', type: FinancialReportTotalDto }) + totalDebit?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Total credit', type: FinancialReportTotalDto }) + totalCredit?: FinancialReportTotalDto; +} + +export class CustomerBalanceSummaryMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted as-of date' }) + formattedAsDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class CustomerBalanceSummaryQueryResponseDto { + @ApiProperty({ description: 'As-of date' }) + asDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Customer IDs to include', type: [Number] }) + customersIds: number[]; + + @ApiProperty({ description: 'Exclude zero balance customers' }) + noneZero: boolean; + + @ApiProperty({ description: 'Exclude inactive customers' }) + noneInactive: boolean; +} + +export class CustomerBalanceSummaryResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: CustomerBalanceSummaryQueryResponseDto }) + query: CustomerBalanceSummaryQueryResponseDto; + + @ApiProperty({ description: 'Customer balances', type: [CustomerBalanceDto] }) + data: CustomerBalanceDto[]; + + @ApiProperty({ description: 'Report metadata', type: CustomerBalanceSummaryMetaDto }) + meta: CustomerBalanceSummaryMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as CustomerBalanceSummaryTableCellDto, + FinancialTableRowDto as CustomerBalanceSummaryTableRowDto, + FinancialTableColumnDto as CustomerBalanceSummaryTableColumnDto, + FinancialTableDataDto as CustomerBalanceSummaryTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class CustomerBalanceSummaryTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: CustomerBalanceSummaryQueryResponseDto }) + query: CustomerBalanceSummaryQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: CustomerBalanceSummaryMetaDto }) + meta: CustomerBalanceSummaryMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryService.ts b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryService.ts index 4523844b6..920f69816 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryService.ts @@ -63,15 +63,16 @@ export class CustomerBalanceSummaryService { // Ledger query. const ledger = new Ledger(customersEntries); + // Retrieve the customer balance summary meta first to get date format. + const meta = await this.customerBalanceSummaryMeta.meta(filter); + // Report instance. const report = new CustomerBalanceSummaryReport( ledger, customers, filter, - tenantMetadata.baseCurrency, + { baseCurrency: tenantMetadata.baseCurrency, dateFormat: meta.dateFormat }, ); - // Retrieve the customer balance summary meta. - const meta = await this.customerBalanceSummaryMeta.meta(filter); // Triggers `onCustomerBalanceSummaryViewed` event. await this.eventPublisher.emitAsync( diff --git a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryTableRows.ts b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryTableRows.ts index abba4417d..da6680730 100644 --- a/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryTableRows.ts +++ b/packages/server/src/modules/FinancialStatements/modules/CustomerBalanceSummary/CustomerBalanceSummaryTableRows.ts @@ -91,7 +91,7 @@ export class CustomerBalanceSummaryTable { */ private getTotalColumnsAccessor = (): IColumnMapperMeta[] => { const columns = [ - { key: 'name', value: this.i18n.t('Total') }, + { key: 'name', value: this.i18n.t('contact_summary_balance.total') }, { key: 'total', accessor: 'total.formattedAmount' }, ]; // @ts-ignore diff --git a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.controller.ts b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.controller.ts index 7a65dc77f..dc169da1c 100644 --- a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.controller.ts @@ -1,30 +1,52 @@ import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { Response } from 'express'; -import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; +import { Controller, Get, Headers, Query, Res, UseGuards } from '@nestjs/common'; import { GeneralLedgerApplication } from './GeneralLedgerApplication'; import { AcceptType } from '@/constants/accept-type'; import { GeneralLedgerQueryDto } from './GeneralLedgerQuery.dto'; import { GeneralLedgerResponseExample } from './GeneralLedger.swagger'; +import { + GeneralLedgerResponseDto, + GeneralLedgerTableResponseDto, +} from './GeneralLedgerResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ReportsAction } from '../../types/Report.types'; @Controller('/reports/general-ledger') @ApiTags('Reports') @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +@ApiExtraModels(GeneralLedgerResponseDto, GeneralLedgerTableResponseDto) export class GeneralLedgerController { constructor( private readonly generalLedgerApplication: GeneralLedgerApplication, ) {} @Get() + @RequirePermission(ReportsAction.READ_GENERAL_LEDGET, AbilitySubject.Report) @ApiResponse({ status: 200, description: 'General ledger report', - example: GeneralLedgerResponseExample, + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(GeneralLedgerResponseDto) }, + example: GeneralLedgerResponseExample, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(GeneralLedgerTableResponseDto) }, + }, + }, }) @ApiOperation({ summary: 'Get general ledger report' }) @ApiProduces( diff --git a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.ts b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.ts index af5febfc8..bfc946b72 100644 --- a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.ts +++ b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.ts @@ -19,6 +19,7 @@ import { Account } from '@/modules/Accounts/models/Account.model'; import { ModelObject } from 'objection'; import { flatToNestedArray } from '@/utils/flat-to-nested-array'; import { getTransactionTypeLabel } from '@/modules/BankingTransactions/utils'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export class GeneralLedgerSheet extends R.compose(FinancialSheetStructure)( FinancialSheet, @@ -33,18 +34,21 @@ export class GeneralLedgerSheet extends R.compose(FinancialSheetStructure)( * @param {IGeneralLedgerSheetQuery} query - * @param {GeneralLedgerRepository} repository - * @param {I18nService} i18n - + * @param {IFinancialReportMeta} meta - */ constructor( query: IGeneralLedgerSheetQuery, repository: GeneralLedgerRepository, i18n: I18nService, + meta: IFinancialReportMeta, ) { super(); this.query = query; this.numberFormat = this.query.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.repository = repository; - this.baseCurrency = this.repository.tenant.metadata.baseCurrency; + this.baseCurrency = meta.baseCurrency; this.i18n = i18n; } @@ -87,7 +91,7 @@ export class GeneralLedgerSheet extends R.compose(FinancialSheetStructure)( return { id: entry.id, date: entry.date, - dateFormatted: moment(entry.date).format('YYYY MMM DD'), + dateFormatted: moment(entry.date).format(this.dateFormat), referenceType: entry.transactionType, referenceId: entry.transactionId, diff --git a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.types.ts b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.types.ts index 9b57bbcc3..05881cdf2 100644 --- a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.types.ts +++ b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedger.types.ts @@ -6,6 +6,7 @@ export interface IGeneralLedgerSheetQuery { toDate: Date | string; basis: string; numberFormat: IGeneralLedgerNumberFormat; + dateFormat?: string; noneTransactions: boolean; accountsIds: number[]; branchesIds?: number[]; diff --git a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerMeta.ts b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerMeta.ts index 544ee9537..2aa82352e 100644 --- a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerMeta.ts @@ -19,8 +19,8 @@ export class GeneralLedgerMeta { ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDate = moment(query.toDate).format(commonMeta.dateFormat); + const formattedFromDate = moment(query.fromDate).format(commonMeta.dateFormat); const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`; return { diff --git a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerResponse.dto.ts new file mode 100644 index 000000000..5c2372386 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerResponse.dto.ts @@ -0,0 +1,156 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class GeneralLedgerTransactionDto { + @ApiProperty({ description: 'Transaction date' }) + date: string; + + @ApiProperty({ description: 'Formatted date' }) + dateFormatted: string; + + @ApiProperty({ description: 'Reference type' }) + referenceType: string; + + @ApiProperty({ description: 'Reference ID', type: Number }) + referenceId: number; + + @ApiPropertyOptional({ description: 'Transaction number' }) + transactionNumber: string | null; + + @ApiProperty({ description: 'Formatted transaction type' }) + transactionTypeFormatted: string; + + @ApiProperty({ description: 'Contact name' }) + contactName: string; + + @ApiProperty({ description: 'Contact type' }) + contactType: string; + + @ApiProperty({ description: 'Transaction type' }) + transactionType: string; + + @ApiProperty({ description: 'Transaction index', type: Number }) + index: number; + + @ApiPropertyOptional({ description: 'Transaction note' }) + note: string | null; + + @ApiProperty({ description: 'Credit amount', type: Number }) + credit: number; + + @ApiProperty({ description: 'Debit amount', type: Number }) + debit: number; + + @ApiProperty({ description: 'Transaction amount', type: Number }) + amount: number; + + @ApiProperty({ description: 'Running balance', type: Number }) + runningBalance: number; + + @ApiProperty({ description: 'Formatted amount' }) + formattedAmount: string; + + @ApiProperty({ description: 'Formatted credit' }) + formattedCredit: string; + + @ApiProperty({ description: 'Formatted debit' }) + formattedDebit: string; + + @ApiProperty({ description: 'Formatted running balance' }) + formattedRunningBalance: string; + + @ApiProperty({ description: 'Currency code' }) + currencyCode: string; +} + +export class GeneralLedgerAccountDto { + @ApiProperty({ description: 'Account ID', type: Number }) + id: number; + + @ApiProperty({ description: 'Account name' }) + name: string; + + @ApiProperty({ description: 'Account code' }) + code: string; + + @ApiProperty({ description: 'Account index', type: Number }) + index: number; + + @ApiPropertyOptional({ description: 'Parent account ID', type: Number }) + parentAccountId: number | null; + + @ApiProperty({ description: 'Opening balance', type: FinancialReportTotalDto }) + openingBalance: FinancialReportTotalDto; + + @ApiProperty({ description: 'Account transactions', type: [GeneralLedgerTransactionDto] }) + transactions: GeneralLedgerTransactionDto[]; + + @ApiProperty({ description: 'Closing balance', type: FinancialReportTotalDto }) + closingBalance: FinancialReportTotalDto; +} + +export class GeneralLedgerMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class GeneralLedgerQueryResponseDto { + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Accounting basis', enum: ['cash', 'accrual'] }) + basis: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Exclude zero balance accounts' }) + noneZero: boolean; + + @ApiProperty({ description: 'Account IDs to include', type: [Number] }) + accountsIds: number[]; +} + +export class GeneralLedgerResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: GeneralLedgerQueryResponseDto }) + query: GeneralLedgerQueryResponseDto; + + @ApiProperty({ description: 'General ledger data', type: [GeneralLedgerAccountDto] }) + data: GeneralLedgerAccountDto[]; + + @ApiProperty({ description: 'Report metadata', type: GeneralLedgerMetaDto }) + meta: GeneralLedgerMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as GeneralLedgerTableCellDto, + FinancialTableRowDto as GeneralLedgerTableRowDto, + FinancialTableColumnDto as GeneralLedgerTableColumnDto, + FinancialTableDataDto as GeneralLedgerTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class GeneralLedgerTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: GeneralLedgerQueryResponseDto }) + query: GeneralLedgerQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: GeneralLedgerMetaDto }) + meta: GeneralLedgerMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerService.ts b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerService.ts index 28143d3c1..ab0a0dcf0 100644 --- a/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerService.ts @@ -37,18 +37,19 @@ export class GeneralLedgerService { this.generalLedgerRepository.setFilter(filter); await this.generalLedgerRepository.asyncInitialize(); + // Retrieve general ledger report metadata first to get the date format. + const meta = await this.generalLedgerMeta.meta(filter); + // General ledger report instance. const generalLedgerInstance = new GeneralLedgerSheet( filter, this.generalLedgerRepository, this.i18n, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); // Retrieve general ledger report data. const reportData = generalLedgerInstance.reportData(); - // Retrieve general ledger report metadata. - const meta = await this.generalLedgerMeta.meta(filter); - // Triggers `onGeneralLedgerViewed` event. await this.eventEmitter.emitAsync(events.reports.onGeneralLedgerViewed, {}); diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.controller.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.controller.ts index a3074e66e..a099900db 100644 --- a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.controller.ts @@ -1,14 +1,19 @@ import { Response } from 'express'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExtraModels, ApiOperation, ApiTags, ApiResponse, ApiProduces, getSchemaPath } from '@nestjs/swagger'; import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; import { InventoryItemDetailsApplication } from './InventoryItemDetailsApplication'; import { AcceptType } from '@/constants/accept-type'; import { InventoryItemDetailsQueryDto } from './InventoryItemDetailsQuery.dto'; +import { + InventoryItemDetailsResponseDto, + InventoryItemDetailsTableResponseDto, +} from './InventoryItemDetailsResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('reports/inventory-item-details') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(InventoryItemDetailsResponseDto, InventoryItemDetailsTableResponseDto) export class InventoryItemDetailsController { constructor( private readonly inventoryItemDetailsApp: InventoryItemDetailsApplication, @@ -16,6 +21,25 @@ export class InventoryItemDetailsController { @Get('/') @ApiOperation({ summary: 'Get inventory item details' }) + @ApiResponse({ + status: 200, + description: 'Inventory item details report', + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(InventoryItemDetailsResponseDto) }, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(InventoryItemDetailsTableResponseDto) }, + }, + }, + }) + @ApiProduces( + AcceptType.ApplicationJson, + AcceptType.ApplicationJsonTable, + AcceptType.ApplicationPdf, + AcceptType.ApplicationXlsx, + AcceptType.ApplicationCsv, + ) async inventoryItemDetails( @Query() query: InventoryItemDetailsQueryDto, @Res({ passthrough: true }) res: Response, diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.service.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.service.ts index 6f248074a..2ee46fb9d 100644 --- a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.service.ts +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.service.ts @@ -8,6 +8,7 @@ import { InventoryDetails } from './InventoryItemDetails'; import { InventoryItemDetailsRepository } from './InventoryItemDetailsRepository'; import { InventoryDetailsMetaInjectable } from './InventoryItemDetailsMeta'; import { getInventoryItemDetailsDefaultQuery } from './constant'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; @Injectable() export class InventoryDetailsService { @@ -15,6 +16,7 @@ export class InventoryDetailsService { private readonly inventoryItemDetailsRepository: InventoryItemDetailsRepository, private readonly inventoryDetailsMeta: InventoryDetailsMetaInjectable, private readonly i18n: I18nService, + private readonly tenancyContext: TenancyContext, ) {} /** @@ -34,13 +36,16 @@ export class InventoryDetailsService { this.inventoryItemDetailsRepository.setFilter(filter); await this.inventoryItemDetailsRepository.asyncInit(); + // Retrieve the meta first to get date format. + const meta = await this.inventoryDetailsMeta.meta(query); + // Inventory details report mapper. const inventoryDetailsInstance = new InventoryDetails( filter, this.inventoryItemDetailsRepository, this.i18n, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); - const meta = await this.inventoryDetailsMeta.meta(query); return { data: inventoryDetailsInstance.reportData(), diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.ts index e5cc9cc46..30e3c9b42 100644 --- a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.ts +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetails.ts @@ -17,6 +17,8 @@ import { Item } from '@/modules/Items/models/Item'; import { IFormatNumberSettings, INumberFormatQuery, + IFinancialReportMeta, + DEFAULT_REPORT_META, } from '../../types/Report.types'; import { InventoryTransaction } from '@/modules/InventoryCost/models/InventoryTransaction'; import { InventoryItemDetailsRepository } from './InventoryItemDetailsRepository'; @@ -35,11 +37,13 @@ export class InventoryDetails extends FinancialSheet { * Constructor method. * @param {InventoryItemDetailsRepository} repository - The repository. * @param {I18nService} i18n - The i18n service. + * @param {IFinancialReportMeta} meta - Report meta. */ constructor( filter: IInventoryDetailsQuery, repository: InventoryItemDetailsRepository, i18n: I18nService, + meta: IFinancialReportMeta, ) { super(); @@ -48,6 +52,7 @@ export class InventoryDetails extends FinancialSheet { this.query = filter; this.numberFormat = this.query.numberFormat; this.i18n = i18n; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } /** @@ -89,7 +94,7 @@ export class InventoryDetails extends FinancialSheet { */ public getDateMeta(date: Date | string): IInventoryDetailsDate { return { - formattedDate: moment(date).format('YYYY-MM-DD'), + formattedDate: moment(date).format(this.dateFormat), date: moment(date).toDate(), }; } diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsMeta.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsMeta.ts index 7515e1917..ea8c1848b 100644 --- a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsMeta.ts @@ -19,8 +19,8 @@ export class InventoryDetailsMetaInjectable { ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); - const formattedToDay = moment(query.toDate).format('YYYY/MM/DD'); + const formattedFromDate = moment(query.fromDate).format(commonMeta.dateFormat); + const formattedToDay = moment(query.toDate).format(commonMeta.dateFormat); const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDay}`; const sheetName = 'Inventory Item Details'; diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsResponse.dto.ts new file mode 100644 index 000000000..eeb5450b3 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsResponse.dto.ts @@ -0,0 +1,114 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class InventoryItemTransactionDto { + @ApiProperty({ description: 'Transaction date' }) + date: string; + + @ApiProperty({ description: 'Formatted date' }) + dateFormatted: string; + + @ApiProperty({ description: 'Transaction type' }) + transactionType: string; + + @ApiProperty({ description: 'Reference ID', type: Number }) + referenceId: number; + + @ApiProperty({ description: 'Transaction number' }) + transactionNumber: string; + + @ApiPropertyOptional({ description: 'Transaction description' }) + description?: string; + + @ApiProperty({ description: 'Quantity', type: Number }) + quantity: number; + + @ApiProperty({ description: 'Rate', type: Number }) + rate: number; + + @ApiProperty({ description: 'Total amount', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; + + @ApiProperty({ description: 'Running quantity', type: Number }) + runningQuantity: number; +} + +export class InventoryItemDetailDto { + @ApiProperty({ description: 'Item ID', type: Number }) + id: number; + + @ApiProperty({ description: 'Item name' }) + name: string; + + @ApiProperty({ description: 'Item code' }) + code: string; + + @ApiProperty({ description: 'Opening quantity', type: Number }) + openingQuantity: number; + + @ApiProperty({ description: 'Closing quantity', type: Number }) + closingQuantity: number; + + @ApiProperty({ description: 'Item transactions', type: [InventoryItemTransactionDto] }) + transactions: InventoryItemTransactionDto[]; +} + +export class InventoryItemDetailsMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class InventoryItemDetailsQueryResponseDto { + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Item IDs to include', type: [Number] }) + itemsIds: number[]; +} + +export class InventoryItemDetailsResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: InventoryItemDetailsQueryResponseDto }) + query: InventoryItemDetailsQueryResponseDto; + + @ApiProperty({ description: 'Inventory items with details', type: [InventoryItemDetailDto] }) + data: InventoryItemDetailDto[]; + + @ApiProperty({ description: 'Report metadata', type: InventoryItemDetailsMetaDto }) + meta: InventoryItemDetailsMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as InventoryItemDetailsTableCellDto, + FinancialTableRowDto as InventoryItemDetailsTableRowDto, + FinancialTableColumnDto as InventoryItemDetailsTableColumnDto, + FinancialTableDataDto as InventoryItemDetailsTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class InventoryItemDetailsTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: InventoryItemDetailsQueryResponseDto }) + query: InventoryItemDetailsQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: InventoryItemDetailsMetaDto }) + meta: InventoryItemDetailsMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsTable.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsTable.ts index e42408c83..6f55875ca 100644 --- a/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsTable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryItemDetails/InventoryItemDetailsTable.ts @@ -93,7 +93,7 @@ export class InventoryItemDetailsTable { ): ITableRow => { const columns: Array = [ { key: 'date', accessor: 'date.formattedDate' }, - { key: 'closing', value: this.i18n.t('Opening balance') }, + { key: 'closing', value: this.i18n.t('inventory_item_details.opening_balance') }, { key: 'empty', value: '' }, { key: 'quantity', accessor: 'quantity.formattedNumber' }, { key: 'empty', value: '' }, @@ -115,7 +115,7 @@ export class InventoryItemDetailsTable { ): ITableRow => { const columns: Array = [ { key: 'date', accessor: 'date.formattedDate' }, - { key: 'closing', value: this.i18n.t('Closing balance') }, + { key: 'closing', value: this.i18n.t('inventory_item_details.closing_balance') }, { key: 'empty', value: '' }, { key: 'quantity', accessor: 'quantity.formattedNumber' }, { key: 'empty', value: '' }, @@ -193,16 +193,16 @@ export class InventoryItemDetailsTable { */ public tableColumns = (): ITableColumn[] => { return [ - { key: 'date', label: this.i18n.t('Date') }, - { key: 'transaction_type', label: this.i18n.t('Transaction type') }, - { key: 'transaction_id', label: this.i18n.t('Transaction #') }, - { key: 'quantity', label: this.i18n.t('Quantity') }, - { key: 'rate', label: this.i18n.t('Rate') }, - { key: 'total', label: this.i18n.t('Total') }, - { key: 'value', label: this.i18n.t('Value') }, - { key: 'profit_margin', label: this.i18n.t('Profit Margin') }, - { key: 'running_quantity', label: this.i18n.t('Running quantity') }, - { key: 'running_value', label: this.i18n.t('Running Value') }, + { key: 'date', label: this.i18n.t('inventory_item_details.date') }, + { key: 'transaction_type', label: this.i18n.t('inventory_item_details.transaction_type') }, + { key: 'transaction_id', label: this.i18n.t('inventory_item_details.transaction_number') }, + { key: 'quantity', label: this.i18n.t('inventory_item_details.quantity') }, + { key: 'rate', label: this.i18n.t('inventory_item_details.rate') }, + { key: 'total', label: this.i18n.t('inventory_item_details.total') }, + { key: 'value', label: this.i18n.t('inventory_item_details.value') }, + { key: 'profit_margin', label: this.i18n.t('inventory_item_details.profit_margin') }, + { key: 'running_quantity', label: this.i18n.t('inventory_item_details.running_quantity') }, + { key: 'running_value', label: this.i18n.t('inventory_item_details.running_value') }, ]; }; } diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuation.controller.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuation.controller.ts index 4edc380e2..1dfbf0c80 100644 --- a/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuation.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuation.controller.ts @@ -1,19 +1,26 @@ import { Response } from 'express'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; import { InventoryValuationSheetApplication } from './InventoryValuationSheetApplication'; import { InventoryValuationQueryDto } from './InventoryValuationQuery.dto'; import { AcceptType } from '@/constants/accept-type'; +import { + InventoryValuationResponseDto, + InventoryValuationTableResponseDto, +} from './InventoryValuationResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('reports/inventory-valuation') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(InventoryValuationResponseDto, InventoryValuationTableResponseDto) export class InventoryValuationController { constructor( private readonly inventoryValuationApp: InventoryValuationSheetApplication, @@ -24,6 +31,14 @@ export class InventoryValuationController { @ApiResponse({ status: 200, description: 'The inventory valuation sheet', + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(InventoryValuationResponseDto) }, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(InventoryValuationTableResponseDto) }, + }, + }, }) @ApiProduces( AcceptType.ApplicationJson, diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationResponse.dto.ts new file mode 100644 index 000000000..d59f5a029 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationResponse.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class InventoryValuationItemDto { + @ApiProperty({ description: 'Item ID', type: Number }) + id: number; + + @ApiProperty({ description: 'Item name' }) + name: string; + + @ApiProperty({ description: 'Item code' }) + code: string; + + @ApiProperty({ description: 'Item type' }) + type: string; + + @ApiPropertyOptional({ description: 'Quantity on hand', type: Number }) + quantityOnHand?: number; + + @ApiPropertyOptional({ description: 'Average cost', type: Number }) + averageCost?: number; + + @ApiPropertyOptional({ description: 'Total value', type: FinancialReportTotalDto }) + totalValue?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Asset account name' }) + assetAccountName?: string; + + @ApiPropertyOptional({ description: 'Asset account code' }) + assetAccountCode?: string; +} + +export class InventoryValuationMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted as-of date' }) + formattedAsDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class InventoryValuationQueryResponseDto { + @ApiProperty({ description: 'As-of date' }) + asDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Item IDs to include', type: [Number] }) + itemsIds: number[]; + + @ApiProperty({ description: 'Exclude zero quantity items' }) + noneZero: boolean; +} + +export class InventoryValuationResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: InventoryValuationQueryResponseDto }) + query: InventoryValuationQueryResponseDto; + + @ApiProperty({ description: 'Inventory items valuation', type: [InventoryValuationItemDto] }) + data: InventoryValuationItemDto[]; + + @ApiProperty({ description: 'Report metadata', type: InventoryValuationMetaDto }) + meta: InventoryValuationMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as InventoryValuationTableCellDto, + FinancialTableRowDto as InventoryValuationTableRowDto, + FinancialTableColumnDto as InventoryValuationTableColumnDto, + FinancialTableDataDto as InventoryValuationTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class InventoryValuationTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: InventoryValuationQueryResponseDto }) + query: InventoryValuationQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: InventoryValuationMetaDto }) + meta: InventoryValuationMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheet.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheet.ts index 2fb7f2cf7..dbc11d7d1 100644 --- a/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheet.ts +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheet.ts @@ -12,6 +12,7 @@ import { InventoryCostLotTracker } from '@/modules/InventoryCost/models/Inventor import { FinancialSheet } from '../../common/FinancialSheet'; import { InventoryValuationSheetRepository } from './InventoryValuationSheetRepository'; import { allPassedConditionsPass } from '@/utils/all-conditions-passed'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export class InventoryValuationSheet extends FinancialSheet { readonly query: IInventoryValuationReportQuery; @@ -21,16 +22,20 @@ export class InventoryValuationSheet extends FinancialSheet { * Constructor method. * @param {IInventoryValuationReportQuery} query - Inventory valuation query. * @param {InventoryValuationSheetRepository} repository - Inventory valuation sheet repository. + * @param {IFinancialReportMeta} meta - Report meta. */ constructor( query: IInventoryValuationReportQuery, repository: InventoryValuationSheetRepository, + meta: IFinancialReportMeta, ) { super(); this.query = query; this.repository = repository; + this.baseCurrency = meta.baseCurrency; this.numberFormat = this.query.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } /** diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheetMeta.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheetMeta.ts index d383eaf09..b25b3671e 100644 --- a/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheetMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheetMeta.ts @@ -18,7 +18,7 @@ export class InventoryValuationMetaInjectable { query: IInventoryValuationReportQuery, ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedAsDate = moment(query.asDate).format('YYYY/MM/DD'); + const formattedAsDate = moment(query.asDate).format(commonMeta.dateFormat); const formattedDateRange = `As ${formattedAsDate}`; return { diff --git a/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheetService.ts b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheetService.ts index 560b77a8d..2a72767b4 100644 --- a/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheetService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/InventoryValuationSheet/InventoryValuationSheetService.ts @@ -34,16 +34,17 @@ export class InventoryValuationSheetService { this.inventoryValuationSheetRepository.setFilter(filter); await this.inventoryValuationSheetRepository.asyncInit(); + // Retrieves the inventorty valuation meta first to get date format. + const meta = await this.inventoryValuationMeta.meta(filter); + const inventoryValuationInstance = new InventoryValuationSheet( filter, this.inventoryValuationSheetRepository, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); // Retrieve the inventory valuation report data. const inventoryValuationData = inventoryValuationInstance.reportData(); - // Retrieves the inventorty valuation meta. - const meta = await this.inventoryValuationMeta.meta(filter); - // Triggers `onInventoryValuationViewed` event. await this.eventPublisher.emitAsync( events.reports.onInventoryValuationViewed, diff --git a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.controller.ts b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.controller.ts index a33184f88..1989b30da 100644 --- a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.controller.ts @@ -1,28 +1,50 @@ -import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; +import { Controller, Get, Headers, Query, Res, UseGuards } from '@nestjs/common'; import { Response } from 'express'; import { AcceptType } from '@/constants/accept-type'; import { JournalSheetApplication } from './JournalSheetApplication'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { JournalSheetQueryDto } from './JournalSheetQuery.dto'; import { JournalSheetResponseExample } from './JournalSheet.swagger'; +import { + JournalSheetResponseDto, + JournalSheetTableResponseDto, +} from './JournalSheetResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ReportsAction } from '../../types/Report.types'; @Controller('/reports/journal') @ApiTags('Reports') @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +@ApiExtraModels(JournalSheetResponseDto, JournalSheetTableResponseDto) export class JournalSheetController { constructor(private readonly journalSheetApp: JournalSheetApplication) {} @Get() + @RequirePermission(ReportsAction.READ_JOURNAL, AbilitySubject.Report) @ApiResponse({ status: 200, description: 'Journal report', - example: JournalSheetResponseExample, + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(JournalSheetResponseDto) }, + example: JournalSheetResponseExample, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(JournalSheetTableResponseDto) }, + }, + }, }) @ApiOperation({ summary: 'Journal report' }) @ApiProduces( diff --git a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.ts b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.ts index 3278acb37..39e0cbefe 100644 --- a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.ts +++ b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.ts @@ -11,6 +11,7 @@ import { FinancialSheet } from '../../common/FinancialSheet'; import { JournalSheetRepository } from './JournalSheetRepository'; import { ILedgerEntry } from '@/modules/Ledger/types/Ledger.types'; import { getTransactionTypeLabel } from '@/modules/BankingTransactions/utils'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export class JournalSheet extends FinancialSheet { readonly query: IJournalReportQuery; @@ -22,20 +23,24 @@ export class JournalSheet extends FinancialSheet { * @param {IJournalReportQuery} query - * @param {JournalSheetRepository} repository - * @param {I18nService} i18n - + * @param {IFinancialReportMeta} meta - */ constructor( query: IJournalReportQuery, repository: JournalSheetRepository, i18n: I18nService, + meta: IFinancialReportMeta, ) { super(); this.query = query; this.repository = repository; + this.baseCurrency = meta.baseCurrency; this.numberFormat = { ...this.numberFormat, ...this.query.numberFormat, }; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.i18n = i18n; } @@ -94,7 +99,7 @@ export class JournalSheet extends FinancialSheet { return { date: moment(groupEntry.date).toDate(), - dateFormatted: moment(groupEntry.date).format('YYYY MMM DD'), + dateFormatted: moment(groupEntry.date).format(this.dateFormat), transactionType: groupEntry.transactionType, referenceId: groupEntry.transactionId, diff --git a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.types.ts b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.types.ts index fb0b46d76..f9b44b530 100644 --- a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.types.ts +++ b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.types.ts @@ -8,6 +8,7 @@ export interface IJournalReportQuery { noCents: boolean; divideOn1000: boolean; }; + dateFormat?: string; transactionType: string; transactionId: string; diff --git a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetMeta.ts b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetMeta.ts index da6afbd81..acdaaff1b 100644 --- a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetMeta.ts @@ -17,8 +17,8 @@ export class JournalSheetMeta { ): Promise { const common = await this.financialSheetMeta.meta(); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDate = moment(query.toDate).format(common.dateFormat); + const formattedFromDate = moment(query.fromDate).format(common.dateFormat); const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`; return { diff --git a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetResponse.dto.ts new file mode 100644 index 000000000..af529a2c8 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetResponse.dto.ts @@ -0,0 +1,137 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class JournalEntryDto { + @ApiProperty({ description: 'Entry index', type: Number }) + index: number; + + @ApiPropertyOptional({ description: 'Entry note' }) + note: string | null; + + @ApiPropertyOptional({ description: 'Contact name' }) + contactName?: string; + + @ApiPropertyOptional({ description: 'Contact type' }) + contactType?: string; + + @ApiProperty({ description: 'Account name' }) + accountName: string; + + @ApiProperty({ description: 'Account code' }) + accountCode: string; + + @ApiPropertyOptional({ description: 'Transaction number' }) + transactionNumber: string | null; + + @ApiProperty({ description: 'Formatted credit' }) + formattedCredit: string; + + @ApiProperty({ description: 'Formatted debit' }) + formattedDebit: string; + + @ApiProperty({ description: 'Credit amount', type: Number }) + credit: number; + + @ApiProperty({ description: 'Debit amount', type: Number }) + debit: number; +} + +export class JournalTransactionDto { + @ApiProperty({ description: 'Transaction date' }) + date: string; + + @ApiProperty({ description: 'Formatted date' }) + dateFormatted: string; + + @ApiProperty({ description: 'Transaction type' }) + transactionType: string; + + @ApiProperty({ description: 'Reference ID', type: Number }) + referenceId: number; + + @ApiProperty({ description: 'Formatted reference type' }) + referenceTypeFormatted: string; + + @ApiProperty({ description: 'Journal entries', type: [JournalEntryDto] }) + entries: JournalEntryDto[]; + + @ApiProperty({ description: 'Total credit', type: Number }) + credit: number; + + @ApiProperty({ description: 'Total debit', type: Number }) + debit: number; + + @ApiProperty({ description: 'Formatted total credit' }) + formattedCredit: string; + + @ApiProperty({ description: 'Formatted total debit' }) + formattedDebit: string; +} + +export class JournalSheetMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class JournalSheetQueryResponseDto { + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiPropertyOptional({ description: 'From range' }) + fromRange: number | null; + + @ApiPropertyOptional({ description: 'To range' }) + toRange: number | null; + + @ApiProperty({ description: 'Account IDs to include', type: [Number] }) + accountsIds: number[]; + + @ApiProperty({ description: 'Number format settings', type: Object }) + numberFormat: { + noCents: boolean; + divideOn1000: boolean; + }; +} + +export class JournalSheetResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: JournalSheetQueryResponseDto }) + query: JournalSheetQueryResponseDto; + + @ApiProperty({ description: 'Journal transactions', type: [JournalTransactionDto] }) + data: JournalTransactionDto[]; + + @ApiProperty({ description: 'Report metadata', type: JournalSheetMetaDto }) + meta: JournalSheetMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as JournalSheetTableCellDto, + FinancialTableRowDto as JournalSheetTableRowDto, + FinancialTableColumnDto as JournalSheetTableColumnDto, + FinancialTableDataDto as JournalSheetTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class JournalSheetTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: JournalSheetQueryResponseDto }) + query: JournalSheetQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: JournalSheetMetaDto }) + meta: JournalSheetMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetService.ts b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetService.ts index 5e702a2cf..e60e850a0 100644 --- a/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetService.ts @@ -30,18 +30,19 @@ export class JournalSheetService { this.journalRepository.setFilter(query); await this.journalRepository.load(); + // Retrieve the journal sheet meta first to get the date format. + const meta = await this.journalSheetMeta.meta(filter); + // Journal report instance. const journalSheetInstance = new JournalSheet( filter, this.journalRepository, this.i18n, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); // Retrieve journal report columns. const journalSheetData = journalSheetInstance.reportData(); - // Retrieve the journal sheet meta. - const meta = await this.journalSheetMeta.meta(filter); - // Triggers `onJournalViewed` event. await this.eventPublisher.emitAsync(events.reports.onJournalViewed, { query, diff --git a/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheet.controller.ts b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheet.controller.ts index 38895cd96..c28bbe370 100644 --- a/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheet.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheet.controller.ts @@ -1,20 +1,33 @@ import { Response } from 'express'; -import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; +import { Controller, Get, Headers, Query, Res, UseGuards } from '@nestjs/common'; import { ProfitLossSheetApplication } from './ProfitLossSheetApplication'; import { AcceptType } from '@/constants/accept-type'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { ProfitLossSheetQueryDto } from './ProfitLossSheetQuery.dto'; import { ProfitLossSheetResponseExample } from './ProfitLossSheet.swagger'; +import { + ProfitLossSheetResponseDto, + ProfitLossSheetTableResponseDto, +} from './ProfitLossSheetResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ReportsAction } from '../../types/Report.types'; @Controller('/reports/profit-loss-sheet') @ApiTags('Reports') @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +@ApiExtraModels(ProfitLossSheetResponseDto, ProfitLossSheetTableResponseDto) export class ProfitLossSheetController { constructor( private readonly profitLossSheetApp: ProfitLossSheetApplication, @@ -27,10 +40,19 @@ export class ProfitLossSheetController { * @param {string} acceptHeader */ @Get('/') + @RequirePermission(ReportsAction.READ_PROFIT_LOSS, AbilitySubject.Report) @ApiResponse({ status: 200, description: 'Profit & loss statement', - example: ProfitLossSheetResponseExample, + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(ProfitLossSheetResponseDto) }, + example: ProfitLossSheetResponseExample, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(ProfitLossSheetTableResponseDto) }, + }, + }, }) @ApiOperation({ summary: 'Get profit/loss statement report' }) @ApiProduces( diff --git a/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheet.ts b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheet.ts index 628a25113..fe7e935fe 100644 --- a/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheet.ts +++ b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheet.ts @@ -28,6 +28,7 @@ import { FinancialSheetStructure } from '../../common/FinancialSheetStructure'; import { FinancialSheet } from '../../common/FinancialSheet'; import { Account } from '@/modules/Accounts/models/Account.model'; import { flatToNestedArray } from '@/utils/flat-to-nested-array'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export default class ProfitLossSheet extends R.pipe( ProfitLossSheetPreviousYear, @@ -71,20 +72,24 @@ export default class ProfitLossSheet extends R.pipe( /** * Constructor method. + * @param {ProfitLossSheetRepository} repository - * @param {IProfitLossSheetQuery} query - - * @param {IAccount[]} accounts - - * @param {IJournalPoster} transactionsJournal - + * @param {I18nService} i18n - + * @param {IFinancialReportMeta} meta - */ constructor( repository: ProfitLossSheetRepository, query: IProfitLossSheetQuery, i18n: I18nService, + meta: IFinancialReportMeta, ) { super(); this.query = new ProfitLossSheetQuery(query); this.repository = repository; + this.baseCurrency = meta.baseCurrency; this.numberFormat = this.query.query.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; this.i18n = i18n; } diff --git a/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetMeta.ts b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetMeta.ts index 8a37f21d8..15fb0b2f1 100644 --- a/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetMeta.ts @@ -19,8 +19,8 @@ export class ProfitLossSheetMeta { query: IProfitLossSheetQuery, ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDate = moment(query.toDate).format(commonMeta.dateFormat); + const formattedFromDate = moment(query.fromDate).format(commonMeta.dateFormat); const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`; const sheetName = 'Cashflow Statement'; diff --git a/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetResponse.dto.ts new file mode 100644 index 000000000..80053fb51 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetResponse.dto.ts @@ -0,0 +1,170 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportPercentageDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class ProfitLossSheetDataNodeDto { + @ApiProperty({ description: 'Node identifier (string for aggregates, number for accounts)' }) + id: string | number; + + @ApiProperty({ description: 'Account or category name' }) + name: string; + + @ApiProperty({ + description: 'Type of node', + enum: ['ACCOUNTS', 'ACCOUNT', 'EQUATION', 'TOTAL'], + }) + node_type: string; + + @ApiPropertyOptional({ description: 'Node type alias' }) + type?: string; + + @ApiProperty({ description: 'Total amount information', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Horizontal totals for date periods', type: [FinancialReportTotalDto] }) + horizontal_totals?: FinancialReportTotalDto[]; + + @ApiPropertyOptional({ description: 'Percentage of income', type: FinancialReportPercentageDto }) + percentage_income?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Percentage of expense', type: FinancialReportPercentageDto }) + percentage_expense?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Percentage of row', type: FinancialReportPercentageDto }) + percentage_row?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Percentage of column', type: FinancialReportPercentageDto }) + percentage_column?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Previous period total', type: FinancialReportTotalDto }) + previous_period?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Previous period change', type: FinancialReportTotalDto }) + previous_period_change?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Previous period percentage', type: FinancialReportPercentageDto }) + previous_period_percentage?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Previous year total', type: FinancialReportTotalDto }) + previous_year?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Previous year change', type: FinancialReportTotalDto }) + previous_year_change?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Previous year percentage', type: FinancialReportPercentageDto }) + previous_year_percentage?: FinancialReportPercentageDto; + + @ApiPropertyOptional({ description: 'Account code' }) + code?: string; + + @ApiPropertyOptional({ description: 'Display index', type: Number }) + index?: number; + + @ApiPropertyOptional({ description: 'Child nodes', type: () => [ProfitLossSheetDataNodeDto] }) + children?: ProfitLossSheetDataNodeDto[]; +} + +export class ProfitLossSheetMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formatted_from_date: string; + + @ApiProperty({ description: 'Formatted to date' }) + formatted_to_date: string; + + @ApiProperty({ description: 'Formatted date range' }) + formatted_date_range: string; +} + +export class ProfitLossSheetQueryResponseDto { + @ApiProperty({ description: 'Column display type', enum: ['total', 'date_periods'] }) + display_columns_type: string; + + @ApiProperty({ description: 'Column grouping', enum: ['day', 'month', 'year', 'quarter'] }) + display_columns_by: string; + + @ApiProperty({ description: 'Start date' }) + from_date: string; + + @ApiProperty({ description: 'End date' }) + to_date: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + number_format: NumberFormatQueryDto; + + @ApiProperty({ description: 'Exclude zero balance accounts' }) + none_zero: boolean; + + @ApiProperty({ description: 'Exclude accounts with no transactions' }) + none_transactions: boolean; + + @ApiProperty({ description: 'Accounting basis', enum: ['cash', 'accrual'] }) + basis: string; + + @ApiProperty({ description: 'Account IDs to include', type: [Number] }) + accounts_ids: number[]; + + @ApiProperty({ description: 'Show percentage of column' }) + percentage_column: boolean; + + @ApiProperty({ description: 'Show percentage of row' }) + percentage_row: boolean; + + @ApiProperty({ description: 'Show percentage of income' }) + percentage_income: boolean; + + @ApiProperty({ description: 'Show percentage of expense' }) + percentage_expense: boolean; + + @ApiProperty({ description: 'Include previous period' }) + previous_period: boolean; + + @ApiProperty({ description: 'Show previous period amount change' }) + previous_period_amount_change: boolean; + + @ApiProperty({ description: 'Show previous period percentage change' }) + previous_period_percentage_change: boolean; + + @ApiProperty({ description: 'Include previous year' }) + previous_year: boolean; + + @ApiProperty({ description: 'Show previous year amount change' }) + previous_year_amount_change: boolean; + + @ApiProperty({ description: 'Show previous year percentage change' }) + previous_year_percentage_change: boolean; +} + +export class ProfitLossSheetResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: ProfitLossSheetQueryResponseDto }) + query: ProfitLossSheetQueryResponseDto; + + @ApiProperty({ description: 'Hierarchical profit/loss data', type: [ProfitLossSheetDataNodeDto] }) + data: ProfitLossSheetDataNodeDto[]; + + @ApiProperty({ description: 'Report metadata', type: ProfitLossSheetMetaDto }) + meta: ProfitLossSheetMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as ProfitLossSheetTableCellDto, + FinancialTableRowDto as ProfitLossSheetTableRowDto, + FinancialTableColumnDto as ProfitLossSheetTableColumnDto, + FinancialTableDataDto as ProfitLossSheetTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class ProfitLossSheetTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: ProfitLossSheetQueryResponseDto }) + query: ProfitLossSheetQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: ProfitLossSheetMetaDto }) + meta: ProfitLossSheetMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetService.ts b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetService.ts index 8a819217a..6093d232f 100644 --- a/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/ProfitLossSheet/ProfitLossSheetService.ts @@ -40,18 +40,19 @@ export class ProfitLossSheetService { this.profitLossRepository.setFilter(filter); await this.profitLossRepository.asyncInitialize(); + // Retrieve the profit/loss sheet meta first to get date format. + const meta = await this.profitLossSheetMeta.meta(filter); + // Profit/Loss report instance. const profitLossInstance = new ProfitLossSheet( this.profitLossRepository, filter, this.i18nService, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); // Profit/loss report data and columns. const data = profitLossInstance.reportData(); - // Retrieve the profit/loss sheet meta. - const meta = await this.profitLossSheetMeta.meta(filter); - // Triggers `onProfitLossSheetViewed` event. await this.eventPublisher.emitAsync( events.reports.onProfitLossSheetViewed, diff --git a/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.controller.ts b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.controller.ts index ae78f3b89..66c223a1e 100644 --- a/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.controller.ts @@ -2,20 +2,36 @@ import { Response } from 'express'; import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; import { PurchasesByItemsApplication } from './PurchasesByItemsApplication'; import { AcceptType } from '@/constants/accept-type'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; import { PurchasesByItemsQueryDto } from './PurchasesByItemsQuery.dto'; +import { + PurchasesByItemsResponseDto, + PurchasesByItemsTableResponseDto, +} from './PurchasesByItemsResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('/reports/purchases-by-items') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(PurchasesByItemsResponseDto, PurchasesByItemsTableResponseDto) export class PurchasesByItemReportController { constructor( private readonly purchasesByItemsApp: PurchasesByItemsApplication, ) {} @Get() - @ApiResponse({ status: 200, description: 'Purchases by items report' }) + @ApiResponse({ + status: 200, + description: 'Purchases by items report', + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(PurchasesByItemsResponseDto) }, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(PurchasesByItemsTableResponseDto) }, + }, + }, + }) @ApiOperation({ summary: 'Get purchases by items report' }) async purchasesByItems( @Query() filter: PurchasesByItemsQueryDto, diff --git a/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.service.ts b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.service.ts index 41f5aacec..2acc845bd 100644 --- a/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.service.ts +++ b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.service.ts @@ -73,17 +73,17 @@ export class PurchasesByItemsService { // Filter the date range of the sheet. builder.modify('filterDateRange', filter.fromDate, filter.toDate); }); + // Retrieve the purchases by items meta first to get date format. + const meta = await this.purchasesByItemsMeta.meta(query); + const purchasesByItemsInstance = new PurchasesByItems( filter, inventoryItems, inventoryTransactions, - tenantMetadata.baseCurrency, + { baseCurrency: tenantMetadata.baseCurrency, dateFormat: meta.dateFormat }, ); const purchasesByItemsData = purchasesByItemsInstance.reportData(); - // Retrieve the purchases by items meta. - const meta = await this.purchasesByItemsMeta.meta(query); - // Triggers `onPurchasesByItemViewed` event. await this.eventPublisher.emitAsync( events.reports.onPurchasesByItemViewed, diff --git a/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.ts b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.ts index 91f4b1544..f8f50f1e0 100644 --- a/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.ts +++ b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItems.ts @@ -11,6 +11,7 @@ import { FinancialSheet } from '../../common/FinancialSheet'; import { transformToMapBy } from '@/utils/transform-to-map-by'; import { Item } from '@/modules/Items/models/Item'; import { InventoryTransaction } from '@/modules/InventoryCost/models/InventoryTransaction'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export class PurchasesByItems extends FinancialSheet{ readonly baseCurrency: string; @@ -29,14 +30,15 @@ export class PurchasesByItems extends FinancialSheet{ query: IPurchasesByItemsReportQuery, items: Item[], itemsTransactions: InventoryTransaction[], - baseCurrency: string + meta: IFinancialReportMeta, ) { super(); - this.baseCurrency = baseCurrency; + this.baseCurrency = meta.baseCurrency; this.items = items; this.itemsTransactions = transformToMapBy(itemsTransactions, 'itemId'); this.query = query; this.numberFormat = this.query.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } /** diff --git a/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItemsMeta.ts b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItemsMeta.ts index 11ee37480..80879ceb3 100644 --- a/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItemsMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItemsMeta.ts @@ -21,8 +21,8 @@ export class PurchasesByItemsMeta { query: IPurchasesByItemsReportQuery ): Promise { const commonMeta = await this.financialSheetMetaModel.meta(); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDate = moment(query.toDate).format(commonMeta.dateFormat); + const formattedFromDate = moment(query.fromDate).format(commonMeta.dateFormat); const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`; return { diff --git a/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItemsResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItemsResponse.dto.ts new file mode 100644 index 000000000..7bc34b93a --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/PurchasesByItems/PurchasesByItemsResponse.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class PurchasesByItemDto { + @ApiProperty({ description: 'Item ID', type: Number }) + id: number; + + @ApiProperty({ description: 'Item name' }) + name: string; + + @ApiProperty({ description: 'Item code' }) + code: string; + + @ApiProperty({ description: 'Item type' }) + type: string; + + @ApiProperty({ description: 'Quantity purchased', type: Number }) + quantity: number; + + @ApiProperty({ description: 'Total purchases amount', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Average cost', type: Number }) + averageCost?: number; +} + +export class PurchasesByItemsMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class PurchasesByItemsQueryResponseDto { + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Item IDs to include', type: [Number] }) + itemsIds: number[]; + + @ApiProperty({ description: 'Vendor IDs to include', type: [Number] }) + vendorsIds: number[]; +} + +export class PurchasesByItemsResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: PurchasesByItemsQueryResponseDto }) + query: PurchasesByItemsQueryResponseDto; + + @ApiProperty({ description: 'Purchases by items', type: [PurchasesByItemDto] }) + data: PurchasesByItemDto[]; + + @ApiProperty({ description: 'Report metadata', type: PurchasesByItemsMetaDto }) + meta: PurchasesByItemsMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as PurchasesByItemsTableCellDto, + FinancialTableRowDto as PurchasesByItemsTableRowDto, + FinancialTableColumnDto as PurchasesByItemsTableColumnDto, + FinancialTableDataDto as PurchasesByItemsTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class PurchasesByItemsTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: PurchasesByItemsQueryResponseDto }) + query: PurchasesByItemsQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: PurchasesByItemsMetaDto }) + meta: PurchasesByItemsMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItems.controller.ts b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItems.controller.ts index 5361beca6..16904db27 100644 --- a/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItems.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItems.controller.ts @@ -10,18 +10,34 @@ import { import { AcceptType } from '@/constants/accept-type'; import { SalesByItemsApplication } from './SalesByItemsApplication'; import { Response } from 'express'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; import { SalesByItemsQueryDto } from './SalesByItemsQuery.dto'; +import { + SalesByItemsResponseDto, + SalesByItemsTableResponseDto, +} from './SalesByItemsResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('/reports/sales-by-items') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(SalesByItemsResponseDto, SalesByItemsTableResponseDto) export class SalesByItemsController { constructor(private readonly salesByItemsApp: SalesByItemsApplication) {} @Get() - @ApiResponse({ status: 200, description: 'Sales by items report' }) + @ApiResponse({ + status: 200, + description: 'Sales by items report', + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(SalesByItemsResponseDto) }, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(SalesByItemsTableResponseDto) }, + }, + }, + }) @ApiOperation({ summary: 'Sales by items report', description: 'Retrieves the sales by items report.', diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItems.ts b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItems.ts index 8022efd34..7328994e1 100644 --- a/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItems.ts +++ b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItems.ts @@ -12,6 +12,7 @@ import { Item } from '@/modules/Items/models/Item'; import { transformToMap } from '@/utils/transform-to-key'; import { allPassedConditionsPass } from '@/utils/all-conditions-passed'; import { InventoryTransaction } from '@/modules/InventoryCost/models/InventoryTransaction'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export class SalesByItemsReport extends FinancialSheet { readonly baseCurrency: string; @@ -24,21 +25,22 @@ export class SalesByItemsReport extends FinancialSheet { * @param {ISalesByItemsReportQuery} query * @param {IItem[]} items * @param {IAccountTransaction[]} itemsTransactions - * @param {string} baseCurrency + * @param {IFinancialReportMeta} meta */ constructor( query: ISalesByItemsReportQuery, items: Item[], itemsTransactions: ModelObject[], - baseCurrency: string, + meta: IFinancialReportMeta, ) { super(); - this.baseCurrency = baseCurrency; + this.baseCurrency = meta.baseCurrency; this.items = items; this.itemsTransactions = transformToMap(itemsTransactions, 'itemId'); this.query = query; this.numberFormat = this.query.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } /** diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsMeta.ts b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsMeta.ts index a34f53fbe..4e918df3a 100644 --- a/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsMeta.ts @@ -18,8 +18,8 @@ export class SalesByItemsMeta { query: ISalesByItemsReportQuery, ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDate = moment(query.toDate).format(commonMeta.dateFormat); + const formattedFromDate = moment(query.fromDate).format(commonMeta.dateFormat); const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`; const sheetName = 'Sales By Items'; diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsResponse.dto.ts new file mode 100644 index 000000000..2634542de --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsResponse.dto.ts @@ -0,0 +1,97 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class SalesByItemDto { + @ApiProperty({ description: 'Item ID', type: Number }) + id: number; + + @ApiProperty({ description: 'Item name' }) + name: string; + + @ApiProperty({ description: 'Item code' }) + code: string; + + @ApiProperty({ description: 'Item type' }) + type: string; + + @ApiProperty({ description: 'Quantity sold', type: Number }) + quantity: number; + + @ApiProperty({ description: 'Total sales amount', type: FinancialReportTotalDto }) + total: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Average price', type: Number }) + averagePrice?: number; + + @ApiPropertyOptional({ description: 'COGS', type: FinancialReportTotalDto }) + cogs?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Profit', type: FinancialReportTotalDto }) + profit?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Profit percentage', type: Number }) + profitPercentage?: number; +} + +export class SalesByItemsMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class SalesByItemsQueryResponseDto { + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Item IDs to include', type: [Number] }) + itemsIds: number[]; + + @ApiProperty({ description: 'Customer IDs to include', type: [Number] }) + customersIds: number[]; +} + +export class SalesByItemsResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: SalesByItemsQueryResponseDto }) + query: SalesByItemsQueryResponseDto; + + @ApiProperty({ description: 'Sales by items', type: [SalesByItemDto] }) + data: SalesByItemDto[]; + + @ApiProperty({ description: 'Report metadata', type: SalesByItemsMetaDto }) + meta: SalesByItemsMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as SalesByItemsTableCellDto, + FinancialTableRowDto as SalesByItemsTableRowDto, + FinancialTableColumnDto as SalesByItemsTableColumnDto, + FinancialTableDataDto as SalesByItemsTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class SalesByItemsTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: SalesByItemsQueryResponseDto }) + query: SalesByItemsQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: SalesByItemsMetaDto }) + meta: SalesByItemsMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsService.ts b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsService.ts index 12cafc900..1844312f0 100644 --- a/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/SalesByItems/SalesByItemsService.ts @@ -68,17 +68,17 @@ export class SalesByItemsReportService { // Filter the date range of the sheet. builder.modify('filterDateRange', filter.fromDate, filter.toDate); }); + // Retrieve the sales by items meta first to get date format. + const meta = await this.salesByItemsMeta.meta(query); + const sheet = new SalesByItemsReport( filter, inventoryItems, inventoryTransactions, - tenantMetadata.baseCurrency, + { baseCurrency: tenantMetadata.baseCurrency, dateFormat: meta.dateFormat }, ); const salesByItemsData = sheet.reportData(); - // Retrieve the sales by items meta. - const meta = await this.salesByItemsMeta.meta(query); - // Triggers `onSalesByItemViewed` event. await this.eventPublisher.emitAsync(events.reports.onSalesByItemViewed, { query, diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.module.ts b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.module.ts index cdbc35df8..0f2c9c5d1 100644 --- a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.module.ts +++ b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.module.ts @@ -8,9 +8,10 @@ import { SalesTaxLiabilitySummaryController } from './SalesTaxLiabilitySummary.c import { FinancialSheetCommonModule } from '../../common/FinancialSheetCommon.module'; import { SalesTaxLiabilitySummaryRepository } from './SalesTaxLiabilitySummaryRepository'; import { SalesTaxLiabilitySummaryMeta } from './SalesTaxLiabilitySummaryMeta'; +import { TenancyModule } from '@/modules/Tenancy/Tenancy.module'; @Module({ - imports: [FinancialSheetCommonModule], + imports: [FinancialSheetCommonModule, TenancyModule], providers: [ SalesTaxLiabiltiySummaryPdf, SalesTaxLiabilitySummaryTableInjectable, diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.controller.ts b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.controller.ts index c6ea1b7ff..51c9ec2ea 100644 --- a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.controller.ts @@ -1,19 +1,26 @@ import { Response } from 'express'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; import { AcceptType } from '@/constants/accept-type'; import { SalesTaxLiabilitySummaryApplication } from './SalesTaxLiabilitySummaryApplication'; import { SalesTaxLiabilitySummaryQueryDto } from './dtos/SalesTaxLiabilityQuery.dto'; +import { + SalesTaxLiabilitySummaryResponseDto, + SalesTaxLiabilitySummaryTableResponseDto, +} from './SalesTaxLiabilitySummaryResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('/reports/sales-tax-liability-summary') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(SalesTaxLiabilitySummaryResponseDto, SalesTaxLiabilitySummaryTableResponseDto) export class SalesTaxLiabilitySummaryController { constructor( private readonly salesTaxLiabilitySummaryApp: SalesTaxLiabilitySummaryApplication, @@ -23,6 +30,14 @@ export class SalesTaxLiabilitySummaryController { @ApiResponse({ status: 200, description: 'Sales tax liability summary report', + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(SalesTaxLiabilitySummaryResponseDto) }, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(SalesTaxLiabilitySummaryTableResponseDto) }, + }, + }, }) @ApiOperation({ summary: 'Get sales tax liability summary report' }) @ApiProduces( diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts index 666c34956..24dbaae48 100644 --- a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts +++ b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts @@ -10,6 +10,7 @@ import { FinancialSheet } from '../../common/FinancialSheet'; import { ModelObject } from 'objection'; import { TaxRateModel } from '@/modules/TaxRates/models/TaxRate.model'; import { SalesTaxLiabilitySummaryRepository } from './SalesTaxLiabilitySummaryRepository'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export class SalesTaxLiabilitySummary extends FinancialSheet { private query: SalesTaxLiabilitySummaryQuery; @@ -18,18 +19,20 @@ export class SalesTaxLiabilitySummary extends FinancialSheet { /** * Sales tax liability summary constructor. * @param {SalesTaxLiabilitySummaryQuery} query - * @param {ITaxRate[]} taxRates - * @param {SalesTaxLiabilitySummaryPayableById} payableTaxesById - * @param {SalesTaxLiabilitySummarySalesById} salesTaxesById + * @param {SalesTaxLiabilitySummaryRepository} repository + * @param {IFinancialReportMeta} meta */ constructor( query: SalesTaxLiabilitySummaryQuery, repository: SalesTaxLiabilitySummaryRepository, + meta: IFinancialReportMeta, ) { super(); this.query = query; this.repository = repository; + this.baseCurrency = meta.baseCurrency; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } /** diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts index 258a11ff9..51b6168a2 100644 --- a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts @@ -14,8 +14,8 @@ export class SalesTaxLiabilitySummaryMeta { */ public async meta(query: SalesTaxLiabilitySummaryQuery) { const commonMeta = await this.financialSheetMeta.meta(); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDate = moment(query.toDate).format(commonMeta.dateFormat); + const formattedFromDate = moment(query.fromDate).format(commonMeta.dateFormat); const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`; const sheetName = 'Sales Tax Liability Summary'; diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryResponse.dto.ts new file mode 100644 index 000000000..5de510434 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryResponse.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class TaxRateSummaryDto { + @ApiProperty({ description: 'Tax rate ID', type: Number }) + id: number; + + @ApiProperty({ description: 'Tax rate name' }) + name: string; + + @ApiProperty({ description: 'Tax rate percentage', type: Number }) + rate: number; + + @ApiProperty({ description: 'Taxable amount', type: FinancialReportTotalDto }) + taxableAmount: FinancialReportTotalDto; + + @ApiProperty({ description: 'Tax amount collected', type: FinancialReportTotalDto }) + taxAmount: FinancialReportTotalDto; + + @ApiProperty({ description: 'Total sales (including tax)', type: FinancialReportTotalDto }) + totalSales: FinancialReportTotalDto; +} + +export class SalesTaxLiabilitySummaryMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class SalesTaxLiabilitySummaryQueryResponseDto { + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; +} + +export class SalesTaxLiabilitySummaryResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: SalesTaxLiabilitySummaryQueryResponseDto }) + query: SalesTaxLiabilitySummaryQueryResponseDto; + + @ApiProperty({ description: 'Tax rate summaries', type: [TaxRateSummaryDto] }) + data: TaxRateSummaryDto[]; + + @ApiProperty({ description: 'Report metadata', type: SalesTaxLiabilitySummaryMetaDto }) + meta: SalesTaxLiabilitySummaryMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as SalesTaxLiabilitySummaryTableCellDto, + FinancialTableRowDto as SalesTaxLiabilitySummaryTableRowDto, + FinancialTableColumnDto as SalesTaxLiabilitySummaryTableColumnDto, + FinancialTableDataDto as SalesTaxLiabilitySummaryTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class SalesTaxLiabilitySummaryTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: SalesTaxLiabilitySummaryQueryResponseDto }) + query: SalesTaxLiabilitySummaryQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: SalesTaxLiabilitySummaryMetaDto }) + meta: SalesTaxLiabilitySummaryMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts index 92cd9233e..9cf555a88 100644 --- a/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts @@ -3,12 +3,14 @@ import { SalesTaxLiabilitySummary } from './SalesTaxLiabilitySummary'; import { SalesTaxLiabilitySummaryMeta } from './SalesTaxLiabilitySummaryMeta'; import { Injectable } from '@nestjs/common'; import { SalesTaxLiabilitySummaryQuery } from './SalesTaxLiability.types'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; @Injectable() export class SalesTaxLiabilitySummaryService { constructor( private readonly repository: SalesTaxLiabilitySummaryRepository, private readonly salesTaxLiabilityMeta: SalesTaxLiabilitySummaryMeta, + private readonly tenancyContext: TenancyContext, ) {} /** @@ -19,11 +21,17 @@ export class SalesTaxLiabilitySummaryService { public async salesTaxLiability(query: SalesTaxLiabilitySummaryQuery) { await this.repository.load(); + // Retrieve the meta first to get date format. + const meta = await this.salesTaxLiabilityMeta.meta(query); + + // Get tenant metadata for baseCurrency + const tenantMetadata = await this.tenancyContext.getTenantMetadata(); + const taxLiabilitySummary = new SalesTaxLiabilitySummary( query, this.repository, + { baseCurrency: tenantMetadata.baseCurrency, dateFormat: meta.dateFormat }, ); - const meta = await this.salesTaxLiabilityMeta.meta(query); return { data: taxLiabilitySummary.reportData(), diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByContact/TransactionsByContactTableRows.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByContact/TransactionsByContactTableRows.ts index e131f6678..601da9737 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByContact/TransactionsByContactTableRows.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByContact/TransactionsByContactTableRows.ts @@ -14,9 +14,10 @@ enum ROW_TYPE { export class TransactionsByContactsTableRows { public i18n: I18nService; + public dateFormat: string; public dateAccessor = (value): string => { - return moment(value.date).format('YYYY MMM DD'); + return moment(value.date).format(this.dateFormat); }; /** @@ -52,7 +53,7 @@ export class TransactionsByContactsTableRows { const columns = [ { key: 'openingBalanceLabel', - value: this.i18n.t('Opening balance') as string, + value: this.i18n.t('transactions_by_contact.opening_balance') as string, }, ...R.repeat({ key: 'empty', value: '' }, 5), { @@ -76,7 +77,7 @@ export class TransactionsByContactsTableRows { const columns = [ { key: 'closingBalanceLabel', - value: this.i18n.t('Closing balance') as string, + value: this.i18n.t('transactions_by_contact.closing_balance') as string, }, ...R.repeat({ key: 'empty', value: '' }, 5), { diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomer.controller.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomer.controller.ts index 34fbef486..4ddf615ed 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomer.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomer.controller.ts @@ -1,5 +1,9 @@ import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; +import { + TransactionsByCustomerResponseDto, + TransactionsByCustomerTableResponseDto, +} from './TransactionsByCustomerResponse.dto'; import { ITransactionsByCustomersFilter } from './TransactionsByCustomer.types'; import { TransactionsByCustomerApplication } from './TransactionsByCustomersApplication'; import { AcceptType } from '@/constants/accept-type'; @@ -10,6 +14,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('/reports/transactions-by-customers') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(TransactionsByCustomerResponseDto, TransactionsByCustomerTableResponseDto) export class TransactionsByCustomerController { constructor( private readonly transactionsByCustomersApp: TransactionsByCustomerApplication, @@ -17,7 +22,18 @@ export class TransactionsByCustomerController { @Get() @ApiOperation({ summary: 'Get transactions by customer' }) - @ApiResponse({ status: 200, description: 'Transactions by customer' }) + @ApiResponse({ + status: 200, + description: 'Transactions by customer', + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(TransactionsByCustomerResponseDto) }, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(TransactionsByCustomerTableResponseDto) }, + }, + }, + }) async transactionsByCustomer( @Query() filter: TransactionsByCustomerQueryDto, @Res({ passthrough: true }) res: Response, diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomerResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomerResponse.dto.ts new file mode 100644 index 000000000..c3afbf30a --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomerResponse.dto.ts @@ -0,0 +1,117 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class CustomerTransactionDto { + @ApiProperty({ description: 'Transaction date' }) + date: string; + + @ApiProperty({ description: 'Formatted date' }) + dateFormatted: string; + + @ApiProperty({ description: 'Transaction type' }) + transactionType: string; + + @ApiProperty({ description: 'Transaction number' }) + transactionNumber: string; + + @ApiPropertyOptional({ description: 'Reference type' }) + referenceType?: string; + + @ApiPropertyOptional({ description: 'Reference ID', type: Number }) + referenceId?: number; + + @ApiPropertyOptional({ description: 'Transaction description' }) + description?: string; + + @ApiProperty({ description: 'Transaction amount', type: FinancialReportTotalDto }) + amount: FinancialReportTotalDto; + + @ApiProperty({ description: 'Running balance', type: FinancialReportTotalDto }) + runningBalance: FinancialReportTotalDto; +} + +export class CustomerWithTransactionsDto { + @ApiProperty({ description: 'Customer ID', type: Number }) + customerId: number; + + @ApiProperty({ description: 'Customer name' }) + customerName: string; + + @ApiPropertyOptional({ description: 'Opening balance', type: FinancialReportTotalDto }) + openingBalance?: FinancialReportTotalDto; + + @ApiProperty({ description: 'Customer transactions', type: [CustomerTransactionDto] }) + transactions: CustomerTransactionDto[]; + + @ApiProperty({ description: 'Closing balance', type: FinancialReportTotalDto }) + closingBalance: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Total debit', type: FinancialReportTotalDto }) + totalDebit?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Total credit', type: FinancialReportTotalDto }) + totalCredit?: FinancialReportTotalDto; +} + +export class TransactionsByCustomerMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class TransactionsByCustomerQueryResponseDto { + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Customer IDs to include', type: [Number] }) + customersIds: number[]; + + @ApiProperty({ description: 'Exclude zero balance customers' }) + noneZero: boolean; +} + +export class TransactionsByCustomerResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: TransactionsByCustomerQueryResponseDto }) + query: TransactionsByCustomerQueryResponseDto; + + @ApiProperty({ description: 'Customers with transactions', type: [CustomerWithTransactionsDto] }) + data: CustomerWithTransactionsDto[]; + + @ApiProperty({ description: 'Report metadata', type: TransactionsByCustomerMetaDto }) + meta: TransactionsByCustomerMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as TransactionsByCustomerTableCellDto, + FinancialTableRowDto as TransactionsByCustomerTableRowDto, + FinancialTableColumnDto as TransactionsByCustomerTableColumnDto, + FinancialTableDataDto as TransactionsByCustomerTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class TransactionsByCustomerTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: TransactionsByCustomerQueryResponseDto }) + query: TransactionsByCustomerQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: TransactionsByCustomerMetaDto }) + meta: TransactionsByCustomerMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomers.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomers.ts index f85992b7f..cdbea1cce 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomers.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomers.ts @@ -12,6 +12,7 @@ import { TransactionsByContact } from '../TransactionsByContact/TransactionsByCo import { Customer } from '@/modules/Customers/models/Customer'; import { INumberFormatQuery } from '../../types/Report.types'; import { TransactionsByCustomersRepository } from './TransactionsByCustomersRepository'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; const CUSTOMER_NORMAL = 'debit'; @@ -25,12 +26,14 @@ export class TransactionsByCustomers extends TransactionsByContact { * Constructor method. * @param {ICustomer} customers * @param {Map} transactionsLedger - * @param {string} baseCurrency + * @param {I18nService} i18n + * @param {IFinancialReportMeta} meta */ constructor( filter: ITransactionsByCustomersFilter, transactionsByCustomersRepository: TransactionsByCustomersRepository, - i18n: I18nService + i18n: I18nService, + meta: IFinancialReportMeta, ) { super(); @@ -38,6 +41,8 @@ export class TransactionsByCustomers extends TransactionsByContact { this.repository = transactionsByCustomersRepository; this.numberFormat = this.filter.numberFormat; this.i18n = i18n; + this.baseCurrency = meta.baseCurrency; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } /** diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersMeta.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersMeta.ts index 8f003cd09..41f1b131e 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersMeta.ts @@ -20,8 +20,8 @@ export class TransactionsByCustomersMeta { ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDate = moment(query.toDate).format(commonMeta.dateFormat); + const formattedFromDate = moment(query.fromDate).format(commonMeta.dateFormat); const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`; return { diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersService.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersService.ts index 132a5937e..a68ebb17f 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersService.ts @@ -36,13 +36,16 @@ export class TransactionsByCustomersSheet { this.transactionsByCustomersRepository.setFilter(filter); await this.transactionsByCustomersRepository.asyncInit(); + // Retrieve the meta first to get date format. + const meta = await this.transactionsByCustomersMeta.meta(filter); + // Transactions by customers data mapper. const reportInstance = new TransactionsByCustomers( filter, this.transactionsByCustomersRepository, this.i18n, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); - const meta = await this.transactionsByCustomersMeta.meta(filter); // Triggers `onCustomerTransactionsViewed` event. await this.eventPublisher.emitAsync( diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersTable.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersTable.ts index 1ba956ac6..50aaa4eaa 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersTable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersTable.ts @@ -22,10 +22,12 @@ export class TransactionsByCustomersTable extends TransactionsByContactsTableRow constructor( customersTransactions: ITransactionsByCustomersCustomer[], i18n: I18nService, + dateFormat: string, ) { super(); this.customersTransactions = customersTransactions; this.i18n = i18n; + this.dateFormat = dateFormat; } /** diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersTableInjectable.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersTableInjectable.ts index 560f44a71..bb5242f50 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersTableInjectable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByCustomer/TransactionsByCustomersTableInjectable.ts @@ -28,6 +28,7 @@ export class TransactionsByCustomersTableInjectable { const table = new TransactionsByCustomersTable( customersTransactions.data, this.i18n, + customersTransactions.meta.dateFormat, ); return { table: { diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByReference/TransactionsByReference.service.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByReference/TransactionsByReference.service.ts index c5d48bd5b..3136a61c4 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByReference/TransactionsByReference.service.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByReference/TransactionsByReference.service.ts @@ -38,7 +38,7 @@ export class TransactionsByReferenceService { const report = new TransactionsByReference( transactions, filter, - tenantMetadata.baseCurrency + { baseCurrency: tenantMetadata.baseCurrency, dateFormat: tenantMetadata.dateFormat } ); return { diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByReference/TransactionsByReferenceReport.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByReference/TransactionsByReferenceReport.ts index d46431d23..56ed35cbd 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByReference/TransactionsByReferenceReport.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByReference/TransactionsByReferenceReport.ts @@ -4,7 +4,7 @@ import { import { FinancialSheet } from '../../common/FinancialSheet'; import { ModelObject } from 'objection'; import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model'; -import { INumberFormatQuery } from '../../types/Report.types'; +import { INumberFormatQuery, IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; import { TransactionsByReferenceQueryDto } from './TransactionsByReferenceQuery.dto'; export class TransactionsByReference extends FinancialSheet { @@ -17,18 +17,19 @@ export class TransactionsByReference extends FinancialSheet { * Constructor method. * @param {ModelObject[]} transactions * @param {TransactionsByVendorQueryDto} query - * @param {string} baseCurrency + * @param {IFinancialReportMeta} meta */ constructor( transactions: ModelObject[], query: TransactionsByReferenceQueryDto, - baseCurrency: string + meta: IFinancialReportMeta, ) { super(); this.transactions = transactions; this.query = query; - this.baseCurrency = baseCurrency; + this.baseCurrency = meta.baseCurrency; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; // this.numberFormat = this.query.numberFormat; } diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendor.controller.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendor.controller.ts index 291855844..7705d82cd 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendor.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendor.controller.ts @@ -3,13 +3,18 @@ import { ITransactionsByVendorsFilter } from './TransactionsByVendor.types'; import { AcceptType } from '@/constants/accept-type'; import { Response } from 'express'; import { TransactionsByVendorApplication } from './TransactionsByVendorApplication'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; +import { + TransactionsByVendorResponseDto, + TransactionsByVendorTableResponseDto, +} from './TransactionsByVendorResponse.dto'; import { TransactionsByVendorQueryDto } from './TransactionsByVendorQuery.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('/reports/transactions-by-vendors') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(TransactionsByVendorResponseDto, TransactionsByVendorTableResponseDto) export class TransactionsByVendorController { constructor( private readonly transactionsByVendorsApp: TransactionsByVendorApplication, @@ -17,7 +22,18 @@ export class TransactionsByVendorController { @Get() @ApiOperation({ summary: 'Get transactions by vendor' }) - @ApiResponse({ status: 200, description: 'Transactions by vendor' }) + @ApiResponse({ + status: 200, + description: 'Transactions by vendor', + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(TransactionsByVendorResponseDto) }, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(TransactionsByVendorTableResponseDto) }, + }, + }, + }) async transactionsByVendor( @Query() filter: TransactionsByVendorQueryDto, @Res({ passthrough: true }) res: Response, diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendor.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendor.ts index b7a18787e..5f859fe5e 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendor.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendor.ts @@ -12,6 +12,7 @@ import { TransactionsByContact } from '../TransactionsByContact/TransactionsByCo import { Vendor } from '@/modules/Vendors/models/Vendor'; import { INumberFormatQuery } from '../../types/Report.types'; import { TransactionsByVendorRepository } from './TransactionsByVendorRepository'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; const VENDOR_NORMAL = 'credit'; @@ -26,11 +27,13 @@ export class TransactionsByVendor extends TransactionsByContact { * @param {TransactionsByVendorRepository} transactionsByVendorRepository - Transactions by vendor repository. * @param {ITransactionsByVendorsFilter} filter - Transactions by vendors filter. * @param {I18nService} i18n - Internationalization service. + * @param {IFinancialReportMeta} meta - Report meta. */ constructor( transactionsByVendorRepository: TransactionsByVendorRepository, filter: ITransactionsByVendorsFilter, i18n: I18nService, + meta: IFinancialReportMeta, ) { super(); @@ -38,6 +41,8 @@ export class TransactionsByVendor extends TransactionsByContact { this.filter = filter; this.numberFormat = this.filter.numberFormat; this.i18n = i18n; + this.baseCurrency = meta.baseCurrency; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } /** diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorInjectable.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorInjectable.ts index 5359ba5a8..30a191fa1 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorInjectable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorInjectable.ts @@ -36,13 +36,16 @@ export class TransactionsByVendorsInjectable { // Initialize the repository. await this.transactionsByVendorRepository.asyncInit(); - // Transactions by customers data mapper. + // Retrieve the meta first to get date format. + const meta = await this.transactionsByVendorMeta.meta(filter); + + // Transactions by vendors data mapper. const reportInstance = new TransactionsByVendor( this.transactionsByVendorRepository, filter, this.i18n, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); - const meta = await this.transactionsByVendorMeta.meta(filter); // Triggers `onVendorTransactionsViewed` event. await this.eventPublisher.emitAsync( diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorMeta.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorMeta.ts index 654d972ac..4af3dcd65 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorMeta.ts @@ -21,8 +21,8 @@ export class TransactionsByVendorMeta { ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDate = moment(query.toDate).format(commonMeta.dateFormat); + const formattedFromDate = moment(query.fromDate).format(commonMeta.dateFormat); const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`; const sheetName = 'Transactions By Vendor'; diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorResponse.dto.ts new file mode 100644 index 000000000..a3b2502b1 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorResponse.dto.ts @@ -0,0 +1,117 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class VendorTransactionDto { + @ApiProperty({ description: 'Transaction date' }) + date: string; + + @ApiProperty({ description: 'Formatted date' }) + dateFormatted: string; + + @ApiProperty({ description: 'Transaction type' }) + transactionType: string; + + @ApiProperty({ description: 'Transaction number' }) + transactionNumber: string; + + @ApiPropertyOptional({ description: 'Reference type' }) + referenceType?: string; + + @ApiPropertyOptional({ description: 'Reference ID', type: Number }) + referenceId?: number; + + @ApiPropertyOptional({ description: 'Transaction description' }) + description?: string; + + @ApiProperty({ description: 'Transaction amount', type: FinancialReportTotalDto }) + amount: FinancialReportTotalDto; + + @ApiProperty({ description: 'Running balance', type: FinancialReportTotalDto }) + runningBalance: FinancialReportTotalDto; +} + +export class VendorWithTransactionsDto { + @ApiProperty({ description: 'Vendor ID', type: Number }) + vendorId: number; + + @ApiProperty({ description: 'Vendor name' }) + vendorName: string; + + @ApiPropertyOptional({ description: 'Opening balance', type: FinancialReportTotalDto }) + openingBalance?: FinancialReportTotalDto; + + @ApiProperty({ description: 'Vendor transactions', type: [VendorTransactionDto] }) + transactions: VendorTransactionDto[]; + + @ApiProperty({ description: 'Closing balance', type: FinancialReportTotalDto }) + closingBalance: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Total debit', type: FinancialReportTotalDto }) + totalDebit?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Total credit', type: FinancialReportTotalDto }) + totalCredit?: FinancialReportTotalDto; +} + +export class TransactionsByVendorMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class TransactionsByVendorQueryResponseDto { + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Vendor IDs to include', type: [Number] }) + vendorsIds: number[]; + + @ApiProperty({ description: 'Exclude zero balance vendors' }) + noneZero: boolean; +} + +export class TransactionsByVendorResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: TransactionsByVendorQueryResponseDto }) + query: TransactionsByVendorQueryResponseDto; + + @ApiProperty({ description: 'Vendors with transactions', type: [VendorWithTransactionsDto] }) + data: VendorWithTransactionsDto[]; + + @ApiProperty({ description: 'Report metadata', type: TransactionsByVendorMetaDto }) + meta: TransactionsByVendorMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as TransactionsByVendorTableCellDto, + FinancialTableRowDto as TransactionsByVendorTableRowDto, + FinancialTableColumnDto as TransactionsByVendorTableColumnDto, + FinancialTableDataDto as TransactionsByVendorTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class TransactionsByVendorTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: TransactionsByVendorQueryResponseDto }) + query: TransactionsByVendorQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: TransactionsByVendorMetaDto }) + meta: TransactionsByVendorMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorTable.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorTable.ts index 93088f49f..5ececc29b 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorTable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorTable.ts @@ -1,4 +1,5 @@ import * as R from 'ramda'; +import { I18nService } from 'nestjs-i18n'; import { ITransactionsByVendorsVendor } from './TransactionsByVendor.types'; import { TransactionsByContactsTableRows } from '../TransactionsByContact/TransactionsByContactTableRows'; import { tableRowMapper } from '../../utils/Table.utils'; @@ -19,11 +20,16 @@ export class TransactionsByVendorsTable extends TransactionsByContactsTableRows * @param {ITransactionsByVendorsVendor[]} vendorsTransactions - * @param {any} i18n */ - constructor(vendorsTransactions: ITransactionsByVendorsVendor[], i18n) { + constructor( + vendorsTransactions: ITransactionsByVendorsVendor[], + i18n: I18nService, + dateFormat: string, + ) { super(); this.vendorsTransactions = vendorsTransactions; this.i18n = i18n; + this.dateFormat = dateFormat; } /** diff --git a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorTableInjectable.ts b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorTableInjectable.ts index 209fde297..436ad39f5 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorTableInjectable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TransactionsByVendor/TransactionsByVendorTableInjectable.ts @@ -25,7 +25,7 @@ export class TransactionsByVendorTableInjectable { const sheet = await this.transactionsByVendor.transactionsByVendors( query ); - const table = new TransactionsByVendorsTable(sheet.data, this.i18n); + const table = new TransactionsByVendorsTable(sheet.data, this.i18n, sheet.meta.dateFormat); return { table: { diff --git a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheet.controller.ts b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheet.controller.ts index 6090dba68..578e0e69f 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheet.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheet.controller.ts @@ -1,9 +1,11 @@ import { Controller, Get, Headers, Query, Res } from '@nestjs/common'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { castArray } from 'lodash'; import { Response } from 'express'; @@ -11,11 +13,16 @@ import { AcceptType } from '@/constants/accept-type'; import { TrialBalanceSheetApplication } from './TrialBalanceSheetApplication'; import { TrialBalanceSheetQueryDto } from './TrialBalanceSheetQuery.dto'; import { TrialBalanceSheetResponseExample } from './TrialBalanceSheet.swagger'; +import { + TrialBalanceSheetResponseDto, + TrialBalanceSheetTableResponseDto, +} from './TrialBalanceSheetResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('reports/trial-balance-sheet') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(TrialBalanceSheetResponseDto, TrialBalanceSheetTableResponseDto) export class TrialBalanceSheetController { constructor( private readonly trialBalanceSheetApp: TrialBalanceSheetApplication, @@ -26,7 +33,15 @@ export class TrialBalanceSheetController { @ApiResponse({ status: 200, description: 'Trial balance sheet', - example: TrialBalanceSheetResponseExample, + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(TrialBalanceSheetResponseDto) }, + example: TrialBalanceSheetResponseExample, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(TrialBalanceSheetTableResponseDto) }, + }, + }, }) @ApiProduces( AcceptType.ApplicationJson, diff --git a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheet.ts b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheet.ts index 1cee9693a..ba470fee8 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheet.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheet.ts @@ -12,6 +12,7 @@ import { Account } from '@/modules/Accounts/models/Account.model'; import { allPassedConditionsPass } from '@/utils/all-conditions-passed'; import { ModelObject } from 'objection'; import { flatToNestedArray } from '@/utils/flat-to-nested-array'; +import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; export class TrialBalanceSheet extends FinancialSheet { /** @@ -42,14 +43,15 @@ export class TrialBalanceSheet extends FinancialSheet { constructor( query: ITrialBalanceSheetQuery, repository: TrialBalanceSheetRepository, - baseCurrency: string + meta: IFinancialReportMeta, ) { super(); this.query = query; this.repository = repository; this.numberFormat = this.query.numberFormat; - this.baseCurrency = baseCurrency; + this.baseCurrency = meta.baseCurrency; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } /** @@ -172,8 +174,11 @@ export class TrialBalanceSheet extends FinancialSheet { private filterNoneTransactions = ( accountNode: ITrialBalanceAccount ): boolean => { - const accountLedger = this.repository.totalAccountsLedger.whereAccountId( - accountNode.id, + const depsAccountsIds = + this.repository.accountsDepGraph.dependenciesOf(accountNode.id); + + const accountLedger = this.repository.totalAccountsLedger.whereAccountsIds( + [accountNode.id, ...depsAccountsIds] ); return !accountLedger.isEmpty(); }; @@ -241,8 +246,8 @@ export class TrialBalanceSheet extends FinancialSheet { */ private accountsSection(accounts: ModelObject[]) { return R.compose( - this.nestedAccountsNode, this.accountsFilter, + this.nestedAccountsNode, this.accountsMapper )(accounts); } @@ -250,7 +255,6 @@ export class TrialBalanceSheet extends FinancialSheet { /** * Retrieve trial balance sheet statement data. * Note: Retruns null in case there is no transactions between the given date periods. - * * @return {ITrialBalanceSheetData} */ public reportData(): ITrialBalanceSheetData { diff --git a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetInjectable.ts b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetInjectable.ts index 7f1a2a290..dd21a7e16 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetInjectable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetInjectable.ts @@ -39,18 +39,18 @@ export class TrialBalanceSheetService { // Loads the resources. await this.trialBalanceSheetRepository.asyncInitialize(); + // Trial balance sheet meta first to get date format. + const meta = await this.trialBalanceSheetMetaService.meta(filter); + // Trial balance report instance. const trialBalanceInstance = new TrialBalanceSheet( filter, this.trialBalanceSheetRepository, - tenantMetadata.baseCurrency, + { baseCurrency: tenantMetadata.baseCurrency, dateFormat: meta.dateFormat }, ); // Trial balance sheet data. const trialBalanceSheetData = trialBalanceInstance.reportData(); - // Trial balance sheet meta. - const meta = await this.trialBalanceSheetMetaService.meta(filter); - // Triggers `onTrialBalanceSheetViewed` event. await this.eventPublisher.emitAsync( events.reports.onTrialBalanceSheetView, diff --git a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetMeta.ts b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetMeta.ts index 92801143c..3fc4a491e 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetMeta.ts @@ -16,8 +16,8 @@ export class TrialBalanceSheetMeta { ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); - const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); + const formattedFromDate = moment(query.fromDate).format(commonMeta.dateFormat); + const formattedToDate = moment(query.toDate).format(commonMeta.dateFormat); const formattedDateRange = `From ${formattedFromDate} to ${formattedToDate}`; const sheetName = 'Trial Balance Sheet'; diff --git a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetResponse.dto.ts new file mode 100644 index 000000000..faa33cd15 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetResponse.dto.ts @@ -0,0 +1,123 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class TrialBalanceSheetAccountDto { + @ApiProperty({ description: 'Account ID', type: Number }) + id: number; + + @ApiProperty({ description: 'Account name' }) + name: string; + + @ApiProperty({ description: 'Account code' }) + code: string; + + @ApiPropertyOptional({ description: 'Opening balance', type: FinancialReportTotalDto }) + openingBalance?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Closing balance', type: FinancialReportTotalDto }) + closingBalance?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Debit total', type: FinancialReportTotalDto }) + debitTotal?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Credit total', type: FinancialReportTotalDto }) + creditTotal?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Debit/change', type: FinancialReportTotalDto }) + debit?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Credit/change', type: FinancialReportTotalDto }) + credit?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Period balance', type: FinancialReportTotalDto }) + periodBalance?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Account normal', enum: ['debit', 'credit'] }) + accountNormal?: string; + + @ApiPropertyOptional({ description: 'Account index', type: Number }) + index?: number; +} + +export class TrialBalanceSheetMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted from date' }) + formattedFromDate: string; + + @ApiProperty({ description: 'Formatted to date' }) + formattedToDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; + + @ApiPropertyOptional({ description: 'Opening balance at', type: Date }) + openingBalanceAt?: Date; + + @ApiPropertyOptional({ description: 'Closing balance at', type: Date }) + closingBalanceAt?: Date; + + @ApiPropertyOptional({ description: 'Formatted opening balance date' }) + formattedOpeningBalanceDate?: string; + + @ApiPropertyOptional({ description: 'Formatted closing balance date' }) + formattedClosingBalanceDate?: string; +} + +export class TrialBalanceSheetQueryResponseDto { + @ApiProperty({ description: 'Start date' }) + fromDate: string; + + @ApiProperty({ description: 'End date' }) + toDate: string; + + @ApiProperty({ description: 'Account IDs to include', type: [Number] }) + accountIds: number[]; + + @ApiProperty({ description: 'Exclude zero balance accounts' }) + noneZero: boolean; + + @ApiProperty({ description: 'Exclude accounts with no transactions' }) + noneTransactions: boolean; + + @ApiProperty({ description: 'Accounting basis', enum: ['cash', 'accrual'] }) + basis: string; + + @ApiProperty({ description: 'Column display type', enum: ['total', 'date_periods'] }) + displayColumnsType: string; + + @ApiProperty({ description: 'Column grouping', enum: ['day', 'month', 'year', 'quarter'] }) + displayColumnsBy: string; +} + +export class TrialBalanceSheetResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: TrialBalanceSheetQueryResponseDto }) + query: TrialBalanceSheetQueryResponseDto; + + @ApiProperty({ description: 'Trial balance sheet data', type: [TrialBalanceSheetAccountDto] }) + data: TrialBalanceSheetAccountDto[]; + + @ApiProperty({ description: 'Report metadata', type: TrialBalanceSheetMetaDto }) + meta: TrialBalanceSheetMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as TrialBalanceSheetTableCellDto, + FinancialTableRowDto as TrialBalanceSheetTableRowDto, + FinancialTableColumnDto as TrialBalanceSheetTableColumnDto, + FinancialTableDataDto as TrialBalanceSheetTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class TrialBalanceSheetTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: TrialBalanceSheetQueryResponseDto }) + query: TrialBalanceSheetQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: TrialBalanceSheetMetaDto }) + meta: TrialBalanceSheetMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetTable.ts b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetTable.ts index b8690b94d..b470fb023 100644 --- a/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetTable.ts +++ b/packages/server/src/modules/FinancialStatements/modules/TrialBalanceSheet/TrialBalanceSheetTable.ts @@ -141,10 +141,10 @@ export class TrialBalanceSheetTable extends R.compose( return R.compose( this.tableColumnsCellIndexing, R.concat([ - { key: 'account', label: 'Account' }, - { key: 'debit', label: 'Debit' }, - { key: 'credit', label: 'Credit' }, - { key: 'total', label: 'Total' }, + { key: 'account', label: this.i18n.t('trial_balance_sheet.account') }, + { key: 'debit', label: this.i18n.t('trial_balance_sheet.debit') }, + { key: 'credit', label: this.i18n.t('trial_balance_sheet.credit') }, + { key: 'total', label: this.i18n.t('trial_balance_sheet.total') }, ]), )([]); }; diff --git a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummary.controller.ts b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummary.controller.ts index bfb2fa748..cb21f4f19 100644 --- a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummary.controller.ts +++ b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummary.controller.ts @@ -4,17 +4,24 @@ import { VendorBalanceSummaryApplication } from './VendorBalanceSummaryApplicati import { Response } from 'express'; import { AcceptType } from '@/constants/accept-type'; import { + ApiExtraModels, ApiOperation, ApiProduces, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { VendorBalanceSummaryQueryDto } from './VendorBalanceSummaryQuery.dto'; +import { + VendorBalanceSummaryResponseDto, + VendorBalanceSummaryTableResponseDto, +} from './VendorBalanceSummaryResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('/reports/vendor-balance-summary') @ApiTags('Reports') @ApiCommonHeaders() +@ApiExtraModels(VendorBalanceSummaryResponseDto, VendorBalanceSummaryTableResponseDto) export class VendorBalanceSummaryController { constructor( private readonly vendorBalanceSummaryApp: VendorBalanceSummaryApplication, @@ -22,7 +29,18 @@ export class VendorBalanceSummaryController { @Get() @ApiOperation({ summary: 'Get vendor balance summary' }) - @ApiResponse({ status: 200, description: 'Vendor balance summary' }) + @ApiResponse({ + status: 200, + description: 'Vendor balance summary', + content: { + [AcceptType.ApplicationJson]: { + schema: { $ref: getSchemaPath(VendorBalanceSummaryResponseDto) }, + }, + [AcceptType.ApplicationJsonTable]: { + schema: { $ref: getSchemaPath(VendorBalanceSummaryTableResponseDto) }, + }, + }, + }) @ApiProduces( AcceptType.ApplicationJson, AcceptType.ApplicationJsonTable, diff --git a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummary.ts b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummary.ts index 361b15baf..287c843da 100644 --- a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummary.ts +++ b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummary.ts @@ -8,7 +8,7 @@ import { } from './VendorBalanceSummary.types'; import { ContactBalanceSummaryReport } from '../ContactBalanceSummary/ContactBalanceSummary'; import { Vendor } from '@/modules/Vendors/models/Vendor'; -import { INumberFormatQuery } from '../../types/Report.types'; +import { INumberFormatQuery, IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types'; import { VendorBalanceSummaryRepository } from './VendorBalanceSummaryRepository'; import { Ledger } from '@/modules/Ledger/Ledger'; @@ -27,15 +27,17 @@ export class VendorBalanceSummaryReport extends ContactBalanceSummaryReport { constructor( repository: VendorBalanceSummaryRepository, filter: IVendorBalanceSummaryQuery, + meta: IFinancialReportMeta, ) { super(); - + this.repository = repository; this.ledger = this.repository.ledger; - this.baseCurrency = this.repository.baseCurrency; + this.baseCurrency = meta.baseCurrency; this.filter = filter; this.numberFormat = this.filter.numberFormat; + this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat; } diff --git a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryMeta.ts b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryMeta.ts index 206ab5f61..3fcf6c549 100644 --- a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryMeta.ts +++ b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryMeta.ts @@ -19,7 +19,7 @@ export class VendorBalanceSummaryMeta { query: IVendorBalanceSummaryQuery, ): Promise { const commonMeta = await this.financialSheetMeta.meta(); - const formattedAsDate = moment(query.asDate).format('YYYY/MM/DD'); + const formattedAsDate = moment(query.asDate).format(commonMeta.dateFormat); return { ...commonMeta, diff --git a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryResponse.dto.ts new file mode 100644 index 000000000..4a3957e19 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryResponse.dto.ts @@ -0,0 +1,82 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto'; +import { + FinancialReportTotalDto, + FinancialReportMetaDto, + FinancialTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class VendorBalanceDto { + @ApiProperty({ description: 'Vendor ID', type: Number }) + vendorId: number; + + @ApiProperty({ description: 'Vendor name' }) + vendorName: string; + + @ApiPropertyOptional({ description: 'Opening balance', type: FinancialReportTotalDto }) + openingBalance?: FinancialReportTotalDto; + + @ApiProperty({ description: 'Closing balance', type: FinancialReportTotalDto }) + closingBalance: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Total debit', type: FinancialReportTotalDto }) + totalDebit?: FinancialReportTotalDto; + + @ApiPropertyOptional({ description: 'Total credit', type: FinancialReportTotalDto }) + totalCredit?: FinancialReportTotalDto; +} + +export class VendorBalanceSummaryMetaDto extends FinancialReportMetaDto { + @ApiProperty({ description: 'Formatted as-of date' }) + formattedAsDate: string; + + @ApiProperty({ description: 'Formatted date range' }) + formattedDateRange: string; +} + +export class VendorBalanceSummaryQueryResponseDto { + @ApiProperty({ description: 'As-of date' }) + asDate: string; + + @ApiProperty({ description: 'Number format settings', type: NumberFormatQueryDto }) + numberFormat: NumberFormatQueryDto; + + @ApiProperty({ description: 'Vendor IDs to include', type: [Number] }) + vendorsIds: number[]; + + @ApiProperty({ description: 'Exclude zero balance vendors' }) + noneZero: boolean; + + @ApiProperty({ description: 'Exclude inactive vendors' }) + noneInactive: boolean; +} + +export class VendorBalanceSummaryResponseDto { + @ApiProperty({ description: 'Query parameters used to generate the report', type: VendorBalanceSummaryQueryResponseDto }) + query: VendorBalanceSummaryQueryResponseDto; + + @ApiProperty({ description: 'Vendor balances', type: [VendorBalanceDto] }) + data: VendorBalanceDto[]; + + @ApiProperty({ description: 'Report metadata', type: VendorBalanceSummaryMetaDto }) + meta: VendorBalanceSummaryMetaDto; +} + +// Re-export table DTOs for convenience +export { + FinancialTableCellDto as VendorBalanceSummaryTableCellDto, + FinancialTableRowDto as VendorBalanceSummaryTableRowDto, + FinancialTableColumnDto as VendorBalanceSummaryTableColumnDto, + FinancialTableDataDto as VendorBalanceSummaryTableDataDto, +} from '../../dtos/FinancialReportResponse.dto'; + +export class VendorBalanceSummaryTableResponseDto { + @ApiProperty({ description: 'Table data structure', type: () => FinancialTableDataDto }) + table: FinancialTableDataDto; + + @ApiProperty({ description: 'Query parameters used to generate the report', type: VendorBalanceSummaryQueryResponseDto }) + query: VendorBalanceSummaryQueryResponseDto; + + @ApiProperty({ description: 'Report metadata', type: VendorBalanceSummaryMetaDto }) + meta: VendorBalanceSummaryMetaDto; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryService.ts b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryService.ts index bf448742a..296777218 100644 --- a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryService.ts +++ b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryService.ts @@ -31,13 +31,15 @@ export class VendorBalanceSummaryService { this.vendorBalanceSummaryRepository.setFilter(filter); await this.vendorBalanceSummaryRepository.asyncInit(); + // Retrieve the vendor balance summary meta first to get date format. + const meta = await this.vendorBalanceSummaryMeta.meta(filter); + // Report instance. const reportInstance = new VendorBalanceSummaryReport( this.vendorBalanceSummaryRepository, filter, + { baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat }, ); - // Retrieve the vendor balance summary meta. - const meta = await this.vendorBalanceSummaryMeta.meta(filter); // Triggers `onVendorBalanceSummaryViewed` event. await this.eventEmitter.emitAsync( diff --git a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryTableRows.ts b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryTableRows.ts index fcc22e1cb..a8016f598 100644 --- a/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryTableRows.ts +++ b/packages/server/src/modules/FinancialStatements/modules/VendorBalanceSummary/VendorBalanceSummaryTableRows.ts @@ -91,7 +91,7 @@ export class VendorBalanceSummaryTable { */ private getTotalColumnsAccessor = (): IColumnMapperMeta[] => { const columns = [ - { key: 'name', value: this.i18n.t('Total') }, + { key: 'name', value: this.i18n.t('contact_summary_balance.total') }, { key: 'total', accessor: 'total.formattedAmount' }, ]; return R.compose( diff --git a/packages/server/src/modules/FinancialStatements/types/Report.types.ts b/packages/server/src/modules/FinancialStatements/types/Report.types.ts index 33f3d2d56..9327c2a67 100644 --- a/packages/server/src/modules/FinancialStatements/types/Report.types.ts +++ b/packages/server/src/modules/FinancialStatements/types/Report.types.ts @@ -52,6 +52,22 @@ export interface IFinancialSheetCommonMeta { sheetName: string; } +/** + * Report meta interface for sheet constructors. + * Combines baseCurrency and dateFormat for a cleaner API. + */ +export interface IFinancialReportMeta { + baseCurrency: string; + dateFormat: string; +} + +/** + * Default report meta values. + */ +export const DEFAULT_REPORT_META: Omit = { + dateFormat: 'YYYY MMM DD', +}; + export enum IFinancialDatePeriodsUnit { Day = 'day', Month = 'month', diff --git a/packages/server/src/modules/InventoryAdjutments/InventoryAdjustments.controller.ts b/packages/server/src/modules/InventoryAdjutments/InventoryAdjustments.controller.ts index 00e4c8e5b..ed4f79962 100644 --- a/packages/server/src/modules/InventoryAdjutments/InventoryAdjustments.controller.ts +++ b/packages/server/src/modules/InventoryAdjutments/InventoryAdjustments.controller.ts @@ -14,24 +14,35 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { InventoryAdjustmentsApplicationService } from './InventoryAdjustmentsApplication.service'; import { IInventoryAdjustmentsFilter } from './types/InventoryAdjustments.types'; import { InventoryAdjustment } from './models/InventoryAdjustment'; import { CreateQuickInventoryAdjustmentDto } from './dtos/CreateQuickInventoryAdjustment.dto'; +import { InventoryAdjustmentsFilterDto } from './dtos/InventoryAdjustmentsFilter.dto'; +import { InventoryAdjustmentsListResponseDto } from './dtos/InventoryAdjustmentsListResponse.dto'; import { InventoryAdjustmentResponseDto } from './dtos/InventoryAdjustmentResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { InventoryAdjustmentAction } from './types/InventoryAdjustments.types'; @Controller('inventory-adjustments') @ApiTags('Inventory Adjustments') @ApiExtraModels(InventoryAdjustmentResponseDto) +@ApiExtraModels(InventoryAdjustmentsListResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class InventoryAdjustmentsController { constructor( private readonly inventoryAdjustmentsApplicationService: InventoryAdjustmentsApplicationService, ) {} @Post('quick') + @RequirePermission(InventoryAdjustmentAction.CREATE, AbilitySubject.InventoryAdjustment) @ApiOperation({ summary: 'Create a quick inventory adjustment.' }) @ApiResponse({ status: 200, @@ -46,6 +57,7 @@ export class InventoryAdjustmentsController { } @Delete(':id') + @RequirePermission(InventoryAdjustmentAction.DELETE, AbilitySubject.InventoryAdjustment) @ApiOperation({ summary: 'Delete the given inventory adjustment.' }) @ApiResponse({ status: 200, @@ -60,24 +72,25 @@ export class InventoryAdjustmentsController { } @Get() + @RequirePermission(InventoryAdjustmentAction.VIEW, AbilitySubject.InventoryAdjustment) @ApiOperation({ summary: 'Retrieves the inventory adjustments.' }) @ApiResponse({ status: 200, description: 'The inventory adjustments have been successfully retrieved.', schema: { - type: 'array', - items: { $ref: getSchemaPath(InventoryAdjustmentResponseDto) }, + $ref: getSchemaPath(InventoryAdjustmentsListResponseDto), }, }) public async getInventoryAdjustments( - @Query() filterDTO: IInventoryAdjustmentsFilter, + @Query() filterDTO: InventoryAdjustmentsFilterDto, ) { return this.inventoryAdjustmentsApplicationService.getInventoryAdjustments( - filterDTO, + filterDTO as IInventoryAdjustmentsFilter, ); } @Get(':id') + @RequirePermission(InventoryAdjustmentAction.VIEW, AbilitySubject.InventoryAdjustment) @ApiOperation({ summary: 'Retrieves the inventory adjustment details.' }) @ApiResponse({ status: 200, @@ -94,6 +107,7 @@ export class InventoryAdjustmentsController { } @Put(':id/publish') + @RequirePermission(InventoryAdjustmentAction.EDIT, AbilitySubject.InventoryAdjustment) @ApiOperation({ summary: 'Publish the given inventory adjustment.' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/InventoryAdjutments/dtos/InventoryAdjustmentsFilter.dto.ts b/packages/server/src/modules/InventoryAdjutments/dtos/InventoryAdjustmentsFilter.dto.ts new file mode 100644 index 000000000..888ed3391 --- /dev/null +++ b/packages/server/src/modules/InventoryAdjutments/dtos/InventoryAdjustmentsFilter.dto.ts @@ -0,0 +1,9 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class InventoryAdjustmentsFilterDto { + @ApiPropertyOptional({ example: 1 }) + page?: number; + + @ApiPropertyOptional({ example: 12 }) + pageSize?: number; +} diff --git a/packages/server/src/modules/InventoryAdjutments/dtos/InventoryAdjustmentsListResponse.dto.ts b/packages/server/src/modules/InventoryAdjutments/dtos/InventoryAdjustmentsListResponse.dto.ts new file mode 100644 index 000000000..88533f310 --- /dev/null +++ b/packages/server/src/modules/InventoryAdjutments/dtos/InventoryAdjustmentsListResponse.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { InventoryAdjustmentResponseDto } from './InventoryAdjustmentResponse.dto'; + +class InventoryAdjustmentsPaginationDto { + @ApiProperty({ example: 1 }) + page: number; + + @ApiProperty({ example: 12 }) + pageSize: number; + + @ApiProperty({ example: 42 }) + total: number; +} + +export class InventoryAdjustmentsListResponseDto { + @ApiProperty({ type: [InventoryAdjustmentResponseDto] }) + data: InventoryAdjustmentResponseDto[]; + + @ApiProperty({ type: InventoryAdjustmentsPaginationDto }) + pagination: InventoryAdjustmentsPaginationDto; +} diff --git a/packages/server/src/modules/InventoryAdjutments/models/InventoryAdjustment.ts b/packages/server/src/modules/InventoryAdjutments/models/InventoryAdjustment.ts index e0dfa61b3..be4801ae9 100644 --- a/packages/server/src/modules/InventoryAdjutments/models/InventoryAdjustment.ts +++ b/packages/server/src/modules/InventoryAdjutments/models/InventoryAdjustment.ts @@ -32,7 +32,7 @@ export class InventoryAdjustment extends TenantBaseModel { * Timestamps columns. */ get timestamps(): Array { - return ['created_at']; + return ['createdAt']; } /** diff --git a/packages/server/src/modules/InventoryAdjutments/models/InventoryAdjustmentEntry.ts b/packages/server/src/modules/InventoryAdjutments/models/InventoryAdjustmentEntry.ts index 900ed8b1f..72009fa43 100644 --- a/packages/server/src/modules/InventoryAdjutments/models/InventoryAdjustmentEntry.ts +++ b/packages/server/src/modules/InventoryAdjutments/models/InventoryAdjustmentEntry.ts @@ -20,6 +20,13 @@ export class InventoryAdjustmentEntry extends BaseModel { return 'inventory_adjustments_entries'; } + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/modules/InventoryCost/InventoryCost.controller.ts b/packages/server/src/modules/InventoryCost/InventoryCost.controller.ts index 15b456b0b..4b94a1f68 100644 --- a/packages/server/src/modules/InventoryCost/InventoryCost.controller.ts +++ b/packages/server/src/modules/InventoryCost/InventoryCost.controller.ts @@ -1,7 +1,8 @@ import { Controller, Get, Query } from '@nestjs/common'; import { GetItemsInventoryValuationListService } from './queries/GetItemsInventoryValuationList.service'; import { GetInventoyItemsCostQueryDto } from './dtos/GetInventoryItemsCostQuery.dto'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { GetInventoryItemsCostResponseDto } from './dtos/GetInventoryItemsCostResponse.dto'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @Controller('inventory-cost') @@ -14,9 +15,14 @@ export class InventoryCostController { @Get('items') @ApiOperation({ summary: 'Get items inventory valuation list' }) + @ApiResponse({ + status: 200, + description: 'Items inventory cost list', + type: GetInventoryItemsCostResponseDto, + }) async getItemsCost( @Query() itemsCostsQueryDto: GetInventoyItemsCostQueryDto, - ) { + ): Promise { const costs = await this.inventoryItemCost.getItemsInventoryValuationList( itemsCostsQueryDto.itemsIds, itemsCostsQueryDto.date, diff --git a/packages/server/src/modules/InventoryCost/dtos/GetInventoryItemsCostResponse.dto.ts b/packages/server/src/modules/InventoryCost/dtos/GetInventoryItemsCostResponse.dto.ts new file mode 100644 index 000000000..9bc498ce0 --- /dev/null +++ b/packages/server/src/modules/InventoryCost/dtos/GetInventoryItemsCostResponse.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InventoryItemCostDto { + @ApiProperty({ description: 'Item ID' }) + itemId: number; + + @ApiProperty({ description: 'Valuation' }) + valuation: number; + + @ApiProperty({ description: 'Quantity' }) + quantity: number; + + @ApiProperty({ description: 'Average cost' }) + average: number; +} + +export class GetInventoryItemsCostResponseDto { + @ApiProperty({ + type: [InventoryItemCostDto], + description: 'List of item costs', + }) + costs: InventoryItemCostDto[]; +} diff --git a/packages/server/src/modules/InventoryCost/models/InventoryTransactionMeta.ts b/packages/server/src/modules/InventoryCost/models/InventoryTransactionMeta.ts index 30041855f..1da927d24 100644 --- a/packages/server/src/modules/InventoryCost/models/InventoryTransactionMeta.ts +++ b/packages/server/src/modules/InventoryCost/models/InventoryTransactionMeta.ts @@ -13,6 +13,13 @@ export class InventoryTransactionMeta extends BaseModel { return 'inventory_transaction_meta'; } + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/modules/ItemCategories/ItemCategory.application.ts b/packages/server/src/modules/ItemCategories/ItemCategory.application.ts index f9fab9582..9e790a8bd 100644 --- a/packages/server/src/modules/ItemCategories/ItemCategory.application.ts +++ b/packages/server/src/modules/ItemCategories/ItemCategory.application.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { - IItemCategoriesFilter, IItemCategoryOTD, } from './ItemCategory.interfaces'; +import { GetItemCategoriesQueryDto } from './dtos/GetItemCategoriesQuery.dto'; import { CreateItemCategoryService } from './commands/CreateItemCategory.service'; import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service'; import { EditItemCategoryService } from './commands/EditItemCategory.service'; @@ -75,10 +75,10 @@ export class ItemCategoryApplication { /** * Retrieves the item categories list. - * @param {IItemCategoriesFilter} filterDTO - The item categories filter DTO. + * @param {GetItemCategoriesQueryDto} filterDTO - The item categories filter DTO. * @returns {Promise} */ - public getItemCategories(filterDTO: Partial) { + public getItemCategories(filterDTO: GetItemCategoriesQueryDto) { return this.getItemCategoriesService.getItemCategories(filterDTO); } } diff --git a/packages/server/src/modules/ItemCategories/ItemCategory.controller.ts b/packages/server/src/modules/ItemCategories/ItemCategory.controller.ts index ea902928d..217a34ae1 100644 --- a/packages/server/src/modules/ItemCategories/ItemCategory.controller.ts +++ b/packages/server/src/modules/ItemCategories/ItemCategory.controller.ts @@ -9,10 +9,7 @@ import { Query, } from '@nestjs/common'; import { ItemCategoryApplication } from './ItemCategory.application'; -import { - GetItemCategoriesResponse, - IItemCategoriesFilter, -} from './ItemCategory.interfaces'; +import { GetItemCategoriesResponse } from './ItemCategory.interfaces'; import { ApiExtraModels, ApiOperation, @@ -24,6 +21,7 @@ import { CreateItemCategoryDto, EditItemCategoryDto, } from './dtos/ItemCategory.dto'; +import { GetItemCategoriesQueryDto } from './dtos/GetItemCategoriesQuery.dto'; import { ItemCategoryResponseDto } from './dtos/ItemCategoryResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @@ -53,7 +51,7 @@ export class ItemCategoryController { }, }) async getItemCategories( - @Query() filterDTO: Partial, + @Query() filterDTO: GetItemCategoriesQueryDto, ): Promise { return this.itemCategoryApplication.getItemCategories(filterDTO); } diff --git a/packages/server/src/modules/ItemCategories/dtos/GetItemCategoriesQuery.dto.ts b/packages/server/src/modules/ItemCategories/dtos/GetItemCategoriesQuery.dto.ts new file mode 100644 index 000000000..1df8f5ad9 --- /dev/null +++ b/packages/server/src/modules/ItemCategories/dtos/GetItemCategoriesQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetItemCategoriesQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/ItemCategories/queries/GetItemCategories.service.ts b/packages/server/src/modules/ItemCategories/queries/GetItemCategories.service.ts index 104f7383a..b1a391259 100644 --- a/packages/server/src/modules/ItemCategories/queries/GetItemCategories.service.ts +++ b/packages/server/src/modules/ItemCategories/queries/GetItemCategories.service.ts @@ -2,10 +2,8 @@ import * as R from 'ramda'; import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service'; import { ItemCategory } from '../models/ItemCategory.model'; import { Inject } from '@nestjs/common'; -import { - GetItemCategoriesResponse, - IItemCategoriesFilter, -} from '../ItemCategory.interfaces'; +import { GetItemCategoriesResponse } from '../ItemCategory.interfaces'; +import { GetItemCategoriesQueryDto } from '../dtos/GetItemCategoriesQuery.dto'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { ISortOrder } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types'; @@ -31,11 +29,11 @@ export class GetItemCategoriesService { /** * Retrieve item categories list. - * @param {IItemCategoriesFilter} filterDTO + * @param {GetItemCategoriesQueryDto} filterDTO * @returns {Promise} */ public async getItemCategories( - filterDto: Partial, + filterDto: GetItemCategoriesQueryDto, ): Promise { const _filterDto = { sortOrder: ISortOrder.ASC, diff --git a/packages/server/src/modules/Items/Item.controller.ts b/packages/server/src/modules/Items/Item.controller.ts index 5f174f766..1c49978a9 100644 --- a/packages/server/src/modules/Items/Item.controller.ts +++ b/packages/server/src/modules/Items/Item.controller.ts @@ -9,6 +9,7 @@ import { Put, Query, HttpCode, + UseGuards, } from '@nestjs/common'; import { TenantController } from '../Tenancy/Tenant.controller'; import { ItemsApplicationService } from './ItemsApplication.service'; @@ -35,6 +36,11 @@ import { ValidateBulkDeleteItemsResponseDto, } from './dtos/BulkDeleteItems.dto'; import { ItemApiErrorResponseDto } from './dtos/ItemErrorResponse.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ItemAction } from '@/interfaces/Item'; @Controller('/items') @ApiTags('Items') @@ -48,12 +54,14 @@ import { ItemApiErrorResponseDto } from './dtos/ItemErrorResponse.dto'; @ApiExtraModels(ValidateBulkDeleteItemsResponseDto) @ApiExtraModels(ItemApiErrorResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class ItemsController extends TenantController { constructor(private readonly itemsApplication: ItemsApplicationService) { super(); } @Get() + @RequirePermission(ItemAction.VIEW, AbilitySubject.Item) @ApiOperation({ summary: 'Retrieves the item list.' }) @ApiResponse({ status: 200, @@ -144,6 +152,7 @@ export class ItemsController extends TenantController { * @returns The updated item id. */ @Put(':id') + @RequirePermission(ItemAction.EDIT, AbilitySubject.Item) @ApiOperation({ summary: 'Edit the given item (product or service).' }) @ApiResponse({ status: 200, @@ -174,6 +183,7 @@ export class ItemsController extends TenantController { @Post('validate-bulk-delete') @HttpCode(200) + @RequirePermission(ItemAction.DELETE, AbilitySubject.Item) @ApiOperation({ summary: 'Validates which items can be deleted and returns counts of deletable and non-deletable items.', @@ -194,6 +204,7 @@ export class ItemsController extends TenantController { @Post('bulk-delete') @HttpCode(200) + @RequirePermission(ItemAction.DELETE, AbilitySubject.Item) @ApiOperation({ summary: 'Deletes multiple items in bulk.' }) @ApiResponse({ status: 200, @@ -208,6 +219,7 @@ export class ItemsController extends TenantController { } @Post() + @RequirePermission(ItemAction.CREATE, AbilitySubject.Item) @ApiOperation({ summary: 'Create a new item (product or service).' }) @ApiResponse({ status: 200, @@ -230,6 +242,7 @@ export class ItemsController extends TenantController { } @Delete(':id') + @RequirePermission(ItemAction.DELETE, AbilitySubject.Item) @ApiOperation({ summary: 'Delete the given item (product or service).' }) @ApiResponse({ status: 200, @@ -255,6 +268,7 @@ export class ItemsController extends TenantController { } @Patch(':id/inactivate') + @RequirePermission(ItemAction.EDIT, AbilitySubject.Item) @ApiOperation({ summary: 'Inactivate the given item (product or service).' }) @ApiResponse({ status: 200, @@ -273,6 +287,7 @@ export class ItemsController extends TenantController { } @Patch(':id/activate') + @RequirePermission(ItemAction.EDIT, AbilitySubject.Item) @ApiOperation({ summary: 'Activate the given item (product or service).' }) @ApiResponse({ status: 200, @@ -291,6 +306,7 @@ export class ItemsController extends TenantController { } @Get(':id') + @RequirePermission(ItemAction.VIEW, AbilitySubject.Item) @ApiOperation({ summary: 'Get the given item (product or service).' }) @ApiResponse({ status: 200, @@ -312,6 +328,7 @@ export class ItemsController extends TenantController { } @Get(':id/invoices') + @RequirePermission(ItemAction.VIEW, AbilitySubject.Item) @ApiOperation({ summary: 'Retrieves the item associated invoices transactions.', }) @@ -337,6 +354,7 @@ export class ItemsController extends TenantController { } @Get(':id/bills') + @RequirePermission(ItemAction.VIEW, AbilitySubject.Item) @ApiOperation({ summary: 'Retrieves the item associated bills transactions.', }) @@ -362,6 +380,7 @@ export class ItemsController extends TenantController { } @Get(':id/estimates') + @RequirePermission(ItemAction.VIEW, AbilitySubject.Item) @ApiOperation({ summary: 'Retrieves the item associated estimates transactions.', }) @@ -387,6 +406,7 @@ export class ItemsController extends TenantController { } @Get(':id/receipts') + @RequirePermission(ItemAction.VIEW, AbilitySubject.Item) @ApiOperation({ summary: 'Retrieves the item associated receipts transactions.', }) diff --git a/packages/server/src/modules/Items/models/Item.ts b/packages/server/src/modules/Items/models/Item.ts index d3a5900f1..db8e0d965 100644 --- a/packages/server/src/modules/Items/models/Item.ts +++ b/packages/server/src/modules/Items/models/Item.ts @@ -44,6 +44,13 @@ export class Item extends TenantBaseModel { return 'items'; } + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + /** * Model modifiers. */ @@ -70,6 +77,16 @@ export class Item extends TenantBaseModel { }; } + /** + * Model search roles. + */ + static get searchRoles() { + return [ + { condition: 'or', fieldKey: 'name', comparator: 'contains' }, + { condition: 'or', fieldKey: 'code', comparator: 'like' }, + ]; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/modules/ManualJournals/ManualJournals.controller.ts b/packages/server/src/modules/ManualJournals/ManualJournals.controller.ts index 239ebadf7..34b906f50 100644 --- a/packages/server/src/modules/ManualJournals/ManualJournals.controller.ts +++ b/packages/server/src/modules/ManualJournals/ManualJournals.controller.ts @@ -8,6 +8,7 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { ManualJournalsApplication } from './ManualJournalsApplication.service'; import { @@ -22,23 +23,30 @@ import { CreateManualJournalDto, EditManualJournalDto, } from './dtos/ManualJournal.dto'; -import { IManualJournalsFilter } from './types/ManualJournals.types'; +import { GetManualJournalsQueryDto } from './dtos/GetManualJournalsQuery.dto'; import { ManualJournalResponseDto } from './dtos/ManualJournalResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { BulkDeleteDto, ValidateBulkDeleteResponseDto, } from '@/common/dtos/BulkDelete.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { ManualJournalAction } from './types/ManualJournals.types'; @Controller('manual-journals') @ApiTags('Manual Journals') @ApiExtraModels(ManualJournalResponseDto) @ApiExtraModels(ValidateBulkDeleteResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class ManualJournalsController { constructor(private manualJournalsApplication: ManualJournalsApplication) { } @Post('validate-bulk-delete') + @RequirePermission(ManualJournalAction.Delete, AbilitySubject.ManualJournal) @ApiOperation({ summary: 'Validate which manual journals can be deleted and return the results.', @@ -60,6 +68,7 @@ export class ManualJournalsController { } @Post('bulk-delete') + @RequirePermission(ManualJournalAction.Delete, AbilitySubject.ManualJournal) @ApiOperation({ summary: 'Deletes multiple manual journals.' }) @ApiResponse({ status: 200, @@ -75,6 +84,7 @@ export class ManualJournalsController { } @Post() + @RequirePermission(ManualJournalAction.Create, AbilitySubject.ManualJournal) @ApiOperation({ summary: 'Create a new manual journal.' }) @ApiResponse({ status: 201, @@ -86,6 +96,7 @@ export class ManualJournalsController { } @Put(':id') + @RequirePermission(ManualJournalAction.Edit, AbilitySubject.ManualJournal) @ApiOperation({ summary: 'Edit the given manual journal.' }) @ApiResponse({ status: 200, @@ -110,6 +121,7 @@ export class ManualJournalsController { } @Delete(':id') + @RequirePermission(ManualJournalAction.Delete, AbilitySubject.ManualJournal) @ApiOperation({ summary: 'Delete the given manual journal.' }) @ApiResponse({ status: 200, @@ -127,6 +139,7 @@ export class ManualJournalsController { } @Patch(':id/publish') + @RequirePermission(ManualJournalAction.Edit, AbilitySubject.ManualJournal) @ApiOperation({ summary: 'Publish the given manual journal.' }) @ApiResponse({ status: 200, @@ -147,6 +160,7 @@ export class ManualJournalsController { } @Get(':id') + @RequirePermission(ManualJournalAction.View, AbilitySubject.ManualJournal) @ApiOperation({ summary: 'Retrieves the manual journal details.' }) @ApiResponse({ status: 200, @@ -167,6 +181,7 @@ export class ManualJournalsController { } @Get() + @RequirePermission(ManualJournalAction.View, AbilitySubject.ManualJournal) @ApiOperation({ summary: 'Retrieves the manual journals paginated list.' }) @ApiResponse({ status: 200, @@ -179,7 +194,7 @@ export class ManualJournalsController { }, }) @ApiResponse({ status: 404, description: 'The manual journal not found.' }) - public getManualJournals(@Query() filterDto: Partial) { + public getManualJournals(@Query() filterDto: GetManualJournalsQueryDto) { return this.manualJournalsApplication.getManualJournals(filterDto); } } diff --git a/packages/server/src/modules/ManualJournals/ManualJournalsApplication.service.ts b/packages/server/src/modules/ManualJournals/ManualJournalsApplication.service.ts index 164bfd943..030c04cb9 100644 --- a/packages/server/src/modules/ManualJournals/ManualJournalsApplication.service.ts +++ b/packages/server/src/modules/ManualJournals/ManualJournalsApplication.service.ts @@ -4,11 +4,11 @@ import { EditManualJournal } from './commands/EditManualJournal.service'; import { PublishManualJournal } from './commands/PublishManualJournal.service'; import { GetManualJournal } from './queries/GetManualJournal.service'; import { DeleteManualJournalService } from './commands/DeleteManualJournal.service'; -import { IManualJournalsFilter } from './types/ManualJournals.types'; import { CreateManualJournalDto, EditManualJournalDto, } from './dtos/ManualJournal.dto'; +import { GetManualJournalsQueryDto } from './dtos/GetManualJournalsQuery.dto'; import { GetManualJournals } from './queries/GetManualJournals.service'; import { BulkDeleteManualJournalsService } from './BulkDeleteManualJournals.service'; import { ValidateBulkDeleteManualJournalsService } from './ValidateBulkDeleteManualJournals.service'; @@ -105,9 +105,9 @@ export class ManualJournalsApplication { /** * Retrieves the paginated manual journals. - * @param {IManualJournalsFilter} filterDTO + * @param {GetManualJournalsQueryDto} filterDTO */ - public getManualJournals = (filterDTO: Partial) => { + public getManualJournals = (filterDTO: GetManualJournalsQueryDto) => { return this.getManualJournalsService.getManualJournals(filterDTO); }; } diff --git a/packages/server/src/modules/ManualJournals/constants.ts b/packages/server/src/modules/ManualJournals/constants.ts index f10b4d44f..825c1ddbb 100644 --- a/packages/server/src/modules/ManualJournals/constants.ts +++ b/packages/server/src/modules/ManualJournals/constants.ts @@ -28,7 +28,28 @@ export const CONTACTS_CONFIG = [ }, ]; -export const ManualJournalDefaultViews = []; +export const DEFAULT_VIEW_COLUMNS = []; + +export const ManualJournalDefaultViews = [ + { + name: 'Draft', + slug: 'draft', + rolesLogicExpression: '1', + roles: [ + { index: 1, fieldKey: 'status', comparator: 'equals', value: 'draft' }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Published', + slug: 'published', + rolesLogicExpression: '1', + roles: [ + { index: 1, fieldKey: 'status', comparator: 'equals', value: 'published' }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, +]; export const ManualJournalsSampleData = [ { diff --git a/packages/server/src/modules/ManualJournals/dtos/GetManualJournalsQuery.dto.ts b/packages/server/src/modules/ManualJournals/dtos/GetManualJournalsQuery.dto.ts new file mode 100644 index 000000000..382497728 --- /dev/null +++ b/packages/server/src/modules/ManualJournals/dtos/GetManualJournalsQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetManualJournalsQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/ManualJournals/models/ManualJournal.ts b/packages/server/src/modules/ManualJournals/models/ManualJournal.ts index 1f2dc98db..65387f952 100644 --- a/packages/server/src/modules/ManualJournals/models/ManualJournal.ts +++ b/packages/server/src/modules/ManualJournals/models/ManualJournal.ts @@ -5,10 +5,13 @@ import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { ManualJournalMeta } from './ManualJournal.meta'; +import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection'; import { ImportableModel } from '@/modules/Import/decorators/Import.decorator'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { ManualJournalDefaultViews } from '../constants'; +import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; +@InjectAttachable() @ExportableModel() @ImportableModel() @InjectModelMeta(ManualJournalMeta) @@ -78,7 +81,8 @@ export class ManualJournal extends TenantBaseModel { * Sort by status query. */ sortByStatus(query, order) { - query.orderByRaw(`PUBLISHED_AT IS NULL ${order}`); + const dir = sanitizeSortDirection(order); + query.orderByRaw(`PUBLISHED_AT IS NULL ${dir}`); }, /** diff --git a/packages/server/src/modules/ManualJournals/queries/GetManualJournals.service.ts b/packages/server/src/modules/ManualJournals/queries/GetManualJournals.service.ts index e540f2280..3d35cdc34 100644 --- a/packages/server/src/modules/ManualJournals/queries/GetManualJournals.service.ts +++ b/packages/server/src/modules/ManualJournals/queries/GetManualJournals.service.ts @@ -5,7 +5,7 @@ import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectab import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service'; import { ManualJournal } from '../models/ManualJournal'; import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model'; -import { IManualJournalsFilter } from '../types/ManualJournals.types'; +import { GetManualJournalsQueryDto } from '../dtos/GetManualJournalsQuery.dto'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @Injectable() @@ -28,10 +28,10 @@ export class GetManualJournals { /** * Retrieve manual journals datatable list. - * @param {IManualJournalsFilter} filter - + * @param {GetManualJournalsQueryDto} filter - */ public getManualJournals = async ( - filterDTO: Partial, + filterDTO: GetManualJournalsQueryDto, ): Promise<{ manualJournals: ManualJournal[]; pagination: IPaginationMeta; diff --git a/packages/server/src/modules/Miscellaneous/Miscellaneous.controller.ts b/packages/server/src/modules/Miscellaneous/Miscellaneous.controller.ts index d409e80a5..ab42e603a 100644 --- a/packages/server/src/modules/Miscellaneous/Miscellaneous.controller.ts +++ b/packages/server/src/modules/Miscellaneous/Miscellaneous.controller.ts @@ -1,13 +1,22 @@ import { Controller, Get } from '@nestjs/common'; import { GetDateFormatsService } from './queries/GetDateFormats.service'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiExtraModels, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; +import { DateFormatResponseDto } from './dtos/DateFormatResponse.dto'; @Controller('/') @ApiTags('misc') +@ApiExtraModels(DateFormatResponseDto) export class MiscellaneousController { constructor(private readonly getDateFormatsSevice: GetDateFormatsService) {} @Get('/date-formats') + @ApiResponse({ + status: 200, + schema: { + type: 'array', + items: { $ref: getSchemaPath(DateFormatResponseDto) }, + }, + }) getDateFormats() { return this.getDateFormatsSevice.getDateFormats(); } diff --git a/packages/server/src/modules/Miscellaneous/dtos/DateFormatResponse.dto.ts b/packages/server/src/modules/Miscellaneous/dtos/DateFormatResponse.dto.ts new file mode 100644 index 000000000..93af1eaf3 --- /dev/null +++ b/packages/server/src/modules/Miscellaneous/dtos/DateFormatResponse.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class DateFormatResponseDto { + @ApiProperty({ example: '03/09/2026 [MM/DD/YYYY]' }) + label: string; + + @ApiProperty({ example: 'MM/DD/YYYY' }) + key: string; +} diff --git a/packages/server/src/modules/Organization/Organization.constants.ts b/packages/server/src/modules/Organization/Organization.constants.ts index a1c0bd6f7..e3252fa58 100644 --- a/packages/server/src/modules/Organization/Organization.constants.ts +++ b/packages/server/src/modules/Organization/Organization.constants.ts @@ -1,18 +1,15 @@ import currencies from 'js-money/lib/currency'; export const DATE_FORMATS = [ - 'MM.dd.yy', - 'dd.MM.yy', - 'yy.MM.dd', - 'MM.dd.yyyy', - 'dd.MM.yyyy', - 'yyyy.MM.dd', - 'MM/DD/YYYY', - 'M/D/YYYY', - 'dd MMM YYYY', - 'dd MMMM YYYY', - 'MMMM dd, YYYY', - 'EEE, MMMM dd, YYYY', + 'MM/DD/YY', + 'DD/MM/YY', + 'YY/MM/DD', + 'MM/DD/yyyy', + 'DD/MM/yyyy', + 'yyyy/MM/DD', + 'DD MMM YYYY', + 'DD MMMM YYYY', + 'MMMM DD, YYYY', ]; export const MONTHS = [ 'january', diff --git a/packages/server/src/modules/Organization/Organization.controller.ts b/packages/server/src/modules/Organization/Organization.controller.ts index 529065e80..b9b3fe4ea 100644 --- a/packages/server/src/modules/Organization/Organization.controller.ts +++ b/packages/server/src/modules/Organization/Organization.controller.ts @@ -17,6 +17,7 @@ import { HttpCode, Param, } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { BuildOrganizationService } from './commands/BuildOrganization.service'; import { BuildOrganizationDto, @@ -27,6 +28,7 @@ import { UpdateOrganizationService } from './commands/UpdateOrganization.service import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard'; import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards'; import { IgnoreTenantModelsInitialize } from '../Tenancy/TenancyInitializeModels.guard'; +import { IgnoreUserVerifiedRoute } from '../Auth/guards/EnsureUserVerified.guard'; import { GetBuildOrganizationBuildJob } from './commands/GetBuildOrganizationJob.service'; import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBaseCurrencyLocking.service'; import { @@ -34,6 +36,7 @@ import { OrganizationBuiltResponseExample, } from './Organization.swagger'; import { GetCurrentOrganizationResponseDto } from './dtos/GetCurrentOrganizationResponse.dto'; +import { OrganizationBuildJobResponseDto } from './dtos/OrganizationBuildJobResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @ApiTags('Organization') @@ -42,6 +45,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @IgnoreTenantSeededRoute() @IgnoreTenantModelsInitialize() @ApiExtraModels(GetCurrentOrganizationResponseDto) +@ApiExtraModels(OrganizationBuildJobResponseDto) @ApiCommonHeaders() export class OrganizationController { constructor( @@ -50,7 +54,7 @@ export class OrganizationController { private readonly updateOrganizationService: UpdateOrganizationService, private readonly getBuildOrganizationJobService: GetBuildOrganizationBuildJob, private readonly orgBaseCurrencyLockingService: OrganizationBaseCurrencyLocking, - ) { } + ) {} @Post('build') @HttpCode(200) @@ -77,6 +81,7 @@ export class OrganizationController { } @Get('build/:buildJobId') + @Throttle({ default: { limit: 300, ttl: 60000 } }) // 300 req/min @ApiParam({ name: 'buildJobId', required: true, @@ -85,12 +90,20 @@ export class OrganizationController { }) @HttpCode(200) @ApiOperation({ summary: 'Gets the organization build job details' }) + @ApiResponse({ + status: 200, + description: 'Returns the organization build job details', + schema: { + $ref: getSchemaPath(OrganizationBuildJobResponseDto), + }, + }) async buildJob(@Param('buildJobId') buildJobId: string) { return this.getBuildOrganizationJobService.getJobDetails(buildJobId); } @Get('current') @HttpCode(200) + @IgnoreUserVerifiedRoute() @ApiOperation({ summary: 'Get current organization' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/Organization/Organization.module.ts b/packages/server/src/modules/Organization/Organization.module.ts index 9da36b41b..0973ed5e5 100644 --- a/packages/server/src/modules/Organization/Organization.module.ts +++ b/packages/server/src/modules/Organization/Organization.module.ts @@ -15,6 +15,8 @@ import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBase import { SyncSystemUserToTenantService } from './commands/SyncSystemUserToTenant.service'; import { SyncSystemUserToTenantSubscriber } from './subscribers/SyncSystemUserToTenant.subscriber'; import { GetBuildOrganizationBuildJob } from './commands/GetBuildOrganizationJob.service'; +import { AttachmentsModule } from '../Attachments/Attachment.module'; +import { TransformerModule } from '../Transformer/Transformer.module'; @Module({ providers: [ @@ -36,6 +38,8 @@ import { GetBuildOrganizationBuildJob } from './commands/GetBuildOrganizationJob adapter: BullMQAdapter, }), TenantDBManagerModule, + AttachmentsModule, + TransformerModule, ], controllers: [OrganizationController], }) diff --git a/packages/server/src/modules/Organization/Organization.utils.ts b/packages/server/src/modules/Organization/Organization.utils.ts index 780318a08..1e2469fd2 100644 --- a/packages/server/src/modules/Organization/Organization.utils.ts +++ b/packages/server/src/modules/Organization/Organization.utils.ts @@ -12,6 +12,6 @@ export const transformBuildDto = ( ): BuildOrganizationDto => { return { ...buildDTO, - dateFormat: defaultTo(buildDTO.dateFormat, 'DD MMM yyyy'), + dateFormat: defaultTo(buildDTO.dateFormat, 'DD MMM YYYY'), }; }; diff --git a/packages/server/src/modules/Organization/dtos/GetCurrentOrganizationResponse.dto.ts b/packages/server/src/modules/Organization/dtos/GetCurrentOrganizationResponse.dto.ts index e30b42eba..affff45db 100644 --- a/packages/server/src/modules/Organization/dtos/GetCurrentOrganizationResponse.dto.ts +++ b/packages/server/src/modules/Organization/dtos/GetCurrentOrganizationResponse.dto.ts @@ -79,6 +79,13 @@ export class OrganizationMetadataResponseDto { }) logoKey: string; + @ApiPropertyOptional({ + description: 'Logo URL (presigned or public) for display', + example: 'https://...', + nullable: true, + }) + logoUri: string; + @ApiPropertyOptional({ description: 'Organization address details', example: '123 Main St, New York, NY', diff --git a/packages/server/src/modules/Organization/dtos/OrganizationBuildJobResponse.dto.ts b/packages/server/src/modules/Organization/dtos/OrganizationBuildJobResponse.dto.ts new file mode 100644 index 000000000..21b4857a0 --- /dev/null +++ b/packages/server/src/modules/Organization/dtos/OrganizationBuildJobResponse.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class OrganizationBuildJobResponseDto { + @ApiProperty({ example: '123' }) + id: string; + + @ApiProperty({ example: 'active' }) + state: string; + + @ApiProperty({ example: 50 }) + progress: number | Record; + + @ApiProperty({ example: false }) + isCompleted: boolean; + + @ApiProperty({ example: true }) + isRunning: boolean; + + @ApiProperty({ example: false }) + isWaiting: boolean; + + @ApiProperty({ example: false }) + isFailed: boolean; +} diff --git a/packages/server/src/modules/Organization/queries/GetCurrentOrganization.service.ts b/packages/server/src/modules/Organization/queries/GetCurrentOrganization.service.ts index f23909dd2..1cc3fb99f 100644 --- a/packages/server/src/modules/Organization/queries/GetCurrentOrganization.service.ts +++ b/packages/server/src/modules/Organization/queries/GetCurrentOrganization.service.ts @@ -3,14 +3,20 @@ import { throwIfTenantNotExists } from '../Organization/_utils'; import { TenantModel } from '@/modules/System/models/TenantModel'; import { Injectable } from '@nestjs/common'; import { ModelObject } from 'objection'; +import { GetAttachmentPresignedUrl } from '@/modules/Attachments/GetAttachmentPresignedUrl'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { GetCurrentOrganizationTransformer } from './GetCurrentOrganization.transformer'; @Injectable() export class GetCurrentOrganizationService { - constructor(private readonly tenancyContext: TenancyContext) {} + constructor( + private readonly tenancyContext: TenancyContext, + private readonly getPresignedUrlService: GetAttachmentPresignedUrl, + private readonly transformer: TransformerInjectable, + ) {} /** * Retrieve the current organization metadata. - * @param {number} tenantId * @returns {Promise} */ async getCurrentOrganization(): Promise> { @@ -21,6 +27,13 @@ export class GetCurrentOrganizationService { throwIfTenantNotExists(tenant); - return tenant; + const logoUri = tenant.metadata?.logoKey ? + await this.getPresignedUrlService.getPresignedUrl(tenant.metadata.logoKey) : null; + + return await this.transformer.transform( + tenant, + new GetCurrentOrganizationTransformer(), + { logoUri }, + ); } } diff --git a/packages/server/src/modules/Organization/queries/GetCurrentOrganization.transformer.ts b/packages/server/src/modules/Organization/queries/GetCurrentOrganization.transformer.ts new file mode 100644 index 000000000..67771d1cc --- /dev/null +++ b/packages/server/src/modules/Organization/queries/GetCurrentOrganization.transformer.ts @@ -0,0 +1,21 @@ +import { Transformer } from '@/modules/Transformer/Transformer'; +import { GetCurrentOrganizationMetadataTransformer } from './GetCurrentOrganizationMetadata.transformer'; + +export class GetCurrentOrganizationTransformer extends Transformer { + /** + * Transforms the tenant/organization for the current-organization response. + * Delegates metadata transformation to GetCurrentOrganizationMetadataTransformer + * and injects the pre-computed logoUri from options. + */ + transform = (tenant: Record) => { + const metadataTransformer = new GetCurrentOrganizationMetadataTransformer(); + const transformedMetadata = this.item(tenant.metadata, metadataTransformer, { + logoUri: this.options?.logoUri, + }); + + return { + ...tenant, + metadata: transformedMetadata, + }; + }; +} diff --git a/packages/server/src/modules/Organization/queries/GetCurrentOrganizationMetadata.transformer.ts b/packages/server/src/modules/Organization/queries/GetCurrentOrganizationMetadata.transformer.ts new file mode 100644 index 000000000..77b5f2ade --- /dev/null +++ b/packages/server/src/modules/Organization/queries/GetCurrentOrganizationMetadata.transformer.ts @@ -0,0 +1,21 @@ +import { Transformer } from '@/modules/Transformer/Transformer'; + +export class GetCurrentOrganizationMetadataTransformer extends Transformer { + /** + * Include these attributes in the metadata response. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return ['logoUri']; + }; + + /** + * Logo URI (presigned or public URL) for display. + * Provided via options from the service after resolving logoKey. + * @param metadata + * @returns {string | null} + */ + public logoUri = (metadata: Record): string | null => { + return this.options?.logoUri ?? null; + }; +} diff --git a/packages/server/src/modules/PaymentLinks/PaymentLinks.controller.ts b/packages/server/src/modules/PaymentLinks/PaymentLinks.controller.ts index 6be5bfb0b..4acf9d220 100644 --- a/packages/server/src/modules/PaymentLinks/PaymentLinks.controller.ts +++ b/packages/server/src/modules/PaymentLinks/PaymentLinks.controller.ts @@ -3,6 +3,10 @@ import { Controller, Get, Param, Post, Res } from '@nestjs/common'; import { PaymentLinksApplication } from './PaymentLinksApplication'; import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { + GetInvoicePaymentLinkResponseWrapperDto, +} from './dtos/GetInvoicePaymentLinkResponse.dto'; +import { CreateStripeCheckoutSessionResponseDto } from './dtos/CreateStripeCheckoutSessionResponse.dto'; @Controller('payment-links') @ApiTags('Payment Links') @@ -24,12 +28,7 @@ export class PaymentLinksController { @ApiResponse({ status: 200, description: 'Successfully retrieved payment link metadata', - schema: { - type: 'object', - properties: { - data: { type: 'object', description: 'Payment link metadata' }, - }, - }, + type: GetInvoicePaymentLinkResponseWrapperDto, }) @ApiResponse({ status: 404, description: 'Payment link not found' }) public async getPaymentLinkPublicMeta( @@ -55,19 +54,7 @@ export class PaymentLinksController { @ApiResponse({ status: 200, description: 'Successfully created Stripe checkout session', - schema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Stripe checkout session ID', - }, - url: { - type: 'string', - description: 'Stripe checkout session URL', - }, - }, - }, + type: CreateStripeCheckoutSessionResponseDto, }) @ApiResponse({ status: 404, description: 'Payment link not found' }) public async createInvoicePaymentLinkCheckoutSession( diff --git a/packages/server/src/modules/PaymentLinks/dtos/CreateStripeCheckoutSessionResponse.dto.ts b/packages/server/src/modules/PaymentLinks/dtos/CreateStripeCheckoutSessionResponse.dto.ts new file mode 100644 index 000000000..16a2b316a --- /dev/null +++ b/packages/server/src/modules/PaymentLinks/dtos/CreateStripeCheckoutSessionResponse.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateStripeCheckoutSessionResponseDto { + @ApiProperty({ + description: 'Stripe checkout session ID', + example: 'cs_test_xxx', + }) + sessionId: string; + + @ApiProperty({ + description: 'Stripe publishable key for the client', + example: 'pk_test_xxx', + }) + publishableKey: string; + + @ApiProperty({ + description: 'URL to redirect the customer to complete checkout', + example: 'https://checkout.stripe.com/c/pay/cs_test_xxx', + }) + redirectTo: string; +} diff --git a/packages/server/src/modules/PaymentLinks/dtos/GetInvoicePaymentLinkResponse.dto.ts b/packages/server/src/modules/PaymentLinks/dtos/GetInvoicePaymentLinkResponse.dto.ts new file mode 100644 index 000000000..37328a239 --- /dev/null +++ b/packages/server/src/modules/PaymentLinks/dtos/GetInvoicePaymentLinkResponse.dto.ts @@ -0,0 +1,171 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PaymentLinkAddressDto { + @ApiProperty({ description: 'Address line 1' }) + address_1: string; + + @ApiProperty({ description: 'Address line 2' }) + address_2: string; + + @ApiProperty({ description: 'Postal code' }) + postal_code: string; + + @ApiProperty({ description: 'City' }) + city: string; + + @ApiProperty({ description: 'State or province' }) + state_province: string; + + @ApiProperty({ description: 'Phone number' }) + phone: string; +} + +export class PaymentLinkOrganizationDto { + @ApiProperty({ type: 'object', description: 'Organization address' }) + address: Record; + + @ApiProperty({ description: 'Organization name' }) + name: string; + + @ApiProperty({ description: 'Primary brand color' }) + primaryColor: string; + + @ApiProperty({ description: 'Logo URI' }) + logoUri: string; + + @ApiProperty({ description: 'Formatted address text' }) + addressTextFormatted: string; +} + +export class PaymentLinkEntryDto { + @ApiProperty({ description: 'Line item description' }) + description: string; + + @ApiProperty({ description: 'Item name' }) + itemName: string; + + @ApiProperty({ description: 'Quantity' }) + quantity: number; + + @ApiProperty({ description: 'Formatted quantity' }) + quantityFormatted: string; + + @ApiProperty({ description: 'Unit rate' }) + rate: number; + + @ApiProperty({ description: 'Formatted rate' }) + rateFormatted: string; + + @ApiProperty({ description: 'Line total' }) + total: number; + + @ApiProperty({ description: 'Formatted total' }) + totalFormatted: string; +} + +export class PaymentLinkTaxEntryDto { + @ApiProperty({ description: 'Tax name' }) + name: string; + + @ApiProperty({ description: 'Tax rate amount' }) + taxRateAmount: number; + + @ApiProperty({ description: 'Formatted tax rate amount' }) + taxRateAmountFormatted: string; + + @ApiProperty({ description: 'Tax rate code' }) + taxRateCode: string; +} + +export class PaymentLinkBrandingTemplateDto { + @ApiProperty({ description: 'Company logo URI' }) + companyLogoUri: string; + + @ApiProperty({ description: 'Primary color' }) + primaryColor: string; + + @ApiProperty({ description: 'Secondary color', required: false }) + secondaryColor?: string; +} + +export class GetInvoicePaymentLinkResponseDto { + @ApiProperty({ description: 'Amount due' }) + dueAmount: number; + + @ApiProperty({ description: 'Formatted amount due' }) + dueAmountFormatted: string; + + @ApiProperty({ description: 'Due date' }) + dueDate: string; + + @ApiProperty({ description: 'Formatted due date' }) + dueDateFormatted: string; + + @ApiProperty({ description: 'Formatted invoice date' }) + invoiceDateFormatted: string; + + @ApiProperty({ description: 'Invoice number' }) + invoiceNo: string; + + @ApiProperty({ description: 'Payment amount' }) + paymentAmount: number; + + @ApiProperty({ description: 'Formatted payment amount' }) + paymentAmountFormatted: string; + + @ApiProperty({ description: 'Subtotal' }) + subtotal: number; + + @ApiProperty({ description: 'Formatted subtotal' }) + subtotalFormatted: string; + + @ApiProperty({ description: 'Formatted subtotal in local currency' }) + subtotalLocalFormatted: string; + + @ApiProperty({ description: 'Total amount' }) + total: number; + + @ApiProperty({ description: 'Formatted total' }) + totalFormatted: string; + + @ApiProperty({ description: 'Formatted total in local currency' }) + totalLocalFormatted: string; + + @ApiProperty({ description: 'Customer name' }) + customerName: string; + + @ApiProperty({ description: 'Invoice message' }) + invoiceMessage: string; + + @ApiProperty({ description: 'Terms and conditions' }) + termsConditions: string; + + @ApiProperty({ type: [PaymentLinkEntryDto], description: 'Invoice line entries' }) + entries: PaymentLinkEntryDto[]; + + @ApiProperty({ type: [PaymentLinkTaxEntryDto], description: 'Tax entries' }) + taxes: PaymentLinkTaxEntryDto[]; + + @ApiProperty({ type: PaymentLinkBrandingTemplateDto, description: 'Branding template' }) + brandingTemplate: PaymentLinkBrandingTemplateDto; + + @ApiProperty({ type: PaymentLinkOrganizationDto, description: 'Organization metadata' }) + organization: PaymentLinkOrganizationDto; + + @ApiProperty({ description: 'Whether Stripe is available as payment method' }) + hasStripePaymentMethod: boolean; + + @ApiProperty({ description: 'Whether invoice has receivable balance' }) + isReceivable: boolean; + + @ApiProperty({ description: 'Formatted customer address' }) + formattedCustomerAddress: string; +} + +export class GetInvoicePaymentLinkResponseWrapperDto { + @ApiProperty({ + type: GetInvoicePaymentLinkResponseDto, + description: 'Payment link invoice metadata', + }) + data: GetInvoicePaymentLinkResponseDto; +} diff --git a/packages/server/src/modules/PaymentReceived/PaymentReceived.application.ts b/packages/server/src/modules/PaymentReceived/PaymentReceived.application.ts index 32780174d..bcd56dc86 100644 --- a/packages/server/src/modules/PaymentReceived/PaymentReceived.application.ts +++ b/packages/server/src/modules/PaymentReceived/PaymentReceived.application.ts @@ -1,7 +1,5 @@ -import { - IPaymentsReceivedFilter, - PaymentReceiveMailOptsDTO, -} from './types/PaymentReceived.types'; +import { PaymentReceiveMailOptsDTO } from './types/PaymentReceived.types'; +import { GetPaymentsReceivedQueryDto } from './dtos/GetPaymentsReceivedQuery.dto'; import { Injectable } from '@nestjs/common'; import { CreatePaymentReceivedService } from './commands/CreatePaymentReceived.serivce'; import { EditPaymentReceivedService } from './commands/EditPaymentReceived.service'; @@ -103,11 +101,11 @@ export class PaymentReceivesApplication { /** * Retrieve payment receives paginated and filterable. * @param {number} tenantId - * @param {IPaymentsReceivedFilter} filterDTO + * @param {GetPaymentsReceivedQueryDto} filterDTO * @returns */ public async getPaymentsReceived( - filterDTO: Partial, + filterDTO: GetPaymentsReceivedQueryDto, ) { return this.getPaymentsReceivedService.getPaymentReceives(filterDTO); } diff --git a/packages/server/src/modules/PaymentReceived/PaymentsReceived.controller.ts b/packages/server/src/modules/PaymentReceived/PaymentsReceived.controller.ts index cac29ebef..d017d55f6 100644 --- a/packages/server/src/modules/PaymentReceived/PaymentsReceived.controller.ts +++ b/packages/server/src/modules/PaymentReceived/PaymentsReceived.controller.ts @@ -19,12 +19,13 @@ import { Put, Query, Res, + UseGuards, } from '@nestjs/common'; import { PaymentReceivesApplication } from './PaymentReceived.application'; import { - IPaymentsReceivedFilter, PaymentReceiveMailOptsDTO, } from './types/PaymentReceived.types'; +import { GetPaymentsReceivedQueryDto } from './dtos/GetPaymentsReceivedQuery.dto'; import { CreatePaymentReceivedDto, EditPaymentReceivedDto, @@ -38,6 +39,11 @@ import { BulkDeleteDto, ValidateBulkDeleteResponseDto, } from '@/common/dtos/BulkDelete.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { PaymentReceiveAction } from './types/PaymentReceived.types'; @Controller('payments-received') @ApiTags('Payments Received') @@ -46,6 +52,7 @@ import { @ApiExtraModels(PaymentReceivedStateResponseDto) @ApiExtraModels(ValidateBulkDeleteResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class PaymentReceivesController { constructor(private paymentReceivesApplication: PaymentReceivesApplication) { } @@ -94,6 +101,7 @@ export class PaymentReceivesController { } @Post() + @RequirePermission(PaymentReceiveAction.Create, AbilitySubject.PaymentReceive) @ApiOperation({ summary: 'Create a new payment received.' }) public createPaymentReceived( @Body() paymentReceiveDTO: CreatePaymentReceivedDto, @@ -104,6 +112,7 @@ export class PaymentReceivesController { } @Put(':id') + @RequirePermission(PaymentReceiveAction.Edit, AbilitySubject.PaymentReceive) @ApiOperation({ summary: 'Edit the given payment received.' }) public editPaymentReceive( @Param('id', ParseIntPipe) paymentReceiveId: number, @@ -116,6 +125,7 @@ export class PaymentReceivesController { } @Delete(':id') + @RequirePermission(PaymentReceiveAction.Delete, AbilitySubject.PaymentReceive) @ApiOperation({ summary: 'Delete the given payment received.' }) public deletePaymentReceive( @Param('id', ParseIntPipe) paymentReceiveId: number, @@ -126,6 +136,7 @@ export class PaymentReceivesController { } @Get() + @RequirePermission(PaymentReceiveAction.View, AbilitySubject.PaymentReceive) @ApiOperation({ summary: 'Retrieves the payment received list.' }) @ApiResponse({ status: 200, @@ -145,12 +156,13 @@ export class PaymentReceivesController { }, }) public getPaymentsReceived( - @Query() filterDTO: Partial, + @Query() filterDTO: GetPaymentsReceivedQueryDto, ) { return this.paymentReceivesApplication.getPaymentsReceived(filterDTO); } @Post('validate-bulk-delete') + @RequirePermission(PaymentReceiveAction.Delete, AbilitySubject.PaymentReceive) @ApiOperation({ summary: 'Validates which payments received can be deleted and returns the results.', @@ -172,6 +184,7 @@ export class PaymentReceivesController { } @Post('bulk-delete') + @RequirePermission(PaymentReceiveAction.Delete, AbilitySubject.PaymentReceive) @ApiOperation({ summary: 'Deletes multiple payments received.' }) @ApiResponse({ status: 200, @@ -187,6 +200,7 @@ export class PaymentReceivesController { } @Get('state') + @RequirePermission(PaymentReceiveAction.View, AbilitySubject.PaymentReceive) @ApiOperation({ summary: 'Retrieves the payment received state.' }) @ApiResponse({ status: 200, @@ -200,6 +214,7 @@ export class PaymentReceivesController { } @Get(':id/invoices') + @RequirePermission(PaymentReceiveAction.View, AbilitySubject.PaymentReceive) @ApiOperation({ summary: 'Retrieves the payment received invoices.' }) @ApiResponse({ status: 200, @@ -215,6 +230,7 @@ export class PaymentReceivesController { } @Get(':id') + @RequirePermission(PaymentReceiveAction.View, AbilitySubject.PaymentReceive) @ApiOperation({ summary: 'Retrieves the payment received details.' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts b/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts index 2749541a8..e087e027e 100644 --- a/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts +++ b/packages/server/src/modules/PaymentReceived/PaymentsReceived.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { BullBoardModule } from '@bull-board/nestjs'; -import { BullAdapter } from '@bull-board/api/bullAdapter'; -import { BullModule } from '@nestjs/bull'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { BullModule } from '@nestjs/bullmq'; import { PaymentReceivesController } from './PaymentsReceived.controller'; import { PaymentReceivesApplication } from './PaymentReceived.application'; import { CreatePaymentReceivedService } from './commands/CreatePaymentReceived.serivce'; @@ -99,7 +99,7 @@ import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePa BullModule.registerQueue({ name: SEND_PAYMENT_RECEIVED_MAIL_QUEUE }), BullBoardModule.forFeature({ name: SEND_PAYMENT_RECEIVED_MAIL_QUEUE, - adapter: BullAdapter, + adapter: BullMQAdapter, }), ], }) diff --git a/packages/server/src/modules/PaymentReceived/dtos/GetPaymentsReceivedQuery.dto.ts b/packages/server/src/modules/PaymentReceived/dtos/GetPaymentsReceivedQuery.dto.ts new file mode 100644 index 000000000..49d030dd4 --- /dev/null +++ b/packages/server/src/modules/PaymentReceived/dtos/GetPaymentsReceivedQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetPaymentsReceivedQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/PaymentReceived/dtos/PaymentReceived.dto.ts b/packages/server/src/modules/PaymentReceived/dtos/PaymentReceived.dto.ts index cb7bbdc05..0b57ad24f 100644 --- a/packages/server/src/modules/PaymentReceived/dtos/PaymentReceived.dto.ts +++ b/packages/server/src/modules/PaymentReceived/dtos/PaymentReceived.dto.ts @@ -35,7 +35,7 @@ export class PaymentReceivedEntryDto { invoiceId: number; @ToNumber() - @IsInt() + @IsNumber() @IsNotEmpty() paymentAmount: number; } diff --git a/packages/server/src/modules/PaymentReceived/models/PaymentReceived.ts b/packages/server/src/modules/PaymentReceived/models/PaymentReceived.ts index 55edd254e..7b114423f 100644 --- a/packages/server/src/modules/PaymentReceived/models/PaymentReceived.ts +++ b/packages/server/src/modules/PaymentReceived/models/PaymentReceived.ts @@ -1,5 +1,6 @@ import { Model } from 'objection'; import { PaymentReceivedEntry } from './PaymentReceivedEntry'; +import { Document } from '@/modules/ChromiumlyTenancy/models/Document'; import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator'; import { ImportableModel } from '@/modules/Import/decorators/Import.decorator'; @@ -7,7 +8,9 @@ import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/Inje import { PaymentReceivedMeta } from './PaymentReceived.meta'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { PaymentReceivedDefaultViews } from '../constants'; +import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; +@InjectAttachable() @ExportableModel() @ImportableModel() @InjectModelMeta(PaymentReceivedMeta) @@ -31,6 +34,7 @@ export class PaymentReceived extends TenantBaseModel { updatedAt: string; entries?: PaymentReceivedEntry[]; + public attachments!: Document[]; /** * Table name. @@ -43,7 +47,7 @@ export class PaymentReceived extends TenantBaseModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** @@ -159,7 +163,7 @@ export class PaymentReceived extends TenantBaseModel { to: 'documents.id', }, filter(query) { - query.where('model_ref', 'PaymentReceive'); + query.where('model_ref', 'PaymentReceived'); }, }, diff --git a/packages/server/src/modules/PaymentReceived/processors/PaymentReceivedMailNotification.processor.ts b/packages/server/src/modules/PaymentReceived/processors/PaymentReceivedMailNotification.processor.ts index 3337529a3..8fef3a666 100644 --- a/packages/server/src/modules/PaymentReceived/processors/PaymentReceivedMailNotification.processor.ts +++ b/packages/server/src/modules/PaymentReceived/processors/PaymentReceivedMailNotification.processor.ts @@ -1,6 +1,6 @@ -import { JOB_REF, Process, Processor } from '@nestjs/bull'; -import { Job } from 'bull'; -import { Inject, Scope } from '@nestjs/common'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { Scope } from '@nestjs/common'; import { ClsService, UseCls } from 'nestjs-cls'; import { SEND_PAYMENT_RECEIVED_MAIL_JOB, @@ -13,20 +13,18 @@ import { SendPaymentReceivedMailPayload } from '../types/PaymentReceived.types'; name: SEND_PAYMENT_RECEIVED_MAIL_QUEUE, scope: Scope.REQUEST, }) -export class SendPaymentReceivedMailProcessor { +export class SendPaymentReceivedMailProcessor extends WorkerHost { constructor( private readonly sendPaymentReceivedMail: SendPaymentReceiveMailNotification, private readonly clsService: ClsService, + ) { + super(); + } - @Inject(JOB_REF) - private readonly jobRef: Job, - ) { } - - @Process(SEND_PAYMENT_RECEIVED_MAIL_JOB) @UseCls() - async handleSendMail() { + async process(job: Job) { const { messageOptions, paymentReceivedId, organizationId, userId } = - this.jobRef.data; + job.data; this.clsService.set('organizationId', organizationId); this.clsService.set('userId', userId); diff --git a/packages/server/src/modules/PaymentReceived/queries/GetPaymentReceived.service.ts b/packages/server/src/modules/PaymentReceived/queries/GetPaymentReceived.service.ts index 9f159becb..6da696b41 100644 --- a/packages/server/src/modules/PaymentReceived/queries/GetPaymentReceived.service.ts +++ b/packages/server/src/modules/PaymentReceived/queries/GetPaymentReceived.service.ts @@ -32,6 +32,7 @@ export class GetPaymentReceivedService { .withGraphFetched('entries.invoice') .withGraphFetched('transactions') .withGraphFetched('branch') + .withGraphFetched('attachments') .findById(paymentReceiveId); if (!paymentReceive) { diff --git a/packages/server/src/modules/PaymentReceived/queries/GetPaymentsReceived.service.ts b/packages/server/src/modules/PaymentReceived/queries/GetPaymentsReceived.service.ts index 11d5f51ac..eb86caf16 100644 --- a/packages/server/src/modules/PaymentReceived/queries/GetPaymentsReceived.service.ts +++ b/packages/server/src/modules/PaymentReceived/queries/GetPaymentsReceived.service.ts @@ -5,7 +5,7 @@ import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectab import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service'; import { PaymentReceived } from '../models/PaymentReceived'; import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model'; -import { IPaymentsReceivedFilter } from '../types/PaymentReceived.types'; +import { GetPaymentsReceivedQueryDto } from '../dtos/GetPaymentsReceivedQuery.dto'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @Injectable() @@ -22,10 +22,10 @@ export class GetPaymentsReceivedService { /** * Retrieve payment receives paginated and filterable list. - * @param {IPaymentsReceivedFilter} filterDTO + * @param {GetPaymentsReceivedQueryDto} filterDTO */ public async getPaymentReceives( - filterDTO: Partial, + filterDTO: GetPaymentsReceivedQueryDto, ): Promise<{ paymentReceives: PaymentReceived[]; pagination: IPaginationMeta; diff --git a/packages/server/src/modules/PaymentReceived/queries/PaymentReceivedTransformer.ts b/packages/server/src/modules/PaymentReceived/queries/PaymentReceivedTransformer.ts index 7b7a121d9..427a4c634 100644 --- a/packages/server/src/modules/PaymentReceived/queries/PaymentReceivedTransformer.ts +++ b/packages/server/src/modules/PaymentReceived/queries/PaymentReceivedTransformer.ts @@ -2,6 +2,7 @@ import { Transformer } from '../../Transformer/Transformer'; import { PaymentReceived } from '../models/PaymentReceived'; import { PaymentReceivedEntry } from '../models/PaymentReceivedEntry'; import { PaymentReceivedEntryTransfromer } from './PaymentReceivedEntryTransformer'; +import { AttachmentTransformer } from '@/modules/Attachments/Attachment.transformer'; export class PaymentReceiveTransfromer extends Transformer { /** @@ -17,6 +18,7 @@ export class PaymentReceiveTransfromer extends Transformer { 'formattedAmount', 'formattedExchangeRate', 'entries', + 'attachments', ]; }; @@ -89,4 +91,13 @@ export class PaymentReceiveTransfromer extends Transformer { protected entries = (payment: PaymentReceived): PaymentReceivedEntry[] => { return this.item(payment.entries, new PaymentReceivedEntryTransfromer()); }; + + /** + * Retrieves the payment received attachments. + * @param {PaymentReceived} payment + * @returns + */ + protected attachments = (payment: PaymentReceived) => { + return this.item(payment.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/modules/PdfTemplate/PdfTemplates.module.ts b/packages/server/src/modules/PdfTemplate/PdfTemplates.module.ts index 698daa646..81df06a8a 100644 --- a/packages/server/src/modules/PdfTemplate/PdfTemplates.module.ts +++ b/packages/server/src/modules/PdfTemplate/PdfTemplates.module.ts @@ -13,6 +13,7 @@ import { BrandingTemplateDTOTransformer } from './BrandingTemplateDTOTransformer import { GetOrganizationBrandingAttributesService } from './queries/GetOrganizationBrandingAttributes.service'; import { GetPdfTemplates } from './queries/GetPdfTemplates.service'; import { GetPdfTemplateBrandingState } from './queries/GetPdfTemplateBrandingState.service'; +import { AttachmentsModule } from '../Attachments/Attachment.module'; @Module({ exports: [ @@ -20,7 +21,7 @@ import { GetPdfTemplateBrandingState } from './queries/GetPdfTemplateBrandingSta BrandingTemplateDTOTransformer, GetOrganizationBrandingAttributesService, ], - imports: [TenancyDatabaseModule], + imports: [TenancyDatabaseModule, AttachmentsModule], controllers: [PdfTemplatesController], providers: [ PdfTemplateApplication, diff --git a/packages/server/src/modules/PdfTemplate/models/PdfTemplate.ts b/packages/server/src/modules/PdfTemplate/models/PdfTemplate.ts index 718208934..1478cf8df 100644 --- a/packages/server/src/modules/PdfTemplate/models/PdfTemplate.ts +++ b/packages/server/src/modules/PdfTemplate/models/PdfTemplate.ts @@ -56,16 +56,6 @@ export class PdfTemplateModel extends TenantBaseModel { return ['companyLogoUri']; } - /** - * Retrieves the company logo uri. - * @returns {string} - */ - // get companyLogoUri() { - // return this.attributes?.companyLogoKey - // ? getUploadedObjectUri(this.attributes.companyLogoKey) - // : ''; - // } - /** * Relationship mapping. */ diff --git a/packages/server/src/modules/PdfTemplate/queries/GetOrganizationBrandingAttributes.service.ts b/packages/server/src/modules/PdfTemplate/queries/GetOrganizationBrandingAttributes.service.ts index 36a282a2b..21895e2cd 100644 --- a/packages/server/src/modules/PdfTemplate/queries/GetOrganizationBrandingAttributes.service.ts +++ b/packages/server/src/modules/PdfTemplate/queries/GetOrganizationBrandingAttributes.service.ts @@ -1,10 +1,14 @@ import { Injectable } from '@nestjs/common'; import { CommonOrganizationBrandingAttributes } from '../types'; import { TenancyContext } from '../../Tenancy/TenancyContext.service'; +import { GetAttachmentPresignedUrl } from '@/modules/Attachments/GetAttachmentPresignedUrl'; @Injectable() export class GetOrganizationBrandingAttributesService { - constructor(private readonly tenancyContext: TenancyContext) {} + constructor( + private readonly tenancyContext: TenancyContext, + private readonly getPresignedUrlService: GetAttachmentPresignedUrl, + ) {} /** * Retrieves the given organization branding attributes initial state. @@ -17,13 +21,22 @@ export class GetOrganizationBrandingAttributesService { const companyName = tenantMetadata?.name; const primaryColor = tenantMetadata?.primaryColor; const companyLogoKey = tenantMetadata?.logoKey; - const companyLogoUri = tenantMetadata?.logoUri; const companyAddress = tenantMetadata?.addressTextFormatted; + let companyLogoUri: string | null = null; + if (companyLogoKey) { + try { + companyLogoUri = + await this.getPresignedUrlService.getPresignedUrl(companyLogoKey); + } catch { + companyLogoUri = null; + } + } + return { companyName, companyAddress, - companyLogoUri, + companyLogoUri: companyLogoUri ?? undefined, companyLogoKey, primaryColor, }; diff --git a/packages/server/src/modules/PdfTemplate/queries/GetPdfTemplate.service.ts b/packages/server/src/modules/PdfTemplate/queries/GetPdfTemplate.service.ts index 990a04d52..b0042ea59 100644 --- a/packages/server/src/modules/PdfTemplate/queries/GetPdfTemplate.service.ts +++ b/packages/server/src/modules/PdfTemplate/queries/GetPdfTemplate.service.ts @@ -4,6 +4,7 @@ import { GetPdfTemplateTransformer } from './GetPdfTemplate.transformer'; import { PdfTemplateModel } from '../models/PdfTemplate'; import { TransformerInjectable } from '../../Transformer/TransformerInjectable.service'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { GetAttachmentPresignedUrl } from '@/modules/Attachments/GetAttachmentPresignedUrl'; @Injectable() export class GetPdfTemplateService { @@ -13,6 +14,7 @@ export class GetPdfTemplateService { typeof PdfTemplateModel >, private readonly transformer: TransformerInjectable, + private readonly getPresignedUrlService: GetAttachmentPresignedUrl, ) {} /** @@ -29,8 +31,19 @@ export class GetPdfTemplateService { .findById(templateId) .throwIfNotFound(); + const companyLogoKey = template.attributes?.companyLogoKey; + let companyLogoUri: string | null = null; + + if (companyLogoKey) { + try { + companyLogoUri = + await this.getPresignedUrlService.getPresignedUrl(companyLogoKey); + } catch { + companyLogoUri = null; + } + } return this.transformer.transform( - template, + { ...template, companyLogoUri }, new GetPdfTemplateTransformer(), ); } diff --git a/packages/server/src/modules/PdfTemplate/queries/GetPdfTemplate.transformer.ts b/packages/server/src/modules/PdfTemplate/queries/GetPdfTemplate.transformer.ts index 986c0d496..6d3ef895f 100644 --- a/packages/server/src/modules/PdfTemplate/queries/GetPdfTemplate.transformer.ts +++ b/packages/server/src/modules/PdfTemplate/queries/GetPdfTemplate.transformer.ts @@ -7,7 +7,7 @@ export class GetPdfTemplateTransformer extends Transformer { * @returns {string[]} */ public includeAttributes = (): string[] => { - return ['createdAtFormatted', 'resourceFormatted', 'attributes']; + return ['createdAtFormatted', 'resourceFormatted', 'attributes', 'companyLogoUri']; }; /** @@ -28,6 +28,15 @@ export class GetPdfTemplateTransformer extends Transformer { // return getTransactionTypeLabel(template.resource); }; + /** + * Retrieves the company logo URI. + * @param {Object} template + * @returns {string | null} + */ + protected companyLogoUri = (template) => { + return template.companyLogoUri; + }; + /** * Retrieves transformed brand attributes. * @param {} template diff --git a/packages/server/src/modules/Resource/Resource.controller.ts b/packages/server/src/modules/Resource/Resource.controller.ts index fbf389ebf..496197bb6 100644 --- a/packages/server/src/modules/Resource/Resource.controller.ts +++ b/packages/server/src/modules/Resource/Resource.controller.ts @@ -1,21 +1,41 @@ import { Controller, Get, Param } from '@nestjs/common'; -import { GetResourceViewsService } from '../Views/GetResourceViews.service'; import { ResourceService } from './ResourceService'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiExtraModels, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; +import { ResourceMetaResponseDto } from './dtos/ResourceMetaResponse.dto'; @Controller('resources') @ApiTags('resources') +@ApiExtraModels(ResourceMetaResponseDto) export class ResourceController { constructor(private readonly resourcesService: ResourceService) {} @Get('/:resourceModel/meta') - @ApiResponse({ status: 200, description: 'Retrieves the resource meta' }) @ApiOperation({ summary: 'Retrieves the resource meta' }) - getResourceMeta(@Param('resourceModel') resourceModel: string) { + @ApiParam({ + name: 'resourceModel', + description: 'The resource model name (e.g., SaleInvoice, Customer, Item)', + example: 'SaleInvoice', + required: true, + }) + @ApiResponse({ + status: 200, + description: 'Retrieves the resource meta', + schema: { + $ref: getSchemaPath(ResourceMetaResponseDto), + }, + }) + getResourceMeta( + @Param('resourceModel') resourceModel: string, + ): ResourceMetaResponseDto { const resourceMeta = this.resourcesService.getResourceMeta(resourceModel); - return { - resourceMeta, - }; + return resourceMeta as ResourceMetaResponseDto; } } diff --git a/packages/server/src/modules/Resource/dtos/ResourceMetaResponse.dto.ts b/packages/server/src/modules/Resource/dtos/ResourceMetaResponse.dto.ts new file mode 100644 index 000000000..ff9ecb882 --- /dev/null +++ b/packages/server/src/modules/Resource/dtos/ResourceMetaResponse.dto.ts @@ -0,0 +1,386 @@ +import { ApiProperty, getSchemaPath } from '@nestjs/swagger'; +import { IModelMetaDefaultSort } from '@/interfaces/Model'; + +export class ModelMetaDefaultSortDto implements IModelMetaDefaultSort { + @ApiProperty({ + description: 'The sort order', + example: 'DESC', + enum: ['DESC', 'ASC'], + }) + sortOrder: 'DESC' | 'ASC'; + + @ApiProperty({ + description: 'The sort field', + example: 'createdAt', + }) + sortField: string; +} + +export class ModelMetaEnumerationOptionDto { + @ApiProperty({ + description: 'The option key', + example: 'active', + }) + key: string; + + @ApiProperty({ + description: 'The option label', + example: 'Active', + }) + label: string; +} + +export class ModelMetaFieldCommonDto { + @ApiProperty({ + description: 'The field name', + example: 'Customer Name', + }) + name: string; + + @ApiProperty({ + description: 'The database column name', + example: 'customerName', + }) + column: string; + + @ApiProperty({ + description: 'Whether the field is columnable', + example: true, + required: false, + }) + columnable?: boolean; + + @ApiProperty({ + description: 'Whether the field is required', + example: true, + required: false, + }) + required?: boolean; + + @ApiProperty({ + description: 'The import hint for the field', + example: 'Enter the customer display name', + required: false, + }) + importHint?: string; + + @ApiProperty({ + description: 'The importable relation label', + example: 'displayName', + required: false, + }) + importableRelationLabel?: string; + + @ApiProperty({ + description: 'The field order', + example: 1, + required: false, + }) + order?: number; + + @ApiProperty({ + description: 'Whether the field is unique', + example: 1, + required: false, + }) + unique?: number; + + @ApiProperty({ + description: 'The data transfer object key', + example: 'customerDisplayName', + required: false, + }) + dataTransferObjectKey?: string; +} + +export class ModelMetaFieldTextDto extends ModelMetaFieldCommonDto { + @ApiProperty({ + description: 'The field type', + example: 'text', + }) + fieldType: 'text'; + + @ApiProperty({ + description: 'The minimum length', + example: 1, + required: false, + }) + minLength?: number; + + @ApiProperty({ + description: 'The maximum length', + example: 255, + required: false, + }) + maxLength?: number; +} + +export class ModelMetaFieldNumberDto extends ModelMetaFieldCommonDto { + @ApiProperty({ + description: 'The field type', + example: 'number', + }) + fieldType: 'number'; + + @ApiProperty({ + description: 'The minimum value', + example: 0, + required: false, + }) + min?: number; + + @ApiProperty({ + description: 'The maximum value', + example: 999999, + required: false, + }) + max?: number; +} + +export class ModelMetaFieldBooleanDto extends ModelMetaFieldCommonDto { + @ApiProperty({ + description: 'The field type', + example: 'boolean', + }) + fieldType: 'boolean'; +} + +export class ModelMetaFieldDateDto extends ModelMetaFieldCommonDto { + @ApiProperty({ + description: 'The field type', + example: 'date', + }) + fieldType: 'date'; +} + +export class ModelMetaFieldUrlDto extends ModelMetaFieldCommonDto { + @ApiProperty({ + description: 'The field type', + example: 'url', + }) + fieldType: 'url'; +} + +export class ModelMetaFieldEnumerationDto extends ModelMetaFieldCommonDto { + @ApiProperty({ + description: 'The field type', + example: 'enumeration', + }) + fieldType: 'enumeration'; + + @ApiProperty({ + description: 'The enumeration options', + type: 'array', + items: { $ref: getSchemaPath(ModelMetaEnumerationOptionDto) }, + }) + options: ModelMetaEnumerationOptionDto[]; +} + +export class ModelMetaFieldRelationDto extends ModelMetaFieldCommonDto { + @ApiProperty({ + description: 'The field type', + example: 'relation', + }) + fieldType: 'relation'; + + @ApiProperty({ + description: 'The relation type', + example: 'enumeration', + }) + relationType: 'enumeration'; + + @ApiProperty({ + description: 'The relation key', + example: 'customerId', + }) + relationKey: string; + + @ApiProperty({ + description: 'The relation entity label', + example: 'displayName', + }) + relationEntityLabel: string; + + @ApiProperty({ + description: 'The relation entity key', + example: 'id', + }) + relationEntityKey: string; +} + +export class ModelMetaFieldCollectionDto extends ModelMetaFieldCommonDto { + @ApiProperty({ + description: 'The field type', + example: 'collection', + }) + fieldType: 'collection'; + + @ApiProperty({ + description: 'The collection type', + example: 'object', + }) + collectionOf: 'object'; + + @ApiProperty({ + description: 'The minimum collection length', + example: 1, + required: false, + }) + collectionMinLength?: number; + + @ApiProperty({ + description: 'The maximum collection length', + example: 100, + required: false, + }) + collectionMaxLength?: number; + + @ApiProperty({ + description: 'The nested fields', + required: false, + }) + fields?: Record; +} + +export class ModelMetaColumnMetaDto { + @ApiProperty({ + description: 'The column name', + example: 'Customer Name', + }) + name: string; + + @ApiProperty({ + description: 'The column accessor', + example: 'customer.displayName', + required: false, + }) + accessor?: string; + + @ApiProperty({ + description: 'Whether the column is exportable', + example: true, + required: false, + }) + exportable?: boolean; +} + +export class ModelMetaColumnTextDto extends ModelMetaColumnMetaDto { + @ApiProperty({ + description: 'The column type', + example: 'text', + }) + type: 'text'; +} + +export class ModelMetaColumnCollectionDto extends ModelMetaColumnMetaDto { + @ApiProperty({ + description: 'The column type', + example: 'collection', + }) + type: 'collection'; + + @ApiProperty({ + description: 'The collection type', + example: 'object', + }) + collectionOf: 'object'; + + @ApiProperty({ + description: 'The nested columns', + type: 'object', + additionalProperties: { $ref: getSchemaPath(ModelMetaColumnTextDto) }, + }) + columns: Record; +} + +export class ModelPrintMetaDto { + @ApiProperty({ + description: 'The page title for print', + example: 'Invoice INV-0001', + }) + pageTitle: string; +} + +export class ResourceMetaResponseDto { + @ApiProperty({ + description: 'The default filter field', + example: 'query', + }) + defaultFilterField: string; + + @ApiProperty({ + description: 'The default sort configuration', + type: () => ModelMetaDefaultSortDto, + }) + defaultSort: ModelMetaDefaultSortDto; + + @ApiProperty({ + description: 'Whether the resource is exportable', + example: true, + required: false, + }) + exportable?: boolean; + + @ApiProperty({ + description: 'The field to flatten on during export', + example: 'entries', + required: false, + }) + exportFlattenOn?: string; + + @ApiProperty({ + description: 'Whether the resource is importable', + example: true, + required: false, + }) + importable?: boolean; + + @ApiProperty({ + description: 'The import aggregator field', + example: 'entries', + required: false, + }) + importAggregator?: string; + + @ApiProperty({ + description: 'The field to aggregate on during import', + example: 'referenceNo', + required: false, + }) + importAggregateOn?: string; + + @ApiProperty({ + description: 'The field to aggregate by during import', + example: 'id', + required: false, + }) + importAggregateBy?: string; + + @ApiProperty({ + description: 'The print metadata', + type: () => ModelPrintMetaDto, + required: false, + }) + print?: ModelPrintMetaDto; + + @ApiProperty({ + description: 'The resource fields (legacy format)', + type: 'object', + additionalProperties: true, + }) + fields: Record; + + @ApiProperty({ + description: 'The resource fields (new format)', + type: 'object', + additionalProperties: true, + }) + fields2: Record; + + @ApiProperty({ + description: 'The resource columns', + type: 'object', + additionalProperties: true, + }) + columns: Record; +} diff --git a/packages/server/src/modules/Roles/AbilitySchema.ts b/packages/server/src/modules/Roles/AbilitySchema.ts index c89c60259..c881f76aa 100644 --- a/packages/server/src/modules/Roles/AbilitySchema.ts +++ b/packages/server/src/modules/Roles/AbilitySchema.ts @@ -311,6 +311,13 @@ export const AbilitySchema: ISubjectAbilitiesSchema[] = [ subjectLabel: 'ability.audit_log', abilities: [ { key: AuditLogAction.View, label: 'ability.view' }, + }, + { + subject: AbilitySubject.Attachment, + subjectLabel: 'ability.attachments', + abilities: [ + { key: AttachmentAction.View, label: 'ability.view', default: true }, + { key: AttachmentAction.Delete, label: 'ability.delete', default: true }, ], }, ]; diff --git a/packages/server/src/modules/Roles/Authorization.guard.ts b/packages/server/src/modules/Roles/Authorization.guard.ts index c6bf3bdba..5f863c509 100644 --- a/packages/server/src/modules/Roles/Authorization.guard.ts +++ b/packages/server/src/modules/Roles/Authorization.guard.ts @@ -31,9 +31,10 @@ export class AuthorizationGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const { user } = request as any; + const userId = this.clsService.get('userId'); - if (ABILITIES_CACHE.has(user.id)) { - (request as any).ability = ABILITIES_CACHE.get(user.id); + if (ABILITIES_CACHE.has(userId)) { + (request as any).ability = ABILITIES_CACHE.get(userId); } else { const ability = await this.getAbilityForUser(); (request as any).ability = ability; diff --git a/packages/server/src/modules/Roles/RequirePermission.decorator.ts b/packages/server/src/modules/Roles/RequirePermission.decorator.ts index c5cbc28b5..40330c48c 100644 --- a/packages/server/src/modules/Roles/RequirePermission.decorator.ts +++ b/packages/server/src/modules/Roles/RequirePermission.decorator.ts @@ -8,6 +8,21 @@ export interface RequiredPermission { subject: AbilitySubject | string; } +/** + * Decorator to specify required ability and subject for a route handler or controller. + * @param ability - The ability/action required (e.g., 'Create', 'View', 'Edit', 'Delete') + * @param subject - The subject/entity the ability applies to (e.g., AbilitySubject.Item, AbilitySubject.SaleInvoice) + * @example + * ```typescript + * @RequirePermission('Create', AbilitySubject.Item) + * @Post() + * async createItem(@Body() dto: CreateItemDto) { ... } + * + * @RequirePermission('View', AbilitySubject.SaleInvoice) + * @Get(':id') + * async getInvoice(@Param('id') id: number) { ... } + * ``` + */ export const RequirePermission = ( ability: string, subject: AbilitySubject | string, diff --git a/packages/server/src/modules/Roles/Roles.module.ts b/packages/server/src/modules/Roles/Roles.module.ts index 4e00ba21e..489501a12 100644 --- a/packages/server/src/modules/Roles/Roles.module.ts +++ b/packages/server/src/modules/Roles/Roles.module.ts @@ -10,6 +10,8 @@ import { RolePermission } from './models/RolePermission.model'; import { RolesController } from './Roles.controller'; import { RolesApplication } from './Roles.application'; import { RolePermissionsSchema } from './queries/RolePermissionsSchema'; +import { AuthorizationGuard } from './Authorization.guard'; +import { PermissionGuard } from './Permission.guard'; const models = [ RegisterTenancyModel(Role), @@ -25,9 +27,11 @@ const models = [ GetRoleService, GetRolesService, RolesApplication, - RolePermissionsSchema + RolePermissionsSchema, + AuthorizationGuard, + PermissionGuard, ], controllers: [RolesController], - exports: [...models], + exports: [...models, AuthorizationGuard, PermissionGuard], }) export class RolesModule {} diff --git a/packages/server/src/modules/Roles/Roles.types.ts b/packages/server/src/modules/Roles/Roles.types.ts index 74fc63505..dcecc86b7 100644 --- a/packages/server/src/modules/Roles/Roles.types.ts +++ b/packages/server/src/modules/Roles/Roles.types.ts @@ -65,6 +65,7 @@ export enum AbilitySubject { Role = 'Role', Warehouse = 'Warehouse', Branch = 'Branch', + Attachment = 'Attachment', } export interface IRoleCreatedPayload { diff --git a/packages/server/src/modules/Roles/dtos/Role.dto.ts b/packages/server/src/modules/Roles/dtos/Role.dto.ts index 02b04c876..3ef300553 100644 --- a/packages/server/src/modules/Roles/dtos/Role.dto.ts +++ b/packages/server/src/modules/Roles/dtos/Role.dto.ts @@ -1,3 +1,4 @@ +import { IsOptional } from '@/common/decorators/Validators'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { @@ -41,7 +42,7 @@ export class CommandRolePermissionDto { export class CreateRolePermissionDto extends CommandRolePermissionDto { } export class EditRolePermissionDto extends CommandRolePermissionDto { @IsNumber() - @IsNotEmpty() + @IsOptional() @ApiProperty({ example: 1, description: 'The permission ID', @@ -59,7 +60,6 @@ class CommandRoleDto { roleName: string; @IsString() - @IsNotEmpty() @ApiProperty({ example: 'Administrator', description: 'The description of the role', @@ -71,9 +71,9 @@ export class CreateRoleDto extends CommandRoleDto { @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) - @Type(() => CommandRolePermissionDto) + @Type(() => CreateRolePermissionDto) @ApiProperty({ - type: [CommandRolePermissionDto], + type: [CreateRolePermissionDto], description: 'The permissions of the role', }) permissions: Array; diff --git a/packages/server/src/modules/Roles/models/Role.model.ts b/packages/server/src/modules/Roles/models/Role.model.ts index f9a5b1641..3ea987e4f 100644 --- a/packages/server/src/modules/Roles/models/Role.model.ts +++ b/packages/server/src/modules/Roles/models/Role.model.ts @@ -16,6 +16,13 @@ export class Role extends TenantBaseModel { return 'roles'; } + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/modules/Roles/models/RolePermission.model.ts b/packages/server/src/modules/Roles/models/RolePermission.model.ts index 1f82b4fcc..1bd6dc313 100644 --- a/packages/server/src/modules/Roles/models/RolePermission.model.ts +++ b/packages/server/src/modules/Roles/models/RolePermission.model.ts @@ -13,6 +13,13 @@ export class RolePermission extends TenantBaseModel { return 'role_permissions'; } + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/modules/SaleEstimates/SaleEstimates.controller.ts b/packages/server/src/modules/SaleEstimates/SaleEstimates.controller.ts index fe11ea3a6..fda590534 100644 --- a/packages/server/src/modules/SaleEstimates/SaleEstimates.controller.ts +++ b/packages/server/src/modules/SaleEstimates/SaleEstimates.controller.ts @@ -19,12 +19,10 @@ import { Put, Query, Res, + UseGuards, } from '@nestjs/common'; import { SaleEstimatesApplication } from './SaleEstimates.application'; -import { - ISalesEstimatesFilter, - SaleEstimateMailOptionsDTO, -} from './types/SaleEstimates.types'; +import { SaleEstimateMailOptionsDTO } from './types/SaleEstimates.types'; import { SaleEstimate } from './models/SaleEstimate'; import { CreateSaleEstimateDto, @@ -33,6 +31,7 @@ import { import { AcceptType } from '@/constants/accept-type'; import { Response } from 'express'; import { SaleEstimateResponseDto } from './dtos/SaleEstimateResponse.dto'; +import { GetSaleEstimatesQueryDto } from './dtos/GetSaleEstimatesQuery.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; import { SaleEstiamteStateResponseDto } from './dtos/SaleEstimateStateResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; @@ -40,6 +39,11 @@ import { BulkDeleteDto, ValidateBulkDeleteResponseDto, } from '@/common/dtos/BulkDelete.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { SaleEstimateAction } from './types/SaleEstimates.types'; @Controller('sale-estimates') @ApiTags('Sale Estimates') @@ -48,8 +52,10 @@ import { @ApiExtraModels(SaleEstiamteStateResponseDto) @ApiCommonHeaders() @ApiExtraModels(ValidateBulkDeleteResponseDto) +@UseGuards(AuthorizationGuard, PermissionGuard) export class SaleEstimatesController { @Post('validate-bulk-delete') + @RequirePermission(SaleEstimateAction.Delete, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Validates which sale estimates can be deleted and returns the results.', @@ -71,6 +77,7 @@ export class SaleEstimatesController { } @Post('bulk-delete') + @RequirePermission(SaleEstimateAction.Delete, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Deletes multiple sale estimates.' }) @ApiResponse({ status: 200, @@ -93,6 +100,7 @@ export class SaleEstimatesController { ) { } @Post() + @RequirePermission(SaleEstimateAction.Create, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Create a new sale estimate.' }) @ApiResponse({ status: 200, @@ -105,6 +113,7 @@ export class SaleEstimatesController { } @Put(':id') + @RequirePermission(SaleEstimateAction.Edit, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Edit the given sale estimate.' }) @ApiResponse({ status: 200, @@ -131,6 +140,7 @@ export class SaleEstimatesController { } @Delete(':id') + @RequirePermission(SaleEstimateAction.Delete, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Delete the given sale estimate.' }) @ApiResponse({ status: 200, @@ -153,6 +163,7 @@ export class SaleEstimatesController { } @Get('state') + @RequirePermission(SaleEstimateAction.View, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Retrieves the sale estimate state.' }) @ApiResponse({ status: 200, @@ -166,6 +177,7 @@ export class SaleEstimatesController { } @Get() + @RequirePermission(SaleEstimateAction.View, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Retrieves the sale estimates.' }) @ApiResponse({ status: 200, @@ -184,11 +196,12 @@ export class SaleEstimatesController { ], }, }) - public getSaleEstimates(@Query() filterDTO: ISalesEstimatesFilter) { + public getSaleEstimates(@Query() filterDTO: GetSaleEstimatesQueryDto) { return this.saleEstimatesApplication.getSaleEstimates(filterDTO); } @Post(':id/deliver') + @RequirePermission(SaleEstimateAction.Edit, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Deliver the given sale estimate.' }) @ApiResponse({ status: 200, @@ -207,6 +220,7 @@ export class SaleEstimatesController { } @Put(':id/approve') + @RequirePermission(SaleEstimateAction.Edit, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Approve the given sale estimate.' }) @ApiParam({ name: 'id', @@ -221,6 +235,7 @@ export class SaleEstimatesController { } @Put(':id/reject') + @RequirePermission(SaleEstimateAction.Edit, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Reject the given sale estimate.' }) @ApiParam({ name: 'id', @@ -235,6 +250,7 @@ export class SaleEstimatesController { } @Post(':id/notify-sms') + @RequirePermission(SaleEstimateAction.NotifyBySms, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Notify the given sale estimate by SMS.' }) @ApiParam({ name: 'id', @@ -251,6 +267,7 @@ export class SaleEstimatesController { } @Get(':id/sms-details') + @RequirePermission(SaleEstimateAction.View, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Retrieves the sale estimate SMS details.' }) public getSaleEstimateSmsDetails( @Param('id', ParseIntPipe) saleEstimateId: number, @@ -262,6 +279,7 @@ export class SaleEstimatesController { @Post(':id/mail') @HttpCode(200) + @RequirePermission(SaleEstimateAction.Edit, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Send the given sale estimate by mail.' }) @ApiParam({ name: 'id', @@ -280,6 +298,7 @@ export class SaleEstimatesController { } @Get(':id/mail') + @RequirePermission(SaleEstimateAction.View, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Retrieves the sale estimate mail state.' }) @ApiParam({ name: 'id', @@ -296,6 +315,7 @@ export class SaleEstimatesController { } @Get(':id') + @RequirePermission(SaleEstimateAction.View, AbilitySubject.SaleEstimate) @ApiOperation({ summary: 'Retrieves the sale estimate details.', }) diff --git a/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts b/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts index c9bb58fbb..ed96c467b 100644 --- a/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts +++ b/packages/server/src/modules/SaleEstimates/SaleEstimates.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { BullBoardModule } from '@bull-board/nestjs'; -import { BullAdapter } from '@bull-board/api/bullAdapter'; -import { BullModule } from '@nestjs/bull'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { BullModule } from '@nestjs/bullmq'; import { TenancyContext } from '../Tenancy/TenancyContext.service'; import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module'; import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; @@ -58,7 +58,7 @@ import { SendSaleEstimateMailProcess } from './processes/SendSaleEstimateMail.pr BullModule.registerQueue({ name: SendSaleEstimateMailQueue }), BullBoardModule.forFeature({ name: SendSaleEstimateMailQueue, - adapter: BullAdapter, + adapter: BullMQAdapter, }), ], controllers: [SaleEstimatesController], diff --git a/packages/server/src/modules/SaleEstimates/commands/SendSaleEstimateMail.ts b/packages/server/src/modules/SaleEstimates/commands/SendSaleEstimateMail.ts index cc647430a..3141cbec4 100644 --- a/packages/server/src/modules/SaleEstimates/commands/SendSaleEstimateMail.ts +++ b/packages/server/src/modules/SaleEstimates/commands/SendSaleEstimateMail.ts @@ -1,5 +1,5 @@ import { InjectQueue } from '@nestjs/bullmq'; -import { Queue } from 'bull'; +import { Queue } from 'bullmq'; import { Inject, Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { ContactMailNotification } from '@/modules/MailNotification/ContactMailNotification'; diff --git a/packages/server/src/modules/SaleEstimates/dtos/GetSaleEstimatesQuery.dto.ts b/packages/server/src/modules/SaleEstimates/dtos/GetSaleEstimatesQuery.dto.ts new file mode 100644 index 000000000..568c5f269 --- /dev/null +++ b/packages/server/src/modules/SaleEstimates/dtos/GetSaleEstimatesQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetSaleEstimatesQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/SaleEstimates/models/SaleEstimate.ts b/packages/server/src/modules/SaleEstimates/models/SaleEstimate.ts index 69721ebf6..96b2ed460 100644 --- a/packages/server/src/modules/SaleEstimates/models/SaleEstimate.ts +++ b/packages/server/src/modules/SaleEstimates/models/SaleEstimate.ts @@ -6,13 +6,16 @@ import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.dec import { ImportableModel } from '@/modules/Import/decorators/Import.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { SaleEstimateMeta } from './SaleEstimate.meta'; +import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection'; import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry'; import { Document } from '@/modules/ChromiumlyTenancy/models/Document'; import { Customer } from '@/modules/Customers/models/Customer'; import { DiscountType } from '@/common/types/Discount'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { SaleEstimateDefaultViews } from '../constants'; +import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; +@InjectAttachable() @ExportableModel() @ImportableModel() @InjectModelMeta(SaleEstimateMeta) @@ -248,7 +251,8 @@ export class SaleEstimate extends TenantBaseModel { * Sorting the estimates orders by delivery status. */ orderByStatus(query, order) { - query.orderByRaw(`delivered_at is null ${order}`); + const dir = sanitizeSortDirection(order); + query.orderByRaw(`delivered_at is null ${dir}`); }, /** * Filtering the estimates oreders by status field. diff --git a/packages/server/src/modules/SaleEstimates/processes/SendSaleEstimateMail.process.ts b/packages/server/src/modules/SaleEstimates/processes/SendSaleEstimateMail.process.ts index 6690c4b37..b9fbfc4f1 100644 --- a/packages/server/src/modules/SaleEstimates/processes/SendSaleEstimateMail.process.ts +++ b/packages/server/src/modules/SaleEstimates/processes/SendSaleEstimateMail.process.ts @@ -1,7 +1,6 @@ -import { Process, Processor } from '@nestjs/bull'; -import { Job } from 'bull'; -import { Inject, Scope } from '@nestjs/common'; -import { JOB_REF } from '@nestjs/bull'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { Scope } from '@nestjs/common'; import { SendSaleEstimateMailJob, SendSaleEstimateMailQueue, @@ -13,18 +12,17 @@ import { ClsService, UseCls } from 'nestjs-cls'; name: SendSaleEstimateMailQueue, scope: Scope.REQUEST, }) -export class SendSaleEstimateMailProcess { +export class SendSaleEstimateMailProcess extends WorkerHost { constructor( private readonly sendEstimateMailService: SendSaleEstimateMail, private readonly clsService: ClsService, - @Inject(JOB_REF) - private readonly jobRef: Job, - ) { } + ) { + super(); + } - @Process(SendSaleEstimateMailJob) @UseCls() - async handleSendMail() { - const { saleEstimateId, messageOptions, organizationId, userId } = this.jobRef.data; + async process(job: Job) { + const { saleEstimateId, messageOptions, organizationId, userId } = job.data; this.clsService.set('organizationId', organizationId); this.clsService.set('userId', userId); diff --git a/packages/server/src/modules/SaleEstimates/types/SaleEstimates.types.ts b/packages/server/src/modules/SaleEstimates/types/SaleEstimates.types.ts index a32182f38..c30fac6e9 100644 --- a/packages/server/src/modules/SaleEstimates/types/SaleEstimates.types.ts +++ b/packages/server/src/modules/SaleEstimates/types/SaleEstimates.types.ts @@ -9,7 +9,7 @@ import { CommonMailOptionsDTO } from '@/modules/MailNotification/MailNotificatio import { CommonMailOptions } from '@/modules/MailNotification/MailNotification.types'; import { EditSaleEstimateDto } from '../dtos/SaleEstimate.dto'; -export const SendSaleEstimateMailQueue = 'SendSaleEstimateMailProcessor'; +export const SendSaleEstimateMailQueue = 'SendSaleEstimateMailQueue'; export const SendSaleEstimateMailJob = 'SendSaleEstimateMailProcess'; export interface ISaleEstimateDTO { diff --git a/packages/server/src/modules/SaleInvoices/SaleInvoices.application.ts b/packages/server/src/modules/SaleInvoices/SaleInvoices.application.ts index 0cd0535ab..7c2a2d193 100644 --- a/packages/server/src/modules/SaleInvoices/SaleInvoices.application.ts +++ b/packages/server/src/modules/SaleInvoices/SaleInvoices.application.ts @@ -12,10 +12,10 @@ import { GetSaleInvoiceState } from './queries/GetSaleInvoiceState.service'; import { GetSaleInvoiceMailState } from './queries/GetSaleInvoiceMailState.service'; import { ISaleInvoiceWriteoffDTO, - ISalesInvoicesFilter, SaleInvoiceMailState, SendInvoiceMailDTO, } from './SaleInvoice.types'; +import { GetSaleInvoicesQueryDto } from './dtos/GetSaleInvoicesQuery.dto'; import { GetSaleInvoicesService } from './queries/GetSaleInvoices'; import { SendSaleInvoiceMail } from './commands/SendSaleInvoiceMail'; import { @@ -112,7 +112,7 @@ export class SaleInvoiceApplication { * @param {ISalesInvoicesFilter} filterDTO * @returns {Promise<{ salesInvoices: SaleInvoice[]; pagination: IPaginationMeta; filterMeta: IFilterMeta; }>} */ - public getSaleInvoices(filterDTO: Partial) { + public getSaleInvoices(filterDTO: GetSaleInvoicesQueryDto) { return this.getSaleInvoicesService.getSaleInvoices(filterDTO); } diff --git a/packages/server/src/modules/SaleInvoices/SaleInvoices.controller.ts b/packages/server/src/modules/SaleInvoices/SaleInvoices.controller.ts index 5c85d097b..73760252f 100644 --- a/packages/server/src/modules/SaleInvoices/SaleInvoices.controller.ts +++ b/packages/server/src/modules/SaleInvoices/SaleInvoices.controller.ts @@ -12,10 +12,10 @@ import { Put, Query, Res, + UseGuards, } from '@nestjs/common'; import { ISaleInvoiceWriteoffDTO, - ISalesInvoicesFilter, SaleInvoiceMailState, SendInvoiceMailDTO, } from './SaleInvoice.types'; @@ -33,6 +33,7 @@ import { CreateSaleInvoiceDto, EditSaleInvoiceDto, } from './dtos/SaleInvoice.dto'; +import { GetSaleInvoicesQueryDto } from './dtos/GetSaleInvoicesQuery.dto'; import { AcceptType } from '@/constants/accept-type'; import { SaleInvoiceResponseDto } from './dtos/SaleInvoiceResponse.dto'; import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto'; @@ -43,6 +44,11 @@ import { BulkDeleteDto, ValidateBulkDeleteResponseDto, } from '@/common/dtos/BulkDelete.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { SaleInvoiceAction } from './SaleInvoice.types'; @Controller('sale-invoices') @ApiTags('Sale Invoices') @@ -52,10 +58,12 @@ import { @ApiExtraModels(GenerateSaleInvoiceSharableLinkResponseDto) @ApiCommonHeaders() @ApiExtraModels(ValidateBulkDeleteResponseDto) +@UseGuards(AuthorizationGuard, PermissionGuard) export class SaleInvoicesController { constructor(private saleInvoiceApplication: SaleInvoiceApplication) { } @Post('validate-bulk-delete') + @RequirePermission(SaleInvoiceAction.Delete, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Validates which sale invoices can be deleted and returns the results.', @@ -77,6 +85,7 @@ export class SaleInvoicesController { } @Post('bulk-delete') + @RequirePermission(SaleInvoiceAction.Delete, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Deletes multiple sale invoices.' }) @ApiResponse({ status: 200, @@ -90,6 +99,7 @@ export class SaleInvoicesController { } @Post() + @RequirePermission(SaleInvoiceAction.Create, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Create a new sale invoice.' }) @ApiResponse({ status: 201, @@ -121,6 +131,7 @@ export class SaleInvoicesController { } @Put(':id') + @RequirePermission(SaleInvoiceAction.Edit, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Edit the given sale invoice.' }) @ApiResponse({ status: 200, @@ -141,6 +152,7 @@ export class SaleInvoicesController { } @Delete(':id') + @RequirePermission(SaleInvoiceAction.Delete, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Delete the given sale invoice.' }) @ApiResponse({ status: 200, @@ -158,6 +170,7 @@ export class SaleInvoicesController { } @Get('receivable') + @RequirePermission(SaleInvoiceAction.View, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Retrieves the receivable sale invoices.' }) @ApiResponse({ status: 200, @@ -176,6 +189,7 @@ export class SaleInvoicesController { } @Get('state') + @RequirePermission(SaleInvoiceAction.View, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Retrieves the sale invoice state.' }) @ApiResponse({ status: 200, @@ -190,6 +204,7 @@ export class SaleInvoicesController { } @Get(':id') + @RequirePermission(SaleInvoiceAction.View, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Retrieves the sale invoice details.' }) @ApiResponse({ status: 200, @@ -228,6 +243,7 @@ export class SaleInvoicesController { } @Get() + @RequirePermission(SaleInvoiceAction.View, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Retrieves the sale invoices.' }) @ApiResponse({ status: 200, @@ -246,11 +262,12 @@ export class SaleInvoicesController { ], }, }) - getSaleInvoices(@Query() filterDTO: Partial) { + getSaleInvoices(@Query() filterDTO: GetSaleInvoicesQueryDto) { return this.saleInvoiceApplication.getSaleInvoices(filterDTO); } @Put(':id/deliver') + @RequirePermission(SaleInvoiceAction.Edit, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Deliver the given sale invoice.' }) @ApiResponse({ status: 200, @@ -269,6 +286,7 @@ export class SaleInvoicesController { } @Post(':id/writeoff') + @RequirePermission(SaleInvoiceAction.Writeoff, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Write off the given sale invoice.' }) @HttpCode(200) @ApiResponse({ @@ -290,6 +308,7 @@ export class SaleInvoicesController { } @Post(':id/cancel-writeoff') + @RequirePermission(SaleInvoiceAction.Writeoff, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Cancel the written off sale invoice.' }) @ApiResponse({ status: 200, @@ -309,6 +328,7 @@ export class SaleInvoicesController { } @Get(':id/payments') + @RequirePermission(SaleInvoiceAction.View, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Retrieves the sale invoice payments.' }) @ApiResponse({ status: 404, description: 'The sale invoice not found.' }) @ApiParam({ @@ -322,6 +342,7 @@ export class SaleInvoicesController { } @Get(':id/html') + @RequirePermission(SaleInvoiceAction.View, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Retrieves the sale invoice HTML.' }) @ApiResponse({ status: 404, description: 'The sale invoice not found.' }) @ApiParam({ @@ -335,6 +356,7 @@ export class SaleInvoicesController { } @Get(':id/mail') + @RequirePermission(SaleInvoiceAction.View, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Retrieves the sale invoice mail state.' }) @ApiResponse({ status: 200, @@ -354,6 +376,7 @@ export class SaleInvoicesController { } @Post(':id/generate-link') + @RequirePermission(SaleInvoiceAction.Edit, AbilitySubject.SaleInvoice) @ApiOperation({ summary: 'Generate sharable sale invoice link (private or public)', }) diff --git a/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts b/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts index f66661188..0c66e1111 100644 --- a/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts +++ b/packages/server/src/modules/SaleInvoices/SaleInvoices.module.ts @@ -46,8 +46,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { MailNotificationModule } from '../MailNotification/MailNotification.module'; import { SendSaleInvoiceMailProcessor } from './processors/SendSaleInvoiceMail.processor'; import { BullBoardModule } from '@bull-board/nestjs'; -import { BullAdapter } from '@bull-board/api/bullAdapter'; -import { BullModule } from '@nestjs/bull'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { BullModule } from '@nestjs/bullmq'; import { SendSaleInvoiceQueue } from './constants'; import { InvoicePaymentIntegrationSubscriber } from './subscribers/InvoicePaymentIntegrationSubscriber'; import { InvoiceChangeStatusOnMailSentSubscriber } from './subscribers/InvoiceChangeStatusOnMailSentSubscriber'; @@ -85,7 +85,7 @@ import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleI BullModule.registerQueue({ name: SendSaleInvoiceQueue }), BullBoardModule.forFeature({ name: SendSaleInvoiceQueue, - adapter: BullAdapter, + adapter: BullMQAdapter, }), ], controllers: [SaleInvoicesController], diff --git a/packages/server/src/modules/SaleInvoices/constants.ts b/packages/server/src/modules/SaleInvoices/constants.ts index 6ab883cff..48b9da1b0 100644 --- a/packages/server/src/modules/SaleInvoices/constants.ts +++ b/packages/server/src/modules/SaleInvoices/constants.ts @@ -1,9 +1,6 @@ -// import config from '@/config'; - export const SendSaleInvoiceQueue = 'SendSaleInvoiceQueue'; export const SendSaleInvoiceMailJob = 'SendSaleInvoiceMailJob'; - export const DEFAULT_INVOICE_MAIL_SUBJECT = 'Invoice {Invoice Number} from {Company Name} for {Customer Name}'; export const DEFAULT_INVOICE_MAIL_CONTENT = `Hi {Customer Name}, diff --git a/packages/server/src/modules/SaleInvoices/dtos/GetSaleInvoicesQuery.dto.ts b/packages/server/src/modules/SaleInvoices/dtos/GetSaleInvoicesQuery.dto.ts new file mode 100644 index 000000000..7985fa492 --- /dev/null +++ b/packages/server/src/modules/SaleInvoices/dtos/GetSaleInvoicesQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetSaleInvoicesQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/SaleInvoices/models/SaleInvoice.ts b/packages/server/src/modules/SaleInvoices/models/SaleInvoice.ts index bf2168d0e..301f5fffa 100644 --- a/packages/server/src/modules/SaleInvoices/models/SaleInvoice.ts +++ b/packages/server/src/modules/SaleInvoices/models/SaleInvoice.ts @@ -10,6 +10,7 @@ import { Document } from '@/modules/ChromiumlyTenancy/models/Document'; import { DiscountType } from '@/common/types/Discount'; import { Account } from '@/modules/Accounts/models/Account.model'; import { ISearchRole } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types'; +import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection'; import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; import { TransactionPaymentServiceEntry } from '@/modules/PaymentServices/models/TransactionPaymentServiceEntry.model'; import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; @@ -74,7 +75,7 @@ export class SaleInvoice extends TenantBaseModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** @@ -417,14 +418,16 @@ export class SaleInvoice extends TenantBaseModel { * Sort the sale invoices by full-payment invoices. */ sortByStatus(query, order) { - query.orderByRaw(`PAYMENT_AMOUNT = BALANCE ${order}`); + const dir = sanitizeSortDirection(order); + query.orderByRaw(`PAYMENT_AMOUNT = BALANCE ${dir}`); }, /** * Sort the sale invoices by the due amount. */ sortByDueAmount(query, order) { - query.orderByRaw(`BALANCE - PAYMENT_AMOUNT ${order}`); + const dir = sanitizeSortDirection(order); + query.orderByRaw(`BALANCE - PAYMENT_AMOUNT ${dir}`); }, /** diff --git a/packages/server/src/modules/SaleInvoices/processors/SendSaleInvoiceMail.processor.ts b/packages/server/src/modules/SaleInvoices/processors/SendSaleInvoiceMail.processor.ts index ed5149b01..ffe7d358b 100644 --- a/packages/server/src/modules/SaleInvoices/processors/SendSaleInvoiceMail.processor.ts +++ b/packages/server/src/modules/SaleInvoices/processors/SendSaleInvoiceMail.processor.ts @@ -1,9 +1,8 @@ -import { JOB_REF, Process, Processor } from '@nestjs/bull'; -import { Job } from 'bull'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; import { SendSaleInvoiceMailJob, SendSaleInvoiceQueue } from '../constants'; import { SendSaleInvoiceMail } from '../commands/SendSaleInvoiceMail'; -import { Inject, Scope } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; +import { Scope } from '@nestjs/common'; import { ClsService, UseCls } from 'nestjs-cls'; import { SendSaleInvoiceMailJobPayload } from '../SaleInvoice.types'; @@ -11,20 +10,18 @@ import { SendSaleInvoiceMailJobPayload } from '../SaleInvoice.types'; name: SendSaleInvoiceQueue, scope: Scope.REQUEST, }) -export class SendSaleInvoiceMailProcessor { +export class SendSaleInvoiceMailProcessor extends WorkerHost { constructor( private readonly sendSaleInvoiceMail: SendSaleInvoiceMail, - @Inject(REQUEST) private readonly request: Request, - @Inject(JOB_REF) - private readonly jobRef: Job, private readonly clsService: ClsService, - ) { } + ) { + super(); + } - @Process(SendSaleInvoiceMailJob) @UseCls() - async handleSendInvoice() { + async process(job: Job) { const { messageOptions, saleInvoiceId, organizationId, userId } = - this.jobRef.data; + job.data; this.clsService.set('organizationId', organizationId); this.clsService.set('userId', userId); diff --git a/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoices.ts b/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoices.ts index 4777ce281..e0cb1536f 100644 --- a/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoices.ts +++ b/packages/server/src/modules/SaleInvoices/queries/GetSaleInvoices.ts @@ -6,7 +6,7 @@ import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectab import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service'; import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model'; import { SaleInvoice } from '../models/SaleInvoice'; -import { ISalesInvoicesFilter } from '../SaleInvoice.types'; +import { GetSaleInvoicesQueryDto } from '../dtos/GetSaleInvoicesQuery.dto'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @Injectable() @@ -21,11 +21,11 @@ export class GetSaleInvoicesService { /** * Retrieve sales invoices filterable and paginated list. - * @param {ISalesInvoicesFilter} filterDTO - - * @returns {Promise<{ salesInvoices: SaleInvoice[]; pagination: IPaginationMeta; filterMeta: IFilterMeta; }>} + * @param {GetSaleInvoicesQueryDto} filterDTO - + * @returns {Promise<{ data: SaleInvoice[]; pagination: IPaginationMeta; filterMeta: IFilterMeta; }>} */ public async getSaleInvoices( - filterDTO: Partial, + filterDTO: GetSaleInvoicesQueryDto, ): Promise<{ salesInvoices: SaleInvoice[]; pagination: IPaginationMeta; diff --git a/packages/server/src/modules/SaleReceipts/SaleReceiptApplication.service.ts b/packages/server/src/modules/SaleReceipts/SaleReceiptApplication.service.ts index 9b97def9a..14e5d6dde 100644 --- a/packages/server/src/modules/SaleReceipts/SaleReceiptApplication.service.ts +++ b/packages/server/src/modules/SaleReceipts/SaleReceiptApplication.service.ts @@ -9,10 +9,10 @@ import { GetSaleReceipt } from './queries/GetSaleReceipt.service'; import { EditSaleReceipt } from './commands/EditSaleReceipt.service'; import { ISaleReceiptState, - ISalesReceiptsFilter, SaleReceiptMailOpts, SaleReceiptMailOptsDTO, } from './types/SaleReceipts.types'; +import { GetSaleReceiptsQueryDto } from './dtos/GetSaleReceiptsQuery.dto'; import { GetSaleReceiptsService } from './queries/GetSaleReceipts.service'; import { SaleReceipt } from './models/SaleReceipt'; import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model'; @@ -115,10 +115,10 @@ export class SaleReceiptApplication { /** * Retrieve sales receipts paginated and filterable list. - * @param {ISalesReceiptsFilter} filterDTO + * @param {GetSaleReceiptsQueryDto} filterDTO * @returns */ - public async getSaleReceipts(filterDTO: ISalesReceiptsFilter): Promise<{ + public async getSaleReceipts(filterDTO: GetSaleReceiptsQueryDto): Promise<{ data: SaleReceipt[]; pagination: IPaginationMeta; filterMeta: IFilterMeta; diff --git a/packages/server/src/modules/SaleReceipts/SaleReceipts.controller.ts b/packages/server/src/modules/SaleReceipts/SaleReceipts.controller.ts index 08523bf6b..822a53825 100644 --- a/packages/server/src/modules/SaleReceipts/SaleReceipts.controller.ts +++ b/packages/server/src/modules/SaleReceipts/SaleReceipts.controller.ts @@ -25,8 +25,8 @@ import { CreateSaleReceiptDto, EditSaleReceiptDto, } from './dtos/SaleReceipt.dto'; +import { GetSaleReceiptsQueryDto } from './dtos/GetSaleReceiptsQuery.dto'; import { - ISalesReceiptsFilter, SaleReceiptMailOptsDTO, } from './types/SaleReceipts.types'; import { AcceptType } from '@/constants/accept-type'; @@ -206,7 +206,7 @@ export class SaleReceiptsController { ], }, }) - getSaleReceipts(@Query() filterDTO: Partial) { + getSaleReceipts(@Query() filterDTO: GetSaleReceiptsQueryDto) { return this.saleReceiptApplication.getSaleReceipts(filterDTO); } diff --git a/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts b/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts index 184bfe3a5..480cc102c 100644 --- a/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts +++ b/packages/server/src/modules/SaleReceipts/SaleReceipts.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { BullBoardModule } from '@bull-board/nestjs'; -import { BullAdapter } from '@bull-board/api/bullAdapter'; -import { BullModule } from '@nestjs/bull'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { BullModule } from '@nestjs/bullmq'; import { SaleReceiptApplication } from './SaleReceiptApplication.service'; import { CreateSaleReceipt } from './commands/CreateSaleReceipt.service'; import { EditSaleReceipt } from './commands/EditSaleReceipt.service'; @@ -66,7 +66,7 @@ import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleR BullModule.registerQueue({ name: SendSaleReceiptMailQueue }), BullBoardModule.forFeature({ name: SendSaleReceiptMailQueue, - adapter: BullAdapter, + adapter: BullMQAdapter, }), ], providers: [ diff --git a/packages/server/src/modules/SaleReceipts/commands/SaleReceiptMailNotification.ts b/packages/server/src/modules/SaleReceipts/commands/SaleReceiptMailNotification.ts index ebd542015..58e6de4df 100644 --- a/packages/server/src/modules/SaleReceipts/commands/SaleReceiptMailNotification.ts +++ b/packages/server/src/modules/SaleReceipts/commands/SaleReceiptMailNotification.ts @@ -1,4 +1,4 @@ -import { InjectQueue } from '@nestjs/bull'; +import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { DEFAULT_RECEIPT_MAIL_CONTENT, diff --git a/packages/server/src/modules/SaleReceipts/commands/SaleReceiptsExportable.ts b/packages/server/src/modules/SaleReceipts/commands/SaleReceiptsExportable.ts index 88d565f87..b3f42c372 100644 --- a/packages/server/src/modules/SaleReceipts/commands/SaleReceiptsExportable.ts +++ b/packages/server/src/modules/SaleReceipts/commands/SaleReceiptsExportable.ts @@ -1,8 +1,9 @@ import { Exportable } from '@/modules/Export/Exportable'; import { Injectable } from '@nestjs/common'; import { SaleReceiptApplication } from '../SaleReceiptApplication.service'; -import { ISalesReceiptsFilter } from '../types/SaleReceipts.types'; import { EXPORT_SIZE_LIMIT } from '@/modules/Export/constants'; +import { GetSaleReceiptsQueryDto } from '../dtos/GetSaleReceiptsQuery.dto'; +import { ISortOrder } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types'; @Injectable() export class SaleReceiptsExportable extends Exportable { @@ -12,21 +13,21 @@ export class SaleReceiptsExportable extends Exportable { /** * Retrieves the accounts data to exportable sheet. - * @param {ISalesReceiptsFilter} query - + * @param {GetSaleReceiptsQueryDto} query - */ - public exportable(query: ISalesReceiptsFilter) { + public exportable(query: GetSaleReceiptsQueryDto) { const filterQuery = (query) => { query.withGraphFetched('branch'); query.withGraphFetched('warehouse'); }; const parsedQuery = { - sortOrder: 'desc', + sortOrder: 'desc' as ISortOrder, columnSortBy: 'created_at', ...query, page: 1, pageSize: EXPORT_SIZE_LIMIT, filterQuery, - } as ISalesReceiptsFilter; + } as GetSaleReceiptsQueryDto; return this.saleReceiptsApp .getSaleReceipts(parsedQuery) diff --git a/packages/server/src/modules/SaleReceipts/dtos/GetSaleReceiptsQuery.dto.ts b/packages/server/src/modules/SaleReceipts/dtos/GetSaleReceiptsQuery.dto.ts new file mode 100644 index 000000000..091989ef7 --- /dev/null +++ b/packages/server/src/modules/SaleReceipts/dtos/GetSaleReceiptsQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetSaleReceiptsQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/SaleReceipts/models/SaleReceipt.ts b/packages/server/src/modules/SaleReceipts/models/SaleReceipt.ts index 87a840369..b4a7430d7 100644 --- a/packages/server/src/modules/SaleReceipts/models/SaleReceipt.ts +++ b/packages/server/src/modules/SaleReceipts/models/SaleReceipt.ts @@ -5,6 +5,7 @@ import { BaseModel } from '@/models/Model'; import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry'; import { Customer } from '@/modules/Customers/models/Customer'; import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model'; +import { Document } from '@/modules/ChromiumlyTenancy/models/Document'; import { Branch } from '@/modules/Branches/models/Branch.model'; import { Warehouse } from '@/modules/Warehouses/models/Warehouse.model'; import { DiscountType } from '@/common/types/Discount'; @@ -15,7 +16,9 @@ import { SearchableBaseModelMixin } from '@/modules/DynamicListing/models/Search import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator'; import { ImportableModel } from '@/modules/Import/decorators/Import.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; +import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; import { SaleReceiptMeta } from './SaleReceipt.meta'; +import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { SaleReceiptDefaultViews } from '../constants'; @@ -26,6 +29,7 @@ const ExtendedModel = R.pipe( MetadataModelMixin, )(BaseModel); +@InjectAttachable() @ExportableModel() @ImportableModel() @InjectModelMeta(SaleReceiptMeta) @@ -58,6 +62,7 @@ export class SaleReceipt extends ExtendedModel { public customer!: Customer; public entries!: ItemEntry[]; public transactions!: AccountTransaction[]; + public attachments!: Document[]; public branch!: Branch; public warehouse!: Warehouse; @@ -72,7 +77,7 @@ export class SaleReceipt extends ExtendedModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** @@ -234,7 +239,8 @@ export class SaleReceipt extends ExtendedModel { * Sorting the receipts order by status. */ sortByStatus(query, order) { - query.orderByRaw(`CLOSED_AT IS NULL ${order}`); + const dir = sanitizeSortDirection(order); + query.orderByRaw(`CLOSED_AT IS NULL ${dir}`); }, /** diff --git a/packages/server/src/modules/SaleReceipts/processes/SendSaleReceiptMail.process.ts b/packages/server/src/modules/SaleReceipts/processes/SendSaleReceiptMail.process.ts index 96af47bd9..185a631ee 100644 --- a/packages/server/src/modules/SaleReceipts/processes/SendSaleReceiptMail.process.ts +++ b/packages/server/src/modules/SaleReceipts/processes/SendSaleReceiptMail.process.ts @@ -1,30 +1,26 @@ -import { Process, Processor } from '@nestjs/bull'; -import { Job } from 'bull'; -import { Inject, Scope } from '@nestjs/common'; -import { JOB_REF } from '@nestjs/bull'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { Scope } from '@nestjs/common'; import { SendSaleReceiptMailQueue, SendSaleReceiptMailJob } from '../constants'; import { SaleReceiptMailNotification } from '../commands/SaleReceiptMailNotification'; -import { SaleReceiptSendMailPayload } from '../types/SaleReceipts.types'; import { ClsService, UseCls } from 'nestjs-cls'; @Processor({ name: SendSaleReceiptMailQueue, scope: Scope.REQUEST, }) -export class SendSaleReceiptMailProcess { +export class SendSaleReceiptMailProcess extends WorkerHost { constructor( private readonly saleReceiptMailNotification: SaleReceiptMailNotification, private readonly clsService: ClsService, + ) { + super(); + } - @Inject(JOB_REF) - private readonly jobRef: Job, - ) { } - - @Process(SendSaleReceiptMailJob) @UseCls() - async handleSendMailJob() { + async process(job: Job) { const { messageOpts, saleReceiptId, organizationId, userId } = - this.jobRef.data; + job.data; this.clsService.set('organizationId', organizationId); this.clsService.set('userId', userId); diff --git a/packages/server/src/modules/SaleReceipts/queries/GetSaleReceipts.service.ts b/packages/server/src/modules/SaleReceipts/queries/GetSaleReceipts.service.ts index 628d7d83e..6602d05c7 100644 --- a/packages/server/src/modules/SaleReceipts/queries/GetSaleReceipts.service.ts +++ b/packages/server/src/modules/SaleReceipts/queries/GetSaleReceipts.service.ts @@ -4,7 +4,7 @@ import { SaleReceiptTransformer } from './SaleReceiptTransformer'; import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service'; import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model'; -import { ISalesReceiptsFilter } from '../types/SaleReceipts.types'; +import { GetSaleReceiptsQueryDto } from '../dtos/GetSaleReceiptsQuery.dto'; import { SaleReceipt } from '../models/SaleReceipt'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @@ -23,9 +23,9 @@ export class GetSaleReceiptsService { /** * Retrieve sales receipts paginated and filterable list. - * @param {ISalesReceiptsFilter} salesReceiptsFilter - Sales receipts filter. + * @param {GetSaleReceiptsQueryDto} filterDTO - Sales receipts filter. */ - public async getSaleReceipts(filterDTO: ISalesReceiptsFilter): Promise<{ + public async getSaleReceipts(filterDTO: GetSaleReceiptsQueryDto): Promise<{ data: SaleReceipt[]; pagination: IPaginationMeta; filterMeta: IFilterMeta; diff --git a/packages/server/src/modules/SaleReceipts/queries/SaleReceiptTransformer.ts b/packages/server/src/modules/SaleReceipts/queries/SaleReceiptTransformer.ts index b7eddc152..25b2ab529 100644 --- a/packages/server/src/modules/SaleReceipts/queries/SaleReceiptTransformer.ts +++ b/packages/server/src/modules/SaleReceipts/queries/SaleReceiptTransformer.ts @@ -139,22 +139,22 @@ export class SaleReceiptTransformer extends Transformer { }; /** - * Retrieves the entries of the credit note. - * @param {ISaleReceipt} credit + * Retrieves the entries of the sale receipt. + * @param {ISaleReceipt} receipt * @returns {} */ - // protected entries = (receipt: SaleReceipt) => { - // return this.item(receipt.entries, new ItemEntryTransformer(), { - // currencyCode: receipt.currencyCode, - // }); - // }; + protected entries = (receipt: SaleReceipt) => { + return this.item(receipt.entries, new ItemEntryTransformer(), { + currencyCode: receipt.currencyCode, + }); + }; /** * Retrieves the sale receipt attachments. * @param {SaleReceipt} receipt * @returns */ - // protected attachments = (receipt: SaleReceipt) => { - // return this.item(receipt.attachments, new AttachmentTransformer()); - // }; + protected attachments = (receipt: SaleReceipt) => { + return this.item(receipt.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/modules/Settings/Settings.controller.ts b/packages/server/src/modules/Settings/Settings.controller.ts index 1477510b6..6a226060e 100644 --- a/packages/server/src/modules/Settings/Settings.controller.ts +++ b/packages/server/src/modules/Settings/Settings.controller.ts @@ -1,16 +1,22 @@ import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { Body, Controller, Get, Put } from '@nestjs/common'; +import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common'; import { SettingsApplicationService } from './SettingsApplication.service'; -import { ISettingsDTO } from './Settings.types'; +import { ISettingsDTO, PreferencesAction } from './Settings.types'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; @Controller('settings') @ApiTags('Settings') +@UseGuards(AuthorizationGuard, PermissionGuard) export class SettingsController { constructor( private readonly settingsApplicationService: SettingsApplicationService, ) {} @Put() + @RequirePermission(PreferencesAction.Mutate, AbilitySubject.Preferences) @ApiOperation({ summary: 'Save the given settings.' }) async saveSettings(@Body() settingsDTO: ISettingsDTO) { return this.settingsApplicationService.saveSettings(settingsDTO); diff --git a/packages/server/src/modules/Settings/models/Setting.ts b/packages/server/src/modules/Settings/models/Setting.ts index ace76f31f..1dd0c016a 100644 --- a/packages/server/src/modules/Settings/models/Setting.ts +++ b/packages/server/src/modules/Settings/models/Setting.ts @@ -10,6 +10,10 @@ export class Setting extends BaseModel { return 'settings'; } + get timestamps() { + return []; + } + /** * Extra metadata query to query with the current authenticate user. * @param {Object} query diff --git a/packages/server/src/modules/StripePayment/CreateStripeAccountLink.ts b/packages/server/src/modules/StripePayment/CreateStripeAccountLink.ts index ddba0de52..f57677071 100644 --- a/packages/server/src/modules/StripePayment/CreateStripeAccountLink.ts +++ b/packages/server/src/modules/StripePayment/CreateStripeAccountLink.ts @@ -12,4 +12,12 @@ export class CreateStripeAccountLinkService { public createAccountLink(stripeAccountId: string) { return this.stripePaymentService.createAccountLink(stripeAccountId); } + + /** + * Creates a Stripe account session for the Connect embedded component. + * @param {string} accountId - Stripe Connect account ID. + */ + public createAccountSession(accountId: string) { + return this.stripePaymentService.createAccountSession(accountId); + } } diff --git a/packages/server/src/modules/StripePayment/StripePayment.controller.ts b/packages/server/src/modules/StripePayment/StripePayment.controller.ts index d2ccc1dc9..fea3c65ed 100644 --- a/packages/server/src/modules/StripePayment/StripePayment.controller.ts +++ b/packages/server/src/modules/StripePayment/StripePayment.controller.ts @@ -1,6 +1,13 @@ import { Body, Controller, Get, Injectable, Post } from '@nestjs/common'; import { StripePaymentApplication } from './StripePaymentApplication'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { GetStripeConnectLinkResponseDto } from './dtos/GetStripeConnectLinkResponse.dto'; +import { ExchangeStripeOAuthBodyDto } from './dtos/ExchangeStripeOAuthBody.dto'; +import { CreateStripeAccountLinkBodyDto } from './dtos/CreateStripeAccountLinkBody.dto'; +import { CreateStripeAccountLinkResponseDto } from './dtos/CreateStripeAccountLinkResponse.dto'; +import { CreateStripeAccountResponseDto } from './dtos/CreateStripeAccountResponse.dto'; +import { CreateStripeAccountSessionBodyDto } from './dtos/CreateStripeAccountSessionBody.dto'; +import { CreateStripeAccountSessionResponseDto } from './dtos/CreateStripeAccountSessionResponse.dto'; @Controller('/stripe') @ApiTags('stripe') @@ -9,9 +16,17 @@ export class StripeIntegrationController { /** * Retrieves Stripe OAuth2 connect link. - * @returns {Promise} */ @Get('/link') + @ApiOperation({ + summary: 'Get Stripe Connect link', + description: 'Retrieves the Stripe OAuth2 Connect authorization URL', + }) + @ApiResponse({ + status: 200, + description: 'Successfully retrieved Stripe Connect link', + type: GetStripeConnectLinkResponseDto, + }) public async getStripeConnectLink() { const authorizationUri = this.stripePaymentApp.getStripeConnectLink(); @@ -20,37 +35,85 @@ export class StripeIntegrationController { /** * Exchanges the given Stripe authorization code to Stripe user id and access token. - * @returns {Promise} */ @Post('/callback') - public async exchangeOAuth(@Body('code') code: string) { - await this.stripePaymentApp.exchangeStripeOAuthToken(code); + @ApiOperation({ + summary: 'Exchange Stripe OAuth code', + description: + 'Exchanges the Stripe authorization code for user id and access token', + }) + @ApiBody({ type: ExchangeStripeOAuthBodyDto }) + @ApiResponse({ + status: 201, + description: 'Successfully exchanged OAuth code', + }) + public async exchangeOAuth(@Body() body: ExchangeStripeOAuthBodyDto) { + await this.stripePaymentApp.exchangeStripeOAuthToken(body.code); return {}; } /** * Creates a new Stripe account. - * @returns {Promise} */ + @Post('/account') + @ApiOperation({ + summary: 'Create Stripe account', + description: 'Creates a new Stripe Connect account', + }) + @ApiResponse({ + status: 201, + description: 'Successfully created Stripe account', + type: CreateStripeAccountResponseDto, + }) public async createAccount() { const accountId = await this.stripePaymentApp.createStripeAccount(); - return { - accountId, - message: 'The Stripe account has been created successfully.', - }; + return { account_id: accountId }; } /** - * Creates a new Stripe account session. - * @returns {Promise} + * Creates a Stripe account session for the Connect embedded component. + */ + @Post('/account_session') + @ApiOperation({ + summary: 'Create Stripe account session', + description: + 'Creates an account session for the Stripe Connect embedded component', + }) + @ApiBody({ type: CreateStripeAccountSessionBodyDto }) + @ApiResponse({ + status: 201, + description: 'Successfully created account session', + type: CreateStripeAccountSessionResponseDto, + }) + public async createAccountSession( + @Body() body: CreateStripeAccountSessionBodyDto, + ) { + const clientSecret = await this.stripePaymentApp.createAccountSession( + body.account ?? '', + ); + return { client_secret: clientSecret }; + } + + /** + * Creates a new Stripe account link for onboarding. */ @Post('/account_link') + @ApiOperation({ + summary: 'Create Stripe account link', + description: 'Creates a Stripe Connect account link for onboarding', + }) + @ApiBody({ type: CreateStripeAccountLinkBodyDto }) + @ApiResponse({ + status: 201, + description: 'Successfully created account link', + type: CreateStripeAccountLinkResponseDto, + }) public async createAccountLink( - @Body('stripeAccountId') stripeAccountId: string, + @Body() body: CreateStripeAccountLinkBodyDto, ) { const clientSecret = - await this.stripePaymentApp.createAccountLink(stripeAccountId); + await this.stripePaymentApp.createAccountLink(body.stripeAccountId); return { clientSecret }; } diff --git a/packages/server/src/modules/StripePayment/StripePaymentApplication.ts b/packages/server/src/modules/StripePayment/StripePaymentApplication.ts index 62303dd39..3c588dcff 100644 --- a/packages/server/src/modules/StripePayment/StripePaymentApplication.ts +++ b/packages/server/src/modules/StripePayment/StripePaymentApplication.ts @@ -37,6 +37,14 @@ export class StripePaymentApplication { ); } + /** + * Creates a Stripe account session for the Connect embedded component. + * @param {string} accountId - Stripe Connect account ID. + */ + public createAccountSession(accountId: string) { + return this.createStripeAccountLinkService.createAccountSession(accountId); + } + /** * Retrieves Stripe OAuth2 connect link. * @returns {string} diff --git a/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountLinkBody.dto.ts b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountLinkBody.dto.ts new file mode 100644 index 000000000..b34a52f92 --- /dev/null +++ b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountLinkBody.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class CreateStripeAccountLinkBodyDto { + @ApiProperty({ + description: 'Stripe Connect account ID', + example: 'acct_xxx', + }) + @IsString() + stripeAccountId: string; +} diff --git a/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountLinkResponse.dto.ts b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountLinkResponse.dto.ts new file mode 100644 index 000000000..dbaa2926e --- /dev/null +++ b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountLinkResponse.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StripeAccountLinkResponseDto } from './StripeAccountLinkResponse.dto'; + +export class CreateStripeAccountLinkResponseDto { + @ApiProperty({ + type: StripeAccountLinkResponseDto, + description: 'Stripe AccountLink object for onboarding', + }) + clientSecret: StripeAccountLinkResponseDto; +} diff --git a/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountResponse.dto.ts b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountResponse.dto.ts new file mode 100644 index 000000000..6fc60d371 --- /dev/null +++ b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountResponse.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateStripeAccountResponseDto { + @ApiProperty({ + description: 'The Stripe Connect account ID', + example: 'acct_1234567890', + }) + account_id: string; +} diff --git a/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountSessionBody.dto.ts b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountSessionBody.dto.ts new file mode 100644 index 000000000..03ba5bba4 --- /dev/null +++ b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountSessionBody.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateStripeAccountSessionBodyDto { + @ApiProperty({ + description: 'Stripe Connect account ID to create a session for', + example: 'acct_1234567890', + required: false, + }) + account?: string; +} diff --git a/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountSessionResponse.dto.ts b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountSessionResponse.dto.ts new file mode 100644 index 000000000..97a567b44 --- /dev/null +++ b/packages/server/src/modules/StripePayment/dtos/CreateStripeAccountSessionResponse.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateStripeAccountSessionResponseDto { + @ApiProperty({ + description: 'Stripe Account Session client secret for the Connect embedded component', + example: 'acs_xxx_secret_xxx', + }) + client_secret: string; +} diff --git a/packages/server/src/modules/StripePayment/dtos/ExchangeStripeOAuthBody.dto.ts b/packages/server/src/modules/StripePayment/dtos/ExchangeStripeOAuthBody.dto.ts new file mode 100644 index 000000000..3402db667 --- /dev/null +++ b/packages/server/src/modules/StripePayment/dtos/ExchangeStripeOAuthBody.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class ExchangeStripeOAuthBodyDto { + @ApiProperty({ + description: 'Authorization code returned by Stripe OAuth', + example: 'ac_xxx', + }) + @IsString() + code: string; +} diff --git a/packages/server/src/modules/StripePayment/dtos/GetStripeConnectLinkResponse.dto.ts b/packages/server/src/modules/StripePayment/dtos/GetStripeConnectLinkResponse.dto.ts new file mode 100644 index 000000000..2b19a5596 --- /dev/null +++ b/packages/server/src/modules/StripePayment/dtos/GetStripeConnectLinkResponse.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class GetStripeConnectLinkResponseDto { + @ApiProperty({ + description: 'Stripe OAuth2 Connect authorization URL', + example: 'https://connect.stripe.com/oauth/authorize?response_type=code&client_id=...', + }) + url: string; +} diff --git a/packages/server/src/modules/StripePayment/dtos/StripeAccountLinkResponse.dto.ts b/packages/server/src/modules/StripePayment/dtos/StripeAccountLinkResponse.dto.ts new file mode 100644 index 000000000..d7c3920cd --- /dev/null +++ b/packages/server/src/modules/StripePayment/dtos/StripeAccountLinkResponse.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class StripeAccountLinkResponseDto { + @ApiProperty({ + description: 'URL for the account onboarding flow', + example: 'https://connect.stripe.com/setup/xxx', + }) + url: string; + + @ApiProperty({ + description: 'Unix timestamp when the link was created', + example: 1234567890, + }) + created: number; + + @ApiProperty({ + description: 'Unix timestamp when the link expires', + example: 1234567890, + }) + expires_at: number; + + @ApiProperty({ + description: 'Stripe object type', + example: 'account_link', + }) + object: string; +} diff --git a/packages/server/src/modules/System/SystemDB/SystemDB.controller.ts b/packages/server/src/modules/System/SystemDB/SystemDB.controller.ts index ca010fd99..af4e98217 100644 --- a/packages/server/src/modules/System/SystemDB/SystemDB.controller.ts +++ b/packages/server/src/modules/System/SystemDB/SystemDB.controller.ts @@ -1,12 +1,14 @@ -import { Controller, Get, Post } from '@nestjs/common'; +import { Controller, Get, HttpCode } from '@nestjs/common'; +import { PublicRoute } from '@/modules/Auth/guards/jwt.guard'; -@Controller('/system_db') +@Controller('system_db') +@PublicRoute() export class SystemDatabaseController { constructor() {} - @Post() @Get() - ping(){ - + @HttpCode(200) + ping() { + return { status: 'ok' }; } } diff --git a/packages/server/src/modules/System/SystemDB/SystemDB.module.ts b/packages/server/src/modules/System/SystemDB/SystemDB.module.ts index d1387efa5..6c3610385 100644 --- a/packages/server/src/modules/System/SystemDB/SystemDB.module.ts +++ b/packages/server/src/modules/System/SystemDB/SystemDB.module.ts @@ -6,6 +6,7 @@ import { SystemKnexConnectionConfigure, } from './SystemDB.constants'; import { knexSnakeCaseMappers } from 'objection'; +import { SystemDatabaseController } from './SystemDB.controller'; const providers = [ { @@ -42,6 +43,7 @@ const providers = [ @Global() @Module({ + controllers: [SystemDatabaseController], providers: [...providers], exports: [...providers], }) diff --git a/packages/server/src/modules/System/models/TenantMetadataModel.ts b/packages/server/src/modules/System/models/TenantMetadataModel.ts index 22c0723f0..2886113c6 100644 --- a/packages/server/src/modules/System/models/TenantMetadataModel.ts +++ b/packages/server/src/modules/System/models/TenantMetadataModel.ts @@ -4,7 +4,6 @@ import { organizationAddressTextFormat, } from '@/utils/address-text-format'; import { findByIsoCountryCode } from '@bigcapital/utils'; -// import { getUploadedObjectUri } from '../../services/Attachments/utils'; export class TenantMetadata extends BaseModel { public baseCurrency!: string; @@ -50,6 +49,13 @@ export class TenantMetadata extends BaseModel { */ static tableName = 'tenants_metadata'; + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Virtual attributes. */ @@ -58,17 +64,9 @@ export class TenantMetadata extends BaseModel { } /** - * Organization logo url. - * @returns {string | null} + * Retrieves the organization address formatted text. + * @returns {string} */ - // public get logoUri() { - // return this.logoKey ? getUploadedObjectUri(this.logoKey) : null; - // } - - // /** - // * Retrieves the organization address formatted text. - // * @returns {string} - // */ public get addressTextFormatted() { const addressCountry = findByIsoCountryCode(this.location); diff --git a/packages/server/src/modules/TaxRates/TaxRate.application.ts b/packages/server/src/modules/TaxRates/TaxRate.application.ts index 2159c7d33..8847dcbe7 100644 --- a/packages/server/src/modules/TaxRates/TaxRate.application.ts +++ b/packages/server/src/modules/TaxRates/TaxRate.application.ts @@ -62,10 +62,11 @@ export class TaxRatesApplication { /** * Retrieves the tax rates list. - * @returns {Promise} + * @returns {Promise<{ data: ITaxRate[] }>} */ - public getTaxRates() { - return this.getTaxRatesService.getTaxRates(); + public async getTaxRates() { + const taxRates = await this.getTaxRatesService.getTaxRates(); + return { data: taxRates }; } /** diff --git a/packages/server/src/modules/TaxRates/TaxRate.controller.ts b/packages/server/src/modules/TaxRates/TaxRate.controller.ts index 476ac4e32..0edaff458 100644 --- a/packages/server/src/modules/TaxRates/TaxRate.controller.ts +++ b/packages/server/src/modules/TaxRates/TaxRate.controller.ts @@ -6,6 +6,7 @@ import { Param, Post, Put, + UseGuards, } from '@nestjs/common'; import { TaxRatesApplication } from './TaxRate.application'; import { @@ -18,15 +19,22 @@ import { import { CreateTaxRateDto, EditTaxRateDto } from './dtos/TaxRate.dto'; import { TaxRateResponseDto } from './dtos/TaxRateResponse.dto'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { TaxRateAction } from './TaxRates.types'; @Controller('tax-rates') @ApiTags('Tax Rates') @ApiExtraModels(TaxRateResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class TaxRatesController { constructor(private readonly taxRatesApplication: TaxRatesApplication) { } @Post() + @RequirePermission(TaxRateAction.CREATE, AbilitySubject.TaxRate) @ApiOperation({ summary: 'Create a new tax rate.' }) @ApiResponse({ status: 201, @@ -38,6 +46,7 @@ export class TaxRatesController { } @Put(':id') + @RequirePermission(TaxRateAction.EDIT, AbilitySubject.TaxRate) @ApiOperation({ summary: 'Edit the given tax rate.' }) @ApiResponse({ status: 200, @@ -54,6 +63,7 @@ export class TaxRatesController { } @Delete(':id') + @RequirePermission(TaxRateAction.DELETE, AbilitySubject.TaxRate) @ApiOperation({ summary: 'Delete the given tax rate.' }) @ApiResponse({ status: 200, @@ -67,6 +77,7 @@ export class TaxRatesController { } @Get(':id') + @RequirePermission(TaxRateAction.VIEW, AbilitySubject.TaxRate) @ApiOperation({ summary: 'Retrieves the tax rate details.' }) @ApiResponse({ status: 200, @@ -80,14 +91,20 @@ export class TaxRatesController { } @Get() + @RequirePermission(TaxRateAction.VIEW, AbilitySubject.TaxRate) @ApiOperation({ summary: 'Retrieves the tax rates.' }) @ApiResponse({ status: 200, description: 'The tax rates have been successfully retrieved.', schema: { - type: 'array', - items: { - $ref: getSchemaPath(TaxRateResponseDto), + type: 'object', + properties: { + data: { + type: 'array', + items: { + $ref: getSchemaPath(TaxRateResponseDto), + }, + }, }, }, }) @@ -96,6 +113,7 @@ export class TaxRatesController { } @Put(':id/activate') + @RequirePermission(TaxRateAction.EDIT, AbilitySubject.TaxRate) @ApiOperation({ summary: 'Activate the given tax rate.' }) @ApiResponse({ status: 200, @@ -109,6 +127,7 @@ export class TaxRatesController { } @Put(':id/inactivate') + @RequirePermission(TaxRateAction.EDIT, AbilitySubject.TaxRate) @ApiOperation({ summary: 'Inactivate the given tax rate.' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/TaxRates/dtos/TaxRate.dto.ts b/packages/server/src/modules/TaxRates/dtos/TaxRate.dto.ts index 678e81c94..7bbca6105 100644 --- a/packages/server/src/modules/TaxRates/dtos/TaxRate.dto.ts +++ b/packages/server/src/modules/TaxRates/dtos/TaxRate.dto.ts @@ -1,3 +1,4 @@ +import { ToNumber } from '@/common/decorators/Validators'; import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { @@ -30,6 +31,7 @@ export class CommandTaxRateDto { */ @IsNumber() @IsNotEmpty() + @ToNumber() @ApiProperty({ description: 'The rate of the tax rate.', example: 10, diff --git a/packages/server/src/modules/Tenancy/EnsureTenantIsInitialized.guard.ts b/packages/server/src/modules/Tenancy/EnsureTenantIsInitialized.guard.ts index 01333e2a3..67282b5ee 100644 --- a/packages/server/src/modules/Tenancy/EnsureTenantIsInitialized.guard.ts +++ b/packages/server/src/modules/Tenancy/EnsureTenantIsInitialized.guard.ts @@ -8,6 +8,7 @@ import { import { TenancyContext } from './TenancyContext.service'; import { Reflector } from '@nestjs/core'; import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants'; +import { IS_TENANT_AGNOSTIC } from './TenancyGlobal.guard'; export const IS_IGNORE_TENANT_INITIALIZED = 'IS_IGNORE_TENANT_INITIALIZED'; export const IgnoreTenantInitializedRoute = () => @@ -35,8 +36,12 @@ export class EnsureTenantIsInitializedGuard implements CanActivate { IS_PUBLIC_ROUTE, [context.getHandler(), context.getClass()], ); + const isTenantAgnostic = this.reflector.getAllAndOverride( + IS_TENANT_AGNOSTIC, + [context.getHandler(), context.getClass()], + ); // Skip the guard early if the route marked as public or ignored. - if (isPublic || isIgnoreEnsureTenantInitialized) { + if (isPublic || isIgnoreEnsureTenantInitialized || isTenantAgnostic) { return true; } const tenant = await this.tenancyContext.getTenant(); diff --git a/packages/server/src/modules/Tenancy/EnsureTenantIsSeeded.guards.ts b/packages/server/src/modules/Tenancy/EnsureTenantIsSeeded.guards.ts index 8149179bc..cab864c8f 100644 --- a/packages/server/src/modules/Tenancy/EnsureTenantIsSeeded.guards.ts +++ b/packages/server/src/modules/Tenancy/EnsureTenantIsSeeded.guards.ts @@ -9,6 +9,7 @@ import { import { TenancyContext } from './TenancyContext.service'; import { Reflector } from '@nestjs/core'; import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants'; +import { IS_TENANT_AGNOSTIC } from './TenancyGlobal.guard'; export const IS_IGNORE_TENANT_SEEDED = 'IS_IGNORE_TENANT_SEEDED'; export const IgnoreTenantSeededRoute = () => @@ -36,7 +37,12 @@ export class EnsureTenantIsSeededGuard implements CanActivate { context.getHandler(), context.getClass(), ]); - if (isPublic || isIgnoreEnsureTenantSeeded) { + const isTenantAgnostic = this.reflector.getAllAndOverride( + IS_TENANT_AGNOSTIC, + [context.getHandler(), context.getClass()], + ); + // Skip the guard early if the route marked as public, tenant agnostic or ignored. + if (isPublic || isIgnoreEnsureTenantSeeded || isTenantAgnostic) { return true; } const tenant = await this.tenancyContext.getTenant(); diff --git a/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts b/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts index 04310a100..edb146410 100644 --- a/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts +++ b/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts @@ -7,6 +7,7 @@ import { SetMetadata, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { ClsService } from 'nestjs-cls'; import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants'; import { getAuthApiKey } from '../Auth/Auth.utils'; @@ -16,7 +17,10 @@ export const TenantAgnosticRoute = () => SetMetadata(IS_TENANT_AGNOSTIC, true); @Injectable() export class TenancyGlobalGuard implements CanActivate { - constructor(private reflector: Reflector) {} + constructor( + private readonly reflector: Reflector, + private readonly clsService: ClsService, + ) {} /** * Validates the organization ID in the request headers. @@ -43,6 +47,14 @@ export class TenancyGlobalGuard implements CanActivate { if (!organizationId) { throw new UnauthorizedException('Organization ID is required.'); } + const authenticatedOrganizationId = + this.clsService.get('organizationId'); + if ( + authenticatedOrganizationId && + authenticatedOrganizationId !== organizationId + ) { + throw new UnauthorizedException('Organization mismatch.'); + } return true; } } diff --git a/packages/server/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts b/packages/server/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts index 3cc272fc4..619cd96d0 100644 --- a/packages/server/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts +++ b/packages/server/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts @@ -24,7 +24,7 @@ export class TenantUser extends TenantBaseModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** diff --git a/packages/server/src/modules/TransactionItemEntry/models/ItemEntry.ts b/packages/server/src/modules/TransactionItemEntry/models/ItemEntry.ts index 73b5fba37..5ce0268d1 100644 --- a/packages/server/src/modules/TransactionItemEntry/models/ItemEntry.ts +++ b/packages/server/src/modules/TransactionItemEntry/models/ItemEntry.ts @@ -53,7 +53,7 @@ export class ItemEntry extends BaseModel { * @returns {string[]} */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** diff --git a/packages/server/src/modules/UsersModule/Users.controller.ts b/packages/server/src/modules/UsersModule/Users.controller.ts index 301202de0..5e7218f71 100644 --- a/packages/server/src/modules/UsersModule/Users.controller.ts +++ b/packages/server/src/modules/UsersModule/Users.controller.ts @@ -22,7 +22,7 @@ export class UsersController { /** * Edit details of the given user. */ - @Post(':id') + @Put(':id') @ApiOperation({ summary: 'Edit details of the given user.' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/UsersModule/Users.module.ts b/packages/server/src/modules/UsersModule/Users.module.ts index 7b83e493b..3e16edd91 100644 --- a/packages/server/src/modules/UsersModule/Users.module.ts +++ b/packages/server/src/modules/UsersModule/Users.module.ts @@ -1,4 +1,7 @@ import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { BullBoardModule } from '@bull-board/nestjs'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; import { ActivateUserService } from './commands/ActivateUser.service'; import { DeleteUserService } from './commands/DeleteUser.service'; import { EditUserService } from './commands/EditUser.service'; @@ -17,12 +20,26 @@ import { GetUsersService } from './queries/GetUsers.service'; import { AcceptInviteUserService } from './commands/AcceptInviteUser.service'; import { InviteTenantUserService } from './commands/InviteUser.service'; import { UsersInviteController } from './UsersInvite.controller'; +import { UsersInvitePublicController } from './UsersInvitePublic.controller'; import { InjectSystemModel } from '../System/SystemModels/SystemModels.module'; +import { SendInviteUserMailQueue } from './Users.constants'; +import InviteSendMainNotificationSubscribe from './subscribers/InviteSendMailNotification.subscriber'; +import { SendInviteUserMailProcessor } from './processors/SendInviteUserMail.processor'; +import { SendInviteUsersMailMessage } from './commands/SendInviteUsersMailMessage.service'; +import { MailModule } from '../Mail/Mail.module'; const models = [InjectSystemModel(UserInvite)]; @Module({ - imports: [TenancyModule], + imports: [ + TenancyModule, + MailModule, + BullModule.registerQueue({ name: SendInviteUserMailQueue }), + BullBoardModule.forFeature({ + name: SendInviteUserMailQueue, + adapter: BullMQAdapter, + }), + ], exports: [...models], providers: [ ...models, @@ -39,8 +56,11 @@ const models = [InjectSystemModel(UserInvite)]; SyncTenantUserMutateSubscriber, SyncSystemSendInviteSubscriber, SyncTenantAcceptInviteSubscriber, + InviteSendMainNotificationSubscribe, + SendInviteUserMailProcessor, + SendInviteUsersMailMessage, UsersApplication ], - controllers: [UsersController, UsersInviteController], + controllers: [UsersController, UsersInviteController, UsersInvitePublicController], }) export class UsersModule {} diff --git a/packages/server/src/modules/UsersModule/Users.types.ts b/packages/server/src/modules/UsersModule/Users.types.ts index 5d19cbf37..73200204c 100644 --- a/packages/server/src/modules/UsersModule/Users.types.ts +++ b/packages/server/src/modules/UsersModule/Users.types.ts @@ -32,10 +32,12 @@ export interface ITenantUserDeletedPayload { export interface IUserInvitedEventPayload { inviteToken: string; user: ModelObject; + invitingUser: ModelObject; } export interface IUserInviteTenantSyncedEventPayload { invite: ModelObject; user: ModelObject; + invitingUser: ModelObject; } export interface IUserInviteResendEventPayload { diff --git a/packages/server/src/modules/UsersModule/UsersInvite.controller.ts b/packages/server/src/modules/UsersModule/UsersInvite.controller.ts index 3f43baa27..c1c417e7b 100644 --- a/packages/server/src/modules/UsersModule/UsersInvite.controller.ts +++ b/packages/server/src/modules/UsersModule/UsersInvite.controller.ts @@ -1,40 +1,13 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Param, Patch, Post } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { UsersApplication } from './Users.application'; -import { InviteUserDto, SendInviteUserDto } from './dtos/InviteUser.dto'; +import { SendInviteUserDto } from './dtos/InviteUser.dto'; @Controller('invite') @ApiTags('Users') export class UsersInviteController { constructor(private readonly usersApplication: UsersApplication) {} - /** - * Accept a user invitation. - */ - @Post('accept/:token') - @ApiOperation({ summary: 'Accept a user invitation.' }) - async acceptInvite( - @Param('token') token: string, - @Body() inviteUserDTO: InviteUserDto, - ) { - await this.usersApplication.acceptInvite(token, inviteUserDTO); - - return { - message: 'The invitation has been accepted successfully.', - }; - } - - /** - * Check if an invitation token is valid. - */ - @Get('check/:token') - @ApiOperation({ summary: 'Check if an invitation token is valid.' }) - async checkInvite(@Param('token') token: string) { - const inviteDetails = await this.usersApplication.checkInvite(token); - - return inviteDetails; - } - /** * Send an invitation to a new user. */ diff --git a/packages/server/src/modules/UsersModule/UsersInvitePublic.controller.ts b/packages/server/src/modules/UsersModule/UsersInvitePublic.controller.ts new file mode 100644 index 000000000..56bef5659 --- /dev/null +++ b/packages/server/src/modules/UsersModule/UsersInvitePublic.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PublicRoute } from '@/modules/Auth/guards/jwt.guard'; +import { UsersApplication } from './Users.application'; +import { InviteUserDto } from './dtos/InviteUser.dto'; + +@Controller('invite') +@ApiTags('Users') +@PublicRoute() +export class UsersInvitePublicController { + constructor(private readonly usersApplication: UsersApplication) {} + + /** + * Accept a user invitation. + */ + @Post('accept/:token') + @ApiOperation({ summary: 'Accept a user invitation.' }) + async acceptInvite( + @Param('token') token: string, + @Body() inviteUserDTO: InviteUserDto, + ) { + await this.usersApplication.acceptInvite(token, inviteUserDTO); + + return { + message: 'The invitation has been accepted successfully.', + }; + } + + /** + * Check if an invitation token is valid. + */ + @Get('check/:token') + @ApiOperation({ summary: 'Check if an invitation token is valid.' }) + async checkInvite(@Param('token') token: string) { + const inviteDetails = await this.usersApplication.checkInvite(token); + + return inviteDetails; + } +} diff --git a/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts b/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts index 5e948b1a0..d228a88c7 100644 --- a/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts +++ b/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as moment from 'moment'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ClsService } from 'nestjs-cls'; import { IAcceptInviteEventPayload, ICheckInviteEventPayload, @@ -15,6 +16,11 @@ import { UserInvite } from '../models/InviteUser.model'; import { ModelObject } from 'objection'; import { InviteUserDto } from '../dtos/InviteUser.dto'; +interface InviteAcceptResponseDto { + inviteToken: { email: string, token: string, createdAt: Date }; + orgName: string +} + @Injectable() export class AcceptInviteUserService { constructor( @@ -27,6 +33,7 @@ export class AcceptInviteUserService { @Inject(UserInvite.name) private readonly userInviteModel: typeof UserInvite, private readonly eventEmitter: EventEmitter2, + private readonly cls: ClsService, ) {} /** @@ -62,6 +69,16 @@ export class AcceptInviteUserService { // Clear invite token by the given user id. await this.clearInviteTokensByUserId(inviteToken.userId); + // Retrieve the tenant to get the organizationId for CLS. + const tenant = await this.tenantModel + .query() + .findById(inviteToken.tenantId); + + // Set CLS values for tenant context before triggering sync events. + this.cls.set('tenantId', inviteToken.tenantId); + this.cls.set('userId', systemUser.id); + this.cls.set('organizationId', tenant.organizationId); + // Triggers `onUserAcceptInvite` event. await this.eventEmitter.emitAsync(events.inviteUser.acceptInvite, { inviteToken, @@ -77,7 +94,7 @@ export class AcceptInviteUserService { */ public async checkInvite( token: string, - ): Promise<{ inviteToken: ModelObject; orgName: string }> { + ): Promise { const inviteToken = await this.getInviteTokenOrThrowError(token); // Find the tenant that associated to the given token. @@ -92,7 +109,16 @@ export class AcceptInviteUserService { tenant, } as ICheckInviteEventPayload); - return { inviteToken, orgName: tenant.metadata.name }; + // Explicitly convert to plain object to ensure all fields are serialized + const result = { + inviteToken: { + email: inviteToken.email, + token: inviteToken.token, + createdAt: inviteToken.createdAt, + }, + orgName: tenant.metadata.name, + }; + return result; } /** diff --git a/packages/server/src/modules/UsersModule/commands/InactivateUser.service.ts b/packages/server/src/modules/UsersModule/commands/InactivateUser.service.ts index 173f0e822..4e9758d0f 100644 --- a/packages/server/src/modules/UsersModule/commands/InactivateUser.service.ts +++ b/packages/server/src/modules/UsersModule/commands/InactivateUser.service.ts @@ -43,11 +43,11 @@ export class InactivateUserService { // Throw serivce error if the user is already inactivated. this.throwErrorIfUserInactive(tenantUser); - // Marks the tenant user as active. + // Marks the tenant user as inactive. await this.tenantUserModel() .query() .findById(userId) - .update({ active: true }); + .update({ active: false }); // Triggers `onTenantUserActivated` event. await this.eventEmitter.emitAsync(events.tenantUser.onInactivated, { diff --git a/packages/server/src/modules/UsersModule/commands/InviteUser.service.ts b/packages/server/src/modules/UsersModule/commands/InviteUser.service.ts index e78d78eb6..951b685c1 100644 --- a/packages/server/src/modules/UsersModule/commands/InviteUser.service.ts +++ b/packages/server/src/modules/UsersModule/commands/InviteUser.service.ts @@ -15,11 +15,13 @@ import { events } from '@/common/events/events'; import { Role } from '@/modules/Roles/models/Role.model'; import { ModelObject } from 'objection'; import { SendInviteUserDto } from '../dtos/InviteUser.dto'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; @Injectable() export class InviteTenantUserService { constructor( private readonly eventEmitter: EventEmitter2, + private readonly tenancyContext: TenancyContext, @Inject(TenantUser.name) private readonly tenantUserModel: TenantModelProxy, @@ -53,10 +55,18 @@ export class InviteTenantUserService { active: true, invitedAt: new Date(), }); + + // Retrieves the authorized user (inviting user). + const authorizedUser = await this.tenancyContext.getSystemUser(); + const invitingUser = await this.tenantUserModel() + .query() + .findOne({ systemUserId: authorizedUser.id }); + // Triggers `onUserSendInvite` event. await this.eventEmitter.emitAsync(events.inviteUser.sendInvite, { inviteToken, user, + invitingUser, } as IUserInvitedEventPayload); return { invitedUser: user }; diff --git a/packages/server/src/modules/UsersModule/commands/SendInviteUsersMailMessage.service.ts b/packages/server/src/modules/UsersModule/commands/SendInviteUsersMailMessage.service.ts index 4f0644c5e..eac363800 100644 --- a/packages/server/src/modules/UsersModule/commands/SendInviteUsersMailMessage.service.ts +++ b/packages/server/src/modules/UsersModule/commands/SendInviteUsersMailMessage.service.ts @@ -27,8 +27,8 @@ export class SendInviteUsersMailMessage { invite: ModelObject, ) { const tenant = await this.tenancyContext.getTenant(true); - const root = path.join(global.__views_dir, '/images/bigcapital.png'); - const baseURL = this.configService.get('baseURL'); + const root = path.join(global.__images_dirname, '/bigcapital.png'); + const baseURL = this.configService.get('app.baseUrl'); const mail = new Mail() .setSubject(`${fromUser.firstName} has invited you to join a Bigcapital`) diff --git a/packages/server/src/modules/UsersModule/models/InviteUser.model.ts b/packages/server/src/modules/UsersModule/models/InviteUser.model.ts index a69e11f69..fff6a1b3c 100644 --- a/packages/server/src/modules/UsersModule/models/InviteUser.model.ts +++ b/packages/server/src/modules/UsersModule/models/InviteUser.model.ts @@ -6,6 +6,7 @@ export class UserInvite extends BaseModel { userId!: number; tenantId!: number; email!: string; + createdAt!: Date; /** * Table name. @@ -32,4 +33,11 @@ export class UserInvite extends BaseModel { }, }; } + + /** + * Called before inserting a new record. + */ + $beforeInsert() { + this.createdAt = new Date(); + } } diff --git a/packages/server/src/modules/UsersModule/processors/SendInviteUserMail.processor.ts b/packages/server/src/modules/UsersModule/processors/SendInviteUserMail.processor.ts index 6677beaf9..202999234 100644 --- a/packages/server/src/modules/UsersModule/processors/SendInviteUserMail.processor.ts +++ b/packages/server/src/modules/UsersModule/processors/SendInviteUserMail.processor.ts @@ -1,7 +1,6 @@ -import { JOB_REF, Process, Processor } from '@nestjs/bull'; -import { Job } from 'bull'; -import { Inject, Scope } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { Scope } from '@nestjs/common'; import { ClsService, UseCls } from 'nestjs-cls'; import { SendInviteUserMailJob, @@ -14,19 +13,17 @@ import { SendInviteUsersMailMessage } from '../commands/SendInviteUsersMailMessa name: SendInviteUserMailQueue, scope: Scope.REQUEST, }) -export class SendInviteUserMailProcessor { +export class SendInviteUserMailProcessor extends WorkerHost { constructor( private readonly sendInviteUsersMailService: SendInviteUsersMailMessage, - @Inject(REQUEST) private readonly request: Request, - @Inject(JOB_REF) - private readonly jobRef: Job, private readonly clsService: ClsService, - ) { } + ) { + super(); + } - @Process(SendInviteUserMailJob) @UseCls() - async handleSendInviteMail() { - const { fromUser, invite, organizationId, userId } = this.jobRef.data; + async process(job: Job) { + const { fromUser, invite, organizationId, userId } = job.data; this.clsService.set('organizationId', organizationId); this.clsService.set('userId', userId); diff --git a/packages/server/src/modules/UsersModule/subscribers/InviteSendMailNotification.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/InviteSendMailNotification.subscriber.ts index d670dd776..19c4c6c0c 100644 --- a/packages/server/src/modules/UsersModule/subscribers/InviteSendMailNotification.subscriber.ts +++ b/packages/server/src/modules/UsersModule/subscribers/InviteSendMailNotification.subscriber.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { InjectQueue } from '@nestjs/bull'; -import { Queue } from 'bull'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; import { events } from '@/common/events/events'; import { OnEvent } from '@nestjs/event-emitter'; import { @@ -29,6 +29,7 @@ export default class InviteSendMainNotificationSubscribe { async sendMailNotification({ invite, user, + invitingUser, }: IUserInviteTenantSyncedEventPayload) { const tenant = await this.tenancyContext.getTenant(); const authedUser = await this.tenancyContext.getSystemUser(); @@ -36,8 +37,8 @@ export default class InviteSendMainNotificationSubscribe { const organizationId = tenant.organizationId; const userId = authedUser.id; - this.sendInviteMailQueue.add(SendInviteUserMailJob, { - fromUser: user, + await this.sendInviteMailQueue.add(SendInviteUserMailJob, { + fromUser: invitingUser, invite, userId, organizationId, diff --git a/packages/server/src/modules/UsersModule/subscribers/SyncSystemSendInvite.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/SyncSystemSendInvite.subscriber.ts index 1de7c337b..ed74118b0 100644 --- a/packages/server/src/modules/UsersModule/subscribers/SyncSystemSendInvite.subscriber.ts +++ b/packages/server/src/modules/UsersModule/subscribers/SyncSystemSendInvite.subscriber.ts @@ -33,7 +33,7 @@ export class SyncSystemSendInviteSubscriber { * @param {IUserInvitedEventPayload} payload - */ @OnEvent(events.inviteUser.sendInvite) - async syncSendInviteSystem({ inviteToken, user }: IUserInvitedEventPayload) { + async syncSendInviteSystem({ inviteToken, user, invitingUser }: IUserInvitedEventPayload) { const authorizedUser = await this.tenancyContext.getSystemUser(); const tenantId = authorizedUser.tenantId; @@ -63,6 +63,7 @@ export class SyncSystemSendInviteSubscriber { { invite, user, + invitingUser, } as IUserInviteTenantSyncedEventPayload, ); } diff --git a/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts index 90b5ba7ff..9e4fbc964 100644 --- a/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts +++ b/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts @@ -1,4 +1,4 @@ -import { omit } from 'lodash'; +import { pick } from 'lodash'; import * as moment from 'moment'; import { Inject, Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; @@ -22,13 +22,12 @@ export class SyncTenantAcceptInviteSubscriber { async syncTenantAcceptInvite({ inviteToken, user, - inviteUserDTO, }: IAcceptInviteEventPayload) { await this.tenantUserModel() .query() .where('systemUserId', inviteToken.userId) .update({ - ...omit(inviteUserDTO, ['password']), + ...pick(user, ['firstName', 'lastName', 'email', 'active']), inviteAcceptedAt: moment().format('YYYY-MM-DD'), }); } diff --git a/packages/server/src/modules/VendorCredit/VendorCredits.controller.ts b/packages/server/src/modules/VendorCredit/VendorCredits.controller.ts index a98bcf9b7..c4d08d31a 100644 --- a/packages/server/src/modules/VendorCredit/VendorCredits.controller.ts +++ b/packages/server/src/modules/VendorCredit/VendorCredits.controller.ts @@ -7,9 +7,10 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { VendorCreditsApplicationService } from './VendorCreditsApplication.service'; -import { IVendorCreditsQueryDTO } from './types/VendorCredit.types'; +import { GetVendorCreditsQueryDto } from './dtos/GetVendorCreditsQuery.dto'; import { ApiExtraModels, ApiOperation, @@ -26,17 +27,24 @@ import { BulkDeleteDto, ValidateBulkDeleteResponseDto, } from '@/common/dtos/BulkDelete.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { VendorCreditAction } from './types/VendorCredit.types'; @Controller('vendor-credits') @ApiTags('Vendor Credits') @ApiCommonHeaders() @ApiExtraModels(ValidateBulkDeleteResponseDto) +@UseGuards(AuthorizationGuard, PermissionGuard) export class VendorCreditsController { constructor( private readonly vendorCreditsApplication: VendorCreditsApplicationService, ) { } @Post('validate-bulk-delete') + @RequirePermission(VendorCreditAction.Delete, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Validates which vendor credits can be deleted and returns the results.', @@ -58,6 +66,7 @@ export class VendorCreditsController { } @Post('bulk-delete') + @RequirePermission(VendorCreditAction.Delete, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Deletes multiple vendor credits.' }) @ApiResponse({ status: 200, @@ -73,24 +82,28 @@ export class VendorCreditsController { } @Post() + @RequirePermission(VendorCreditAction.Create, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Create a new vendor credit.' }) async createVendorCredit(@Body() dto: CreateVendorCreditDto) { return this.vendorCreditsApplication.createVendorCredit(dto); } @Put(':id/open') + @RequirePermission(VendorCreditAction.Edit, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Open the given vendor credit.' }) async openVendorCredit(@Param('id') vendorCreditId: number) { return this.vendorCreditsApplication.openVendorCredit(vendorCreditId); } @Get() + @RequirePermission(VendorCreditAction.View, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Retrieves the vendor credits.' }) - async getVendorCredits(@Query() filterDTO: IVendorCreditsQueryDTO) { + async getVendorCredits(@Query() filterDTO: GetVendorCreditsQueryDto) { return this.vendorCreditsApplication.getVendorCredits(filterDTO); } @Put(':id') + @RequirePermission(VendorCreditAction.Edit, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Edit the given vendor credit.' }) async editVendorCredit( @Param('id') vendorCreditId: number, @@ -100,12 +113,14 @@ export class VendorCreditsController { } @Delete(':id') + @RequirePermission(VendorCreditAction.Delete, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Delete the given vendor credit.' }) async deleteVendorCredit(@Param('id') vendorCreditId: number) { return this.vendorCreditsApplication.deleteVendorCredit(vendorCreditId); } @Get(':id') + @RequirePermission(VendorCreditAction.View, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Retrieves the vendor credit details.' }) async getVendorCredit(@Param('id') vendorCreditId: number) { return this.vendorCreditsApplication.getVendorCredit(vendorCreditId); diff --git a/packages/server/src/modules/VendorCredit/VendorCreditsApplication.service.ts b/packages/server/src/modules/VendorCredit/VendorCreditsApplication.service.ts index 6d0a1003d..aacf02b6a 100644 --- a/packages/server/src/modules/VendorCredit/VendorCreditsApplication.service.ts +++ b/packages/server/src/modules/VendorCredit/VendorCreditsApplication.service.ts @@ -5,9 +5,9 @@ import { EditVendorCreditService } from './commands/EditVendorCredit.service'; import { GetVendorCreditService } from './queries/GetVendorCredit.service'; import { IVendorCreditEditDTO, - IVendorCreditsQueryDTO, } from './types/VendorCredit.types'; import { IVendorCreditCreateDTO } from './types/VendorCredit.types'; +import { GetVendorCreditsQueryDto } from './dtos/GetVendorCreditsQuery.dto'; import { Injectable } from '@nestjs/common'; import { OpenVendorCreditService } from './commands/OpenVendorCredit.service'; import { GetVendorCreditsService } from './queries/GetVendorCredits.service'; @@ -95,10 +95,10 @@ export class VendorCreditsApplicationService { /** * Retrieves the paginated filterable vendor credits list. - * @param {IVendorCreditsQueryDTO} query + * @param {GetVendorCreditsQueryDto} query * @returns {} */ - getVendorCredits(query: IVendorCreditsQueryDTO) { + getVendorCredits(query: GetVendorCreditsQueryDto) { return this.getVendorCreditsService.getVendorCredits(query); } diff --git a/packages/server/src/modules/VendorCredit/dtos/GetVendorCreditsQuery.dto.ts b/packages/server/src/modules/VendorCredit/dtos/GetVendorCreditsQuery.dto.ts new file mode 100644 index 000000000..fa3abc7a8 --- /dev/null +++ b/packages/server/src/modules/VendorCredit/dtos/GetVendorCreditsQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetVendorCreditsQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/VendorCredit/models/VendorCredit.ts b/packages/server/src/modules/VendorCredit/models/VendorCredit.ts index 11912c2b5..b66a1d49f 100644 --- a/packages/server/src/modules/VendorCredit/models/VendorCredit.ts +++ b/packages/server/src/modules/VendorCredit/models/VendorCredit.ts @@ -9,9 +9,12 @@ import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.dec import { ImportableModel } from '@/modules/Import/decorators/Import.decorator'; import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { VendorCreditMeta } from './VendorCredit.meta'; +import { sanitizeSortDirection } from '@/modules/DynamicListing/DynamicFilter/sanitizeSortDirection'; import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator'; import { VendorCreditDefaultViews } from '../constants'; +import { InjectAttachable } from '@/modules/Attachments/decorators/InjectAttachable.decorator'; +@InjectAttachable() @ExportableModel() @ImportableModel() @InjectModelMeta(VendorCreditMeta) @@ -196,8 +199,9 @@ export class VendorCredit extends TenantBaseModel { * */ sortByStatus(query, order) { + const dir = sanitizeSortDirection(order); query.orderByRaw( - `COALESCE(REFUNDED_AMOUNT) + COALESCE(INVOICED_AMOUNT) = COALESCE(AMOUNT) ${order}`, + `COALESCE(REFUNDED_AMOUNT) + COALESCE(INVOICED_AMOUNT) = COALESCE(AMOUNT) ${dir}`, ); }, }; diff --git a/packages/server/src/modules/VendorCredit/queries/GetVendorCredits.service.ts b/packages/server/src/modules/VendorCredit/queries/GetVendorCredits.service.ts index b0f1a75bd..ee4a6dd60 100644 --- a/packages/server/src/modules/VendorCredit/queries/GetVendorCredits.service.ts +++ b/packages/server/src/modules/VendorCredit/queries/GetVendorCredits.service.ts @@ -4,7 +4,7 @@ import { VendorCreditTransformer } from './VendorCreditTransformer'; import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service'; import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { VendorCredit } from '../models/VendorCredit'; -import { IVendorCreditsQueryDTO } from '../types/VendorCredit.types'; +import { GetVendorCreditsQueryDto } from '../dtos/GetVendorCreditsQuery.dto'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @Injectable() @@ -19,19 +19,19 @@ export class GetVendorCreditsService { /** * Parses the sale invoice list filter DTO. - * @param {IVendorCreditsQueryDTO} filterDTO + * @param {GetVendorCreditsQueryDto} filterDTO * @returns */ - private parseListFilterDTO = (filterDTO: IVendorCreditsQueryDTO) => { + private parseListFilterDTO = (filterDTO: GetVendorCreditsQueryDto) => { return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO); }; /** * Retrieve the vendor credits list. - * @param {IVendorCreditsQueryDTO} vendorCreditQuery - + * @param {GetVendorCreditsQueryDto} vendorCreditQuery - */ public getVendorCredits = async ( - vendorCreditQuery: IVendorCreditsQueryDTO, + vendorCreditQuery: GetVendorCreditsQueryDto, ) => { const filterDto = { sortOrder: 'desc', @@ -58,7 +58,7 @@ export class GetVendorCreditsService { // Gives ability to inject custom query to filter results. filterDto?.filterQuery && filterDto?.filterQuery(builder); }) - .pagination(filter.page - 1, filter.pageSize); + .pagination(filterDto.page - 1, filterDto.pageSize); // Transformes the vendor credits models to POJO. const vendorCredits = await this.transformer.transform( diff --git a/packages/server/src/modules/VendorCreditsApplyBills/VendorCreditApplyBills.controller.ts b/packages/server/src/modules/VendorCreditsApplyBills/VendorCreditApplyBills.controller.ts index d9a7f81e2..f1858d4a2 100644 --- a/packages/server/src/modules/VendorCreditsApplyBills/VendorCreditApplyBills.controller.ts +++ b/packages/server/src/modules/VendorCreditsApplyBills/VendorCreditApplyBills.controller.ts @@ -1,16 +1,34 @@ -import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + UseGuards, +} from '@nestjs/common'; import { VendorCreditApplyBillsApplicationService } from './VendorCreditApplyBillsApplication.service'; -import { IVendorCreditApplyToInvoicesDTO } from './types/VendorCreditApplyBills.types'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { ApplyVendorCreditToBillsDto } from './dtos/ApplyVendorCreditToBills.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { VendorCreditAction } from '../VendorCredit/types/VendorCredit.types'; @Controller('vendor-credits') @ApiTags('Vendor Credits Apply Bills') +@ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class VendorCreditApplyBillsController { constructor( private readonly vendorCreditApplyBillsApplication: VendorCreditApplyBillsApplicationService, ) {} @Get(':vendorCreditId/bills-to-apply') + @RequirePermission(VendorCreditAction.View, AbilitySubject.VendorCredit) + @ApiOperation({ summary: 'Get bills that can be applied with this vendor credit.' }) async getVendorCreditToApplyBills( @Param('vendorCreditId') vendorCreditId: number, ) { @@ -20,9 +38,12 @@ export class VendorCreditApplyBillsController { } @Post(':vendorCreditId/apply-to-bills') + @RequirePermission(VendorCreditAction.Edit, AbilitySubject.VendorCredit) + @ApiOperation({ summary: 'Apply vendor credit to the given bills.' }) + @ApiBody({ type: ApplyVendorCreditToBillsDto }) async applyVendorCreditToBills( @Param('vendorCreditId') vendorCreditId: number, - @Body() applyCreditToBillsDTO: IVendorCreditApplyToInvoicesDTO, + @Body() applyCreditToBillsDTO: ApplyVendorCreditToBillsDto, ) { return this.vendorCreditApplyBillsApplication.applyVendorCreditToBills( vendorCreditId, @@ -31,6 +52,8 @@ export class VendorCreditApplyBillsController { } @Delete('applied-bills/:vendorCreditAppliedBillId') + @RequirePermission(VendorCreditAction.Edit, AbilitySubject.VendorCredit) + @ApiOperation({ summary: 'Remove an applied bill from the vendor credit.' }) async deleteAppliedBillToVendorCredit( @Param('vendorCreditAppliedBillId') vendorCreditAppliedBillId: number, ) { @@ -40,6 +63,8 @@ export class VendorCreditApplyBillsController { } @Get(':vendorCreditId/applied-bills') + @RequirePermission(VendorCreditAction.View, AbilitySubject.VendorCredit) + @ApiOperation({ summary: 'Get bills already applied to this vendor credit.' }) async getAppliedBillsToVendorCredit( @Param('vendorCreditId') vendorCreditId: number, ) { diff --git a/packages/server/src/modules/VendorCreditsApplyBills/command/ApplyVendorCreditSyncBills.service.ts b/packages/server/src/modules/VendorCreditsApplyBills/command/ApplyVendorCreditSyncBills.service.ts index 60a002ac6..eb22d212c 100644 --- a/packages/server/src/modules/VendorCreditsApplyBills/command/ApplyVendorCreditSyncBills.service.ts +++ b/packages/server/src/modules/VendorCreditsApplyBills/command/ApplyVendorCreditSyncBills.service.ts @@ -1,4 +1,4 @@ -import Bluebird from 'bluebird'; +import * as Bluebird from 'bluebird'; import { Inject, Injectable } from '@nestjs/common'; import { Knex } from 'knex'; import { IVendorCreditAppliedBill } from '../types/VendorCreditApplyBills.types'; diff --git a/packages/server/src/modules/VendorCreditsApplyBills/dtos/ApplyVendorCreditToBills.dto.ts b/packages/server/src/modules/VendorCreditsApplyBills/dtos/ApplyVendorCreditToBills.dto.ts new file mode 100644 index 000000000..77668bd8f --- /dev/null +++ b/packages/server/src/modules/VendorCreditsApplyBills/dtos/ApplyVendorCreditToBills.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsInt, + IsNotEmpty, + IsNumber, + ValidateNested, +} from 'class-validator'; + +export class ApplyVendorCreditToBillEntryDto { + @IsNotEmpty() + @IsInt() + @ApiProperty({ description: 'Bill ID to apply vendor credit to', example: 1 }) + billId: number; + + @IsNotEmpty() + @IsNumber() + @ApiProperty({ description: 'Amount to apply', example: 100.5 }) + amount: number; +} + +export class ApplyVendorCreditToBillsDto { + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => ApplyVendorCreditToBillEntryDto) + @ApiProperty({ + description: 'Entries of bill ID and amount to apply', + type: [ApplyVendorCreditToBillEntryDto], + example: [ + { billId: 1, amount: 100.5 }, + { billId: 2, amount: 50 }, + ], + }) + entries: ApplyVendorCreditToBillEntryDto[]; +} diff --git a/packages/server/src/modules/VendorCreditsApplyBills/models/VendorCreditAppliedBill.ts b/packages/server/src/modules/VendorCreditsApplyBills/models/VendorCreditAppliedBill.ts index 2a4ab9769..36f2a1af9 100644 --- a/packages/server/src/modules/VendorCreditsApplyBills/models/VendorCreditAppliedBill.ts +++ b/packages/server/src/modules/VendorCreditsApplyBills/models/VendorCreditAppliedBill.ts @@ -26,7 +26,7 @@ export class VendorCreditAppliedBill extends BaseModel { * Timestamps columns. */ public get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** diff --git a/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.application.ts b/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.application.ts index bf488a768..38834239d 100644 --- a/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.application.ts +++ b/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.application.ts @@ -3,6 +3,7 @@ import { DeleteRefundVendorCreditService } from './commands/DeleteRefundVendorCr import { RefundVendorCredit } from './models/RefundVendorCredit'; import { CreateRefundVendorCredit } from './commands/CreateRefundVendorCredit.service'; import { RefundVendorCreditDto } from './dtos/RefundVendorCredit.dto'; +import { GetRefundVendorCreditService } from './queries/GetRefundVendorCredit.service'; import { GetRefundVendorCreditsService } from './queries/GetRefundVendorCredits.service'; import { IRefundVendorCreditPOJO } from './types/VendorCreditRefund.types'; @@ -12,11 +13,13 @@ export class VendorCreditsRefundApplication { * @param {CreateRefundVendorCredit} createRefundVendorCreditService * @param {DeleteRefundVendorCreditService} deleteRefundVendorCreditService * @param {GetRefundVendorCreditsService} getRefundVendorCreditsService + * @param {GetRefundVendorCreditService} getRefundVendorCreditService */ constructor( private readonly createRefundVendorCreditService: CreateRefundVendorCredit, private readonly deleteRefundVendorCreditService: DeleteRefundVendorCreditService, private readonly getRefundVendorCreditsService: GetRefundVendorCreditsService, + private readonly getRefundVendorCreditService: GetRefundVendorCreditService, ) { } /** @@ -48,6 +51,19 @@ export class VendorCreditsRefundApplication { ); } + /** + * Retrieve a single refund vendor credit transaction by id. + * @param {number} refundCreditId + * @returns {Promise} + */ + public getRefundVendorCreditTransaction( + refundCreditId: number, + ): Promise { + return this.getRefundVendorCreditService.getRefundCreditTransaction( + refundCreditId, + ); + } + /** * Deletes a refund vendor credit. * @param {number} refundCreditId diff --git a/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.controller.ts b/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.controller.ts index 9951cb6bc..f7d0081c9 100644 --- a/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.controller.ts +++ b/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.controller.ts @@ -1,22 +1,55 @@ -import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + UseGuards, +} from '@nestjs/common'; import { VendorCreditsRefundApplication } from './VendorCreditsRefund.application'; import { RefundVendorCredit } from './models/RefundVendorCredit'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { RefundVendorCreditDto } from './dtos/RefundVendorCredit.dto'; +import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { VendorCreditAction } from '../VendorCredit/types/VendorCredit.types'; @Controller('vendor-credits') @ApiTags('Vendor Credits Refunds') +@ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class VendorCreditsRefundController { constructor( private readonly vendorCreditsRefundApplication: VendorCreditsRefundApplication, ) { } + /** + * Retrieve a single refund vendor credit transaction by id. + * @param {number} refundCreditId + * @returns {Promise} + */ + @Get('refunds/:refundCreditId') + @RequirePermission(VendorCreditAction.View, AbilitySubject.VendorCredit) + @ApiOperation({ summary: 'Retrieve a refund vendor credit transaction by id.' }) + public getRefundVendorCreditTransaction( + @Param('refundCreditId') refundCreditId: string, + ) { + return this.vendorCreditsRefundApplication.getRefundVendorCreditTransaction( + Number(refundCreditId), + ); + } + /** * Retrieve the vendor credit refunds graph. * @param {number} vendorCreditId - Vendor credit id. * @returns {Promise} */ @Get(':vendorCreditId/refund') + @RequirePermission(VendorCreditAction.View, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Retrieve the vendor credit refunds graph.' }) public getVendorCreditRefunds( @Param('vendorCreditId') vendorCreditId: string, @@ -33,6 +66,7 @@ export class VendorCreditsRefundController { * @returns {Promise} */ @Post(':vendorCreditId/refund') + @RequirePermission(VendorCreditAction.Refund, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Create a refund for the given vendor credit.' }) public async createRefundVendorCredit( @Param('vendorCreditId') vendorCreditId: string, @@ -50,6 +84,7 @@ export class VendorCreditsRefundController { * @returns {Promise} */ @Delete('refunds/:refundCreditId') + @RequirePermission(VendorCreditAction.Refund, AbilitySubject.VendorCredit) @ApiOperation({ summary: 'Delete a refund for the given vendor credit.' }) public async deleteRefundVendorCredit( @Param('refundCreditId') refundCreditId: string, diff --git a/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.module.ts b/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.module.ts index 894f48485..49aa421cc 100644 --- a/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.module.ts +++ b/packages/server/src/modules/VendorCreditsRefund/VendorCreditsRefund.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { GetRefundVendorCreditService } from './queries/GetRefundVendorCredit.service'; import { GetRefundVendorCreditsService } from './queries/GetRefundVendorCredits.service'; import { DeleteRefundVendorCreditService } from './commands/DeleteRefundVendorCredit.service'; import { VendorCreditsRefundController } from './VendorCreditsRefund.controller'; @@ -14,6 +15,7 @@ import { AccountsModule } from '../Accounts/Accounts.module'; @Module({ imports: [WarehousesModule, BranchesModule, LedgerModule, AccountsModule], providers: [ + GetRefundVendorCreditService, GetRefundVendorCreditsService, DeleteRefundVendorCreditService, CreateRefundVendorCredit, diff --git a/packages/server/src/modules/VendorCreditsRefund/models/RefundVendorCredit.ts b/packages/server/src/modules/VendorCreditsRefund/models/RefundVendorCredit.ts index aabc2e48e..e3013994a 100644 --- a/packages/server/src/modules/VendorCreditsRefund/models/RefundVendorCredit.ts +++ b/packages/server/src/modules/VendorCreditsRefund/models/RefundVendorCredit.ts @@ -32,7 +32,7 @@ export class RefundVendorCredit extends BaseModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /* diff --git a/packages/server/src/modules/Vendors/Vendors.controller.ts b/packages/server/src/modules/Vendors/Vendors.controller.ts index f2d212b8b..f3c29030c 100644 --- a/packages/server/src/modules/Vendors/Vendors.controller.ts +++ b/packages/server/src/modules/Vendors/Vendors.controller.ts @@ -7,10 +7,12 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { VendorsApplication } from './VendorsApplication.service'; import { VendorOpeningBalanceEditDto } from './dtos/VendorOpeningBalanceEdit.dto'; import { + ApiExtraModels, ApiOperation, ApiResponse, ApiTags, @@ -24,44 +26,57 @@ import { BulkDeleteVendorsDto, ValidateBulkDeleteVendorsResponseDto, } from './dtos/BulkDeleteVendors.dto'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { VendorAction } from '../Customers/types/Customers.types'; @Controller('vendors') @ApiTags('Vendors') +@ApiExtraModels(ValidateBulkDeleteVendorsResponseDto) @ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) export class VendorsController { constructor(private vendorsApplication: VendorsApplication) {} @Get() + @RequirePermission(VendorAction.View, AbilitySubject.Vendor) @ApiOperation({ summary: 'Retrieves the vendors.' }) getVendors(@Query() filterDTO: GetVendorsQueryDto) { return this.vendorsApplication.getVendors(filterDTO); } @Get(':id') + @RequirePermission(VendorAction.View, AbilitySubject.Vendor) @ApiOperation({ summary: 'Retrieves the vendor details.' }) getVendor(@Param('id') vendorId: number) { return this.vendorsApplication.getVendor(vendorId); } @Post() + @RequirePermission(VendorAction.Create, AbilitySubject.Vendor) @ApiOperation({ summary: 'Create a new vendor.' }) createVendor(@Body() vendorDTO: CreateVendorDto) { return this.vendorsApplication.createVendor(vendorDTO); } @Put(':id') + @RequirePermission(VendorAction.Edit, AbilitySubject.Vendor) @ApiOperation({ summary: 'Edit the given vendor.' }) editVendor(@Param('id') vendorId: number, @Body() vendorDTO: EditVendorDto) { return this.vendorsApplication.editVendor(vendorId, vendorDTO); } @Delete(':id') + @RequirePermission(VendorAction.Delete, AbilitySubject.Vendor) @ApiOperation({ summary: 'Delete the given vendor.' }) deleteVendor(@Param('id') vendorId: number) { return this.vendorsApplication.deleteVendor(vendorId); } @Put(':id/opening-balance') + @RequirePermission(VendorAction.Edit, AbilitySubject.Vendor) @ApiOperation({ summary: 'Edit the given vendor opening balance.' }) editOpeningBalance( @Param('id') vendorId: number, @@ -74,6 +89,7 @@ export class VendorsController { } @Post('validate-bulk-delete') + @RequirePermission(VendorAction.Delete, AbilitySubject.Vendor) @ApiOperation({ summary: 'Validates which vendors can be deleted and returns counts of deletable and non-deletable vendors.', @@ -93,6 +109,7 @@ export class VendorsController { } @Post('bulk-delete') + @RequirePermission(VendorAction.Delete, AbilitySubject.Vendor) @ApiOperation({ summary: 'Deletes multiple vendors in bulk.' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/Vendors/dtos/CreateVendor.dto.ts b/packages/server/src/modules/Vendors/dtos/CreateVendor.dto.ts index 65cf9fe4e..fd97a23fb 100644 --- a/packages/server/src/modules/Vendors/dtos/CreateVendor.dto.ts +++ b/packages/server/src/modules/Vendors/dtos/CreateVendor.dto.ts @@ -115,4 +115,13 @@ export class CreateVendorDto extends ContactAddressDto { @IsOptional() @IsBoolean() active?: boolean; + + @ApiProperty({ + required: false, + description: 'Vendor code', + example: 'VEND-001', + }) + @IsOptional() + @IsString() + code?: string; } diff --git a/packages/server/src/modules/Vendors/dtos/EditVendor.dto.ts b/packages/server/src/modules/Vendors/dtos/EditVendor.dto.ts index 1396b948a..8e2980b40 100644 --- a/packages/server/src/modules/Vendors/dtos/EditVendor.dto.ts +++ b/packages/server/src/modules/Vendors/dtos/EditVendor.dto.ts @@ -1,5 +1,6 @@ import { ContactAddressDto } from '@/modules/Customers/dtos/ContactAddress.dto'; -import { IsEmail, IsString, IsBoolean, IsOptional } from 'class-validator'; +import { IsEmail, IsString, IsBoolean } from 'class-validator'; +import { IsOptional } from '@/common/decorators/Validators'; import { ApiProperty } from '@nestjs/swagger'; export class EditVendorDto extends ContactAddressDto { @@ -48,7 +49,10 @@ export class EditVendorDto extends ContactAddressDto { @IsString() personalPhone?: string; - @ApiProperty({ required: false, description: 'Additional notes about the vendor' }) + @ApiProperty({ + required: false, + description: 'Additional notes about the vendor', + }) @IsOptional() @IsString() note?: string; @@ -57,4 +61,9 @@ export class EditVendorDto extends ContactAddressDto { @IsOptional() @IsBoolean() active?: boolean; + + @ApiProperty({ required: false, description: 'Vendor code' }) + @IsOptional() + @IsString() + code?: string; } diff --git a/packages/server/src/modules/Vendors/models/Vendor.ts b/packages/server/src/modules/Vendors/models/Vendor.ts index 8ccd81640..c03a39c68 100644 --- a/packages/server/src/modules/Vendors/models/Vendor.ts +++ b/packages/server/src/modules/Vendors/models/Vendor.ts @@ -71,6 +71,8 @@ export class Vendor extends TenantBaseModel { note: string; active: boolean; + code?: string; + /** * Query builder. */ diff --git a/packages/server/src/modules/Vendors/types/Vendors.types.ts b/packages/server/src/modules/Vendors/types/Vendors.types.ts index 381e3abc9..47a8d360f 100644 --- a/packages/server/src/modules/Vendors/types/Vendors.types.ts +++ b/packages/server/src/modules/Vendors/types/Vendors.types.ts @@ -31,6 +31,7 @@ export interface IVendorNewDTO extends IContactAddressDTO { note?: string; active?: boolean; + code?: string; } export interface IVendorEditDTO extends IContactAddressDTO { salutation?: string; @@ -46,6 +47,7 @@ export interface IVendorEditDTO extends IContactAddressDTO { note?: string; active?: boolean; + code?: string; } export interface IVendorsFilter extends IDynamicListFilter { diff --git a/packages/server/src/modules/Views/models/ViewColumn.model.ts b/packages/server/src/modules/Views/models/ViewColumn.model.ts index 7a7b5c1a0..b93a5e619 100644 --- a/packages/server/src/modules/Views/models/ViewColumn.model.ts +++ b/packages/server/src/modules/Views/models/ViewColumn.model.ts @@ -8,6 +8,13 @@ export class ViewColumn extends BaseModel { return 'view_has_columns'; } + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/modules/Views/models/ViewRole.model.ts b/packages/server/src/modules/Views/models/ViewRole.model.ts index 7af0a8f1a..0d6f3d024 100644 --- a/packages/server/src/modules/Views/models/ViewRole.model.ts +++ b/packages/server/src/modules/Views/models/ViewRole.model.ts @@ -25,6 +25,13 @@ export class ViewRole extends BaseModel { return 'view_roles'; } + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/modules/Warehouses/models/ItemWarehouseQuantity.ts b/packages/server/src/modules/Warehouses/models/ItemWarehouseQuantity.ts index 5ff763dd3..8640e1bd3 100644 --- a/packages/server/src/modules/Warehouses/models/ItemWarehouseQuantity.ts +++ b/packages/server/src/modules/Warehouses/models/ItemWarehouseQuantity.ts @@ -9,6 +9,13 @@ export class ItemWarehouseQuantity extends BaseModel{ return 'items_warehouses_quantity'; } + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Relation mappings. */ diff --git a/packages/server/src/modules/Warehouses/models/Warehouse.model.ts b/packages/server/src/modules/Warehouses/models/Warehouse.model.ts index b0b9683bf..be51d687b 100644 --- a/packages/server/src/modules/Warehouses/models/Warehouse.model.ts +++ b/packages/server/src/modules/Warehouses/models/Warehouse.model.ts @@ -24,7 +24,7 @@ export class Warehouse extends BaseModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** diff --git a/packages/server/src/modules/WarehousesTransfers/models/WarehouseTransfer.ts b/packages/server/src/modules/WarehousesTransfers/models/WarehouseTransfer.ts index 3146b91c1..071821a69 100644 --- a/packages/server/src/modules/WarehousesTransfers/models/WarehouseTransfer.ts +++ b/packages/server/src/modules/WarehousesTransfers/models/WarehouseTransfer.ts @@ -28,7 +28,7 @@ export class WarehouseTransfer extends TenantBaseModel { * Timestamps columns. */ get timestamps() { - return ['created_at', 'updated_at']; + return ['createdAt', 'updatedAt']; } /** diff --git a/packages/server/src/modules/WarehousesTransfers/models/WarehouseTransferEntry.ts b/packages/server/src/modules/WarehousesTransfers/models/WarehouseTransferEntry.ts index 894bffcfb..7acfc3155 100644 --- a/packages/server/src/modules/WarehousesTransfers/models/WarehouseTransferEntry.ts +++ b/packages/server/src/modules/WarehousesTransfers/models/WarehouseTransferEntry.ts @@ -19,6 +19,13 @@ export class WarehouseTransferEntry extends TenantBaseModel { return 'warehouses_transfers_entries'; } + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + /** * Virtual attributes. */ diff --git a/packages/server/test/expenses.e2e-spec.ts b/packages/server/test/expenses.e2e-spec.ts index 9d9850cba..1d86645a9 100644 --- a/packages/server/test/expenses.e2e-spec.ts +++ b/packages/server/test/expenses.e2e-spec.ts @@ -77,4 +77,16 @@ describe('Expenses (e2e)', () => { .set('Authorization', AuthorizationHeader) .expect(200); }); + + it('/expenses (GET) honors page and pageSize query params', async () => { + const response = await request(app.getHttpServer()) + .get('/expenses?page=2&pageSize=5') + .set('organization-id', orgainzationId) + .set('Authorization', AuthorizationHeader) + .expect(200); + + expect(response.body.pagination).toBeDefined(); + expect(response.body.pagination.page).toBe(2); + expect(response.body.pagination.page_size).toBe(5); + }); }); diff --git a/packages/server/test/jest-e2e.json b/packages/server/test/jest-e2e.json index 9a90d0fe3..78a1c028e 100644 --- a/packages/server/test/jest-e2e.json +++ b/packages/server/test/jest-e2e.json @@ -2,7 +2,7 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", + "testRegex": "auth.e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/packages/webapp/Dockerfile b/packages/webapp/Dockerfile index 88d2f5f6c..3844cbdb2 100644 --- a/packages/webapp/Dockerfile +++ b/packages/webapp/Dockerfile @@ -4,7 +4,7 @@ FROM node:18.16.0-alpine AS builder WORKDIR /app # Install pnpm -RUN npm install -g pnpm@8.10.2 +RUN npm install -g pnpm@9.0.5 # Install build dependencies RUN apk add --no-cache python3 build-base chromium @@ -15,18 +15,13 @@ ENV PYTHON=/usr/bin/python3 # Copy package files for dependency installation COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml lerna.json ./ COPY --chown=node:node packages/webapp/package.json ./packages/webapp/ -COPY --chown=node:node shared/bigcapital-utils/package.json ./shared/bigcapital-utils/ -COPY --chown=node:node shared/pdf-templates/package.json ./shared/pdf-templates/ -COPY --chown=node:node shared/email-components/package.json ./shared/email-components/ +COPY --chown=node:node shared ./shared # Install all dependencies (including devDependencies for build) RUN pnpm install # Copy source code for webapp and dependencies COPY --chown=node:node ./packages/webapp ./packages/webapp -COPY --chown=node:node ./shared/bigcapital-utils ./shared/bigcapital-utils -COPY --chown=node:node ./shared/pdf-templates ./shared/pdf-templates -COPY --chown=node:node ./shared/email-components ./shared/email-components # Build webapp package RUN pnpm run build:webapp diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 8172fc1a9..77653d103 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -5,6 +5,7 @@ "dependencies": { "@bigcapital/email-components": "workspace:*", "@bigcapital/pdf-templates": "workspace:*", + "@bigcapital/sdk-ts": "workspace:*", "@bigcapital/utils": "workspace:*", "@blueprintjs-formik/core": "^0.3.7", "@blueprintjs-formik/datetime": "^0.4.0", @@ -137,6 +138,8 @@ "build": "vite build", "preview": "cross-env PORT=4173 vite preview", "typecheck": "tsc --noEmit", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,md}\"", "test": "node scripts/test.js", "storybook": "start-storybook -p 6006" }, diff --git a/packages/webapp/src/components/Currencies/CurrencySelectList.tsx b/packages/webapp/src/components/Currencies/CurrencySelectList.tsx index 10c4bcb08..96739f954 100644 --- a/packages/webapp/src/components/Currencies/CurrencySelectList.tsx +++ b/packages/webapp/src/components/Currencies/CurrencySelectList.tsx @@ -19,7 +19,7 @@ export function CurrencySelectList({ name={name} items={items} textAccessor={'currency_code'} - valueAccessor={'id'} + valueAccessor={'currency_code'} placeholder={placeholder} popoverProps={{ minimal: true, usePortal: true, inline: false }} {...props} diff --git a/packages/webapp/src/components/Select/DisplayNameList.tsx b/packages/webapp/src/components/Select/DisplayNameList.tsx index d42da993c..442f0ca89 100644 --- a/packages/webapp/src/components/Select/DisplayNameList.tsx +++ b/packages/webapp/src/components/Select/DisplayNameList.tsx @@ -4,6 +4,11 @@ import { FSelect } from '../Forms'; import { useFormikContext } from 'formik'; export type DisplayNameListItem = { label: string }; +type DisplayNameFormat = { + format: string; + values: Array; + required: number[]; +}; export interface DisplayNameListProps extends Omit< @@ -11,6 +16,47 @@ export interface DisplayNameListProps 'items' | 'valueAccessor' | 'textAccessor' | 'labelAccessor' > {} +function useDisplayNameFormatOptions( + salutation?: string, + firstName?: string, + lastName?: string, + companyName?: string, +): DisplayNameListItem[] { + return useMemo(() => { + const formats: DisplayNameFormat[] = [ + { + format: '{1} {2} {3}', + values: [salutation, firstName, lastName], + required: [1], + }, + { format: '{1} {2}', values: [firstName, lastName], required: [] }, + { format: '{1}, {2}', values: [firstName, lastName], required: [1, 2] }, + { format: '{1}', values: [companyName], required: [1] }, + ]; + + return formats + .filter( + (format) => + !format.values.some((value, index) => { + return !value && format.required.indexOf(index + 1) !== -1; + }), + ) + .map((formatOption) => { + const { format, values } = formatOption; + let label = format; + + values.forEach((value, index) => { + const replaceWith = value || ''; + label = label.replace(`{${index + 1}}`, replaceWith).trim(); + }); + return { + label: label.replace(/\s+/g, ' ').replace(/\s+,/g, ',').trim(), + }; + }) + .filter(({ label }) => Boolean(label)); + }, [salutation, firstName, lastName, companyName]); +} + export function DisplayNameList({ ...restProps }: DisplayNameListProps) { const { values: { @@ -21,40 +67,11 @@ export function DisplayNameList({ ...restProps }: DisplayNameListProps) { }, } = useFormikContext(); - const formats = useMemo( - () => [ - { - format: '{1} {2} {3}', - values: [salutation, firstName, lastName], - required: [1], - }, - { format: '{1} {2}', values: [firstName, lastName], required: [] }, - { format: '{1}, {2}', values: [firstName, lastName], required: [1, 2] }, - { format: '{1}', values: [companyName], required: [1] }, - ], - [firstName, lastName, companyName, salutation], - ); - - const formatOptions: DisplayNameListItem[] = useMemo( - () => - formats - .filter( - (format) => - !format.values.some((value, index) => { - return !value && format.required.indexOf(index + 1) !== -1; - }), - ) - .map((formatOption) => { - const { format, values } = formatOption; - let label = format; - - values.forEach((value, index) => { - const replaceWith = value || ''; - label = label.replace(`{${index + 1}}`, replaceWith).trim(); - }); - return { label: label.replace(/\s+/g, ' ') }; - }), - [formats], + const formatOptions = useDisplayNameFormatOptions( + salutation, + firstName, + lastName, + companyName, ); return ( @@ -62,6 +79,7 @@ export function DisplayNameList({ ...restProps }: DisplayNameListProps) { items={formatOptions} valueAccessor={'label'} textAccessor={'label'} + labelAccessor={'_label'} placeholder={intl.get('select_display_name_as')} filterable={false} {...restProps} diff --git a/packages/webapp/src/components/Select/SalutationList.tsx b/packages/webapp/src/components/Select/SalutationList.tsx index a57dd1229..d869b20fc 100644 --- a/packages/webapp/src/components/Select/SalutationList.tsx +++ b/packages/webapp/src/components/Select/SalutationList.tsx @@ -28,6 +28,7 @@ export function SalutationList({ ...restProps }: SalutationListProps) { items={items} valueAccessor={'key'} textAccessor={'label'} + labelAccessor={'_label'} placeholder={intl.get('salutation')} filterable={false} {...restProps} diff --git a/packages/webapp/src/components/TextStatus/index.tsx b/packages/webapp/src/components/TextStatus/index.tsx index 1c3d5fe03..a46a0e81d 100644 --- a/packages/webapp/src/components/TextStatus/index.tsx +++ b/packages/webapp/src/components/TextStatus/index.tsx @@ -10,12 +10,17 @@ const TextStatusRoot = styled.span` ${(props) => props.intent === 'warning' && ` - color: #ec5b0a;`} + color: #c87619;`} + + ${(props) => + props.intent === 'danger' && + ` + color: #f17377;`} ${(props) => props.intent === 'success' && ` - color: #2ba01d;`} + color: #238551;`} ${(props) => props.intent === 'none' && diff --git a/packages/webapp/src/components/UniversalSearch/UniversalSearch.tsx b/packages/webapp/src/components/UniversalSearch/UniversalSearch.tsx index 9d65ef565..5874abba3 100644 --- a/packages/webapp/src/components/UniversalSearch/UniversalSearch.tsx +++ b/packages/webapp/src/components/UniversalSearch/UniversalSearch.tsx @@ -1,7 +1,5 @@ -// @ts-nocheck -import React from 'react'; +import React, { KeyboardEvent, ReactNode } from 'react'; import intl from 'react-intl-universal'; -import classNames from 'classnames'; import { isUndefined } from 'lodash'; import { Overlay, @@ -10,11 +8,14 @@ import { MenuItem, Spinner, Intent, + OverlayProps, + Button, } from '@blueprintjs/core'; -import { QueryList } from '@blueprintjs/select'; -import { CLASSES } from '@/constants/classes'; - -import { Icon, If, ListSelect, FormattedMessage as T } from '@/components'; +import { QueryList, ItemRenderer } from '@blueprintjs/select'; +import { x } from '@xstyled/emotion'; +import { css } from '@emotion/css'; +import { Icon, If, FormattedMessage as T } from '@/components'; +import { Select } from '@blueprintjs-formik/select'; import { UniversalSearchProvider, useUniversalSearchContext, @@ -22,59 +23,297 @@ import { import { filterItemsByResourceType } from './utils'; import { RESOURCES_TYPES } from '@/constants/resourcesTypes'; +// Resource type from RESOURCES_TYPES constant +type ResourceType = string; + +// Search type option item +interface SearchTypeOption { + key: ResourceType; + label: string; +} + +// Universal search item +interface UniversalSearchItem { + id: number | string; + _type: ResourceType; + text: string; + subText?: string; + label?: string; + [key: string]: any; +} + +// CSS styles for complex selectors +const overlayStyles = css` + .bp4-overlay-appear, + .bp4-overlay-enter { + filter: blur(20px); + opacity: 0.2; + } + .bp4-overlay-appear-active, + .bp4-overlay-enter-active { + filter: blur(0); + opacity: 1; + transition: + filter 0.2s cubic-bezier(0.4, 1, 0.75, 0.9), + opacity 0.2s cubic-bezier(0.4, 1, 0.75, 0.9); + } + .bp4-overlay-exit { + filter: blur(0); + opacity: 1; + } + .bp4-overlay-exit-active { + filter: blur(20px); + opacity: 0.2; + transition: + filter 0.2s cubic-bezier(0.4, 1, 0.75, 0.9), + opacity 0.2s cubic-bezier(0.4, 1, 0.75, 0.9); + } +`; + +const containerStyles = css` + position: fixed; + filter: blur(0); + opacity: 1; + background-color: var(--color-universal-search-background); + border-radius: 3px; + box-shadow: + 0 0 0 1px rgba(16, 22, 26, 0.1), + 0 4px 8px rgba(16, 22, 26, 0.2), + 0 18px 46px 6px rgba(16, 22, 26, 0.2); + left: calc(50% - 250px); + top: 20vh; + width: 500px; + z-index: 20; + + .bp4-input-group { + .bp4-icon { + margin: 16px; + color: var(--color-universal-search-icon); + + svg { + stroke: currentColor; + fill: none; + fill-rule: evenodd; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; + --text-opacity: 1; + } + } + } + + .bp4-input-group .bp4-input { + border: 0; + box-shadow: 0 0 0 0; + height: 50px; + line-height: 50px; + font-size: 20px; + } + .bp4-input-group.bp4-large .bp4-input:not(:first-child) { + padding-left: 50px !important; + } + .bp4-input-group.bp4-large .bp4-input:not(:last-child) { + padding-right: 130px !important; + } + + .bp4-menu { + border-top: 1px solid var(--color-universal-search-menu-border); + max-height: calc(60vh - 20px); + overflow: auto; + + .bp4-menu-item { + .bp4-text-muted { + font-size: 12px; + + .bp4-icon { + color: var(--bp4-gray-600); + } + } + &.bp4-intent-primary { + &.bp4-active { + background-color: var(--bp4-blue-100); + color: var(--bp4-dark-gray-800); + + .bp4-menu-item-label { + color: var(--bp4-gray-600); + } + } + } + + &-label { + flex-direction: row; + text-align: right; + } + } + } + + .bp4-input-action { + height: 100%; + display: flex; + flex-direction: row; + align-items: center; + } +`; + +const inputRightElementsStyles = css` + display: flex; + margin: 10px; + + .bp4-spinner { + margin-right: 6px; + } +`; + +const footerStyles = css` + padding: 12px 12px; + border-top: 1px solid var(--color-universal-search-footer-divider); +`; + +const actionBaseStyles = css` + &:not(:first-of-type) { + margin-left: 14px; + } + + .bp4-tag { + background: var(--color-universal-search-tag-background); + color: var(--color-universal-search-tag-text); + } +`; + +const actionArrowsStyles = css` + &:not(:first-of-type) { + margin-left: 14px; + } + + .bp4-tag { + background: var(--color-universal-search-tag-background); + color: var(--color-universal-search-tag-text); + padding: 0; + text-align: center; + line-height: 16px; + margin-left: 4px; + + svg { + fill: var(--color-universal-search-tag-text); + height: 100%; + display: block; + width: 100%; + padding: 2px; + } + } +`; + +// UniversalSearchInputRightElements props +interface UniversalSearchInputRightElementsProps { + /** Callback when search type changes */ + onSearchTypeChange?: (option: SearchTypeOption) => void; +} + /** * Universal search input action. */ -function UniversalSearchInputRightElements({ onSearchTypeChange }) { - const { isLoading, searchType, defaultSearchResource, searchTypeOptions } = +function UniversalSearchInputRightElements({ + onSearchTypeChange, +}: UniversalSearchInputRightElementsProps) { + const { isLoading, searchType, searchTypeOptions } = useUniversalSearchContext(); + // Find the currently selected item object. + const selectedItem = searchTypeOptions.find( + (item) => item.key === searchType, + ); + // Handle search type option change. - const handleSearchTypeChange = (option) => { - onSearchTypeChange && onSearchTypeChange(option); + const handleSearchTypeChange = (option: SearchTypeOption) => { + onSearchTypeChange?.(option); + }; + + // Item renderer for the select dropdown. + const itemRenderer: ItemRenderer = ( + item, + { handleClick }, + ) => { + return ; }; return ( -
+ - + - items={searchTypeOptions} + itemRenderer={itemRenderer} onItemSelect={handleSearchTypeChange} + selectedValue={selectedItem?.key} + valueAccessor={'key'} + labelAccessor={'label'} filterable={false} - initialSelectedItem={defaultSearchResource} - selectedItem={searchType} - selectedItemProp={'key'} - textProp={'label'} - // defaultText={intl.get('type')} popoverProps={{ minimal: true, captureDismiss: true, - className: CLASSES.UNIVERSAL_SEARCH_TYPE_SELECT_OVERLAY, - }} - buttonProps={{ - minimal: true, - className: CLASSES.UNIVERSAL_SEARCH_TYPE_SELECT_BTN, }} + input={({ activeItem }) => ( +
+ ); } +// QueryList renderer props +interface QueryListRendererProps { + /** Current query string */ + query: string; + /** Callback when query changes */ + handleQueryChange: (event: React.ChangeEvent) => void; + /** Item list element */ + itemList: ReactNode; + /** Class name */ + className?: string; + /** Handle key down */ + handleKeyDown?: (event: KeyboardEvent) => void; + /** Handle key up */ + handleKeyUp?: (event: KeyboardEvent) => void; +} + +// UniversalSearchQueryList props +interface UniversalSearchQueryListProps { + /** Whether the search is open */ + isOpen: boolean; + /** Whether the search is loading */ + isLoading: boolean; + /** Callback when search type changes */ + onSearchTypeChange?: (option: SearchTypeOption) => void; + /** Current search type */ + searchType: ResourceType; + /** Items to display */ + items: UniversalSearchItem[]; + /** Renderer for items */ + itemRenderer?: ItemRenderer; + /** Callback when an item is selected */ + onItemSelect?: (item: UniversalSearchItem, event?: any) => void; + /** Current query string */ + query: string; + /** Callback when query changes */ + onQueryChange?: (query: string) => void; +} + /** * Universal search query list. */ -function UniversalSearchQueryList(props) { - const { isOpen, isLoading, onSearchTypeChange, searchType, ...restProps } = - props; - +function UniversalSearchQueryList({ + isOpen, + isLoading, + onSearchTypeChange, + ...restProps +}: UniversalSearchQueryListProps) { return ( - + {...(restProps as any)} initialContent={null} - renderer={(listProps) => ( + renderer={(listProps: QueryListRendererProps) => ( -
+ + ENTER - {intl.get('universal_search.enter_text')} -
+ {intl.get('universal_search.enter_text')} + -
+ ESC{' '} - {intl.get('universal_search.close_text')} -
+ {intl.get('universal_search.close_text')} + -
+ - {intl.get('universal_seach.navigate_text')} -
- + {intl.get('universal_seach.navigate_text')} + + ); } +// UniversalSearchBar props +interface UniversalSearchBarProps extends QueryListRendererProps { + /** Whether the search is open */ + isOpen: boolean; + /** Callback when search type changes */ + onSearchTypeChange?: (option: SearchTypeOption) => void; +} + /** * Universal search input bar with items list. */ -function UniversalSearchBar({ isOpen, onSearchTypeChange, ...listProps }) { +function UniversalSearchBar({ + isOpen, + onSearchTypeChange, + ...listProps +}: UniversalSearchBarProps) { const { handleKeyDown, handleKeyUp } = listProps; const handlers = isOpen ? { onKeyDown: handleKeyDown, onKeyUp: handleKeyUp } : {}; return ( -
+ } @@ -155,17 +400,44 @@ function UniversalSearchBar({ isOpen, onSearchTypeChange, ...listProps }) { autoFocus={true} /> {listProps.itemList} -
+ ); } +// UniversalSearch props +export interface UniversalSearchProps { + /** Default search resource type */ + defaultSearchResource?: ResourceType; + /** Controlled search resource type */ + searchResource?: ResourceType; + /** Overlay props */ + overlayProps?: OverlayProps; + /** Whether the search overlay is open */ + isOpen: boolean; + /** Whether the search is loading */ + isLoading: boolean; + /** Callback when search type changes */ + onSearchTypeChange?: (resource: SearchTypeOption) => void; + /** Items to display */ + items: UniversalSearchItem[]; + /** Available search type options */ + searchTypeOptions: SearchTypeOption[]; + /** Renderer for items */ + itemRenderer?: ItemRenderer; + /** Callback when an item is selected */ + onItemSelect?: (item: UniversalSearchItem, event?: any) => void; + /** Current query string */ + query: string; + /** Callback when query changes */ + onQueryChange?: (query: string) => void; +} + /** * Universal search. */ export function UniversalSearch({ defaultSearchResource, searchResource, - overlayProps, isOpen, isLoading, @@ -173,9 +445,9 @@ export function UniversalSearch({ items, searchTypeOptions, ...queryListProps -}) { +}: UniversalSearchProps) { // Search type state. - const [searchType, setSearchType] = React.useState( + const [searchType, setSearchType] = React.useState( defaultSearchResource || RESOURCES_TYPES.CUSTOMER, ); // Handle search resource type controlled mode. @@ -189,9 +461,9 @@ export function UniversalSearch({ }, [searchResource, defaultSearchResource]); // Handle search type change. - const handleSearchTypeChange = (searchTypeResource) => { + const handleSearchTypeChange = (searchTypeResource: SearchTypeOption) => { setSearchType(searchTypeResource.key); - onSearchTypeChange && onSearchTypeChange(searchTypeResource); + onSearchTypeChange?.(searchTypeResource); }; // Filters query list items based on the given search type. const filteredItems = filterItemsByResourceType(items, searchType); @@ -200,7 +472,7 @@ export function UniversalSearch({ -
+ -
+ -
-
+ +
); diff --git a/packages/webapp/src/components/UniversalSearch/UniversalSearchProvider.tsx b/packages/webapp/src/components/UniversalSearch/UniversalSearchProvider.tsx index f7f206593..6cb84b978 100644 --- a/packages/webapp/src/components/UniversalSearch/UniversalSearchProvider.tsx +++ b/packages/webapp/src/components/UniversalSearch/UniversalSearchProvider.tsx @@ -1,30 +1,82 @@ -// @ts-nocheck -import React, { createContext } from 'react'; +import React, { createContext, ReactNode, useContext } from 'react'; -const UniversalSearchContext = createContext(); +// The resource type value from RESOURCES_TYPES constant +type ResourceType = string; + +// Search type option item +interface SearchTypeOption { + key: ResourceType; + label: string; +} + +// Context value type +interface UniversalSearchContextValue { + /** Whether the search is loading */ + isLoading: boolean; + /** Current search type/resource type */ + searchType: ResourceType; + /** Default search resource type */ + defaultSearchResource?: ResourceType; + /** List of available search type options */ + searchTypeOptions: SearchTypeOption[]; +} + +// Create the context with undefined as initial value +const UniversalSearchContext = createContext< + UniversalSearchContextValue | undefined +>(undefined); + +// Provider props interface +interface UniversalSearchProviderProps { + /** Whether the search is loading */ + isLoading: boolean; + /** Default search resource type */ + defaultSearchResource?: ResourceType; + /** Current search type/resource type */ + searchType: ResourceType; + /** List of available search type options */ + searchTypeOptions: SearchTypeOption[]; + /** Child elements */ + children: ReactNode; +} /** * Universal search data provider. */ -function UniversalSearchProvider({ +export function UniversalSearchProvider({ isLoading, defaultSearchResource, searchType, searchTypeOptions, - ...props -}) { + children, +}: UniversalSearchProviderProps) { // Provider payload. - const provider = { + const provider: UniversalSearchContextValue = { isLoading, searchType, defaultSearchResource, searchTypeOptions, }; - return ; + return ( + + {children} + + ); } -const useUniversalSearchContext = () => - React.useContext(UniversalSearchContext); +/** + * Hook to access the universal search context. + * @throws Error if used outside of UniversalSearchProvider + */ +export const useUniversalSearchContext = (): UniversalSearchContextValue => { + const context = useContext(UniversalSearchContext); -export { UniversalSearchProvider, useUniversalSearchContext }; + if (context === undefined) { + throw new Error( + 'useUniversalSearchContext must be used within a UniversalSearchProvider', + ); + } + + return context; +}; diff --git a/packages/webapp/src/components/Utils/If.tsx b/packages/webapp/src/components/Utils/If.tsx index baf80bdc8..85263ca34 100644 --- a/packages/webapp/src/components/Utils/If.tsx +++ b/packages/webapp/src/components/Utils/If.tsx @@ -1,12 +1,10 @@ -// @ts-nocheck -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { ReactNode } from 'react'; -export const If = (props) => - props.condition ? (props.render ? props.render() : props.children) : null; +interface IfProps { + condition: boolean; + children?: ReactNode; + render?: () => ReactNode; +} -If.propTypes = { - // condition: PropTypes.bool.isRequired, - children: PropTypes.node, - render: PropTypes.func, -}; +export const If = (props: IfProps): React.ReactElement | null => + props.condition ? (props.render ? <>{props.render()} : <>{props.children}) : null; diff --git a/packages/webapp/src/constants/accountTypes.tsx b/packages/webapp/src/constants/accountTypes.tsx index 5cb08e13f..4ff41984d 100644 --- a/packages/webapp/src/constants/accountTypes.tsx +++ b/packages/webapp/src/constants/accountTypes.tsx @@ -26,7 +26,7 @@ export const ACCOUNT_TYPE = { export const ACCOUNT_PARENT_TYPE = { CURRENT_ASSET: 'current-asset', FIXED_ASSET: 'fixed-asset', - NON_CURRENT_ASSET: 'non-ACCOUNT_PARENT_TYPE.CURRENT_ASSET', + NON_CURRENT_ASSET: 'non-current-asset', CURRENT_LIABILITY: 'current-liability', LOGN_TERM_LIABILITY: 'long-term-liability', @@ -41,7 +41,7 @@ export const ACCOUNT_ROOT_TYPE = { ASSET: 'asset', LIABILITY: 'liability', EQUITY: 'equity', - EXPENSE: 'expene', + EXPENSE: 'expense', INCOME: 'income', }; diff --git a/packages/webapp/src/constants/cashflowOptions.tsx b/packages/webapp/src/constants/cashflowOptions.tsx index 5faaa629b..45de9850a 100644 --- a/packages/webapp/src/constants/cashflowOptions.tsx +++ b/packages/webapp/src/constants/cashflowOptions.tsx @@ -19,7 +19,7 @@ export const getAddMoneyInOptions = () => [ export const getAddMoneyOutOptions = () => [ { name: intl.get('banking.owner_drawings'), - value: 'OwnerDrawing', + value: 'owner_drawing', }, { name: intl.get('banking.expenses'), @@ -31,11 +31,11 @@ export const getAddMoneyOutOptions = () => [ }, ]; -export const TRANSACRIONS_TYPE = [ +export const TRANSACTIONS_TYPE = [ 'OwnerContribution', 'OtherIncome', 'TransferFromAccount', - 'OnwersDrawing', + 'OwnerDrawing', 'OtherExpense', 'TransferToAccount', ]; diff --git a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFloatingActions.tsx b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFloatingActions.tsx index 1b0164e38..aca9a0c3a 100644 --- a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFloatingActions.tsx +++ b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFloatingActions.tsx @@ -32,38 +32,38 @@ export default function MakeJournalFloatingAction() { // Handle submit & publish button click. const handleSubmitPublishBtnClick = (event) => { - submitForm(); setSubmitPayload({ redirect: true, publish: true }); + submitForm(); }; // Handle submit, publish & new button click. const handleSubmitPublishAndNewBtnClick = (event) => { - submitForm(); setSubmitPayload({ redirect: false, publish: true, resetForm: true }); + submitForm(); }; // Handle submit, publish & edit button click. const handleSubmitPublishContinueEditingBtnClick = (event) => { - submitForm(); setSubmitPayload({ redirect: false, publish: true }); + submitForm(); }; // Handle submit as draft button click. const handleSubmitDraftBtnClick = (event) => { - submitForm(); setSubmitPayload({ redirect: true, publish: false }); + submitForm(); }; // Handle submit as draft & new button click. const handleSubmitDraftAndNewBtnClick = (event) => { - submitForm(); setSubmitPayload({ redirect: false, publish: false, resetForm: true }); + submitForm(); }; // Handle submit as draft & continue editing button click. const handleSubmitDraftContinueEditingBtnClick = (event) => { - submitForm(); setSubmitPayload({ redirect: false, publish: false }); + submitForm(); }; // Handle cancel button click. diff --git a/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx b/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx index bd17c58af..4188fd15c 100644 --- a/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx +++ b/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx @@ -213,17 +213,17 @@ export const currenciesFieldShouldUpdate = (newProps, oldProps) => { export const useSetPrimaryBranchToForm = () => { const { setFieldValue } = useFormikContext(); - const { branches, isBranchesSuccess } = useMakeJournalFormContext(); + const { branches, isBranchesSuccess, isNewMode } = useMakeJournalFormContext(); React.useEffect(() => { - if (isBranchesSuccess) { + if (isBranchesSuccess && isNewMode) { const primaryBranch = branches.find((b) => b.primary) || first(branches); if (primaryBranch) { setFieldValue('branch_id', primaryBranch.id); } } - }, [isBranchesSuccess, setFieldValue, branches]); + }, [isBranchesSuccess, setFieldValue, branches, isNewMode]); }; export const useManualJournalCreditTotal = () => { diff --git a/packages/webapp/src/containers/Authentication/InviteAcceptForm.tsx b/packages/webapp/src/containers/Authentication/InviteAcceptForm.tsx index d4a000c4c..f738edf6c 100644 --- a/packages/webapp/src/containers/Authentication/InviteAcceptForm.tsx +++ b/packages/webapp/src/containers/Authentication/InviteAcceptForm.tsx @@ -58,7 +58,7 @@ export default function InviteAcceptForm() { data: { errors }, }, }) => { - if (errors.find((e) => e.type === 'INVITE.TOKEN.NOT.FOUND')) { + if (errors.find((e) => e.type === 'INVITE_TOKEN_INVALID')) { AppToaster.show({ message: intl.get('an_unexpected_error_occurred'), intent: Intent.DANGER, @@ -71,14 +71,6 @@ export default function InviteAcceptForm() { phone_number: 'This phone number is used in another account.', }); } - if (errors.find((e) => e.type === 'INVITE.TOKEN.NOT.FOUND')) { - AppToaster.show({ - message: intl.get('an_unexpected_error_occurred'), - intent: Intent.DANGER, - position: Position.BOTTOM, - }); - history.push('/auth/login'); - } setSubmitting(false); }, ); diff --git a/packages/webapp/src/containers/Authentication/InviteAcceptProvider.tsx b/packages/webapp/src/containers/Authentication/InviteAcceptProvider.tsx index f78043079..ed9db2d6b 100644 --- a/packages/webapp/src/containers/Authentication/InviteAcceptProvider.tsx +++ b/packages/webapp/src/containers/Authentication/InviteAcceptProvider.tsx @@ -29,14 +29,22 @@ function InviteAcceptProvider({ token, ...props }) { if (inviteMetaError) { history.push('/auth/login'); } }, [history, inviteMetaError]); + // Transform the backend response to match frontend expectations. + const transformedInviteMeta = inviteMeta + ? { + email: inviteMeta.inviteToken?.email, + organizationName: inviteMeta.orgName, + } + : null; + // Provider payload. const provider = { token, - inviteMeta, + inviteMeta: transformedInviteMeta, inviteMetaError, isInviteMetaError, isInviteMetaLoading, - inviteAcceptMutate + inviteAcceptMutate, }; if (inviteMetaError) { @@ -45,7 +53,6 @@ function InviteAcceptProvider({ token, ...props }) { return ( - { isInviteMetaError } ); diff --git a/packages/webapp/src/containers/Authentication/RegisterVerify.module.scss b/packages/webapp/src/containers/Authentication/RegisterVerify.module.scss deleted file mode 100644 index f0754f470..000000000 --- a/packages/webapp/src/containers/Authentication/RegisterVerify.module.scss +++ /dev/null @@ -1,18 +0,0 @@ - -.root { - text-align: center; -} - -.title{ - font-size: 18px; - font-weight: 600; - margin-bottom: 0.5rem; - color: #252A31; -} - -.description{ - margin-bottom: 1rem; - font-size: 15px; - line-height: 1.45; - color: #404854; -} \ No newline at end of file diff --git a/packages/webapp/src/containers/Authentication/RegisterVerify.tsx b/packages/webapp/src/containers/Authentication/RegisterVerify.tsx index 46148a426..d022ff454 100644 --- a/packages/webapp/src/containers/Authentication/RegisterVerify.tsx +++ b/packages/webapp/src/containers/Authentication/RegisterVerify.tsx @@ -1,12 +1,13 @@ // @ts-nocheck import { Button, Intent } from '@blueprintjs/core'; +import { x } from '@xstyled/emotion'; import AuthInsider from './AuthInsider'; import { AuthInsiderCard } from './_components'; -import styles from './RegisterVerify.module.scss'; import { AppToaster, Stack } from '@/components'; import { useAuthActions, useAuthUserVerifyEmail } from '@/hooks/state'; import { useAuthSignUpVerifyResendMail } from '@/hooks/query'; import { AuthContainer } from './AuthContainer'; +import { useIsDarkMode } from '@/hooks/useDarkMode'; export default function RegisterVerify() { const { setLogout } = useAuthActions(); @@ -14,6 +15,7 @@ export default function RegisterVerify() { useAuthSignUpVerifyResendMail(); const emailAddress = useAuthUserVerifyEmail(); + const isDarkMode = useIsDarkMode(); const handleResendMailBtnClick = () => { resendSignUpVerifyMail() @@ -37,12 +39,24 @@ export default function RegisterVerify() { return ( - -

Please verify your email

-

+ + + Please verify your email + + We sent an email to {emailAddress} Click the link inside to get started. -

+ + + + )} + ); } diff --git a/packages/webapp/src/containers/Customers/CustomerForm/CustomersTabs.tsx b/packages/webapp/src/containers/Customers/CustomerForm/CustomersTabs.tsx index 02ecd39ac..893a2cd8f 100644 --- a/packages/webapp/src/containers/Customers/CustomerForm/CustomersTabs.tsx +++ b/packages/webapp/src/containers/Customers/CustomerForm/CustomersTabs.tsx @@ -5,7 +5,7 @@ import { Tabs, Tab } from '@blueprintjs/core'; import CustomerAddressTabs from './CustomerAddressTabs'; import CustomerAttachmentTabs from './CustomerAttachmentTabs'; -import CustomerFinancialPanel from './CustomerFinancialPanel'; +import CustomerFinancialPanel from './CustomerFormFinancialSection'; import CustomerNotePanel from './CustomerNotePanel'; export default function CustomersTabs() { diff --git a/packages/webapp/src/containers/Customers/CustomerForm/utils.tsx b/packages/webapp/src/containers/Customers/CustomerForm/utils.tsx index a6d17c45c..458ff1a3f 100644 --- a/packages/webapp/src/containers/Customers/CustomerForm/utils.tsx +++ b/packages/webapp/src/containers/Customers/CustomerForm/utils.tsx @@ -14,6 +14,7 @@ export const defaultInitialValues = { last_name: '', company_name: '', display_name: '', + code: '', email: '', work_phone: '', @@ -23,16 +24,16 @@ export const defaultInitialValues = { active: true, billing_address_country: '', - billing_address_1: '', - billing_address_2: '', + billing_address1: '', + billing_address2: '', billing_address_city: '', billing_address_state: '', billing_address_postcode: '', billing_address_phone: '', shipping_address_country: '', - shipping_address_1: '', - shipping_address_2: '', + shipping_address1: '', + shipping_address2: '', shipping_address_city: '', shipping_address_state: '', shipping_address_postcode: '', diff --git a/packages/webapp/src/containers/Dialogs/AccountDialog/utils.tsx b/packages/webapp/src/containers/Dialogs/AccountDialog/utils.tsx index 3bb43df16..852e3f18d 100644 --- a/packages/webapp/src/containers/Dialogs/AccountDialog/utils.tsx +++ b/packages/webapp/src/containers/Dialogs/AccountDialog/utils.tsx @@ -15,10 +15,16 @@ export const AccountDialogAction = { */ export const transformApiErrors = (errors) => { const fields = {}; + if (errors.find((e) => e.type === 'account_code_required')) { + fields.code = intl.get('account_code_is_required'); + } if (errors.find((e) => e.type === 'NOT_UNIQUE_CODE')) { fields.code = intl.get('account_code_is_not_unique'); } - if (errors.find((e) => e.type === 'ACCOUNT.NAME.NOT.UNIQUE')) { + if (errors.find((e) => e.type === 'account_code_not_unique')) { + fields.code = intl.get('account_code_is_not_unique'); + } + if (errors.find((e) => e.type === 'account_name_not_unqiue')) { fields.name = intl.get('account_name_is_already_used'); } if ( diff --git a/packages/webapp/src/containers/Drawers/AccountDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/AccountDrawer/utils.tsx index 1a0312a20..9ed26bc71 100644 --- a/packages/webapp/src/containers/Drawers/AccountDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/AccountDrawer/utils.tsx @@ -2,7 +2,6 @@ import intl from 'react-intl-universal'; import React from 'react'; -import { FormatDateCell } from '@/components'; import { useAccountDrawerTableOptionsContext } from './AccountDrawerTableOptionsProvider'; /** @@ -15,8 +14,7 @@ export const useAccountReadEntriesColumns = () => { () => [ { Header: intl.get('transaction_date'), - accessor: 'date', - Cell: FormatDateCell, + accessor: 'formatted_date', width: 110, textOverview: true, }, diff --git a/packages/webapp/src/containers/Drawers/ContactDetailDrawer/ContactDetailActionsBar.tsx b/packages/webapp/src/containers/Drawers/ContactDetailDrawer/ContactDetailActionsBar.tsx index 27f767133..dfa09477b 100644 --- a/packages/webapp/src/containers/Drawers/ContactDetailDrawer/ContactDetailActionsBar.tsx +++ b/packages/webapp/src/containers/Drawers/ContactDetailDrawer/ContactDetailActionsBar.tsx @@ -15,7 +15,7 @@ import { useContactDetailDrawerContext } from './ContactDetailDrawerProvider'; import { withAlertActions } from '@/containers/Alert/withAlertActions'; import { withDrawerActions } from '@/containers/Drawer/withDrawerActions'; -import { DashboardActionsBar, Icon, FormattedMessage as T } from '@/components'; +import { DrawerActionsBar, Icon, FormattedMessage as T } from '@/components'; import { safeCallback, compose } from '@/utils'; @@ -46,7 +46,7 @@ function ContactDetailActionsBar({ }; return ( - +