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