forked from formapro/KlarnaInvoice
-
Notifications
You must be signed in to change notification settings - Fork 0
/
klarnacalc.php
655 lines (593 loc) · 20.9 KB
/
klarnacalc.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
<?php
/**
* KlarnaCalc
*
* PHP Version 5.3
*
* @category Payment
* @package KlarnaAPI
* @author MS Dev <[email protected]>
* @copyright 2012 Klarna AB (http://klarna.com)
* @license http://opensource.org/licenses/BSD-2-Clause BSD-2
* @link https://developers.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 <[email protected]>
* @copyright 2012 Klarna AB (http://klarna.com)
* @license http://opensource.org/licenses/BSD-2-Clause BSD-2
* @link https://developers.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 "AT":
return 6.95;
case "NL":
return 5.0;
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;
}
}