mollie / laravel-cashier-mollie

Official Mollie integration for Laravel Cashier
https://www.cashiermollie.com/
MIT License
135 stars 44 forks source link

Coupons in database have to store discount value as decimal #258

Closed evertjanMlbrgn closed 1 month ago

evertjanMlbrgn commented 1 month ago

I've create a custom CouponRepository that stores coupons in a database.

like others have mentioned, there is no CouponRepository interface, but i managed to implement it. It would be nice to have an interface though.

Now for the real question. I've notice that somehow i have to save the discount value (the discount amount) in decimal notation in my database, otherwise the discount will be way too height. :-)

I suspect this is because I use the FixedDiscountHandler provided by Laravel Cashier Mollie and it expects decimal notation, and usually coupon discount values are written in decimal notation in the cashier_coupons.php config file. I find it a bit hard to test however since the orders are generated on the background and i'm not sure how to debug there.

My DatabaseCouponRepository code:

<?php

namespace App\Cashier;

use App\Models\Coupon;
use Exception;
use Laravel\Cashier\Coupon\Contracts\CouponRepository;
use Laravel\Cashier\Coupon\Coupon as CashierCoupon;
use Laravel\Cashier\Coupon\FixedDiscountHandler;
use Laravel\Cashier\Coupon\PercentageDiscountHandler;
use Laravel\Cashier\Exceptions\CouponNotFoundException;

class DatabaseCouponRepository implements CouponRepository
{

    public function find(string $coupon): ?CashierCoupon
    {
        $couponModel = Coupon::where('coupon_code', $coupon)->first();

        if (!$couponModel) {
            return null;
        }

        $handler = match ($couponModel->coupon_type) {
            'fixed' => new FixedDiscountHandler(),
            'percentage' => new PercentageDiscountHandler(),
            default => throw new Exception('Invalid coupon type')
        };

        return new CashierCoupon($coupon, $handler, $couponModel->coupon_context);
    }

    public function findOrFail(string $coupon)
    {
        $result = $this->find($coupon);
        throw_if(is_null($result), CouponNotFoundException::class);

        return $result;
    }
}

My Coupon model

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Coupon extends Model
{
    use HasFactory;

    protected $table = 'coupons';
    protected $fillable = [
        'coupon_code',
        'coupon_description',
        'coupon_type',
        'coupon_discount_percentage',
        'coupon_discount_value',
        'coupon_discount_currency',
        'coupon_context',
    ];

    protected $casts = [
        'coupon_context' => 'array',
    ];

}

I have registered the custom DatabaseCouponHandler in my AppServiceProvider and that works fine

 public function register(): void
    {
        // Set the locale to Dutch
        Carbon::setLocale('nl');

        // get subscription plans from database table plans
        $this->app->singleton(PlanRepository::class, DatabasePlanRepository::class);
        $this->app->singleton(CouponRepository::class, DatabaseCouponRepository::class);
    }
Naoray commented 1 month ago

Hi @evertjanMlbrgn,

thanks for your issue!

like others have mentioned, there is no CouponRepository interface, but i managed to implement it. It would be nice to have an interface though.

I am not sure what you mean by that. As shown in your code snippet, you are using the provided interface Laravel\Cashier\Coupon\Contracts\CouponRepository.

I've notice that somehow i have to save the discount value (the discount amount) in decimal notation in my database, otherwise the discount will be way too height. :-)

Let me explain the workflow of the coupon feature.

First the user redeems the coupon, by inserting it in some input and you calling the code that ultimately triggers RedeemedCoupon::record($coupon, $subscription).

// for new subscription
$user->newSubscription($name, $plan)
  ->withCoupon('your-coupon-code')
  ->create();

// for active subscription
$user->redeemCoupon('your-coupon-code');

Next, cashier:run is executed allOrderItemsthat are due (processed_at < now() and order_id === null) are queried, grouped for each user and currency and then passed into theOrder::createFromItems()method. There we call the$items->preprocess()method, which triggers the execution of all preprocessors defined incashier_plans.defaults.order_item_preprocessorsviaSubscription::preprocessOrderItem()`.

The CouponOrderItemPreprocessor is responsible for checking if an active ReedemCoupon was associated with the active subscription (essentially if owner_type equals Subscription::class and owner_id matches the active subscription id. When this is the case the CouponOrderItemPreprocessor will add a new OrderItem to the collection of items which will be added to the current order. The resulting OrderItem has a negative unit_price and is calculated via the FixedDiscountHandler@unitPrice(), which passes the coupon's discount value from the context inside the mollie_array_to_money() which looks like the following.

function mollie_array_to_money(array $array)
{
    return decimal_to_money($array['value'], $array['currency']);
}

And now we are finally at the point where you can see that you don't necessarily have to store the coupon value as decimal inside your DB. You just have to make sure to convert the value from the DB to a decimal value before calling new CashierCoupon($coupon, $handler, $couponModel->coupon_context);.

tl;dr; Like the rest of the prices in the cashier_plan the coupon functionality relies on the provided amount to be in decimal notation. ou can store the value in any format, but ensure it’s converted to decimal notation before passing it to the Coupon class within the $context array.

evertjanMlbrgn commented 1 month ago

Thanks for the great explanation, it works.