Skip to content
This repository has been archived by the owner on Feb 2, 2022. It is now read-only.

Commit

Permalink
Merge pull request #279 from ciungulete/rounding_bug
Browse files Browse the repository at this point in the history
Rounding bug with tax calculations
  • Loading branch information
sandervanhooft committed Dec 9, 2020
2 parents a2877b2 + f78cb27 commit 40f4884
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 5 deletions.
4 changes: 3 additions & 1 deletion src/FirstPayment/Actions/AddGenericOrderItem.php
Expand Up @@ -13,14 +13,16 @@ class AddGenericOrderItem extends BaseAction
* @param \Illuminate\Database\Eloquent\Model $owner
* @param \Money\Money $subtotal
* @param string $description
* @param int $roundingMode
*/
public function __construct(Model $owner, Money $subtotal, string $description)
public function __construct(Model $owner, Money $subtotal, string $description, int $roundingMode = Money::ROUND_HALF_UP)
{
$this->owner = $owner;
$this->taxPercentage = $this->owner->taxPercentage();
$this->unitPrice = $subtotal;
$this->currency = $subtotal->getCurrency()->getCode();
$this->description = $description;
$this->roundingMode = $roundingMode;
}

/**
Expand Down
14 changes: 13 additions & 1 deletion src/FirstPayment/Actions/BaseAction.php
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Database\Eloquent\Model;
use Laravel\Cashier\Cashier;
use Money\Money;

abstract class BaseAction
{
Expand All @@ -25,6 +26,9 @@ abstract class BaseAction
/** @var int */
protected $quantity = 1;

/** @var int */
protected $roundingMode = Money::ROUND_HALF_UP;

/**
* Rebuild the Action from a payload.
*
Expand Down Expand Up @@ -69,6 +73,14 @@ public function getCurrency()
return $this->currency ?? strtoupper(Cashier::usesCurrency());
}

/**
* @return int
*/
public function getRoundingMode()
{
return $this->roundingMode;
}

/**
* @return float
*/
Expand Down Expand Up @@ -112,7 +124,7 @@ public function getTax()
{
return $this->getSubtotal()
->multiply($this->getTaxPercentage())
->divide(100);
->divide(100, $this->getRoundingMode());
}

/**
Expand Down
37 changes: 34 additions & 3 deletions src/SubscriptionBuilder/FirstPaymentSubscriptionBuilder.php
Expand Up @@ -12,6 +12,7 @@
use Laravel\Cashier\Plan\Contracts\PlanRepository;
use Laravel\Cashier\Plan\Plan;
use Laravel\Cashier\SubscriptionBuilder\Contracts\SubscriptionBuilder as Contract;
use Money\Money;

/**
* Creates and configures a Mollie first payment to create a new mandate via Mollie's checkout
Expand Down Expand Up @@ -89,18 +90,20 @@ public function create()
if ($this->isTrial) {
$taxPercentage = $this->owner->taxPercentage() * 0.01;
$total = $this->plan->firstPaymentAmount();

if ($total->isZero()) {
$vat = $total->subtract($total); // zero VAT
} else {
$vat = $total->divide(1 + $taxPercentage)->multiply($taxPercentage);
$vat = $total->divide(1 + $taxPercentage)
->multiply($taxPercentage, $this->roundingMode($total, $taxPercentage));
}
$subtotal = $total->subtract($vat);

$actions[] = new AddGenericOrderItem(
$this->owner,
$subtotal,
$this->plan->firstPaymentDescription()
$this->plan->firstPaymentDescription(),
$this->roundingMode($total, $taxPercentage)
);
} elseif ($coupon) {
$actions[] = new ApplySubscriptionCouponToPayment($this->owner, $coupon, $actions->processedOrderItems());
Expand Down Expand Up @@ -240,4 +243,32 @@ protected function initializeFirstPaymentBuilder(Model $owner, $paymentOptions =

return $this->firstPaymentBuilder;
}

/**
* Format the money as basic decimal
*
* @param \Money\Money $total
* @param float $taxPercentage
*
* @return int
*/
public function roundingMode(Money $total, float $taxPercentage)
{
$vat = $total->divide(1 + $taxPercentage)->multiply($taxPercentage);

$subtotal = $total->subtract($vat);

$recalculatedTax = $subtotal->multiply($taxPercentage * 100)->divide(100);

$finalTotal = $subtotal->add($recalculatedTax);

if ($finalTotal->equals($total)) {
return Money::ROUND_HALF_UP;
}
if ($finalTotal->greaterThan($total)) {
return Money::ROUND_UP;
}

return Money::ROUND_DOWN;
}
}
@@ -0,0 +1,117 @@
<?php

namespace Laravel\Cashier\Tests\SubscriptionBuilder;

use Laravel\Cashier\Cashier;
use Laravel\Cashier\Mollie\Contracts\CreateMolliePayment;
use Laravel\Cashier\Mollie\Contracts\GetMollieCustomer;
use Laravel\Cashier\SubscriptionBuilder\FirstPaymentSubscriptionBuilder;
use Laravel\Cashier\Tests\BaseTestCase;
use Mollie\Api\MollieApiClient;
use Mollie\Api\Resources\Customer;
use Mollie\Api\Resources\Payment;
use Money\Money;

class FirstPaymentSubscriptionBuilderApplyCorrectTaxTest extends BaseTestCase
{
protected $user;

protected function setUp(): void
{
parent::setUp();
Cashier::useCurrency('eur');
$this->withTestNow('2019-01-01');
$this->withPackageMigrations();
$this->withConfiguredPlans();
$this->user = $this->getCustomerUser(true, [
'tax_percentage' => 21,
'mollie_customer_id' => 'cst_unique_customer_id',
]);
}

/** @test */
public function handlesTrialDaysAndFirstPaymentWithTaxAppliedCorrect()
{
$firstPaymentAmounts = collect(['10.00', '11.00', '21.00', '24.00', '280.00']);

$firstPaymentAmounts->each(function ($amount) {
$this->withMockedCreateMolliePayment();
$this->withMockedGetMollieCustomerTwice();

config(['cashier_plans.defaults.first_payment.amount.value' => $amount]);

$trialBuilder = $this->getBuilder();
$trialBuilder->trialDays(5)->create();
$this->assertEquals(
$amount,
$trialBuilder->getMandatePaymentBuilder()->getMolliePayload()['amount']['value']
);
});
}

/** @test */
public function roundingModeReturnCorrectValue()
{
$down = $this->getBuilder()->roundingMode(Money::EUR(1000), 0.21); //total is 1001
$equals = $this->getBuilder()->roundingMode(Money::EUR(1100), 0.21); // total is 1100
$up = $this->getBuilder()->roundingMode(Money::EUR(2100), 0.21); // total is 2099

$this->assertSame(Money::ROUND_UP, $down);
$this->assertSame(Money::ROUND_HALF_UP, $equals);
$this->assertSame(Money::ROUND_DOWN, $up);
}

/**
* @return \Laravel\Cashier\SubscriptionBuilder\FirstPaymentSubscriptionBuilder
*/
protected function getBuilder()
{
return new FirstPaymentSubscriptionBuilder(
$this->user,
'default',
'monthly-10-1'
);
}

protected function withMockedGetMollieCustomer()
{
$this->mock(GetMollieCustomer::class, function ($mock) {
$customer = new Customer(new MollieApiClient);
$customer->id = 'cst_unique_customer_id';

return $mock->shouldReceive('execute')
->with('cst_unique_customer_id')
->once()
->andReturn($customer);
});
}

protected function withMockedGetMollieCustomerTwice()
{
$this->mock(GetMollieCustomer::class, function ($mock) {
$customer = new Customer(new MollieApiClient);
$customer->id = 'cst_unique_customer_id';

return $mock->shouldReceive('execute')
->with('cst_unique_customer_id')
->twice()
->andReturn($customer);
});
}

protected function withMockedCreateMolliePayment(): void
{
$this->mock(CreateMolliePayment::class, function ($mock) {
$payment = new Payment(new MollieApiClient);
$payment->id = 'tr_unique_payment_id';
$payment->_links = json_decode(json_encode([
'checkout' => [
'href' => 'https://foo-redirect-bar.com',
'type' => 'text/html',
],
]));

return $mock->shouldReceive('execute')->once()->andReturn($payment);
});
}
}

0 comments on commit 40f4884

Please sign in to comment.