Skip to content

Instantly share code, notes, and snippets.

@kastaneda
Last active March 6, 2023 10:26
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kastaneda/37573ae5e902a06405f099c065157cf7 to your computer and use it in GitHub Desktop.
Save kastaneda/37573ae5e902a06405f099c065157cf7 to your computer and use it in GitHub Desktop.
<?php
/**
* Simple Loan Calculator
*
* This is standalone web application. It can calculate loan amortization
* schedule based on amount of requested loan, annual interest rate, loan
* fee and duration of the loan in months. Linear and annuity payment
* methods are supported.
*
* This script requires PHP 7.1 (or later) to run.
*
* Contents:
* 1. General purpose library
* 2. Fixed point arithmetic
* 3. Classes for currency and money
* 4. Application, request and responce
* 5. Templating library
* 6. Unit tests
* 7. Templates
* 8. Runtime configuration
* 9. Front controller
*
* @author Dmitry Kolesnikov <kolesnikov.dmitry@gmail.com>
* @copyright 2017 Dmitry Kolesnikov
* @license https://creativecommons.org/licenses/by-sa/4.0/ CC BY-SA 4.0
*/
/**
* Part 1. General purpose library
*/
trait Container
{
protected $container = [];
public function __construct(array $data = [])
{
$this->container = $data;
}
public function offsetGet($key)
{
return $this->container[$key];
}
public function offsetSet($key, $value): void
{
$this->container[$key] = $value;
}
public function offsetExists($key)
{
return array_key_exists($key, $this->container);
}
public function offsetUnset($key): void
{
unset($this->container[$key]);
}
}
/**
* Part 2. Classes for fixed point arithmetic
*/
interface FixedPointImmutable
{
const ERR_NEGATIVE_DIGITS_COUNT = 'Digits number must not be negative';
const STR_DOT = '.';
const STR_MINUS = '-';
const STR_ZERO = '0';
public function __construct(string $amount, int $decimalDigits);
public function getAmount(): string;
public function getDecimalDigits(): int;
public function setDecimalDigits(int $newDigits): FixedPointImmutable;
public function add(FixedPointImmutable $operand): FixedPointImmutable;
public function sub(FixedPointImmutable $operand): FixedPointImmutable;
public function mul(float $operand): FixedPointImmutable;
public function div(float $operand): FixedPointImmutable;
public function __toString(): string;
}
trait FixedPointDecimalDigits
{
private $decimalDigits;
public function getDecimalDigits(): int
{
return $this->decimalDigits;
}
public function setDecimalDigits(int $newDigits): FixedPointImmutable
{
if ($this->getDecimalDigits() == $newDigits) {
return $this;
}
return new self($this->getAmount(), $newDigits);
}
}
trait FixedPointToString
{
public function __toString(): string
{
return $this->getAmount();
}
}
class NaiveFixedPoint implements FixedPointImmutable
{
use FixedPointDecimalDigits, FixedPointToString;
private $amountMultiplied;
public function __construct(string $amount, int $decimalDigits)
{
if ($decimalDigits < 0) {
throw new RangeException(self::ERR_NEGATIVE_DIGITS_COUNT);
}
$this->decimalDigits = $decimalDigits;
$this->amountMultiplied = $this->convertToInt($amount, $decimalDigits);
}
public function getAmount(): string
{
return $this->convertToString(
$this->amountMultiplied,
$this->decimalDigits
);
}
public function add(FixedPointImmutable $operand): FixedPointImmutable
{
$digits = max($this->getDecimalDigits(), $operand->getDecimalDigits());
$amountLeft = $this->amountMultiplied;
if ($this->getDecimalDigits() < $digits) {
$amountLeft *= 10 ** ($digits - $this->getDecimalDigits());
}
$amountRight = $this->convertToInt($operand->getAmount(), $digits);
return new self(
$this->convertToString($amountLeft + $amountRight, $digits),
$digits
);
}
public function sub(FixedPointImmutable $operand): FixedPointImmutable
{
$amount = $operand->getAmount();
if (substr($amount, 0, 1) == self::STR_MINUS) {
$amountInv = substr($amount, 1);
} else {
$amountInv = self::STR_MINUS . $amount;
}
$operandInv = new self($amountInv, $operand->getDecimalDigits());
return $this->add($operandInv);
}
public function mul(float $operand): FixedPointImmutable
{
return new self(
$this->convertToString(
$this->amountMultiplied * $operand,
$this->getDecimalDigits()
),
$this->getDecimalDigits()
);
}
public function div(float $operand): FixedPointImmutable
{
return $this->mul(1 / $operand);
}
private function convertToInt(string $amount, int $decimalDigits): int
{
if (strpos($amount, self::STR_DOT) === false) {
[$intPart, $fractionalPart] = [$amount, 0];
} else {
[$intPart, $fractionalPart] = explode(self::STR_DOT, $amount, 2);
$fractional = str_pad(
substr($fractionalPart, 0, $decimalDigits),
$decimalDigits,
self::STR_ZERO
);
$tail = substr($fractionalPart, $decimalDigits + 1);
if ((int) substr($tail, 0, 1) >= 5) {
$fractional += 1;
}
}
$result = (int) $intPart * 10 ** $decimalDigits;
if ($result >= 0) {
$result += $fractional;
} else {
$result -= $fractional;
}
return $result;
}
private function convertToString(
int $amountMultiplied,
int $decimalDigits
): string {
if (!$decimalDigits) {
return (string) $amountMultiplied;
}
$sign = ($amountMultiplied < 0) ? self::STR_MINUS : '';
$amount = (string) abs($amountMultiplied);
$intPart = substr($amount, 0, -$decimalDigits);
if (empty($intPart)) {
$intPart = self::STR_ZERO;
}
$fractionalPart = str_pad(
substr($amount, -$decimalDigits),
$decimalDigits,
self::STR_ZERO,
STR_PAD_LEFT
);
return $sign . $intPart . self::STR_DOT . $fractionalPart;
}
}
class BcmathFixedPoint implements FixedPointImmutable
{
use FixedPointDecimalDigits, FixedPointToString;
private $amount;
public function __construct(string $amount, int $decimalDigits)
{
if ($decimalDigits < 0) {
throw new RangeException(self::ERR_NEGATIVE_DIGITS_COUNT);
}
$roundFix = 5 * 0.1 ** ($decimalDigits + 1);
if (substr($amount, 0, 1) == self::STR_MINUS) {
$this->amount = bcsub($amount, $roundFix, $decimalDigits);
} else {
$this->amount = bcadd($amount, $roundFix, $decimalDigits);
}
$this->decimalDigits = $decimalDigits;
}
public function getAmount(): string
{
return $this->amount;
}
public function add(FixedPointImmutable $operand): FixedPointImmutable
{
$digits = max($this->getDecimalDigits(), $operand->getDecimalDigits());
return new self(
bcadd($this->amount, $operand->getAmount(), $digits),
$digits
);
}
public function sub(FixedPointImmutable $operand): FixedPointImmutable
{
$digits = max($this->getDecimalDigits(), $operand->getDecimalDigits());
return new self(
bcsub($this->amount, $operand->getAmount(), $digits),
$digits
);
}
public function mul(float $operand): FixedPointImmutable
{
return new self(
bcmul($this->amount, $operand, $this->getDecimalDigits() + 1),
$this->getDecimalDigits()
);
}
public function div(float $operand): FixedPointImmutable
{
return new self(
bcdiv($this->amount, $operand, $this->getDecimalDigits() + 1),
$this->getDecimalDigits()
);
}
}
interface FixedPointFactory
{
public function create(
string $amount,
int $decimalDigits
): FixedPointImmutable;
}
class NaiveFixedPointFactory implements FixedPointFactory
{
public function create(
string $amount,
int $decimalDigits
): FixedPointImmutable {
return new NaiveFixedPoint($amount, $decimalDigits);
}
}
class BcmathFixedPointFactory implements FixedPointFactory
{
public function create(
string $amount,
int $decimalDigits
): FixedPointImmutable {
return new BcmathFixedPoint($amount, $decimalDigits);
}
}
/**
* Part 3. Classes for currency and money
*/
class Currency
{
private $literalCode;
private $numericCode;
private $decimalDigits;
private $name;
public function __construct(
string $literalCode,
?int $numericCode,
?int $decimalDigits,
string $name
) {
$this->literalCode = $literalCode;
$this->numericCode = $numericCode;
$this->decimalDigits = $decimalDigits;
$this->name = $name;
}
public function getLiteralCode(): string
{
return $this->literalCode;
}
public function getNumericCode(): ?int
{
return $this->numericCode;
}
public function getDecimalDigits(): ?int
{
return $this->decimalDigits;
}
public function getName(): string
{
return $this->name;
}
}
class MoneyImmutable
{
private $amount;
private $currency;
const DEFAULT_PRECISION = 8;
const STR_FALLBACK_FORMAT = '%s %s';
const ERR_CURRENCY_MISMATCH = 'Currency mismatch: %s != %s';
public function __construct(FixedPointImmutable $amount, Currency $currency)
{
$this->currency = $currency;
$this->amount = $amount->setDecimalDigits(
$currency->getDecimalDigits() ?? self::DEFAULT_PRECISION
);
}
public function getAmount(): FixedPointImmutable
{
return $this->amount;
}
public function getCurrency(): Currency
{
return $this->currency;
}
public function add(MoneyImmutable $operand): MoneyImmutable
{
$this->assertSameCurrency($operand);
return new self(
$this->amount->add($operand->getAmount()),
$this->currency
);
}
public function sub(MoneyImmutable $operand): MoneyImmutable
{
$this->assertSameCurrency($operand);
return new self(
$this->amount->sub($operand->getAmount()),
$this->currency
);
}
public function mul(float $operand): MoneyImmutable
{
return new self($this->amount->mul($operand), $this->currency);
}
public function div(float $operand): MoneyImmutable
{
return new self($this->amount->div($operand), $this->currency);
}
public function __toString(): string
{
return sprintf(
self::STR_FALLBACK_FORMAT,
$this->currency->getLiteralCode(),
$this->getAmount()->getAmount() // XXX
);
}
private function assertSameCurrency(self $other): void
{
if ($other->getCurrency() !== $this->currency) {
throw new InvalidArgumentException(
sprintf(
self::ERR_CURRENCY_MISMATCH,
$this->currency->getLiteralCode(),
$other->getCurrency()->getLiteralCode()
)
);
}
}
}
class MoneyFactory
{
private $fixedPointFactory;
private $currency;
public function __construct(
FixedPointFactory $fixedPointFactory,
Currency $currency
) {
$this->fixedPointFactory = $fixedPointFactory;
$this->currency = $currency;
}
public function create(string $amount): MoneyImmutable
{
return new MoneyImmutable(
$this->fixedPointFactory->create(
$amount,
$this->currency->getDecimalDigits() ??
MoneyImmutable::DEFAULT_PRECISION
),
$this->currency
);
}
}
abstract class AbstractLoanAmortizationSchedule
{
protected $moneyFactory;
const KEY_BALANCE_START = 'balance_start';
const KEY_INTEREST = 'interest';
const KEY_PAYMENT = 'payment';
const KEY_BALANCE_END = 'balance_end';
public function __construct(MoneyFactory $moneyFactory)
{
$this->moneyFactory = $moneyFactory;
}
abstract public function getAmortizationSchedule(
string $amount,
float $ratePerPeriod,
int $periods
): array;
}
class AnnuityLoanAmortizationSchedule extends AbstractLoanAmortizationSchedule
{
public function getAmortizationSchedule(
string $amount,
float $ratePerPeriod,
int $periods
): array {
$loanAmount = $this->moneyFactory->create($amount);
$annuityFactor =
$ratePerPeriod * (1 + $ratePerPeriod) ** $periods
/ ((1 + $ratePerPeriod) ** $periods - 1);
$paymentPerPeriod = $loanAmount->mul($annuityFactor);
$schedule = [];
$balance = $loanAmount;
for ($n = 1; $n <= $periods; $n++) {
$interest = $balance->mul($ratePerPeriod);
$endBalance = $balance->add($interest)->sub($paymentPerPeriod);
$payment = $paymentPerPeriod;
if ($n == $periods) {
$payment = $payment->add($endBalance);
$endBalance = $endBalance->mul(0);
}
$schedule[] = [
self::KEY_BALANCE_START => $balance,
self::KEY_INTEREST => $interest,
self::KEY_PAYMENT => $payment,
self::KEY_BALANCE_END => $endBalance,
];
$balance = $endBalance;
}
return $schedule;
}
}
class LinearLoanAmortizationSchedule extends AbstractLoanAmortizationSchedule
{
public function getAmortizationSchedule(
string $amount,
float $ratePerPeriod,
int $periods
): array {
$loanAmount = $this->moneyFactory->create($amount);
$schedule = [];
$balance = $loanAmount;
for ($n = 1; $n <= $periods; $n++) {
$interest = $balance->mul($ratePerPeriod);
$payment = $loanAmount->div($periods)->add($interest);
$endBalance = $balance->add($interest)->sub($payment);
if ($n == $periods) {
$payment = $payment->add($endBalance);
$endBalance = $endBalance->mul(0);
}
$schedule[] = [
self::KEY_BALANCE_START => $balance,
self::KEY_INTEREST => $interest,
self::KEY_PAYMENT => $payment,
self::KEY_BALANCE_END => $endBalance,
];
$balance = $endBalance;
}
return $schedule;
}
}
/**
* Part 4. Application, request and responce
*/
class Application implements ArrayAccess
{
use Container;
protected $cache = [];
const ERR_ELEMENT_NOT_DEFINED = 'Element %s is nod defined';
public function offsetGet($key)
{
if (array_key_exists($key, $this->cache)) {
return $this->cache[$key];
}
if (!array_key_exists($key, $this->container)) {
throw new InvalidArgumentException(
sprintf(
self::ERR_ELEMENT_NOT_DEFINED,
$key
)
);
}
if (is_callable($this->container[$key])) {
$this->cache[$key] = $this->container[$key]($this);
return $this->cache[$key];
}
return $this->container[$key];
}
public function offsetUnset($key): void
{
unset($this->container[$key]);
unset($this->cache[$key]);
}
}
class Request implements ArrayAccess
{
use Container;
public static function createFromGlobals(): self
{
return new self($_GET);
}
}
interface Responce
{
public function sendHeaders();
}
/**
* Part 5. Templating library
*/
interface Renderable
{
public function render();
}
abstract class AbstractTemplate implements Renderable, ArrayAccess
{
use Container;
const KEY_I18N = 'translate';
const KEY_URL_PARAM = 'url_param';
const STR_URL_FORMAT = '?%s';
const STR_SPACE = ' ';
const STR_TAG_OPEN = '<%s>';
const STR_TAG_CLOSE = '</%s>';
abstract public function render();
public function __toString(): string
{
ob_start();
$this->render();
$content = ob_get_contents();
ob_end_clean();
return $content;
}
protected function getUrl(array $parameters)
{
$currentParameters = $this[self::KEY_URL_PARAM] ?? [];
$newParameters = $parameters + $currentParameters;
ksort($currentParameters);
ksort($newParameters);
if ($currentParameters == $newParameters) {
// should not link to current page
return false;
}
return sprintf(self::STR_URL_FORMAT, http_build_query($newParameters));
}
protected function say(string $message, ?string $tag = null): void
{
if ($tag) {
printf(self::STR_TAG_OPEN, $tag);
}
echo htmlspecialchars($message);
if ($tag) {
[$tag] = explode(self::STR_SPACE, $tag);
printf(self::STR_TAG_CLOSE, $tag);
}
}
protected function getMsg(string $message, ...$args): string
{
return sprintf($this[self::KEY_I18N][$message] ?? $message, ...$args);
}
protected function sayMsg(
string $message,
?string $tag = null,
...$args
): void {
$this->say($this->getMsg($message, ...$args), $tag);
}
protected function sayVar($key, $default = '', ?string $tag = null): void
{
$this->say($this->container[$key] ?? $default, $tag);
}
}
/**
* Part 6. Unit tests
*/
class TestFailed extends Exception
{
}
abstract class AbstractTest
{
protected $alreadyTested = false;
protected $assertionsPassed = 0;
protected $assertionsFailed = 0;
protected $failLog = [];
const STR_TEST_PATTERN = '/^test[A-Z]/';
const ERR_NOT_EQUAL = '%s is not equal to %s';
const ERR_NOT_TRUE = 'Argument is not true';
const KEY_METHOD = 'method';
const KEY_FILE = 'file';
const KEY_LINE = 'line';
const KEY_MESSAGE = 'message';
public function getAssertionsPassed()
{
return $this->assertionsPassed;
}
public function getAssertionsFailed()
{
return $this->assertionsFailed;
}
public function getFailLog()
{
return $this->failLog;
}
public function runTests(): void
{
if ($this->alreadyTested) {
return;
}
foreach ($this->getTestMethods() as $method) {
try {
$this->$method();
} catch (TestFailed $fail) {
$this->failLog[] = [
self::KEY_METHOD => $method,
self::KEY_FILE => $fail->getFile(),
self::KEY_LINE => $fail->getLine(),
self::KEY_MESSAGE => $fail->getMessage(),
];
}
}
$this->alreadyTested = true;
}
protected function getTestMethods()
{
foreach (get_class_methods($this) as $method) {
if (preg_match(self::STR_TEST_PATTERN, $method)) {
yield $method;
}
}
}
protected function assertTrue($arg, string $message = null): void
{
if ($arg) {
$this->assertionsPassed++;
} else {
$this->assertionsFailed++;
throw new TestFailed($message ?? self::ERR_NOT_TRUE);
}
}
protected function assertEqual($arg1, $arg2, string $message = null): void
{
if ($arg1 == $arg2) {
$this->assertionsPassed++;
} else {
$this->assertionsFailed++;
throw new TestFailed(
sprintf(
$message ?? self::ERR_NOT_EQUAL,
json_encode($arg1),
json_encode($arg2)
)
);
}
}
}
abstract class AbstractTestFixedPoint extends AbstractTest
{
protected $fixedPointFactory;
protected function testCreateSimple()
{
$test = $this->fixedPointFactory->create('123.45', 2);
$this->assertTrue($test instanceof FixedPointImmutable);
$this->assertEqual($test->getAmount(), '123.45');
$this->assertEqual($test->getDecimalDigits(), 2);
}
protected function testCreateNegative()
{
$test = $this->fixedPointFactory->create('-123.45', 2);
$this->assertTrue($test instanceof FixedPointImmutable);
$this->assertEqual($test->getAmount(), '-123.45');
}
protected function testZeroPadding()
{
$test = $this->fixedPointFactory->create('.00100', 5);
$this->assertTrue($test instanceof FixedPointImmutable);
$this->assertEqual($test->getAmount(), '0.00100');
}
protected function testRounding()
{
$test = $this->fixedPointFactory->create('0.999999', 2);
$this->assertEqual($test->getAmount(), '1.00');
}
protected function testAdd()
{
$test1 = $this->fixedPointFactory->create('12.34', 2);
$test2 = $test1->add($test1);
$this->assertEqual($test2->getAmount(), '24.68');
$test3 = $this->fixedPointFactory->create('0.01', 2);
$test4 = $test2->add($test3);
$this->assertEqual($test4->getAmount(), '24.69');
}
protected function testSub()
{
$test1 = $this->fixedPointFactory->create('12.34', 2);
$test2 = $test1->sub($test1);
$this->assertEqual($test2->getAmount(), '0.00');
$test3 = $test2->sub($test1);
$this->assertEqual($test3->getAmount(), '-12.34');
}
protected function testMul()
{
$test1 = $this->fixedPointFactory->create('12.34', 2);
$test2 = $test1->mul(2);
$this->assertEqual($test2->getAmount(), '24.68');
}
protected function testDiv()
{
$test1 = $this->fixedPointFactory->create('10.00', 2);
$test2 = $test1->div(2);
$this->assertEqual($test2->getAmount(), '5.00');
$test3 = $test1->div(3);
$this->assertEqual($test3->getAmount(), '3.33');
}
}
class TestNaiveFixedPoint extends AbstractTestFixedPoint
{
public function __construct()
{
$this->fixedPointFactory = new NaiveFixedPointFactory;
}
}
class TestBcmathFixedPoint extends AbstractTestFixedPoint
{
public function __construct()
{
$this->fixedPointFactory = new BcmathFixedPointFactory;
}
}
/**
* Part 7. Templates
*/
abstract class GenericLayout extends AbstractTemplate implements Responce
{
protected $headers = [
'Content-type: text/html; charset=UTF-8',
];
public function sendHeaders(): AbstractTemplate
{
foreach ($this->headers as $header) {
header($header);
}
return $this;
}
abstract protected function renderContentBlock();
public function render()
{
?><!DOCTYPE html>
<html lang="<?php $this->sayVar('lang', 'en'); ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php $this->sayMsg('Loan calculator'); ?></title>
<style>
body { max-width: 55em; margin: 0 auto; padding: 1em }
nav+article, article+footer { margin: 1em 0 0 0 }
footer { border-top: thin solid }
.aside { float: right }
nav li { display: inline }
nav ul, nav li { margin: 0; padding: 0; list-style-type: none }
nav li+li { margin-left: .5em }
h1, h2 { font-weight: normal }
h1 { font-size: 200%; margin: .5em 0 }
h2 { font-size: 150%; margin: .6666em 0 }
.error { padding: 1em; border: dotted thin }
table { width: 100% }
table, th, td { border: dotted thin; border-collapse: collapse }
.four-equal-col td { width: 25% }
.schedule td { text-align: right }
th, td { padding: .5em }
input[type="text"] { width: 100% }
</style>
</head>
<body>
<?php $this->renderMenuBlock(); ?>
<article>
<?php $this->renderContentBlock(); ?>
</article>
<footer>
© <a href="https://gist.github.com/kastaneda"><?php
$this->sayMsg('Dmitry Kolesnikov'); ?></a>
</footer>
</body>
</html><?php
}
protected function renderMenuBlock()
{
if (isset($this['nav.aside'])) {
echo '<nav class="aside">';
$this->linkListRenderHelper($this['nav.aside']);
echo '</nav>';
}
if (isset($this['nav.main'])) {
echo '<nav class="main">';
$this->linkListRenderHelper($this['nav.main']);
echo '</nav>';
}
}
protected function linkListRenderHelper($linkList)
{
echo '<ul>';
foreach ($linkList as $item) {
[$param, $label] = $item;
$url = is_scalar($param) ? $param : $this->getUrl($param);
if ($url) {
echo '<li><a href="' . htmlspecialchars($url) . '">';
$this->sayMsg($label);
echo '</a></li>';
} else {
echo '<li><strong>';
$this->sayMsg($label);
echo '</strong></li>';
}
}
echo '</ul>';
}
protected function sayMoney(MoneyImmutable $money)
{
if ($format = ($this['currencyFormat'] ?? false)) {
$amount = $money->getAmount();
[$intPart, $fractionalPart] =
explode(FixedPointImmutable::STR_DOT, $amount);
$intPart = trim(strrev(
chunk_split(strrev($intPart), 3, self::STR_SPACE)
));
echo htmlspecialchars(sprintf($format, $intPart, $fractionalPart));
} else {
// fallback
echo htmlspecialchars((string) $money);
}
}
protected function selectOptionHelper(
string $value,
string $message,
bool $selected
): void {
$tag = '<option value="%s">%s</option>';
if ($selected) {
$tag = '<option value="%s" selected>%s</option>';
}
printf($tag, htmlspecialchars($value), htmlspecialchars($message));
}
protected function inputTypeRadioHelper(string $name, string $value): void
{
$tag = '<input type="radio" name="%s" value="%s">';
if (isset($this[$name]) && $this[$name] == $value) {
$tag = '<input type="radio" name="%s" value="%s" checked>';
}
printf($tag, htmlspecialchars($name), htmlspecialchars($value));
}
}
class LoanCalculatorPage extends GenericLayout
{
protected function renderContentBlock()
{
$this->sayMsg('Loan calculator', 'h1'); ?>
<form method="get">
<table class="four-equal-col">
<tr>
<th><?php $this->sayMsg('Amount of loan'); ?></th>
<th><?php $this->sayMsg('Annual interest rate'); ?></th>
<th><?php $this->sayMsg('Loan fee'); ?></th>
<th><?php $this->sayMsg('Duration of the loan'); ?></th>
</tr>
<tr>
<td><input type="text" name="amount"
value="<?php $this->sayVar('amount'); ?>"></td>
<td><input type="text" name="rate"
value="<?php $this->sayVar('rate'); ?>"
placeholder="<?php $this->sayMsg('percents'); ?>"
title="<?php $this->sayMsg('percents'); ?>"></td>
<td><input type="text" name="fee"
value="<?php $this->sayVar('fee'); ?>"
placeholder="<?php $this->sayMsg('percents'); ?>"
title="<?php $this->sayMsg('percents'); ?>"></td>
<td><input type="number" name="months"
value="<?php $this->sayVar('months'); ?>"
placeholder="<?php $this->sayMsg('months'); ?>"
title="<?php $this->sayMsg('months'); ?>"></td>
</tr>
</table>
<?php if (isset($this['currency']) && isset($this['defaultCurrency'])): ?>
<p class="aside">
<select name="currency"><?php
foreach ($this['currency'] as $currency) {
$this->selectOptionHelper(
$currency->getLiteralCode(),
$this->getMsg($currency->getName()),
$currency == $this['defaultCurrency']
);
} ?></select>
</p>
<?php endif; ?>
<p>
<label>
<?php $this->inputTypeRadioHelper('type', 'annuity'); ?>
<?php $this->sayMsg('Annuity payments'); ?>
</label><br>
<label>
<?php $this->inputTypeRadioHelper('type', 'linear'); ?>
<?php $this->sayMsg('Linear payments'); ?>
</label>
</p>
<p>
<input type="hidden" name="page" value="calculator">
<input type="hidden" name="lang"
value="<?php $this->sayVar('lang', 'en'); ?>">
<input type="submit" value="<?php
$this->sayMsg('Calculate amortization schedule'); ?>">
</p>
</form>
<?php if (isset($this['schedule'])): ?>
<h2><?php $this->sayMsg('Amortization schedule'); ?></h2>
<table class="schedule four-equal-col">
<tr>
<th><?php $this->sayMsg('Beginning balance'); ?></th>
<th><?php $this->sayMsg('Interest'); ?></th>
<th><?php $this->sayMsg('Payment'); ?></th>
<th><?php $this->sayMsg('Ending balance'); ?></th>
</tr>
<?php foreach ($this['schedule'] as $row): ?>
<tr>
<td><?php $this->sayMoney($row['balance_start']); ?></td>
<td><?php $this->sayMoney($row['interest']); ?></td>
<td><?php $this->sayMoney($row['payment']); ?></td>
<td><?php $this->sayMoney($row['balance_end']); ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php
}
}
class SelfTestPage extends GenericLayout
{
protected function renderContentBlock()
{
$tests = $this['tests'] ?? [];
$this->sayMsg('Self-test', 'h1');
if (!count($tests)) {
$this->sayMsg('No tests defined', 'p class="error"');
return;
}
foreach ($tests as $test) { ?>
<h2>
<?php $this->sayMsg('Class'); ?>:
<code><?php $this->say(get_class($test)); ?></code>
</h2>
<p>
<?php $this->sayMsg(
'Assertions passed: %d, failed: %d.',
null,
$test->getAssertionsPassed(),
$test->getAssertionsFailed()
); ?>
</p><?php
if ($test->getAssertionsFailed()) {
echo '<ul>';
foreach ($test->getFailLog() as $row) {
$this->say(
$row[AbstractTest::KEY_METHOD] . ': ' .
$row[AbstractTest::KEY_MESSAGE],
'li'
);
}
echo '</ul>';
}
}
}
}
class ShowSourcePage extends GenericLayout
{
protected function renderContentBlock()
{
$this->sayMsg('Source code', 'h1');
show_source(__FILE__);
}
}
class ErrorPage extends GenericLayout
{
protected function renderContentBlock()
{
$this->sayMsg('Something went wrong', 'h1');
if (isset($this['message'])) {
$this->say($this['message'], 'p class="error"');
}
if (isset($this['exception'])) {
$this->say($this['exception'], 'pre class="error"');
}
}
}
/**
* Part 8. Runtime configuration
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
set_error_handler(function ($severity, $message, $file, $line) {
throw new ErrorException($message, 0, $severity, $file, $line);
});
$app = new Application();
// ISO 4217
$app['currency'] = [
'UAH' => new Currency('UAH', 980, 2, 'Ukrainian hryvnia'),
// Major reserve currencies
'USD' => new Currency('USD', 840, 2, 'United States dollar'),
'EUR' => new Currency('EUR', 978, 2, 'Euro'),
// Other reserve currencies
'GBP' => new Currency('GBP', 826, 2, 'Pound sterling'),
'JPY' => new Currency('JPY', 392, 0, 'Japanese yen'),
'CHF' => new Currency('CHF', 756, 2, 'Swiss franc'),
'CAD' => new Currency('CAD', 124, 2, 'Canadian dollar'),
'CNY' => new Currency('CNY', 156, 2, 'Chinese yuan'),
// Precious metals
'XAU' => new Currency('XAU', 959, null, 'Gold'),
'XAG' => new Currency('XAG', 961, null, 'Silver'),
// Cryptocurrencies
'XBT' => new Currency('XBT', null, 8, 'Bitcoin'),
];
$app['supportedLocales'] = ['en', 'uk'];
$app['translate'] = [
'uk' => [
'Dmitry Kolesnikov' => 'Дмитро Колесников',
'Loan calculator' => 'Кредитний калькулятор',
'Self-test' => 'Самоперевірка',
'Source code' => 'Програмний код',
'No tests defined' => 'Тести не визначені',
'Class' => 'Клас',
'Assertions passed: %d, failed: %d.' =>
'Тверджень перевірено: %d, провалено: %d.',
'Amount of loan' => 'Сума кредиту',
'Annual interest rate' => 'Річна відсоткова ставка',
'Loan fee' => 'Плата за кредит',
'Duration of the loan' => 'Тривалість позики',
'percents' => 'відсотки',
'months' => 'місяці',
'Calculate amortization schedule' => 'Розрахувати графік погашення',
'Annuity payments' => 'Аннуітетні платежі',
'Linear payments' => 'Лінійні платежі',
'Amortization schedule' => 'Графік погашення',
'Beginning balance' => 'Початковий баланс',
'Interest' => 'Сума відсотків',
'Payment' => 'Платіж',
'Ending balance' => 'Кінцевий баланс',
'Ukrainian hryvnia' => 'Гривня',
'United States dollar' => 'Долар США',
'Euro' => 'Євро',
'Pound sterling' => 'Фунт стерлінгів',
'Japanese yen' => 'Єна',
'Swiss franc' => 'Швейцарський франк',
'Canadian dollar' => 'Канадський долар',
'Chinese yuan' => 'Юань',
'Gold' => 'Золото',
'Silver' => 'Срібло',
'Bitcoin' => 'Біткойн',
],
];
$app['locale'] = [
'uk' => [
'defaultCurrency' => 'UAH',
'currencyFormat' => [
'UAH' => '%s,%s грн',
'EUR' => '%s,%s €',
'USD' => '%s,%s $',
'GBP' => '%s,%s £',
],
],
'en' => [
'defaultCurrency' => 'USD',
'currencyFormat' => [
'EUR' => '€%s.%s',
'USD' => '$%s.%s',
'GBP' => '£%s.%s',
],
],
];
$app['defaultLang'] = function (Application $app) {
$lang = $app['request']['lang'] ?? 'en';
if (in_array($lang, $app['supportedLocales'])) {
return $lang;
}
return 'en';
};
$app['defaultCurrency'] = function (Application $app) {
if (isset($app['request']['currency'])) {
$code = $app['request']['currency'];
if (isset($app['currency'][$code])) {
return $app['currency'][$code];
}
}
$code = 'EUR';
$lang = $app['defaultLang'];
$code = $app['locale'][$lang]['defaultCurrency'] ?? $code;
return $app['currency'][$code];
};
$app['currencyFormat'] = function (Application $app) {
$lang = $app['defaultLang'];
$code = $app['defaultCurrency']->getLiteralCode();
return $app['locale'][$lang]['currencyFormat'][$code] ?? false;
};
$app['moneyFactory'] = function (Application $app) {
return new MoneyFactory($app['fixedPointFactory'], $app['defaultCurrency']);
};
$app['fixedPointFactory'] = function (Application $app) {
if (extension_loaded('bcmath')) {
return new BcmathFixedPointFactory();
} else {
return new NaiveFixedPointFactory();
}
};
$app['testSuite'] = function (Application $app) {
return [
new TestNaiveFixedPoint(),
new TestBcmathFixedPoint(),
];
};
$app['data.lang'] = function (Application $app) {
$lang = $app['defaultLang'];
return [
'lang' => $lang,
'url_param' => ['lang' => $lang],
'translate' => $app['translate'][$lang] ?? [],
];
};
$app['data.nav'] = [
'nav.main' => [
[['page' => 'calculator'], 'Loan calculator'],
[['page' => 'self-test'], 'Self-test'],
[['page' => 'source-code'], 'Source code'],
],
'nav.aside' => [
[['lang' => 'en'], 'English'],
[['lang' => 'uk'], 'Українська'],
],
];
$app['schedule.annuity'] = function (Application $app) {
return new AnnuityLoanAmortizationSchedule($app['moneyFactory']);
};
$app['schedule.linear'] = function (Application $app) {
return new LinearLoanAmortizationSchedule($app['moneyFactory']);
};
$app['request'] = function (Application $app) {
return Request::createFromGlobals();
};
$app['responce'] = function (Application $app) {
$data = [];
$data += $app['data.lang'] ?? [];
$data += $app['data.nav'] ?? [];
$pageName = $app['request']['page'] ?? 'calculator';
$data['url_param']['page'] = $pageName;
switch ($pageName) {
case 'calculator':
foreach (['amount', 'rate', 'fee', 'months'] as $key) {
$value = $app['request'][$key] ?? '';
if (is_numeric($value)) {
$data[$key] = $value;
}
}
$type = $app['request']['type'] ?? 'annuity';
if (in_array($type, ['annuity', 'linear'])) {
$data['type'] = $type;
if ($scheduleClass = $app['schedule.' . $type]) {
$fee = $data['fee'] ?? 0;
$amount = ($data['amount'] ?? 0) * (1 + $fee / 100);
$rate = $data['rate'] ?? false;
$months = $data['months'] ?? false;
if ($amount > 0 && $rate > 0 && $months > 0) {
$data['schedule'] = $scheduleClass
->getAmortizationSchedule(
$amount,
$rate / 12 / 100,
$months
);
}
}
}
$data['currency'] = $app['currency'];
$data['currencyFormat'] = $app['currencyFormat'];
$data['defaultCurrency'] = $app['defaultCurrency'];
$page = new LoanCalculatorPage($data);
break;
case 'source-code':
$page = new ShowSourcePage($data);
break;
case 'self-test':
$data['tests'] = $app['testSuite'] ?? [];
foreach ($data['tests'] as $test) {
$test->runTests();
}
$page = new SelfTestPage($data);
break;
default:
$data += ['message' => 'Page not found'];
$page = new ErrorPage($data);
break;
}
return $page;
};
/**
* Part 9. Front controller
*/
try {
$app['responce']->sendHeaders()->render();
} catch (Exception $exception) {
$page = new ErrorPage(['exception' => $exception]);
$page->sendHeaders()->render();
}
@topkalouis
Copy link

Hi, Thank you very much for this script.
How can i display the fee in a column as you displayed the Interest in the column ?
Thank.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment