Skip to content

Commit

Permalink
Merge pull request #234 from citrineos/rc-1.4.1
Browse files Browse the repository at this point in the history
Rc 1.4.1
  • Loading branch information
thanaParis committed Aug 19, 2024
2 parents d4d40af + 5075f99 commit 049c3e3
Show file tree
Hide file tree
Showing 26 changed files with 2,013 additions and 402 deletions.
11 changes: 11 additions & 0 deletions 00_Base/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { JestConfigWithTsJest } from 'ts-jest';

const jestConfig: JestConfigWithTsJest = {
testEnvironment: 'node',
transform: {
'^.+.tsx?$': ['ts-jest', {}],
},
displayName: 'Base Module',
};

export default jestConfig;
3 changes: 2 additions & 1 deletion 00_Base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@
"@faker-js/faker": "8.4.1"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "6.5.0",
"@types/big.js": "6.2.1",
"ajv": "8.17.1",
"big.js": "6.2.1",
"class-transformer": "0.5.1",
"fastify": "4.22.2",
"@fastify/auth": "4.6.1",
Expand Down
27 changes: 27 additions & 0 deletions 00_Base/src/assertion/assertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export function assert(
predicate: boolean | (() => boolean),
message?: string,
): asserts predicate {
switch (typeof predicate) {
case 'boolean': {
if (!predicate) {
throw new Error(message);
}
break;
}
case 'function': {
if (!predicate()) {
throw new Error(message);
}
break;
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const exhaustiveCheck: never = predicate;
}
}
}

export function notNull(object: any): boolean {
return object !== undefined && object !== null;
}
2 changes: 2 additions & 0 deletions 00_Base/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,8 @@ export const systemConfigSchema = z
storage: z.string(),
sync: z.boolean(),
alter: z.boolean().optional(),
maxRetries: z.number().int().positive().optional(),
retryDelay: z.number().int().positive().optional(),
}),
}),
util: z.object({
Expand Down
3 changes: 3 additions & 0 deletions 00_Base/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ export { eventGroupFromString } from './interfaces/messages';
export { UnauthorizedException } from './interfaces/api/exceptions/unauthorized.exception';
export { HttpHeader } from './interfaces/api/http.header';
export { HttpStatus } from './interfaces/api/http.status';
export { Money } from './money/Money';
export { Currency, CurrencyCode } from './money/Currency';
export { assert, notNull } from './assertion/assertion';
export { UnauthorizedError } from './interfaces/api/exception/UnauthorizedError';
export { AuthorizationSecurity } from './interfaces/api/AuthorizationSecurity';
export { Ajv };
80 changes: 80 additions & 0 deletions 00_Base/src/money/Currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { assert } from '../assertion/assertion';

/**
* ISO-4217 currency codes.
*/
const CURRENCY_CODES = ['USD', 'EUR', 'CAD', 'GBP'] as const;

export type CurrencyCode = (typeof CURRENCY_CODES)[number];

export function isCurrencyCode(value: string): value is CurrencyCode {
return (CURRENCY_CODES as readonly string[]).includes(value);
}

export function currencyCode(value: string): CurrencyCode {
assert(isCurrencyCode(value), `Unsupported currency code: ${value}`);
return value;
}

const CURRENCY_SCALES = [2] as const;

/**
* Represents the scale of the currency.
*
* - `2`: The minor unit is 1/100 of the major unit.
*/
type CurrencyScale = (typeof CURRENCY_SCALES)[number];

export function isCurrencyScale(value: number): value is CurrencyScale {
return (CURRENCY_SCALES as readonly number[]).includes(value);
}

export function currencyScale(value: number): CurrencyScale {
assert(isCurrencyScale(value), `Unsupported currency scale: ${value}`);
return value;
}

type CurrencyMap = {
[K in CurrencyCode]: Currency;
};

/**
* Represents a currency with decimal precision.
*
* To add support for a currency:
* 1. Add the new currency code to the {@link CURRENCY_CODES} array.
* 2. Create a corresponding mapping in the {@link SUPPORTED_CURRENCIES} map.
*/
export class Currency {
private static readonly SUPPORTED_CURRENCIES: CurrencyMap = {
USD: new Currency('USD', 2),
EUR: new Currency('EUR', 2),
CAD: new Currency('CAD', 2),
GBP: new Currency('GBP', 2),
};

private readonly _code: CurrencyCode;
private readonly _scale: CurrencyScale;

constructor(code: string | CurrencyCode, scale: number | CurrencyScale) {
this._code = currencyCode(code);
this._scale = currencyScale(scale);
}

get code() {
return this._code;
}

get scale() {
return this._scale;
}

static of(code: string | CurrencyCode) {
assert(isCurrencyCode(code), `Unsupported currency code: ${code}`);
const currency = Currency.SUPPORTED_CURRENCIES[code];
if (currency === undefined) {
throw Error(`${code} currency is not supported`);
}
return currency;
}
}
114 changes: 114 additions & 0 deletions 00_Base/src/money/Money.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Currency, CurrencyCode } from './Currency';
import { Big } from 'big.js';
import { assert, notNull } from '../assertion/assertion';

export type CurrencySource = string | CurrencyCode | Currency;

export class Money {
private readonly _amount: Big;
private readonly _currency: Currency;

private constructor(amount: number | string | Big, currency: CurrencySource) {
assert(notNull(amount), 'Amount has to be defined');
assert(notNull(currency), 'Currency has to be defined');
try {
this._amount = new Big(amount);
} catch (error) {
throw new Error(`Invalid money amount: ${amount}`);
}
this._currency =
typeof currency === 'string' ? Currency.of(currency) : currency;
}

get amount(): Big {
return this._amount;
}

get currency(): Currency {
return this._currency;
}

static of(amount: number | string | Big, currency: CurrencySource): Money {
return new Money(amount, currency);
}

static USD(amount: number | string | Big) {
return new Money(amount, 'USD');
}

toNumber(): number {
return this._amount.toNumber();
}

/**
* Rounds the amount down to match the currency's defined scale.
* This method could be used when converting an amount to its final monetary value.
*
* @returns {Money} A new Money instance with the amount rounded down to the currency's scale.
*/
roundToCurrencyScale(): Money {
const newAmount = this._amount.round(
this.currency.scale,
0, // RoundDown
);
return this.withAmount(newAmount);
}

multiply(multiplier: number | string | Big): Money {
return this.withAmount(this.amount.times(multiplier));
}

add(money: Money): Money {
this.requireSameCurrency(money);
return this.withAmount(this.amount.plus(money.amount));
}

subtract(money: Money): Money {
this.requireSameCurrency(money);
return this.withAmount(this.amount.minus(money.amount));
}

equals(money: Money): boolean {
return this._currency === money._currency && this.amount.eq(money.amount);
}

greaterThan(money: Money): boolean {
this.requireSameCurrency(money);
return this.amount.gt(money.amount);
}

greaterThanOrEqual(money: Money): boolean {
this.requireSameCurrency(money);
return this.amount.gte(money.amount);
}

lessThan(money: Money): boolean {
this.requireSameCurrency(money);
return this.amount.lt(money.amount);
}

lessThanOrEqual(money: Money): boolean {
this.requireSameCurrency(money);
return this.amount.lte(money.amount);
}

isZero(): boolean {
return this.amount.eq(0);
}

isPositive(): boolean {
return this.amount.gt(0);
}

isNegative(): boolean {
return this.amount.lt(0);
}

private withAmount(amount: Big): Money {
return new Money(amount, this._currency);
}

private requireSameCurrency(money: Money) {
assert(this.currency.code === money.currency.code, 'Currency mismatch');
}
}
55 changes: 55 additions & 0 deletions 00_Base/test/money/Currency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Currency } from '../../src/money/Currency';
import { expect } from '@jest/globals';

describe('currency', () => {
describe('of', () => {
it.each(['', ' ', 'RANDOM', 'USD ', ' USD', 'usd', 'PLN', 'CHF'])(
'should fail if currency is not supported',
(currencyCode) => {
expect(() => Currency.of(currencyCode)).toThrow(
`Unsupported currency code: ${currencyCode}`,
);
},
);

it.each([
['USD', Currency.of('USD')],
['EUR', Currency.of('EUR')],
['CAD', Currency.of('CAD')],
['GBP', Currency.of('GBP')],
] as Array<[string, Currency]>)(
'should return currency for currency code',
(currencyCode, expectedCurrency) => {
expect(Currency.of(currencyCode)).toEqual(expectedCurrency);
},
);
});

describe('code', () => {
it.each([
[Currency.of('USD'), 'USD'],
[Currency.of('EUR'), 'EUR'],
[Currency.of('CAD'), 'CAD'],
[Currency.of('GBP'), 'GBP'],
] as Array<[Currency, string]>)(
'should return currency code',
(currency, expectedCode) => {
expect(currency.code).toEqual(expectedCode);
},
);
});

describe('scale', () => {
it.each([
[Currency.of('USD'), 2],
[Currency.of('EUR'), 2],
[Currency.of('CAD'), 2],
[Currency.of('GBP'), 2],
] as Array<[Currency, number]>)(
'should return currency scale',
(currency, expectedScale) => {
expect(currency.scale).toEqual(expectedScale);
},
);
});
});
Loading

0 comments on commit 049c3e3

Please sign in to comment.