Fix Woo sync: exempt webhooks from CSRF and backfill on connect.
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:
isaacclad
2026-07-07 00:15:37 +00:00
co-authored by Cursor
parent ec138a903f
commit e3ef23218f
9 changed files with 222 additions and 5 deletions
@@ -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(),
+26 -1
View File
@@ -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,
));
}
} }
+34
View File
@@ -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,
]);
}
}
+42
View File
@@ -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;
}
}
+46
View File
@@ -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;
}
}
+4
View File
@@ -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(),
])); ]));
+10 -1
View File
@@ -1,10 +1,19 @@
<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 class="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h1 class="text-xl font-semibold text-slate-900">Orders</h1> <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> <p class="mt-1 text-sm text-slate-500">WooCommerce orders synced for fulfillment.</p>
</div> </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>
<form method="get" class="flex flex-wrap items-end gap-3"> <form method="get" class="flex flex-wrap items-end gap-3">
<div class="min-w-[12rem] flex-1 max-w-md"> <div class="min-w-[12rem] flex-1 max-w-md">
@@ -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">
+1
View File
@@ -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');
+53
View File
@@ -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();