Fix Woo sync: exempt webhooks from CSRF and backfill on connect.
Deploy Ladill Woo Manager / deploy (push) Successful in 39s
Deploy Ladill Woo Manager / deploy (push) Successful in 39s
Auto-import catalog and orders after store activation, add manual order sync, and allow WooCommerce webhooks without CSRF tokens. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Jobs\StoreBootstrapSync;
|
||||||
use App\Models\WooStore;
|
use App\Models\WooStore;
|
||||||
use App\Services\Woo\InstallTokenService;
|
use App\Services\Woo\InstallTokenService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -37,6 +38,8 @@ class StoreActivationController extends Controller
|
|||||||
$pluginToken = $this->tokens->issuePluginToken($store);
|
$pluginToken = $this->tokens->issuePluginToken($store);
|
||||||
$store = $store->fresh();
|
$store = $store->fresh();
|
||||||
|
|
||||||
|
StoreBootstrapSync::dispatch($store->id)->afterResponse();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'store_id' => $store->public_id,
|
'store_id' => $store->public_id,
|
||||||
'webhook_url' => $store->webhookUrl(),
|
'webhook_url' => $store->webhookUrl(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\WooOrder;
|
use App\Models\WooOrder;
|
||||||
use App\Models\WooStore;
|
use App\Models\WooStore;
|
||||||
use App\Services\Woo\FulfillmentSyncService;
|
use App\Services\Woo\FulfillmentSyncService;
|
||||||
|
use App\Services\Woo\OrderSyncService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -13,7 +14,10 @@ use Illuminate\View\View;
|
|||||||
|
|
||||||
class OrderController extends Controller
|
class OrderController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private FulfillmentSyncService $sync) {}
|
public function __construct(
|
||||||
|
private FulfillmentSyncService $sync,
|
||||||
|
private OrderSyncService $orderSync,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): View
|
public function index(Request $request): View
|
||||||
{
|
{
|
||||||
@@ -58,4 +62,25 @@ class OrderController extends Controller
|
|||||||
|
|
||||||
return back()->with('success', 'Fulfillment status updated.');
|
return back()->with('success', 'Fulfillment status updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sync(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'store' => ['required', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$store = WooStore::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->whereKey((int) $validated['store'])
|
||||||
|
->where('status', WooStore::STATUS_ACTIVE)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$count = $this->orderSync->syncOrders($store);
|
||||||
|
|
||||||
|
return back()->with('success', sprintf(
|
||||||
|
'Synced %d orders from %s.',
|
||||||
|
$count,
|
||||||
|
$store->site_name ?? $store->site_url,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use App\Services\Woo\StoreSyncService;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class StoreBootstrapSync
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public int $storeId) {}
|
||||||
|
|
||||||
|
public function handle(StoreSyncService $sync): void
|
||||||
|
{
|
||||||
|
$store = WooStore::query()
|
||||||
|
->whereKey($this->storeId)
|
||||||
|
->where('status', WooStore::STATUS_ACTIVE)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $store) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = $sync->syncAll($store);
|
||||||
|
|
||||||
|
Log::info('Woo store bootstrap sync completed', [
|
||||||
|
'store_id' => $store->id,
|
||||||
|
'counts' => $counts,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Woo;
|
||||||
|
|
||||||
|
use App\Models\WooStore;
|
||||||
|
|
||||||
|
class OrderSyncService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PluginClient $client,
|
||||||
|
private OrderIngestService $orders,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function syncOrders(WooStore $store): int
|
||||||
|
{
|
||||||
|
$response = $this->client->get($store, 'orders', ['per_page' => 100]);
|
||||||
|
if (! is_array($response)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $response['data'] ?? $response;
|
||||||
|
if (! is_array($items)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (! is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->orders->ingest($store, $item);
|
||||||
|
$count++;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Woo;
|
||||||
|
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class StoreSyncService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CatalogSyncService $catalog,
|
||||||
|
private OrderSyncService $orders,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array{categories: int, products: int, orders: int} */
|
||||||
|
public function syncAll(WooStore $store): array
|
||||||
|
{
|
||||||
|
$counts = [
|
||||||
|
'categories' => 0,
|
||||||
|
'products' => 0,
|
||||||
|
'orders' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$catalogCounts = $this->catalog->syncAll($store);
|
||||||
|
$counts['categories'] = $catalogCounts['categories'];
|
||||||
|
$counts['products'] = $catalogCounts['products'];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Woo catalog sync failed', [
|
||||||
|
'store_id' => $store->id,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$counts['orders'] = $this->orders->syncOrders($store);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Woo order sync failed', [
|
||||||
|
'store_id' => $store->id,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
'webhooks/woocommerce/*',
|
||||||
|
]);
|
||||||
|
|
||||||
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
|
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
|
||||||
'redirect' => $request->fullUrl(),
|
'redirect' => $request->fullUrl(),
|
||||||
]));
|
]));
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
<x-user-layout>
|
<x-user-layout>
|
||||||
<x-slot name="title">Orders</x-slot>
|
<x-slot name="title">Orders</x-slot>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<h1 class="text-xl font-semibold text-slate-900">Orders</h1>
|
<div>
|
||||||
<p class="mt-1 text-sm text-slate-500">WooCommerce orders synced for fulfillment.</p>
|
<h1 class="text-xl font-semibold text-slate-900">Orders</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">WooCommerce orders synced for fulfillment.</p>
|
||||||
|
</div>
|
||||||
|
@if($stores->isNotEmpty())
|
||||||
|
<form method="post" action="{{ route('woo.orders.sync') }}">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="store" value="{{ $storeFilter ?: $stores->first()->id }}">
|
||||||
|
<button type="submit" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Sync from store</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="get" class="flex flex-wrap items-end gap-3">
|
<form method="get" class="flex flex-wrap items-end gap-3">
|
||||||
@@ -23,7 +32,7 @@
|
|||||||
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||||
@if($orders->isEmpty())
|
@if($orders->isEmpty())
|
||||||
<p class="px-6 py-10 text-sm text-slate-500">No orders yet. Connect a store and place a test order in WooCommerce.</p>
|
<p class="px-6 py-10 text-sm text-slate-500">No orders yet. Use <strong>Sync from store</strong> to import existing orders, or wait for new WooCommerce order events.</p>
|
||||||
@else
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
|||||||
|
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('woo.dashboard');
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('woo.dashboard');
|
||||||
Route::get('/orders', [OrderController::class, 'index'])->name('woo.orders.index');
|
Route::get('/orders', [OrderController::class, 'index'])->name('woo.orders.index');
|
||||||
|
Route::post('/orders/sync', [OrderController::class, 'sync'])->name('woo.orders.sync');
|
||||||
Route::patch('/orders/{order}/status', [OrderController::class, 'updateStatus'])->name('woo.orders.update-status');
|
Route::patch('/orders/{order}/status', [OrderController::class, 'updateStatus'])->name('woo.orders.update-status');
|
||||||
Route::get('/products', [ProductController::class, 'index'])->name('woo.products.index');
|
Route::get('/products', [ProductController::class, 'index'])->name('woo.products.index');
|
||||||
Route::get('/products/create', [ProductController::class, 'create'])->name('woo.products.create');
|
Route::get('/products/create', [ProductController::class, 'create'])->name('woo.products.create');
|
||||||
|
|||||||
@@ -230,6 +230,59 @@ class WooWebTest extends TestCase
|
|||||||
->assertSee('Manage connected stores');
|
->assertSee('Manage connected stores');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_store_activation_dispatches_bootstrap_sync(): void
|
||||||
|
{
|
||||||
|
\Illuminate\Support\Facades\Bus::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$store = WooStore::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'site_url' => 'https://shop.example.com',
|
||||||
|
'status' => WooStore::STATUS_PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = app(InstallTokenService::class)->issue($store)['token'];
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/stores/activate', [
|
||||||
|
'install_token' => $token,
|
||||||
|
'site_url' => 'https://shop.example.com',
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Bus::assertDispatched(\App\Jobs\StoreBootstrapSync::class, fn ($job) => $job->storeId === $store->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_webhook_accepts_post_without_csrf_token(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$store = WooStore::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'site_url' => 'https://shop.example.com',
|
||||||
|
'status' => WooStore::STATUS_ACTIVE,
|
||||||
|
'connected_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = ['id' => 99, 'number' => '1099', 'status' => 'processing', 'currency' => 'GHS', 'total' => '10.00', 'billing' => ['email' => 'a@b.com'], 'line_items' => [['name' => 'Item', 'quantity' => 1, 'total' => '10.00']], 'needs_payment' => false];
|
||||||
|
$body = json_encode($payload, JSON_THROW_ON_ERROR);
|
||||||
|
$signature = base64_encode(hash_hmac('sha256', $body, (string) $store->webhook_secret, true));
|
||||||
|
|
||||||
|
$this->withMiddleware(\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class)
|
||||||
|
->call(
|
||||||
|
'POST',
|
||||||
|
route('woo.webhooks.woocommerce', $store->public_id),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X_WC_WEBHOOK_SIGNATURE' => $signature,
|
||||||
|
'HTTP_X_WC_WEBHOOK_TOPIC' => 'order.updated',
|
||||||
|
],
|
||||||
|
$body,
|
||||||
|
)->assertOk();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('woo_orders', ['external_order_id' => 99]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_merchant_can_view_products_page(): void
|
public function test_merchant_can_view_products_page(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
Reference in New Issue
Block a user