diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c87c926..2f43fdb 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -19,6 +19,9 @@ jobs: APP_KEY=${{ secrets.APP_KEY }} APP_DEBUG=false APP_URL=${{ vars.APP_URL }} + SHOPIFY_SHOP_DOMAIN=${{ vars.SHOPIFY_SHOP_DOMAIN }} + SHOPIFY_ACCESS_TOKEN=${{ secrets.SHOPIFY_ACCESS_TOKEN }} + SHOPIFY_WEBHOOK_SECRET=${{ secrets.SHOPIFY_WEBHOOK_SECRET }} DB_CONNECTION=mariadb DB_HOST=db DB_PORT=3306 diff --git a/app/Console/Commands/ShopifyMap.php b/app/Console/Commands/ShopifyMap.php new file mode 100644 index 0000000..df67223 --- /dev/null +++ b/app/Console/Commands/ShopifyMap.php @@ -0,0 +1,57 @@ +option('dry-run'); + $variants = $shopify->getAllVariants(); + + $this->info('Shopify-Varianten geladen: ' . count($variants)); + + $mapped = 0; + $skipped = 0; + $noSku = 0; + + foreach ($variants as $variant) { + if (empty($variant['sku'])) { + $noSku++; + continue; + } + + $size = ProductSize::where('sku', $variant['sku'])->first(); + + if (!$size) { + $this->warn(" SKU nicht gefunden: {$variant['sku']} ({$variant['product_title']})"); + $skipped++; + continue; + } + + if ($size->shopify_variant_id === $variant['id']) { + continue; // already correct + } + + if (!$dryRun) { + $size->update(['shopify_variant_id' => $variant['id']]); + } + + $this->line(" {$variant['sku']} → Variant-ID {$variant['id']}"); + $mapped++; + } + + $this->info("✓ {$mapped} verknüpft" . ($dryRun ? ' (dry-run)' : '')); + if ($skipped) $this->warn("⚠ {$skipped} SKUs lokal nicht gefunden"); + if ($noSku) $this->warn("⚠ {$noSku} Shopify-Varianten ohne SKU übersprungen"); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ShopifySync.php b/app/Console/Commands/ShopifySync.php new file mode 100644 index 0000000..cc50003 --- /dev/null +++ b/app/Console/Commands/ShopifySync.php @@ -0,0 +1,78 @@ +option('dry-run'); + $variants = $shopify->getAllVariants(); + + $added = 0; + $removed = 0; + $inSync = 0; + + DB::transaction(function () use ($variants, $dryRun, &$added, &$removed, &$inSync) { + foreach ($variants as $variant) { + $size = ProductSize::where('shopify_variant_id', $variant['id'])->first(); + + if (!$size) { + continue; // not mapped yet — run shopify:map first + } + + $shopifyQty = $variant['inventory_quantity']; + $localQty = ProductInventory::where('size_id', $size->id) + ->whereNull('out_at') + ->count(); + + if ($localQty === $shopifyQty) { + $inSync++; + continue; + } + + $diff = $shopifyQty - $localQty; + + if ($diff > 0) { + // Shopify hat mehr → Einträge hinzufügen + $this->line(" +{$diff} {$variant['product_title']} (SKU {$variant['sku']})"); + if (!$dryRun) { + for ($i = 0; $i < $diff; $i++) { + ProductInventory::create([ + 'product_id' => $size->product_id, + 'size_id' => $size->id, + ]); + } + } + $added += $diff; + } else { + // Shopify hat weniger → älteste Einträge als ausgebucht markieren + $excess = abs($diff); + $this->line(" -{$excess} {$variant['product_title']} (SKU {$variant['sku']})"); + if (!$dryRun) { + ProductInventory::where('size_id', $size->id) + ->whereNull('out_at') + ->oldest() + ->limit($excess) + ->update(['out_at' => now()]); + } + $removed += $excess; + } + } + }); + + $this->info("✓ Sync abgeschlossen" . ($dryRun ? ' (dry-run)' : '')); + $this->info(" In Sync: {$inSync} | Hinzugefügt: {$added} | Ausgebucht: {$removed}"); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/ShopifyWebhookController.php b/app/Http/Controllers/ShopifyWebhookController.php new file mode 100644 index 0000000..04e2dc3 --- /dev/null +++ b/app/Http/Controllers/ShopifyWebhookController.php @@ -0,0 +1,63 @@ +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); + } +} diff --git a/app/Http/Middleware/VerifyShopifyWebhook.php b/app/Http/Middleware/VerifyShopifyWebhook.php new file mode 100644 index 0000000..bc2b1ae --- /dev/null +++ b/app/Http/Middleware/VerifyShopifyWebhook.php @@ -0,0 +1,28 @@ +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); + } +} diff --git a/app/Models/ProductSale.php b/app/Models/ProductSale.php index 6f2275b..70331bc 100644 --- a/app/Models/ProductSale.php +++ b/app/Models/ProductSale.php @@ -14,6 +14,7 @@ class ProductSale extends Model 'size_id', 'amount', 'sold_at', + 'shopify_order_id', ]; // Beziehungen diff --git a/app/Models/ProductSize.php b/app/Models/ProductSize.php index 241ae0e..3428256 100644 --- a/app/Models/ProductSize.php +++ b/app/Models/ProductSize.php @@ -9,7 +9,7 @@ class ProductSize extends Model { use HasFactory; protected $table = 'products_sizes'; - protected $fillable = ['product_id', 'packing_id', 'sku', 'price', 'description']; + protected $fillable = ['product_id', 'packing_id', 'sku', 'shopify_variant_id', 'price', 'description']; public function product() { return $this->belongsTo(Product::class); diff --git a/app/Services/ShopifyService.php b/app/Services/ShopifyService.php new file mode 100644 index 0000000..f58af45 --- /dev/null +++ b/app/Services/ShopifyService.php @@ -0,0 +1,73 @@ +baseUrl = "https://{$domain}/admin/api/{$version}"; + $this->token = config('services.shopify.access_token'); + } + + /** + * Returns all product variants from Shopify. + * Each entry: ['id', 'sku', 'inventory_quantity', 'product_title'] + */ + public function getAllVariants(): array + { + $variants = []; + $url = "{$this->baseUrl}/products.json?limit=250&fields=id,title,variants"; + + while ($url) { + $response = Http::withHeader('X-Shopify-Access-Token', $this->token)->get($url); + + if ($response->failed()) { + Log::error('Shopify API error', ['status' => $response->status(), 'body' => $response->body()]); + break; + } + + foreach ($response->json('products', []) as $product) { + foreach ($product['variants'] as $variant) { + $variants[] = [ + 'id' => (string) $variant['id'], + 'sku' => $variant['sku'] ?? '', + 'inventory_quantity' => (int) ($variant['inventory_quantity'] ?? 0), + 'product_title' => $product['title'], + ]; + } + } + + // Follow Shopify's Link header pagination + $url = $this->nextPageUrl($response->header('Link')); + + if ($url) { + usleep(500_000); // 0.5s — stay well within 2 req/s limit + } + } + + return $variants; + } + + private function nextPageUrl(?string $linkHeader): ?string + { + if (!$linkHeader) { + return null; + } + + // Link: ; rel="next" + if (preg_match('/<([^>]+)>;\s*rel="next"/', $linkHeader, $m)) { + return $m[1]; + } + + return null; + } +} diff --git a/config/services.php b/config/services.php index 27a3617..21ceac2 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,11 @@ return [ ], ], + 'shopify' => [ + 'domain' => env('SHOPIFY_SHOP_DOMAIN'), + 'access_token' => env('SHOPIFY_ACCESS_TOKEN'), + 'webhook_secret' => env('SHOPIFY_WEBHOOK_SECRET'), + 'api_version' => '2024-10', + ], + ]; diff --git a/database/migrations/2026_09_11_000001_shopify_variant_ids.php b/database/migrations/2026_09_11_000001_shopify_variant_ids.php new file mode 100644 index 0000000..24b6746 --- /dev/null +++ b/database/migrations/2026_09_11_000001_shopify_variant_ids.php @@ -0,0 +1,33 @@ +string('shopify_variant_id')->nullable()->unique()->after('sku'); + }); + + Schema::table('products_sales', function (Blueprint $table) { + $table->string('shopify_order_id')->nullable()->after('sold_at'); + $table->index('shopify_order_id'); + }); + } + + public function down(): void + { + Schema::table('products_sales', function (Blueprint $table) { + $table->dropIndex(['shopify_order_id']); + $table->dropColumn('shopify_order_id'); + }); + + Schema::table('products_sizes', function (Blueprint $table) { + $table->dropUnique(['shopify_variant_id']); + $table->dropColumn('shopify_variant_id'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 4b7aa30..364c0cc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,8 +1,13 @@ middleware('throttle:30,1') ->name('api.lot'); + +Route::post('/webhooks/shopify/orders-paid', [ShopifyWebhookController::class, 'ordersPaid']) + ->middleware(VerifyShopifyWebhook::class);