655 lines
21 KiB
PHP
655 lines
21 KiB
PHP
<?php
|
|
/**
|
|
* KlarnaCalc
|
|
*
|
|
* PHP Version 5.3
|
|
*
|
|
* @category Payment
|
|
* @package KlarnaAPI
|
|
* @author MS Dev <ms.modules@klarna.com>
|
|
* @copyright 2012 Klarna AB (http://klarna.com)
|
|
* @license http://opensource.org/licenses/BSD-2-Clause BSD-2
|
|
* @link http://integration.klarna.com/
|
|
*/
|
|
|
|
/**
|
|
* KlarnaCalc provides methods to calculate part payment functions.
|
|
*
|
|
* All rates are yearly rates, but they are calculated monthly. So
|
|
* a rate of 9 % is used 0.75% monthly. The first is the one we specify
|
|
* to the customers, and the second one is the one added each month to
|
|
* the account. The IRR uses the same notation.
|
|
*
|
|
* The APR is however calculated by taking the monthly rate and raising
|
|
* it to the 12 power. This is according to the EU law, and will give
|
|
* very large numbers if the $pval is small compared to the $fee and
|
|
* the amount of months you repay is small as well.
|
|
*
|
|
* All functions work in discrete mode, and the time interval is the
|
|
* mythical evenly divided month. There is no way to calculate APR in
|
|
* days without using integrals and other hairy math. So don't try.
|
|
* The amount of days between actual purchase and the first bill can
|
|
* of course vary between 28 and 61 days, but all calculations in this
|
|
* class assume this time is exactly and that is ok since this will only
|
|
* overestimate the APR and all examples in EU law uses whole months as well.
|
|
*
|
|
* @category Payment
|
|
* @package KlarnaAPI
|
|
* @author MS Dev <ms.modules@klarna.com>
|
|
* @copyright 2012 Klarna AB (http://klarna.com)
|
|
* @license http://opensource.org/licenses/BSD-2-Clause BSD-2
|
|
* @link http://integration.klarna.com/
|
|
*/
|
|
class KlarnaCalc
|
|
{
|
|
|
|
/**
|
|
* This constant tells the irr function when to stop.
|
|
* If the calculation error is lower than this the calculation is done.
|
|
*
|
|
* @var float
|
|
*/
|
|
protected static $accuracy = 0.01;
|
|
|
|
/**
|
|
* Calculates the midpoint between two points. Used by divide and conquer.
|
|
*
|
|
* @param float $a point a
|
|
* @param float $b point b
|
|
*
|
|
* @return float
|
|
*/
|
|
private static function _midpoint($a, $b)
|
|
{
|
|
return (($a+$b)/2);
|
|
}
|
|
|
|
/**
|
|
* npv - Net Present Value
|
|
* Calculates the difference between the initial loan to the customer
|
|
* and the individual payments adjusted for the inverse of the interest
|
|
* rate. The variable we are searching for is $rate and if $pval,
|
|
* $payarray and $rate is perfectly balanced this function returns 0.0.
|
|
*
|
|
* @param float $pval initial loan to customer (in any currency)
|
|
* @param array $payarray array of monthly payments from the customer
|
|
* @param float $rate interest rate per year in %
|
|
* @param int $fromdayone count interest from the first day? yes(1)/no(0)
|
|
*
|
|
* @return float
|
|
*/
|
|
private static function _npv($pval, $payarray, $rate, $fromdayone)
|
|
{
|
|
$month = $fromdayone;
|
|
foreach ($payarray as $payment) {
|
|
$pval -= $payment / pow(1 + $rate/(12*100.0), $month++);
|
|
}
|
|
|
|
return ($pval);
|
|
}
|
|
|
|
/**
|
|
* This function uses divide and conquer to numerically find the IRR,
|
|
* Internal Rate of Return. It starts of by trying a low of 0% and a
|
|
* high of 100%. If this isn't enough it will double the interval up
|
|
* to 1000000%. Note that this is insanely high, and if you try to convert
|
|
* an IRR that high to an APR you will get even more insane values,
|
|
* so feed this function good data.
|
|
*
|
|
* Return values: float irr if it was possible to find a rate that gets
|
|
* npv closer to 0 than $accuracy.
|
|
* int -1 The sum of the payarray is less than the lent
|
|
* amount, $pval. Hellooooooo. Impossible.
|
|
* int -2 the IRR is way to high, giving up.
|
|
*
|
|
* This algorithm works in logarithmic time no matter what inputs you give
|
|
* and it will come to a good answer within ~30 steps.
|
|
*
|
|
* @param float $pval initial loan to customer (in any currency)
|
|
* @param array $payarray array of monthly payments from the customer
|
|
* @param int $fromdayone count interest from the first day? yes(1)/no(0)
|
|
*
|
|
* @return float
|
|
*/
|
|
private static function _irr($pval, $payarray, $fromdayone)
|
|
{
|
|
$low = 0.0;
|
|
$high = 100.0;
|
|
$lowval = self::_npv($pval, $payarray, $low, $fromdayone);
|
|
$highval = self::_npv($pval, $payarray, $high, $fromdayone);
|
|
|
|
// The sum of $payarray is smaller than $pval, impossible!
|
|
if ($lowval > 0.0) {
|
|
return (-1);
|
|
}
|
|
|
|
// Standard divide and conquer.
|
|
do {
|
|
$mid = self::_midpoint($low, $high);
|
|
$midval = self::_npv($pval, $payarray, $mid, $fromdayone);
|
|
if (abs($midval) < self::$accuracy) {
|
|
//we are close enough
|
|
return ($mid);
|
|
}
|
|
|
|
if ($highval < 0.0) {
|
|
// we are not in range, so double it
|
|
$low = $high;
|
|
$lowval = $highval;
|
|
$high *= 2;
|
|
$highval = self::_npv($pval, $payarray, $high, $fromdayone);
|
|
} else if ($midval >= 0.0) {
|
|
// irr is between low and mid
|
|
$high = $mid;
|
|
$highval = $midval;
|
|
} else {
|
|
// irr is between mid and high
|
|
$low = $mid;
|
|
$lowval = $midval;
|
|
}
|
|
} while ($high < 1000000);
|
|
// bad input, insanely high interest. APR will be INSANER!
|
|
return (-2);
|
|
}
|
|
|
|
/**
|
|
* IRR is not the same thing as APR, Annual Percentage Rate. The
|
|
* IRR is per time period, i.e. 1 month, and the APR is per year,
|
|
* and note that that you need to raise to the power of 12, not
|
|
* mutliply by 12.
|
|
*
|
|
* This function turns an IRR into an APR.
|
|
*
|
|
* If you feed it a value of 100%, yes the APR will be millions!
|
|
* If you feed it a value of 9%, it will be 9.3806%.
|
|
* That is the nature of this math and you can check the wiki
|
|
* page for APR for more info.
|
|
*
|
|
* @param float $irr Internal Rate of Return, expressed yearly, in %
|
|
*
|
|
* @return float Annual Percentage Rate, in %
|
|
*/
|
|
private static function _irr2apr($irr)
|
|
{
|
|
return (100 * (pow(1 + $irr / (12 * 100.0), 12) - 1));
|
|
}
|
|
|
|
/**
|
|
* This is a simplified model of how our paccengine works if
|
|
* a client always pays their bills. It adds interest and fees
|
|
* and checks minimum payments. It will run until the value
|
|
* of the account reaches 0, and return an array of all the
|
|
* individual payments. Months is the amount of months to run
|
|
* the simulation. Important! Don't feed it too few months or
|
|
* the whole loan won't be paid off, but the other functions
|
|
* should handle this correctly.
|
|
*
|
|
* Giving it too many months has no bad effects, or negative
|
|
* amount of months which means run forever, but it will stop
|
|
* as soon as the account is paid in full.
|
|
*
|
|
* Depending if the account is a base account or not, the
|
|
* payment has to be 1/24 of the capital amount.
|
|
*
|
|
* The payment has to be at least $minpay, unless the capital
|
|
* amount + interest + fee is less than $minpay; in that case
|
|
* that amount is paid and the function returns since the client
|
|
* no longer owes any money.
|
|
*
|
|
* @param float $pval initial loan to customer (in any currency)
|
|
* @param float $rate interest rate per year in %
|
|
* @param float $fee monthly invoice fee
|
|
* @param float $minpay minimum monthly payment allowed for this country.
|
|
* @param float $payment payment the client to pay each month
|
|
* @param int $months amount of months to run (-1 => infinity)
|
|
* @param boolean $base is it a base account?
|
|
*
|
|
* @return array An array of monthly payments for the customer.
|
|
*/
|
|
private static function _fulpacc(
|
|
$pval, $rate, $fee, $minpay, $payment, $months, $base
|
|
) {
|
|
$bal = $pval;
|
|
$payarray = array();
|
|
while (($months != 0) && ($bal > self::$accuracy)) {
|
|
$interest = $bal * $rate / (100.0 * 12);
|
|
$newbal = $bal + $interest + $fee;
|
|
|
|
if ($minpay >= $newbal || $payment >= $newbal) {
|
|
$payarray[] = $newbal;
|
|
return $payarray;
|
|
}
|
|
|
|
$newpay = max($payment, $minpay);
|
|
if ($base) {
|
|
$newpay = max($newpay, $bal/24.0 + $fee + $interest);
|
|
}
|
|
|
|
$bal = $newbal - $newpay;
|
|
$payarray[] = $newpay;
|
|
$months -= 1;
|
|
}
|
|
|
|
return $payarray;
|
|
}
|
|
|
|
/**
|
|
* Calculates how much you have to pay each month if you want to
|
|
* pay exactly the same amount each month. The interesting input
|
|
* is the amount of $months.
|
|
*
|
|
* It does not include the fee so add that later.
|
|
*
|
|
* Return value: monthly payment.
|
|
*
|
|
* @param float $pval principal value
|
|
* @param int $months months to pay of in
|
|
* @param float $rate interest rate in % as before
|
|
*
|
|
* @return float monthly payment
|
|
*/
|
|
private static function _annuity($pval, $months, $rate)
|
|
{
|
|
if ($months == 0) {
|
|
return $pval;
|
|
}
|
|
|
|
if ($rate == 0) {
|
|
return $pval/$months;
|
|
}
|
|
|
|
$p = $rate / (100.0*12);
|
|
return $pval * $p / (1 - pow((1+$p), -$months));
|
|
}
|
|
|
|
/**
|
|
* Calculate the APR for an annuity given the following inputs.
|
|
*
|
|
* If you give it bad inputs, it will return negative values.
|
|
*
|
|
* @param float $pval principal value
|
|
* @param int $months months to pay off in
|
|
* @param float $rate interest rate in % as before
|
|
* @param float $fee monthly fee
|
|
* @param float $minpay minimum payment per month
|
|
*
|
|
* @return float APR in %
|
|
*/
|
|
private static function _aprAnnuity($pval, $months, $rate, $fee, $minpay)
|
|
{
|
|
$payment = self::_annuity($pval, $months, $rate) + $fee;
|
|
if ($payment < 0) {
|
|
return $payment;
|
|
}
|
|
$payarray = self::_fulpacc(
|
|
$pval, $rate, $fee, $minpay, $payment, $months, false
|
|
);
|
|
$apr = self::_irr2apr(self::_irr($pval, $payarray, 1));
|
|
|
|
return $apr;
|
|
}
|
|
|
|
/**
|
|
* Grabs the array of all monthly payments for specified PClass.
|
|
*
|
|
* <b>Flags can be either</b>:<br>
|
|
* {@link KlarnaFlags::CHECKOUT_PAGE}<br>
|
|
* {@link KlarnaFlags::PRODUCT_PAGE}<br>
|
|
*
|
|
* @param float $sum The sum for the order/product.
|
|
* @param KlarnaPClass $pclass KlarnaPClass used to calculate the APR.
|
|
* @param int $flags Checkout or Product page.
|
|
*
|
|
* @throws KlarnaException
|
|
* @return array An array of monthly payments.
|
|
*/
|
|
private static function _getPayArray($sum, $pclass, $flags)
|
|
{
|
|
$monthsfee = 0;
|
|
if ($flags === KlarnaFlags::CHECKOUT_PAGE) {
|
|
$monthsfee = $pclass->getInvoiceFee();
|
|
}
|
|
$startfee = 0;
|
|
if ($flags === KlarnaFlags::CHECKOUT_PAGE) {
|
|
$startfee = $pclass->getStartFee();
|
|
}
|
|
|
|
//Include start fee in sum
|
|
$sum += $startfee;
|
|
|
|
$base = ($pclass->getType() === KlarnaPClass::ACCOUNT);
|
|
$lowest = self::get_lowest_payment_for_account($pclass->getCountry());
|
|
|
|
if ($flags == KlarnaFlags::CHECKOUT_PAGE) {
|
|
$minpay = ($pclass->getType() === KlarnaPClass::ACCOUNT) ? $lowest : 0;
|
|
} else {
|
|
$minpay = 0;
|
|
}
|
|
|
|
$payment = self::_annuity(
|
|
$sum,
|
|
$pclass->getMonths(),
|
|
$pclass->getInterestRate()
|
|
);
|
|
|
|
//Add monthly fee
|
|
$payment += $monthsfee;
|
|
|
|
return self::_fulpacc(
|
|
$sum,
|
|
$pclass->getInterestRate(),
|
|
$monthsfee,
|
|
$minpay,
|
|
$payment,
|
|
$pclass->getMonths(),
|
|
$base
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Calculates APR for the specified values.<br>
|
|
* Result is rounded with two decimals.<br>
|
|
*
|
|
* <b>Flags can be either</b>:<br>
|
|
* {@link KlarnaFlags::CHECKOUT_PAGE}<br>
|
|
* {@link KlarnaFlags::PRODUCT_PAGE}<br>
|
|
*
|
|
* @param float $sum The sum for the order/product.
|
|
* @param KlarnaPClass $pclass KlarnaPClass used to calculate the APR.
|
|
* @param int $flags Checkout or Product page.
|
|
* @param int $free Number of free months.
|
|
*
|
|
* @throws KlarnaException
|
|
* @return float APR in %
|
|
*/
|
|
public static function calc_apr($sum, $pclass, $flags, $free = 0)
|
|
{
|
|
if (!is_numeric($sum)) {
|
|
throw new Klarna_InvalidTypeException('sum', 'numeric');
|
|
}
|
|
if (is_numeric($sum) && (!is_int($sum) || !is_float($sum))) {
|
|
$sum = floatval($sum);
|
|
}
|
|
|
|
if (!($pclass instanceof KlarnaPClass)) {
|
|
throw new Klarna_InvalidTypeException('pclass', 'KlarnaPClass');
|
|
}
|
|
|
|
if (!is_numeric($free)) {
|
|
throw new Klarna_InvalidTypeException('free', 'integer');
|
|
}
|
|
|
|
if (is_numeric($free) && !is_int($free)) {
|
|
$free = intval($free);
|
|
}
|
|
|
|
if ($free < 0) {
|
|
throw new KlarnaException(
|
|
'Error in ' . __METHOD__ .
|
|
': Number of free months must be positive or zero!'
|
|
);
|
|
}
|
|
|
|
if (is_numeric($flags) && !is_int($flags)) {
|
|
$flags = intval($flags);
|
|
}
|
|
|
|
if (!is_numeric($flags)
|
|
|| !in_array(
|
|
$flags, array(
|
|
KlarnaFlags::CHECKOUT_PAGE, KlarnaFlags::PRODUCT_PAGE
|
|
)
|
|
)
|
|
) {
|
|
throw new Klarna_InvalidTypeException(
|
|
'flags',
|
|
KlarnaFlags::CHECKOUT_PAGE . ' or ' . KlarnaFlags::PRODUCT_PAGE
|
|
);
|
|
}
|
|
|
|
$monthsfee = 0;
|
|
if ($flags === KlarnaFlags::CHECKOUT_PAGE) {
|
|
$monthsfee = $pclass->getInvoiceFee();
|
|
}
|
|
$startfee = 0;
|
|
if ($flags === KlarnaFlags::CHECKOUT_PAGE) {
|
|
$startfee = $pclass->getStartFee();
|
|
}
|
|
|
|
//Include start fee in sum
|
|
$sum += $startfee;
|
|
|
|
$lowest = self::get_lowest_payment_for_account($pclass->getCountry());
|
|
|
|
if ($flags == KlarnaFlags::CHECKOUT_PAGE) {
|
|
$minpay = ($pclass->getType() === KlarnaPClass::ACCOUNT) ? $lowest : 0;
|
|
} else {
|
|
$minpay = 0;
|
|
}
|
|
|
|
//add monthly fee
|
|
$payment = self::_annuity(
|
|
$sum,
|
|
$pclass->getMonths(),
|
|
$pclass->getInterestRate()
|
|
) + $monthsfee;
|
|
|
|
$type = $pclass->getType();
|
|
switch($type) {
|
|
case KlarnaPClass::CAMPAIGN:
|
|
case KlarnaPClass::ACCOUNT:
|
|
return round(
|
|
self::_aprAnnuity(
|
|
$sum, $pclass->getMonths(),
|
|
$pclass->getInterestRate(),
|
|
$pclass->getInvoiceFee(),
|
|
$minpay
|
|
),
|
|
2
|
|
);
|
|
case KlarnaPClass::SPECIAL:
|
|
throw new Klarna_PClassException(
|
|
'Method is not available for SPECIAL pclasses'
|
|
);
|
|
case KlarnaPClass::FIXED:
|
|
throw new Klarna_PClassException(
|
|
'Method is not available for FIXED pclasses'
|
|
);
|
|
default:
|
|
throw new Klarna_PClassException(
|
|
'Unknown PClass type! ('.$type.')'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates the total credit purchase cost.<br>
|
|
* The result is rounded up, depending on the pclass country.<br>
|
|
*
|
|
* <b>Flags can be either</b>:<br>
|
|
* {@link KlarnaFlags::CHECKOUT_PAGE}<br>
|
|
* {@link KlarnaFlags::PRODUCT_PAGE}<br>
|
|
*
|
|
* @param float $sum The sum for the order/product.
|
|
* @param KlarnaPClass $pclass PClass used to calculate total credit cost.
|
|
* @param int $flags Checkout or Product page.
|
|
*
|
|
* @throws KlarnaException
|
|
* @return float Total credit purchase cost.
|
|
*/
|
|
public static function total_credit_purchase_cost($sum, $pclass, $flags)
|
|
{
|
|
if (!is_numeric($sum)) {
|
|
throw new Klarna_InvalidTypeException('sum', 'numeric');
|
|
}
|
|
|
|
if (is_numeric($sum) && (!is_int($sum) || !is_float($sum))) {
|
|
$sum = floatval($sum);
|
|
}
|
|
|
|
if (!($pclass instanceof KlarnaPClass)) {
|
|
throw new Klarna_InvalidTypeException('pclass', 'KlarnaPClass');
|
|
}
|
|
|
|
if (is_numeric($flags) && !is_int($flags)) {
|
|
$flags = intval($flags);
|
|
}
|
|
|
|
if (!is_numeric($flags)
|
|
|| !in_array(
|
|
$flags,
|
|
array(
|
|
KlarnaFlags::CHECKOUT_PAGE, KlarnaFlags::PRODUCT_PAGE
|
|
)
|
|
)
|
|
) {
|
|
throw new Klarna_InvalidTypeException(
|
|
'flags',
|
|
KlarnaFlags::CHECKOUT_PAGE . ' or ' . KlarnaFlags::PRODUCT_PAGE
|
|
);
|
|
}
|
|
|
|
$payarr = self::_getPayArray($sum, $pclass, $flags);
|
|
|
|
$credit_cost = 0;
|
|
foreach ($payarr as $pay) {
|
|
$credit_cost += $pay;
|
|
}
|
|
|
|
return self::pRound($credit_cost, $pclass->getCountry());
|
|
}
|
|
|
|
/**
|
|
* Calculates the monthly cost for the specified pclass.
|
|
* The result is rounded up to the correct value depending on the
|
|
* pclass country.<br>
|
|
*
|
|
* Example:<br>
|
|
* <ul>
|
|
* <li>In product view, round monthly cost with max 0.5 or 0.1
|
|
* depending on currency.<br>
|
|
* <ul>
|
|
* <li>10.50 SEK rounds to 11 SEK</li>
|
|
* <li>10.49 SEK rounds to 10 SEK</li>
|
|
* <li> 8.55 EUR rounds to 8.6 EUR</li>
|
|
* <li> 8.54 EUR rounds to 8.5 EUR</li>
|
|
* </ul></li>
|
|
* <li>
|
|
* In checkout, round the monthly cost to have 2 decimals.<br>
|
|
* For example 10.57 SEK/per månad
|
|
* </li>
|
|
* </ul>
|
|
*
|
|
* <b>Flags can be either</b>:<br>
|
|
* {@link KlarnaFlags::CHECKOUT_PAGE}<br>
|
|
* {@link KlarnaFlags::PRODUCT_PAGE}<br>
|
|
*
|
|
* @param int $sum The sum for the order/product.
|
|
* @param KlarnaPClass $pclass PClass used to calculate monthly cost.
|
|
* @param int $flags Checkout or product page.
|
|
*
|
|
* @throws KlarnaException
|
|
* @return float The monthly cost.
|
|
*/
|
|
public static function calc_monthly_cost($sum, $pclass, $flags)
|
|
{
|
|
if (!is_numeric($sum)) {
|
|
throw new Klarna_InvalidTypeException('sum', 'numeric');
|
|
}
|
|
|
|
if (is_numeric($sum) && (!is_int($sum) || !is_float($sum))) {
|
|
$sum = floatval($sum);
|
|
}
|
|
|
|
if (!($pclass instanceof KlarnaPClass)) {
|
|
throw new Klarna_InvalidTypeException('pclass', 'KlarnaPClass');
|
|
}
|
|
|
|
if (is_numeric($flags) && !is_int($flags)) {
|
|
$flags = intval($flags);
|
|
}
|
|
|
|
if (!is_numeric($flags)
|
|
|| !in_array(
|
|
$flags,
|
|
array(
|
|
KlarnaFlags::CHECKOUT_PAGE, KlarnaFlags::PRODUCT_PAGE
|
|
)
|
|
)
|
|
) {
|
|
throw new Klarna_InvalidTypeException(
|
|
'flags',
|
|
KlarnaFlags::CHECKOUT_PAGE . ' or ' . KlarnaFlags::PRODUCT_PAGE
|
|
);
|
|
}
|
|
|
|
$payarr = self::_getPayArray($sum, $pclass, $flags);
|
|
$value = 0;
|
|
if (isset($payarr[0])) {
|
|
$value = $payarr[0];
|
|
}
|
|
|
|
if (KlarnaFlags::CHECKOUT_PAGE == $flags) {
|
|
return round($value, 2);
|
|
}
|
|
|
|
return self::pRound($value, $pclass->getCountry());
|
|
}
|
|
|
|
/**
|
|
* Returns the lowest monthly payment for Klarna Account.
|
|
*
|
|
* @param int $country KlarnaCountry constant.
|
|
*
|
|
* @throws KlarnaException
|
|
* @return int|float Lowest monthly payment.
|
|
*/
|
|
public static function get_lowest_payment_for_account($country)
|
|
{
|
|
$country = KlarnaCountry::getCode($country);
|
|
|
|
switch (strtoupper($country)) {
|
|
case "SE":
|
|
return 50.0;
|
|
case "NO":
|
|
return 95.0;
|
|
case "FI":
|
|
return 8.95;
|
|
case "DK":
|
|
return 89.0;
|
|
case "DE":
|
|
case "NL":
|
|
case "AT":
|
|
return 6.95;
|
|
default:
|
|
throw new KlarnaException("Invalid country {$country}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rounds a value depending on the specified country.
|
|
*
|
|
* @param int|float $value The value to be rounded.
|
|
* @param int $country KlarnaCountry constant.
|
|
*
|
|
* @return float|int
|
|
*/
|
|
public static function pRound($value, $country)
|
|
{
|
|
$multiply = 1; //Round to closest integer
|
|
$country = KlarnaCountry::getCode($country);
|
|
switch($country) {
|
|
case "FI":
|
|
case "DE":
|
|
case "NL":
|
|
case "AT":
|
|
$multiply = 10; //Round to closest decimal
|
|
break;
|
|
}
|
|
|
|
return floor(($value*$multiply)+0.5)/$multiply;
|
|
}
|
|
|
|
}
|
|
|