# Claude Guide — 4Colors Backend

## Claude Behaviour Rules

- **Always ask for permission before executing any shell command** (Bash, artisan, npm, composer, git, etc.). State what the command does and wait for explicit approval.
- **Always ask for permission before deleting any file or directory**. Never delete without confirmation, even if the file appears unused or was just created by mistake.

## Stack

- **Framework**: Laravel 11 (PHP 8.2)
- **ORM**: Eloquent — models in `app/Models/`
- **Auth**: JWT via `php-open-source-saver/jwt-auth` (HS256, TTL: 600 min, Refresh: 20160 min)
- **Roles**: Admin, User, Vendor — each has separate guards, middleware, controllers, and services
- **HTTP client**: Laravel `Http` facade (`Http::withHeaders()->get/post()`) — never use raw `curl` or `file_get_contents`
- **Mail**: `MailService` via Laravel `Mail` facade
- **Third-party payments**: Bani, Doja, Kora, Monnify, PalmPay, SafeHaven — all in `app/Service/ThirdParty/`
- **Testing**: Pest PHP

---

## Architecture

### Directory Structure

```
app/Http/Controllers/api/v1/   # Thin controllers — Admin/, User/, Vendor/ subdirs
app/Service/                   # Business logic — Admin/, User/, Vendor/, ThirdParty/
app/Models/                    # Eloquent models
app/Http/Middleware/           # ApiKeyMiddleware, AdminMiddleware, UserMiddleware, VendorMiddleware
app/Http/Resources/            # API resource transformers (JsonResource)
app/Http/Requests/             # Form request validation classes
app/DTOs/                      # Data transfer objects for payment/bank operations
app/Exceptions/                # HttpException, NotFound, UnauthorizedException, InternalServerException
app/Enum/                      # UserType, BankProvider, PaymentProvider, OtpReason
app/Traits/                    # JsonResponseTrait, PaginationResponseTrait
app/Service/ThirdParty/Interfaces/  # Contracts for payment/verification providers
routes/v1/                     # Modular routes — auth.php, Admin/, User/, Vendor/
database/migrations/           # Schema migrations
```

### Layering — one job per layer

```
RouteServiceProvider → Middleware → Controller → Service → Model/ThirdParty
```

- **Controllers**: extract request data, call one service method, return response. Zero business logic.
- **Services**: own all business logic and orchestration. One service per domain per role.
- **Models**: Eloquent relationships, scopes, casts only. No business logic.
- **Resources**: shape API output. Never add logic that belongs in a service.
- **DTOs**: typed data bags passed into third-party services. No methods.
- **ThirdParty services**: one class per provider. Implement the relevant interface.

---

## Core Principles

### Security first

- Never log sensitive fields — BVN, card tokens, API keys, passwords, JWT secrets. Strip before logging.
- No secrets in code. Use `config()` backed by `.env`. Validate required env vars at the top of the method and throw `InternalServerException` if missing.
- Every route that touches user data must go through the appropriate middleware (`ApiKeyMiddleware` + role middleware).
- No endpoint is "safe to leave open temporarily". Guard it now.
- Never expose stack traces, Eloquent error details, or provider error messages to the client in production — `APP_DEBUG=false` on production and staging.
- Use `app/Exceptions/Handler.php` to sanitize all error output.
- Treat a `200` response with an empty or malformed body as a failure — validate the response shape before using it.

### Architecture before speed — SOLID enforced

- **Single Responsibility**: each class owns one domain. A service that handles auth and payments is two services.
- **Open/Closed**: extend behaviour via new service classes or strategy implementations, not by editing existing financial logic.
- **Liskov Substitution**: every `ThirdParty` concrete class must be a correct drop-in for its interface — never add behaviour that the interface does not promise.
- **Interface Segregation**: define narrow interfaces per provider capability — a payment interface should not carry KYC methods.
- **Dependency Inversion**: code against the interface in `app/Service/ThirdParty/Interfaces/`, not the concrete class.
- Layering is enforced: controllers delegate, services own logic, models are data.
- If a service method grows beyond one responsibility, split it into separate private methods or a helper service.
- External API calls live exclusively in `app/Service/ThirdParty/` — never inline inside a domain service.

### DRY

- Before writing a function, check `app/Traits/`, the module's own service, and `app/Service/` shared services.
- Two service methods that are 80%+ identical should be one parameterised method.
- Shared response formatting lives in `JsonResponseTrait` and `PaginationResponseTrait` — use them everywhere, never build raw arrays inline.
- Enums in `app/Enum/` cover user types, bank providers, payment providers, OTP reasons — use them, never hardcode strings.
- Do not register the same route group, middleware, or service binding in more than one place.

---

## Laravel-Specific Rules

### Dependency Injection — always inject, never instantiate

Laravel's service container wires up singleton lifecycles, config bindings, and interface-to-implementation resolution. Manual `new` bypasses all of it.

```php
// wrong — bypasses the container, skips singleton lifecycle, ignores bound interface
$kora = new KoraService();

// right — injected via constructor, resolved through the container
public function __construct(private readonly PaymentGatewayInterface $gateway) {}
```

Bind interfaces to implementations in `AppServiceProvider`:

```php
$this->app->bind(PaymentGatewayInterface::class, KoraService::class);
```

### Validation — always use Form Requests

```php
// wrong — validating in the controller body
public function store(Request $request)
{
    $request->validate(['name' => 'required']);
}

// right — delegate to a Form Request
public function store(StoreVendorRequest $request)
{
    // $request is already validated before this line runs
}
```

Create Form Requests in `app/Http/Requests/`. Never trust `$request->all()` without validation.

### Exception types — throw the right one

Do not throw `HttpException` for everything. Use the specific exception that matches the situation.

| Situation | Exception |
| --- | --- |
| Resource not found | `NotFound` (404) |
| Business rule violation / bad input | `HttpException` (400) |
| Auth failure / invalid token | `UnauthorizedException` (401) |
| Wrong role / permission denied | `HttpException` (403) |
| Duplicate / state conflict | `HttpException` (409) |
| Unexpected server fault | `InternalServerException` (500) |

Never swallow exceptions:

```php
// wrong — hides the real error, collapses every failure into 400
} catch (\Exception $e) {
    throw new HttpException($e->getMessage());
}

// right — only catch what you can meaningfully handle
} catch (QueryException $e) {
    if ($e->getCode() === '23000') {
        throw new HttpException('Record already exists', 409);
    }
    throw $e; // let the global handler deal with it
}
```

### Logging — use Laravel Log facade, not echo/var_dump

```php
// wrong — bleeds into API responses, cannot be silenced per environment
var_dump($response);
echo 'payment initiated';

// right
use Illuminate\Support\Facades\Log;

Log::info('Payment initiated', ['reference' => $reference]);
Log::error('Charge failed', ['error' => $e->getMessage(), 'provider' => 'kora']);
```

`var_dump` and `echo` bleed into API responses. `Log` writes to `storage/logs/` and can be channeled per environment.

### Eloquent — no raw queries for simple operations

```php
// wrong
DB::select('SELECT * FROM users WHERE id = ?', [$id]);

// right
User::findOrFail($id);
```

Use raw `DB::` only when Eloquent cannot express the query (complex aggregations, window functions).

### HTTP client — always set timeout

```php
// wrong — hangs indefinitely if provider is down
Http::post($url, $payload);

// right
Http::timeout(10)->post($url, $payload);
```

10 seconds is the safe default for outbound payment/verification calls.

### Response format — always use the traits

```php
// wrong — raw array, inconsistent shape
return response()->json(['data' => $user, 'success' => true]);

// right
use App\Traits\JsonResponseTrait;

return $this->successResponse('User fetched', $resource);
return $this->errorResponse('Not found', 404);
```

Every controller uses `JsonResponseTrait`. Paginated lists use `PaginationResponseTrait`.

---

## Type Safety

PHP types are your first line of defence against financial bugs. Treat them as non-negotiable.

- Declare **return types and parameter types** on every method. Omitting them is not a style choice — it is a bug waiting to happen.
- Financial amounts are always `int` or `float`. Never `mixed`, never untyped. An `amount: mixed` in a payment flow is a defect.
- Use **enums** from `app/Enum/` for all status, type, provider, and reason values — never hardcode raw strings.
- If an external API response shape is unknown, define a DTO or typed array shape. Do not pass raw `array` through the service layer.

```php
// wrong — untyped, amount can be anything
public function deduct($userId, $amount) { ... }

// right — typed, self-documenting, catches misuse at analysis time
public function deduct(int $userId, float $amount): void { ... }
```

---

## Third-Party API Calls

All outbound provider calls live exclusively in `app/Service/ThirdParty/`. No exceptions.

- Use the **correct API key for each provider**. Never cross-wire keys (e.g. Kora secret sent to Monnify is a bug, not a config issue).
- **Validate required env vars before the call** — throw `InternalServerException` early with a clear message rather than getting a cryptic provider error.
- **Always set `timeout`** — 10 seconds is the safe default. A missing timeout hangs the worker indefinitely if the provider is down.
- **Validate the response shape** — treat a `200` with an empty or unexpected body as a failure. Never assume a non-exception means success.
- **Redact sensitive fields** before logging — strip `api_key`, `secret`, `token`, `authorization`, `password` from any log context.
- If two providers do the same thing with slightly different config, write **one parameterised method**, not two near-identical ones.

```php
// wrong — concrete class, no env check, no timeout, logs raw response
$kora = new KoraService();
Http::post($url, $payload);
Log::info('Kora response', ['response' => $response]); // may contain keys

// right — injected interface, env validated, timeout set, response validated
public function __construct(private readonly PaymentGatewayInterface $gateway) {}

public function initiatePayment(InitiatePaymentDto $dto): array
{
    throw_if(empty(config('services.kora.secret')), InternalServerException::class, 'Kora secret not configured');

    $result = $this->gateway->initiate($dto);

    if (empty($result['reference'])) {
        throw new InternalServerException('Kora returned an unexpected response shape');
    }

    return $result;
}
```

---

## Financial Operations — Non-negotiable

### Wrap multi-step money movement in DB::transaction()

If an operation touches more than one table and involves money, it must be atomic:

```php
DB::transaction(function () use ($user, $amount, $reference) {
    $user->wallet()->decrement('balance', $amount);   // deduct
    Transaction::create([...]);                        // audit trail
    Order::where('reference', $reference)->update(['status' => 'paid']); // mark paid
});
```

Without this, a failure mid-way leaves the DB inconsistent. A manual rollback in `catch` is not a substitute — the catch itself can fail.

### Validate all preconditions before touching balances

Check limits, holds, bank details, and balance sufficiency **before** any mutation:

```php
// wrong — deducts first, tries to refund on failure (not atomic, refund can also fail)
$user->wallet()->decrement('balance', $amount);
$bank = $user->bankDetails()->first();
if (!$bank) {
    $user->wallet()->increment('balance', $amount); // refund — can also fail
    throw new HttpException('Add a bank account first');
}

// right — validate everything before any mutation
$bank = $user->bankDetails()->first();
if (!$bank) throw new HttpException('Add a bank account first');

DB::transaction(function () use ($user, $amount, $bank) {
    $user->wallet()->decrement('balance', $amount);
    // proceed safely
});
```

### Race condition guard — atomic update with affected-rows check

Never `SELECT` to check balance then `UPDATE` separately — that window is a race condition. The `where` clause is the balance check, executed atomically at the DB level:

```php
// right — atomic: no-ops if balance < amount, so $affected === 0 means insufficient funds
$affected = DB::table('wallets')
    ->where('user_id', $userId)
    ->where('balance', '>=', $amount)
    ->decrement('balance', $amount);

if ($affected === 0) {
    throw new HttpException('Insufficient funds');
}
```

### Pessimistic locking — for operations that need serialization

When optimistic locking is insufficient (e.g. subscription processing, sequential payout batches), use `lockForUpdate()` to serialize access:

```php
DB::transaction(function () use ($userId, $amount) {
    // lock the row for the duration of the transaction — no other process can read or write it
    $wallet = Wallet::where('user_id', $userId)->lockForUpdate()->firstOrFail();

    if ($wallet->balance < $amount) {
        throw new HttpException('Insufficient funds');
    }

    $wallet->decrement('balance', $amount);
    Transaction::create([...]);
});
```

Only use `lockForUpdate()` inside a `DB::transaction()` — a lock without a transaction releases immediately and is useless.

---

## Comments

Short, purposeful, explain *why* not *what* — the code shows what:

```php
// wrong — restates what the code says
// Decrement the wallet balance by the amount
$user->wallet()->decrement('balance', $amount);

// right — explains the non-obvious intent
// atomic: no-ops if balance < amount, so $affected === 0 means insufficient funds
$affected = DB::table('wallets')->where('balance', '>=', $amount)->decrement('balance', $amount);
```

Use section dividers in long service files:

```php
// ── Validation ───────────────────────────────────────────────────────────────
// ── Payment flow ─────────────────────────────────────────────────────────────
// ── Private helpers ──────────────────────────────────────────────────────────
```

No docblocks on internal methods. Type signatures already tell that story.

---

## Adding a New Feature

1. Define the Form Request in `app/Http/Requests/` — validation first.
2. If crossing third-party providers, define or implement the interface in `app/Service/ThirdParty/Interfaces/`.
3. Write the service method in the correct role subdirectory (`Admin/`, `User/`, `Vendor/`). Validate all preconditions at the top before touching the DB.
4. Wrap multi-step DB writes in `DB::transaction()`.
5. Use the correct exception type for each failure case.
6. Add the route in the correct role file under `routes/v1/` via `RouteServiceProvider`.
7. Add the thin controller method — extract, delegate, respond.
8. Use `JsonResponseTrait` / `PaginationResponseTrait` for all responses.
9. Use enums from `app/Enum/` for all status/type/provider values.
10. Declare explicit return types and parameter types on every new method.

## Modifying Existing Code

- Read the file before editing. Understand context — do not assume.
- Do not refactor beyond the scope of the change.
- If you spot a missing `DB::transaction()` on a financial operation you are already touching — add it.
- If you spot a wrong exception type in code you are changing — fix it in the same commit.
- If you spot an untyped financial parameter (`$amount` with no type hint) in code you are touching — type it.
- Dead code (commented-out imports, stale TODOs, duplicate registrations, `MonnifyService copy.php`) — delete it after asking for permission. Git has history.
- Never use `mixed` without justification in financial flows. Amount fields are always `int` or `float` — never untyped.

---

## Deployment

CI/CD via GitHub Actions (`.github/workflows/`):

- `staging.yaml` — push/merge to `staging` branch → FTP upload + SSH post-deploy (migrate, cache)
- `production.yaml` — push/merge to `production` branch → same, different credentials. **Only merge `staging` into `production`**, never a feature branch directly.

Post-deploy SSH runs:

```bash
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
```

Excluded from FTP upload: `.git`, `node_modules`, `tests`, `storage/logs/`, `storage/framework/cache/`, `.env.example`, build config files.

`.env` is never committed — it is written from `STAGING_ENV_FILE` / `PROD_ENV_FILE` GitHub secrets at deploy time.

SSH keys are stored locally in `ssh-keys/` (gitignored) and as `SSH_PRIVATE_KEY` GitHub secret.

---

## Environment Variables

- `.env` — local active config
- `.env.example` — template only, never contains real values
- Production/staging values live exclusively in GitHub Secrets (`STAGING_ENV_FILE`, `PROD_ENV_FILE`)
- Required secrets per environment: `APP_KEY`, `APP_URL`, `DB_*`, `JWT_SECRET`, `APP_API_KEY`, mail config, payment provider keys

## Testing

```bash
php artisan test
php artisan test --filter SomeTest
vendor/bin/pest
```

Tests use Pest PHP. Do not hit production or staging DBs from tests.
