qty] or [rowId => qty]. * @return object * @throws \Illuminate\Validation\ValidationException If a product from the basket is not accessible anymore. */ public static function updateBasketAmounts(object $basket, array $newQuantities): object { $newQuantities = \collect($newQuantities); // ! Ici on veux réutiliser le cart calculator. $cart = self::createVirtualCart(\collect($basket) ->map(function (object $basketItem) { $cartItem = self::basketItemObjectToCartItem($basketItem); if (Product::query()->where('id', $cartItem->id)->exists()) { $cartItem->associate(Product::query()->where('id', $cartItem->id)->first()); } return $cartItem; }) // * Enlever les produits qui ne seront pas livrés. ->filter(function (CartItem $basketItem) { return $basketItem->qty; })); // * Update virtual basket with actual content \collect($basket)->each(function (object $basketItem) use ($newQuantities, $cart) { /** @var \Gloudemans\Shoppingcart\CartItem $cartItem */ $cartItem = $cart->search(function (CartItem $cartItem) use ($basketItem) { return $cartItem->id === $basketItem->id; })->first(); $newQty = \intval( $newQuantities->get($basketItem->id, $newQuantities->get($basketItem->rowId)) ); // * Update Qty or remove item from the cart. if (!$newQty) { $cart->remove($cartItem->rowId); return; } $cartItem->setQuantity($newQty); // ! https://github.com/bumbummen99/LaravelShoppingcart/pull/181 // * Note Lib is is actually refactoring for laravel 9 php8 only // * https://github.com/bumbummen99/LaravelShoppingcart/pull/145 // ! These lines are required to prevent amount bug. $cartItem->setTaxRate($basketItem->options->tax_rate / 100); $cartItem->setDiscountRate($basketItem->options->discount_rate); }); $virtualOrder = new Order(); $virtualOrder->assignBasket($cart); return (object)$virtualOrder->basket; } /** * Crete a virtual cart to use calculator * * @param \Illuminate\Support\Collection $cartItemList * @return \Gloudemans\Shoppingcart\Cart */ public static function createVirtualCart(Collection $cartItemList): Cart { // * Get temp cart instance. /** @var \Gloudemans\Shoppingcart\Cart */ $basket = app('basket'); $virtualCart = $basket->instance(\strval(\uniqid('_temp', true))); $cartItemList->each(function (CartItem $cartItem) use ($virtualCart) { if (!($cartItem instanceof CartItem)) { throw new \RuntimeException('Cart item shall be a ' . CartItem::class); } $virtualCart->addCartItem($cartItem); return $cartItem; }); return $virtualCart; } /** * Parse a basket item from DB to and actual BasketItem * * @param object $basketItem This should actually come from DB. * @return \Gloudemans\Shoppingcart\CartItem * @throws \RuntimeException If basketItem format is not valid. */ public static function basketItemObjectToCartItem(object $basketItem): CartItem { if (!self::basketObjectIsValid($basketItem)) { throw new \RuntimeException('Cannot validate basket item'); } $cartItem = CartItem::fromArray(\json_decode(\json_encode($basketItem), true)); $cartItem->setQuantity($basketItem->qty); // ! https://github.com/bumbummen99/LaravelShoppingcart/pull/181 // * Note Lib is is actually refactoring for laravel 9 php8 only // * https://github.com/bumbummen99/LaravelShoppingcart/pull/145 // ! These lines are required to prevent amount bug. $cartItem->setTaxRate($basketItem->options->tax_rate / 100); $cartItem->setDiscountRate($basketItem->options->discount_rate); return $cartItem; } /** * Get basket as product list * * @param \Gloudemans\Shoppingcart\Cart $basket * @return \Illuminate\Support\Collection */ public static function cartToList(Cart $basket): Collection { return $basket->content()->map(function (CartItem $cartItem) { return \json_decode(\json_encode($cartItem->toArray())); }); } /** * Validate Basket object * * ! This validation format should be equals to whats in * index.d.ts bItem and also into Fo/BasketController::addToBasket * * ! This does report validation errors. * * @param object $basket * @return boolean */ public static function basketObjectIsValid(object $basket): bool { $validator = Validator::make(\json_decode(\json_encode($basket), true), [ 'rowId' => 'required|regex:/^[0-9a-z]{32}$/', 'id' => 'required|integer|min:1', 'name' => 'required|string|min:1|max:255', 'qty' => 'required|integer|min:1', 'price' => 'required|integer|min:1', 'weight' => 'required|integer|min:0', 'options.reference' => 'required|string|max:255', 'options.slug' => 'required|string|min:1|max:255', 'options.picture' => 'nullable|string|min:1|max:255', 'options.sell_unit' => 'required|string|min:1|max:255', 'options.weight_with_unit' => 'required|string|min:1|max:255', 'options.discount_type' => 'nullable|in:' . \collect(DiscountType::toValues())->implode(','), 'options.discount_rate' => 'required|numeric|min:0', 'options.price_ttc' => 'required|numeric|min:1', 'options.undiscounted_price_ttc' => 'required|numeric|min:1', 'options.tax_rate' => [ 'required', 'numeric', Rule::in(\collect(TaxRate::toArray()) ->map(function (TaxRate $taxRate) { return $taxRate->rate; })) ], 'discount' => 'required|integer|min:0', 'tax' => 'required|numeric|min:0', 'subtotal' => 'required|numeric|min:1' ]); try { $validator->validate(); return true; } catch (ValidationException $e) { \report(new \RuntimeException('Could not validate basket object ' . \implode(',', collect($e->errors())->flatten()->all()))); return false; } } /** * Get displayable price value * * @param mixed $value Amount in cents. * @param string $suffix Append some text. * @return string */ public static function fmtCurrency($value, string $suffix = 'TTC'): string { return \trim(sprintf( '%s %s', app('phpMoneyFormatter')->format(new Money(\intval($value), app('phpMoneyCurrency'))), $suffix )); } /** * Get displayable price value * * @param mixed $value * @return string */ public static function fmtSellUnit($value): string { return !empty($value) ? "/ {$value}" : ''; } /** * Generate an unique reference from model on reference column by default * * * The pattern is YYYYMMDDXXXXXXXXXX where X is 10 random numbers. * * @param string $modelClass The model to use for reference. * @param integer $recursiveIncrement Used for internal only. * @param string $column The column name where are stored references. * @return string * @throws \RuntimeException If $modelClass does not extends \Illuminate\Database\Eloquent\Model. */ public static function getUniqueReference( string $modelClass, int $recursiveIncrement = 0, string $column = 'reference' ): string { if (!\is_subclass_of($modelClass, Model::class)) { throw new \RuntimeException("{$modelClass} have to extend " . Model::class); } /** @var \Illuminate\Database\Eloquent\Model */ $model = new $modelClass(); $counter = $model::query()->whereDate('created_at', Carbon::today())->count() + 1 + $recursiveIncrement; $reference = Carbon::now()->isoFormat('YYYYMMDD') . str_pad(crc32(uniqid('', true)), 10, '0', STR_PAD_LEFT); if ($model::query()->where($column, $reference)->exists()) { // * Recursive counter to determinate reference unicity. return self::getUniqueReference($model, $counter + 1); } return $reference; } /** * Strip scheme (https) on oembed urls * Only embed allowed are for MP4 self hosted videos right now * * https is force by ckeditor MediaElements which natively supports * only cloud hosted medias (youtube, spotify etc..) * * @param string $input * @return string */ public static function stripCkEditorSchemesOnMediaInputs(string $input): string { $input = \preg_replace('/(?<=)/m', '', $input); return $input; } /** * Assert That no Back Office url is present on txt * * @param string $field * @param string $text * @return string * @throws \Illuminate\Validation\ValidationException If url is a bo url. */ public static function assertNoInternalBoUrls(string $field, string $text): string { $routes = Route::getRoutes(); // * Url replace and email replace. $text = preg_replace_callback( // phpcs:ignore Generic.Files.LineLength.TooLong '/(?P(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/', function ($matches) use ($routes, $field) { $match = collect($matches)->first() ?? ''; $url = $match; try { $request = Request::create($url); // If a route is match then it do not throws exception. if ( \parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) && trim($routes->match($request)->getPrefix() ?? '', '/') === 'bo' ) { // Removes the matched url. throw ValidationException::withMessages([ $field => ':attribute : Il est interdit de coller une url du back office dans ce champ' ]); } return $match; } catch (NotFoundHttpException $e) { return $match; } }, $text ); return $text; } /** * Assert That only external url is present on txt * * @param string $field * @param string $text * @return string * @throws \Illuminate\Validation\ValidationException If url is a bo url. */ public static function assertNoInternalUrls(string $field, string $text): string { self::assertNoInternalBoUrls($field, $text); $routes = Route::getRoutes(); // * Url replace and email replace. $text = preg_replace_callback( // phpcs:ignore Generic.Files.LineLength.TooLong '/(?P(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/', function ($matches) use ($routes, $field) { $match = collect($matches)->first() ?? ''; $url = $match; try { $request = Request::create($url); // If a route is match then it do not throws exception. if ( \parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) && $routes->match($request) ) { // Removes the matched url. throw ValidationException::withMessages([ $field => sprintf( ':attribute : Il est interdit de coller une url de %s dans ce champ', config('app.name') ) ]); } return $match; } catch (NotFoundHttpException $e) { return $match; } }, $text ); return $text; } /** * Mutltibyte string replace * @param mixed $search * @param mixed $replace * @param mixed $subject * @param integer $count * @return mixed */ public static function mbReplace($search, $replace, $subject, int &$count = 0) { if (!is_array($search) && is_array($replace)) { return false; } if (is_array($subject)) { // Call mb_replace for each single string in $subject . foreach ($subject as &$string) { $string = &self::mbReplace($search, $replace, $string, $count); } } elseif (is_array($search)) { if (!is_array($replace)) { foreach ($search as &$string) { $subject = self::mbReplace($string, $replace, $subject, $count); } } else { $n = max(count($search), count($replace)); while ($n--) { $subject = self::mbReplace(current($search), current($replace), $subject, $count); next($search); next($replace); } } } else { $parts = mb_split(preg_quote($search), $subject); $count = count($parts) - 1; $subject = implode($replace, $parts); } //end if return $subject; } /** * Asserts that the field is unique * * @param string $table * @param string $field * @param mixed $value * @param integer|null $id * @param callable|null $query * @return void * @throws \Illuminate\Validation\ValidationException If the field is not unique. */ public static function assertFieldIsUnique( string $table, string $field, $value, ?int $id = null, ?callable $query = null ) { if (!$query) { Validator::make([$field => $value], [ self::mbReplace('.', '\.', $field) => Rule::unique($table, $field)->ignore($id), ], [ self::mbReplace('.', '\.', "{$field}.unique") => trans('La valeur :value pour le champ :attribute est déjà utilisé', [ 'attribute' => $field, 'value' => $value ]) ])->validate(); return; } Validator::make([$field => $value], [ $field => Rule::unique($table)->where($query)->ignore($id), ], [ $field . '.unique' => trans('La valeur pour le champ :attribute est déjà utilisé', [ 'attribute' => $field ]) ])->validate(); } /** * Generate a unique reference * * @return string */ public static function getUniqueProductReference(): string { \mt_srand(); $ref = Str::padLeft(\mt_rand(1, 99999999), 8, '0'); if (Product::where('reference', $ref)->count()) { return self::getUniqueProductReference(); } return $ref; } /** * Convert deeply an object to array * * @param array|object $input * @return array * @throws \RuntimeException If $input var is a scalar value. */ public static function objectToArray($input): array { // Only process if it's an object or array being passed to the function. if (is_object($input) or is_array($input)) { $ret = (array)$input; foreach ($ret as &$item) { // Recursively process EACH element only if not scalar. $item = (is_object($item) or is_array($item)) ? self::objectToArray($item) : $item; } return $ret; } else { throw new \RuntimeException('cannot handle $input of type ' . \gettype($input)); } } /** * Convert deeply an array to object * * @param array|object $input * @return object * @throws \RuntimeException If $input var is a scalar value. */ public static function arrayToObject($input): object { // Only process if it's an object or array being passed to the function. if (is_object($input) or is_array($input)) { $ret = (object)$input; foreach ($ret as &$item) { // Recursively process EACH element only if not scalar. $item = (is_object($item) or is_array($item)) ? self::arrayToObject($item) : $item; } return $ret; } else { throw new \RuntimeException('cannot handle $input of type ' . \gettype($input)); } } /** * Validate recaptcha * * @param \Illuminate\Http\Request $request * @param boolean $isV3 * @return void */ public static function validateRecaptcha(Request $request, bool $isV3 = true) { Validator::validate($request->all(), [ 'g-recaptcha-response' => [ 'required', 'string', // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed function ($attribute, $value, $fail) use ($isV3) { try { $response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [ 'secret' => config('services.google.recaptcha.private'), 'response' => \strval($value), 'remoteip' => app('clientIp') ]); if ($e = $response->toException()) { throw $e; } if ($isV3) { if ( !$response->json('success') or !$response->json('challenge_ts') or Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or $response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST) or !$response->json('score') ) { throw new \RuntimeException(); } if (\floatval($response->json('score')) < 0.8) { $fail("Vous n'avez pas obtenu un score assez élevé lors de la validation du recaptcha"); } return; } if ( !$response->json('success') or !$response->json('challenge_ts') or Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or $response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST) ) { throw new \RuntimeException(); } } catch (\Exception $e) { $fail('Erreur lors de la validation du formulaire veuillez réssayer'); } //end try } ] ], [], ['g-recaptcha-response' => 'reCaptcha']); } /** * Generate Invoice * * @param \App\Models\Shop\Order $order * @param \Illuminate\Support\Carbon $date * @param string $title * @param string $documentType * @param object $items * @param string $transactionReference * @return \Mccarlosen\LaravelMpdf\LaravelMpdf */ public static function generateInvoice( Order $order, Carbon $date, string $title, string $documentType, object $items, string $transactionReference ): LaravelMpdf { /** @var \App\Models\Site\GeneralConfig */ $config = GeneralConfig::getCached()->where('type', GeneralConfigType::global())->firstOrFail(); $additionalData = $config->data; /** @var \Mccarlosen\LaravelMpdf\LaravelMpdfWrapper */ $mpdf = app('laravel-mpdf'); $defaultFontConfig = (new FontVariables())->getDefaults(); $fontData = $defaultFontConfig['fontdata']; $invoiceBasketItems = \collect($items); /** * 7.5pt 10px * 9pt 12px * 10.5pt 14px * 12pt 16px * 13.5pt 18px */ /** @var \Mccarlosen\LaravelMpdf\LaravelMpdf */ $pdf = $mpdf->loadView( 'back::documents.order-invoice', [ 'title' => $title, 'billing' => !empty($order->billing->firstname) ? $order->billing : $order->shipping, 'shipping' => $order->shipping, 'logo' => \public_path(resize($additionalData->logo->value ?? '', 'BoutiqueLogo')), 'introLines' => \collect($additionalData)->map(function (object $input, string $inputName) { return Str::substr($inputName, 0, -1) === 'invoice_line_' ? $input->value : null; })->filter()->all(), 'orderReference' => $order->reference, 'transactionReference' => $transactionReference, 'date' => $date, 'documentType' => $documentType, 'basket' => $invoiceBasketItems, 'totalTtc' => \intval($invoiceBasketItems->sum('subtotal')), 'totalTaxes' => \intval($invoiceBasketItems->sum(function (object $basketItem) { return $basketItem->qty * $basketItem->tax; })) ], [], [ 'mode' => 'utf-8', 'format' => 'A4', // * 10.5pt = 14px 'default_font_size' => 10.5, 'default_font' => 'roboto', 'margin_left' => 5, 'margin_right' => 5, 'margin_top' => 5, 'margin_bottom' => 5, 'margin_header' => 0, 'margin_footer' => 5, 'orientation' => 'P', 'title' => $title, 'author' => \config('app.name'), 'watermark' => '', 'show_watermark' => false, 'watermark_font' => 'sans-serif', 'display_mode' => 'fullpage', 'watermark_text_alpha' => 0.1, 'custom_font_dir' => '', 'custom_font_data' => [], 'auto_language_detection' => false, 'temp_dir' => rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR), 'pdfa' => false, 'pdfaauto' => false, 'fontdata' => $fontData + [ 'roboto' => [ 'R' => \resource_path('assets/fonts/bo/roboto/RobotoMono-Regular.ttf'), 'B' => \resource_path('assets/fonts/bo/roboto/RobotoMono-Bold.ttf'), 'I' => \resource_path('assets/fonts/bo/roboto/RobotoMono-Italic.ttf'), 'BI' => \resource_path('assets/fonts/bo/roboto/RobotoMono-BoldItalic.ttf') ] ] ] ); return $pdf; } }