Scaffold Ladill Woo Manager for WooCommerce order fulfillment.
Deploy Ladill Woo Manager / deploy (push) Failing after 2s

Standalone app with SSO shell, WordPress plugin connect flow, webhook ingest,
fulfillment inbox, and plugin activation API — no payment processing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-06 22:39:38 +00:00
co-authored by Cursor
commit ffe0b9d877
326 changed files with 33210 additions and 0 deletions
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\WooStore;
use App\Services\Woo\InstallTokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class StoreActivationController extends Controller
{
public function __construct(private InstallTokenService $tokens) {}
public function activate(Request $request): JsonResponse
{
$validated = $request->validate([
'install_token' => ['required', 'string'],
'site_url' => ['required', 'url', 'max:500'],
]);
try {
$store = $this->tokens->consume($validated['install_token']);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
$parsed = parse_url($validated['site_url']);
$scheme = $parsed['scheme'] ?? 'https';
$host = strtolower((string) ($parsed['host'] ?? ''));
$siteUrl = rtrim($scheme.'://'.$host, '/');
if ($store->site_url !== $siteUrl) {
return response()->json(['message' => 'Site URL does not match the connection request.'], 422);
}
$pluginToken = $this->tokens->issuePluginToken($store);
$store = $store->fresh();
return response()->json([
'store_id' => $store->public_id,
'webhook_url' => $store->webhookUrl(),
'webhook_secret' => $store->webhook_secret,
'plugin_token' => $pluginToken,
'webhook_topics' => config('woo.webhook_topics'),
]);
}
public function show(Request $request): JsonResponse
{
$store = $this->storeFromRequest($request);
if (! $store) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
return response()->json([
'store_id' => $store->public_id,
'site_url' => $store->site_url,
'site_name' => $store->site_name,
'status' => $store->status,
'webhook_url' => $store->webhookUrl(),
]);
}
private function storeFromRequest(Request $request): ?WooStore
{
$storeId = (string) $request->header('X-Ladill-Store', '');
$token = (string) $request->bearerToken();
if ($storeId === '' || $token === '') {
return null;
}
$store = WooStore::query()->where('public_id', $storeId)->first();
if (! $store || ! $this->tokens->verifyPluginToken($store, $token)) {
return null;
}
return $store;
}
}
@@ -0,0 +1,338 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* "Sign in with Ladill" Authorization Code + PKCE against auth.ladill.com.
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
*/
class SsoLoginController extends Controller
{
/**
* Max consecutive failed callbacks before we stop bouncing back into the
* authorize flow and show an error page instead. Without this, a persistent
* upstream failure (e.g. the token endpoint erroring) produces an infinite
* /sso/callback /sso/connect redirect loop in the browser.
*/
private const MAX_SSO_ATTEMPTS = 3;
public function connect(Request $request): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('woo.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('woo.dashboard'));
}
// A fresh, user-initiated sign-in (not a post-failure retry) resets the
// loop guard so the counter only accrues across an actual failure loop.
if (! $request->boolean('fallback')) {
$request->session()->forget('sso.attempts');
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('woo.dashboard'));
}
$verifier = Str::random(64);
$state = Str::random(40);
$request->session()->put('sso.verifier', $verifier);
$request->session()->put('sso.state', $state);
$request->session()->put('sso.intended', $intended);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$query = [
'response_type' => 'code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
];
$loginHint = (string) $request->session()->get('sso.login_hint', '');
if ($loginHint !== '') {
$query['login_hint'] = $loginHint;
}
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
if (! $request->boolean('interactive') || $request->boolean('fallback')) {
$request->session()->forget('sso.popup');
}
if ($request->boolean('interactive') && ! $request->boolean('fallback')) {
$request->session()->forget('sso.popup');
return redirect()->away($authorizeUrl);
}
return redirect()->away($authorizeUrl);
}
public function callback(Request $request): RedirectResponse
{
$intended = (string) $request->session()->get('sso.intended', route('woo.dashboard'));
$popup = (bool) $request->session()->pull('sso.popup', false);
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->away((string) config('ladill.marketing_url'));
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return $this->finishCallback($request, $intended, 'invalid_state', $popup);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'code' => (string) $request->query('code'),
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
]);
if ($tokenRes->failed()) {
return $this->finishCallback($request, $intended, 'token_exchange_failed', $popup);
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
}
Auth::login($user, remember: true);
$request->session()->regenerate();
$request->session()->forget('sso.attempts');
return $this->finishCallback($request, $intended, null, $popup);
}
public function failed(Request $request): View
{
return view('auth.sso-error', [
'reason' => (string) $request->session()->get('sso.error', ''),
]);
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// Per-app sign-out: end only this app's session and keep the platform
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently
// without a password. Full "sign out of all Ladill apps" lives on account.
return redirect()->away($this->defaultSignedOutUrl());
}
/** Platform session ended — clear this app and offer silent sign-in again. */
public function platformSignedOut(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => (string) $request->query('redirect', ''),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
'embedded' => 1,
'return' => $return,
]);
return view('auth.sso-logout-bridge', [
'hubUrl' => $hubUrl,
'return' => $return,
'authOrigin' => 'https://'.config('app.auth_domain'),
]);
}
public function frontchannelLogout(Request $request): Response|RedirectResponse
{
if ($this->shouldLogoutForMailbox($request)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
$return = (string) $request->query('return', '');
$root = (string) config('app.platform_domain', 'ladill.com');
$host = parse_url($return, PHP_URL_HOST);
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($return);
}
return response('', 204);
}
private function attemptSilentRefresh(Request $request, string $intended): bool
{
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
if ($refreshToken === '') {
return false;
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'scope' => 'openid profile email',
]);
if ($tokenRes->failed()) {
$request->session()->forget('sso.refresh_token');
return false;
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return false;
}
Auth::login($user, remember: true);
$request->session()->put('sso.intended', $intended);
return true;
}
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
{
$refreshToken = (string) $tokenRes->json('refresh_token', '');
if ($refreshToken !== '') {
$request->session()->put('sso.refresh_token', $refreshToken);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return null;
}
$email = (string) ($claims->json('email') ?: '');
if ($email !== '') {
$request->session()->put('sso.login_hint', $email);
}
return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
}
private function shouldLogoutForMailbox(Request $request): bool
{
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
return true;
}
$user = Auth::user();
if (! $user) {
return false;
}
return strtolower((string) $user->email) !== $mailbox;
}
private function finishCallback(Request $request, string $intended, ?string $error = null, bool $popup = false): RedirectResponse
{
if ($error) {
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
$request->session()->flash('sso.error', $error);
return redirect()->route('sso.failed');
}
$request->session()->put('sso.attempts', $attempts);
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]);
}
return $this->safeRedirect($intended, route('woo.dashboard'));
}
private function defaultSignedOutUrl(): string
{
foreach (array_keys(Route::getRoutes()->getRoutesByName()) as $name) {
if (str_ends_with($name, '.signed-out')) {
return route($name);
}
}
return 'https://'.config('app.platform_domain');
}
private function safeReturnUrl(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return $url;
}
return $this->defaultSignedOutUrl();
}
private function safeRedirect(string $url, string $fallback): RedirectResponse
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($url);
}
return redirect()->away($fallback);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->user()
->notifications()
->latest()
->paginate(20);
return view('notifications.index', compact('notifications'));
}
public function unread(Request $request): JsonResponse
{
$notifications = $request->user()
->unreadNotifications()
->latest()
->take(10)
->get()
->map(fn ($n) => [
'id' => $n->id,
'type' => class_basename($n->type),
'title' => $n->data['title'] ?? 'Notification',
'message' => $n->data['message'] ?? '',
'icon' => $n->data['icon'] ?? 'bell',
'url' => $n->data['url'] ?? null,
'created_at' => $n->created_at->diffForHumans(),
]);
return response()->json([
'notifications' => $notifications,
'unread_count' => $request->user()->unreadNotifications()->count(),
]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$notification = $request->user()
->notifications()
->where('id', $id)
->first();
if ($notification) {
$notification->markAsRead();
}
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Lightweight wallet balance for the avatar dropdown widget.
*/
class WalletBalanceController extends Controller
{
public function balance(Request $request, BillingClient $billing): JsonResponse
{
$publicId = (string) $request->user()->public_id;
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
try {
return $billing->balanceMinor($publicId);
} catch (\Throwable) {
return null;
}
});
if ($minor === null) {
return response()->json(['available' => false]);
}
$currency = (string) config('billing.currency', 'GHS');
return response()->json([
'available' => true,
'balance_minor' => $minor,
'currency' => $currency,
'formatted' => $currency.' '.number_format($minor / 100, 2),
]);
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Models\WooStore;
use App\Services\Woo\InstallTokenService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class ConnectWordPressController extends Controller
{
public function __construct(private InstallTokenService $tokens) {}
public function start(Request $request): RedirectResponse|View
{
$validated = $request->validate([
'site_url' => ['required', 'url', 'max:500'],
'return_url' => ['required', 'url', 'max:2048'],
'site_name' => ['nullable', 'string', 'max:160'],
]);
$siteUrl = $this->normalizeSiteUrl($validated['site_url']);
if (! $this->isAllowedReturnUrl($validated['return_url'], $siteUrl)) {
abort(422, 'Return URL must belong to the WooCommerce site.');
}
$request->session()->put('woo.connect', [
'site_url' => $siteUrl,
'return_url' => $validated['return_url'],
'site_name' => $validated['site_name'] ?? null,
]);
if (! $request->user()) {
return redirect()->route('sso.connect', [
'redirect' => $request->fullUrl(),
'interactive' => 1,
]);
}
return $this->complete($request);
}
public function complete(Request $request): RedirectResponse|View
{
$pending = (array) $request->session()->pull('woo.connect', []);
if ($pending === []) {
return redirect()->route('woo.dashboard');
}
$user = $request->user();
abort_if(! $user, 401);
$siteUrl = (string) ($pending['site_url'] ?? '');
$returnUrl = (string) ($pending['return_url'] ?? '');
$store = WooStore::query()->updateOrCreate(
[
'user_id' => $user->id,
'site_url' => $siteUrl,
],
[
'site_name' => $pending['site_name'] ?? parse_url($siteUrl, PHP_URL_HOST),
'return_url' => $returnUrl,
'status' => WooStore::STATUS_PENDING,
],
);
$issued = $this->tokens->issue($store);
$separator = str_contains($returnUrl, '?') ? '&' : '?';
return redirect()->away($returnUrl.$separator.http_build_query([
'ladill_connect' => '1',
'install_token' => $issued['token'],
'store_id' => $store->public_id,
]));
}
private function normalizeSiteUrl(string $url): string
{
$parsed = parse_url($url);
$scheme = $parsed['scheme'] ?? 'https';
$host = strtolower((string) ($parsed['host'] ?? ''));
return rtrim($scheme.'://'.$host, '/');
}
private function isAllowedReturnUrl(string $returnUrl, string $siteUrl): bool
{
$returnHost = strtolower((string) (parse_url($returnUrl, PHP_URL_HOST) ?: ''));
$siteHost = strtolower((string) (parse_url($siteUrl, PHP_URL_HOST) ?: ''));
return $returnHost !== '' && $returnHost === $siteHost;
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Models\WooOrder;
use App\Models\WooStore;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(Request $request): View
{
$user = $request->user();
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
$stats = [
'stores' => WooStore::query()->where('user_id', $user->id)->where('status', WooStore::STATUS_ACTIVE)->count(),
'orders_new' => WooOrder::query()->whereIn('woo_store_id', $storeIds)->where('fulfillment_status', WooOrder::FULFILLMENT_NEW)->count(),
'orders_open' => WooOrder::query()->whereIn('woo_store_id', $storeIds)->whereNotIn('fulfillment_status', [
WooOrder::FULFILLMENT_DELIVERED,
WooOrder::FULFILLMENT_CANCELLED,
])->count(),
];
$recent = WooOrder::query()
->whereIn('woo_store_id', $storeIds)
->with('store')
->latest('created_at')
->limit(8)
->get();
return view('woo.dashboard', compact('stats', 'recent'));
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Models\WooOrder;
use App\Models\WooStore;
use App\Services\Woo\FulfillmentSyncService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class OrderController extends Controller
{
public function __construct(private FulfillmentSyncService $sync) {}
public function index(Request $request): View
{
$user = $request->user();
$search = trim((string) $request->query('q', ''));
$storeFilter = $request->query('store');
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
$orders = WooOrder::query()
->whereIn('woo_store_id', $storeIds)
->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter))
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('customer_name', 'like', $like)
->orWhere('customer_email', 'like', $like)
->orWhere('order_number', 'like', $like);
});
})
->with('store')
->latest('created_at')
->paginate(20)
->withQueryString();
$stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get();
return view('woo.orders.index', compact('orders', 'stores', 'search', 'storeFilter'));
}
public function updateStatus(Request $request, WooOrder $order): RedirectResponse
{
$order->load('store');
abort_if($order->store?->user_id !== $request->user()->id, 403);
$validated = $request->validate([
'fulfillment_status' => ['required', Rule::in(array_keys(WooOrder::FULFILLMENT_STATUSES))],
]);
$order->update(['fulfillment_status' => $validated['fulfillment_status']]);
$this->sync->push($order->fresh());
return back()->with('success', 'Fulfillment status updated.');
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Models\WooStore;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class StoreController extends Controller
{
public function index(Request $request): View
{
$stores = WooStore::query()
->where('user_id', $request->user()->id)
->latest('updated_at')
->get();
return view('woo.stores.index', compact('stores'));
}
public function destroy(Request $request, WooStore $store): RedirectResponse
{
abort_if($store->user_id !== $request->user()->id, 403);
$store->update(['status' => WooStore::STATUS_DISCONNECTED]);
return back()->with('success', 'Store disconnected. Install the Ladill plugin again to reconnect.');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\WooStore;
use App\Services\Woo\OrderIngestService;
use App\Services\Woo\WooWebhookVerifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WooWebhookController extends Controller
{
public function __construct(
private WooWebhookVerifier $verifier,
private OrderIngestService $ingest,
) {}
public function __invoke(Request $request, string $storePublicId): JsonResponse
{
$store = WooStore::query()
->where('public_id', $storePublicId)
->where('status', WooStore::STATUS_ACTIVE)
->first();
if (! $store) {
return response()->json(['message' => 'Store not found.'], 404);
}
if (! $this->verifier->verify($request, $store)) {
return response()->json(['message' => 'Invalid signature.'], 401);
}
$topic = (string) $request->header('X-WC-Webhook-Topic', '');
if (! in_array($topic, (array) config('woo.webhook_topics', []), true)) {
return response()->json(['ignored' => true]);
}
/** @var array<string, mixed> $payload */
$payload = $request->json()->all();
if ($payload === []) {
return response()->json(['ignored' => true]);
}
$order = $this->ingest->ingest($store, $payload);
return response()->json([
'ok' => true,
'order_id' => $order->id,
]);
}
}