-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #234 from citrineos/rc-1.4.1
Rc 1.4.1
- Loading branch information
Showing
26 changed files
with
2,013 additions
and
402 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}, | ||
); | ||
}); | ||
}); |
Oops, something went wrong.