diff --git a/apps/backend/src/domain/commodities/commodity.entity.ts b/apps/backend/src/domain/commodities/commodity.entity.ts new file mode 100644 index 00000000..cc300d1f --- /dev/null +++ b/apps/backend/src/domain/commodities/commodity.entity.ts @@ -0,0 +1,190 @@ +import { + CommodityPrecisionNumber, + CommoditySymbolString, +} from '@ledgerly/shared/types'; + +import { + CommodityCode, + EntityIdentity, + EntityTimestamps, + Id, + Name, + ParentChildRelation, + SoftDelete, + Timestamp, +} from '../domain-core'; +import { DeletedEntityOperationError } from '../domain.errors'; +import { User } from '../users/user.entity'; + +import { parseCommodityPrecision, parseCommoditySymbol } from './helpers'; +import type { CommoditySnapshot, CommodityUpdateProps } from './types'; + +export class Commodity { + static readonly entityType = 'commodity'; + private constructor( + private readonly identity: EntityIdentity, + private timestamps: EntityTimestamps, + private softDelete: SoftDelete, + private readonly ownership: ParentChildRelation, + private code: CommodityCode, + public symbol: CommoditySymbolString | null, + public name: Name, + private precision: CommodityPrecisionNumber, + ) {} + + static create( + user: User, + name: Name, + code: CommodityCode, + precision = 2, + symbol: string | null = null, + ): Commodity { + const identity = EntityIdentity.create(); + const softDelete = SoftDelete.create(); + const timestamps = EntityTimestamps.create(); + + const ownership = ParentChildRelation.create( + user.getId(), + identity.getId(), + ); + + const parsedCommoditySymbol = parseCommoditySymbol(symbol); + const parsedCommodityPrecision = parseCommodityPrecision(precision); + + return new Commodity( + identity, + timestamps, + softDelete, + ownership, + code, + parsedCommoditySymbol, + name, + parsedCommodityPrecision, // use the provided precision value + ); + } + + static restore(data: CommoditySnapshot): Commodity { + const { + code, + createdAt, + id, + isTombstone, + precision, + symbol, + updatedAt, + userId, + } = data; + + const identity = EntityIdentity.restore(Id.restore(id)); + + const timestamps = EntityTimestamps.restore( + Timestamp.restore(updatedAt), + Timestamp.restore(createdAt), + ); + + const softDelete = SoftDelete.restore(isTombstone); + + const ownership = ParentChildRelation.create( + Id.restore(userId), + identity.getId(), + ); + + const commodityCode = CommodityCode.restore(code); + const commodityName = Name.restore(data.name); + + const parsedCommoditySymbol = parseCommoditySymbol(symbol); + const parsedCommodityPrecision = parseCommodityPrecision(precision); + + return new Commodity( + identity, + timestamps, + softDelete, + ownership, + commodityCode, + parsedCommoditySymbol, + commodityName, + parsedCommodityPrecision, + ); + } + + getId(): Id { + return this.identity.getId(); + } + + // Delegation methods for timestamps + getUpdatedAt(): Timestamp { + return this.timestamps.getUpdatedAt(); + } + + getCreatedAt(): Timestamp { + return this.timestamps.getCreatedAt(); + } + + private touch(now?: Timestamp): void { + this.timestamps = this.timestamps.touch(now); + } + + markAsDeleted(): void { + this.softDelete = this.softDelete.markAsDeleted( + new DeletedEntityOperationError(Commodity.entityType, 'delete'), + ); + this.touch(); + } + + isDeleted(): boolean { + return this.softDelete.isDeleted(); + } + + private validateUpdateIsAllowed(): void { + this.softDelete.validateUpdateIsAllowed( + DeletedEntityOperationError.forUpdate(Commodity.entityType), + ); + } + + belongsToUser(userId: Id): boolean { + return this.ownership.belongsToParent(userId); + } + + getUserId(): Id { + return this.ownership.getParentId(); + } + + toSnapshot(): CommoditySnapshot { + return { + code: this.code.valueOf(), + createdAt: this.getCreatedAt().valueOf(), + id: this.getId().valueOf(), + isTombstone: this.softDelete.getIsTombstone(), + name: this.name.valueOf(), + precision: this.precision, + symbol: this.symbol, + updatedAt: this.getUpdatedAt().valueOf(), + userId: this.getUserId().valueOf(), + }; + } + + update(data: CommodityUpdateProps): void { + this.validateUpdateIsAllowed(); + + let isUpdated = false; + + if (data.code !== undefined) { + this.code = CommodityCode.create(data.code); + isUpdated = true; + } + + if (data.symbol !== undefined) { + this.symbol = parseCommoditySymbol(data.symbol); + isUpdated = true; + } + + if (data.name !== undefined) { + this.name = Name.create(data.name); + isUpdated = true; + } + + if (isUpdated) { + this.touch(); + } + } +} diff --git a/apps/backend/src/domain/commodities/commodity.test.ts b/apps/backend/src/domain/commodities/commodity.test.ts new file mode 100644 index 00000000..8654c82d --- /dev/null +++ b/apps/backend/src/domain/commodities/commodity.test.ts @@ -0,0 +1,345 @@ +import { createUser } from 'src/testing'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { CommodityCode, Id, Name, Timestamp } from '../domain-core'; +import { + DeletedEntityOperationError, + InvalidCommodityPrecisionError, + InvalidCommoditySymbolError, +} from '../domain.errors'; +import { User } from '../users/user.entity'; + +import { Commodity } from './commodity.entity'; +import { CommoditySnapshot } from './types'; + +describe('Commodity Domain Entity', () => { + let user: User; + let userId: ReturnType; + + const commodityCode = CommodityCode.create('commodityCode'); + const commoditySymbol = 'CMD'; + const commodityName = Name.create('Commodity Name'); + const commodityPrecision = 2; + + beforeAll(async () => { + user = await createUser(); + userId = user.getId(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('create method', () => { + it('should create commodity with valid data', () => { + const commodity = Commodity.create( + user, + commodityName, + commodityCode, + commodityPrecision, + commoditySymbol, + ); + + expect(commodity).toBeInstanceOf(Commodity); + expect(commodity.getId()).toBeDefined(); + expect(commodity.getUserId().valueOf()).toBe(userId.valueOf()); + expect(commodity).toHaveProperty('code', commodityCode); + expect(commodity).toHaveProperty('symbol', commoditySymbol); + expect(commodity.getUserId().equals(userId)).toBe(true); + }); + + it('should create commodity with default precision and null symbol', () => { + const commodity = Commodity.create(user, commodityName, commodityCode); + + expect(commodity).toBeInstanceOf(Commodity); + expect(commodity.getId()).toBeDefined(); + expect(commodity.getUserId().valueOf()).toBe(userId.valueOf()); + expect(commodity).toHaveProperty('code', commodityCode); + expect(commodity).toHaveProperty('symbol', null); + expect(commodity.getUserId().equals(userId)).toBe(true); + }); + + it('should create commodity with specified precision and null symbol', () => { + const specifiedPrecision = 4; + const commodity = Commodity.create( + user, + commodityName, + commodityCode, + specifiedPrecision, + ); + + expect(commodity).toBeInstanceOf(Commodity); + expect(commodity.getId()).toBeDefined(); + expect(commodity.getUserId().valueOf()).toBe(userId.valueOf()); + expect(commodity).toHaveProperty('code', commodityCode); + expect(commodity).toHaveProperty('symbol', null); + expect(commodity.getUserId().equals(userId)).toBe(true); + }); + + it('should throw error when creating commodity with invalid precision', () => { + const invalidPrecision = -1; + + expect(() => { + Commodity.create(user, commodityName, commodityCode, invalidPrecision); + }).toThrowError(InvalidCommodityPrecisionError); + }); + + it('should throw error when creating commodity with invalid symbol', () => { + const invalidSymbol = 'INVALID_SYMBOL'; + + expect(() => { + Commodity.create( + user, + commodityName, + commodityCode, + commodityPrecision, + invalidSymbol, + ); + }).toThrowError(InvalidCommoditySymbolError); + }); + }); + + describe('restore method', () => { + it('should restore commodity from snapshot', () => { + const createdAtValue = '2023-10-01T12:00:00.000Z'; + const updatedAtValue = '2023-10-02T12:00:00.000Z'; + const commodityIdValue = '223e4567-e89b-12d3-a456-426614174000'; + + const createdAt = Timestamp.restore(createdAtValue); + const updatedAt = Timestamp.restore(updatedAtValue); + + const commodityId = Id.restore(commodityIdValue); + + const snapshot: CommoditySnapshot = { + code: commodityCode.valueOf(), + createdAt: createdAt.valueOf(), + id: commodityId.valueOf(), + isTombstone: false, + name: commodityName.valueOf(), + precision: commodityPrecision, + symbol: 'RCMD', + updatedAt: updatedAt.valueOf(), + userId: userId.valueOf(), + }; + + const restoredCommodity = Commodity.restore(snapshot); + + expect(restoredCommodity).toBeInstanceOf(Commodity); + expect(restoredCommodity.getId().valueOf()).toBe(snapshot.id); + expect(restoredCommodity.getUserId().valueOf()).toBe(snapshot.userId); + expect(restoredCommodity).toHaveProperty( + 'code', + CommodityCode.create(snapshot.code), + ); + + expect(restoredCommodity.symbol).toBe(snapshot.symbol); + expect(restoredCommodity.belongsToUser(userId)).toBe(true); + }); + + it('should throw error when restoring commodity with invalid precision', () => { + const snapshot: CommoditySnapshot = { + code: commodityCode.valueOf(), + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isTombstone: false, + name: commodityName.valueOf(), + precision: -1, // Invalid precision + symbol: 'RCMD', + updatedAt: Timestamp.create().valueOf(), + userId: userId.valueOf(), + }; + + expect(() => { + Commodity.restore(snapshot); + }).toThrowError(InvalidCommodityPrecisionError); + }); + + it('should throw error when restoring commodity with invalid symbol', () => { + const snapshot: CommoditySnapshot = { + code: commodityCode.valueOf(), + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isTombstone: false, + name: commodityName.valueOf(), + precision: commodityPrecision, + symbol: 'INVALID_SYMBOL', // Invalid symbol + updatedAt: Timestamp.create().valueOf(), + userId: userId.valueOf(), + }; + + expect(() => { + Commodity.restore(snapshot); + }).toThrowError(InvalidCommoditySymbolError); + }); + }); + + describe('commodity management', () => { + describe('markAsDeleted and isDeleted methods', () => { + it('should mark commodity as deleted and check deletion status', () => { + const commodity = Commodity.create( + user, + commodityName, + commodityCode, + commodityPrecision, + commoditySymbol, + ); + + expect(commodity.isDeleted()).toBe(false); + + commodity.markAsDeleted(); + + expect(commodity.isDeleted()).toBe(true); + }); + + it('should not allow updates on a deleted commodity', () => { + const commodity = Commodity.create( + user, + commodityName, + commodityCode, + commodityPrecision, + commoditySymbol, + ); + + commodity.markAsDeleted(); + + const newCode = CommodityCode.create('updatedCode'); + + expect(() => { + commodity.update({ code: newCode.valueOf(), symbol: 'NEW' }); + }).toThrow(DeletedEntityOperationError); + }); + + it('should not allow deleting an already deleted commodity', () => { + const commodity = Commodity.create( + user, + commodityName, + commodityCode, + commodityPrecision, + commoditySymbol, + ); + + commodity.markAsDeleted(); + + const deletedSnapshot = commodity.toSnapshot(); + + let thrownError: unknown; + + try { + commodity.markAsDeleted(); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toMatchObject({ + code: 'DELETED_ENTITY_OPERATION', + context: { + entityType: Commodity.entityType, + operation: 'delete', + }, + }); + + expect(commodity.toSnapshot()).toEqual(deletedSnapshot); + }); + + it('should only mark commodity as deleted and update timestamp during soft deletion', () => { + vi.useFakeTimers(); + + const timestampBeforeDeletionValue = '2025-01-01T00:00:00.000Z'; + const timestampDuringDeletionValue = '2025-01-01T00:00:01.000Z'; + + vi.setSystemTime(new Date(timestampBeforeDeletionValue)); + + const commodity = Commodity.create( + user, + commodityName, + commodityCode, + commodityPrecision, + commoditySymbol, + ); + + const commoditySnapshotBeforeDeletion = commodity.toSnapshot(); + + expect(commodity.isDeleted()).toBe(false); + + vi.setSystemTime(new Date(timestampDuringDeletionValue)); + + commodity.markAsDeleted(); + + const commoditySnapshotAfterDeletion = commodity.toSnapshot(); + + expect(commodity.isDeleted()).toBe(true); + + expect(commoditySnapshotAfterDeletion).toEqual({ + ...commoditySnapshotBeforeDeletion, + isTombstone: true, + updatedAt: commoditySnapshotAfterDeletion.updatedAt, + }); + + expect(commoditySnapshotAfterDeletion.updatedAt).not.toBe( + commoditySnapshotBeforeDeletion.updatedAt, + ); + }); + }); + + describe('update method', () => { + it('should update commodity name, code, and symbol and touch the timestamp', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const commodity = Commodity.create( + user, + commodityName, + commodityCode, + commodityPrecision, + commoditySymbol, + ); + + const before = commodity.toSnapshot(); + + const newName = Name.create('Updated Commodity Name'); + const newCode = CommodityCode.create('updatedCode'); + const newSymbol = 'UCMD'; + + vi.setSystemTime(new Date('2025-01-01T00:00:01.000Z')); + + commodity.update({ + code: newCode.valueOf(), + name: newName.valueOf(), + symbol: newSymbol, + }); + + const updatedSnapshot = commodity.toSnapshot(); + + expect(updatedSnapshot.name).toBe(newName.valueOf()); + expect(updatedSnapshot.symbol).toBe(newSymbol); + expect(updatedSnapshot.code).toBe(newCode.valueOf()); + expect(updatedSnapshot.updatedAt).not.toBe(before.updatedAt); + }); + }); + + it('should throw error when updating commodity with invalid symbol', () => { + const commodity = Commodity.create( + user, + commodityName, + commodityCode, + commodityPrecision, + commoditySymbol, + ); + + expect(() => { + commodity.update({ + symbol: 'INVALID_SYMBOL', + }); + }).toThrowError(); + }); + + it('should not update timestamp for empty update', () => { + const commodity = Commodity.create(user, commodityName, commodityCode); + const snapshot = commodity.toSnapshot(); + + commodity.update({}); + + expect(commodity.toSnapshot()).toEqual(snapshot); + }); + }); +}); diff --git a/apps/backend/src/domain/commodities/helpers.ts b/apps/backend/src/domain/commodities/helpers.ts new file mode 100644 index 00000000..211880aa --- /dev/null +++ b/apps/backend/src/domain/commodities/helpers.ts @@ -0,0 +1,34 @@ +import { + CommoditySymbolString, + CommodityPrecisionNumber, +} from '@ledgerly/shared/types'; +import { + commoditySymbol, + commodityPrecision, +} from '@ledgerly/shared/validation'; + +import { parseValueObject } from '../domain-core'; +import { + InvalidCommoditySymbolError, + InvalidCommodityPrecisionError, +} from '../domain.errors'; + +export const parseCommoditySymbol = ( + symbol: string | null, +): CommoditySymbolString | null => { + return parseValueObject(symbol, commoditySymbol, (cause, invalidValue) => { + throw new InvalidCommoditySymbolError(invalidValue, cause); + }); +}; + +export const parseCommodityPrecision = ( + precision: number, +): CommodityPrecisionNumber => { + return parseValueObject( + precision, + commodityPrecision, + (cause, invalidValue) => { + throw new InvalidCommodityPrecisionError(invalidValue, cause); + }, + ); +}; diff --git a/apps/backend/src/domain/commodities/index.ts b/apps/backend/src/domain/commodities/index.ts new file mode 100644 index 00000000..adfe6c1b --- /dev/null +++ b/apps/backend/src/domain/commodities/index.ts @@ -0,0 +1 @@ +export { Commodity } from './commodity.entity'; diff --git a/apps/backend/src/domain/commodities/types.ts b/apps/backend/src/domain/commodities/types.ts new file mode 100644 index 00000000..1af354a1 --- /dev/null +++ b/apps/backend/src/domain/commodities/types.ts @@ -0,0 +1,25 @@ +import { + CommodityCodeString, + CommoditySymbolString, + IsoDatetimeString, + UUID, + CommodityPrecisionNumber, +} from '@ledgerly/shared/types'; + +export type CommoditySnapshot = { + createdAt: IsoDatetimeString; + id: UUID; + isTombstone: boolean; + code: CommodityCodeString; + symbol: CommoditySymbolString | null; + updatedAt: IsoDatetimeString; + userId: UUID; + name: string; + precision: CommodityPrecisionNumber; +}; + +export type CommodityUpdateProps = Partial<{ + code: string; + symbol: string | null; + name: string; +}>; diff --git a/apps/backend/src/domain/domain-core/value-objects/CommodityCode.test.ts b/apps/backend/src/domain/domain-core/value-objects/CommodityCode.test.ts new file mode 100644 index 00000000..2c0444a3 --- /dev/null +++ b/apps/backend/src/domain/domain-core/value-objects/CommodityCode.test.ts @@ -0,0 +1,89 @@ +import { InvalidCommodityCodeError } from 'src/domain/domain.errors'; +import { describe, expect, it } from 'vitest'; + +import { CommodityCode } from './CommodityCode'; + +describe('CommodityCode Value Object', () => { + describe('create method', () => { + it('should create valid commodity code', () => { + const commodityCode = CommodityCode.create('CMD'); + + expect(commodityCode.valueOf()).toBe('CMD'); + }); + + it('should normalize commodity code to uppercase', () => { + const commodityCode = CommodityCode.create('cmd'); + + expect(commodityCode.valueOf()).toBe('CMD'); + }); + + it('should trim whitespace', () => { + const commodityCode = CommodityCode.create(' CMD '); + + expect(commodityCode.valueOf()).toBe('CMD'); + }); + + it('should throw error for invalid commodity codes', () => { + const invalidCodes = [ + '', + ' ', + ' ', + 'US', + '123', + '1USD', + 'USD EUR', + ' USD EUR ', + 'USD!', + 'USD$', + 'USD.', + 'USD,', + 'USD/', + 'USD\\', + 'USD@', + 'USD#', + '_USD', + '-USD', + 'РУБ', + '€', + 'ドル', + 'QWERTYUIOPASDFGHJ', + ]; + + invalidCodes.forEach((invalidCode) => { + expect(() => CommodityCode.create(invalidCode)).toThrow( + InvalidCommodityCodeError, + ); + }); + }); + + it('should accept valid commodity codes', () => { + const validCodes = [ + 'CMD', + 'USD', + 'EUR', + 'JPY', + 'GBP', + 'AUD', + 'CAD', + 'CHF', + 'CNY', + 'SEK', + 'RUB', + 'BTC', + 'ETH', + 'XAU', + 'BONUS', + 'WORKHOURS', + 'USD1', + 'ABC123', + 'usd', + ' UsD ', + 'QWERTYUIOPASDFGH', + ]; + + validCodes.forEach((validCode) => { + expect(() => CommodityCode.create(validCode)).not.toThrow(); + }); + }); + }); +}); diff --git a/apps/backend/src/domain/domain-core/value-objects/CommodityCode.ts b/apps/backend/src/domain/domain-core/value-objects/CommodityCode.ts new file mode 100644 index 00000000..fcdd05af --- /dev/null +++ b/apps/backend/src/domain/domain-core/value-objects/CommodityCode.ts @@ -0,0 +1,34 @@ +import { CommodityCodeString } from '@ledgerly/shared/types'; +import { commodityCode } from '@ledgerly/shared/validation'; +import { InvalidCommodityCodeError } from 'src/domain/domain.errors'; + +import { parseValueObject } from './parseValueObject'; + +const parseCommodityCode = (code: string): CommodityCodeString => { + return parseValueObject(code, commodityCode, (cause, invalidValue) => { + throw new InvalidCommodityCodeError(invalidValue, cause); + }); +}; + +export class CommodityCode { + private constructor(private readonly code: CommodityCodeString) { + Object.freeze(this); + } + + static create(code: string): CommodityCode { + const normalizedCode = parseCommodityCode(code); + return new CommodityCode(normalizedCode); + } + + static restore(code: string): CommodityCode { + return new CommodityCode(parseCommodityCode(code)); + } + + equals(other: CommodityCode): boolean { + return this.code === other.code; + } + + valueOf(): CommodityCodeString { + return this.code; + } +} diff --git a/apps/backend/src/domain/domain-core/value-objects/index.ts b/apps/backend/src/domain/domain-core/value-objects/index.ts index 039c697e..278baf2d 100644 --- a/apps/backend/src/domain/domain-core/value-objects/index.ts +++ b/apps/backend/src/domain/domain-core/value-objects/index.ts @@ -9,3 +9,4 @@ export { Password } from './Password'; export { DateValue } from './DateValue'; export { Version } from './Version'; export { parseValueObject } from './parseValueObject'; +export { CommodityCode } from './CommodityCode'; diff --git a/apps/backend/src/domain/domain.errors.ts b/apps/backend/src/domain/domain.errors.ts index 78e6de93..73f36d15 100644 --- a/apps/backend/src/domain/domain.errors.ts +++ b/apps/backend/src/domain/domain.errors.ts @@ -75,6 +75,57 @@ export class InvalidAmountError extends CodedDomainError<'INVALID_AMOUNT'> { } } +export class InvalidCommodityCodeError extends CodedDomainError<'INVALID_COMMODITY_CODE'> { + constructor( + public readonly value: unknown, + cause?: Error, + ) { + super( + 'commodity code is invalid', + apiErrorCodes.invalidCommodityCode, + { + reason: 'INVALID_FORMAT', + received: String(value), + }, + cause, + ); + } +} + +export class InvalidCommodityPrecisionError extends CodedDomainError<'INVALID_COMMODITY_PRECISION'> { + constructor( + public readonly value: unknown, + cause?: Error, + ) { + super( + 'commodity precision is invalid', + apiErrorCodes.invalidCommodityPrecision, + { + reason: 'INVALID_FORMAT', + received: String(value), + }, + cause, + ); + } +} + +export class InvalidCommoditySymbolError extends CodedDomainError<'INVALID_COMMODITY_SYMBOL'> { + constructor( + public readonly value: unknown, + cause?: Error, + ) { + super( + 'commodity symbol is invalid', + apiErrorCodes.invalidCommoditySymbol, + { + reason: 'INVALID_FORMAT', + received: String(value), + }, + cause, + ); + } +} + export class InvalidDateError extends CodedDomainError<'INVALID_DATE'> { constructor(cause?: Error) { super( diff --git a/apps/backend/src/domain/index.ts b/apps/backend/src/domain/index.ts index a8666551..c6be0b0c 100644 --- a/apps/backend/src/domain/index.ts +++ b/apps/backend/src/domain/index.ts @@ -2,5 +2,6 @@ export { User } from './users/user.entity'; export { Account, AccountType } from './accounts/'; export { Transaction } from './transactions/'; export { Operation } from './operations'; +export { Commodity } from './commodities'; export * from './domain.errors'; diff --git a/apps/backend/src/presentation/http/error-handler.ts b/apps/backend/src/presentation/http/error-handler.ts index 608a14dd..a4421476 100644 --- a/apps/backend/src/presentation/http/error-handler.ts +++ b/apps/backend/src/presentation/http/error-handler.ts @@ -26,6 +26,9 @@ const statusByErrorCode = { [apiErrorCodes.internalServerError]: 500, [apiErrorCodes.invalidAccountType]: 400, [apiErrorCodes.invalidAmount]: 400, + [apiErrorCodes.invalidCommodityCode]: 400, + [apiErrorCodes.invalidCommodityPrecision]: 400, + [apiErrorCodes.invalidCommoditySymbol]: 400, [apiErrorCodes.invalidDate]: 400, [apiErrorCodes.invalidEmail]: 400, [apiErrorCodes.invalidIdentifier]: 400, diff --git a/packages/shared/src/types/error-contract.ts b/packages/shared/src/types/error-contract.ts index 81189bae..f0db0b07 100644 --- a/packages/shared/src/types/error-contract.ts +++ b/packages/shared/src/types/error-contract.ts @@ -14,6 +14,9 @@ export const apiErrorCodes = { internalServerError: "INTERNAL_SERVER_ERROR", invalidAccountType: "INVALID_ACCOUNT_TYPE", invalidAmount: "INVALID_AMOUNT", + invalidCommodityCode: "INVALID_COMMODITY_CODE", + invalidCommodityPrecision: "INVALID_COMMODITY_PRECISION", + invalidCommoditySymbol: "INVALID_COMMODITY_SYMBOL", invalidDate: "INVALID_DATE", invalidEmail: "INVALID_EMAIL", invalidIdentifier: "INVALID_IDENTIFIER", @@ -91,6 +94,21 @@ export type ErrorContextByCode = { reason: "NOT_INTEGER_MINOR_UNITS"; received: string; }; + INVALID_COMMODITY_CODE: { + field?: string; + reason: "INVALID_FORMAT"; + received: string; + }; + INVALID_COMMODITY_PRECISION: { + field?: string; + reason: "INVALID_FORMAT"; + received: string; + }; + INVALID_COMMODITY_SYMBOL: { + field?: string; + reason: "INVALID_FORMAT"; + received: string; + }; INVALID_DATE: { reason: "INVALID_FORMAT"; }; diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index ffd4afc4..0408bdad 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -7,6 +7,9 @@ import { sha256String, uuid, amountString, + commodityCode, + commodityPrecision, + commoditySymbol, } from "../validation/baseValidations"; export type CurrencyCode = z.infer; @@ -18,3 +21,7 @@ export type Sha256String = z.infer; export type UUID = z.infer; export type AmountString = z.infer; + +export type CommodityCodeString = z.infer; +export type CommodityPrecisionNumber = z.infer; +export type CommoditySymbolString = z.infer; diff --git a/packages/shared/src/validation/baseValidations.ts b/packages/shared/src/validation/baseValidations.ts index 5b53f1ec..62fc00cf 100644 --- a/packages/shared/src/validation/baseValidations.ts +++ b/packages/shared/src/validation/baseValidations.ts @@ -80,3 +80,28 @@ export const getTransactionsQuerySchema = z }); } }); + +const COMMODITY_CODE_MIN_LENGTH = 3; +const COMMODITY_CODE_MAX_LENGTH = 16; + +export const commodityCode = z + .string() + .trim() + .toUpperCase() + .min( + COMMODITY_CODE_MIN_LENGTH, + `Commodity code must be at least ${COMMODITY_CODE_MIN_LENGTH} characters long`, + ) + .max( + COMMODITY_CODE_MAX_LENGTH, + `Commodity code must be at most ${COMMODITY_CODE_MAX_LENGTH} characters long`, + ) + .regex( + /^[A-Z][A-Z0-9]*$/, + "Commodity code must start with a letter and contain only letters and digits", + ) + .brand<"CommodityCode">(); + +export const commoditySymbol = z.string().trim().max(12).nullable(); + +export const commodityPrecision = z.number().int().min(0).max(18);