diff --git a/.github/workflows/all.publish.yml b/.github/workflows/all.publish.yml index f0e263d1b..13b9b0225 100644 --- a/.github/workflows/all.publish.yml +++ b/.github/workflows/all.publish.yml @@ -1,9 +1,10 @@ name: Publishing + on: release: - types: - - published + types: [published] + permissions: contents: read @@ -12,6 +13,8 @@ jobs: publish: name: Publish NPM Packages runs-on: ubuntu-latest + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - name: Harden Runner @@ -27,32 +30,19 @@ jobs: with: node-version: 20.x - - name: Create file .npmrc - run: | - touch .npmrc - echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc - cp .npmrc ./packages/ats/contracts/.npmrc - cp .npmrc ./packages/ats/sdk/.npmrc - - name: Install dependencies run: npm ci # --- ATS publishing --- - name: Publish ats/contracts if: contains(github.ref_name, 'ats') - run: npm run ats:contracts:publish --access=public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm run ats:contracts:publish - name: Publish ats/sdk if: contains(github.ref_name, 'ats') - run: npm run ats:sdk:publish --access=public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm run ats:sdk:publish # --- Mass Payout publishing --- - name: Publish mass-payout if: contains(github.ref_name, 'mp') - run: npm run mass-payout:publish --access=public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm run mass-payout:publish diff --git a/.gitignore b/.gitignore index 85fb6c4b4..561e1422f 100644 --- a/.gitignore +++ b/.gitignore @@ -117,6 +117,7 @@ dist # Stores VSCode versions used for testing VSCode extensions .vscode-test +.github/ # Mac files .DS_Store diff --git a/apps/ats/web/package.json b/apps/ats/web/package.json index 3e2bd1a08..48590352e 100644 --- a/apps/ats/web/package.json +++ b/apps/ats/web/package.json @@ -1,6 +1,6 @@ { "name": "@hashgraph/asset-tokenization-dapp", - "version": "1.16.0", + "version": "1.16.1", "license": "Apache-2.0", "scripts": { "build": "tsc && vite build", diff --git a/apps/ats/web/src/i18n/en/security/coupons.ts b/apps/ats/web/src/i18n/en/security/coupons.ts index 2a0e88d34..e733b165b 100644 --- a/apps/ats/web/src/i18n/en/security/coupons.ts +++ b/apps/ats/web/src/i18n/en/security/coupons.ts @@ -216,6 +216,7 @@ export default { recordDate: 'Record Date', executionDate: 'Execution Date', rate: 'Coupon Rate', + period: 'Period', snapshotId: 'Snapshot', }, emptyTable: 'No coupons found', @@ -239,6 +240,19 @@ export default { placeholder: '0,123%', tooltip: 'Interest rate for the coupon.', }, + period: { + label: 'Coupon period', + placeholder: 'Select coupon period', + tooltip: + 'The period between coupon payments. This field is required for all coupon operations.', + options: { + day: '1 Day', + week: '1 Week', + month: '1 Month', + quarter: '3 Months', + year: '1 Year', + }, + }, }, }, see: { @@ -261,6 +275,7 @@ export default { details: { title: 'Detail', paymentDay: 'Payment day', + period: 'Period', amount: 'Amount', }, }, diff --git a/apps/ats/web/src/utils/constants.ts b/apps/ats/web/src/utils/constants.ts index 9503ec475..cbf53cb6b 100644 --- a/apps/ats/web/src/utils/constants.ts +++ b/apps/ats/web/src/utils/constants.ts @@ -224,5 +224,28 @@ export const NOMINAL_VALUE_FACTOR = 100; export const DATE_TIME_FORMAT = 'dd/MM/yyyy HH:mm:ss'; +// * Time periods (in seconds and milliseconds) +export const TIME_PERIODS_S = { + SECOND: 1, + MINUTE: 60, + HOUR: 60 * 60, + DAY: 24 * 60 * 60, + WEEK: 7 * 24 * 60 * 60, + MONTH: 30 * 24 * 60 * 60, + QUARTER: 90 * 24 * 60 * 60, + YEAR: 365 * 24 * 60 * 60, +}; + +export const TIME_PERIODS_MS = { + SECOND: TIME_PERIODS_S.SECOND * 1000, + MINUTE: TIME_PERIODS_S.MINUTE * 1000, + HOUR: TIME_PERIODS_S.HOUR * 1000, + DAY: TIME_PERIODS_S.DAY * 1000, + WEEK: TIME_PERIODS_S.WEEK * 1000, + MONTH: TIME_PERIODS_S.MONTH * 1000, + QUARTER: TIME_PERIODS_S.QUARTER * 1000, + YEAR: TIME_PERIODS_S.YEAR * 1000, +}; + export const DEFAULT_PARTITION = '0x0000000000000000000000000000000000000000000000000000000000000001'; diff --git a/apps/ats/web/src/utils/format.ts b/apps/ats/web/src/utils/format.ts index a5387f068..6ed22b358 100644 --- a/apps/ats/web/src/utils/format.ts +++ b/apps/ats/web/src/utils/format.ts @@ -292,6 +292,59 @@ export const formatPeriod = ({ return `${amount} ${unit}`; }; +/** + * Formats a period in seconds to human-readable format + */ +export const formatCouponPeriod = (periodInSeconds: number): string => { + const { TIME_PERIODS_S } = require('./constants'); + + if (periodInSeconds >= TIME_PERIODS_S.YEAR) { + const years = Math.floor(periodInSeconds / TIME_PERIODS_S.YEAR); + return `${years} ${years === 1 ? 'Year' : 'Years'}`; + } + if (periodInSeconds >= TIME_PERIODS_S.QUARTER) { + const quarters = Math.floor(periodInSeconds / TIME_PERIODS_S.QUARTER); + return `${quarters} ${quarters === 1 ? 'Quarter' : 'Quarters'}`; + } + if (periodInSeconds >= TIME_PERIODS_S.MONTH) { + const months = Math.floor(periodInSeconds / TIME_PERIODS_S.MONTH); + return `${months} ${months === 1 ? 'Month' : 'Months'}`; + } + if (periodInSeconds >= TIME_PERIODS_S.WEEK) { + const weeks = Math.floor(periodInSeconds / TIME_PERIODS_S.WEEK); + return `${weeks} ${weeks === 1 ? 'Week' : 'Weeks'}`; + } + if (periodInSeconds >= TIME_PERIODS_S.DAY) { + const days = Math.floor(periodInSeconds / TIME_PERIODS_S.DAY); + return `${days} ${days === 1 ? 'Day' : 'Days'}`; + } + return `${periodInSeconds} Seconds`; +}; + +/** + * Validates if a period is within acceptable bounds + * Period is REQUIRED for all coupon operations + */ +export const validateCouponPeriod = ( + periodInSeconds: number, + maturityDate?: Date, +): string | true => { + // Period is required - cannot be null or undefined + if (!periodInSeconds || periodInSeconds < 0) { + return 'Coupon period is required and must be greater or equal to 0'; + } + + if (maturityDate) { + const timeToMaturity = Math.floor( + (maturityDate.getTime() - Date.now()) / 1000, + ); + if (periodInSeconds > timeToMaturity) { + return 'Period cannot exceed bond maturity date'; + } + } + return true; +}; + //TODO: remove? export const formatNumber = ( value: number | string | null, diff --git a/apps/ats/web/src/utils/rules.ts b/apps/ats/web/src/utils/rules.ts index a7782ffa0..dc282f5ce 100644 --- a/apps/ats/web/src/utils/rules.ts +++ b/apps/ats/web/src/utils/rules.ts @@ -210,8 +210,8 @@ import isEqual from 'date-fns/isEqual'; import i18n from '../i18n'; import { formatDate, toDate } from './format'; -const t = (key: string, options?: object) => { - return i18n.t(`rules:${key}`, options || {}); +const t = (key: string, options?: Record) => { + return i18n.t(`rules:${key}`, options); }; export const maxLength = (value: number) => ({ @@ -321,3 +321,23 @@ export const isValidHederaId = (val: string) => { const maskRegex = /^[0-9]\.[0-9]\.[0-9]{1,7}$/; return maskRegex.test(val) || t('isValidHederaId'); }; + +export const isValidCouponPeriod = (val: string) => { + try { + // Period is required - cannot be empty or null + if (!val || val.trim() === '') { + return 'Coupon period is required'; + } + + const periodValue = parseInt(val); + if (isNaN(periodValue) || periodValue <= 0) { + return 'Coupon period must be a valid positive number'; + } + + const { validateCouponPeriod } = require('./format'); + const validation = validateCouponPeriod(periodValue); + return validation === true || validation; + } catch (error) { + return 'Invalid coupon period'; + } +}; diff --git a/apps/ats/web/src/views/CreateBond/Components/StepCoupon.tsx b/apps/ats/web/src/views/CreateBond/Components/StepCoupon.tsx index d4437cc8c..a30ec15b5 100644 --- a/apps/ats/web/src/views/CreateBond/Components/StepCoupon.tsx +++ b/apps/ats/web/src/views/CreateBond/Components/StepCoupon.tsx @@ -309,6 +309,7 @@ export const StepCoupon = () => { options={CouponTypeOptions} /> + {couponType === 1 && ( <> diff --git a/apps/ats/web/src/views/CreateBond/Components/StepReview.tsx b/apps/ats/web/src/views/CreateBond/Components/StepReview.tsx index 7a6b59505..af5c12069 100644 --- a/apps/ats/web/src/views/CreateBond/Components/StepReview.tsx +++ b/apps/ats/web/src/views/CreateBond/Components/StepReview.tsx @@ -344,15 +344,15 @@ export const StepReview = () => { configVersion: parseInt(process.env.REACT_APP_BOND_CONFIG_VERSION ?? '0'), ...(externalPausesList && externalPausesList.length > 0 && { - externalPauses: externalPausesList, + externalPausesIds: externalPausesList, }), ...(externalControlList && externalControlList.length > 0 && { - externalControlLists: externalControlList, + externalControlListsIds: externalControlList, }), ...(externalKYCList && externalKYCList.length > 0 && { - externalKycLists: externalKYCList, + externalKycListsIds: externalKYCList, }), internalKycActivated, ...(complianceId && { diff --git a/apps/ats/web/src/views/CreateEquity/Components/StepReview.tsx b/apps/ats/web/src/views/CreateEquity/Components/StepReview.tsx index ce577d717..5d01d3d17 100644 --- a/apps/ats/web/src/views/CreateEquity/Components/StepReview.tsx +++ b/apps/ats/web/src/views/CreateEquity/Components/StepReview.tsx @@ -336,15 +336,15 @@ export const StepReview = () => { ), ...(externalPausesList && externalPausesList.length > 0 && { - externalPauses: externalPausesList, + externalPausesIds: externalPausesList, }), ...(externalControlList && externalControlList.length > 0 && { - externalControlLists: externalControlList, + externalControlListsIds: externalControlList, }), ...(externalKYCList && externalKYCList.length > 0 && { - externalKycLists: externalKYCList, + externalKycListsIds: externalKYCList, }), internalKycActivated, ...(complianceId && { diff --git a/apps/ats/web/src/views/DigitalSecurityDetails/Components/ClearingOperations/ClearingOperationCreate.tsx b/apps/ats/web/src/views/DigitalSecurityDetails/Components/ClearingOperations/ClearingOperationCreate.tsx index c1f1d77c3..f2d358638 100644 --- a/apps/ats/web/src/views/DigitalSecurityDetails/Components/ClearingOperations/ClearingOperationCreate.tsx +++ b/apps/ats/web/src/views/DigitalSecurityDetails/Components/ClearingOperations/ClearingOperationCreate.tsx @@ -132,7 +132,7 @@ export const ClearingOperationsCreate = () => { amount: amount.toString(), clearingExpirationDate: dateToUnixTimestamp(expirationDate), holdExpirationDate: dateToUnixTimestamp(holdExpirationDate), - escrow: escrowAccount, + escrowId: escrowAccount, // TODO: check with SDK: sourceId, targetId, partitionId: DEFAULT_PARTITION, diff --git a/apps/ats/web/src/views/DigitalSecurityDetails/Components/Coupons/CouponsList.tsx b/apps/ats/web/src/views/DigitalSecurityDetails/Components/Coupons/CouponsList.tsx index 2a88a69b3..db262837e 100644 --- a/apps/ats/web/src/views/DigitalSecurityDetails/Components/Coupons/CouponsList.tsx +++ b/apps/ats/web/src/views/DigitalSecurityDetails/Components/Coupons/CouponsList.tsx @@ -8,7 +8,7 @@ import { createColumnHelper } from '@tanstack/table-core'; import { Table, Text } from 'io-bricks-ui'; import { useTranslation } from 'react-i18next'; import { COUPONS_FACTOR, DATE_TIME_FORMAT } from '../../../../utils/constants'; -import { formatDate } from '../../../../utils/format'; +import { formatDate, formatCouponPeriod } from '../../../../utils/format'; export const CouponsList = () => { const { id } = useParams(); @@ -49,6 +49,11 @@ export const CouponsList = () => { cell: (row) => `${parseInt(row.getValue()) / COUPONS_FACTOR}%`, enableSorting: false, }), + columnHelper.accessor('period', { + header: t('columns.period'), + cell: (row) => formatCouponPeriod(row.getValue()), + enableSorting: false, + }), columnHelper.accessor('snapshotId', { header: t('columns.snapshotId'), cell: (row) => row.getValue() ?? '-', diff --git a/apps/ats/web/src/views/DigitalSecurityDetails/Components/Coupons/ProgramCoupon.tsx b/apps/ats/web/src/views/DigitalSecurityDetails/Components/Coupons/ProgramCoupon.tsx index dd1a26132..d71924707 100644 --- a/apps/ats/web/src/views/DigitalSecurityDetails/Components/Coupons/ProgramCoupon.tsx +++ b/apps/ats/web/src/views/DigitalSecurityDetails/Components/Coupons/ProgramCoupon.tsx @@ -208,6 +208,7 @@ import { CalendarInputController, InputNumberController, PhosphorIcon, + SelectController, Text, Tooltip, } from 'io-bricks-ui'; @@ -222,14 +223,22 @@ import { import { useParams } from 'react-router-dom'; import { useCoupons } from '../../../../hooks/queries/useCoupons'; import { useGetBondDetails } from '../../../../hooks/queries/useGetSecurityDetails'; -import { dateToUnixTimestamp } from '../../../../utils/format'; -import { COUPONS_FACTOR, DATE_TIME_FORMAT } from '../../../../utils/constants'; +import { + dateToUnixTimestamp, + validateCouponPeriod, +} from '../../../../utils/format'; +import { + COUPONS_FACTOR, + DATE_TIME_FORMAT, + TIME_PERIODS_S, +} from '../../../../utils/constants'; import { isBeforeDate } from '../../../../utils/helpers'; interface ProgramCouponFormValues { rate: number; recordTimestamp: string; executionTimestamp: string; + period: string; } export const ProgramCoupon = () => { @@ -257,6 +266,7 @@ export const ProgramCoupon = () => { rate: (params.rate * COUPONS_FACTOR).toString(), recordTimestamp: dateToUnixTimestamp(params.recordTimestamp), executionTimestamp: dateToUnixTimestamp(params.executionTimestamp), + period: params.period, }); createCoupon(request, { @@ -345,6 +355,50 @@ export const ProgramCoupon = () => { decimalSeparator="." /> + + + + {tForm('period.label')}* + + + + + + { + const validation = validateCouponPeriod(parseInt(value)); + return validation === true || validation; + }, + }} + placeholder={tForm('period.placeholder')} + options={[ + { + label: tForm('period.options.day'), + value: TIME_PERIODS_S.DAY.toString(), + }, + { + label: tForm('period.options.week'), + value: TIME_PERIODS_S.WEEK.toString(), + }, + { + label: tForm('period.options.month'), + value: TIME_PERIODS_S.MONTH.toString(), + }, + { + label: tForm('period.options.quarter'), + value: TIME_PERIODS_S.QUARTER.toString(), + }, + { + label: tForm('period.options.year'), + value: TIME_PERIODS_S.YEAR.toString(), + }, + ]} + /> +