- Migration: shopify_variant_id on products_sizes, shopify_order_id on products_sales - ShopifyService: paginated REST client (2024-10 API) - shopify:map — one-time command to link variants by SKU - shopify:sync — reconciliation command (Shopify is master) - VerifyShopifyWebhook middleware: HMAC-SHA256 verification - ShopifyWebhookController: idempotent orders/paid handler, FIFO inventory deduction - Webhook route: POST /api/webhooks/shopify/orders-paid Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f54b471848
commit
467d85dfec
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ProductInventory;
|
||||
use App\Models\ProductSale;
|
||||
use App\Models\ProductSize;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ShopifyWebhookController extends Controller
|
||||
{
|
||||
public function ordersPaid(Request $request): Response
|
||||
{
|
||||
$order = $request->json()->all();
|
||||
$orderId = (string) ($order['id'] ?? '');
|
||||
|
||||
// Idempotency: ignore already-processed orders
|
||||
if (ProductSale::where('shopify_order_id', $orderId)->exists()) {
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($order, $orderId) {
|
||||
foreach ($order['line_items'] ?? [] as $item) {
|
||||
$variantId = (string) ($item['variant_id'] ?? '');
|
||||
$quantity = (int) ($item['quantity'] ?? 0);
|
||||
|
||||
if (!$variantId || $quantity <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$size = ProductSize::where('shopify_variant_id', $variantId)->first();
|
||||
|
||||
if (!$size) {
|
||||
Log::warning('Shopify webhook: unbekannte Variant-ID', [
|
||||
'variant_id' => $variantId,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// FIFO: älteste verfügbare Einheiten ausbuchen
|
||||
ProductInventory::where('size_id', $size->id)
|
||||
->whereNull('out_at')
|
||||
->oldest()
|
||||
->limit($quantity)
|
||||
->update(['out_at' => now()]);
|
||||
|
||||
ProductSale::create([
|
||||
'product_id' => $size->product_id,
|
||||
'size_id' => $size->id,
|
||||
'amount' => $quantity,
|
||||
'sold_at' => now(),
|
||||
'shopify_order_id' => $orderId,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return response('', 200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class VerifyShopifyWebhook
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$hmac = $request->header('X-Shopify-Hmac-Sha256');
|
||||
$secret = config('services.shopify.webhook_secret');
|
||||
|
||||
if (!$hmac || !$secret) {
|
||||
abort(401);
|
||||
}
|
||||
|
||||
$computed = base64_encode(hash_hmac('sha256', $request->getContent(), $secret, true));
|
||||
|
||||
if (!hash_equals($computed, $hmac)) {
|
||||
abort(401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user