diff --git a/.github/workflows/stage7-checkout-safety.yml b/.github/workflows/stage7-checkout-safety.yml new file mode 100644 index 0000000..a4ff61a --- /dev/null +++ b/.github/workflows/stage7-checkout-safety.yml @@ -0,0 +1,69 @@ +name: Stage 7 checkout safety + +on: + pull_request: + branches: [main] + paths: + - "backend/**" + - "frontend/**" + - ".github/workflows/stage7-checkout-safety.yml" + push: + branches: [stage7-checkout-safety] + +permissions: + contents: read + +jobs: + backend-tests: + name: Laravel feature and unit tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: pdo_pgsql,pgsql,pdo_sqlite,sqlite3,mbstring,openssl,tokenizer,xml,curl,ctype,fileinfo + coverage: none + + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist + + - name: Prepare Laravel application + run: | + cp .env.example .env + php artisan key:generate + + - name: Run Laravel tests + run: php artisan test + + frontend-build: + name: Next.js production build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + env: + NEXT_PUBLIC_APP_NAME: CharpsDev + NEXT_PUBLIC_API_URL: http://127.0.0.1:8000/api + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install npm dependencies + run: npm ci + + - name: Build frontend + run: npm run build diff --git a/backend/app/Http/Controllers/Api/CheckoutController.php b/backend/app/Http/Controllers/Api/CheckoutController.php index 18c3ade..aa2cd73 100644 --- a/backend/app/Http/Controllers/Api/CheckoutController.php +++ b/backend/app/Http/Controllers/Api/CheckoutController.php @@ -48,26 +48,6 @@ public function store(Request $request, WebPushService $webPush, NotificationSer { $userId = $request->user()->id; - $cartItems = CartItem::with('service') - ->where('user_id', $userId) - ->get(); - - if ($cartItems->isEmpty()) { - return response()->json([ - 'success' => false, - 'message' => 'Your cart is empty.', - ], 422); - } - - foreach ($cartItems as $item) { - if (! $item->service || ! $item->service->active) { - return response()->json([ - 'success' => false, - 'message' => "\"{$item->service?->name}\" is no longer available. Please remove it from your cart.", - ], 422); - } - } - $couponCode = $request->filled('coupon_code') ? Str::upper(trim($request->input('coupon_code'))) : null; // Phase 6 (more notification triggers): populated (by reference) @@ -78,18 +58,47 @@ public function store(Request $request, WebPushService $webPush, NotificationSer $lowStockAlerts = []; try { - $order = DB::transaction(function () use ($cartItems, $userId, $couponCode, &$lowStockAlerts) { + $order = DB::transaction(function () use ($userId, $couponCode, &$lowStockAlerts) { + // Serialize checkouts for this user before reading their cart. + // A second checkout waits, then sees the first checkout's + // committed cart deletion instead of charging the same items. + User::query()->whereKey($userId)->lockForUpdate()->firstOrFail(); + + // Read and lock the current cart inside the same transaction + // that debits the wallet. Never trust a cart snapshot loaded + // before the transaction began. + $cartItems = CartItem::query() + ->where('user_id', $userId) + ->orderBy('id') + ->lockForUpdate() + ->get(); + + if ($cartItems->isEmpty()) { + throw ValidationException::withMessages([ + 'cart' => 'Your cart is empty.', + ]); + } + + $cartItemIds = $cartItems->modelKeys(); + // Lock every affected service row to prevent concurrent // checkouts from overselling limited stock. $lockedServices = Service::query() ->whereIn('id', $cartItems->pluck('service_id')) + ->orderBy('id') ->lockForUpdate() ->get() ->keyBy('id'); $subtotal = 0.0; foreach ($cartItems as $item) { - $service = $lockedServices[$item->service_id]; + $service = $lockedServices->get($item->service_id); + + if (! $service || ! $service->active) { + throw ValidationException::withMessages([ + 'cart' => 'A service in your cart is no longer available. Please refresh your cart.', + ]); + } if (! $service->hasStockFor($item->quantity)) { throw ValidationException::withMessages([ @@ -206,7 +215,9 @@ public function store(Request $request, WebPushService $webPush, NotificationSer 'description' => 'Purchase: order '.$reference.' ('.$cartItems->count().' item'.($cartItems->count() === 1 ? '' : 's').')', ]); - CartItem::where('user_id', $userId)->delete(); + // Delete only the cart rows included in this checkout. A + // newly-added row must not disappear without being charged. + CartItem::where('user_id', $userId)->whereIn('id', $cartItemIds)->delete(); return $order; }); diff --git a/backend/app/Http/Controllers/Api/OrderController.php b/backend/app/Http/Controllers/Api/OrderController.php index 71214bf..c0c4263 100644 --- a/backend/app/Http/Controllers/Api/OrderController.php +++ b/backend/app/Http/Controllers/Api/OrderController.php @@ -3,11 +3,8 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Http\Requests\StoreOrderRequest; use App\Models\Order; -use App\Models\Service; use Illuminate\Http\Request; -use Illuminate\Support\Str; class OrderController extends Controller { @@ -25,32 +22,18 @@ public function index(Request $request) ]); } - public function store(StoreOrderRequest $request) + /** + * Legacy direct order creation bypassed wallet debit and stock handling. + * Purchases must go through POST /api/checkout so payment, order creation, + * stock changes, and ledger entries remain atomic. + */ + public function store() { - $data = $request->validated(); - $service = Service::query()->where('active', true)->findOrFail($data['service_id']); - $quantity = (int) $data['quantity']; - $amount = (float) $service->price * $quantity; - - $order = Order::create([ - 'user_id' => $request->user()->id, - 'service_id' => $service->id, - 'reference' => 'ORD-' . Str::upper(Str::random(10)), - 'quantity' => $quantity, - 'amount' => $amount, - 'details' => [ - 'service_name' => $service->name, - 'unit_price' => (float) $service->price, - 'quantity' => $quantity, - ], - 'status' => 'pending', - ]); - return response()->json([ - 'success' => true, - 'message' => 'Order created successfully.', - 'data' => $order->load('service'), - ], 201); + 'success' => false, + 'message' => 'Direct order creation is disabled. Add services to your cart and complete wallet checkout.', + 'checkout_url' => '/api/checkout', + ], 410); } public function show(Order $order, Request $request) diff --git a/backend/app/Services/FulfillmentProviders/MockFulfillmentProvider.php b/backend/app/Services/FulfillmentProviders/MockFulfillmentProvider.php index 6c3ec04..72f91f6 100644 --- a/backend/app/Services/FulfillmentProviders/MockFulfillmentProvider.php +++ b/backend/app/Services/FulfillmentProviders/MockFulfillmentProvider.php @@ -48,6 +48,16 @@ private function mode(): string public function fulfill(array $request): array { + if (app()->environment('production')) { + // This adapter is only a test double. Never let it manufacture a + // successful delivery for a real customer or trigger a false + // completed order in a live environment. + throw new FulfillmentException( + 'Mock fulfilment is disabled in production. Configure a real provider adapter.', + FulfillmentErrorType::Ambiguous, + ); + } + $mode = $this->mode(); return match ($mode) { @@ -90,6 +100,13 @@ public function fulfill(array $request): array public function checkStatus(string $reference): array { + if (app()->environment('production')) { + // The mock cannot know whether an upstream transaction completed. + // Keep the outcome unresolved so ProviderRouter sends it for + // manual review rather than treating a mock response as delivery. + return ['status' => 'pending', 'raw' => ['reference' => $reference, 'blocked' => 'mock adapter in production']]; + } + // Only meaningfully exercised by the 'ambiguous_timeout' mode in // tests: the fulfill() call above threw Ambiguous, but the upstream // actually DID complete the transaction, which is exactly the diff --git a/backend/tests/Feature/Providers/CheckoutFulfillmentTest.php b/backend/tests/Feature/Providers/CheckoutFulfillmentTest.php index 5a16bba..9fead4f 100644 --- a/backend/tests/Feature/Providers/CheckoutFulfillmentTest.php +++ b/backend/tests/Feature/Providers/CheckoutFulfillmentTest.php @@ -9,6 +9,9 @@ use App\Models\ServiceProviderRoute; use App\Models\User; use App\Models\Wallet; +use App\Services\FulfillmentProviders\FulfillmentErrorType; +use App\Services\FulfillmentProviders\MockFulfillmentProvider; +use App\Services\FulfillmentProviders\FulfillmentException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; use Tests\TestCase; @@ -160,4 +163,54 @@ public function test_checkout_with_all_providers_down_leaves_order_pending_for_m $response->assertJsonPath('data.status', 'pending'); $this->assertSame(9500.0, (float) $user->wallet->fresh()->balance); } + public function test_second_checkout_cannot_charge_the_same_cart_twice(): void + { + $user = $this->makeUserWithWallet(10000); + $service = $this->makeService(500); + CartItem::create(['user_id' => $user->id, 'service_id' => $service->id, 'quantity' => 1]); + + $first = $this->actingAs($user)->postJson('/api/checkout', []); + $first->assertStatus(201); + + $second = $this->actingAs($user)->postJson('/api/checkout', []); + $second->assertStatus(422); + $second->assertJsonPath('message', 'Your cart is empty.'); + + $this->assertSame(9500.0, (float) $user->wallet->fresh()->balance); + $this->assertSame(1, Order::where('user_id', $user->id)->count()); + } + + public function test_legacy_direct_order_endpoint_is_disabled(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson('/api/orders', [ + 'service_id' => 1, + 'quantity' => 1, + ]); + + $response->assertStatus(410); + $response->assertJsonPath('success', false); + } + + public function test_mock_provider_cannot_claim_delivery_in_production(): void + { + $provider = new Provider([ + 'slug' => 'unimplemented-provider', + 'api_key' => 'mock:success', + 'base_url' => '', + ]); + $adapter = new MockFulfillmentProvider('mock:success'); + $this->app['env'] = 'production'; + + try { + $adapter->fulfill(['reference' => 'ORD-TEST']); + $this->fail('A mock provider must not complete an order in production.'); + } catch (FulfillmentException $exception) { + $this->assertSame(FulfillmentErrorType::Ambiguous, $exception->errorType); + } + + $this->assertSame('pending', $adapter->checkStatus('ORD-TEST')['status']); + } + } diff --git a/frontend/app/(dashboard)/orders/page.tsx b/frontend/app/(dashboard)/orders/page.tsx index 87af255..a704fe2 100644 --- a/frontend/app/(dashboard)/orders/page.tsx +++ b/frontend/app/(dashboard)/orders/page.tsx @@ -1,20 +1,18 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { PackageCheckIcon, RepeatIcon, ShoppingCartIcon, StarIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { EmptyBlock, ErrorBlock, TableSkeleton } from "@/components/common/StateBlock"; import ReviewFormRow from "@/components/common/ReviewFormRow"; import { useAddToCartMutation } from "@/hooks/queries/useCartQueries"; -import { useCreateOrderMutation, useOrdersQuery } from "@/hooks/queries/useOrdersQueries"; -import { useServicesQuery } from "@/hooks/queries/useServicesQuery"; +import { useOrdersQuery } from "@/hooks/queries/useOrdersQueries"; import { extractErrorMessage } from "@/lib/api"; import { formatCurrency, formatDate } from "@/lib/format"; import type { Order } from "@/types/api"; @@ -45,15 +43,10 @@ function orderLines(order: Order): Array<{ service_id: number; quantity: number; export default function OrdersPage() { const router = useRouter(); const orders = useOrdersQuery(); - const services = useServicesQuery(); - const createOrder = useCreateOrderMutation(); const addToCart = useAddToCartMutation(); const searchParams = useSearchParams(); const placedOrderId = searchParams.get("placed"); const highlightRef = searchParams.get("ref"); - const [serviceId, setServiceId] = useState(""); - const [quantity, setQuantity] = useState("1"); - const [message, setMessage] = useState(null); const highlightedRowRef = useRef(null); const [expandedOrderIds, setExpandedOrderIds] = useState>(new Set()); const [expandedReviewIds, setExpandedReviewIds] = useState>(new Set()); @@ -111,26 +104,11 @@ export default function OrdersPage() { // until the table has actually rendered past the loading skeleton). }, [highlightRef, orders.data]); - const firstServiceId = useMemo(() => services.data?.[0]?.id?.toString() || "", [services.data]); - const selectedServiceId = serviceId || firstServiceId; - - if (orders.isPending || services.isPending) return ; - if (orders.error || services.error) { - return ; + if (orders.isPending) return ; + if (orders.error) { + return ; } - const onCreateOrder = async (event: React.FormEvent) => { - event.preventDefault(); - if (!selectedServiceId) return; - setMessage(null); - try { - const response = await createOrder.mutateAsync({ service_id: Number(selectedServiceId), quantity: Number(quantity) }); - setMessage(response.message || `Order #${response.data.id} created.`); - } catch (error) { - setMessage(extractErrorMessage(error, "Failed to create order.")); - } - }; - const rows = orders.data?.data || []; return ( @@ -138,7 +116,7 @@ export default function OrdersPage() {

Orders

-

Create new orders and track their status.

+

Track your purchases, delivery status, and order history.

) : null} - - - Create order - - -
-
- - setQuantity(e.target.value)} aria-label="Quantity" /> -
- - {message ?

{message}

: null} -
-
-
{!rows.length ? ( } variant="outline" size="sm">