File: D:/HostingSpaces/SBogers10/shop.komma.nl/app/Checkout/CheckoutService.php
<?php
namespace App\Checkout;
use App\Addresses\AddressService;
use App\Addresses\Models\Address;
use App\Cart\ShoppingCartInterface;
use App\Cart\ShoppingCartItem;
use App\Discounts\DiscountService;
use App\Finance\RoundingService;
use App\Orders\Product\OrderedProduct;
use App\Payment\PaymentService;
use App\Session\HasSessionTrait;
use App\Cart\ShoppingCart;
use App\Orders\InvoiceNumberSequence;
use App\Orders\Kms\OrderService;
use App\Orders\Models\Order;
use App\Orders\OrderNumberSequence;
use App\Orders\OrderStatus;
use App\Products\Product\Product;
use App\Products\Product\ProductModelService;
use App\Products\ProductComposite\ProductCompositeModelService;
use App\Products\ProductGroup\ProductGroupModelService;
use App\ShippingCosts\ShippingCostsService;
use App\Users\SiteUserInterface;
use App\Users\SiteUserRole;
use App\Vat\Models\VatScenario;
use App\Vat\VatRateTotal;
use App\Vat\VatScenarioEnum;
use App\Vat\VatService;
use Illuminate\Database\Eloquent\Collection as DatabaseCollection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;
use Komma\KMS\Globalization\Language;
use Komma\KMS\Globalization\RegionInfo;
/**
* Class CheckoutService
*
* The checkout service sits between Order related, product related and payment related code.
* It directs those domains to work together.
*
* @package App\Checkout
*/
class CheckoutService
{
use HasSessionTrait;
private OrderService $orderService;
private ProductCompositeModelService $productCompositeService;
private PaymentService $paymentService;
private AddressService $addressService;
private OrderNumberSequence $orderNumberGenerator;
private ProductGroupModelService $productGroupService;
private ProductModelService $productService;
private VatService $vatService;
private ShippingCostsService $shippingCostService;
private InvoiceNumberSequence $invoiceNumberGenerator;
private ?Address $shippingAddress;
private ?Address $invoiceAddress;
private ?SiteUserInterface $user;
//The names of variables in this class to store and restore using a php session.
protected array $variablesForSession = [
'shippingAddress',
'invoiceAddress',
'user'
];
private string $siteUserClass;
private DiscountService $discountService;
public function __construct()
{
$this->discountService = new DiscountService();
$this->orderService = app(OrderService::class);
$this->productService = new ProductModelService();
$this->productGroupService = new ProductGroupModelService();
$this->productCompositeService = new ProductCompositeModelService();
$this->paymentService = new PaymentService();
$this->addressService = new AddressService();
$this->orderNumberGenerator = app(OrderNumberSequence::class);
$this->invoiceNumberGenerator = app(InvoiceNumberSequence::class);
$this->vatService = new VatService();
$this->shippingCostService = new ShippingCostsService();
$this->invoiceAddress = null;
$this->shippingAddress = null;
$this->user = null;
$this->siteUserClass = get_class(app(SiteUserInterface::class));
$this->restoreFromSession();
}
/**
* Initializes a payment request for the user that owns the order.
*
* @param Order $order
* @return RedirectResponse
*/
public function startPaymentForOrder(Order $order): RedirectResponse
{
$pspAdapter = $this->paymentService->getAdapter();
$transaction = $pspAdapter->createTransaction($order);
return $pspAdapter->redirectForPayment($transaction);
}
/**
* @param ShoppingCartInterface $shoppingCart
* @param SiteUserInterface $customer
* @param Address $addressForShipping
* @param Address $addressForInvoice
*
* @return Order|null
*/
public function createOrder(ShoppingCartInterface $shoppingCart, SiteUserInterface $customer, Address $addressForShipping, Address $addressForInvoice): ?Order {
$order = null;
\DB::transaction(function () use ($shoppingCart, $customer, $addressForShipping, $addressForInvoice, &$order) {
//Create the order, link customer and addresses to it and save it. We need to save it because the ordered productables need a order id
/** @var ShippingCostsService $shippingCostsService */
$shippingCostsService = app(ShippingCostsService::class);
/** @var Order $order */
$order = $this->orderService->newModel();
$order->status = OrderStatus::NEW;
$order->order_number = (string) $this->orderNumberGenerator->next();
$order->invoice_number = (string) $this->invoiceNumberGenerator->next();
$order->shipping_costs = 0;
$order->remarks = '';
//Attach the customer to the order and fill the order with address and customer details.
$order->customer()->associate($customer);
$this->fillOrderWithUserDetails($order, $customer);
$this->fillOrderWithInvoiceAddressFromAddress($order, $addressForInvoice);
$this->fillOrderWithShippingAddressFromAddress($order, $addressForShipping);
$order->save();
//Take the shipping costs into account.
$shippingCosts = $shippingCostsService->get($shoppingCart, $addressForShipping);
$this->vatService->calculateVatForModelWithVatScenarioEnum($shippingCosts);
$this->discountService->applyDiscountsTo($shoppingCart);
//Re-calculate the total inc and ex vat price of the shopping cart
$subtotalInc = 0;
$subtotalEx = 0;
foreach($shoppingCart->getItems() as $item) {
$this->discountService->applyDiscountsTo($item);
$this->vatService->calculateVatForModelWithVatScenarioEnum($item);
$subtotalInc += $item->getPriceInc();
$subtotalEx += $item->getPriceEx();
}
$cartPriceModifications = $shoppingCart->getDiscountPriceMutations();
$subtotalWithDiscountsEx = $subtotalEx;
$subtotalWithDiscountsInc = $subtotalInc;
foreach($cartPriceModifications as $cartPriceModification) {
$subtotalWithDiscountsEx += $cartPriceModification->getPriceEx();
$subtotalWithDiscountsInc += $cartPriceModification->getPriceInc();
}
//Create ordered productables and attach them to the order.
$this->createOrderedProductablesForOrder($order, $shoppingCart);
//Fill order model with financial data
$order->subtotal = RoundingService::Round($subtotalWithDiscountsInc); //Inc vat, inc discounts
$order->shipping_costs = RoundingService::Round($shippingCosts->getPriceInc());
$order->total = RoundingService::Round($order->subtotal + $order->shipping_costs); //including vat
//And finally save it
$order->save();
});
return $order;
}
/**
* @param Order $order
* @param ShoppingCartInterface $cart
*/
private function createOrderedProductablesForOrder(Order $order, ShoppingCartInterface $cart): void
{
//Loop over all shopping cart items and create ordered products for them. And link them to the order.
foreach ($cart->getItems() as $shoppingCartItem) {
/** @var ShoppingCartItem $shoppingCartItem */
$productable = $shoppingCartItem->getProductable();
switch (get_class($productable)) {
case Product::class:
/** @var Product $productable */
$this->productService->createOrderedProductFromProduct($productable, $order, $shoppingCartItem->getQuantity());
break;
// case ProductGroup::class:
// /** @var ProductGroup $productable */
// $this->productGroupService->createOrderedProductGroupFromProductGroup($productable, $order, $shoppingCartItem->getQuantity());
// break;
// case ProductComposite::class:
// /** @var ProductComposite $productable */
// $this->productCompositeService->createOrderedProductCompositeProductComposite($productable, $order, $shoppingCartItem->getQuantity());
// break;
default:
return;
}
};
}
/**
* @param Order $order
* @param Address $address
*/
private function fillOrderWithShippingAddressFromAddress(Order $order, Address $address): void
{
foreach($order->shippingAttributeNames as $index => $attributeName) {
$addressAttributeName = str_replace('shipping_', '', $attributeName);
$order[$attributeName] = $address->$addressAttributeName;
}
}
/**
* @param Order $order
* @param Address $address
*/
private function fillOrderWithInvoiceAddressFromAddress(Order $order, Address $address): void
{
foreach($order->invoiceAttributeNames as $index => $attributeName) {
$addressAttributeName = str_replace('invoice_', '', $attributeName);
$order[$attributeName] = $address->$addressAttributeName;
}
}
/**
* @param Order $order
* @param SiteUserInterface $customer
*/
private function fillOrderWithUserDetails(Order $order, SiteUserInterface $customer): void
{
foreach($order->userDetailAttributeNames as $attributeName) {
$order[$attributeName] = $customer->$attributeName;
}
}
/**
* A previously used user is a SiteUserInterface that was being used in an earlier session.
* That user will be returned if the given users e-mail matches and.
*
* @param SiteUserInterface $user
* @return SiteUserInterface|null
*/
public function previouslyUsedUser(SiteUserInterface $user): ? SiteUserInterface
{
/** @var SiteUserInterface|null $previouslyUsedUser */
if($previouslyUsedUser = $this->siteUserClass::where('email', '=', $user->email)->first()) {
return $previouslyUsedUser;
}
return null;
}
/**
* @param SiteUserInterface $prevUser
* @param SiteUserInterface $user
*
* @return SiteUserInterface|null
*/
public function updatePreviouslyUsedUserIfGuest(SiteUserInterface $prevUser, SiteUserInterface $user)
{
if(!$prevUser || !$prevUser->exists) throw new \RuntimeException('The user does not match a previously used guest user');
if($prevUser->is_guest) {
$prevUser->fill($user->getAttributes());
$prevUser->save();
}
return $prevUser;
}
/**
* Returns the last used shipping address for user or null if that not exists.
*
* @param SiteUserInterface $user
* @return Address
*/
public function getLastUsedShippingAddressForUser(SiteUserInterface $user): ? Address
{
$addressQuery = $user->addresses();
/** @var Order $latestOrder */
$latestOrder = $user->orders()->latest()->first();
if(!$latestOrder) return null;
$attributes = $latestOrder->getShippingAddressAttributes();
foreach ($attributes as $attribute => $value) $addressQuery->where(str_replace('shipping_', '', $attribute), '=', $value);
return $addressQuery->first();
}
/**
* Returns the last used invoice address for the user or null if that not exists
*
* @param SiteUserInterface $user
* @return Address
* @see Address
*/
public function getLastUsedInvoiceAddressForUser(SiteUserInterface $user): ? Address
{
$addressQuery = $user->addresses();
/** @var Order $latestOrder */
$latestOrder = $user->orders()->latest()->first();
if(!$latestOrder) return null;
$attributes = $latestOrder->getInvoiceAddressAttributes();
foreach ($attributes as $attribute => $value) $addressQuery->where(str_replace('invoice_', '', $attribute), '=', $value);
return $addressQuery->first();
}
/**
* Clears session variables values.
*/
public function clearCheckoutData(): void
{
$this->clearSession();
}
/**
* @return Address
*/
public function getShippingAddress(): ? Address
{
return $this->shippingAddress;
}
/**
* @param Address|null $shippingAddress
* @return CheckoutService
*/
public function setShippingAddress(Address $shippingAddress = null): CheckoutService
{
$this->shippingAddress = $shippingAddress;
$this->saveSession();
return $this;
}
/**
* @param Address|null $invoiceAddress
* @return CheckoutService
*/
public function setInvoiceAddress(Address $invoiceAddress = null): CheckoutService
{
$this->invoiceAddress = $invoiceAddress;
$this->saveSession();
return $this;
}
/**
* @return Address
*/
public function getInvoiceAddress(): ? Address
{
return $this->invoiceAddress;
}
/**
* Gets the user that checks out.
* @return SiteUserInterface|null
*/
public function getUser(): ? SiteUserInterface
{
return $this->user;
}
/**
* Get the user from the input.
*
* @return SiteUserInterface|null
*/
public function getUserFromInput(): ?SiteUserInterface
{
/** @var null|RegionInfo $usersRegionInfo */
$usersRegionInfo = RegionInfo::getWhere('threeLetterISORegionName', '=', request()->get('invoice_country'))->first();
/** @var Language $language */
$language = $usersRegionInfo->getLanguages()->first();
$culture = '';
if($language) $culture = mb_strtolower($language->getTwoLetterISOLanguageName()).'-';
$culture .= mb_strtoupper($usersRegionInfo->getName());
//Determine guest attributes
$attributes = [
'role' => SiteUserRole::Customer,
'email' => request('email'),
'password' => Hash::make(''),
'first_name' => request('first_name'),
'last_name_prefix' => request('last_name_prefix'),
'last_name' => request('last_name'),
'culture' => $culture,
'telephone' => request('phone'),
'is_guest' => 1,
];
return new $this->siteUserClass($attributes);
}
/**
* Use the form input to get a user that previously ordered if possible. Else we return null
*
* @return SiteUserInterface|null
*/
public function getPreviouslyUsedUser(): ? SiteUserInterface
{
//If we know his e-mail address we try to get his previously used guest user. If it does not exist, use the existing guest user.
if(request()->has('email') && (!$this->user || !$this->user->exists)) {
return $this->siteUserClass::where('email', '=', request()->get('email'))->first();
if($previouslyUsedUser) return $previouslyUsedUser;
}
return null;
}
/**
* Returns a collection of VatRateTotal models which hold information about vat totals by scenario
*
* @param ShoppingCartInterface $shoppingCart
*
* @return Collection|VatRateTotal[]
*/
public function getVatRateTotalsFromShoppingCart(ShoppingCartInterface $shoppingCart): Collection
{
//Group the shopping cart items by their vat scenario first. Throw an error when they don't have a vat_scenario_enum.
$vatRateTotals = collect($shoppingCart->getItems())->groupBy(function(ShoppingCartItem $item) {
if(is_null($item->getProductable()->vat_scenario_enum))
throw new \RuntimeException('Encountered a shopping cart item with productable of class "'.get_class($item->getProductable()).'" without a vat scenario');
return $item->getProductable()->vat_scenario_enum;
})
//Calculate their vat totals.
->map(function(Collection $shoppingCartItems, $vatEnumValue) {
$vatTotal = $shoppingCartItems->sum(function (ShoppingCartItem $shoppingCartItem) {
return $shoppingCartItem->getVatAmount();
});
//Then return a vatRateTotal model which carries the data
return (new VatRateTotal())
->setName(__('shop/vatrates.scenarios.'.$vatEnumValue.'.rate_name'))
->setVatScenarioEnum($vatEnumValue)
->setVatTotal($vatTotal);
});
return $vatRateTotals;
}
/**
* @param SiteUserInterface $user
* @return CheckoutService
*/
public function setUser(SiteUserInterface $user): CheckoutService
{
$this->user = $user;
$this->saveSession();
return $this;
}
/**
* @param Collection $vatRateTotals
* @return VatRateTotal|null
*/
public function getVatRateTotalHavingTheHighestVatPercentage(Collection $vatRateTotals):? VatRateTotal
{
//Get the vat amount from the shipping costs and add them to the highest vat amount
/** @var VatRateTotal|null $highestVatRateTotal */
$highestVatRateTotal = null;
$highestVatRatePercentage = 0;
$vatRateTotals->each(function (VatRateTotal $vatRateTotal) use(&$highestVatRateTotal, &$highestVatRatePercentage) {
$percentage = config('shop.vat.scenarios')[$vatRateTotal->getVatScenarioEnum()]['percentage'];
if($percentage > $highestVatRatePercentage) {
$highestVatRateTotal = $vatRateTotal;
$highestVatRatePercentage = $percentage;
}
});
return $highestVatRateTotal;
}
/**
* Returns a collection of VatRateTotal models which hold information about vat totals by scenario
*
* @param Order $order
*
* @return DatabaseCollection|VatRateTotal[]
*/
public function getVatRateTotalsFromOrder(Order $order): Collection
{
//Eager load stuff
$order->load('orderedProducts');
$vatRateTotals = $this->getVatRateTotalsFromOrderedProducts($order);
//TODO. Consider if we are going to use productGroups and productComposites too. If we do, create methods for them too.
//These vat rate totals then all may be merged and returned as one collection.
//Get the productable with highest vat percentage from the ordered productables. Remember that that ordered productable does has a vat_scenario_enum.
//We are going to need that in the step after this one.
$orderedProduct = $this->orderService->getOrderedProductWithHighestVatPercentage($order);
//TODO. Same remark as above. But we need to determine which of the ordered productables has the highest vat percentage.
//Get the vatRateTotal that belongs to the vat_scenario_enum that has the highest vat percentage in the ordered productables.
$highestVatRateTotal = $vatRateTotals->get($orderedProduct->vat_scenario_enum);
//Add the vat amount of the shipping costs to the vat amount of the VatRateTotal that has the highest vat percentage.
if($highestVatRateTotal) {
/** @var VatRateTotal $highestVatRateTotal */
$highestVatRateTotal->setVatTotal(RoundingService::RoundVat($highestVatRateTotal->getVatTotal() + $this->vatService->calculateVatRateAmountFromIncAmount($order->shipping_costs)));
}
return $vatRateTotals;
}
/**
* Uses the ordered products vat_scenario_enums to build vatRateTotals.
*
* First it uses the vat_scenario_enum of orderedProducts to get the appropriate vatScenario.
* Then it overrides the percentage of the vatScenario with the one of an ordered product.
*
* Then it creates vatRateTotals, keyed / indexed by vat_scenario_enum. That get filled with the vatAmount
* for each orderedProduct using its vatScenario that was determined earlier.
*
* The collection you'll get is keyed / indexed by vatScenerioEnum. Each value will be a vatRateTotal
*
* @param Order $order
* @return Collection
*/
private function getVatRateTotalsFromOrderedProducts(Order $order): Collection
{
$vatService = $this->vatService;
//Group the shopping cart items by their vat scenario first. Throw an error when they don't have a vat_scenario_enum.
$orderedProducts = $order->orderedProducts->groupBy('vat_scenario_enum')
//Calculate their vat totals.
->mapWithKeys(function( $orderedProducts, $vatScenarioEnum) use($vatService, $order) {
//Sum the vat amounts for each ordered product.
$highestVatPercentage = null;
//Get the vatScenario, based on the vat scenario enum. And override the vat percentage with the one from the ordered.
/** @var VatScenario $vatScenario */
$vatScenario = $vatService->getAllVatScenarios()->where('enumValue', $vatScenarioEnum)->first();
if(!$vatScenario) throw new \RuntimeException('The vat_scenario_enum value of ordered products from order with id' . $order->id.'" was not a valid one. Make sure it is one of the '.VatScenarioEnum::class.' values');
$vatRateTotal = $orderedProducts->sum(function (OrderedProduct $orderedProduct) use($vatService, &$allEncounteredVatScenarios, $vatScenario) {
//Use the vatScenario to fill the orderedProduct with vat information. But use the vat_percentage from the ordered product.
$vatScenario->percentage = $orderedProduct->vat_percentage;
$vatService->calculateVatForModelUsingVatScenario($orderedProduct, $vatScenario);
//Return the vatAmount in cents
return $orderedProduct->getVatAmount();
});
//Then return a vatRateTotal model which carries the data
$vatRateTotal = (new VatRateTotal())
->setName(__('shop/vatrates.scenarios.'.$vatScenarioEnum.'.rate_name'))
->setVatScenarioEnum($vatScenarioEnum)
->setVatTotal($vatRateTotal);
return [$vatScenarioEnum => $vatRateTotal];
});
return $orderedProducts;
}
}