enea/laravel-cashier

Simple package for product management

v3.1.0 2021-03-01 02:46 UTC

README

Build Status Scrutinizer Code Quality Software License

This package provides a functionality to manage the sale of products and abstracts all the calculations you need for a sale.

// create a shopping cart
$document = Invoice::create()->using([Taxes::IVA]);
$shoppingCart = ShoppingManager::initialize($client, $document);

// add a global discount
$discount = Discount::percentage(15)->setCode('PROMOTIONAL');
$shoppingCart->addDiscount($discount);

// add products
$keyboard = $shoppingCart->push(Product::find(1), 5);
$keyboard->addDiscount(Discount::percentage(8)->setCode('ONLY-TODAY'));

$backpack = $shoppingCart->push(Product::find(2));
$backpack->setQuantity(10);

// get totals
$shoppingCart->getSubtotal();
$shoppingCart->getTotalDiscounts();
$shoppingCart->getTotalTaxes();
$shoppingCart->getTotal();

Installation

Laravel Cashier requires PHP 7.4. This version supports Laravel 7

To get the latest version, simply require the project using Composer:

$ composer require enea/laravel-cashier

And publish the configuration file.

$ php artisan vendor:publish --provider='Enea\Cashier\CashierServiceProvider'

Class reference

This table defines the implementation of the models necessary for the operation of the package, there are some models that come to help such as: $discount and $document, however it is recommended to replace these models with your own.

Concrete Abstract Description
$client Enea\Cashier\Contracts\BuyerContract person who makes the purchase
$document Enea\Cashier\Contracts\DocumentContract type of sale document
$product Enea\Cashier\Contracts\ProductContract product being sold
$quote Enea\Cashier\Contracts\QuoteContract quote available for sale
$quotedProduct Enea\Cashier\Contracts\QuotedProductContract quoted product to sell
$discount Enea\Cashier\Modifiers\DiscountContract representation of a discount
$tax Enea\Cashier\Modifiers\TaxContract representation of a tax

Usage

To start a purchase you must use the ShoppingManager::initialize($client, $document).

use App\Client;
use Enea\Cashier\Documents\Invoice;
use Enea\Cashier\Facades\ShoppingManager;

$document = Invoice::create()->using([Taxes::IVA]); // or use your own model
$shoppingCart = ShoppingManager::initialize(Client::find(10), $document);

When you initialize a shopping cart, a token is generated so that it can be searched from ShoppingManager::find($token). This function gets the shopping cart from session.

$token = $shoppingCart->getGeneratedToken();
$shoppingCart = ShoppingManager::find($token); // returns the shopping cart that matches the token

It is also possible that you want to invoice a quote, and to do so you must call the attach function of the $shoppingCart. Doing this creates a QuoteManager instance inside the Shopping Cart, which can be accessed from the $shoppingCart->getQuoteManager() function.

$shoppingCart->attach($quote);

Now you just have to add products to the shopping cart using $shoppingCart->push($product, $quantity).

$keyboard = Product::query()->where('description', 'Keyboard K530-rgb')->firstOrFail();
$productCartItem = $shoppingCart->push($keyboard, 4);

Or you can also pull products from the quote using $shoppingCart->pull($productID).

$productCartItem = $shoppingCart->pull($productID);

The push and pull methods returns an instance of ProductCartItem, which provides a lot of useful method.

// set product quantity
$productCartItem->setQuantity(10);

// configure custom properties
$productCartItem->setProperty(['key' => 'value']);
$productCartItem->putProperty('key', 'value');
$productCartItem->removeProperty('key');

// manage discounts
$productCartItem->addDiscounts($discounts);
$productCartItem->addDiscount($discount);
$productCartItem->getDiscount($discountCode);
$productCartItem->removeDiscount($discountCode);

// get the totals
$cashier = $productCartItem->getCashier();
$cashier->getUnitPrice();
$cashier->getGrossUnitPrice();
$cashier->getNetUnitPrice();
$cashier->getQuantity();
$cashier->getSubtotal();
$cashier->getTotalDiscounts();
$cashier->getTotalTaxes();
$cashier->getTotal();

Example

For this example, we are going to simulate a simple purchase. We need a $client and we will use a $invoice with IVA as a sales document.

class ShoppingCartController extends Controller
{
  public function start(Client $client): JsonResponse
  {
    $shoppingCart = ShoppingManager::initialize($client);        
    $shoppingCart->setDocument(Invoice::create()->using([Taxes::IGV])); 

    return response()->json([
      'token' => $shoppingCart->getGeneratedToken(),
      'shoppingCart' => $shoppingCart->toArray()
    ]);
  }

  public function addGlobalDiscount(Discount $discount, Request $request): JsonResponse
  {
    $shoppingCart = ShoppingManager::find($request->header('CART-TOKEN'));
    $shoppingCart->addDiscount($discount);

    return response()->json(compact('shoppingCart'));
  }

  public function removeGlobalDiscount(Discount $discount, Request $request): JsonResponse
  {
    $shoppingCart = ShoppingManager::find($request->header('CART-TOKEN'));
    $shoppingCart->removeDiscount($discount->getDiscountCode());

    return response()->json(compact('shoppingCart'));
  }
}

Now we add a controller to manage shopping cart products.

class ProductManagerController extends Controller
{
  public function addProduct(Product $product, Request $request): JsonResponse
  {
    $shoppingCart = ShoppingManager::find($request->header('CART-TOKEN'));
    $added = $shoppingCart->push($product, $request->get('quantity'));

    return response()->json(compact('shoppingCart', 'added'));
  }

  public function removeProduct(string $productID, Request $request): JsonResponse
  {
    $shoppingCart = ShoppingManager::find($request->header('CART-TOKEN'));
    $shoppingCart->remove($productID);

    return response()->json(compact('shoppingCart'));
  }

  public function updateProductQuantity(string $productID, Request $request): JsonResponse
  {
    $shoppingCart = ShoppingManager::find($request->header('CART-TOKEN'));
    $product = $shoppingCart->find($productID);
    $product->setQuantity($request->get('quantity'));

    return response()->json(compact('shoppingCart', 'product'));
  }

  public function addDiscountToProduct(string $productID, Discount $discount, Request $request): JsonResponse
  {
    $shoppingCart = ShoppingManager::find($request->header('CART-TOKEN'));
    $product = $shoppingCart->find($productID);
    $product->addDiscount($discount);

    return response()->json(compact('shoppingCart'));
  }  

  public function removeDiscountToProduct(string $productID, Discount $discount, Request $request): JsonResponse
  {
    $shoppingCart = ShoppingManager::find($request->header('CART-TOKEN'));
    $product = $shoppingCart->find($productID);
    $product->removeDiscount($discount);

    return response()->json(compact('shoppingCart'));
  }
}

And to finish we save the document in the database.

class PurchaseController extends Controller
{
  public function store(Request $request): JsonResponse
  {
    $shoppingCart = ShoppingManager::find($request->header('CART-TOKEN'));
    $products = $shoppingCart->collection()->map($this->toOrderProduct());

    $order = DB::Transaction($this->createOrder($shoppingCart, $products));
    $dropped = $this->destroyShoppingCart($shoppingCart->getGeneratedToken());

    return response()->json(compact('order', 'dropped'));
  }

  private function createOrder(ShoppingCart $cart, Collection $products): Closure
  {
    return function() use ($cart, $products): Order {
      $order = Order::create([
        // complete the structure of your model
        'subtotal' => $cart->getSubtotal(),
        'total' => $cart->getTotal(),
        'document_id'd => $cart->getDocument()->getUniqueIdentificationKey(),
      ]);                
      $order->detail()->saveMany($products);    
      return $order;
    };            
  }

  private function toOrderProduct(): Closure
  {
    return fn(ProductCartItem $product) => new OrderProduct([
      'product_id' => $product->getUniqueIdentificationKey(),
      'quantity' => $product->getQuantity(),
      'unit_price' => $product->getCashier()->getUnitPrice(),
      'discount' => $product->getCashier()->getTotalDiscounts(),
      'iva_pct' =>  $product->getTax('IVA')->getPercentage(),
    ]);
  }

  private function destroyShoppingCart(string $token): bool
  {
    ShoppingManager::drop($token);
    return !ShoppingManager::has($token);
  }
}

Cashier

It is responsible for centralizing the calculations to get taxes, discounts and totals for each product. you can find it in Enea\Cashier\Calculations\Cashier.

Method Description Return
getUnitPrice() unit sales price float
getGrossUnitPrice() gross price (price without tax) float
getNetUnitPrice() net price (price + taxes) float
getSubtotal() subtotal float
getTotalDiscounts() total discounts float
getTotalTaxes() total taxes float
getTotal() final total with discounts and taxes float
getTaxes() all taxes grouped by name Taxed[]
getTax(string $name) tax by name Taxed
getDiscounts() all discounts grouped by code Discounted[]
getDiscount(string $code) discount by code Discounted

Pricing

Cashier separates the prices into 3, getGrossUnitPrice(), getNetUnitPrice() and getUnitPrice(), where the latter is the unit price after evaluating taxes, both included and excluded. $cashier->getUnitPrice() is the function used for all calculations. You can see an example in code from Enea\Tests\Calculations\PriceTest

Method getGrossUnitPrice() getNetUnitPrice() getUnitPrice()
Base 100.00 $USD 100.00 $USD 100.00 $USD
Included Taxes IVA(12%), AnotherTax(11%) IVA(12%), AnotherTax(11%) IVA(12%), AnotherTax(11%)
Tax to use IVA(12%) IVA(12%) IVA(12%)
Applied - IVA and AnotherTax IVA
Total 81.30 $USD 100 $USD 90.24 $USD

Configuration

There are a few things you need to know to set up taxes and discounts correctly.

  • Enea\Cashier\Modifiers\DiscountContract

    Represents an applicable discount. There is quite a functional helper implementation in Enea\Cashier\Modifiers\Discount so it is not totally necessary to assign your own model, unless you want full control over the discount codes.

    namespace Enea\Cashier\Modifiers;
    
    use Enea\Cashier\Calculations\Percentager;
    use Enea\Cashier\Modifiers\DiscountContract;
    
    class Discount implements DiscountContract
    {
        public function getDiscountCode(): string
        {
            return $this->code;
        }
    
        public function getDescription(): string
        {
            return $this->description;
        }
    
        public function extract(float $total): float
        {
            if (! $this->percentage) {
                return $this->discount;
            }
    				// logic to calculate a percentage discount
            return Percentager::excluded($total, $this->discount)->calculate();
        }
    }
  • Enea\Cashier\Contracts\DocumentContract

    Represents the type of document with which the sale will be made and also defines the taxes that will be applied to the products.

    namespace App\Models;
    
    use Enea\Cashier\Taxes;
    use Enea\Cashier\Contracts\DocumentContract;
    use Illuminate\Database\Eloquent\Model;
    
    class Document extends Model implements DocumentContract
    {
        public function taxesToUse(): array
        {
          	// some logic
            return [
              Taxes::IGV, // tax name
            ];
        }
    }
  • Enea\Cashier\Modifiers\TaxContract

    Represents the tax on the product, the package has a help implementation which can be found in Enea\Cashier\Modifiers\Tax

    namespace App\Models;
    
    use Enea\Cashier\Contracts\ProductContract;
    use Enea\Cashier\Modifiers\Tax;
    use Enea\Cashier\Taxes;
    
    class Product extends Model implements ProductContract
    {
        public function getUnitPrice(): float
        {
            return $this->sale_price;
        }
    
        public function getShortDescription(): string
        {
            return $this->short_description;
        }
    
        public function getTaxes(): array
        {
            return [
                Tax::included(Taxes::IGV, $this->igv_pct),
            ];
        }
    }

    To use taxes it is necessary to understand that they can be configured in 2 ways, included and excluded

    Type INCLUDED EXCLUDED
    Unit Price 100.00 $USD 100.00 $USD
    Tax % 10% 10%
    Total Tax 9.09 $USD 10.00 $USD
    Net Price 100.00 $USD 110.00 $USD

More documentation

You can find a lot of comments within the source code as well as the tests located in the tests directory.