Overview
  • Namespace
  • Class

Namespaces

  • Klarna
    • XMLRPC
      • Exception

Interfaces

  • Klarna\XMLRPC\Exception\KlarnaException
  1 <?php
  2 /**
  3  * Copyright 2016 Klarna AB.
  4  *
  5  * Licensed under the Apache License, Version 2.0 (the "License");
  6  * you may not use this file except in compliance with the License.
  7  * You may obtain a copy of the License at
  8  *
  9  *     http://www.apache.org/licenses/LICENSE-2.0
 10  *
 11  * Unless required by applicable law or agreed to in writing, software
 12  * distributed under the License is distributed on an "AS IS" BASIS,
 13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 14  * See the License for the specific language governing permissions and
 15  * limitations under the License.
 16  */
 17 namespace Klarna\XMLRPC;
 18 
 19 /**
 20  * Calc provides methods to calculate part payment functions.
 21  *
 22  * All rates are yearly rates, but they are calculated monthly. So
 23  * a rate of 9 % is used 0.75% monthly. The first is the one we specify
 24  * to the customers, and the second one is the one added each month to
 25  * the account. The IRR uses the same notation.
 26  *
 27  * The APR is however calculated by taking the monthly rate and raising
 28  * it to the 12 power. This is according to the EU law, and will give
 29  * very large numbers if the $pval is small compared to the $fee and
 30  * the amount of months you repay is small as well.
 31  *
 32  * All functions work in discrete mode, and the time interval is the
 33  * mythical evenly divided month. There is no way to calculate APR in
 34  * days without using integrals and other hairy math. So don't try.
 35  * The amount of days between actual purchase and the first bill can
 36  * of course vary between 28 and 61 days, but all calculations in this
 37  * class assume this time is exactly and that is ok since this will only
 38  * overestimate the APR and all examples in EU law uses whole months as well.
 39  */
 40 class Calc
 41 {
 42     /**
 43      * This constant tells the irr function when to stop.
 44      * If the calculation error is lower than this the calculation is done.
 45      *
 46      * @var float
 47      */
 48     protected static $accuracy = 0.01;
 49 
 50     /**
 51      * Calculates the midpoint between two points. Used by divide and conquer.
 52      *
 53      * @param float $a point a
 54      * @param float $b point b
 55      *
 56      * @return float
 57      */
 58     private static function midpoint($a, $b)
 59     {
 60         return ($a + $b) / 2;
 61     }
 62 
 63     /**
 64      * Net Present Value.
 65      *
 66      * Calculates the difference between the initial loan to the customer
 67      * and the individual payments adjusted for the inverse of the interest
 68      * rate. The variable we are searching for is $rate and if $pval,
 69      * $payarray and $rate is perfectly balanced this function returns 0.0.
 70      *
 71      * @param float $pval       initial loan to customer (in any currency)
 72      * @param array $payarray   array of monthly payments from the customer
 73      * @param float $rate       interest rate per year in %
 74      * @param int   $fromdayone count interest from the first day? yes(1)/no(0)
 75      *
 76      * @return float
 77      */
 78     private static function npv($pval, $payarray, $rate, $fromdayone)
 79     {
 80         $month = $fromdayone;
 81         foreach ($payarray as $payment) {
 82             $pval -= $payment / pow(1 + $rate / (12 * 100.0), $month++);
 83         }
 84 
 85         return $pval;
 86     }
 87 
 88     /**
 89      * This function uses divide and conquer to numerically find the IRR,
 90      * Internal Rate of Return. It starts of by trying a low of 0% and a
 91      * high of 100%. If this isn't enough it will double the interval up
 92      * to 1000000%. Note that this is insanely high, and if you try to convert
 93      * an IRR that high to an APR you will get even more insane values,
 94      * so feed this function good data.
 95      *
 96      * Return values: float irr  if it was possible to find a rate that gets
 97      *                           npv closer to 0 than $accuracy.
 98      *                int -1     The sum of the payarray is less than the lent
 99      *                           amount, $pval. Hellooooooo. Impossible.
100      *                int -2     the IRR is way to high, giving up.
101      *
102      * This algorithm works in logarithmic time no matter what inputs you give
103      * and it will come to a good answer within ~30 steps.
104      *
105      * @param float $pval       initial loan to customer (in any currency)
106      * @param array $payarray   array of monthly payments from the customer
107      * @param int   $fromdayone count interest from the first day? yes(1)/no(0)
108      *
109      * @return float
110      */
111     private static function irr($pval, $payarray, $fromdayone)
112     {
113         $low = 0.0;
114         $high = 100.0;
115         $lowval = self::npv($pval, $payarray, $low, $fromdayone);
116         $highval = self::npv($pval, $payarray, $high, $fromdayone);
117 
118         // The sum of $payarray is smaller than $pval, impossible!
119         if ($lowval > 0.0) {
120             return -1;
121         }
122 
123         // Standard divide and conquer.
124         do {
125             $mid = self::midpoint($low, $high);
126             $midval = self::npv($pval, $payarray, $mid, $fromdayone);
127             if (abs($midval) < self::$accuracy) {
128                 //we are close enough
129                 return $mid;
130             }
131 
132             if ($highval < 0.0) {
133                 // we are not in range, so double it
134                 $low = $high;
135                 $lowval = $highval;
136                 $high *= 2;
137                 $highval = self::npv($pval, $payarray, $high, $fromdayone);
138             } elseif ($midval >= 0.0) {
139                 // irr is between low and mid
140                 $high = $mid;
141                 $highval = $midval;
142             } else {
143                 // irr is between mid and high
144                 $low = $mid;
145                 $lowval = $midval;
146             }
147         } while ($high < 1000000);
148         // bad input, insanely high interest. APR will be INSANER!
149         return -2;
150     }
151 
152     /**
153      * IRR is not the same thing as APR, Annual Percentage Rate. The
154      * IRR is per time period, i.e. 1 month, and the APR is per year,
155      * and note that that you need to raise to the power of 12, not
156      * mutliply by 12.
157      *
158      * This function turns an IRR into an APR.
159      *
160      * If you feed it a value of 100%, yes the APR will be millions!
161      * If you feed it a value of   9%, it will be 9.3806%.
162      * That is the nature of this math and you can check the wiki
163      * page for APR for more info.
164      *
165      * @param float $irr Internal Rate of Return, expressed yearly, in %
166      *
167      * @return float Annual Percentage Rate, in %
168      */
169     private static function irr2apr($irr)
170     {
171         return 100 * (pow(1 + $irr / (12 * 100.0), 12) - 1);
172     }
173 
174     /**
175      * This is a simplified model of how our paccengine works if
176      * a client always pays their bills. It adds interest and fees
177      * and checks minimum payments. It will run until the value
178      * of the account reaches 0, and return an array of all the
179      * individual payments. Months is the amount of months to run
180      * the simulation. Important! Don't feed it too few months or
181      * the whole loan won't be paid off, but the other functions
182      * should handle this correctly.
183      *
184      * Giving it too many months has no bad effects, or negative
185      * amount of months which means run forever, but it will stop
186      * as soon as the account is paid in full.
187      *
188      * Depending if the account is a base account or not, the
189      * payment has to be 1/24 of the capital amount.
190      *
191      * The payment has to be at least $minpay, unless the capital
192      * amount + interest + fee is less than $minpay; in that case
193      * that amount is paid and the function returns since the client
194      * no longer owes any money.
195      *
196      * @param float $pval    initial loan to customer (in any currency)
197      * @param float $rate    interest rate per year in %
198      * @param float $fee     monthly invoice fee
199      * @param float $minpay  minimum monthly payment allowed for this country.
200      * @param float $payment payment the client to pay each month
201      * @param int   $months  amount of months to run (-1 => infinity)
202      * @param bool  $base    is it a base account?
203      *
204      * @return array An array of monthly payments for the customer.
205      */
206     private static function fulpacc(
207         $pval,
208         $rate,
209         $fee,
210         $minpay,
211         $payment,
212         $months,
213         $base
214     ) {
215         $bal = $pval;
216         $payarray = array();
217         while (($months != 0) && ($bal > self::$accuracy)) {
218             $interest = $bal * $rate / (100.0 * 12);
219             $newbal = $bal + $interest + $fee;
220 
221             if ($minpay >= $newbal || $payment >= $newbal) {
222                 $payarray[] = $newbal;
223 
224                 return $payarray;
225             }
226 
227             $newpay = max($payment, $minpay);
228             if ($base) {
229                 $newpay = max($newpay, $bal / 24.0 + $fee + $interest);
230             }
231 
232             $bal = $newbal - $newpay;
233             $payarray[] = $newpay;
234             $months -= 1;
235         }
236 
237         return $payarray;
238     }
239 
240     /**
241      * Calculates how much you have to pay each month if you want to
242      * pay exactly the same amount each month. The interesting input
243      * is the amount of $months.
244      *
245      * It does not include the fee so add that later.
246      *
247      * Return value: monthly payment.
248      *
249      * @param float $pval   principal value
250      * @param int   $months months to pay of in
251      * @param float $rate   interest rate in % as before
252      *
253      * @return float monthly payment
254      */
255     private static function annuity($pval, $months, $rate)
256     {
257         if ($months == 0) {
258             return $pval;
259         }
260 
261         if ($rate == 0) {
262             return $pval / $months;
263         }
264 
265         $p = $rate / (100.0 * 12);
266 
267         return $pval * $p / (1 - pow((1 + $p), -$months));
268     }
269 
270     /**
271      * Calculate the APR for an annuity given the following inputs.
272      *
273      * If you give it bad inputs, it will return negative values.
274      *
275      * @param float $pval   principal value
276      * @param int   $months months to pay off in
277      * @param float $rate   interest rate in % as before
278      * @param float $fee    monthly fee
279      * @param float $minpay minimum payment per month
280      *
281      * @return float APR in %
282      */
283     private static function aprAnnuity($pval, $months, $rate, $fee, $minpay)
284     {
285         $payment = self::annuity($pval, $months, $rate) + $fee;
286         if ($payment < 0) {
287             return $payment;
288         }
289         $payarray = self::fulpacc(
290             $pval,
291             $rate,
292             $fee,
293             $minpay,
294             $payment,
295             $months,
296             false
297         );
298         $apr = self::irr2apr(self::irr($pval, $payarray, 1));
299 
300         return $apr;
301     }
302 
303     /**
304      * Grabs the array of all monthly payments for specified PClass.
305      *
306      * <b>Flags can be either</b>:<br>
307      * {@link Flags::CHECKOUT_PAGE}<br>
308      * {@link Flags::PRODUCT_PAGE}<br>
309      *
310      * @param float  $sum    The sum for the order/product.
311      * @param PClass $pclass PClass used to calculate the APR.
312      * @param int    $flags  Checkout or Product page.
313      *
314      * @throws Exception\KlarnaException
315      *
316      * @return array An array of monthly payments.
317      */
318     private static function getPayArray($sum, $pclass, $flags)
319     {
320         $monthsfee = 0;
321         if ($flags === Flags::CHECKOUT_PAGE) {
322             $monthsfee = $pclass->getInvoiceFee();
323         }
324         $startfee = 0;
325         if ($flags === Flags::CHECKOUT_PAGE) {
326             $startfee = $pclass->getStartFee();
327         }
328 
329         //Include start fee in sum
330         $sum += $startfee;
331 
332         $base = ($pclass->getType() === PClass::ACCOUNT);
333         $lowest = self::getLowestPaymentForAccount($pclass->getCountry());
334 
335         if ($flags == Flags::CHECKOUT_PAGE) {
336             $minpay = ($pclass->getType() === PClass::ACCOUNT) ? $lowest : 0;
337         } else {
338             $minpay = 0;
339         }
340 
341         $payment = self::annuity(
342             $sum,
343             $pclass->getMonths(),
344             $pclass->getInterestRate()
345         );
346 
347         //Add monthly fee
348         $payment += $monthsfee;
349 
350         return  self::fulpacc(
351             $sum,
352             $pclass->getInterestRate(),
353             $monthsfee,
354             $minpay,
355             $payment,
356             $pclass->getMonths(),
357             $base
358         );
359     }
360 
361     /**
362      * Calculates APR for the specified values.<br>
363      * Result is rounded with two decimals.<br>.
364      *
365      * <b>Flags can be either</b>:<br>
366      * {@link Flags::CHECKOUT_PAGE}<br>
367      * {@link Flags::PRODUCT_PAGE}<br>
368      *
369      * @param float  $sum    The sum for the order/product.
370      * @param PClass $pclass PClass used to calculate the APR.
371      * @param int    $flags  Checkout or Product page.
372      * @param int    $free   Number of free months.
373      *
374      * @throws \InvalidArgumentException
375      * @throws Exception\KlarnaException
376      *
377      * @return float APR in %
378      */
379     public static function calcApr($sum, PClass $pclass, $flags, $free = 0)
380     {
381         if (!is_numeric($sum)) {
382             throw new \InvalidArgumentException('sum must be numeric');
383         }
384         if (is_numeric($sum) && (!is_int($sum) || !is_float($sum))) {
385             $sum = floatval($sum);
386         }
387 
388         if (!is_numeric($free)) {
389             throw new \InvalidArgumentException('free must be an integer');
390         }
391 
392         if (is_numeric($free) && !is_int($free)) {
393             $free = intval($free);
394         }
395 
396         if ($free < 0) {
397             throw new \InvalidArgumentException(
398                 'Number of free months must be positive or zero!'
399             );
400         }
401 
402         if (is_numeric($flags) && !is_int($flags)) {
403             $flags = intval($flags);
404         }
405 
406         if (!is_numeric($flags)
407             || !in_array(
408                 $flags,
409                 array(
410                     Flags::CHECKOUT_PAGE, Flags::PRODUCT_PAGE,
411                 )
412             )
413         ) {
414             throw new \InvalidArgumentException(
415                 'Expected $flags to be '.Flags::CHECKOUT_PAGE.' or '.Flags::PRODUCT_PAGE
416             );
417         }
418 
419         $monthsfee = 0;
420         if ($flags === Flags::CHECKOUT_PAGE) {
421             $monthsfee = $pclass->getInvoiceFee();
422         }
423         $startfee = 0;
424         if ($flags === Flags::CHECKOUT_PAGE) {
425             $startfee = $pclass->getStartFee();
426         }
427 
428         //Include start fee in sum
429         $sum += $startfee;
430 
431         $lowest = self::getLowestPaymentForAccount($pclass->getCountry());
432 
433         if ($flags == Flags::CHECKOUT_PAGE) {
434             $minpay = ($pclass->getType() === PClass::ACCOUNT) ? $lowest : 0;
435         } else {
436             $minpay = 0;
437         }
438 
439         //add monthly fee
440         $payment = self::annuity(
441             $sum,
442             $pclass->getMonths(),
443             $pclass->getInterestRate()
444         ) + $monthsfee;
445 
446         $type = $pclass->getType();
447         switch ($type) {
448             case PClass::CAMPAIGN:
449             case PClass::ACCOUNT:
450                 return round(
451                     self::aprAnnuity(
452                         $sum,
453                         $pclass->getMonths(),
454                         $pclass->getInterestRate(),
455                         $pclass->getInvoiceFee(),
456                         $minpay
457                     ),
458                     2
459                 );
460             case PClass::SPECIAL:
461                 throw new \RuntimeException(
462                     'Method is not available for SPECIAL pclasses'
463                 );
464             case PClass::FIXED:
465                 throw new \RuntimeException(
466                     'Method is not available for FIXED pclasses'
467                 );
468             default:
469                 throw new \RuntimeException(
470                     'Unknown PClass type! ('.$type.')'
471                 );
472         }
473     }
474 
475     /**
476      * Calculates the total credit purchase cost.<br>
477      * The result is rounded up, depending on the pclass country.<br>.
478      *
479      * <b>Flags can be either</b>:<br>
480      * {@link Flags::CHECKOUT_PAGE}<br>
481      * {@link Flags::PRODUCT_PAGE}<br>
482      *
483      * @param float  $sum    The sum for the order/product.
484      * @param PClass $pclass PClass used to calculate total credit cost.
485      * @param int    $flags  Checkout or Product page.
486      *
487      * @throws \InvalidArgumentException
488      *
489      * @return float Total credit purchase cost.
490      */
491     public static function totalCreditPurchaseCost($sum, PClass $pclass, $flags)
492     {
493         if (!is_numeric($sum)) {
494             throw new \InvalidArgumentException('Expected $sum to be numeric');
495         }
496 
497         if (is_numeric($sum) && (!is_int($sum) || !is_float($sum))) {
498             $sum = floatval($sum);
499         }
500 
501         if (is_numeric($flags) && !is_int($flags)) {
502             $flags = intval($flags);
503         }
504 
505         if (!is_numeric($flags)
506             || !in_array(
507                 $flags,
508                 array(
509                     Flags::CHECKOUT_PAGE, Flags::PRODUCT_PAGE,
510                 )
511             )
512         ) {
513             throw new \InvalidArgumentException(
514                 'Expected $flags to be '.Flags::CHECKOUT_PAGE.' or '.Flags::PRODUCT_PAGE
515             );
516         }
517 
518         $payarr = self::getPayArray($sum, $pclass, $flags);
519 
520         $credit_cost = 0;
521         foreach ($payarr as $pay) {
522             $credit_cost += $pay;
523         }
524 
525         return self::pRound($credit_cost, $pclass->getCountry());
526     }
527 
528     /**
529      * Calculates the monthly cost for the specified pclass.
530      * The result is rounded up to the correct value depending on the
531      * pclass country.<br>.
532      *
533      * Example:<br>
534      * <ul>
535      *     <li>In product view, round monthly cost with max 0.5 or 0.1
536      *  depending on currency.<br>
537      *     <ul>
538      *         <li>10.50 SEK rounds to 11 SEK</li>
539      *         <li>10.49 SEK rounds to 10 SEK</li>
540      *         <li> 8.55 EUR rounds to 8.6 EUR</li>
541      *         <li> 8.54 EUR rounds to 8.5 EUR</li>
542      *     </ul></li>
543      *     <li>
544      *         In checkout, round the monthly cost to have 2 decimals.<br>
545      *         For example 10.57 SEK/per månad
546      *     </li>
547      * </ul>
548      *
549      * <b>Flags can be either</b>:<br>
550      * {@link Flags::CHECKOUT_PAGE}<br>
551      * {@link Flags::PRODUCT_PAGE}<br>
552      *
553      * @param int    $sum    The sum for the order/product.
554      * @param PClass $pclass PClass used to calculate monthly cost.
555      * @param int    $flags  Checkout or product page.
556      *
557      * @throws \InvalidArgumentException
558      *
559      * @return float The monthly cost.
560      */
561     public static function calcMonthlyCost($sum, PClass $pclass, $flags)
562     {
563         if (!is_numeric($sum)) {
564             throw new \InvalidArgumentException('Expected $sum to be numeric');
565         }
566 
567         if (is_numeric($sum) && (!is_int($sum) || !is_float($sum))) {
568             $sum = floatval($sum);
569         }
570 
571         if (!($pclass instanceof PClass)) {
572             throw new \InvalidArgumentException('Expected $pclass to be a PClass');
573         }
574 
575         if (is_numeric($flags) && !is_int($flags)) {
576             $flags = intval($flags);
577         }
578 
579         if (!is_numeric($flags)
580             || !in_array(
581                 $flags,
582                 array(
583                     Flags::CHECKOUT_PAGE, Flags::PRODUCT_PAGE,
584                 )
585             )
586         ) {
587             throw new \InvalidArgumentException(
588                 'Expected $flags to be '.Flags::CHECKOUT_PAGE.' or '.Flags::PRODUCT_PAGE
589             );
590         }
591 
592         $payarr = self::getPayArray($sum, $pclass, $flags);
593         $value = 0;
594         if (isset($payarr[0])) {
595             $value = $payarr[0];
596         }
597 
598         if (Flags::CHECKOUT_PAGE == $flags) {
599             return round($value, 2);
600         }
601 
602         return self::pRound($value, $pclass->getCountry());
603     }
604 
605     /**
606      * Returns the lowest monthly payment for Klarna Account.
607      *
608      * @param int $country Country constant.
609      *
610      * @throws Exception\KlarnaException
611      *
612      * @return int|float Lowest monthly payment.
613      */
614     public static function getLowestPaymentForAccount($country)
615     {
616         $country = Country::getCode($country);
617 
618         switch (strtoupper($country)) {
619             case 'SE':
620                 return 50.0;
621             case 'NO':
622                 return 95.0;
623             case 'FI':
624                 return 8.95;
625             case 'DK':
626                 return 89.0;
627             case 'DE':
628             case 'AT':
629                 return 6.95;
630             case 'NL':
631                 return 5.0;
632             default:
633                 throw new \RuntimeException("Invalid country {{$country}}");
634         }
635     }
636 
637     /**
638      * Rounds a value depending on the specified country.
639      *
640      * @param int|float $value   The value to be rounded.
641      * @param int       $country Country constant.
642      *
643      * @return float|int
644      */
645     public static function pRound($value, $country)
646     {
647         $multiply = 1; //Round to closest integer
648         $country = Country::getCode($country);
649         switch ($country) {
650             case 'FI':
651             case 'DE':
652             case 'NL':
653             case 'AT':
654                 $multiply = 10; //Round to closest decimal
655                 break;
656         }
657 
658         return floor(($value * $multiply) + 0.5) / $multiply;
659     }
660 }
661 
API documentation generated by ApiGen