diff --git a/src/Parser.php b/src/Parser.php index 269175b..531fe69 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -40,11 +40,45 @@ public function extractValue(): string [$digits, $decimals] = array_pad(explode('.', $matches[1]), 2, '0'); - $decimals = substr(strval(round(floatval('0.' . $decimals), 2)), 2); + $negative = str_starts_with($digits, '-'); + $whole = ltrim($digits, '-'); + if($whole === '') { + $whole = '0'; + } + + $rounded = number_format(round(floatval('0.' . $decimals), 2), 2, '.', ''); + [$carry, $cents] = explode('.', $rounded); + if($carry === '1') { + $whole = $this->increment($whole); + } + + $value = ltrim($whole . $cents, '0'); + if($value === '') { + $value = '0'; + } + if($negative && $value !== '0') { + $value = '-' . $value; + } - $value = ltrim($digits . str_pad($decimals, 2, '0'), '0'); + return $value; + } + + /** + * Add one to a whole-unit digit string. + */ + protected function increment(string $whole): string + { + $chars = str_split($whole); + for($i = count($chars) - 1; $i >= 0; $i--) { + if($chars[$i] !== '9') { + $chars[$i] = (string) ((int) $chars[$i] + 1); + + return implode('', $chars); + } + $chars[$i] = '0'; + } - return strlen($value) ? $value : '0'; + return '1' . implode('', $chars); } /** diff --git a/tests/Unit/ParsesMonetaryStringsTest.php b/tests/Unit/ParsesMonetaryStringsTest.php index f1627d3..3692c57 100644 --- a/tests/Unit/ParsesMonetaryStringsTest.php +++ b/tests/Unit/ParsesMonetaryStringsTest.php @@ -37,6 +37,14 @@ expect(Price::parse('bar 6.50', 'EUR')->__toString())->toBe('EUR 6.50'); }); +it('keeps a rounded cent that carries into the next unit', function() { + expect(Price::parse('1.999', 'EUR')->__toString())->toBe('EUR 2.00'); + expect(Price::parse('0.999', 'EUR')->__toString())->toBe('EUR 1.00'); + expect(Price::parse('4.995', 'EUR')->__toString())->toBe('EUR 5.00'); + expect(Price::parse('-1.999', 'EUR')->__toString())->toBe('EUR -2.00'); + expect(Price::parse('9.996', 'EUR')->__toString())->toBe('EUR 10.00'); +}); + it('parses values with both narrow non-breaking and regular spaces', function() { expect(Price::parse('1 234,56', 'EUR')->__toString())->toBe('EUR 1234.56'); expect(Price::parse('1 234,56', 'EUR')->__toString())->toBe('EUR 1234.56');