Skip to content

About

Laravel marketplace for digital goods with exactly-once key delivery, idempotent payment webhooks, and promo limits under concurrency.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

GameMarket — Digital Goods Marketplace

Production-quality Laravel demo for a GGSel-style digital goods shop.

Engineering focus: exactly-once key delivery, idempotent payment webhooks, safe behavior under concurrent requests, recovery after stock/provider failures, and promo-code usage limits under concurrency.

Stack: Laravel 11 · PHP 8.3 · PostgreSQL 16 · Blade · vanilla JS · Vite · Docker


Table of contents

  1. Tech stack
  2. Project structure
  3. Prerequisites
  4. Installation
  5. Environment variables
  6. Running the application
  7. Manual purchase flow (UI)
  8. API reference
  9. Order lifecycle
  10. Seed data
  11. Admin & recovery
  12. Running tests
  13. Race reproduction scripts
  14. Architecture — exactly-once delivery
  15. Troubleshooting
  16. Submission checklist

Tech stack

Layer Technology
Backend PHP 8.2+, Laravel 11
Database PostgreSQL 14+ (Docker image: 16-alpine)
Frontend HTML, CSS, vanilla JavaScript, Blade templates
Build Vite 6, laravel-vite-plugin
Tests PHPUnit 11
Containers Docker, Docker Compose
Queue sync (no Redis/RabbitMQ)

No React/Vue/Angular. No real payment gateway — payment is simulated via webhooks.


Project structure

app/
  Console/Commands/       # e.g. app:test-payment-race
  Enums/                  # OrderStatus, ProductType, …
  Http/Controllers/       # Storefront, orders, webhooks, admin, mock providers
  Services/
    Orders/               # CreateOrder, OrderStateMachine
    Payments/             # ProcessPaymentWebhook, ReconcilePendingPaymentEvents
    Delivery/             # DeliverOrder, AllocateInventoryKey, IssueViaProvider
    Promo/                # ApplyPromoCode, ReservePromoCode
database/
  migrations/             # PostgreSQL schema, unique indexes, partial indexes
  seeders/                # Products, 50 CS2 keys, promo codes
resources/
  views/                  # storefront, orders/show, admin/recovery
  css/ js/                # Vite entrypoints
routes/web.php            # Web UI + JSON API under /api/*
scripts/                  # Shell scripts to reproduce race conditions
tests/Feature/            # Concurrency & idempotency tests
docker-compose.yml        # postgres + app (php artisan serve)

Prerequisites

Recommended (Docker):

  • Docker & Docker Compose
  • Node.js 18+ and npm (for frontend build)
  • curl, jq or php CLI (for race scripts)

Alternative (local PHP):

  • PHP 8.2+ with pdo_pgsql
  • Composer 2
  • PostgreSQL 14+
  • Node.js 18+ and npm

Installation

Option A — Docker (recommended)

From the project root:

# 1. PHP dependencies (can also run inside container — see below)
composer install

# 2. Environment
cp .env.example .env

# 3. Generate app key (required — empty APP_KEY causes 500 errors)
docker compose run --rm app php artisan key:generate
# Copy the generated APP_KEY into .env if the command prints it there

# 4. Start PostgreSQL
docker compose up -d postgres

# 5. Wait until Postgres is healthy, then migrate + seed
docker compose run --rm app php artisan migrate:fresh --seed

# 6. Frontend assets
npm install
npm run build

# 7. Start the app
docker compose up app

Open http://localhost:8000

Note: Inside Docker, DB_HOST=postgres and DB_PORT=5432 are set in docker-compose.yml.
On the host machine (local php artisan), use DB_HOST=127.0.0.1 and DB_PORT=5433.

If you don't have Composer on the host:

docker compose run --rm app composer install

Option B — Local PHP + Docker Postgres only

composer install
cp .env.example .env
php artisan key:generate

# Point .env at Docker Postgres on host port 5433:
# DB_HOST=127.0.0.1
# DB_PORT=5433
# DB_DATABASE=test_shop
# DB_USERNAME=test_shop
# DB_PASSWORD=secret

docker compose up -d postgres
php artisan migrate:fresh --seed
npm install && npm run build
php artisan serve
# → http://127.0.0.1:8000

Reset database

docker compose exec app php artisan migrate:fresh --seed

Environment variables

Variable Default Description
APP_KEY — Required. Laravel encryption key. Run php artisan key:generate.
APP_URL http://localhost:8000 Base URL for links and provider callbacks
DB_CONNECTION pgsql Database driver
DB_HOST postgres (Docker) / 127.0.0.1 (host) PostgreSQL host
DB_PORT 5432 (Docker) / 5433 (host) PostgreSQL port
DB_DATABASE test_shop Database name
DB_USERNAME test_shop Database user
DB_PASSWORD secret Database password
ADMIN_TOKEN dev-admin-token Bearer token for /api/admin/*
QUEUE_CONNECTION sync Jobs run inline (no worker needed)
PROVIDER_A_URL mock provider A endpoint Used for provider-type products
PROVIDER_B_URL mock provider B endpoint Fallback provider
PROVIDER_A_SUCCESS_RATE 100 % success (mock)
PROVIDER_A_SERVER_ERROR_RATE 0 % HTTP 500
PROVIDER_A_TIMEOUT_RATE 0 % HTTP 504 timeout
PROVIDER_A_DELAY_MS 0 Artificial delay before response
PROVIDER_B_* same pattern Provider B tuning

Money: all amounts are whole rubles (RUB), not kopecks.

Example webhook amount for CS2 Prime: "amount": 1290


Running the application

Start

docker compose up app          # foreground, logs in terminal
docker compose up -d app       # background

Stop

docker compose down

Rebuild after code changes

Change Command
PHP / Blade Usually auto-reloads (volume mount). Restart if needed: docker compose restart app
JS / CSS npm run build (or npm run dev for hot reload)
.env / config docker compose up -d --force-recreate app
Migrations docker compose exec app php artisan migrate

Development frontend (hot reload)

npm run dev

Run the Laravel app separately (docker compose up app). Vite serves assets from port 5173.


Manual purchase flow (UI)

This is the full path a reviewer can click through without curl.

1. Open the storefront

http://localhost:8000

You'll see: header (Каталог, search), hero carousel (auto-rotates every 5s), service icons, Steam top-up widget, and 5 product cards.

2. Create an order

Click Купить on any product card.

  • Creates order via POST /api/orders
  • Redirects to /orders/{public_id}

Product types:

Type Examples Delivery
inventory KEY-CS2-PRIME, KEY-GTA5, KEY-EFT Key from inventory_keys table
provider STEAM-TOPUP-500, subscriptions, gift cards Code from mock provider API

Only KEY-CS2-PRIME has 50 seeded keys. Other inventory SKUs will reach out_of_stock after payment unless you add keys via admin API.

3. Simulate payment

On the order page:

Button Effect
Симулировать оплату Sends paid webhook → triggers delivery
Симулировать ошибку оплаты Sends failed webhook → order becomes payment_failed

The simulator calls the same ProcessPaymentWebhook service as a real payment gateway would.

4. Get the key / code

After successful payment:

  • Inventory product: key appears in the Ключ field (e.g. LFXC-TNCS-BPCD)
  • Provider product: generated code appears (e.g. ABCD-EFGH-IJKL)

Status should become delivered.

5. Promo codes (API only)

Promo codes work when creating an order via API:

curl -X POST http://localhost:8000/api/orders \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: promo-test-1" \
  -d '{"sku":"KEY-CS2-PRIME","promo_code":"GG500"}'
Code Type Effect Max uses
WELCOME10 10% off — 100
GG500 Fixed −500 ₽ 20
LIMIT3 25% off — 3 (concurrency test)
ONCEONLY 50% off — 1

API reference

All JSON API routes live under /api/* and are excluded from CSRF verification.

Create order

POST /api/orders
Content-Type: application/json
Idempotency-Key: <unique-uuid>
Accept: application/json

{
  "sku": "KEY-CS2-PRIME",
  "promo_code": null
}

Response 201:

{
    "data": {
        "id": "ord_abc123",
        "status": "created",
        "product": {
            "sku": "KEY-CS2-PRIME",
            "name": "...",
            "type": "inventory"
        },
        "amounts": {
            "original": 1290,
            "discount": 0,
            "final": 1290,
            "currency": "RUB"
        },
        "payment": { "paid_at": null, "is_paid": false },
        "delivery": { "status": "created", "code": null, "recoverable": false }
    }
}

Idempotency: repeating the same Idempotency-Key returns the same order (no duplicate rows).

Get order

GET /api/orders/{public_id}
Accept: application/json

Payment webhook

POST /api/webhooks/payment
Content-Type: application/json

{
  "event_id": "evt_unique_001",
  "order_id": "ord_abc123",
  "status": "paid",
  "amount": 1290,
  "currency": "RUB",
  "created_at": "2025-01-01T12:00:00Z"
}
Field Rules
event_id Unique per webhook event. Duplicates are ignored (idempotent).
order_id Order public_id
status paid or failed
amount Must match order final_amount exactly
currency Must match order currency (RUB)

If order doesn't exist yet: event is stored as pending_order and reconciled when the order is created.

Payment simulator

Same as webhook, but generates event_id automatically:

POST /api/payments/simulate/{public_id}
Content-Type: application/json

{ "status": "paid" }

Or { "status": "failed" }.

Admin — recovery queue

GET /api/admin/orders/recovery
Authorization: Bearer dev-admin-token
Accept: application/json

Returns orders in: paid, delivering, out_of_stock, delivery_failed.

Admin — retry delivery

POST /api/admin/orders/{public_id}/retry-delivery
Authorization: Bearer dev-admin-token

Re-runs delivery for recoverable orders (after stock replenishment or provider recovery).

Admin — add inventory keys

POST /api/admin/inventory
Authorization: Bearer dev-admin-token
Content-Type: application/json

{
  "sku": "KEY-GTA5",
  "codes": ["GTA5-KEY-001", "GTA5-KEY-002"]
}

Mock provider (internal / testing)

POST /api/mock/providers/a/issue
Content-Type: application/json

{
  "request_id": "req_ord_abc-1",
  "sku": "STEAM-TOPUP-500",
  "order_id": "ord_abc123"
}

Configure behavior via PROVIDER_A_* env vars.


Order lifecycle

created ──paid webhook──► paid ──delivery──► delivering ──success──► delivered
   │                         │
   │                         └── out_of_stock / delivery_failed (recoverable)
   │
   └── failed webhook ──► payment_failed (terminal)
Status Meaning
created Order placed, awaiting payment
paid Payment confirmed, delivery pending or in progress
delivering Delivery attempt in progress
delivered Key/code issued (terminal success)
payment_failed Payment rejected (terminal)
out_of_stock No inventory keys left — recoverable via admin retry after restock
delivery_failed Provider error — recoverable via admin retry

Seed data

Products (12 total, first 5 shown on storefront)

SKU Name Type Price (₽)
STEAM-TOPUP-500 Пополнение Steam 500 ₽ provider 500
STEAM-TOPUP-1000 Пополнение Steam 1000 ₽ provider 1000
STEAM-TOPUP-2500 Пополнение Steam 2500 ₽ provider 2500
KEY-CS2-PRIME CS2 Prime Status ключ inventory 1290
KEY-GTA5 GTA V ключ активации inventory 1990
KEY-EFT Escape from Tarkov ключ inventory 3490
… subscriptions & gift cards provider 299–1490

Inventory keys

  • 50 keys seeded for KEY-CS2-PRIME only (e.g. LFXC-TNCS-BPCD, P3EI-W8UO-9B4K, …)

Promo codes

See Promo codes above.


Admin & recovery

Web UI

http://localhost:8000/admin/recovery

Lists orders needing attention (paid, delivering, out_of_stock, delivery_failed).

Typical recovery scenario

  1. Customer pays for KEY-GTA5 (no keys in seed)
  2. Order becomes out_of_stock
  3. Admin adds keys:
curl -X POST http://localhost:8000/api/admin/inventory \
  -H "Authorization: Bearer dev-admin-token" \
  -H "Content-Type: application/json" \
  -d '{"sku":"KEY-GTA5","codes":["GTA5-RECOVER-001"]}'
  1. Admin retries delivery:
curl -X POST http://localhost:8000/api/admin/orders/ord_xxx/retry-delivery \
  -H "Authorization: Bearer dev-admin-token"
  1. Order → delivered with one key

Running tests

Create test database (once)

docker compose exec postgres psql -U test_shop -d test_shop \
  -c "CREATE DATABASE test_shop_test;" 2>/dev/null || true

Tests use test_shop_test (see phpunit.xml).

Run all tests

docker compose exec app php artisan test
# or
docker compose run --rm app vendor/bin/phpunit

What's covered

Test Scenario
Duplicate event_id Same webhook processed once
Multiple event_ids Many paid events → one delivery
Webhook before order Event stored, reconciled on order create
Empty inventory out_of_stock → retry after restock
Provider timeout Same request_id → same code on retry
Promo LIMIT3 ≤ 3 successful uses under concurrency
Idempotency key Same key → same order

Race reproduction scripts

Prerequisite: app running at http://localhost:8000

Scripts need jq or php on the host for JSON parsing (scripts/lib.sh).

chmod +x scripts/*.sh   # if needed
Script What it proves
./scripts/test-payment-race.sh 50 parallel paid webhooks → 1 delivery, 1 key
./scripts/test-duplicate-webhook.sh Same event_id repeated → no extra delivery
./scripts/test-webhook-before-order.sh Webhook arrives before order → reconciled later
./scripts/test-empty-stock-recovery.sh Empty pool → restock → retry delivers once
./scripts/test-retry-race.sh Parallel admin retries → still one key
./scripts/test-provider-timeout.sh Provider timeout → retry returns same code
./scripts/test-promo-race.sh LIMIT3 promo under parallel order creation

Artisan alternative (payment race)

docker compose exec app php artisan app:test-payment-race --concurrency=50

Optional: pass an existing order public id as first argument.

Expected result (payment race)

{
    "data": {
        "status": "delivered",
        "delivery": { "code": "LFXC-TNCS-BPCD" }
    }
}

Only one inventory key allocated per order.


Architecture — exactly-once delivery

  1. Payment dedup — payment_events.event_id UNIQUE; duplicate webhooks are no-ops (PostgreSQL savepoints handle insert races).
  2. Order lock — SELECT … FOR UPDATE on order row before created → paid; only one worker triggers delivery.
  3. Key allocation — FOR UPDATE SKIP LOCKED on available keys + partial unique index on inventory_keys(order_id).
  4. Provider idempotency — provider_requests (provider, request_id) UNIQUE; response persisted before timeout is returned.
  5. Timeout ≠ failure — retries reuse the same request_id, never mint a second code.
  6. Recovery — out_of_stock / delivery_failed → admin retry re-enters DeliverOrder.
  7. Promo limits — promo row lock + promo_code_usages insert in the same transaction as order creation.

Short answer for reviewers: duplicate webhooks hit a unique event_id constraint; concurrent paid events lock the order row so only one path triggers delivery; key selection uses SKIP LOCKED inside a transaction; provider retries reuse the same request_id with a durable stored response. No invariant relies on PHP-only checks.


Troubleshooting

MissingAppKeyException / HTTP 500 on every page

APP_KEY is empty in .env.

docker compose run --rm app php artisan key:generate
docker compose up -d --force-recreate app

SQLSTATE[08006] / connection refused

Context Fix
App in Docker DB_HOST=postgres, DB_PORT=5432 (set in docker-compose.yml)
PHP on host DB_HOST=127.0.0.1, DB_PORT=5433
Postgres not running docker compose up -d postgres

Product not found on order creation

Database not seeded:

docker compose exec app php artisan migrate:fresh --seed

Vite manifest missing

npm install && npm run build

CSRF token mismatch on API

API routes under /api/* must be CSRF-exempt (configured in bootstrap/app.php). Restart app after changing bootstrap files.

Race scripts fail with empty output

  • Ensure app is running: curl http://localhost:8000/up
  • Install jq: sudo apt install jq
  • Or install PHP CLI on the host

Provider delivery fails in tests

Mock providers are called internally (not via HTTP) when the provider URL contains /api/mock/providers/. No extra setup needed.

PHP_CLI_SERVER_WORKERS

Docker sets PHP_CLI_SERVER_WORKERS=1 to avoid multi-worker .env reload issues. Don't increase without understanding the impact.


Submission checklist

Deliverable Location
Live demo / run instructions This README → Installation, Manual purchase flow
Source code Repository root
Race reproduction Race reproduction scripts
Exactly-once explanation Architecture
Time spent ~8–10 hours (scaffold, backend concurrency, storefront, tests, scripts)

Acceptance scenarios

  1. 50 parallel paid webhooks → one delivery, one key
  2. Same event_id repeated → no additional effect
  3. Webhook before order → stored and reconciled once
  4. Empty pool → out_of_stock, retry after replenish → one key
  5. Promo LIMIT3 under parallel requests → ≤ 3 successful uses

For a public URL: build assets (npm run build), deploy public/ to Netlify/Vercel, and point API calls to your backend. Local Docker demo is sufficient for evaluation.

About

Laravel marketplace for digital goods with exactly-once key delivery, idempotent payment webhooks, and promo limits under concurrency.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages