CoreShop icon indicating copy to clipboard operation
CoreShop copied to clipboard

Rounding of prices (specifically for CHF) - AKA rappenrundung

Open dkarlovi opened this issue 2 years ago • 4 comments

Q A
Bug report? no
Feature request? yes
BC Break report? no
RFC? yes

When using CHF, there's a common usecase to round prices to the nearest 5 rappen (0.05 CHF), AKA "rappenrundung".

This means:

  1. you put in prices already rounded, like 6.75 CHF
  2. apply VAT of 7.7% to it, total comes out to 7.27 CHF (7,26975 CHF)
  3. because of rappenrundung, it should actually become 7.30 CHF
  4. VAT is calculated to be 0,55 CHF instead of 0,53 CHF

This is enough to round everything in the cart down to the nearest 0.05 CHF, since all the components are either pre-round or rounded by this procedure. See the equivalent here: https://github.com/brick/money#cash-rounding

The implementation is done by decorating the tax calculator factory which creates a decorated tax calculator which does the rounding. Rest of the places (cart, quote, order) already store the calculated values so they just use whatever the calculator said.

dkarlovi avatar Dec 14 '21 14:12 dkarlovi

Should we add that to the Core? Does your implementation work?

dpfaffenbauer avatar Mar 24 '22 08:03 dpfaffenbauer

It works, but I don't know if we could make it generic enough, a few things in how various totals are calculated would need to be revised first.

dkarlovi avatar Apr 25 '22 11:04 dkarlovi

@dkarlovi Is there any code of that decorated tax calculator that you could share here? I actually need exactly the same for one of my projects (Rappenrundung to nearest 0.05 CHF). Would be very helpful!

Futhermore, I think it would be a good idea to have a setting in the core for each currency, where you could choose the rounding, e.g. for CHF 0.05 / for EUR 0.01, etc.

aarongerig avatar Aug 19 '22 07:08 aarongerig

@aarongerig not really, it would need to be done on CS directly IMO.

dkarlovi avatar Aug 19 '22 11:08 dkarlovi

@dkarlovi, @aarongerig:

I'm doing this in almost any project with adjustments

<?php

namespace AppBundle\CoreShop\Processor;

use CoreShop\Component\Order\Factory\AdjustmentFactoryInterface;
use CoreShop\Component\Order\Model\CartInterface;
use CoreShop\Component\Order\Processor\CartProcessorInterface;

final class RoundingProcessor implements CartProcessorInterface
{
    public const ADJUSTMENT_NAME = '5-cent-rounding';

    private int $decimalFactor;
    private int $decimalPrecision;
    private AdjustmentFactoryInterface $adjustmentFactory;

    public function __construct(
        int $decimalFactor,
        int $decimalPrecision,
        AdjustmentFactoryInterface $adjustmentFactory
    ) {
        $this->decimalFactor = $decimalFactor;
        $this->decimalPrecision = $decimalPrecision;
        $this->adjustmentFactory = $adjustmentFactory;
    }

    public function process(CartInterface $cart)
    {
        $diffInclTax = $this->calculateRoundingDiff($cart->getTotal(true));

        if ($diffInclTax === 0) {
            return;
        }

        $cart->addAdjustment($this->adjustmentFactory->createWithData(self::ADJUSTMENT_NAME, '', $diffInclTax, 0));
    }

    private function calculateRoundingDiff(int $cartTotal)
    {
        // $this->decimalFactor = 2
        // $this->decimalPrecision = 100

        // define float total
        $cartTotalFloat = abs($cartTotal / $this->decimalFactor);

        // define rounded float total based on 5-cent-rule and use decimalPrecision in round()
        $cartTotalFloatRounded = round($cartTotalFloat / 5, $this->decimalPrecision) * 5;

        // transform total back to int
        // use round() to avoid result of 160399999
        $newCartTotal = (int) round(abs($cartTotalFloatRounded * $this->decimalFactor));

        return $newCartTotal - $cartTotal;
    }
}

Don't forget to clear your ajustments!

<?php

namespace AppBundle\CoreShop\Processor;

use CoreShop\Component\Order\Model\CartInterface;
use CoreShop\Component\Order\Processor\CartProcessorInterface;

final class AdjustmentClearer implements CartProcessorInterface
{
    /**
     * {@inheritdoc}
     */
    public function process(CartInterface $cart)
    {
        $cart->removeAdjustmentsRecursively(RoundingProcessor::ADJUSTMENT_NAME);
    }
}

    AppBundle\CoreShop\Processor\RoundingProcessor:
        arguments:
            - '%coreshop.currency.decimal_factor%'
            - '%coreshop.currency.decimal_precision%'
            - '@coreshop.factory.adjustment'
        tags:
            - { name: coreshop.cart_processor, priority: 320 }

    AppBundle\CoreShop\Processor\AdjustmentClearer:
        tags:
            - { name: coreshop.cart_processor, priority: 640 }

This will add or remove the required delta to the next 5 cent step.

Note: This will only affect the cart / pay total!

solverat avatar Nov 10 '22 12:11 solverat

Better use paymentTotal, that already is rounded to 2 decimals and can then be used for Rappenrundung

dpfaffenbauer avatar Nov 10 '22 13:11 dpfaffenbauer

That's not enough, you also need to present the "adjusted" total to the customer before he's going to confirm the order.

solverat avatar Nov 10 '22 14:11 solverat

payment total is processed before the order is commited, you just need to display it in the cart.

dpfaffenbauer avatar Nov 10 '22 14:11 dpfaffenbauer

Not in CS2, but anyway, it feels terrible wrong to completely ignore the cart total and also to store invalidated values which will fall back to you when it comes to other dependencies like custom reports and so on.

But of course that's up to you.

All the best!

solverat avatar Nov 10 '22 14:11 solverat

Why ignore? The rule is that the customer only pays in 5 cent steps, it doesn't say that the total must be in 5 cent steps.

dpfaffenbauer avatar Nov 10 '22 15:11 dpfaffenbauer

Ok. In our cases, it had to be that way.

Btw. I'm not sure if this is core related. Maybe that's something for a "coreshop cookbook" or something like that.

solverat avatar Nov 10 '22 15:11 solverat

It will not be a Core thing. Even though CoreShop is used a lot in Switzerland, we cannot make exceptions and small adjustments for every country in the Core. Maybe someone can create and maintain a Switzerland Bundle and that takes care of that.

dpfaffenbauer avatar Nov 11 '22 07:11 dpfaffenbauer