Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/stage7-checkout-safety.yml
Original file line number Diff line number Diff line change
@@ -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
57 changes: 34 additions & 23 deletions backend/app/Http/Controllers/Api/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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([
Expand Down Expand Up @@ -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;
});
Expand Down
37 changes: 10 additions & 27 deletions backend/app/Http/Controllers/Api/OrderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions backend/tests/Feature/Providers/CheckoutFulfillmentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
}

}
Loading
Loading