SYNC 150 Add final invoice feature

This commit is contained in:
Jérémy Munsch 2023-05-23 09:32:54 +02:00
parent bea7e22cbb
commit 2bc6d64904
No known key found for this signature in database
GPG key ID: 18361AF3BFEC0FEC
12 changed files with 250 additions and 124 deletions

View file

@ -508,7 +508,7 @@ class OrderController extends Controller
$this->refundTransaction(
$order,
$order->total_ttc - $order->total_refund,
$order->getItemsForOrderCancelling()
$order->getItemForOrderCancelling()
);
return \back()->with('bo.success', trans(
':model a été mis à jour',

View file

@ -236,4 +236,38 @@ class Controller extends BaseController
'X-Generator' => 'mPDF'
]);
}
/**
* Generate and download transaction invoice
*
* ! Appliable for both Fo and Bo controllers
*
* @param \App\Models\Shop\Order $order
* @return \Symfony\Component\HttpFoundation\Response
*/
public function downloadOrderInvoice(Order $order): Response
{
$this->authorize('download', $order);
$title = trans('Commande du :date - :orderRef - :transRef', [
'date' => $order->created_at->isoFormat('DD\/MM\/YYYY'),
'orderRef' => $order->reference,
'transRef' => trans('Toutes transactions'),
]);
$sluggedTitle = Str::slug(\implode(' ', [
config('app.name'),
trans('Facture'),
$order->created_at->isoFormat('YYYY-MM-DD'),
]));
return \response()->streamDownload(function () use ($order, $title) {
echo $order->generateInvoice($title)->output();
}, "{$sluggedTitle}.pdf", [
'Cache-Control' => 'public, must-revalidate, max-age=0',
'Content-Description' => 'File Transfer',
'Content-Type' => 'application/pdf',
'Expires' => 'Sat, 26 Jul 1997 05:00:00 GMT',
'Last-Modified' => gmdate('D, d M Y H:i:s') . ' GMT',
// For glory.
'X-Generator' => 'mPDF'
]);
}
}

View file

@ -3,9 +3,11 @@
namespace App\Lib\Helpers;
use App\Enums\Discount\DiscountType;
use App\Enums\GeneralConfigType;
use App\Enums\Product\TaxRate;
use App\Models\Merchandise\Product;
use App\Models\Shop\Order;
use App\Models\Site\GeneralConfig;
use Gloudemans\Shoppingcart\Cart;
use Gloudemans\Shoppingcart\CartItem;
use Illuminate\Database\Eloquent\Model;
@ -18,7 +20,9 @@ use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Mccarlosen\LaravelMpdf\LaravelMpdf;
use Money\Money;
use Mpdf\Config\FontVariables;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ToolboxHelper
@ -532,4 +536,102 @@ class ToolboxHelper
]
], [], ['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' => $invoiceBasketItems->sum('subtotal'),
'totalTaxes' => $invoiceBasketItems->sum('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;
}
}

View file

@ -2,7 +2,6 @@
namespace App\Models\Payment;
use App\Enums\GeneralConfigType;
use App\Enums\Order\DeliveryStatus;
use App\Enums\Order\PaymentModeEnum;
use App\Enums\Order\PaymentStatus;
@ -12,7 +11,6 @@ use App\Enums\Order\ValidationStatus;
use App\Lib\Helpers\ToolboxHelper;
use App\Lib\Payments\PaymentAdapter;
use App\Models\Shop\Order;
use App\Models\Site\GeneralConfig;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Session;
@ -21,7 +19,6 @@ use Kwaadpepper\Enum\Traits\CastsEnums;
use Mccarlosen\LaravelMpdf\LaravelMpdf;
use Money\Currency;
use Money\Money;
use Mpdf\Config\FontVariables;
/**
* Transaction
@ -312,20 +309,9 @@ class Transaction extends Model
*/
public function generateInvoice(string $title): LaravelMpdf
{
/** @var \App\Models\Site\GeneralConfig */
$config = GeneralConfig::getCached()->where('type', GeneralConfigType::global())->firstOrFail();
$additionalData = $config->data;
/** @var \App\Models\Shop\Order */
$order = $this->order;
/** @var \Mccarlosen\LaravelMpdf\LaravelMpdfWrapper */
$mpdf = app('laravel-mpdf');
$defaultFontConfig = (new FontVariables())->getDefaults();
$fontData = $defaultFontConfig['fontdata'];
/**
* * Because of Stripe reusing transactions, therefore use prefix.
*/
@ -333,73 +319,15 @@ class Transaction extends Model
$invoiceBasketItems = \collect($this->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',
$this->toArray(),
[
'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' => $this->reference . $transactionReferenceSuffix,
'date' => $this->updated_at,
'transactionType' => $this->type,
'basket' => $invoiceBasketItems,
'totalTtc' => $invoiceBasketItems->sum('subtotal'),
'totalTaxes' => $invoiceBasketItems->sum('tax'),
'isLastFullRefundTransaction' => $order->transactions()->where('id', '!=', $this->id)->pluck('items')->sum(function ($items) {
return \collect($items)->sum('subtotal');
}) === \collect($this->items)->sum('subtotal')
],
[
'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 ToolboxHelper::generateInvoice(
$order,
$this->created_at,
$title,
$this->type->equals(TransactionType::debit()) ?
Str::ucfirst(\trans('facture')) : Str::ucfirst(\trans('avoir')),
$invoiceBasketItems,
$this->reference . $transactionReferenceSuffix
);
return $pdf;
}
// * GETTER

View file

@ -24,8 +24,10 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Kwaadpepper\Enum\Traits\CastsEnums;
use Mccarlosen\LaravelMpdf\LaravelMpdf;
use Money\Money;
/**
@ -421,23 +423,12 @@ class Order extends Model
*
* @return \Illuminate\Support\Collection<object>
*/
public function getItemsForOrderCancelling(): Collection
public function getItemForOrderCancelling(): Collection
{
// * Negate all items prices (already refunded)
$orderedItems = \collect($this->basket);
/** @var \Illuminate\Support\Collection<object> */
$refundedTransactionsItems = $this->transactions()->where('type', TransactionType::credit())
->pluck('items');
$final = \collect($orderedItems);
$refundedTransactionsItems->each(function ($items) use (&$final) {
$final = $final->concat(\collect($items));
});
$remainingToRefund = \intval($this->transactions()->where('id', '!=', $this->id)->pluck('items')->sum(function ($items) {
return \collect($items)->sum('subtotal');
}));
$remainingToRefund = \intval($this->transactions()
->where('id', '!=', $this->id)->pluck('items')->sum(function ($items) {
return \collect($items)->sum('subtotal');
}));
// * Créer un dummy product pour représenter la transaction.
$cartItemOptions = \array_merge(Controller::$cartItemOptionsSkel, [
@ -455,13 +446,34 @@ class Order extends Model
$cartItem->setQuantity(1);
// * Négation du prix
$basketItem = \json_decode($cartItem->toJson());
$basketItem = \json_decode($cartItem->toJson());
$basketItem->price = $basketItem->price * -1;
$basketItem->options->price_ttc = $basketItem->options->price_ttc * -1;
$basketItem->subtotal = $basketItem->subtotal * -1;
// * On ajoute la ligne virtuelle en bas de la liste.
$final->add($basketItem);
return collect($basketItem)->values();
}
/**
* Fetch all transaction items
* ! For final bill.
*
* @return \Illuminate\Support\Collection<object>
*/
public function getAllTransactionsItems(): Collection
{
// * Negate all items prices (already refunded)
$orderedItems = \collect($this->basket);
/** @var \Illuminate\Support\Collection<object> */
$refundedTransactionsItems = $this->transactions()->where('type', TransactionType::credit())
->pluck('items');
$final = \collect($orderedItems);
$refundedTransactionsItems->each(function ($items) use (&$final) {
$final = $final->concat(\collect($items));
});
return $final->values();
}
@ -677,4 +689,22 @@ class Order extends Model
$this->total_ht = \intval($this->total_ttc - $basket->tax());
$this->total_weight = \intval($basket->weight());
}
/**
* Generate final Invoice
*
* @param string $title
* @return \Mccarlosen\LaravelMpdf\LaravelMpdf
*/
public function generateInvoice(string $title): LaravelMpdf
{
return ToolboxHelper::generateInvoice(
$this,
$this->created_at,
$title,
Str::ucfirst(\trans('facture')),
$this->getAllTransactionsItems(),
$this->reference
);
}
}

View file

@ -175,7 +175,9 @@ class OrderPolicy extends Policy
$order->payment_status->equals(PaymentStatus::canceled()) or
$order->payment_status->equals(PaymentStatus::failed())
) {
return Response::deny('The order payment status is alread :status.', ['status' => $order->payment_status->label]);
return Response::deny('The order payment status is alread :status.', [
'status' => $order->payment_status->label
]);
}
if (!$transaction) {
return Response::deny('The order does not have a transaction associated.');
@ -212,4 +214,29 @@ class OrderPolicy extends Policy
}
return Response::allow();
}
/**
* Can a user order download final order invoice
*
* @param \App\Models\Customer\Customer|\App\Models\User|null $user
* @param \App\Models\Shop\Order|null $order
*
* @return \Illuminate\Auth\Access\Response
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
*/
public function download(
$user = null,
Order $order = null
): Response {
if (!$order) {
return Response::deny(\trans('No order provided'));
}
if (!$order->transactions()->count()) {
return Response::deny('No transactions are available on this order');
}
if (!\is_object($user) or !($user instanceof User)) {
return Response::deny(trans('Only authenticated users can do this'));
}
return Response::allow();
}
}

View file

@ -11,7 +11,7 @@ use Illuminate\Auth\Access\Response;
class TransactionPolicy extends Policy
{
/**
* Can a user order things ?
* Can a user download transaction invoice
*
* ! Only owner customer or a Bo user
* ! + a validated transaction.
@ -27,12 +27,14 @@ class TransactionPolicy extends Policy
$user = null,
Transaction $transaction = null
): Response {
if (
!$transaction or
!$transaction->status->equals(TransactionStatus::validated()) or
$transaction->needs_validation
) {
return Response::deny();
if (!$transaction) {
return Response::deny(\trans('No transaction provided'));
}
if (!$transaction->status->equals(TransactionStatus::validated())) {
return Response::deny(trans('Transaction is not validated'));
}
if ($transaction->needs_validation) {
return Response::deny(\trans('Transaction needs to be validated first'));
}
if ($user and $user instanceof User) {
return Response::allow();
@ -45,6 +47,6 @@ class TransactionPolicy extends Policy
) {
return Response::allow();
}
return Response::deny();
return Response::deny(\trans('Could not satisfy any conditions'));
}
}

@ -1 +1 @@
Subproject commit d40a32f3fd62cd3df89c556ba9b71299d38594b5
Subproject commit f6dc1ac78237a08670d74721fe22b32f51ac2744

View file

@ -165,10 +165,6 @@
</style>
</head>
@php
$customerIsRefunded = !$transactionType->equals(\App\Enums\Order\TransactionType::debit());
@endphp
<body>
<div id="main-table" cellpadding="0" cellspacing="0">
@ -207,11 +203,7 @@ $customerIsRefunded = !$transactionType->equals(\App\Enums\Order\TransactionType
<tr>
{{-- TYPE --}}
<td width="80%" class="bold-cell font-spaced">
<h2>@if(!$customerIsRefunded){{
Str::upper(__('Facture'))
}}@else{{
Str::upper(__('Avoir'))
}}@endif</h2>
<h2>{{ Str::upper($documentType) }}</h2>
</td>
{{-- BILLING --}}
<td rowspan="4" valign="middle">&nbsp;</td>
@ -241,7 +233,7 @@ $customerIsRefunded = !$transactionType->equals(\App\Enums\Order\TransactionType
<tr>
<td class="bold-cell">{{ __('Date') }}</td>
<td class="font-typed font-upper" style="text-align: center;">{{
Carbon\Carbon::parse($created_at)->isoFormat('DD\/MM\/YYYY')
$date->isoFormat('DD\/MM\/YYYY')
}}</td>
<td class="bold-cell">{{ __('N° Commande') }}</td>
<td class="font-typed font-upper" style="text-align: center;">{{ $orderReference }}</td>
@ -371,7 +363,7 @@ $customerIsRefunded = !$transactionType->equals(\App\Enums\Order\TransactionType
<span>{{ __('page') }}&nbsp;:</span>
</td>
<td valign="bottom" width="10%" style="text-align: left;">
<span>{{ Carbon\Carbon::parse($created_at)->isoFormat('DD\/MM\/YY') }}</span>
<span>{{ $date->isoFormat('DD\/MM\/YY') }}</span>
<span>{PAGENO}&nbsp;/&nbsp;{nbpg}</span>
</td>
</tr>

View file

@ -39,7 +39,7 @@
'amount' => app('phpMoneyFormatter')->format($transaction->amount)
]) }}</span>
@can('download', $transaction)
<a href="{{ route(request()->routeIs('bo.*') ? 'bo.orders.invoice' : 'account.orders.invoice', ['reference' => $order, 'transactionReference' => $transaction]) }}" class="btn btn-primary" target="_blank" role="button">{{
<a href="{{ route(request()->routeIs('bo.*') ? 'bo.orders.transaction.invoice' : 'account.orders.transaction.invoice', ['reference' => $order, 'transactionReference' => $transaction]) }}" class="btn btn-primary" target="_blank" role="button">{{
__('Télécharger la facture')
}}</a>
@elseif ($transaction->needs_validation)
@ -54,3 +54,10 @@
@else
<span class="h-4">{{ __('Aucune transaction') }}</span>
@endif
@can('download', $order)
<div class="text-end">
<a href="{{ route('bo.orders.invoice', ['reference' => $order]) }}" class="btn btn-primary" target="_blank" role="button">{{
__('Télécharger la facture finale')
}}</a>
</div>
@endcan

View file

@ -164,8 +164,12 @@ Route::prefix('bo')->name('bo.')->group(function () {
Route::post('/orders/{order}/refund-submit', [OrderController::class, 'cancelRefundSubmit'])
->name('orders.cancel-refund-submit');
Route::get(
'/commandes/{reference}/transaction/{transactionReference}/invoice',
'/orders/{reference}/transaction/{transactionReference}/invoice',
[OrderController::class, 'downloadTransactionInvoice']
)->name('orders.transaction.invoice');
Route::get(
'/orders/{reference}/invoice',
[OrderController::class, 'downloadOrderInvoice']
)->name('orders.invoice');
});
});

View file

@ -40,7 +40,7 @@ Route::middleware(['auth:frontend', 'verified'])->group(function () {
Route::get(
'/commandes/{reference}/transaction/{transactionReference}/invoice',
[CustomerController::class, 'downloadTransactionInvoice']
)->middleware('throttle:2,1')->name('orders.invoice');
)->middleware('throttle:2,1')->name('orders.transaction.invoice');
// * Security
Route::get('/securite/identification/email', [CustomerController::class, 'email'])->name('email');