feat(purchase): implement purchase limit enforcement and custom exception handling
This commit is contained in:
parent
8537d2ed0a
commit
5c0b3503b7
|
|
@ -103,9 +103,14 @@ class Cart extends Model
|
|||
$cartQuantity = (int) $this->items()
|
||||
->where('catalog_item_id', $catalogItemId)
|
||||
->sum('cantidad');
|
||||
$this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity);
|
||||
$inventoryService = app(CatalogInventoryService::class);
|
||||
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
||||
$this->assertUserPurchaseLimit(
|
||||
$selectedItem,
|
||||
$cartQuantity + $quantity,
|
||||
heldQuantity: $cartQuantity,
|
||||
maximumAddableCeiling: $availableQuantity,
|
||||
);
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
|
|
@ -181,10 +186,13 @@ class Cart extends Model
|
|||
->where('catalog_item_id', $item->catalog_item_id)
|
||||
->whereKeyNot($item->getKey())
|
||||
->sum('cantidad');
|
||||
$nextAvailableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||
$this->assertUserPurchaseLimit(
|
||||
$nextSelection,
|
||||
$otherVariantsQuantity + $quantity,
|
||||
$excludedPurchaseId,
|
||||
$otherVariantsQuantity + $item->cantidad,
|
||||
$nextAvailableQuantity,
|
||||
);
|
||||
|
||||
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
|
||||
|
|
@ -222,6 +230,7 @@ class Cart extends Model
|
|||
}
|
||||
|
||||
$delta = $quantity - $item->cantidad;
|
||||
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
|
||||
|
||||
if ($delta > 0) {
|
||||
$otherVariantsQuantity = (int) $this->items()
|
||||
|
|
@ -232,11 +241,11 @@ class Cart extends Model
|
|||
$currentSelection,
|
||||
$otherVariantsQuantity + $quantity,
|
||||
$excludedPurchaseId,
|
||||
$otherVariantsQuantity + $item->cantidad,
|
||||
$availableQuantity,
|
||||
);
|
||||
}
|
||||
|
||||
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
|
||||
|
||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||
throw ValidationException::withMessages([
|
||||
|
|
@ -355,6 +364,8 @@ class Cart extends Model
|
|||
CatalogItem|Variant $selectedItem,
|
||||
int $cartQuantity,
|
||||
?int $excludedPurchaseId = null,
|
||||
int $heldQuantity = 0,
|
||||
?int $maximumAddableCeiling = null,
|
||||
): void {
|
||||
if ($this->user_id === null) {
|
||||
return;
|
||||
|
|
@ -370,6 +381,8 @@ class Cart extends Model
|
|||
$cartQuantity,
|
||||
$excludedPurchaseId,
|
||||
$this->getKey(),
|
||||
$heldQuantity,
|
||||
$maximumAddableCeiling,
|
||||
field: 'cantidad',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||
'nombre' => $catalogItem->nombre,
|
||||
'descripcion' => $catalogItem->descripcion,
|
||||
'precio' => $catalogItem->precio,
|
||||
'stock_tecnico' => $catalogItem->availableStock(),
|
||||
'maximum_addable_quantity' => $this->maximumAddable(
|
||||
$catalogItem->availableStock(),
|
||||
$remainingUserQuota,
|
||||
|
|
@ -50,9 +49,6 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||
'descripcion' => $variant->getDescription(),
|
||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory->availableStock(),
|
||||
'maximum_addable_quantity' => $this->maximumAddable(
|
||||
$catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -38,10 +38,6 @@ class CatalogItemDetailResource extends JsonResource
|
|||
'max_units_per_user' => $this->max_units_per_user,
|
||||
'has_tickets' => $this->has_tickets,
|
||||
'attributes' => $this->attributesData(),
|
||||
'stock_tecnico' => $this->when(
|
||||
$selectedVariant === null,
|
||||
fn () => $this->availableStock(),
|
||||
),
|
||||
'maximum_addable_quantity' => $this->when(
|
||||
$selectedVariant === null,
|
||||
fn () => $this->maximumAddable($this->availableStock()),
|
||||
|
|
@ -172,7 +168,6 @@ class CatalogItemDetailResource extends JsonResource
|
|||
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||
'descripcion' => $variant->getDescription(),
|
||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
'stock_tecnico' => $this->variantStock($variant),
|
||||
'maximum_addable_quantity' => $this->maximumAddable($this->variantStock($variant)),
|
||||
'values' => $values,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ class CatalogSearchItemResource extends JsonResource
|
|||
'descripcion' => $this->descripcion,
|
||||
'precio' => $this->precio,
|
||||
'image' => $attachment?->getTemporaryUrl(1440),
|
||||
'stock_tecnico' => $this->availableStock(),
|
||||
'maximum_addable_quantity' => $this->maximumAddable($this->availableStock()),
|
||||
'variants' => $this->visibleVariants()
|
||||
->map(fn (Variant $variant): array => [
|
||||
|
|
@ -38,9 +37,6 @@ class CatalogSearchItemResource extends JsonResource
|
|||
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||
'descripcion' => $variant->getDescription(),
|
||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory?->availableStock(),
|
||||
'maximum_addable_quantity' => $this->maximumAddable(
|
||||
$this->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Exceptions;
|
||||
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PurchaseLimitExceededException extends ValidationException
|
||||
{
|
||||
public readonly int $catalogItemId;
|
||||
|
||||
public readonly string $catalogItemName;
|
||||
|
||||
public readonly int $maximumAddableQuantity;
|
||||
|
||||
public function __construct(CatalogItem $catalogItem, int $maximumAddableQuantity, string $field)
|
||||
{
|
||||
$this->catalogItemId = (int) $catalogItem->getKey();
|
||||
$this->catalogItemName = $catalogItem->nombre;
|
||||
$this->maximumAddableQuantity = $maximumAddableQuantity;
|
||||
|
||||
parent::__construct(validator([], []));
|
||||
|
||||
$message = trans_choice('api.purchase_limit.exceeded', $maximumAddableQuantity, [
|
||||
'max' => $maximumAddableQuantity,
|
||||
'product' => $this->catalogItemName,
|
||||
]);
|
||||
$this->message = $message;
|
||||
$this->validator->errors()->add($field, $message);
|
||||
}
|
||||
}
|
||||
|
|
@ -120,10 +120,16 @@ class StartCheckoutService
|
|||
->each(function (Collection $catalogLines) use ($userId): void {
|
||||
/** @var CatalogItem $catalogItem */
|
||||
$catalogItem = $catalogLines->first()['catalog_item'];
|
||||
$availableQuantities = $catalogLines
|
||||
->map(fn (array $line): ?int => $this->inventory->availableQuantity($line['selection']));
|
||||
$maximumAddableCeiling = $availableQuantities->contains(null)
|
||||
? null
|
||||
: (int) $availableQuantities->sum();
|
||||
$this->purchaseLimits->assertCanPurchase(
|
||||
$catalogItem,
|
||||
$userId,
|
||||
(int) $catalogLines->sum('quantity'),
|
||||
maximumAddableCeiling: $maximumAddableCeiling,
|
||||
field: 'direct_items',
|
||||
);
|
||||
});
|
||||
|
|
@ -335,6 +341,7 @@ class StartCheckoutService
|
|||
$userId,
|
||||
$quantity,
|
||||
excludedCartId: $cartId,
|
||||
heldQuantity: $quantity,
|
||||
field: 'cart_id',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ namespace App\Domains\Purchase\Services;
|
|||
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UserPurchaseLimitService
|
||||
{
|
||||
|
|
@ -18,6 +18,8 @@ class UserPurchaseLimitService
|
|||
int $requestedQuantity,
|
||||
?int $excludedPurchaseId = null,
|
||||
?int $excludedCartId = null,
|
||||
int $heldQuantity = 0,
|
||||
?int $maximumAddableCeiling = null,
|
||||
string $field = 'quantity',
|
||||
): void {
|
||||
DB::transaction(function () use (
|
||||
|
|
@ -26,6 +28,8 @@ class UserPurchaseLimitService
|
|||
$requestedQuantity,
|
||||
$excludedPurchaseId,
|
||||
$excludedCartId,
|
||||
$heldQuantity,
|
||||
$maximumAddableCeiling,
|
||||
$field,
|
||||
): void {
|
||||
/** @var CatalogItem $catalogItem */
|
||||
|
|
@ -86,9 +90,20 @@ class UserPurchaseLimitService
|
|||
->sum('cantidad');
|
||||
|
||||
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
|
||||
throw ValidationException::withMessages([
|
||||
$field => __('api.purchase_limit.exceeded', ['max' => $limit]),
|
||||
]);
|
||||
$remainingQuota = max(
|
||||
0,
|
||||
$limit - $purchasedQuantity - $checkoutQuantity - $reservedCartQuantity,
|
||||
);
|
||||
|
||||
$maximumAddableQuantity = max(0, $remainingQuota - $heldQuantity);
|
||||
|
||||
throw new PurchaseLimitExceededException(
|
||||
$catalogItem,
|
||||
$maximumAddableCeiling === null
|
||||
? $maximumAddableQuantity
|
||||
: min($maximumAddableQuantity, $maximumAddableCeiling),
|
||||
$field,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
|
||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Http\Middleware\EnsureAdminAppTenant;
|
||||
use App\Http\Middleware\EnsureScannerTenant;
|
||||
|
|
@ -87,6 +88,20 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
'unavailable_items' => $exception->unavailableItems,
|
||||
], 422);
|
||||
});
|
||||
$exceptions->render(function (PurchaseLimitExceededException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'purchase.limit_exceeded',
|
||||
'message' => $exception->getMessage(),
|
||||
'errors' => $exception->errors(),
|
||||
'catalog_item_id' => $exception->catalogItemId,
|
||||
'catalog_item_name' => $exception->catalogItemName,
|
||||
'maximum_addable_quantity' => $exception->maximumAddableQuantity,
|
||||
], 422);
|
||||
});
|
||||
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ return [
|
|||
'not_available_for_review' => 'The purchase is no longer available for review.',
|
||||
],
|
||||
'purchase_limit' => [
|
||||
'exceeded' => 'You can purchase up to :max units of this product.',
|
||||
'exceeded' => '{0} You cannot add more units of “:product”.|{1} You can add up to :max more unit of “:product”.|[2,*] You can add up to :max more units of “:product”.',
|
||||
],
|
||||
'ticket' => [
|
||||
'not_available' => 'One or more tickets are not available.',
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ return [
|
|||
'not_available_for_review' => "La compra ya no est\u{00E1} disponible para revisi\u{00F3}n.",
|
||||
],
|
||||
'purchase_limit' => [
|
||||
'exceeded' => 'Podés comprar hasta :max unidades de este producto.',
|
||||
'exceeded' => '{0} No podés agregar más unidades de “:product”.|{1} Podés agregar hasta :max unidad más de “:product”.|[2,*] Podés agregar hasta :max unidades más de “:product”.',
|
||||
],
|
||||
'ticket' => [
|
||||
'not_available' => 'Uno o más tickets no están disponibles.',
|
||||
|
|
|
|||
|
|
@ -241,7 +241,15 @@ class CartControllerTest extends TestCase
|
|||
'cantidad' => 2,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('cantidad');
|
||||
->assertJsonValidationErrors('cantidad')
|
||||
->assertJsonPath('code', 'purchase.limit_exceeded')
|
||||
->assertJsonPath('catalog_item_id', $item->id)
|
||||
->assertJsonPath('catalog_item_name', $item->nombre)
|
||||
->assertJsonPath('maximum_addable_quantity', 1)
|
||||
->assertJsonPath(
|
||||
'message',
|
||||
"Podés agregar hasta 1 unidad más de “{$item->nombre}”.",
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('carrito_items', [
|
||||
'catalog_item_id' => $item->id,
|
||||
|
|
|
|||
|
|
@ -267,7 +267,8 @@ class BundleCatalogItemTest extends TestCase
|
|||
$this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
|
||||
->assertJsonPath('data.stock_tecnico', 4)
|
||||
->assertJsonPath('data.maximum_addable_quantity', 4)
|
||||
->assertJsonMissingPath('data.stock_tecnico')
|
||||
->assertJsonCount(1, 'data.components')
|
||||
->assertJsonPath('data.components.0.catalog_item_id', $component->id)
|
||||
->assertJsonPath('data.components.0.variant_id', null)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class CatalogControllerTest extends TestCase
|
|||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_row_and_column_with_cart_return_item_details_variants_and_technical_stock(): void
|
||||
public function test_row_and_column_with_cart_return_item_details_variants_and_maximum_quantity(): void
|
||||
{
|
||||
$tenant = $this->createTenant('catalog-index');
|
||||
$row = $this->createGroup($tenant, ProductLayout::Row, 'Row', 2);
|
||||
|
|
@ -72,16 +72,14 @@ class CatalogControllerTest extends TestCase
|
|||
->assertJsonPath('0.items.0.nombre', 'Variants')
|
||||
->assertJsonPath('0.items.0.descripcion', 'Variants description')
|
||||
->assertJsonPath('0.items.0.precio', '100.00')
|
||||
->assertJsonPath('0.items.0.stock_tecnico', 7)
|
||||
->assertJsonPath('0.items.0.maximum_addable_quantity', 7)
|
||||
->assertJsonCount(2, '0.items.0.variants')
|
||||
->assertJsonPath('0.items.0.variants.0.stock_tecnico', 4)
|
||||
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4)
|
||||
->assertJsonPath('0.items.0.variants.1.stock_tecnico', 3)
|
||||
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3)
|
||||
->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0])
|
||||
->assertJsonMissing(['id' => $unavailableVariant->id])
|
||||
->assertJsonPath('1.title', 'Row')
|
||||
->assertJsonPath('1.items.data.0.stock_tecnico', 8)
|
||||
->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8)
|
||||
->assertJsonMissingPath('1.items.data.0.stock_tecnico')
|
||||
->assertJsonCount(0, '1.items.data.0.variants');
|
||||
}
|
||||
|
||||
|
|
@ -116,10 +114,10 @@ class CatalogControllerTest extends TestCase
|
|||
$this->actingAs($user, 'sanctum')
|
||||
->getJson("/api/tenants/{$tenant->codigo}/catalog")
|
||||
->assertOk()
|
||||
->assertJsonPath('0.items.0.variants.0.stock_tecnico', 8)
|
||||
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 2)
|
||||
->assertJsonPath('0.items.0.variants.1.stock_tecnico', 9)
|
||||
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 2);
|
||||
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 2)
|
||||
->assertJsonMissingPath('0.items.0.variants.0.stock_tecnico')
|
||||
->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico');
|
||||
}
|
||||
|
||||
public function test_it_excludes_items_when_all_of_their_variants_are_out_of_stock(): void
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ class CatalogItemDetailControllerTest extends TestCase
|
|||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.stock_tecnico', 7)
|
||||
->assertJsonPath('data.maximum_addable_quantity', 7)
|
||||
->assertJsonMissingPath('data.stock_tecnico')
|
||||
->assertJsonCount(0, 'data.variants')
|
||||
->assertJsonCount(1, 'data.images');
|
||||
$response->assertJsonMissingPath('data.selected_variant');
|
||||
|
|
@ -71,7 +72,8 @@ class CatalogItemDetailControllerTest extends TestCase
|
|||
->assertJsonCount(1, 'data.variants')
|
||||
->assertJsonPath('data.variants.0.id', $secondVariant->id)
|
||||
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
|
||||
->assertJsonPath('data.selected_variant.stock_tecnico', 6)
|
||||
->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6)
|
||||
->assertJsonMissingPath('data.selected_variant.stock_tecnico')
|
||||
->assertJsonCount(1, 'data.selected_variant.images');
|
||||
$response
|
||||
->assertJsonMissingPath('data.stock_tecnico')
|
||||
|
|
@ -125,11 +127,11 @@ class CatalogItemDetailControllerTest extends TestCase
|
|||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.variants.0.id', $firstVariant->id)
|
||||
->assertJsonPath('data.variants.0.stock_tecnico', 4)
|
||||
->assertJsonPath('data.variants.0.maximum_addable_quantity', 4)
|
||||
->assertJsonPath('data.variants.0.values.size.value', 'S')
|
||||
->assertJsonPath('data.variants.0.values.size.label', 'Small')
|
||||
->assertJsonPath('data.variants.1.id', $secondVariant->id)
|
||||
->assertJsonPath('data.variants.1.stock_tecnico', 7)
|
||||
->assertJsonPath('data.variants.1.maximum_addable_quantity', 7)
|
||||
->assertJsonPath('data.variants.1.values.size.value', 'M')
|
||||
->assertJsonPath('data.variants.1.values.size.label', 'Medium')
|
||||
->assertJsonPath('data.attributes.0.codigo', 'size')
|
||||
|
|
@ -137,7 +139,7 @@ class CatalogItemDetailControllerTest extends TestCase
|
|||
->assertJsonPath('data.attributes.0.options.1.value', 'M')
|
||||
->assertJsonCount(2, 'data.attributes.0.options')
|
||||
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
|
||||
->assertJsonPath('data.selected_variant.stock_tecnico', 7)
|
||||
->assertJsonPath('data.selected_variant.maximum_addable_quantity', 7)
|
||||
->assertJsonPath('data.selected_variant.values.size.value', 'M')
|
||||
->assertJsonPath('data.selected_variant.values.size.label', 'Medium')
|
||||
->assertJsonCount(1, 'data.selected_variant.images');
|
||||
|
|
@ -177,9 +179,11 @@ class CatalogItemDetailControllerTest extends TestCase
|
|||
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.selected_variant.id', $variant->id)
|
||||
->assertJsonPath('data.selected_variant.stock_tecnico', null)
|
||||
->assertJsonPath('data.selected_variant.maximum_addable_quantity', null)
|
||||
->assertJsonMissingPath('data.stock_tecnico')
|
||||
->assertJsonPath('data.variants.0.stock_tecnico', null);
|
||||
->assertJsonPath('data.variants.0.maximum_addable_quantity', null)
|
||||
->assertJsonMissingPath('data.selected_variant.stock_tecnico')
|
||||
->assertJsonMissingPath('data.variants.0.stock_tecnico');
|
||||
}
|
||||
|
||||
public function test_it_exposes_event_dates_as_a_dynamic_variant_attribute(): void
|
||||
|
|
|
|||
|
|
@ -340,7 +340,11 @@ class StorePurchaseTest extends TestCase
|
|||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('direct_items');
|
||||
->assertJsonValidationErrors('direct_items')
|
||||
->assertJsonPath('code', 'purchase.limit_exceeded')
|
||||
->assertJsonPath('catalog_item_id', $variant->catalog_item_id)
|
||||
->assertJsonPath('catalog_item_name', $variant->catalogItem->nombre)
|
||||
->assertJsonPath('maximum_addable_quantity', 1);
|
||||
|
||||
$this->actingAs($otherUser, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
|
|
|
|||
Loading…
Reference in New Issue