Amount::__tostring()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 0
dl 0
loc 4
rs 10
c 0
b 0
f 0
ccs 2
cts 2
cp 1
crap 1
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace byrokrat\amount;
6
7
/**
8
 * Value class for working with and representing monetary amounts
9
 *
10
 * Uses the bcmath extension for arbitrary floating point arithmetic precision
11
 */
12
class Amount
13
{
14
    /**
15
     * @var string
16
     */
17
    private $amount;
18
19
    /**
20
     * @var array Substitution map for signal strings
21
     */
22
    private static $signals = [
23
        '0'=>'å', '1'=>'J', '2'=>'K', '3'=>'L', '4'=>'M', '5'=>'N', '6'=>'O', '7'=>'P', '8'=>'Q', '9'=>'R'
24
    ];
25
26
    /**
27
     * Create amount from numerical string
28
     *
29
     * @throws InvalidArgumentException If $amount is not valid
30
     */
31 381
    public function __construct(string $amount)
32
    {
33 381
        if (!preg_match("/^[+-]?\d*\.?\d*$/", $amount)) {
34 2
            throw new InvalidArgumentException("Constructor expects a numerical string");
35
        }
36
37 379
        $this->amount = $amount;
38 379
    }
39
40
    /**
41
     * Create amount from integer or floating point number
42
     *
43
     * It is important to note that computers internally use the binary floating
44
     * point format and cannot accurately represent a number like 0.1, 0.2 or
45
     * 0.3 at all. Using floating point numbers leads to a loss of precision.
46
     * For example `floor((0.1+0.7)*10)` will usually return 7 instead of the
47
     * expected 8, since the internal representation will be something like
48
     * 7.9999999999999991118....
49
     *
50
     * For this reason floats should never ne used to store monetary data. This
51
     * method exists for rare situations when converting from native formats is
52
     * inevitable. Unless you know what you are doing it should NOT be used.
53
     *
54
     * @param  int|float $number
55
     * @throws InvalidArgumentException If $number is not an integer or float
56
     */
57 19
    public static function createFromNumber($number, int $precision = -1): Amount
58
    {
59 19
        if (!is_int($number) && !is_float($number)) {
60 1
            throw new InvalidArgumentException(
61 1
                "createFromNumber() expects an integer or float, found: " . gettype($number)
62
            );
63
        }
64
65 18
        return new static(
66 18
            number_format(
67 18
                $number,
68 18
                $precision >= 0 ? $precision : self::getInternalPrecision(),
69 18
                '.',
70 18
                ''
71
            )
72
        );
73
    }
74
75
    /**
76
     * Create amount from a formatted string
77
     */
78 4
    public static function createFromFormat(string $amount, string $point = '.', string $sep = ''): Amount
79
    {
80 4
        return new static(
81 4
            str_replace(
82 4
                [$point, $sep, '[~placeholder~]'],
83 4
                ['[~placeholder~]', '', '.'],
84 4
                $amount
85
            )
86
        );
87
    }
88
89
    /**
90
     * Create amount from signal string
91
     *
92
     * Signal strings does not contain a decimal digit separator. Instead the
93
     * last two digits are always considered decimals. For negative values the
94
     * last digit is converted to an alphabetic character according to schema:
95
     * 0 => å, 1 => J, 2 => K, ... 9 => R.
96
     *
97
     * @throws InvalidArgumentException If $signalStr is not a valid signal string
98
     */
99 12
    public static function createFromSignalString(string $signalStr): Amount
100
    {
101 12
        if (!preg_match("/^\d+(å|[JKLMNOPQR])?$/", $signalStr)) {
102 1
            throw new InvalidArgumentException("createFromSignalString() expects a valid signal string");
103
        }
104
105 11
        if (!is_numeric($signalStr)) {
106 10
            $signalStr = '-' . str_replace(
107 10
                self::$signals,
108 10
                array_keys(self::$signals),
109 10
                $signalStr
110
            );
111
        }
112
113 11
        return new static(
114 11
            preg_replace("/^(-?\d*)(\d\d)$/", "$1.$2", $signalStr, 1)
115
        );
116
    }
117
118
    /**
119
     * Get the raw stored amount
120
     */
121 213
    public function getAmount(): string
122
    {
123 213
        return $this->amount;
124
    }
125
126
    /**
127
     * Get new Amount rounded to $precision number of decimal digit using $rounder
128
     */
129 201
    public function roundTo(int $precision = -1, Rounder $rounder = null): Amount
130
    {
131 201
        return new static(
132 201
            ($rounder ?: $this->getDefaultRounder())->round(
133 201
                $this->getAmount(),
134 201
                $precision >= 0 ? $precision : $this->getDisplayPrecision()
135
            )
136
        );
137
    }
138
139
    /**
140
     * Get amount as string
141
     */
142 197
    public function getString(int $precision = -1, Rounder $rounder = null): string
143
    {
144 197
        return bcadd(
145 197
            $this->roundTo($precision, $rounder)->getAmount(),
146 197
            '0.0',
147 197
            $precision >= 0 ? $precision : $this->getDisplayPrecision()
148
        );
149
    }
150
151
    /**
152
     * Get amount as string
153
     */
154 2
    public function __tostring(): string
155
    {
156 2
        return $this->getString();
157
    }
158
159
    /**
160
     * Get amount as integer
161
     */
162 1
    public function getInt(Rounder $rounder = null): int
163
    {
164 1
        return (int)$this->getFloat(0, $rounder);
165
    }
166
167
    /**
168
     * Get amount as float
169
     *
170
     * It is important to note that computers internally use the binary floating
171
     * point format and cannot accurately represent a number like 0.1, 0.2 or
172
     * 0.3 at all. Using floating point numbers leads to a loss of precision.
173
     * For example `floor((0.1+0.7)*10)` will usually return 7 instead of the
174
     * expected 8, since the internal representation will be something like
175
     * 7.9999999999999991118....
176
     *
177
     * For this reason floats should never ne used to store monetary data. This
178
     * method exists for rare situations when converting to native formats is
179
     * inevitable. Unless you know what you are doing it should NOT be used.
180
     */
181 2
    public function getFloat(int $precision = -1, Rounder $rounder = null): float
182
    {
183 2
        return floatval($this->getString($precision, $rounder));
184
    }
185
186
    /**
187
     * Get amount as signal string
188
     *
189
     * Signal strings does not contain a decimal digit separator. Instead the
190
     * last two digits are always considered decimals. For negative values the
191
     * last digit is converted to an alphabetic character according to schema:
192
     * 0 => å, 1 => J, 2 => K, ... 9 => R.
193
     */
194 11
    public function getSignalString(Rounder $rounder = null): string
195
    {
196 11
        if ($this->isNegative()) {
197 10
            $signalStr = $this->getAbsolute()->getString(2, $rounder);
198 10
            $signalStr = substr($signalStr, 0, -1) . self::$signals[substr($signalStr, -1)];
199
        } else {
200 1
            $signalStr = $this->getString(2, $rounder);
201
        }
202
203 11
        return str_replace('.', '', $signalStr);
204
    }
205
206
    /**
207
     * Get new Amount with the value of $amount added to instance
208
     */
209 6 View Code Duplication
    public function add(Amount $amount, int $precision = -1): Amount
210
    {
211 6
        return new static(
212 6
            bcadd(
213 6
                $this->getAmount(),
214 6
                $amount->getAmount(),
215 6
                $precision >= 0 ? $precision : $this->getInternalPrecision()
216
            )
217
        );
218
    }
219
220
    /**
221
     * Get new Amount with the value of $amount subtracted from instance
222
     */
223 6 View Code Duplication
    public function subtract(Amount $amount, int $precision = -1): Amount
224
    {
225 6
        return new static(
226 6
            bcsub(
227 6
                $this->getAmount(),
228 6
                $amount->getAmount(),
229 6
                $precision >= 0 ? $precision : $this->getInternalPrecision()
230
            )
231
        );
232
    }
233
234
    /**
235
     * Get new Amount with the value of instance multiplied with $amount
236
     *
237
     * @param int|float|string|Amount $amount
238
     */
239 8
    public function multiplyWith($amount, int $precision = -1): Amount
240
    {
241 8
        return new static(
242 8
            bcmul(
243 8
                $this->getAmount(),
244 8
                $this->castToString($amount),
245 8
                $precision >= 0 ? $precision : $this->getInternalPrecision()
246
            )
247
        );
248
    }
249
250
    /**
251
     * Get new Amount with the value of instance divided by $amount
252
     *
253
     * @param int|float|string|Amount $divisor
254
     */
255 8
    public function divideBy($divisor, int $precision = -1): Amount
256
    {
257 8
        $strDivisor = $this->castToString($divisor);
258
259 7
        if (!bccomp($strDivisor, '0')) {
260 1
            throw new DivisionByZeroException();
261
        }
262
263 6
        return new static(
264 6
            bcdiv(
265 6
                $this->getAmount(),
266 6
                $strDivisor,
267 6
                $precision >= 0 ? $precision : $this->getInternalPrecision()
268
            )
269
        );
270
    }
271
272
    /**
273
     * Compare to amount
274
     *
275
     * @return int 0 if instance and $amount are equal, 1 if instance is larger, -1 otherwise.
276
     */
277 24
    public function compareTo(Amount $amount, int $precision = -1): int
278
    {
279 24
        return bccomp(
280 24
            $this->getAmount(),
281 24
            $amount->getAmount(),
282 24
            $precision >= 0 ? $precision : $this->getInternalPrecision()
283
        );
284
    }
285
286
    /**
287
     * Check if instance equals amount
288
     */
289 10
    public function equals(Amount $amount, int $precision = -1): bool
290
    {
291 10
        return 0 == $this->compareTo($amount, $precision);
292
    }
293
294
    /**
295
     * Check if instance is less than amount
296
     */
297 15
    public function isLessThan(Amount $amount, int $precision = -1): bool
298
    {
299 15
        return -1 == $this->compareTo($amount, $precision);
300
    }
301
302
    /**
303
     * Check if instance is less than or equals amount
304
     */
305 2
    public function isLessThanOrEquals(Amount $amount, int $precision = -1): bool
306
    {
307 2
        return $this->isLessThan($amount, $precision) || $this->equals($amount, $precision);
308
    }
309
310
    /**
311
     * Check if instance is greater than amount
312
     */
313 8
    public function isGreaterThan(Amount $amount, int $precision = -1): bool
314
    {
315 8
        return 1 == $this->compareTo($amount, $precision);
316
    }
317
318
    /**
319
     * Check if instance is greater than or equals amount
320
     */
321 6
    public function isGreaterThanOrEquals(Amount $amount, int $precision = -1): bool
322
    {
323 6
        return $this->isGreaterThan($amount, $precision) || $this->equals($amount, $precision);
324
    }
325
326
    /**
327
     * Check if amount is zero
328
     */
329 1
    public function isZero(int $precision = -1): bool
330
    {
331 1
        return $this->equals(new static('0'), $precision);
332
    }
333
334
    /**
335
     * Check if amount is greater than zero
336
     */
337 1
    public function isPositive(int $precision = -1): bool
338
    {
339 1
        return $this->isGreaterThan(new static('0'), $precision);
340
    }
341
342
    /**
343
     * Check if amount is less than zero
344
     */
345 12
    public function isNegative(int $precision = -1): bool
346
    {
347 12
        return $this->isLessThan(new static('0'), $precision);
348
    }
349
350
    /**
351
     * Get new Amount with sign inverted
352
     */
353 1
    public function getInverted(): Amount
354
    {
355 1
        return $this->isNegative() ? $this->getAbsolute() : new static('-' . str_replace('+', '', $this->getAmount()));
356
    }
357
358
    /**
359
     * Get new Amount with negative sign removed
360
     */
361 11
    public function getAbsolute(): Amount
362
    {
363 11
        return new static(str_replace('-', '', $this->getAmount()));
364
    }
365
366
    /**
367
     * Allocate amount based on a list of ratios
368
     *
369
     * @param  int[]|float[] $ratios List of ratios
370
     * @param  int $precision Optional decimal precision used in calculation
371
     * @return Amount[] The allocated amounts
372
     */
373 4
    public function allocate(array $ratios, int $precision = -1): array
374
    {
375 4
        $remainder = clone $this;
376 4
        $results = [];
377 4
        $total = array_sum($ratios);
378 4
        $precision = $precision >= 0 ? $precision : $this->getDisplayPrecision();
379 4
        $unit = new static(bcdiv('1', '1' . str_repeat('0', $precision), $precision));
380
381 4
        foreach ($ratios as $ratio) {
382 4
            $share = $this->multiplyWith($ratio)->divideBy($total)->roundTo($precision, new Rounder\RoundDown);
383 4
            $results[] = $share;
384 4
            $remainder = $remainder->subtract($share);
385
        }
386
387 4
        for ($i = 0; $remainder->isGreaterThanOrEquals($unit) > 0; $i++) {
388 3
            $results[$i] = $results[$i]->add($unit);
389 3
            $remainder = $remainder->subtract($unit);
390
        }
391
392 4
        return $results;
393
    }
394
395
396
    /**
397
     * Get the default display precision
398
     */
399 4
    public static function getDisplayPrecision(): int
400
    {
401 4
        return 2;
402
    }
403
404
    /**
405
     * Get the default internal precision used in computations
406
     */
407 45
    public static function getInternalPrecision(): int
408
    {
409 45
        return 10;
410
    }
411
412
    /**
413
     * Get default rounding strategy
414
     */
415 197
    public static function getDefaultRounder(): Rounder
416
    {
417 197
        return new Rounder\RoundHalfUp;
418
    }
419
420
    /**
421
     * Get a numerical string from input
422
     *
423
     * @param  int|float|string|Amount $amount
424
     * @throws InvalidArgumentException If $amount is does not evaluate to a numberical string
425
     */
426 11
    private function castToString($amount): string
427
    {
428 11
        switch (gettype($amount)) {
429 11
            case 'integer':
430 6
            case 'double':
431 5
                $amount = static::createFromNumber($amount);
432 5
                break;
433 6
            case 'string':
434 3
                $amount = new static($amount);
435 3
                break;
436
        }
437
438 11
        if (!$amount instanceof Amount) {
439 1
            throw new InvalidArgumentException("Supplied argument is not valid");
440
        }
441
442 10
        return $amount->getAmount();
443
    }
444
}
445