v2

Loreax — Backend Technical Design Document

Version: 0.1 (MVP Design) Status: Draft for implementation Last updated: April 2026 Audience: Engineering, Product, DevOps, Security


Table of Contents

  1. Executive Summary
  2. Tech Stack
  3. Architectural Standards & Conventions
  4. Domain Map
  5. Domain: Infrastructure
  6. Domain: Identity
  7. Domain: Ledger & Wallet
  8. Domain: Payments
  9. Domain: Content
  10. Domain: Access & Entitlements
  11. Domain: Monetization
  12. Domain: Social Graph
  13. Domain: Fan Club
  14. Domain: Discovery & Ranking
  15. Domain: Promotions
  16. Domain: Notifications
  17. Domain: Moderation
  18. Domain: Admin & Back Office
  19. Cross-Cutting: Observability
  20. Cross-Cutting: Jobs, Scheduling & Events
  21. Cross-Cutting: Security & Compliance
  22. Cross-Cutting: Testing Strategy
  23. Cross-Cutting: Deployment & Operations
  24. Cross-Cutting: Documentation
  25. Appendices

1. Executive Summary

Loreax is a content platform where creators publish exclusive content (video, audio, images, text, links, polls, livestreams) and monetize via three combinable access paths: tier subscriptions, one-off purchases, and free-for-active-fans allowances. The platform handles real money end-to-end — top-ups, purchases, creator earnings, and MPESA-denominated withdrawals — through a double-entry ledger.

This document is the canonical technical design for the MVP backend. It specifies the domain-driven decomposition, entity model, contracts, endpoints, and implementation conventions that all engineers and code-generation agents must follow.

1.1 Product Pillars

  1. Creator monetization — flexible access rules per post, tier subscriptions, promotions, and fast MPESA payouts.
  2. Viewer experience — discover, purchase, subscribe, save, follow, and join fan clubs with minimal friction.
  3. Back-office control — Filament-powered admin with fine-grained scopes, audit trails, and configurable platform policies.
  4. Real-money integrity — every cent traceable via an immutable double-entry ledger.
  5. Observability by default — every request, job, and state change logged with correlation IDs.

1.2 Non-Goals (v1)

  • Native mobile apps (web + PWA only — mobile designs inform API shape)
  • Tips/donations (deferred to v2)
  • PayPal & bank payouts (MPESA only in v1)
  • Explicit content capability (SFW by default; capability stubbed behind config flag)
  • AI pre-screen and manual pre-screen moderation modes (stubbed; default is reactive_only)
  • Elasticsearch / Meilisearch (custom DiscoveryService for v1)
  • Multi-tenant organizations / agencies
  • Internationalized content (English only; i18n infrastructure scaffolded)

1.3 Success Criteria

Criterion Target
Test coverage ≥ 80% (enforced in CI)
p95 API latency (read) < 300 ms
p95 API latency (write) < 600 ms
Ledger integrity (sum of signed entries per transaction) = 0, always
MPESA top-up success rate ≥ 98%
Uptime target 99.5% (MVP; upgrade to 99.9% post-GA)
OpenAPI coverage 100% of public endpoints

2. Tech Stack

2.1 Runtime & Language

  • PHP 8.4+declare(strict_types=1) on every file, final class by default. Projects are free to use PHP 8.4 language features (property hooks, asymmetric visibility, #[\Override], array_find/array_any/array_all) where they clarify intent.
  • Laravel 12.x — framework (PHP 8.4 compatible)
  • Composer 2.x — dependency management

2.2 Core Laravel Packages

Package Purpose
laravel/sanctum API authentication (SPA cookies + personal access tokens)
laravel/horizon Queue worker management with Redis
laravel/scout Scaffolded but unused in v1 (DiscoveryService is custom)
lorisleiva/laravel-actions Business logic layer — thin controllers delegate to Actions
spatie/laravel-permission Role → scope mapping (scopes static, roles DB-backed)
spatie/laravel-activitylog System-wide audit trail
spatie/laravel-data DTOs for request/response payloads
spatie/laravel-query-builder Query-string filtering/sorting for list endpoints
propaganistas/laravel-phone Phone number validation/formatting (MPESA)
simplesoftwareio/simple-qrcode TOTP QR code generation
pragmarx/google2fa-laravel TOTP MFA provider
laravel/socialite OAuth with Google/Facebook/LinkedIn
socialiteproviders/apple Apple Sign-In
intervention/image Self-hosted image resizing
spatie/laravel-medialibrary Media attachment library for posts and derived assets
pbmedia/laravel-ffmpeg Laravel adapter around FFmpeg/FFprobe for queued video/audio processing
bervant/laravel-sms Provider-agnostic SMS abstraction for notifications and MFA delivery
bervant/kashier-laravel-sdk Laravel SDK for Kashier payment aggregation and MPESA/STK workflows
league/flysystem-aws-s3-v3 S3-compatible storage adapter
mongodb/mongodb MongoDB PHP driver for RequestLogger
filament/filament Admin back office
darkaonline/l5-swagger OpenAPI/Swagger generation from annotations

2.3 Data Stores

Store Use
PostgreSQL 16+ (RDS) Primary OLTP database
Redis 7+ Cache, queues (Horizon), feed cache, session, rate limits, SSE pubsub
MongoDB 7+ (Atlas) Request logs (via MongoRequestLogger)
AWS S3 User media (disk abstracted; swap to local/R2 via env)

2.4 External Services

Service Purpose v1 Provider Abstracted By
Payments (C2B/B2C) MPESA-first top-ups & withdrawals Kashier SDK package / Kashier gateway IPaymentProvider
Video transcoding Post video encoding pbmedia/laravel-ffmpeg + local FFmpeg IVideoProvider
Audio transcoding Post audio encoding pbmedia/laravel-ffmpeg + local FFmpeg IAudioProvider
Image processing Thumbnails, variants Intervention Image IImageProvider
Media attachment lifecycle Persist media records, conversions, and responsive variants spatie/laravel-medialibrary IMediaService
SMS delivery OTP and critical notifications bervant/laravel-sms with selectable driver INotificationService / MFA provider adapters
Livestream ingest/playback RTMP → HLS Mux Live ILivestreamProvider
Transactional email Account, receipts AWS SES Laravel Mail
Object storage Media persistence AWS S3 Laravel Filesystem
AI moderation (stubbed) Content review OpenAI Moderation + Claude IModerationAiProvider

2.5 Infrastructure

  • AWS EC2 — self-managed, Nginx + PHP-FPM
  • Ansible — idempotent server provisioning (deploy/ansible/)
  • Docker — parallel container setup (Dockerfile, docker-compose.yml, docker-compose.prod.yml)
  • GitHub Actions — CI (lint, static analysis, tests, coverage) + CD (build → push → deploy)
  • CloudWatch — infra metrics, log aggregation
  • Route 53 — DNS
  • ACM — TLS certificates

2.6 Tooling

Tool Purpose
PHPUnit 11+ Test runner (unit + feature + contract + invariant)
Pint Code style (Laravel preset with customizations)
Larastan (level 8) Static analysis
Rector Automated refactoring
OpenAPI Generator Client SDK generation from swagger spec

3. Architectural Standards & Conventions

3.1 Domain-Driven Monolith

Loreax is a modular monolith organized by bounded context, not by technical layer. Each domain owns its entities, actions, policies, events, and API contracts. Domains communicate via:

  1. Direct method calls through well-defined I* contracts (preferred for synchronous read operations).
  2. Domain events (App\Domain\*\Events\*) dispatched via Laravel's event bus (for decoupled side effects).
  3. Queued jobs for long-running or cross-cutting work.

No domain reaches directly into another domain's Eloquent models except through published contracts or events. Violations fail code review.

3.2 Directory Layout

app/
├── Core/                          ← Cross-cutting infrastructure & contracts
│   ├── Contracts/                 ← I*-prefixed interfaces (IRequestLogger, IIdentityService, ...)
│   ├── Infrastructure/
│   │   ├── Logging/               ← MongoRequestLogger and siblings
│   │   ├── Storage/               ← Filesystem providers
│   │   ├── Media/                 ← Video/Audio/Image provider adapters
│   │   └── Payments/              ← Kashier-backed payment adapter
│   ├── Http/
│   │   ├── Middleware/            ← AssignRequestId, AssignTraceId, AssignConversationId, LogRequest, ResolveRealm, ...
│   │   ├── Responses/             ← Standardized API response shapes
│   │   └── Formatters/            ← camelCase ↔ snake_case translators
│   ├── Authorization/
│   │   ├── Scopes.php             ← Typed scope constants
│   │   ├── ScopeRegistry.php      ← Boot-time validator
│   │   └── PermissionGate.php
│   ├── Support/                   ← Shared helpers, enums, value objects
│   └── Exceptions/                ← Platform-wide exception hierarchy
│
├── Identity/                      ← Domain
│   ├── Contracts/                 ← IIdentityService, IMfaProvider, ...
│   ├── Models/                    ← User, AdminUser, Session, MfaChallenge, ...
│   ├── Actions/                   ← RegisterUserAction, LoginAction, EnableMfaAction, ...
│   ├── Enums/                     ← Realm, MfaProvider, ...
│   ├── Events/                    ← UserRegistered, LoginSucceeded, MfaChallenged, ...
│   ├── Listeners/
│   ├── Policies/
│   ├── Data/                      ← DTOs (RegisterUserData, LoginData, ...)
│   ├── Http/
│   │   ├── Controllers/
│   │   ├── Requests/
│   │   └── Resources/
│   └── Services/                  ← IdentityService implementation
│
├── Ledger/                        ← Domain
├── Payments/                      ← Domain
├── Content/                       ← Domain
├── Access/                        ← Domain
├── Monetization/                  ← Domain
├── Social/                        ← Domain
├── FanClub/                       ← Domain
├── Discovery/                     ← Domain
├── Promotions/                    ← Domain
├── Notifications/                 ← Domain
├── Moderation/                    ← Domain
└── AdminBackoffice/               ← Filament resources, custom admin actions

3.3 Naming Conventions

Element Convention Example
Class PascalCase RegisterUserAction
Interface I-prefixed PascalCase IRequestLogger, IIdentityService
Method camelCase currentRealm()
Property camelCase $requestId
DB table snake_case, plural post_purchases, ledger_entries
DB column snake_case created_at, withdrawable_after
API field (request/response) camelCase {"phoneNumber": "..."}
Route kebab-case /v1/post-purchases/{id}
Env var SCREAMING_SNAKE_CASE APP_REQUEST_LOGGER_DB_CONNECTION
Config key snake_case config('app.custom.service')
Event class Past-tense PascalCase UserRegistered, PostPurchased
Action class Verb + Action PurchasePostAction

Payload translation: API payloads are camelCase; internal DB columns are snake_case. The translation happens in:

  • Incoming: FormRequest::validated() returns camelCase → DTO (spatie/laravel-data with #[MapInputName] attributes converts to snake_case where needed) → Action.
  • Outgoing: Eloquent model → JsonResource transformer returns camelCase payload.

A CamelCaseResource base class and SnakeCaseRequest trait centralize this.

3.4 Thin Controllers, Laravel Actions

Controllers are I/O adapters, nothing more. Every controller method:

  1. Receives a FormRequest (validation + authorization).
  2. Builds a DTO from validated input.
  3. Dispatches a single Action::run(...).
  4. Wraps the result in a JsonResource.
  5. Returns the HTTP response.

No business logic in controllers. No direct Eloquent in controllers. No domain events dispatched in controllers.

<?php

declare(strict_types=1);

namespace App\Identity\Http\Controllers;

use App\Core\Http\Controllers\Controller;
use App\Identity\Actions\RegisterUserAction;
use App\Identity\Data\RegisterUserData;
use App\Identity\Http\Requests\RegisterUserRequest;
use App\Identity\Http\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use OpenApi\Attributes as OA;

final class RegisterUserController extends Controller
{
    public function __construct(
        private readonly RegisterUserAction $registerUser,
    ) {}

    #[OA\Post(
        path: '/v1/identity/register',
        summary: 'Register a new user account',
        tags: ['Identity'],
        requestBody: new OA\RequestBody(
            required: true,
            content: new OA\JsonContent(ref: '#/components/schemas/RegisterUserRequest'),
        ),
        responses: [
            new OA\Response(response: 201, description: 'Created', content: new OA\JsonContent(ref: '#/components/schemas/UserResponse')),
            new OA\Response(response: 422, description: 'Validation error'),
        ],
    )]
    public function __invoke(RegisterUserRequest $request): JsonResponse
    {
        $data = RegisterUserData::from($request->validated());

        $user = $this->registerUser->run($data);

        return UserResource::make($user)
            ->response()
            ->setStatusCode(201);
    }
}

3.5 Actions Contract

Every action follows this shape:

<?php

declare(strict_types=1);

namespace App\Identity\Actions;

use App\Identity\Data\RegisterUserData;
use App\Identity\Events\UserRegistered;
use App\Identity\Models\User;
use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\Concerns\AsAction;

final class RegisterUserAction
{
    use AsAction;

    /**
     * Register a new user account.
     *
     * Preconditions:
     *  - Email is not already registered (verified or unverified)
     *  - Handle is available and valid
     *
     * Postconditions:
     *  - User row created with hashed password
     *  - Email verification sent
     *  - UserRegistered event dispatched
     *  - Referral attribution applied if referral cookie present
     *
     * @throws \App\Identity\Exceptions\EmailAlreadyRegisteredException
     * @throws \App\Identity\Exceptions\HandleUnavailableException
     */
    public function run(RegisterUserData $data): User
    {
        return DB::transaction(function () use ($data): User {
            // ... implementation
            $user = User::create([...]);

            UserRegistered::dispatch($user);

            return $user;
        });
    }
}

Rules:

  • One action = one use case. No multi-purpose actions.
  • Actions return domain models, not responses.
  • Actions throw domain exceptions; controllers/exception handlers translate to HTTP.
  • Side effects (events, jobs) are dispatched inside the action's transaction via DB::afterCommit() where appropriate.
  • Actions are testable in isolation (no HTTP layer needed).

3.6 Eloquent Usage

  • No repository pattern. Eloquent is the data-access primitive.
  • Global scopes for universally applied filters (e.g. SoftDeletingScope, platform TenantScope when multi-tenant lands).
  • Local scopes for reusable query fragments:
public function scopePublished(Builder $query): Builder
{
    return $query->where('status', PostStatus::Published);
}

public function scopeVisibleTo(Builder $query, User $viewer): Builder
{
    return $query->whereHas('accessRules', fn ($q) => $q->visibleTo($viewer));
}
  • Model events via observers, not closures in boot().
  • Strict mode enabled in AppServiceProvider::boot():
Model::shouldBeStrict(! app()->environment('prod'));

3.7 DTOs & Validation

  • Request validation: FormRequest classes in App\<Domain>\Http\Requests\*.
  • DTOs: spatie/laravel-data classes in App\<Domain>\Data\*.
  • One DTO per logical input/output shape.
  • DTOs carry typed properties and can be rehydrated from array/JSON.
<?php

declare(strict_types=1);

namespace App\Identity\Data;

use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;

final class RegisterUserData extends Data
{
    public function __construct(
        #[Email, Max(255)]
        public string $email,

        #[Max(72)]
        public string $password,

        #[Max(64)]
        public string $firstName,

        #[Max(64)]
        public string $lastName,

        #[Max(32)]
        public string $handle,

        public ?string $referralCode = null,
    ) {}
}

3.8 API Response Contract

The response shape is derived from HTTP status code, not a payload flag. Three shapes exist.

A. Success (2xx status codes) — normal state or resource payload

{
  "message": "Created",
  "data": { ... },
  "meta": {
    "requestId": "01HQ...",
    "traceId": "0af7651916cd43dd8448eb211c80319c",
    "timestamp": "2026-04-20T14:05:00Z"
  }
}

message is a short human-readable summary appropriate for UI toasts. Defaults to the HTTP reason phrase ("OK", "Created", "Accepted") when not set explicitly.

B. Validation Error (422 Unprocessable Entity) — input validation failed

{
  "message": "Invalid input",
  "errors": {
    "email": ["The email field is required."],
    "amount": ["Must be a positive integer."]
  },
  "meta": {
    "requestId": "01HQ...",
    "traceId": "0af7651916cd43dd8448eb211c80319c",
    "timestamp": "2026-04-20T14:05:00Z"
  }
}

Always emitted by ValidationException (Laravel's default). The errors object maps field paths (camelCase, matching request input) to arrays of translated messages.

C. Application Error (430 Loreax Application Error + other non-validation 4xx/5xx) — business rule violation or infrastructure failure

{
  "errorCode": "INSUFFICIENT_FUNDS",
  "message": "Wallet balance is lower than the requested withdrawal amount.",
  "meta": {
    "requestId": "01HQ...",
    "traceId": "0af7651916cd43dd8448eb211c80319c",
    "timestamp": "2026-04-20T14:05:00Z"
  }
}
  • errorCode is a stable SCREAMING_SNAKE_CASE identifier suitable for client-side switch statements. The catalog lives in App\Core\Exceptions\ErrorCode enum; every domain exception exposes an ErrorCode case.
  • message is a human-readable, user-safe explanation (never contains stack traces or internal system detail).

Status code → response shape:

Status Shape Used for
200 OK A Successful reads/updates
201 Created A Successful creates
202 Accepted A Async enqueued
204 No Content (empty body) Deletes, idempotent noops
400 Bad Request C Malformed JSON, bad headers
401 Unauthenticated C Missing/invalid auth
403 Forbidden C Missing scope / policy denied
404 Not Found C Missing resource
409 Conflict C Idempotency/state conflict
422 Unprocessable Entity B Validation failure
429 Too Many Requests C Rate limited
430 Loreax Application Error C Business rule violation (insufficient funds, already purchased, tier archived, handle cooldown, MFA challenge required, etc.)
500 Internal Server Error C Unhandled exception
502 Bad Gateway C Downstream provider (MPESA, Mux) failed
503 Service Unavailable C Maintenance mode / queue saturated

Why 430? Distinct from 422 ("your payload was malformed") and 409 ("stateful conflict") to give clients a predictable switch on "your payload was valid and parsed, but a business rule says no." 430 is an application-space status code in the 4xx range, treated as a client-addressable error by standard HTTP libraries while signaling that the response carries a stable errorCode the client branches on. The distinction lets front-ends render field-level feedback (422) versus toast-level error messaging (430) without string-sniffing.

Meta fields:

Field Source
requestId ULID. X-Request-ID header if supplied by client; otherwise generated by AssignRequestId middleware. Also echoed in the X-Request-ID response header.
traceId W3C Trace Context trace ID. Extracted from traceparent header if present; auto-generated (W3C-compliant 32-hex-char) if absent. Also echoed in the traceparent response header for downstream propagation.
timestamp now()->toIso8601String() at response build time (UTC).

conversationId (multi-request correlation for long user journeys) remains captured internally via X-Conversation-ID header and included in RequestLog entries, but is not included in the response meta — it is a server-side observability field only.

3.9 Immutable, Idempotent Writes

Every mutating endpoint accepts an optional Idempotency-Key header. Payment-related endpoints (/v1/top-ups, /v1/withdrawals, /v1/purchases) require it. The key + user + endpoint is stored in Redis with a 24h TTL; replays return the cached response.

3.10 Pagination

All list endpoints return cursor-based pagination:

{
  "data": [...],
  "meta": {
    "cursor": { "next": "...", "prev": "..." },
    "perPage": 20
  }
}

Cursor encodes (sort_column, id) tuples, base64-URL-encoded.

3.11 Filament Back Office

  • Separate guard: admin
  • Separate login route: /admin/login
  • Every Filament resource declares its required scope:
protected static ?Scope $requiredScope = Scope::PostReadWrite;
  • Destructive actions require a reason field; two-person approval on Withdrawal.Approval, Refund.Approval, Ledger.Approval.

3.12 Documentation Standards

  • PHPDoc on every public method — purpose, preconditions, postconditions, thrown exceptions.
  • OpenAPI attributes on every public controller method (uses zircote/swagger-php).
  • Domain README in every app/<Domain>/README.md — purpose, owned entities, key invariants, external dependencies.
  • ADRs (Architecture Decision Records) in docs/adr/ for significant architectural choices.

4. Domain Map

┌─────────────────────────────────────────────────────────────────┐
│                      Infrastructure (Core)                       │
│  Response envelopes • Middleware • Logging • Errors • Scopes     │
└─────────────────────────────────────────────────────────────────┘
                                │
                ┌───────────────┼───────────────┐
                ▼               ▼               ▼
         ┌──────────┐    ┌──────────┐    ┌──────────────┐
         │ Identity │    │ Ledger & │    │ Observability│
         │          │    │ Wallet   │    │ (cross-cut)  │
         └────┬─────┘    └─────┬────┘    └──────────────┘
              │                │
              ├────┬───────────┼───────────┬────────┐
              ▼    ▼           ▼           ▼        ▼
         ┌──────┐ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐
         │Social│ │Content │ │Payments│ │Admin │ │Moderation│
         └──┬───┘ └───┬────┘ └────────┘ └──────┘ └──────────┘
            │        │
            ▼        ▼
       ┌────────┐ ┌────────────┐
       │FanClub │ │ Access &   │
       │        │ │Entitlements│
       └────────┘ └──────┬─────┘
                         │
                         ▼
                  ┌──────────────┐    ┌──────────┐
                  │Monetization  │───▶│Promotions│
                  │(tiers, price)│    └──────────┘
                  └──────┬───────┘
                         │
                         ▼
                   ┌───────────┐
                   │ Discovery │
                   │ & Ranking │
                   └───────────┘
                         │
                         ▼
                  ┌──────────────┐
                  │Notifications │
                  └──────────────┘

Dependency rules:

  1. Infrastructure (Core) depends on nothing in the domain layer.
  2. Identity, Ledger, and Observability are foundational — depended upon by most domains.
  3. Content & Access have a tight mutual relationship — Access rules are a property of a Post but live in their own domain for reuse.
  4. Discovery reads from every domain but writes only to its own tables (caches + interactions).
  5. Notifications is the terminal sink — receives events from every domain, emits nothing.

5. Domain: Infrastructure

5.1 Responsibility

Infrastructure defines the shape of the system before any business domain exists:

  • Standard response envelope & error contract
  • Standard middleware pipeline
  • Request correlation (requestId, conversationId)
  • Authentication guards & realm resolution
  • Rate limiting
  • Exception translation (domain → HTTP)
  • CamelCase ↔ snake_case translation
  • Scope dictionary & permission gate registration
  • Health checks & readiness probes
  • Feature flags
  • Platform settings (runtime-tunable config)

This domain has no Eloquent models of its own other than PlatformSetting and FeatureFlag.

5.2 Middleware Pipeline

In order (outermost first):

  1. TrustProxies — AWS ELB forwarding
  2. AssignRequestId — generate ULID if X-Request-ID absent; attach to $request->attributes as request_id
  3. AssignTraceId — parse traceparent header (W3C Trace Context); if absent, generate W3C-compliant trace-id (32 hex chars) + span-id (16 hex chars); attach to $request->attributes as trace_id / span_id; set outbound traceparent header on response
  4. AssignConversationIdX-Conversation-ID header or fall back to requestId (internal only; not emitted in response meta)
  5. ResolveClientContext — capture X-Platform, X-Scenario, X-App-Version headers
  6. HandleCors
  7. ForceHttps (prod only)
  8. ResolveRealm — set realm attribute based on matched route group (user or admin)
  9. RateLimit — tiered limiter per route group
  10. Authentication guardauth:sanctum (user) or auth:admin (Filament)
  11. VerifyEmailIfRequired — enforce email verification on write endpoints
  12. EnforceMfaChallenge — challenge MFA for sensitive endpoints
  13. CaptureProcessorName — route-matched Action FQCN attached to request
  14. TranslateCamelToSnake — inbound payload normalization
  15. Controller
  16. TranslateSnakeToCamel — outbound payload normalization (via JsonResource)
  17. LogRequest (terminable) — persists to MongoDB via IRequestLogger

5.3 Standard Response Envelope

All responses pass through ApiResponse:

<?php

declare(strict_types=1);

namespace App\Core\Http\Responses;

use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response as HttpStatus;

final class ApiResponse implements Responsable
{
    private function __construct(
        private readonly int $status,
        private readonly ?string $message = null,
        private readonly mixed $data = null,
        private readonly ?string $errorCode = null,
        private readonly ?array $validationErrors = null,
    ) {}

    // ─── Success ─────────────────────────────────────────────────────
    public static function ok(mixed $data = null, ?string $message = 'OK'): self
    {
        return new self(status: 200, message: $message, data: $data);
    }

    public static function created(mixed $data, ?string $message = 'Created'): self
    {
        return new self(status: 201, message: $message, data: $data);
    }

    public static function accepted(mixed $data = null, ?string $message = 'Accepted'): self
    {
        return new self(status: 202, message: $message, data: $data);
    }

    public static function noContent(): self
    {
        return new self(status: 204);
    }

    // ─── Validation Error (shape B) ──────────────────────────────────
    public static function validationError(array $errors, string $message = 'Invalid input'): self
    {
        return new self(status: 422, message: $message, validationErrors: $errors);
    }

    // ─── Application Error (shape C) ─────────────────────────────────
    public static function applicationError(string $errorCode, string $message): self
    {
        return new self(status: 430, message: $message, errorCode: $errorCode);
    }

    /** Generic non-422 error (401, 403, 404, 409, 429, 500, 502, 503). */
    public static function error(int $status, string $errorCode, string $message): self
    {
        return new self(status: $status, message: $message, errorCode: $errorCode);
    }

    public function toResponse($request): JsonResponse
    {
        if ($this->status === HttpStatus::HTTP_NO_CONTENT) {
            return response()->json(null, 204);
        }

        $meta = [
            'requestId' => (string) $request->attributes->get('request_id'),
            'traceId'   => (string) $request->attributes->get('trace_id'),
            'timestamp' => now()->toIso8601String(),
        ];

        $body = match (true) {
            $this->validationErrors !== null => [
                'message' => $this->message,
                'errors'  => $this->validationErrors,
                'meta'    => $meta,
            ],
            $this->errorCode !== null => [
                'errorCode' => $this->errorCode,
                'message'   => $this->message,
                'meta'      => $meta,
            ],
            default => [
                'message' => $this->message,
                'data'    => $this->data,
                'meta'    => $meta,
            ],
        };

        return response()->json($body, $this->status);
    }
}

5.4 Exception Handler

App\Core\Exceptions\Handler translates exceptions into the response shapes from §3.8:

  • ValidationExceptionshape B (422), with errors extracted from the validator
  • AuthenticationException, AuthorizationException, ModelNotFoundException, ThrottleRequestsException, generic HTTP exceptions → shape C with standard status + stable errorCode
  • DomainException subclasses → shape C with status 430 by default (overrideable) and errorCode from the attached ErrorCode case
  • Unhandled \Throwableshape C (500) with errorCode=INTERNAL_ERROR; message scrubbed to generic text in prod
Exception Status Error Code
AuthenticationException 401 UNAUTHENTICATED
AuthorizationException 403 INSUFFICIENT_SCOPE
ModelNotFoundException 404 NOT_FOUND
ValidationException 422 (shape B — no errorCode)
ThrottleRequestsException 429 RATE_LIMITED
InsufficientFundsException 430 INSUFFICIENT_FUNDS
PostAlreadyPurchasedException 430 POST_ALREADY_PURCHASED
TierArchivedException 430 TIER_ARCHIVED
WithdrawalBelowMinimumException 430 WITHDRAWAL_BELOW_MINIMUM
WithdrawalAboveDailyLimitException 430 WITHDRAWAL_ABOVE_DAILY_LIMIT
MfaChallengeRequiredException 430 MFA_CHALLENGE_REQUIRED
MfaCodeInvalidException 430 MFA_CODE_INVALID
HandleCooldownNotElapsedException 430 HANDLE_COOLDOWN_NOT_ELAPSED
EmailAlreadyRegisteredException 430 EMAIL_ALREADY_REGISTERED
HandleUnavailableException 430 HANDLE_UNAVAILABLE
IdempotencyConflictException 409 IDEMPOTENCY_CONFLICT
PaymentProviderException 502 PAYMENT_PROVIDER_FAILED
LedgerIntegrityException 500 LEDGER_INTEGRITY_VIOLATION (alerts ops)
\Throwable (catch-all) 500 INTERNAL_ERROR

Base domain exception:

<?php

declare(strict_types=1);

namespace App\Core\Exceptions;

abstract class DomainException extends \Exception
{
    abstract public function errorCode(): ErrorCode;

    /** Default: business rule violation → 430. Override for infra/other. */
    public function httpStatus(): int
    {
        return 430;
    }
}

ErrorCode enum (full catalog grows per domain; extract):

<?php

declare(strict_types=1);

namespace App\Core\Exceptions;

enum ErrorCode: string
{
    case Unauthenticated             = 'UNAUTHENTICATED';
    case InsufficientScope           = 'INSUFFICIENT_SCOPE';
    case NotFound                    = 'NOT_FOUND';
    case RateLimited                 = 'RATE_LIMITED';
    case IdempotencyConflict         = 'IDEMPOTENCY_CONFLICT';
    case InsufficientFunds           = 'INSUFFICIENT_FUNDS';
    case PostAlreadyPurchased        = 'POST_ALREADY_PURCHASED';
    case TierArchived                = 'TIER_ARCHIVED';
    case WithdrawalBelowMinimum      = 'WITHDRAWAL_BELOW_MINIMUM';
    case WithdrawalAboveDailyLimit   = 'WITHDRAWAL_ABOVE_DAILY_LIMIT';
    case MfaChallengeRequired        = 'MFA_CHALLENGE_REQUIRED';
    case MfaCodeInvalid              = 'MFA_CODE_INVALID';
    case HandleCooldownNotElapsed    = 'HANDLE_COOLDOWN_NOT_ELAPSED';
    case HandleUnavailable           = 'HANDLE_UNAVAILABLE';
    case EmailAlreadyRegistered      = 'EMAIL_ALREADY_REGISTERED';
    case PaymentProviderFailed       = 'PAYMENT_PROVIDER_FAILED';
    case LedgerIntegrityViolation    = 'LEDGER_INTEGRITY_VIOLATION';
    case InternalError               = 'INTERNAL_ERROR';
    // ... extended per domain; each new case PR updates this file + tests + API docs
}

Exception example:

<?php

declare(strict_types=1);

namespace App\Payments\Exceptions;

use App\Core\Exceptions\DomainException;
use App\Core\Exceptions\ErrorCode;

final class WithdrawalBelowMinimumException extends DomainException
{
    public function __construct(int $attemptedAmount, int $minimumAmount)
    {
        parent::__construct(sprintf(
            'Withdrawal of %d is below the minimum allowed (%d).',
            $attemptedAmount,
            $minimumAmount,
        ));
    }

    public function errorCode(): ErrorCode
    {
        return ErrorCode::WithdrawalBelowMinimum;
    }
}

Every domain exception must include a unit test asserting (a) the errorCode() returns the intended case and (b) a controller throwing it produces the expected 430 + shape C response.

5.5 Rate Limiting

config/rate_limits.php:

return [
    'auth' => ['decayMinutes' => 1, 'max' => 10, 'by' => 'ip'],
    'payment' => ['decayMinutes' => 1, 'max' => 5, 'by' => 'user'],
    'write' => ['decayMinutes' => 1, 'max' => 60, 'by' => 'user'],
    'read' => ['decayMinutes' => 1, 'max' => 300, 'by' => 'user'],
    'public_read' => ['decayMinutes' => 1, 'max' => 60, 'by' => 'ip'],
    'admin' => ['decayMinutes' => 1, 'max' => 120, 'by' => 'admin'],
];

Applied via RateLimit::for('auth', fn ($req) => Limit::perMinute(10)->by($req->ip())) in RouteServiceProvider.

5.6 Platform Settings

Runtime-tunable config keyed by dotted namespace:

platform_settings table:

Column Type Notes
id bigint, PK
key string, unique e.g. payment.platform_fee_rate
value jsonb Strongly typed per key
description text Human-readable explanation
updated_by_admin_id bigint, FK → admin_users Last editor
updated_at timestamptz

Managed via Filament PlatformSetting resource (PlatformSetting.ReadWrite scope). Accessed in code via app(IPlatformSettings::class)->get('payment.platform_fee_rate') — cached in Redis with cache-tag invalidation on write.

Canonical keys (v1):

Key Type Default Owner Domain
payment.platform_fee_rate decimal 0.15 Monetization
payment.premium_fee_rate decimal 0.12 Monetization
payment.top_up_min int 5000 (Ksh 50, minor units) Payments
payment.top_up_max int 7000000 (Ksh 70k, minor units) Payments
payment.withdrawal_min int 50000 (Ksh 500, minor units) Payments
payment.withdrawal_max_per_txn int 15000000 (minor units) Payments
payment.withdrawal_max_per_day int 30000000 (minor units) Payments
payment.withdrawal_max_per_day_count int 3 Payments
payment.withdrawal_max_per_day_count_premium int 6 Payments
payment.earnings_hold_days int 3 Payments
payment.mpesa_callback_poll_interval_seconds int 5 Payments
payment.mpesa_callback_poll_timeout_seconds int 120 Payments
content.access_rules_enabled array<string> ["tier_gated","one_off_purchase","free_for_active_fans","public_free"] Content
content.explicit_content_enabled bool false Content
discovery.ranker_weights json (see §14) Discovery
discovery.active_fan_weights json (see §10) Access
discovery.active_fan_threshold decimal 50.0 Access
moderation.mode enum reactive_only Moderation
referral.commission_rate decimal 0.10 Promotions
referral.attribution_window_days int 30 Promotions
referral.welcome_credit int 10000 (Ksh 100, minor units) Promotions
notifications.digest_hour int 8 Notifications

5.7 Feature Flags

Separate from platform settings: binary toggles for in-progress features.

feature_flags table:

Column Type
id bigint, PK
key string, unique
is_enabled bool
rollout_percentage int (0-100, for gradual rollout)
allowed_user_ids jsonb (override: specific users always see it)
description text
updated_by_admin_id bigint
updated_at timestamptz

Accessed via app(IFeatureFlags::class)->enabled('livestream', $user).

5.8 Scope Dictionary

Static, PHP enum–driven. The scope dictionary is a single backed string enum, which is the canonical source of truth for:

  1. Application code (authorizeScope(Scope::LedgerRead))
  2. The Filament admin UI (dropdowns, role editor)
  3. The seeder that populates DB-backed roles (role_scopes stores the value)
  4. Static analysis (custom Larastan rule forbids raw string literals matching .*\\.(Read|ReadWrite|Approval) outside the enum file)
<?php

declare(strict_types=1);

namespace App\Core\Authorization;

enum Scope: string
{
    // Identity
    case UserRead                       = 'User.Read';
    case UserReadWrite                  = 'User.ReadWrite';
    case AdminUserRead                  = 'AdminUser.Read';
    case AdminUserReadWrite             = 'AdminUser.ReadWrite';
    case RoleRead                       = 'Role.Read';

    // Creator lifecycle
    case CreatorRead                    = 'Creator.Read';
    case CreatorReadWrite               = 'Creator.ReadWrite';
    case CreatorApproval                = 'Creator.Approval';
    case VerificationRequestRead        = 'VerificationRequest.Read';
    case VerificationRequestReadWrite   = 'VerificationRequest.ReadWrite';
    case VerificationRequestApproval    = 'VerificationRequest.Approval';

    // Content
    case PostRead                       = 'Post.Read';
    case PostReadWrite                  = 'Post.ReadWrite';
    case PostApproval                   = 'Post.Approval';
    case CommentRead                    = 'Comment.Read';
    case CommentReadWrite               = 'Comment.ReadWrite';
    case CollectionRead                 = 'Collection.Read';
    case CollectionReadWrite            = 'Collection.ReadWrite';
    case TaxonomyRead                   = 'Taxonomy.Read';
    case TaxonomyReadWrite              = 'Taxonomy.ReadWrite';

    // Finance
    case LedgerRead                     = 'Ledger.Read';
    case LedgerReadWrite                = 'Ledger.ReadWrite';
    case LedgerApproval                 = 'Ledger.Approval';
    case WalletRead                     = 'Wallet.Read';
    case TopUpRead                      = 'TopUp.Read';
    case TopUpReadWrite                 = 'TopUp.ReadWrite';
    case WithdrawalRead                 = 'Withdrawal.Read';
    case WithdrawalReadWrite            = 'Withdrawal.ReadWrite';
    case WithdrawalApproval             = 'Withdrawal.Approval';
    case RefundRead                     = 'Refund.Read';
    case RefundReadWrite                = 'Refund.ReadWrite';
    case RefundApproval                 = 'Refund.Approval';

    // Monetization
    case TierRead                       = 'Tier.Read';
    case TierSubscriptionRead           = 'TierSubscription.Read';
    case PremiumSubscriptionRead        = 'PremiumSubscription.Read';
    case PremiumSubscriptionReadWrite   = 'PremiumSubscription.ReadWrite';

    // Moderation
    case ReportRead                     = 'Report.Read';
    case ReportReadWrite                = 'Report.ReadWrite';

    // Promotions
    case PromoCodeRead                  = 'PromoCode.Read';
    case PromoCodeReadWrite             = 'PromoCode.ReadWrite';
    case BoostedPostRead                = 'BoostedPost.Read';
    case BoostedPostReadWrite           = 'BoostedPost.ReadWrite';
    case BoostedPostApproval            = 'BoostedPost.Approval';
    case ReferralRead                   = 'Referral.Read';

    // Platform
    case PlatformSettingRead            = 'PlatformSetting.Read';
    case PlatformSettingReadWrite       = 'PlatformSetting.ReadWrite';
    case FeatureFlagRead                = 'FeatureFlag.Read';
    case FeatureFlagReadWrite           = 'FeatureFlag.ReadWrite';
    case NicheRead                      = 'Niche.Read';
    case NicheReadWrite                 = 'Niche.ReadWrite';
    case CategoryRead                   = 'Category.Read';
    case CategoryReadWrite              = 'Category.ReadWrite';
    case FanClubRead                    = 'FanClub.Read';
    case FanClubReadWrite               = 'FanClub.ReadWrite';

    // Observability & governance
    case AuditLogRead                   = 'AuditLog.Read';
    case RequestLogRead                 = 'RequestLog.Read';
    case ApprovalRequestRead            = 'ApprovalRequest.Read';
    case NotificationRead               = 'Notification.Read';
    case NotificationReadWrite          = 'Notification.ReadWrite';
    case SystemMaintenance              = 'System.Maintenance';

    /** Human-readable description surfaced in Filament role editor. */
    public function description(): string
    {
        return match ($this) {
            self::UserRead                     => 'View user accounts (non-PII scope)',
            self::UserReadWrite                => 'Edit user accounts, reset passwords, impersonate',
            self::CreatorApproval              => 'Grant/revoke notable verification',
            self::LedgerRead                   => 'View ledger transactions & entries',
            self::LedgerReadWrite              => 'Request manual adjustment',
            self::LedgerApproval               => 'Approve manual adjustments (two-person)',
            self::WithdrawalApproval           => 'Force-fail / manually settle withdrawals (two-person)',
            self::RefundApproval               => 'Approve refunds (two-person)',
            self::AdminUserReadWrite           => 'Create/edit admin users (Super Admin only)',
            self::SystemMaintenance            => 'Enter maintenance mode, emergency feature flag toggles',
            // ... exhaustive match arm per case, no default
        };
    }

    /** Grouping for UI / docs. */
    public function group(): ScopeGroup
    {
        return match (true) {
            str_starts_with($this->value, 'User.'),
            str_starts_with($this->value, 'AdminUser.'),
            str_starts_with($this->value, 'Role.')            => ScopeGroup::Identity,
            str_starts_with($this->value, 'Ledger.'),
            str_starts_with($this->value, 'Wallet.'),
            str_starts_with($this->value, 'TopUp.'),
            str_starts_with($this->value, 'Withdrawal.'),
            str_starts_with($this->value, 'Refund.')           => ScopeGroup::Finance,
            str_starts_with($this->value, 'Post.'),
            str_starts_with($this->value, 'Comment.'),
            str_starts_with($this->value, 'Collection.'),
            str_starts_with($this->value, 'Taxonomy.'),
            str_starts_with($this->value, 'Niche.'),
            str_starts_with($this->value, 'Category.')         => ScopeGroup::Content,
            str_starts_with($this->value, 'Report.')           => ScopeGroup::Moderation,
            str_starts_with($this->value, 'Creator.'),
            str_starts_with($this->value, 'VerificationRequest.'),
            str_starts_with($this->value, 'Tier.'),
            str_starts_with($this->value, 'PremiumSubscription.'),
            str_starts_with($this->value, 'PromoCode.'),
            str_starts_with($this->value, 'BoostedPost.'),
            str_starts_with($this->value, 'Referral.')         => ScopeGroup::Monetization,
            str_starts_with($this->value, 'AuditLog.'),
            str_starts_with($this->value, 'RequestLog.'),
            str_starts_with($this->value, 'ApprovalRequest.')  => ScopeGroup::Observability,
            default                                             => ScopeGroup::Platform,
        };
    }

    /** True for `.Approval`-suffixed scopes — must use `ApprovalRequest` workflow. */
    public function requiresDualApproval(): bool
    {
        return str_ends_with($this->value, '.Approval');
    }
}

Companion enum for UI grouping:

<?php

declare(strict_types=1);

namespace App\Core\Authorization;

enum ScopeGroup: string
{
    case Identity      = 'Identity';
    case Finance       = 'Finance';
    case Content       = 'Content';
    case Moderation    = 'Moderation';
    case Monetization  = 'Monetization';
    case Observability = 'Observability';
    case Platform      = 'Platform';
}

Usage:

// Authorize
$this->identity->authorizeScope(Scope::LedgerRead);

// Filament resource declaration
protected static ?Scope $requiredScope = Scope::PostReadWrite;

// Role seeding (role_scopes.scope column stores the string value)
Role::super_admin()->scopes()->sync(
    array_map(fn (Scope $s) => $s->value, Scope::cases())
);

// Two-person gate at runtime
if ($scope->requiresDualApproval()) {
    ApprovalRequest::open($approvable, $scope, $currentAdmin, $reason);
    return;
}

ScopeRegistry validates at boot:

  1. Every enum case has a description arm (tested via ScopeRegistryTest::EveryCase_HasDescription).
  2. Every role seeder references only existing enum cases (impossible to regress — enum typing).
  3. DB role_scopes.scope rows all correspond to current enum values (any dangling row blocks deploy).

5.9 Health & Readiness

Two endpoints:

  • GET /health — liveness probe. Returns 200 if PHP is running. No DB check.
  • GET /ready — readiness probe. Checks: PostgreSQL reachable, Redis reachable, MongoDB reachable, S3 reachable (HEAD on bucket), external payment provider reachable (cached 30s). Returns 200 only if all green.

5.10 Endpoints (Infrastructure-owned)

Method Path Auth Purpose
GET /health public Liveness
GET /ready public Readiness
GET /v1/platform/settings/public public Subset of settings safe to expose (fee rate, limits for UI)
GET /v1/platform/feature-flags user Flags for the current user

6. Domain: Identity

6.1 Responsibility

Owns all authentication, authorization foundations, user account lifecycle, and realm resolution. Split across two realms:

  • Realm user — end users (viewers and creators; the distinction is a status, not a table).
  • Realm admin — Filament back-office administrators.

A Realm enum is the system-wide source of truth:

<?php

declare(strict_types=1);

namespace App\Identity\Enums;

enum Realm: string
{
    case User = 'user';
    case Admin = 'admin';
}

6.2 Entities

6.2.1 users

Column Type Notes
id bigint, PK
public_id ulid, unique External identifier
email citext, unique Case-insensitive
email_verified_at timestamptz, nullable
password string, nullable Nullable for social-only accounts
handle citext, unique URL-safe, [a-z0-9_]{3,32}, case-insensitive
handle_changed_at timestamptz, nullable For 30-day cooldown
first_name string
last_name string
display_name string, nullable Fallback to {first} {last}
bio string(280), nullable
avatar_path string, nullable S3 key
banner_path string, nullable S3 key
city string, nullable
country_code char(2), nullable ISO 3166-1 alpha-2
phone_number string, nullable E.164 format
phone_verified_at timestamptz, nullable
niche_id bigint, FK → niches, nullable
is_creator bool, default false Flipped true on first post or tier creation
is_notable_verified bool, default false Admin-granted
is_profile_public bool, default true
has_explicit_content bool, default false Creator self-attestation
mfa_enabled bool, default false
mfa_provider enum('totp','otp_sms','otp_email','passkey'), nullable
mfa_secret text, nullable Encrypted via Laravel Crypt cast
mfa_backup_codes jsonb, nullable Encrypted; 8 single-use codes
mfa_confirmed_at timestamptz, nullable
locale string(5), default 'en'
timezone string(64), default 'Africa/Nairobi'
last_login_at timestamptz, nullable
last_login_ip inet, nullable
lifetime_earnings_cents bigint, default 0 Denormalized, reconciled from ledger nightly
created_at timestamptz
updated_at timestamptz
deleted_at timestamptz, nullable Soft delete; GDPR/KDPA erasure grace period

Indexes: email, handle, niche_id, is_creator, created_at, (is_creator, created_at) for creator directory.

6.2.2 admin_users

Column Type Notes
id bigint, PK
public_id ulid, unique
email citext, unique
password string Required
first_name string
last_name string
mfa_enabled bool, default false Always required for admins (enforced in middleware, not at signup)
mfa_provider enum
mfa_secret text, encrypted
mfa_backup_codes jsonb, encrypted
mfa_confirmed_at timestamptz, nullable
is_active bool, default true Soft-disable without delete
last_login_at timestamptz, nullable
last_login_ip inet, nullable
created_by_admin_id bigint, FK → admin_users, nullable Who provisioned this admin
created_at timestamptz
updated_at timestamptz

No public signup. Provisioned via seeders (initial Super Admin) or Filament by existing Super Admin.

6.2.3 roles

Column Type
id bigint, PK
name string, unique (e.g. super_admin)
display_name string (e.g. Super Admin)
description text

Seeded from config/roles.php. Managed via RoleSeeder — reseeding is idempotent and reverts manual drift.

6.2.4 role_scopes

Column Type
id bigint, PK
role_id bigint, FK
scope string (matches a Scope enum case value)

Unique on (role_id, scope). Seeded alongside roles.

6.2.5 admin_user_roles

Column Type
admin_user_id bigint, FK
role_id bigint, FK
assigned_at timestamptz
assigned_by_admin_id bigint, FK

Unique on (admin_user_id, role_id). An admin can hold multiple roles; effective scopes = union.

6.2.6 social_accounts

Column Type
id bigint, PK
user_id bigint, FK → users
provider enum('google','apple','facebook','linkedin')
provider_user_id string
provider_email string, nullable
linked_at timestamptz

Unique on (provider, provider_user_id). One user may link multiple providers.

6.2.7 handle_history

Column Type
id bigint, PK
user_id bigint, FK
old_handle citext
released_at timestamptz
created_at timestamptz

Queried during handle availability check: handle is unavailable if currently held by any user OR present in handle_history with released_at > now().

6.2.8 email_verifications

Column Type
id bigint, PK
user_id bigint, FK
token_hash string
expires_at timestamptz
consumed_at timestamptz, nullable

6.2.9 password_reset_tokens

Standard Laravel table, augmented with ip_address, user_agent, consumed_at.

6.2.10 mfa_challenges

Column Type
id bigint, PK
challenger_type string (user/admin_user — polymorphic)
challenger_id bigint
provider enum
challenge_token string, unique
payload jsonb, nullable
verified_at timestamptz, nullable
expires_at timestamptz
created_at timestamptz

6.2.11 personal_access_tokens

Standard Sanctum table. Augmented with device_name, last_used_ip, last_used_user_agent.

6.2.12 user_consents

Column Type
id bigint, PK
user_id bigint, FK
consent_type enum('terms_of_service','privacy_policy','marketing_emails','explicit_content_acknowledgment','age_verification')
document_version string (e.g. 2026-04-20)
granted_at timestamptz
revoked_at timestamptz, nullable
ip_address inet
user_agent string

6.3 Relationships

  • User hasMany SocialAccounts
  • User hasMany HandleHistory
  • User hasMany UserConsents
  • User hasOne Wallet (Ledger domain)
  • User belongsTo Niche (Content domain)
  • User hasMany Posts (Content domain — when is_creator=true)
  • AdminUser belongsToMany Roles through admin_user_roles
  • Role belongsToMany Scopes through role_scopes

6.4 Key Services & Actions

IIdentityService (App\Identity\Contracts\IIdentityService)

Single domain-facing surface. No code reaches into auth() directly.

<?php

declare(strict_types=1);

namespace App\Identity\Contracts;

use App\Identity\Enums\Realm;
use App\Identity\Models\AdminUser;
use App\Identity\Models\User;

interface IIdentityService
{
    public function currentUser(): ?User;
    public function currentAdmin(): ?AdminUser;
    public function currentRealm(): Realm;
    public function isAuthenticated(): bool;
    public function hasScope(string $scope): bool;

    /** Throws \App\Core\Exceptions\AuthorizationException if missing. */
    public function authorizeScope(string $scope): void;
}

Actions

Action Responsibility
RegisterUserAction Create user, hash password, send verification email, apply referral
VerifyEmailAction Consume token, mark email_verified_at
LoginAction Credential check, MFA challenge issuance, session start
LogoutAction Revoke current token / session
RequestPasswordResetAction Generate token, send reset email, rate limit
ResetPasswordAction Consume token, rehash, invalidate all sessions
ChangePasswordAction Require current password, rehash, invalidate other sessions
EnableMfaAction Initiate provider-specific setup (returns QR/secret for TOTP)
ConfirmMfaAction Verify first code, set mfa_confirmed_at, generate backup codes
DisableMfaAction Require re-auth + current MFA code; clear secret
ChallengeMfaAction Issue MfaChallenge during sensitive op
VerifyMfaChallengeAction Verify challenge code; mark verified_at
LinkSocialAccountAction OAuth callback → attach SocialAccount to user; merge if email matches and is verified
CompleteOnboardingAction Set handle, niche, bio, profile media (first-time user setup)
UpdateProfileAction General profile edits
ChangeUsernameAction Enforce 30-day cooldown, move old handle to handle_history
UploadAvatarAction / UploadBannerAction Validate, process, persist
ExportUserDataAction KDPA/GDPR data export — dispatches job returning signed S3 URL
DeleteAccountAction Soft-delete; full erasure after 30-day grace
GrantConsentAction Record a UserConsent row

MFA Provider Interface

<?php

declare(strict_types=1);

namespace App\Identity\Contracts;

use App\Identity\Models\MfaChallenge;

interface IMfaProvider
{
    public function identifier(): string; // 'totp' | 'otp_email' | ...

    /** Returns provider-specific setup payload (e.g. TOTP secret + QR). */
    public function initiate(mixed $challenger): array;

    /** Creates a challenge record and returns the challenge token. */
    public function challenge(mixed $challenger): MfaChallenge;

    /** Verifies the user-supplied code against the challenge. */
    public function verify(MfaChallenge $challenge, string $code): bool;
}

v1 implementations:

  • TotpMfaProvider (active)
  • EmailOtpMfaProvider (active)
  • SmsOtpMfaProvider (stubbed; throws FeatureNotEnabledException)
  • PasskeyMfaProvider (stubbed)

6.5 Social Login Flow

  1. GET /v1/identity/social/{provider}/redirect → returns provider authorization URL
  2. User authorizes at provider
  3. GET /v1/identity/social/{provider}/callback?code=...
  4. LinkSocialAccountAction runs:
    • Exchanges code for provider user
    • If social_accounts row exists → log in that user
    • Else if user with same verified email exists → attach SocialAccount, log in, audit log
    • Else → create new user (social-only, password=null), dispatch UserRegistered event, log in
  5. Issue Sanctum token + redirect to app

6.6 MFA Enforcement Logic

Evaluated per-request by EnforceMfaChallenge middleware:

IF realm = admin                        → MFA required on every login session
IF realm = user:
    IF current endpoint is in SENSITIVE_ENDPOINTS (withdrawals, enable MFA, change password, delete account)
        → MFA challenge required this session
    IF user.lifetime_earnings_cents > 0 AND user.mfa_enabled = false
        → Block request with 403, error code `mfa_required_for_earnings`

SENSITIVE_ENDPOINTS is configurable in config/mfa.php.

6.7 Endpoints

All under /v1/identity/* unless noted.

Method Path Auth Scope Purpose
POST /register public Email/password registration
POST /login public Email/password login
POST /logout user Revoke current token
POST /email/verify/send user Resend verification email
POST /email/verify/confirm public Consume verification token
POST /password/forgot public Request password reset
POST /password/reset public Consume reset token
POST /password/change user Change password (requires current)
GET /social/{provider}/redirect public Start OAuth flow
GET /social/{provider}/callback public OAuth callback
POST /social/link user Link additional provider
DELETE /social/{provider} user Unlink provider
POST /mfa/enable user Initiate MFA setup
POST /mfa/confirm user Confirm first code
POST /mfa/disable user Disable MFA (requires current code)
POST /mfa/challenge user Issue challenge
POST /mfa/verify user Verify challenge
POST /mfa/backup-codes/regenerate user Regenerate backup codes
GET /me user Current user profile
PATCH /me user Update profile
POST /me/avatar user Upload avatar
POST /me/banner user Upload banner
POST /me/handle user Change handle
POST /me/onboarding/complete user Finish first-time setup
POST /me/consents user Record consent
POST /me/data-export user Trigger data export job
DELETE /me user Delete account (soft, 30d grace)

6.8 OpenAPI Example (Login)

#[OA\Post(
    path: '/v1/identity/login',
    summary: 'Authenticate and receive access token',
    tags: ['Identity'],
    requestBody: new OA\RequestBody(
        required: true,
        content: new OA\JsonContent(
            required: ['email', 'password'],
            properties: [
                new OA\Property(property: 'email', type: 'string', format: 'email'),
                new OA\Property(property: 'password', type: 'string', format: 'password'),
                new OA\Property(property: 'deviceName', type: 'string', nullable: true),
            ],
        ),
    ),
    responses: [
        new OA\Response(response: 200, description: 'Authenticated', content: new OA\JsonContent(
            properties: [
                new OA\Property(property: 'message', type: 'string', example: 'OK'),
                new OA\Property(property: 'data', properties: [
                    new OA\Property(property: 'user', ref: '#/components/schemas/User'),
                    new OA\Property(property: 'accessToken', type: 'string'),
                    new OA\Property(property: 'mfaChallengeToken', type: 'string', nullable: true),
                ], type: 'object'),
                new OA\Property(property: 'meta', ref: '#/components/schemas/ResponseMeta'),
            ],
        )),
        new OA\Response(response: 401, description: 'Invalid credentials', content: new OA\JsonContent(ref: '#/components/schemas/ApplicationError')),
        new OA\Response(response: 422, description: 'Validation error', content: new OA\JsonContent(ref: '#/components/schemas/ValidationError')),
        new OA\Response(response: 429, description: 'Rate limited', content: new OA\JsonContent(ref: '#/components/schemas/ApplicationError')),
    ],
)]

7. Domain: Ledger & Wallet

7.1 Responsibility

The Ledger domain is the single source of truth for all money movement on the platform. Every cent that enters, moves within, or leaves the system is recorded as a balanced pair (or set) of ledger entries. This domain provides:

  • Immutable, append-only double-entry ledger
  • Per-user wallet (balance computed from ledger; denormalized for fast read)
  • 3-day earnings hold (withdrawable-after semantics)
  • Idempotent posting primitive
  • Reconciliation job (nightly integrity check)
  • Platform revenue accounting

Every other domain that moves money (Payments, Monetization, Promotions, Moderation/refunds) calls ILedgerService::post(). No direct wallet balance writes exist anywhere else in the codebase.

7.2 Entities

7.2.1 ledger_accounts

Represents a logical account. Balances are derived from entries.

Column Type Notes
id bigint, PK
public_id ulid
type enum user_wallet, user_pending_earnings, platform_revenue, platform_mpesa_float, platform_mpesa_payouts, platform_processor_fees, platform_marketing_expense, platform_refund_liability
owner_type string, nullable Polymorphic (user)
owner_id bigint, nullable
currency char(3) ISO 4217; KES in v1
is_active bool, default true
created_at timestamptz

Per-user accounts (created on user registration):

  • user_wallet (spendable + settled earnings)
  • user_pending_earnings (within 3-day hold)

Platform accounts (singletons, seeded):

  • platform_revenue — where fees land
  • platform_mpesa_float — represents money in the MPESA merchant account
  • platform_mpesa_payouts — outgoing MPESA B2C float
  • platform_processor_fees — MPESA transaction fees paid
  • platform_marketing_expense — referral welcome credits, promotional give-backs
  • platform_refund_liability — refund IOUs awaiting settlement

Unique constraint: (type, owner_type, owner_id, currency) where applicable.

7.2.2 ledger_transactions

One row per business event. Each transaction has ≥2 entries that sum to zero.

Column Type Notes
id bigint, PK
public_id ulid, unique
purpose enum top_up, post_purchase, tier_subscription_payment, tier_subscription_refund, post_purchase_refund, earnings_release, withdrawal, withdrawal_failure_reversal, platform_fee, processor_fee, referral_commission, welcome_credit, manual_adjustment, promo_discount, premium_subscription_payment
reference_type string, nullable Polymorphic to source entity (top_up, post_purchase, etc.)
reference_id bigint, nullable
idempotency_key string, unique, nullable Deduplication
memo string, nullable Human-readable context
metadata jsonb Arbitrary context (MPESA ref, IPs, user agent, etc.)
platform_fee_rate_snapshot decimal(5,4), nullable Snapshot of active fee rate when relevant
posted_by_admin_id bigint, nullable Set only for manual adjustments
approval_request_id bigint, nullable Set only for approval-gated transactions
created_at timestamptz

Immutability: no updated_at. Corrections are new reversing transactions.

7.2.3 ledger_entries

One row per account impact. Paired to balance each transaction.

Column Type Notes
id bigint, PK
ledger_transaction_id bigint, FK
ledger_account_id bigint, FK
direction enum('debit','credit')
amount_minor_units bigint Always positive
signed_amount_minor_units bigint Generated: credit = +amount, debit = -amount
currency char(3) Must match ledger_account.currency
withdrawable_after timestamptz, nullable Only set for entries crediting user_pending_earnings or releasing to user_wallet
created_at timestamptz

Constraints:

  • CHECK: amount_minor_units > 0
  • CHECK: currency = (SELECT currency FROM ledger_accounts WHERE id = ledger_account_id)
  • DB trigger on INSERT: forbids inserts that would cause SUM(signed_amount_minor_units) WHERE ledger_transaction_id = X to be non-zero. Enforced transactionally at commit.

Indexes: ledger_transaction_id, ledger_account_id, (ledger_account_id, created_at) for balance windows, withdrawable_after for release job.

7.2.4 wallets

Denormalized projection for fast read. One row per user.

Column Type Notes
id bigint, PK
user_id bigint, FK, unique
currency char(3)
available_balance_minor_units bigint, default 0 Spendable + settled earnings
pending_balance_minor_units bigint, default 0 Earnings in 3-day hold
lifetime_earnings_minor_units bigint, default 0
lifetime_spend_minor_units bigint, default 0
lifetime_withdrawn_minor_units bigint, default 0
updated_at timestamptz Last change

Maintained atomically alongside ledger writes in the same DB transaction.

Reconciliation job (nightly, ReconcileWalletsJob) recomputes each wallet from ledger_entries and logs any drift to WalletDriftIncident for admin review.

7.2.5 approval_requests

Generic two-person-approval gate.

Column Type
id bigint, PK
approvable_type string (polymorphic)
approvable_id bigint
required_scope string (e.g. Ledger.Approval)
requested_by_admin_id bigint
requested_at timestamptz
reason text
approved_by_admin_id bigint, nullable
approved_at timestamptz, nullable
rejected_by_admin_id bigint, nullable
rejected_at timestamptz, nullable
rejection_reason text, nullable
expires_at timestamptz

7.2.6 wallet_drift_incidents

Written by reconciliation job when wallet.balance != SUM(ledger_entries). Surfaces in Filament, alerts ops.

7.3 Posting Rules Catalog

The canonical set of double-entry postings. Every money movement matches exactly one rule.

Rule: top_up

Viewer top-ups wallet via MPESA.

Direction Account Amount
DEBIT platform_mpesa_float gross
CREDIT user_wallet (viewer) gross

Net effect: platform's MPESA balance goes down (it owes the user now), user's wallet goes up.

Rule: post_purchase (paid via wallet)

Viewer buys a post using their wallet.

Direction Account Amount
DEBIT user_wallet (viewer) gross
CREDIT platform_revenue gross × fee_rate
CREDIT user_pending_earnings (creator) gross × (1 − fee_rate), withdrawable_after = now + 3d

Rule: post_purchase (paid via direct MPESA STK)

Viewer buys a post with a fresh STK push (no wallet top-up step).

Direction Account Amount
DEBIT platform_mpesa_float gross
CREDIT platform_revenue gross × fee_rate
CREDIT user_pending_earnings (creator) gross × (1 − fee_rate), withdrawable_after = now + 3d

Rule: tier_subscription_payment

Recurring tier charge.

Direction Account Amount
DEBIT user_wallet OR platform_mpesa_float gross (depending on payment source)
CREDIT platform_revenue gross × fee_rate
CREDIT user_pending_earnings (creator) gross × (1 − fee_rate), withdrawable_after = now + 3d

Rule: earnings_release

Nightly ReleaseEarningsJob sweeps pending entries with withdrawable_after <= now.

Direction Account Amount
DEBIT user_pending_earnings (creator) amount
CREDIT user_wallet (creator) amount

Rule: withdrawal

Creator withdraws from wallet to MPESA.

Direction Account Amount
DEBIT user_wallet (creator) gross
CREDIT platform_mpesa_payouts gross − processor_fee
CREDIT platform_processor_fees processor_fee

Note: processor_fee reduces what the user ultimately receives. The full gross is debited from the user's wallet; the processor fee is shown as a transparent line item in the Withdrawal reference record.

Rule: withdrawal_failure_reversal

MPESA B2C fails; funds return to user.

Direction Account Amount
DEBIT platform_mpesa_payouts net
DEBIT platform_processor_fees processor_fee
CREDIT user_wallet (creator) gross

Rule: post_purchase_refund

Admin issues refund for a past purchase.

Direction Account Amount
DEBIT platform_revenue original fee
DEBIT user_wallet (creator) OR user_pending_earnings (creator) original creator share
CREDIT user_wallet (viewer) gross

If original purchase was via direct MPESA and platform needs to return via MPESA B2C, the CREDIT side goes to platform_refund_liability, and a subsequent withdrawal-style posting to platform_mpesa_payouts clears it.

Rule: referral_commission

Referral attribution pays out a slice of referred user's spend.

Direction Account Amount
DEBIT platform_revenue commission
CREDIT user_pending_earnings (referrer) commission, withdrawable_after = now + 3d

Rule: welcome_credit

New user referred through a referral link gets platform-funded credit.

Direction Account Amount
DEBIT platform_marketing_expense credit
CREDIT user_wallet (new user) credit

Note: Welcome credits have a used_before expiration (30 days) enforced at spend-time via an accounting tag on the entry metadata. Unused credits are clawed back by a scheduled job that posts a reversing manual_adjustment.

Rule: premium_subscription_payment

Direction Account Amount
DEBIT user_wallet OR platform_mpesa_float gross
CREDIT platform_revenue gross

(No creator share — premium is platform revenue.)

Rule: manual_adjustment

Admin-initiated, requires Ledger.Approval two-person gate, always writes an ApprovalRequest and a reason.

Entries are free-form but must balance. Used for reconciliation, compensation, correction.

7.4 Ledger Service

<?php

declare(strict_types=1);

namespace App\Ledger\Contracts;

use App\Ledger\Data\LedgerPostingData;
use App\Ledger\Models\LedgerTransaction;

interface ILedgerService
{
    /**
     * Post a balanced transaction. All entries committed in a single DB transaction.
     * Idempotent on `idempotencyKey` — replays return the existing transaction.
     *
     * @throws \App\Ledger\Exceptions\UnbalancedLedgerException
     * @throws \App\Ledger\Exceptions\InsufficientFundsException
     * @throws \App\Ledger\Exceptions\CurrencyMismatchException
     */
    public function post(LedgerPostingData $posting): LedgerTransaction;

    public function availableBalance(int $userId, string $currency = 'KES'): int;
    public function pendingBalance(int $userId, string $currency = 'KES'): int;

    /** For reconciliation jobs only. */
    public function recomputeBalance(int $accountId): int;
}

LedgerPostingData DTO:

<?php

declare(strict_types=1);

namespace App\Ledger\Data;

use App\Ledger\Enums\LedgerPurpose;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;

final class LedgerPostingData extends Data
{
    public function __construct(
        public LedgerPurpose $purpose,
        /** @var DataCollection<LedgerEntryData> */
        public DataCollection $entries,
        public ?string $idempotencyKey = null,
        public ?string $referenceType = null,
        public ?int $referenceId = null,
        public ?string $memo = null,
        public ?array $metadata = null,
        public ?string $platformFeeRateSnapshot = null,
        public ?int $postedByAdminId = null,
        public ?int $approvalRequestId = null,
    ) {}
}

final class LedgerEntryData extends Data
{
    public function __construct(
        public int $ledgerAccountId,
        public string $direction, // 'debit' | 'credit'
        public int $amountMinorUnits,
        public string $currency = 'KES',
        public ?\DateTimeImmutable $withdrawableAfter = null,
    ) {}
}

Example usage (inside PurchasePostAction):

$this->ledger->post(new LedgerPostingData(
    purpose: LedgerPurpose::PostPurchase,
    entries: LedgerEntryData::collect([
        ['ledgerAccountId' => $viewerWallet->id, 'direction' => 'debit', 'amountMinorUnits' => $grossCents],
        ['ledgerAccountId' => $platformRevenue->id, 'direction' => 'credit', 'amountMinorUnits' => $feeCents],
        ['ledgerAccountId' => $creatorPending->id, 'direction' => 'credit', 'amountMinorUnits' => $netCents, 'withdrawableAfter' => now()->addDays($holdDays)],
    ], DataCollection::class),
    idempotencyKey: $idempotencyKey,
    referenceType: 'post_purchase',
    referenceId: $postPurchase->id,
    memo: "Purchase of post {$post->public_id}",
    platformFeeRateSnapshot: (string) $feeRate,
));

7.5 Endpoints

Method Path Auth Scope Purpose
GET /v1/wallet user Current user wallet (available + pending balances)
GET /v1/wallet/transactions user Paginated ledger entries affecting current user
GET /v1/admin/ledger/transactions admin Ledger.Read Filament list
POST /v1/admin/ledger/manual-adjustment admin Ledger.ReadWrite Create ApprovalRequest for manual adjustment
POST /v1/admin/ledger/approvals/{id}/approve admin Ledger.Approval Second-person approval
POST /v1/admin/ledger/approvals/{id}/reject admin Ledger.Approval Reject

7.6 Invariants (enforced by tests and DB constraints)

  1. Zero-sum per transaction. SUM(signed_amount_minor_units) GROUP BY ledger_transaction_id = 0 for every transaction. Enforced via deferred DB trigger + unit tests.
  2. Currency consistency. Every entry in a transaction shares the same currency. Enforced in application layer (enum + checks).
  3. Wallet consistency. wallet.available_balance = SUM(signed_amount WHERE account=user_wallet AND user=X). Reconciled nightly.
  4. Pending held. Entries in user_pending_earnings always have non-null withdrawable_after.
  5. Immutability. No UPDATE or DELETE on ledger_transactions or ledger_entries. Enforced via DB privileges (app role has INSERT/SELECT only; DELETE requires a separate ledger_admin role not used at runtime).

8. Domain: Payments

8.1 Responsibility

Payments owns the external money movement — the bridge between the platform and MPESA (and, in v2, PayPal/Bank). It does not own the ledger; it calls ILedgerService::post() after successful external settlement.

Responsibilities:

  • MPESA C2B (top-ups, direct post-purchase STK pushes)
  • MPESA B2C (creator withdrawals)
  • Payment method management (withdrawal destinations)
  • Webhook/callback handling from the configured payment gateway via Kashier
  • Polling & timeout for stuck payments
  • Retry logic for failed B2C
  • Refund execution path (coordinating with Ledger)

8.2 Entities

8.2.1 payment_intents

Represents a pending external money movement in either direction.

Column Type
id bigint, PK
public_id ulid, unique
user_id bigint, FK
direction enum('inbound','outbound')
purpose enum('top_up','post_purchase','tier_subscription','premium_subscription','withdrawal')
provider enum('mpesa') — extensible
provider_method string ('c2b_stk', 'b2c')
amount_minor_units bigint
currency char(3)
status enum('pending','processing','succeeded','failed','cancelled','expired')
idempotency_key string, unique
reference_type string, nullable
reference_id bigint, nullable
provider_request jsonb
provider_response jsonb, nullable
provider_transaction_id string, nullable
provider_receipt string, nullable
failure_reason string, nullable
expires_at timestamptz
settled_at timestamptz, nullable
ledger_transaction_id bigint, FK, nullable
metadata jsonb
created_at timestamptz
updated_at timestamptz

8.2.2 withdrawal_methods

Column Type
id bigint, PK
public_id ulid
user_id bigint, FK
type enum('mpesa','paypal','bank')
is_primary bool
is_verified bool
label string, nullable
encrypted_details text
masked_display string
created_at timestamptz
deleted_at timestamptz, nullable

For MPESA: encrypted_details = {"phone": "254712345678"}, masked_display = "Phone ending in 678".

8.2.3 withdrawals

Column Type
id bigint, PK
public_id ulid
user_id bigint, FK
withdrawal_method_id bigint, FK
amount_minor_units bigint
processor_fee_minor_units bigint
net_minor_units bigint
currency char(3)
status enum('queued','processing','succeeded','failed','reversed')
failure_reason string, nullable
payment_intent_id bigint, FK
ledger_transaction_id bigint, FK, nullable
requested_at timestamptz
processed_at timestamptz, nullable
settled_at timestamptz, nullable
created_at / updated_at timestamptz

8.2.4 top_ups

Column Type
id bigint, PK
public_id ulid
user_id bigint, FK
amount_minor_units bigint
currency char(3)
payment_intent_id bigint, FK
status enum('pending','succeeded','failed','expired')
ledger_transaction_id bigint, FK, nullable
created_at / updated_at timestamptz

8.3 Payment Provider Interface

<?php

declare(strict_types=1);

namespace App\Payments\Contracts;

use App\Payments\Data\C2bStkRequestData;
use App\Payments\Data\C2bStkResponseData;
use App\Payments\Data\B2cRequestData;
use App\Payments\Data\B2cResponseData;
use App\Payments\Data\ProviderTransactionStatus;

interface IPaymentProvider
{
    public function identifier(): string;

    public function initiateC2bStk(C2bStkRequestData $data): C2bStkResponseData;

    public function initiateB2c(B2cRequestData $data): B2cResponseData;

    public function queryTransaction(string $providerTransactionId): ProviderTransactionStatus;

    public function verifyCallback(array $callbackPayload): bool;
}

Implementations:

  • KashierPaymentProvider (v1 active)
  • Stubs reserved for PaypalPaymentProvider, BankTransferPaymentProvider

8.4 Top-up Flow

Client → POST /v1/payments/top-ups { amount, phoneNumber, idempotencyKey }
   ↓
InitiateTopUpAction
   ├── Validate amount (within min/max from platform_settings)
   ├── Check rate limit (5 top-ups per 5 min)
   ├── Create PaymentIntent (status=pending)
   ├── Create TopUp (status=pending)
   ├── IPaymentProvider::initiateC2bStk(...)
   │    └── Kashier SDK initiates the provider-side STK / collection request
   ├── PaymentIntent.provider_transaction_id = provider reference
   ├── Dispatch PollPaymentIntentStatusJob (delay=5s)
   └── Return PaymentIntent payload to client

PollPaymentIntentStatusJob (runs every 5s up to 120s):
   ├── IPaymentProvider::queryTransaction(provider reference)
   ├── IF status=success:
   │      └── SettleTopUpAction
   │           ├── LedgerService::post(top_up rule)
   │           ├── Update PaymentIntent.status=succeeded, TopUp.status=succeeded
   │           ├── Dispatch TopUpSucceeded event
   │           └── Send notification
   ├── IF status=failure:
   │      └── FailTopUpAction (PaymentIntent.status=failed, Notify user)
   └── ELSE re-queue self until timeout

MpesaCallbackController (if Safaricom calls us back first):
   ├── Verify callback signature
   ├── Lookup PaymentIntent by CheckoutRequestID
   ├── SettleTopUpAction (idempotent — if already settled, noop)
   └── 200 OK

Polling and callback are both wired; whichever arrives first settles the intent. Settlement itself is idempotent by payment_intent_id.

8.5 Post-Purchase Direct STK Flow

Same primitives as top-up, but at settlement also writes the post_purchase ledger rule with the creator as the earning party. PurchasePostAction decides the payment source:

IF data.paymentMethod = 'wallet':
    check wallet.available_balance >= amount
    LedgerService::post(post_purchase via wallet)
    return PostPurchase

IF data.paymentMethod = 'mpesa_direct':
    InitiateTopUpAction-like flow BUT with purpose=post_purchase
    PaymentIntent carries reference to post_purchase intent
    Settlement runs the post_purchase rule (not top_up)

8.6 Withdrawal Flow

Client → POST /v1/payments/withdrawals { amount, withdrawalMethodId, idempotencyKey }
   ↓
RequestWithdrawalAction
   ├── Enforce MFA challenge (middleware)
   ├── Validate amount within [min, max_per_txn]
   ├── Enforce daily limits (count + cumulative)
   ├── Lock wallet row (SELECT ... FOR UPDATE)
   ├── Check wallet.available_balance >= amount
   ├── Create PaymentIntent (direction=outbound, status=pending)
   ├── Create Withdrawal (status=queued)
   ├── Dispatch ProcessWithdrawalJob (queue=transactional)
   └── Return Withdrawal payload (status=queued)

ProcessWithdrawalJob:
   ├── Withdrawal.status=processing
   ├── IPaymentProvider::initiateB2c(...)
   ├── PaymentIntent.provider_transaction_id = ConversationID
   ├── Wait for callback OR poll (30s up to 5min) since B2C is slower
   ├── IF success:
   │      └── SettleWithdrawalAction
   │           ├── Record processor_fee (from callback)
   │           ├── LedgerService::post(withdrawal rule)
   │           ├── Update Withdrawal.status=succeeded, settled_at
   │           ├── Mark WithdrawalMethod.is_verified=true (if first success)
   │           └── Notify user
   └── IF failure:
          └── FailWithdrawalAction
              ├── LedgerService::post(withdrawal_failure_reversal rule)
              ├── Update Withdrawal.status=failed
              └── Notify user (+ Filament visibility)

All B2C callbacks are signed/verified. Failures surface in Filament under the Withdrawal resource with a Retry action (requires Withdrawal.ReadWrite; auto-completed retries don't require approval).

8.7 Endpoints

Method Path Auth Scope Purpose
POST /v1/payments/top-ups user Initiate MPESA top-up
GET /v1/payments/top-ups/{id} user Status poll (client-side fallback)
POST /v1/payments/withdrawal-methods user Add MPESA method
GET /v1/payments/withdrawal-methods user List own methods
PATCH /v1/payments/withdrawal-methods/{id} user Set primary, update label
DELETE /v1/payments/withdrawal-methods/{id} user Remove method
POST /v1/payments/withdrawals user Request withdrawal (MFA gated)
GET /v1/payments/withdrawals user List own withdrawals
GET /v1/payments/withdrawals/{id} user Withdrawal detail
POST /v1/payments/kashier/callbacks/c2b public (signed) Kashier C2B callback
POST /v1/payments/kashier/callbacks/b2c public (signed) Kashier B2C callback
POST /v1/payments/kashier/callbacks/timeout public (signed) Kashier timeout callback
GET /v1/admin/payments/withdrawals admin Withdrawal.Read Ops view
POST /v1/admin/payments/withdrawals/{id}/retry admin Withdrawal.ReadWrite Manual retry after failure
POST /v1/admin/payments/withdrawals/{id}/force-fail admin Withdrawal.Approval Force-fail a stuck withdrawal (2-person)

8.8 Safety Rails

  • Row locking on wallet during withdrawal request prevents concurrent withdrawal of same balance.
  • Rate limits: 5 top-ups per 5 min, 3 withdrawals per day (6 for premium), enforced by RateLimit middleware keyed by user.
  • Minimum/maximum enforced per platform setting; configurable live.
  • MFA challenge required before withdrawal endpoint is accepted.
  • Idempotency key required on top-up, purchase, withdrawal endpoints.

9. Domain: Content

9.1 Responsibility

Content owns the lifecycle of creator-published material:

  • Posts (seven formats: text, video, audio, image, link, poll, livestream)
  • Media assets & transcoding pipeline
  • Categorization taxonomy (Niche → Category → Tag)
  • Collections (creator-curated groupings)
  • Comments on posts
  • Post state machine (draft → processing → published → hidden/removed)

Content does not own: access rules (see §10 Access & Entitlements), pricing (see §11 Monetization), or saved-posts/bookmarks (see §12 Social Graph).

9.2 Entities

9.2.1 niches

Column Type
id bigint, PK
slug string, unique (music, gaming, ...)
name string
icon string, nullable
display_order int
is_active bool
created_at / updated_at timestamptz

Seeded from config/taxonomy.php. Managed via Filament Niche resource.

9.2.2 categories

Column Type
id bigint, PK
niche_id bigint, FK
slug string
name string
display_order int
is_active bool

Unique on (niche_id, slug).

9.2.3 tags

Column Type
id bigint, PK
slug string, unique (lowercase, normalized)
name string
use_count int, default 0
created_at timestamptz

9.2.4 taggables (polymorphic pivot)

Column Type
tag_id bigint, FK
taggable_type string (post, collection)
taggable_id bigint

Unique on (tag_id, taggable_type, taggable_id).

9.2.5 posts

Column Type
id bigint, PK
public_id ulid
creator_id bigint, FK → users
type enum('text','video','audio','image','link','poll','livestream')
status enum('draft','processing','published','hidden_by_admin','removed_by_creator','quarantined','pending_review')
title string(180)
body text, nullable
link_url string, nullable
category_id bigint, FK, nullable
impression_count bigint, default 0
like_count int, default 0
comment_count int, default 0
share_count int, default 0
save_count int, default 0
purchase_count int, default 0
published_at timestamptz, nullable
scheduled_at timestamptz, nullable
creator_self_flagged_explicit bool, default false
moderation_notes jsonb, nullable
moderation_state enum('clean','flagged','under_review','cleared_after_review')
metadata jsonb
created_at / updated_at timestamptz
deleted_at timestamptz, nullable

Indexes: (creator_id, status, published_at), (status, published_at) for feeds, category_id, type.

9.2.6 post_media

Represents media files attached to a post (a post can have multiple — e.g. image gallery).

Column Type
id bigint, PK
post_id bigint, FK
ordinal int
kind enum('video','audio','image','thumbnail','transcript','livestream_recording')
storage_disk string
storage_path string
mime_type string
size_bytes bigint
duration_ms int, nullable
width int, nullable
height int, nullable
processing_status enum('pending','processing','ready','failed')
processing_error string, nullable
variants jsonb
provider_asset_id string, nullable
created_at / updated_at timestamptz

9.2.7 livestreams

Column Type
id bigint, PK
post_id bigint, FK, unique
provider enum('mux','self_hosted')
provider_stream_id string
stream_key_encrypted text
ingest_url string
playback_policy enum('public','signed')
status enum('idle','live','ended','errored')
started_at timestamptz, nullable
ended_at timestamptz, nullable
peak_concurrent_viewers int, default 0
recording_asset_id string, nullable
auto_record bool, default true

9.2.8 livestream_viewer_pings

Ephemeral — written to Redis as sorted sets with TTL, materialized to table only for post-stream analytics:

Column Type
id bigint, PK
livestream_id bigint, FK
user_id bigint, FK
minute_bucket timestamptz
count int

Unique on (livestream_id, user_id, minute_bucket).

9.2.9 livestream_chat_messages

Column Type
id bigint, PK
livestream_id bigint, FK
user_id bigint, FK
message string(500)
is_pinned bool
is_deleted bool
deleted_by_user_id bigint, nullable
created_at timestamptz

Also broadcast via Redis pubsub → SSE stream.

9.2.10 polls

Column Type
id bigint, PK
post_id bigint, FK, unique
question string(280)
is_multiple_choice bool, default false
closes_at timestamptz, nullable

9.2.11 poll_options

Column Type
id bigint, PK
poll_id bigint, FK
label string(120)
ordinal int
vote_count int, default 0

9.2.12 poll_votes

Column Type
id bigint, PK
poll_option_id bigint, FK
user_id bigint, FK
created_at timestamptz

Unique on (poll_id, user_id) when !is_multiple_choice; on (poll_option_id, user_id) always.

9.2.13 collections

Column Type
id bigint, PK
public_id ulid
creator_id bigint, FK
slug string
title string
description text, nullable
cover_path string, nullable
visibility enum('public','tier_gated','private')
min_tier_level int, nullable
is_active bool
created_at / updated_at timestamptz

Unique on (creator_id, slug).

9.2.14 collection_posts

Column Type
collection_id bigint, FK
post_id bigint, FK
ordinal int
added_at timestamptz

Unique on (collection_id, post_id).

9.2.15 comments

Column Type
id bigint, PK
public_id ulid
post_id bigint, FK
user_id bigint, FK
parent_comment_id bigint, FK, nullable
body text(1000)
like_count int, default 0
is_pinned bool, default false
is_hidden bool, default false
hidden_reason string, nullable
hidden_by_admin_id bigint, nullable
created_at / updated_at timestamptz
deleted_at timestamptz, nullable

9.3 Post State Machine

                ┌─────┐
        (save)  │draft│
       ◄────────┤     │
       │        └──┬──┘
       │           │ submit (may upload media)
       │           ▼
       │     ┌──────────┐
       │     │processing│── media failed ──► draft (with error)
       │     └────┬─────┘
       │          │ media ready AND moderation=reactive_only
       │          ▼
       │   ┌─────────────┐
       │   │  published  │── creator removes ──► removed_by_creator
       │   └──────┬──────┘
       │          │ admin takedown
       │          ▼
       │   ┌────────────────┐
       │   │hidden_by_admin │
       │   └────────────────┘
       │
       │          ┌──────────────┐
       │◄─────────┤pending_review│  (moderation=manual_prescreen)
                  └──────┬───────┘
                         │ approve
                         ▼
                     published

                  ┌────────────┐
                  │quarantined │  (AI auto-hide)
                  └──────┬─────┘
                         │ admin approve → published
                         │ admin confirm violation → hidden_by_admin

State transitions are methods on Post via a HasPostState trait, each fires a domain event.

9.4 Media Provider Interfaces

<?php

declare(strict_types=1);

namespace App\Content\Contracts;

interface IVideoProvider
{
    public function identifier(): string;

    /** Upload and enqueue transcoding. Returns provider asset ID + polling contract. */
    public function uploadFromPath(string $localPath): VideoProviderAsset;

    /** Check transcoding status. */
    public function status(string $providerAssetId): VideoProviderStatus;

    /** Signed playback URLs (HLS, mp4 variants). */
    public function playbackUrls(string $providerAssetId, ?int $ttlSeconds = 300): array;

    public function delete(string $providerAssetId): void;
}

interface IAudioProvider { /* similar shape */ }
interface IImageProvider { /* resize + variant generation */ }
interface ILivestreamProvider
{
    public function createStream(array $options): LivestreamAsset;
    public function endStream(string $streamId): void;
    public function signedPlaybackUrl(string $streamId, int $ttlSeconds = 120): string;
    public function fetchRecording(string $streamId): ?string;
}

v1 default adapters (self-hosted):

  • FFmpegLocalVideoProvider — transcodes to HLS via queued TranscodeVideoJob
  • FFmpegLocalAudioProvider — normalizes to AAC
  • InterventionImageProvider — resizes to thumb, small, medium, large preset variants
  • MuxLivestreamProvider — livestream via Mux Live (the one provider that's external in v1 per Q12/Q13)

Env-driven: VIDEO_PROVIDER, AUDIO_PROVIDER, IMAGE_PROVIDER, LIVESTREAM_PROVIDER.

Package readiness note:

  • spatie/laravel-medialibrary is already installed via Composer and available through Laravel package discovery, so Content-domain media models can begin integrating it without additional package bootstrap work.
  • pbmedia/laravel-ffmpeg is already installed via Composer and available through Laravel package discovery; runtime binary paths are scaffolded in .env.example through FFMPEG_BINARY_PATH and FFPROBE_BINARY_PATH.
  • bervant/laravel-sms is already installed via Composer and available through Laravel package discovery; project env documentation should use SMS_DRIVER, SMS_API_USERNAME, SMS_API_KEY, SMS_SHORTCODE, and the optional Twilio/Bonga/Envisage variables exposed by the package.
  • bervant/kashier-laravel-sdk is already installed via Composer and available through Laravel package discovery; project env documentation should use KASHIER_ENABLED, KASHIER_URL, KASHIER_MERCHANT_KEY, KASHIER_MERCHANT_SECRET, KASHIER_SERVICE_ID, KASHIER_PAYBILL_NO, and KASHIER_PAYBILL_ACCOUNT.

9.5 Post Upload Flow (Video Example)

Client → POST /v1/content/posts (body: type=video, title, metadata)
   ↓
CreatePostDraftAction
   ├── Create Post (status=draft, no media yet)
   ├── Generate signed upload URL for direct upload (S3 presigned)
   └── Return { post, uploadUrl, uploadFields }

Client → PUT uploadUrl (binary video bytes direct to S3)

Client → POST /v1/content/posts/{id}/media (registers uploaded file)
   ↓
AttachPostMediaAction
   ├── Validate file exists in S3
   ├── Create PostMedia (kind=video, status=pending)
   ├── Dispatch TranscodeVideoJob
   └── Update Post.status=processing, return Post

TranscodeVideoJob:
   ├── IVideoProvider::uploadFromPath(s3_path) → providerAssetId (v1 self-hosted: transcodes locally, writes HLS to S3)
   ├── Poll IVideoProvider::status()
   ├── When ready: update PostMedia.variants, status=ready
   ├── Mark Post.status=published (if moderation=reactive_only) or pending_review
   └── Dispatch PostPublished event

Client → POST /v1/content/posts/{id}/publish (finalizes: sets access rules, tags, category)
   ↓
PublishPostAction
   ├── Validate post is in publishable state
   ├── Attach tags (normalizes slugs, increments use_count)
   ├── Attach AccessRules (handled by Access domain — see §10)
   ├── If livestream: create Livestream + ingest
   └── Post.status=published, published_at=now

Note: publish is a separate step from create + attach media because content can be edited (access rules, tags, title) until published.

9.6 Polls

Polls are a post type with an embedded Poll and PollOptions. Vote submission goes through:

POST /v1/content/posts/{id}/poll/vote { optionIds: [...] }
   ↓
SubmitPollVoteAction
   ├── Verify post type is 'poll', not closed
   ├── Verify user has access to post (Access domain)
   ├── Verify isMultipleChoice compliance
   ├── Upsert vote (idempotent — change vote allowed until close)
   ├── Update vote_counts atomically
   └── Return PollResults

9.7 Endpoints

Method Path Auth Purpose
GET /v1/taxonomy/niches public List active niches
GET /v1/taxonomy/categories?nicheId=.. public Categories
GET /v1/taxonomy/tags?prefix=.. public Tag autocomplete
POST /v1/content/posts user (verified) Create draft
GET /v1/content/posts/{id} public* Read post (access-gated)
PATCH /v1/content/posts/{id} user Edit draft/published
POST /v1/content/posts/{id}/media user Attach uploaded media
POST /v1/content/posts/{id}/publish user Finalize & publish
POST /v1/content/posts/{id}/unpublish user Remove from feed
DELETE /v1/content/posts/{id} user Delete own post
POST /v1/content/posts/{id}/poll/vote user Vote on poll
GET /v1/content/posts/{id}/comments user Comment tree
POST /v1/content/posts/{id}/comments user Add comment
PATCH /v1/content/comments/{id} user Edit own
DELETE /v1/content/comments/{id} user Delete own
POST /v1/content/comments/{id}/pin user (creator of post) Pin comment
POST /v1/content/collections user Create collection
GET /v1/content/collections/{id} public* Read collection
PATCH /v1/content/collections/{id} user Update
DELETE /v1/content/collections/{id} user Delete
POST /v1/content/collections/{id}/posts user Add posts
DELETE /v1/content/collections/{id}/posts/{postId} user Remove post
POST /v1/content/livestreams/{id}/start user (creator) Begin stream
POST /v1/content/livestreams/{id}/end user (creator) End stream
GET /v1/content/livestreams/{id}/playback user Get signed playback URL
GET /v1/content/livestreams/{id}/chat/stream user (SSE) Chat SSE stream
POST /v1/content/livestreams/{id}/chat user Post chat message
POST /v1/content/livestreams/{id}/chat/{msgId}/pin user (creator/mod) Pin
POST /v1/content/livestreams/{id}/chat/{msgId}/delete user (creator/mod) Delete
POST /v1/content/livestreams/{id}/timeout-user user (creator/mod) Timeout user N minutes

public* = access-gated read: post returns full content if user has access; a sanitized teaser (title, cover, price) otherwise.


10. Domain: Access & Entitlements

10.1 Responsibility

Access is the authoritative decision point for "Can user X view/interact with post Y?" It owns:

  • AccessRule polymorphic model (per-post, per-collection access declarations)
  • The three access rule types: tier_gated, one_off_purchase, free_for_active_fans, plus the implicit public_free
  • PostPurchase records
  • FanEngagementScore (computed)
  • IAccessService — the single API other domains call to check access

Every read of a gated resource funnels through IAccessService::canView($user, $post). There is no other path.

10.2 Entities

10.2.1 access_rules

Column Type
id bigint, PK
gated_type string (polymorphic: post, collection)
gated_id bigint
rule_type enum('public_free','one_off_purchase','tier_gated','free_for_active_fans')
price_minor_units bigint, nullable
currency char(3), nullable
min_tier_level int, nullable
is_active bool
created_at / updated_at timestamptz

Multiple rules can exist per post (per Q1: combinable access models). Access is granted if any applicable rule grants it. The public_free rule is the lowest-privilege bypass (if present, everyone can view).

Globally toggling rule types via content.access_rules_enabled setting — if one_off_purchase is disabled platform-wide, those rules are ignored in access checks (and hidden in creator UI).

10.2.2 post_purchases

Column Type
id bigint, PK
public_id ulid
post_id bigint, FK
buyer_user_id bigint, FK
creator_user_id bigint, FK
gross_minor_units bigint
platform_fee_minor_units bigint
creator_net_minor_units bigint
platform_fee_rate_snapshot decimal(5,4)
promo_code_id bigint, FK, nullable
discount_minor_units bigint, default 0
currency char(3)
payment_intent_id bigint, FK, nullable
ledger_transaction_id bigint, FK
status enum('completed','refunded')
expires_at timestamptz, nullable
purchased_at timestamptz

Unique on (post_id, buyer_user_id) where status='completed'. A refunded purchase can be re-bought.

10.2.3 fan_engagement_scores

Column Type
id bigint, PK
user_id bigint, FK
creator_id bigint, FK
score decimal(8,2)
signals_breakdown jsonb
last_computed_at timestamptz

Unique on (user_id, creator_id).

10.2.4 tier_subscriptions (lives in Monetization §11 but referenced here)

IAccessService queries this to check "is user X currently subscribed to creator Y at level ≥ N."

10.3 Access Check Algorithm

function canView(User viewer, Post post): bool {
    if viewer.id == post.creator_id:
        return true // own post

    if post.status != 'published':
        return false

    rules = post.access_rules.where(is_active=true)
    enabled_rule_types = PlatformSettings::get('content.access_rules_enabled')
    rules = rules.filter(r => r.rule_type in enabled_rule_types)

    for rule in rules:
        if rule.rule_type == 'public_free':
            return true

        if rule.rule_type == 'one_off_purchase':
            if PostPurchase::exists(post.id, viewer.id, status=completed):
                return true

        if rule.rule_type == 'tier_gated':
            sub = TierSubscription::activeFor(viewer.id, post.creator_id)
            if sub && sub.tier.level >= rule.min_tier_level:
                return true

        if rule.rule_type == 'free_for_active_fans':
            if FanStatusService::isActiveFan(viewer, post.creator):
                return true

    return false
}

Access checks are cached per (user, post) pair for 60 seconds in Redis. Invalidation triggered by: new purchase, tier sub change, engagement score recomputation.

10.4 Active Fan Status

Weighted engagement score per (user, creator) pair. Computed by:

score = w_like × likes_last_30d
      + w_comment × comments_last_30d
      + w_share × shares_last_30d
      + w_save × saves_last_30d
      + w_purchase × purchase_count_last_30d
      + w_spend × (spend_cents_last_30d / 100)
      + w_tenure × min(tenure_days / 30, 12)  // caps at 1 year worth of tenure weight
      + w_recency × recency_factor(days_since_last_interaction)

Weights in discovery.active_fan_weights:

{
  "w_like": 1.0,
  "w_comment": 3.0,
  "w_share": 4.0,
  "w_save": 2.0,
  "w_purchase": 15.0,
  "w_spend": 0.5,
  "w_tenure": 2.0,
  "w_recency": 5.0
}

Threshold discovery.active_fan_threshold = 50.0 (default).

Tier subscribers auto-qualify — if TierSubscription::activeFor(user, creator) !== null, isActiveFan() returns true regardless of score.

Recomputation:

  • Incremental: every interaction bumps the score for that (user, creator) pair (small update)
  • Full: scheduled job RecomputeFanEngagementScoresJob runs every 4h for any pair with recent activity; nightly full rebuild for all active pairs

10.5 Service Contracts

<?php

declare(strict_types=1);

namespace App\Access\Contracts;

use App\Content\Models\Post;
use App\Identity\Models\User;

interface IAccessService
{
    public function canView(User $viewer, Post $post): bool;
    public function canComment(User $viewer, Post $post): bool;
    public function canInteract(User $viewer, Post $post): bool; // like, share, save
    public function priceFor(User $viewer, Post $post): ?int; // null if free or already has access
    public function accessReason(User $viewer, Post $post): AccessReason; // enum explaining why granted/denied
}

interface IFanStatusService
{
    public function isActiveFan(User $viewer, User $creator): bool;
    public function score(User $viewer, User $creator): float;
    public function recompute(User $viewer, User $creator): FanEngagementScore;
}

10.6 Endpoints

Method Path Auth Purpose
POST /v1/access/purchases user Purchase a post (body: postId, paymentMethod, promoCode?, idempotencyKey)
GET /v1/access/purchases user List own purchases
GET /v1/access/posts/{id}/access user Explicit access check + reason (for UI to show paywall state)
GET /v1/access/posts/{id}/price-quote user Returns effective price (after promo/active-fan)
POST /v1/content/posts/{id}/access-rules user (creator) Define rules for own post
PATCH /v1/content/posts/{id}/access-rules/{ruleId} user (creator) Modify a rule
DELETE /v1/content/posts/{id}/access-rules/{ruleId} user (creator) Remove a rule
GET /v1/creators/{id}/fans user (creator of the profile) List fan engagement scores (their own fans only)

11. Domain: Monetization

11.1 Responsibility

Owns the creator-side revenue primitives:

  • Tiers (ordered subscription levels, one active sub per user per creator)
  • TierSubscriptions + renewals + grace period
  • Premium (platform-side subscription with badge + perks)
  • Earnings aggregation (materialized views)
  • Fee calculation (delegates to config + platform_settings)

Does not own: ledger (§7), payment rails (§8), or promotions (§15).

11.2 Entities

11.2.1 tiers

Column Type
id bigint, PK
public_id ulid
creator_id bigint, FK
level int
name string
description text
price_minor_units bigint
currency char(3)
billing_cycle enum('monthly')
benefits jsonb
max_subscribers int, nullable
is_active bool
archived_at timestamptz, nullable
created_at / updated_at timestamptz

Unique on (creator_id, level) where archived_at is null.

11.2.2 tier_subscriptions

Column Type
id bigint, PK
public_id ulid
user_id bigint, FK
creator_id bigint, FK
tier_id bigint, FK
status enum('active','past_due','cancelled','grace_period','expired')
started_at timestamptz
current_period_start timestamptz
current_period_end timestamptz
cancels_at timestamptz, nullable
cancelled_at timestamptz, nullable
grace_period_ends_at timestamptz, nullable
preferred_payment_method enum('wallet','mpesa_direct')
default_withdrawal_method_id bigint, nullable
created_at / updated_at timestamptz

Unique partial: WHERE user_id = X AND creator_id = Y AND status IN ('active','past_due','grace_period') — at most one per (user, creator).

11.2.3 tier_subscription_payments

One row per billing cycle.

Column Type
id bigint, PK
tier_subscription_id bigint, FK
amount_minor_units bigint
platform_fee_minor_units bigint
creator_net_minor_units bigint
platform_fee_rate_snapshot decimal(5,4)
currency char(3)
payment_intent_id bigint, FK, nullable
ledger_transaction_id bigint, FK
status enum('succeeded','failed')
period_start timestamptz
period_end timestamptz
charged_at timestamptz

11.2.4 premium_subscriptions

Column Type
id bigint, PK
user_id bigint, FK
plan enum('premium')
status enum('active','past_due','cancelled','grace_period','expired')
started_at timestamptz
current_period_start timestamptz
current_period_end timestamptz
cancels_at timestamptz, nullable
preferred_payment_method enum
created_at / updated_at timestamptz

Unique on user_id (at most one active).

11.2.5 verification_requests

Column Type
id bigint, PK
user_id bigint, FK
identity_document_path string (S3 key, encrypted disk)
supporting_links jsonb
notes text, nullable
status enum('pending','approved','rejected','withdrawn')
reviewed_by_admin_id bigint, nullable
reviewed_at timestamptz, nullable
review_notes text, nullable
approval_request_id bigint, nullable
submitted_at timestamptz

11.3 Subscription Lifecycle

Create tier → Creator owns Tier
Subscribe → TierSubscription (status=active, first payment via payment_intent)
Monthly cron → BillActiveTierSubscriptionsJob (day of current_period_end)
   ├── Attempt charge (wallet preferred → mpesa_direct fallback with OTP)
   ├── IF success: extend current_period by 1 month, create TierSubscriptionPayment
   └── IF fail: status=past_due, grace_period_ends_at=now+3d, notify user
Grace period → User has 3 days to fix payment method
   └── Daily retry job until grace ends
Grace end without success → status=expired, lose access
Cancel → status=cancelled, cancels_at=current_period_end. Access continues until period end.

11.4 Endpoints

Method Path Auth Purpose
POST /v1/monetization/tiers user (creator) Create tier
GET /v1/creators/{id}/tiers public List creator's active tiers
PATCH /v1/monetization/tiers/{id} user (creator) Update tier
POST /v1/monetization/tiers/{id}/archive user (creator) Archive (existing subs continue)
POST /v1/monetization/tier-subscriptions user Subscribe to a tier
GET /v1/monetization/tier-subscriptions user Own subscriptions
POST /v1/monetization/tier-subscriptions/{id}/cancel user Cancel at period end
POST /v1/monetization/tier-subscriptions/{id}/resume user Undo cancellation
POST /v1/monetization/tier-subscriptions/{id}/change-tier user Upgrade/downgrade
POST /v1/monetization/premium user Start premium sub
GET /v1/monetization/premium user Current premium status
POST /v1/monetization/premium/cancel user Cancel
POST /v1/monetization/verification-requests user Submit for notable verification
GET /v1/monetization/verification-requests user Status of own submission
GET /v1/admin/verification-requests admin VerificationRequest.Read — queue
POST /v1/admin/verification-requests/{id}/approve admin VerificationRequest.Approval
POST /v1/admin/verification-requests/{id}/reject admin VerificationRequest.ReadWrite
GET /v1/creators/{id}/earnings-summary user (self) Insights aggregate

12. Domain: Social Graph

12.1 Responsibility

Lightweight social primitives:

  • Follow (User follows Creator)
  • Like (Post)
  • Share (Post — outbound share event tracking)
  • Save (bookmark a Post)
  • List (user-curated grouping of creators)

12.2 Entities

12.2.1 follows

Column Type
id bigint, PK
follower_user_id bigint, FK
followed_user_id bigint, FK
created_at timestamptz

Unique on (follower_user_id, followed_user_id).

12.2.2 post_likes

Column Type
id bigint, PK
post_id bigint, FK
user_id bigint, FK
created_at timestamptz

Unique on (post_id, user_id). Increment/decrement posts.like_count via observer.

12.2.3 comment_likes

Same shape as post_likes for comments.

12.2.4 post_shares

Column Type
id bigint, PK
post_id bigint, FK
user_id bigint, FK, nullable
destination enum('whatsapp','twitter','facebook','email','copy_link','other')
ip_hash string, nullable
created_at timestamptz

12.2.5 saved_posts

Column Type
id bigint, PK
user_id bigint, FK
post_id bigint, FK
note string(280), nullable
saved_at timestamptz

Unique on (user_id, post_id).

12.2.6 lists

Column Type
id bigint, PK
public_id ulid
user_id bigint, FK
slug string
name string
description text, nullable
visibility enum('public','private')
created_at / updated_at timestamptz

Unique on (user_id, slug).

12.2.7 list_members

Column Type
list_id bigint, FK
user_id bigint, FK
added_at timestamptz

Unique on (list_id, user_id).

12.3 Endpoints

Method Path Auth Purpose
POST /v1/social/follow/{userId} user Follow
DELETE /v1/social/follow/{userId} user Unfollow
GET /v1/users/{id}/followers public Paginated followers
GET /v1/users/{id}/following public Paginated following
POST /v1/social/posts/{id}/like user Like
DELETE /v1/social/posts/{id}/like user Unlike
POST /v1/social/posts/{id}/share user? Log share event
POST /v1/social/posts/{id}/save user Save
DELETE /v1/social/posts/{id}/save user Unsave
GET /v1/social/saves user Own saves
POST /v1/social/comments/{id}/like user
DELETE /v1/social/comments/{id}/like user
POST /v1/social/lists user Create list
GET /v1/social/lists user Own lists
GET /v1/social/lists/{id} public if public View list
PATCH /v1/social/lists/{id} user (owner) Edit
DELETE /v1/social/lists/{id} user (owner) Delete
POST /v1/social/lists/{id}/members/{userId} user (owner) Add creator
DELETE /v1/social/lists/{id}/members/{userId} user (owner) Remove

13. Domain: Fan Club

13.1 Responsibility

Community hub per creator, auto-populated from fan engagement + tier subscriptions. Provides:

  • Per-creator community space with channels
  • Redis + SSE-based real-time chat (reusing livestream chat primitive)
  • Creator moderation tools (pin, delete, timeout)
  • Creator-side dashboard view (engagement analytics, top fans, bulk message)
  • Automatic membership based on Access domain's isActiveFan + tier subscription status

13.2 Entities

13.2.1 fan_clubs

Column Type
id bigint, PK
public_id ulid
creator_id bigint, FK, unique
name string
description text, nullable
rules text, nullable
is_active bool
created_at / updated_at timestamptz

Auto-created via CreateFanClubOnCreatorStatus listener on UserBecameCreator event.

13.2.2 fan_club_channels

Column Type
id bigint, PK
fan_club_id bigint, FK
slug string
name string
min_tier_level int, nullable
is_default bool
display_order int

Default general channel created with fan club.

13.2.3 fan_club_memberships

Column Type
id bigint, PK
fan_club_id bigint, FK
user_id bigint, FK
role enum('member','moderator')
source enum('active_fan','tier_subscriber','manual_grant')
source_meta jsonb, nullable
joined_at timestamptz
last_active_at timestamptz

Unique on (fan_club_id, user_id).

SyncFanClubMembershipsJob runs every 30 minutes:

  • Adds members for users who newly cross isActiveFan threshold OR have new tier subscription
  • Removes members whose engagement fell below threshold AND no active tier sub (after 7-day grace)

13.2.4 fan_club_messages

Column Type
id bigint, PK
public_id ulid
channel_id bigint, FK
user_id bigint, FK
message string(1000)
is_pinned bool
is_deleted bool
deleted_by_user_id bigint, nullable
deleted_reason string, nullable
reply_to_message_id bigint, FK, nullable
created_at timestamptz

13.2.5 fan_club_timeouts

Column Type
id bigint, PK
fan_club_id bigint, FK
user_id bigint, FK
moderator_user_id bigint, FK
reason string, nullable
expires_at timestamptz
created_at timestamptz

User-with-active-timeout cannot post. Enforced in PostFanClubMessageAction.

13.3 Chat Delivery

Reuses livestream chat primitive:

  • Write: POST /v1/fanclubs/{id}/channels/{slug}/messages
    • Persists FanClubMessage row
    • Publishes to Redis channel fanclub:{id}:{channelSlug}
  • Read (real-time): GET /v1/fanclubs/{id}/channels/{slug}/stream → SSE
    • Subscribes to Redis channel, streams events
  • Read (historical): GET /v1/fanclubs/{id}/channels/{slug}/messages?cursor=... paginated

13.4 Endpoints

Method Path Auth Purpose
GET /v1/fanclubs user Fan clubs the user belongs to
GET /v1/fanclubs/{id} user (member) Fan club detail
GET /v1/fanclubs/{id}/channels user (member) Channels
GET /v1/fanclubs/{id}/channels/{slug}/messages user (member) Historical messages
GET /v1/fanclubs/{id}/channels/{slug}/stream user (member) SSE stream
POST /v1/fanclubs/{id}/channels/{slug}/messages user (member) Post message
POST /v1/fanclubs/{id}/messages/{msgId}/pin user (mod/creator) Pin
POST /v1/fanclubs/{id}/messages/{msgId}/delete user (mod/creator) Delete
POST /v1/fanclubs/{id}/timeout-user user (mod/creator) Timeout user
POST /v1/fanclubs/{id}/channels user (creator) Create channel
PATCH /v1/fanclubs/{id} user (creator) Update fan club meta
GET /v1/fanclubs/{id}/members user (creator) List members w/ engagement
POST /v1/fanclubs/{id}/bulk-message user (creator) Send DM to member segment
POST /v1/fanclubs/{id}/free-grant user (creator) Grant free access to N posts

14. Domain: Discovery & Ranking

14.1 Responsibility

Custom primitive ranking + search service. No external search engine in v1. Provides two entry points:

  • Search — keyword query, returns scored posts/creators/collections
  • Rank — personalized or global-trending feed

Both share a pipeline: candidate generation → scoring → filtering → diversification → pagination.

14.2 Entities

14.2.1 interactions

Append-only event log powering every ranker signal.

Column Type
id bigint, PK
user_id bigint, FK
interactable_type string (post, creator, collection)
interactable_id bigint
type enum('impression','click','dwell','like','unlike','comment','share','save','unsave','purchase','subscribe','skip','report')
weight decimal(4,2), default 1.0
context jsonb
occurred_at timestamptz

Partitioned by month via PostgreSQL declarative partitioning for write scaling. Indexes on (user_id, occurred_at), (interactable_type, interactable_id, occurred_at).

14.2.2 post_signal_cache

Denormalized per-post features refreshed every 10 minutes.

Column Type
post_id bigint, PK, FK
engagement_rate decimal(5,4)
trending_score decimal(8,2)
impressions_24h int
impressions_7d int
purchases_24h int
total_engagement_score decimal(8,2)
last_computed_at timestamptz

Rebuilt by RefreshPostSignalsJob every 10 min.

14.2.3 user_affinity_cache

Per-user denormalized affinities.

Column Type
user_id bigint, PK, FK
niche_affinity jsonb
category_affinity jsonb
tag_affinity jsonb
creator_affinity jsonb
avg_session_length_seconds int
last_computed_at timestamptz

Rebuilt nightly by RefreshUserAffinityJob. Incremental updates on each interaction via a debounced queued job (coalesces into one rebuild per user per 30 min).

14.2.4 user_post_impressions (Redis-backed, not a table)

Sorted set per user, capped at 500 most recent post IDs for already-seen suppression. TTL 7 days.

14.3 Ranker Configuration

discovery.ranker_weights:

{
  "w_recency": 3.0,
  "w_engagement_rate": 2.5,
  "w_creator_affinity": 4.0,
  "w_niche_affinity": 2.0,
  "w_category_affinity": 1.5,
  "w_tag_overlap": 1.0,
  "w_trending_boost": 1.5,
  "w_text_match": 3.0,
  "w_premium_boost": 0.05,
  "w_boosted_post": 2.0,
  "w_already_seen_penalty": -2.0,
  "w_cold_start_bootstrap": 1.2,
  "recency_half_life_hours": 48,
  "max_per_creator_in_top_20": 2,
  "candidate_pool_size": 500
}

Every weight live-tunable via Filament.

14.4 Service Contracts

<?php

declare(strict_types=1);

namespace App\Discovery\Contracts;

use App\Discovery\Data\DiscoveryQuery;
use App\Discovery\Data\DiscoveryResult;

interface IDiscoveryService
{
    public function search(DiscoveryQuery $query): DiscoveryResult;
    public function feed(DiscoveryQuery $query): DiscoveryResult;
    public function trending(DiscoveryQuery $query): DiscoveryResult;
}

interface ISignalContributor
{
    public function identifier(): string;
    public function contribute(DiscoveryCandidate $candidate, DiscoveryContext $context): float;
}

DiscoveryService is composed of an ordered array of ISignalContributor implementations, each returning a float. Final score = weighted sum.

Signal contributors (v1):

  • RecencyDecaySignal
  • EngagementRateSignal
  • CreatorAffinitySignal
  • NicheAffinitySignal
  • CategoryAffinitySignal
  • TagOverlapSignal
  • TrendingBoostSignal
  • TextMatchSignal (only when DiscoveryQuery::$keyword is present)
  • PremiumBoostSignal
  • BoostedPostSignal (promotions)
  • AlreadySeenPenaltySignal
  • ColdStartBootstrapSignal

14.5 Pipeline

IDiscoveryService::feed(query):
  ├── 1. Candidate Generation
  │     ├── Posts from followed creators (last 14d)           → up to 200
  │     ├── Trending posts in user's top niches (last 48h)     → up to 200
  │     ├── Boosted posts (active, budget remaining)           → up to 50
  │     ├── Recent posts globally (cold start fallback)        → up to 100
  │     └── Dedupe, cap at 500, exclude already-dismissed
  ├── 2. Access Filter
  │     └── Drop posts viewer can't see the existence of (tier-gated by a private creator, etc.)
  ├── 3. Scoring
  │     └── Foreach candidate: sum of weighted signals
  ├── 4. Diversification
  │     └── Round-robin to respect max_per_creator_in_top_20
  ├── 5. Slot for Promoted
  │     └── Insert BoostedPosts at prescribed positions (e.g. positions 3, 8, 15)
  ├── 6. Sort by score DESC
  ├── 7. Paginate via cursor (score, postId)
  └── Return + log impressions (bulk `InteractionType::Impression` inserts)

For search, candidate generation uses Postgres FTS:

SELECT id, ts_rank(search_vector, websearch_to_tsquery('simple', :keyword)) as text_score
FROM posts
WHERE search_vector @@ websearch_to_tsquery('simple', :keyword)
  AND status = 'published'
ORDER BY text_score DESC
LIMIT 500

search_vector is a GENERATED tsvector column indexed with GIN:

ALTER TABLE posts ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
  setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
  setweight(to_tsvector('simple', coalesce(body, '')), 'B')
) STORED;

CREATE INDEX posts_search_vector_idx ON posts USING GIN(search_vector);

Tags joined in via a separate subquery to boost text match when tags align.

14.6 Endpoints

Method Path Auth Purpose
GET /v1/discovery/feed user Personalized home feed
GET /v1/discovery/trending?niche=.. user or public Global/niche trending
GET /v1/discovery/search?q=..&type=.. user or public Search posts/creators/collections/tags
GET /v1/discovery/creators public Browse creators with filters (niche, category, verified)
POST /v1/discovery/interactions user Log interaction batch (client-side collection → server flush)

15. Domain: Promotions

15.1 Responsibility

Creator growth & revenue-accelerating primitives:

  • Boosted posts (paid ranker boost)
  • Promo codes (discount codes for purchases & subscriptions)
  • Referral program (personal link → commission on referred user's spend)

15.2 Entities

15.2.1 boosted_posts

Column Type
id bigint, PK
post_id bigint, FK
creator_id bigint, FK
budget_minor_units bigint
spent_minor_units bigint, default 0
cost_model enum('cpm','cpc')
rate_minor_units_per_1000_impressions bigint
starts_at timestamptz
ends_at timestamptz
status enum('pending_payment','active','paused','completed','exhausted','cancelled')
payment_intent_id bigint, FK, nullable
ledger_transaction_id bigint, FK
created_at / updated_at timestamptz

DebitBoostedPostImpressionsJob runs every minute aggregating impressions (from interactions) and debiting spent_minor_units. When spent >= budget → status=exhausted.

15.2.2 promo_codes

Column Type
id bigint, PK
public_id ulid
code citext, unique
creator_id bigint, FK, nullable
discount_type enum('percent','fixed')
discount_value decimal(10,2)
applies_to enum('any','tiers','posts','specific_post','specific_tier')
applies_to_id bigint, nullable
max_uses_total int, nullable
max_uses_per_user int, default 1
min_purchase_minor_units bigint, default 0
uses_count int, default 0
starts_at timestamptz
expires_at timestamptz, nullable
is_active bool
created_at / updated_at timestamptz

15.2.3 promo_code_redemptions

Column Type
id bigint, PK
promo_code_id bigint, FK
user_id bigint, FK
subject_type string (post_purchase, tier_subscription, premium_subscription)
subject_id bigint
discount_applied_minor_units bigint
redeemed_at timestamptz

Unique on (promo_code_id, user_id, subject_type, subject_id).

15.2.4 referral_links

Column Type
id bigint, PK
user_id bigint, FK, unique
slug string(16), unique
clicks int, default 0
signups int, default 0
commissioned_amount_minor_units bigint, default 0
created_at timestamptz

15.2.5 referral_attributions

Column Type
id bigint, PK
referral_link_id bigint, FK
referred_user_id bigint, FK, unique
commission_rate decimal(5,4)
attribution_expires_at timestamptz
welcome_credit_minor_units bigint
welcome_credit_ledger_transaction_id bigint, FK
attributed_at timestamptz

15.2.6 referral_commissions

Column Type
id bigint, PK
referral_attribution_id bigint, FK
source_type string
source_id bigint
referred_user_spend_minor_units bigint
commission_minor_units bigint
ledger_transaction_id bigint, FK
created_at timestamptz

15.3 Flows

Promo code redemption happens inline at purchase/subscribe time:

PurchasePostAction (if promoCode provided):
  ├── Resolve PromoCode (by code, not expired, active)
  ├── Check applies_to compatibility
  ├── Check max_uses_per_user (redeemed by this user already?)
  ├── Calculate discount
  ├── Final gross = original - discount (but not less than 0)
  ├── Fee calculation uses discounted gross
  ├── Post-settlement: insert PromoCodeRedemption, increment uses_count
  └── Return PostPurchase with discount fields populated

Referral attribution captured at signup:

User clicks loreax.com/r/{slug} → AttributeReferralAction
  ├── Set HttpOnly cookie `loreax_ref=<slug>` for 30 days
  └── Increment ReferralLink.clicks

User signs up (RegisterUserAction):
  ├── Read cookie; if present:
  │     ├── Resolve ReferralLink
  │     ├── Create ReferralAttribution (referred_user_id = new user)
  │     ├── Issue welcome_credit ledger transaction
  │     └── Increment ReferralLink.signups
  └── Proceed with registration

Commission on spend — every PostPurchased, TierSubscriptionPaid, PremiumSubscriptionPaid event listened by CreditReferralCommissionListener:

If referred user has active ReferralAttribution (attribution_expires_at > now):
  commission = spend * attribution.commission_rate
  LedgerService::post(referral_commission rule)
  Create ReferralCommission row

15.4 Endpoints

Method Path Auth Purpose
POST /v1/promotions/boosts user (creator) Boost a post
GET /v1/promotions/boosts user Own boosts
POST /v1/promotions/boosts/{id}/pause user Pause
POST /v1/promotions/boosts/{id}/resume user Resume
POST /v1/promotions/boosts/{id}/cancel user Cancel (refund unspent)
POST /v1/promotions/promo-codes user (creator) Create code
GET /v1/promotions/promo-codes user Own codes + analytics
PATCH /v1/promotions/promo-codes/{id} user Update
POST /v1/promotions/promo-codes/validate user Check if code is valid for a purchase
GET /v1/promotions/referral-link user Own link (create on first call)
GET /v1/promotions/referral-stats user Clicks, signups, commissions
POST /v1/promotions/referral-track/{slug} public Track click, set cookie
GET /v1/admin/promotions/boosts admin BoostedPost.Read
POST /v1/admin/promotions/boosts/{id}/approve admin BoostedPost.Approval (if approval required)
GET /v1/admin/promotions/promo-codes admin PromoCode.Read platform-wide

16. Domain: Notifications

16.1 Responsibility

Delivers notifications across in-app, email, web push, and SMS channels. Leverages Laravel's notification system with a centralized preferences layer. Terminal sink — no notification triggers another domain.

16.2 Channels

Channel v1 Laravel Channel Backend
In-app ✅ Mandatory database PostgreSQL
Email ✅ Mandatory mail AWS SES
Web Push ✅ Opt-in webpush (laravel-notification-channels/webpush) Custom
SMS ⚠️ Critical only sms bervant/laravel-sms with configured driver

16.3 Entities

16.3.1 notifications (Laravel default)

Laravel's database channel table with id (UUID), type, notifiable_type, notifiable_id, data (jsonb), read_at, created_at.

16.3.2 notification_preferences

Column Type
id bigint, PK
user_id bigint, FK
notification_type string
channel enum('in_app','email','web_push','sms')
is_enabled bool
updated_at timestamptz

Unique on (user_id, notification_type, channel).

16.3.3 web_push_subscriptions

Standard schema from laravel-notification-channels/webpush.

16.3.4 failed_notifications

Column Type
id bigint, PK
notifiable_type string
notifiable_id bigint
notification_type string
channel string
payload jsonb
error text
attempts int
failed_at timestamptz
resolved_at timestamptz, nullable
resolved_by_admin_id bigint, nullable

16.4 Notification Types (v1 catalog)

App\Notifications\Enums\NotificationType:

  • account.email_verified
  • account.password_changed
  • account.mfa_enabled
  • account.handle_changed
  • account.suspicious_login
  • social.new_follower
  • social.new_fan_club_member
  • social.comment_mention
  • social.comment_reply
  • content.new_post_from_followed (digested)
  • content.saved_post_now_free
  • content.post_you_bought_removed
  • commerce.top_up_succeeded
  • commerce.top_up_failed
  • commerce.purchase_receipt
  • commerce.tier_charged
  • commerce.tier_renewal_upcoming_7d
  • commerce.tier_renewal_upcoming_1d
  • commerce.tier_payment_failed
  • commerce.withdrawal_requested
  • commerce.withdrawal_succeeded
  • commerce.withdrawal_failed
  • commerce.refund_issued
  • creator.new_purchase
  • creator.new_tier_subscriber
  • creator.new_fan_club_member
  • creator.tier_subscriber_cancelled
  • creator.boost_budget_exhausted
  • creator.promo_code_redeemed
  • creator.moderation_action
  • creator.verification_status
  • system.maintenance_scheduled
  • system.policy_update
  • system.policy_violation_warning

Each has default channel routing in config/notifications.php:

return [
    'types' => [
        'commerce.top_up_succeeded' => [
            'default_channels' => ['in_app', 'email'],
            'user_overridable' => ['email'],
            'digest' => false,
        ],
        'content.new_post_from_followed' => [
            'default_channels' => ['in_app', 'email'],
            'user_overridable' => ['in_app', 'email', 'web_push'],
            'digest' => 'daily_8am',
        ],
        'account.suspicious_login' => [
            'default_channels' => ['in_app', 'email', 'sms'],
            'user_overridable' => [], // Non-optional
            'digest' => false,
        ],
    ],
];

16.5 Digest Delivery

DigestNotificationsJob runs daily at user's local 8am (batched by timezone):

  • Collects all pending digest notifications for the user from the last 24h
  • Groups by notification_type
  • Renders one digest email with the groups
  • Clears the pending digest queue

"Pending digest" is maintained in a Redis list per user: notifications:digest:{userId}.

16.6 Endpoints

Method Path Auth Purpose
GET /v1/notifications user Paginated in-app
PATCH /v1/notifications/{id}/read user Mark read
POST /v1/notifications/read-all user Bulk mark read
GET /v1/notifications/preferences user Current preferences
PATCH /v1/notifications/preferences user Bulk update
POST /v1/notifications/web-push/subscribe user Register browser for push
DELETE /v1/notifications/web-push/unsubscribe user Unregister
GET /v1/admin/notifications/failed admin Failed-delivery queue
POST /v1/admin/notifications/failed/{id}/retry admin Manual retry

17. Domain: Moderation

17.1 Responsibility

Enforces content policy in three configurable modes (reactive_only default; ai_prescreen and manual_prescreen stubbed and activatable via platform setting). Owns:

  • User reports
  • Review queue
  • Takedown workflow
  • AI moderation provider stubs (OpenAI Moderation, Claude review notes)
  • Creator warnings & suspensions

17.2 Entities

17.2.1 reports

Column Type
id bigint, PK
public_id ulid
reporter_user_id bigint, FK, nullable
reportable_type string (post, comment, user, collection, fan_club_message)
reportable_id bigint
reason enum('spam','nudity','hate','violence','harassment','impersonation','copyright','misinformation','illegal','other')
details text, nullable
priority enum('p0','p1','p2','p3')
status enum('pending','triaged','resolved','dismissed')
duplicate_count int, default 1
resolved_by_admin_id bigint, nullable
resolved_at timestamptz, nullable
resolution enum('no_action','content_removed','creator_warned','creator_suspended','creator_banned','false_report','other'), nullable
resolution_notes text, nullable
created_at / updated_at timestamptz

Uniqueness constraint relaxed: multiple reports on same subject allowed (counted in duplicate_count on a canonical primary row via service).

17.2.2 moderation_actions

Audit trail of moderator decisions beyond report resolution.

Column Type
id bigint, PK
target_type string
target_id bigint
action enum('takedown','restore','warn','suspend','ban','clear_report','dismiss_report')
reason text
admin_id bigint, FK
expires_at timestamptz, nullable
source_report_id bigint, nullable
created_at timestamptz

17.2.3 creator_warnings

Column Type
id bigint, PK
user_id bigint, FK
severity enum('notice','warning','strike','final_strike')
reason text
issued_by_admin_id bigint
related_post_id bigint, nullable
acknowledged_at timestamptz, nullable
created_at timestamptz

Three strikes → automatic 7-day suspension (configurable).

17.2.4 user_suspensions

Column Type
id bigint, PK
user_id bigint, FK
reason text
starts_at timestamptz
ends_at timestamptz, nullable
issued_by_admin_id bigint
lifted_by_admin_id bigint, nullable
lifted_at timestamptz, nullable
created_at timestamptz

Active suspensions block login and write endpoints; read-only browsing continues.

17.2.5 ai_moderation_results

Stubbed; only populated when moderation.mode != reactive_only.

Column Type
id bigint, PK
post_id bigint, FK, unique
provider enum('openai_moderation','claude','custom')
verdict enum('auto_pass','auto_hide','needs_review')
confidence_scores jsonb
reviewer_notes_md text, nullable
evaluated_at timestamptz

17.3 Provider Interface

<?php

declare(strict_types=1);

namespace App\Moderation\Contracts;

interface IModerationAiProvider
{
    public function identifier(): string;
    public function evaluatePost(Post $post): AiModerationResult;
}

v1 stubs: OpenAiModerationProvider, ClaudeReviewNotesProvider. Wired but never called when moderation.mode = reactive_only.

17.4 Endpoints

Method Path Auth Purpose
POST /v1/reports user Submit a report
GET /v1/admin/reports admin Report.Read queue
POST /v1/admin/reports/{id}/resolve admin Report.ReadWrite
POST /v1/admin/reports/{id}/dismiss admin Report.ReadWrite
POST /v1/admin/posts/{id}/takedown admin Post.Approval
POST /v1/admin/posts/{id}/restore admin Post.ReadWrite
POST /v1/admin/users/{id}/warn admin User.ReadWrite
POST /v1/admin/users/{id}/suspend admin User.ReadWrite
POST /v1/admin/users/{id}/lift-suspension admin User.ReadWrite

18. Domain: Admin & Back Office

18.1 Responsibility

Filament-powered back office, separate admin guard, RBAC via static scope dictionary + DB-backed roles.

18.2 Key Modules (Filament Panel /admin)

  • Dashboard — system-wide KPIs (MAU, DAU, GMV, platform revenue, top creators, pending review count)
  • Users — search, impersonate (with audit), suspend, unsuspend
  • Creators — creator directory, notable verification queue, earnings summary, warning history
  • Admin Users — provision new admins (Super Admin only), assign roles
  • Roles — read roles and scopes (immutable from UI; modified via code + seeders for safety)
  • Posts — moderation, bulk takedown
  • Comments — moderation
  • Reports — review queue with filters, bulk resolve
  • Ledger — transactions browser, manual adjustment (with Ledger.Approval two-person gate)
  • Withdrawals — ops queue, retry, force-fail, CSV export
  • Top-ups — view, manually reconcile stuck ones
  • Promotions — boosts, promo codes, referral stats
  • Tiers — read-only view of creator tiers
  • Niches & Categories — edit taxonomy
  • Platform Settings — all runtime-tunable keys
  • Feature Flags — toggle & rollout %
  • Audit Logactivity_log browser with rich filters
  • Request Log — Mongo-backed log browser (read-only)
  • Failed Notifications — retry queue
  • Jobs — Horizon embedded
  • Wallet Drift Incidents — reconciliation alerts
  • Approval Requests — pending two-person approvals across the system

18.3 Role Definitions (seeded)

Full mapping in Appendix A. Summary:

Role Scope Count Key Access
Super Admin * (wildcard) Everything
Platform Manager ~25 Taxonomy, settings, flags, premium, boosts; no ledger write, no admin user mgmt
Content Reviewer ~15 Moderation queue, takedowns, warnings
Finance Ops ~12 Ledger read, withdrawal/refund with approval gates
Support ~10 User lookup, ticket management, password reset triggers
Read-Only Auditor all .Read View-only system-wide

18.4 Two-Person Approval Pattern

Scopes ending in .Approval on destructive financial actions require a distinct second admin to approve via the ApprovalRequest workflow.

Admin A → InitiateLedgerAdjustmentAction → creates ApprovalRequest (status=pending)
Admin B → ApproveAction → executes the adjustment inside DB::transaction
         → ApprovalRequest.status=approved, sets approved_by

Admin A cannot approve their own request. Enforced in ApproveAction.

18.5 Audit Logging

spatie/laravel-activitylog wired on every Eloquent model through a base LogsActivityStrict trait:

  • causer_type/causer_id polymorphic to User or AdminUser
  • subject_type/subject_id polymorphic to affected model
  • event — e.g. post.published, withdrawal.succeeded, user.suspended
  • properties — before/after diff
  • request_id — correlation to RequestLog
  • reason — required on destructive events

Activity log table is append-only enforced by revoking DELETE privilege from the application DB role.


19. Cross-Cutting: Observability

19.1 Goals

Every request, every job, every state change must be reconstructible from logs. Three observability streams correlate via requestId and conversationId:

  1. RequestLog (MongoDB) — every inbound HTTP request
  2. ActivityLog (Postgres, via spatie/laravel-activitylog) — every domain state change
  3. JobLog (Mongo) — every queued job execution
  4. FileLog (local/runtime) — size-rotated files where sequence 0 is always newest (laravel.0.log, laravel-YYYY-MM-DD.0.log)
  5. Database query log (PHP runtime sampling → CloudWatch) — slow queries only

Correlation flow:

Inbound request → assign requestId → attach to Log context
                → any job dispatched inherits requestId via JobMiddleware
                → any activity log entry records requestId in properties
                → downstream HTTP calls propagate X-Request-ID header

19.2 RequestLogger

Follows the user-provided MongoRequestLogger blueprint. The contract:

<?php

declare(strict_types=1);

namespace App\Core\Contracts;

use App\Identity\Enums\Realm;

interface IRequestLogger
{
    /**
     * Persist a single request log entry.
     *
     * @throws \App\Core\Exceptions\RequestLogWriteException on unrecoverable failure
     */
    public function log(RequestLogEntry $entry): void;
}

RequestLogEntry value object carries:

  • requestId — ULID, mandatory
  • conversationId — propagated from client or defaults to requestId
  • traceId — W3C Trace Context trace-id (32 hex chars), mandatory
  • spanId — W3C Trace Context span-id (16 hex chars), mandatory
  • realmuser | admin | system (system for job-triggered requests)
  • processorName — matched controller action or job class FQCN
  • platform — from X-Platform header (web, ios, android, admin_web)
  • scenario — from X-Scenario header (e.g. onboarding, purchase_flow)
  • appVersion — from X-App-Version header
  • backendVersion — from config (app.version, git sha)
  • method — HTTP method
  • path — route pattern (not raw URI, to avoid cardinality explosion)
  • statusCode
  • durationMs
  • isSuccessful — status < 400 AND no unhandled exception
  • userId — nullable
  • adminId — nullable
  • requestPayload — jsonb, redacted (passwords, tokens, MPESA details stripped)
  • responsePayload — jsonb, redacted, only if 4xx/5xx or explicitly opted-in via route config
  • errorCode — from exception, if any
  • errorMessage — safe-to-log, never PII
  • userAgent, ipAddress, referer
  • occurredAt — UTC timestamp

Implementation (MongoDB, strict-by-design):

<?php

declare(strict_types=1);

namespace App\Core\Infrastructure\Logging;

use App\Core\Contracts\IRequestLogger;
use App\Core\Contracts\RequestLogEntry;
use App\Core\Exceptions\RequestLogWriteException;
use MongoDB\Client as MongoClient;
use MongoDB\Collection;
use MongoDB\Driver\Exception\Exception as MongoException;
use Psr\Log\LoggerInterface;

final class MongoRequestLogger implements IRequestLogger
{
    private readonly Collection $collection;

    public function __construct(
        MongoClient $client,
        private readonly LoggerInterface $fallbackLogger,
        string $database,
        string $collectionName,
    ) {
        $this->collection = $client->selectCollection($database, $collectionName);
    }

    public function log(RequestLogEntry $entry): void
    {
        $document = [
            'requestId' => $entry->requestId,
            'conversationId' => $entry->conversationId,
            'traceId' => $entry->traceId,
            'spanId' => $entry->spanId,
            'realm' => $entry->realm->value,
            'processorName' => $entry->processorName,
            'platform' => $entry->platform,
            'scenario' => $entry->scenario,
            'appVersion' => $entry->appVersion,
            'backendVersion' => $entry->backendVersion,
            'method' => $entry->method,
            'path' => $entry->path,
            'statusCode' => $entry->statusCode,
            'durationMs' => $entry->durationMs,
            'isSuccessful' => $entry->isSuccessful,
            'userId' => $entry->userId,
            'adminId' => $entry->adminId,
            'requestPayload' => $entry->requestPayload,
            'responsePayload' => $entry->responsePayload,
            'errorCode' => $entry->errorCode,
            'errorMessage' => $entry->errorMessage,
            'userAgent' => $entry->userAgent,
            'ipAddress' => $entry->ipAddress,
            'referer' => $entry->referer,
            'occurredAt' => new \MongoDB\BSON\UTCDateTime($entry->occurredAt->getTimestamp() * 1000),
        ];

        try {
            $this->collection->insertOne($document);
        } catch (MongoException $e) {
            $this->fallbackLogger->error('RequestLog write failed', [
                'requestId' => $entry->requestId,
                'error' => $e->getMessage(),
            ]);

            throw new RequestLogWriteException(
                'Unable to persist request log for ' . $entry->requestId,
                previous: $e,
            );
        }
    }
}

Registered in a custom LoggingServiceProvider, wired from config/app.php custom namespace (app.custom.request_logger.*).

Terminable middleware ensures the log write happens after response send — it never blocks response latency:

final class LogRequestMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $start = hrtime(true);
        $request->attributes->set('request_start_hrtime', $start);

        return $next($request);
    }

    public function terminate(Request $request, Response $response): void
    {
        try {
            $entry = RequestLogEntryFactory::fromRequestResponse($request, $response);
            app(IRequestLogger::class)->log($entry);
        } catch (Throwable $e) {
            Log::error('Terminal request log failed', ['exception' => $e]);
            // Do not re-throw — terminable middleware swallows.
        }
    }
}

19.3 Redaction Rules

RequestLogEntryFactory applies field-level redaction:

  • Request paths containing password, passwordConfirmation, currentPassword, mfaCode, mfaBackupCode, otp, token, providerToken, providerIdToken → replaced with [REDACTED]
  • Authorization headers stripped entirely
  • MPESA phoneNumber masked to last 3 digits: xxxxx678
  • Credit card-like patterns regex-stripped
  • Request body for /v1/payments/* → only non-sensitive fields kept (amount, currency, idempotencyKey)
  • Response body persisted only for 4xx/5xx or when route declares logResponsePayload=true

Redaction list centralized in config/request_logger.php — editable without code change.

19.4 JobLog

Every queued job wraps in LogsJobExecution middleware (via Horizon/Laravel job middleware):

  • Job class, payload hash, attempt, queue, requestId (if dispatched in request context), duration, success/failure, exception class + message
  • Written to Mongo job_logs collection via IJobLogger sibling of IRequestLogger
  • Failed jobs also live in standard Laravel failed_jobs table for Horizon UI

19.5 ActivityLog

spatie/laravel-activitylog augmented:

  • properties.requestId always populated if activity happens within a request scope (read from context via AssignRequestId middleware)
  • Log names segmented by domain: identity, ledger, content, etc. — filterable in Filament
  • Sensitive models (e.g. MfaChallenge) excluded from activity log via $logAttributes = false

19.6 Metrics

CloudWatch namespace Loreax/:

  • HTTP: request_count, request_duration (p50/p95/p99), error_rate by path pattern & status class
  • Ledger: transactions_per_minute, unbalanced_transaction_attempts (should always be zero), wallet_drift_count
  • Payments: top_up_success_rate, withdrawal_success_rate, mpesa_callback_lag
  • Queues: queue_depth per Horizon queue, job_duration per job class, failed_job_count
  • DB: connection_count, slow_query_count
  • Discovery: feed_latency, candidate_pool_size

Emitted via spatie/laravel-server-monitor equivalent + a lightweight custom MetricsRecorder shim wrapping CloudWatch PutMetricData calls.

19.7 Alerts

CloudWatch alarms (v1 baseline):

Alarm Threshold Action
high_5xx_rate >2% over 5 min SNS → PagerDuty
ledger_unbalanced_attempts >0 over 1 min SNS → PagerDuty (P0)
wallet_drift_incidents any SNS → PagerDuty (P0)
mpesa_callback_timeout_rate >5% over 15 min SNS → ops email
horizon_queue_depth_transactional >500 SNS → ops email
failed_notification_count >50/hr SNS → ops email
rds_cpu >80% for 10 min SNS → ops email
rds_free_storage <20GB SNS → ops email

20. Cross-Cutting: Jobs, Scheduling & Events

20.1 Queue Configuration

Horizon queues (configured in config/horizon.php):

Queue Purpose Worker Count Timeout Retries
transactional Payments, ledger-touching jobs 4 120s 3 with exponential backoff
notifications Email, SMS, push delivery 6 60s 5
media Video/audio/image processing 2 (long-running) 1800s (30min) 2
logs RequestLog flushes, metrics emission 2 30s 2
discovery Affinity recomputation, signal refresh 2 300s 2
default Miscellaneous 3 60s 3

20.2 Scheduled Jobs Catalog

Registered in App\Console\Kernel::schedule():

Job Frequency Queue Purpose
ReleaseEarningsJob every 30 min transactional Release pending earnings with withdrawable_after <= now
BillActiveTierSubscriptionsJob hourly transactional Charge tier subs whose period ends today
BillActivePremiumSubscriptionsJob hourly transactional Charge premium subs
RetryFailedTierPaymentsJob daily transactional Retry past-due tier subs within grace
ExpireOverdueTierSubscriptionsJob daily transactional Mark expired after grace
ExpireBoostedPostsJob every 5 min default Complete boosts past ends_at
DebitBoostedPostImpressionsJob every minute default Aggregate impressions into spent_minor_units
ClaimExpiredPromoCodesJob daily default Deactivate expired codes
ExpireReferralAttributionsJob daily default Stop commissions past 30d window
ClawbackUnusedWelcomeCreditJob daily transactional Post reversing adjustment if credit unused
RecomputeFanEngagementScoresJob every 4 hours discovery Incremental recompute
RefreshPostSignalsJob every 10 min discovery Refresh post_signal_cache
RefreshUserAffinityJob nightly 3am discovery Full affinity cache rebuild
SyncFanClubMembershipsJob every 30 min default Enroll/remove members by current status
DigestNotificationsJob hourly (timezone-aware dispatch) notifications Send 8am-local digest per user
ReconcileWalletsJob nightly 2am transactional Integrity check wallet balances vs ledger
PruneInteractionsJob weekly default Drop interactions partitions older than 13 months
PruneExpiredMfaChallengesJob hourly default Remove expired unverified challenges
PruneExpiredPaymentIntentsJob every 15 min transactional Mark stale intents as expired
PruneExpiredApprovalRequestsJob every 30 min default Auto-reject stale approval requests
PurgeSoftDeletedUsersJob daily default Permanently delete users past 30d grace
ExportUserDataJob on-demand default KDPA data export
PruneRequestLogsJob daily logs Drop mongo request logs older than 180 days (configurable)
FlushActivityLogToColdStorageJob weekly logs Archive activity_log > 1 year to S3 as parquet
WarmDiscoveryCacheForActiveUsersJob every 6 hours discovery Pre-compute feeds for top-N active users

20.3 Domain Event Catalog

Identity: UserRegistered, UserEmailVerified, UserLoggedIn, UserPasswordChanged, UserSuspended, UserSuspensionLifted, UserBecameCreator, UserBecamePremium, UserMfaEnabled, UserMfaDisabled, UserHandleChanged, UserDeleted, SocialAccountLinked

Ledger: LedgerTransactionPosted, WalletBalanceChanged, WalletDriftDetected, ManualAdjustmentApproved

Payments: TopUpInitiated, TopUpSucceeded, TopUpFailed, WithdrawalRequested, WithdrawalSucceeded, WithdrawalFailed, MpesaCallbackReceived

Content: PostDrafted, PostPublished, PostTakenDown, PostRestored, PostEdited, MediaProcessed, MediaProcessingFailed, LivestreamStarted, LivestreamEnded, PollVoted

Access: PostPurchased, PostRefunded, FanEngagementRecomputed

Monetization: TierCreated, TierArchived, TierSubscribed, TierSubscriptionRenewed, TierSubscriptionCancelled, TierSubscriptionExpired, PremiumSubscribed, PremiumCancelled, CreatorVerified

Social: UserFollowed, UserUnfollowed, PostLiked, PostCommented, CommentLiked, PostShared, PostSaved

FanClub: FanClubMembershipGranted, FanClubMembershipRevoked, FanClubMessagePosted

Discovery: InteractionLogged, FeedServed, SearchServed

Promotions: BoostedPostCreated, BoostedPostExhausted, PromoCodeRedeemed, ReferralAttributed, ReferralCommissionEarned

Moderation: ReportSubmitted, ReportResolved, CreatorWarned, ContentAutoHidden

Notifications: NotificationSent, NotificationFailed, NotificationPreferenceChanged

20.4 Event → Listener Mapping (key ones)

  • UserRegisteredSendEmailVerification, CreateUserWalletListener, ApplyReferralAttributionListener, IssueWelcomeCreditListener
  • PostPurchasedCreditReferralCommissionListener, UpdateFanEngagementListener, NotifyCreatorListener, RecordBuyerInteractionListener
  • TierSubscribedSyncFanClubMembershipListener, NotifyCreatorListener, CreditReferralCommissionListener
  • PostPublishedDispatchNewPostDigestListener, InvalidateFeedCacheListener, ScheduleModerationIfConfiguredListener
  • LedgerTransactionPostedUpdateDenormalizedWalletListener
  • WalletDriftDetectedAlertOpsListener (PagerDuty)
  • UserBecameCreatorCreateFanClubListener, IssueReferralLinkListener

21. Cross-Cutting: Security & Compliance

21.1 Authentication Security

  • Password hashing: bcrypt with cost factor 12 (Laravel default)
  • Password policy: min 12 chars, at least one letter + one number; validated via Laravel's Password rule
  • Failed login rate limiting: 5 attempts per 10 min per IP+email; 15min lockout thereafter
  • Suspicious login detection: new country or new device fingerprint → challenge MFA + notify
  • Session expiry: 14 days inactive, 30 days absolute; Sanctum personal tokens roll every 90 days
  • Admin sessions: 2h inactive, 12h absolute; mandatory MFA

21.2 Authorization

  • All mutation endpoints pass through explicit policy checks via Gate::authorize() or scope-checking middleware
  • No implicit visible-to-all reads — every resource has explicit visibility semantics
  • Creator-owned resources verified via Auth::user()->owns($resource) helper backed by creator_id match
  • Admin scope checks are deny by default — if a scope is not declared required, endpoint is admin-route-level blocked

21.3 Data at Rest

  • RDS Postgres: AWS-managed encryption at rest (AES-256)
  • S3: SSE-S3 encryption at rest; separate buckets for public-read avatars vs. private purchased content (signed URLs, 60s TTL)
  • MongoDB Atlas: encryption at rest
  • Encrypted Eloquent columns: mfa_secret, mfa_backup_codes, withdrawal_methods.encrypted_details, livestreams.stream_key_encrypted, verification_requests.identity_document_path (path is encrypted; the document itself is on a private S3 bucket)
  • Application-level encryption via Laravel encrypted:json and encrypted:array casts using APP_KEY

21.4 Data in Transit

  • TLS 1.2+ only, enforced at ALB
  • Internal service communication (app → RDS, app → Redis, app → MongoDB Atlas) all TLS
  • HSTS header with max-age=31536000; includeSubDomains; preload
  • CSP: strict, self + explicit allowlist for Mux, S3, fonts.gstatic, SES unsubscribe links
  • CORS: explicit allowlist from config/cors.php, credentials only for web origin

21.5 Secrets Management

  • .env never committed (enforced via git secret scanning + pre-commit hook)
  • Production secrets injected via AWS Systems Manager Parameter Store at deploy time
  • Ansible playbook pulls SSM params into /etc/loreax/env with root:www-data 640 permissions
  • Per-environment KMS keys for SSM encryption
  • Kashier/Mux/SES credentials rotated quarterly (ops runbook)

21.6 KDPA / GDPR Compliance

Kenya Data Protection Act (KDPA) compliance features:

  • Consent tracking: versioned user_consents records with document version snapshots
  • Data export: POST /v1/identity/me/data-export → async ExportUserDataJob → signed S3 URL (48h TTL), includes profile, posts, purchases, subscriptions, ledger entries, fan club messages, interactions (subject to retention limits)
  • Right to erasure: DELETE /v1/identity/me → soft delete + 30d grace; PurgeSoftDeletedUsersJob then hard-deletes PII from users, rotates PII out of ledger_transactions.metadata into pseudonymous token
  • Data minimization: only collect fields with a stated purpose; onboarding form shorter than profile edit form
  • Breach runbook: docs/runbooks/data-breach.md with 72-hour notification procedure to Office of the Data Protection Commissioner
  • DPO contact surfaced in privacy policy + Filament setting
  • Explicit opt-in for marketing email + SMS (pre-selected checkbox forbidden, logged in user_consents)
  • Cross-border transfer note: MongoDB Atlas may host in another region; disclosed in privacy policy; covered via Atlas's standard contractual clauses

21.7 Payment Compliance

  • No PCI scope: all payment credentials (MPESA PIN) stay with Safaricom; we never touch card PANs
  • B2C tills and shortcodes configured in SSM; never in app code
  • Kashier credentials rotated through a zero-downtime config reload procedure documented in docs/runbooks/mpesa-credential-rotation.md

21.8 Input Validation & Sanitization

  • All inputs pass through FormRequest with whitelist validation; unknown fields rejected via custom rule
  • HTML stored in post body is sanitized via mews/purifier with a strict allowlist (p, br, strong, em, a, ul, ol, li, blockquote, h3, h4, code, pre)
  • User-generated URLs validated for scheme (http/https only), no javascript:, data:, file:
  • File uploads: MIME sniffed server-side (not trusting client content-type), size caps, antivirus scan via ClamAV daemon on upload worker (stubbed v1, activatable)

21.9 SSRF / Link Preview Hardening

Link-type posts fetch OG metadata via a dedicated worker that:

  • Resolves hostname and rejects private/link-local/metadata-service IPs (AWS 169.254.169.254, 10/8, 172.16/12, 192.168/16, ::1)
  • Fetches with 5s timeout, 2MB body cap
  • Follows at most 3 redirects
  • Isolated network namespace (security group restricts egress to 80/443 public)

21.10 Abuse Prevention

  • Registration rate limit: 3 per IP per hour
  • Referral signup same-IP/device-fingerprint blocked
  • Duplicate reports from same user coalesced (prevents report bombing)
  • Comment/chat profanity filter (stubbed; activatable); rate limit 20 comments/minute per user
  • Boosted post approval gate (configurable; off in v1)
  • Content hash deduplication: reject uploads with same SHA-256 as existing post from another creator (possible plagiarism signal for moderation)

22. Cross-Cutting: Testing Strategy

22.1 Tooling

  • PHPUnit 11+ (Laravel 12, fully PHP 8.4 compatible)
  • #[Test] attribute (PHPUnit\Framework\Attributes\Test); the legacy test* method-prefix style is forbidden
  • RefreshDatabase trait for feature tests (transaction-per-test, rolled back after each)
  • Laravel's built-in TestCase + Eloquent factories
  • Mockery for interface stubs (e.g. IPaymentProvider, IMfaProvider)
  • PCOV (or Xdebug coverage mode) for coverage collection — CI uses PCOV for speed
  • Infection (recommended, not CI-enforced) for mutation testing of Ledger + Payments + Access domains before finance-adjacent PRs merge

22.2 Coverage Target

≥ 80% line coverage enforced in CI via a post-run script that parses the Clover XML report; builds fail below threshold. Branch coverage encouraged, not blocking.

22.3 Test Method Naming Conventions

Every test method is public, returns void, carries the #[Test] attribute, and names itself by behavior, not mechanics. Two patterns allowed:

Pattern 1 — SubjectInPascalCase (single, unambiguous happy-path scenario)

LoginSuccess
RegistrationSuccess
WithdrawalAccepted
PostPurchased
TierSubscribed

Pattern 2 — Subject_Condition (scenarios with a distinct outcome per precondition — underscore-separated words, PascalCase on each side)

LoginWithValidCredential_Success
LoginWithInvalidPassword_ReturnsUnauthenticated
LoginWithUnverifiedEmail_ReturnsApplicationError
WithdrawalBelowMinimum_Returns430WithCode
WithdrawalAboveDailyLimit_Returns430WithCode
PurchasePost_WhenInsufficientFunds_Returns430
EveryPostingRule_SumsToZero
RandomTransactionSequence_NoWalletDrift
Challenge_WhenVerifiedWithCorrectCode_ReturnsTrue

Rules enforced in code review:

  • No snake_case method names (Laravel's test_function_like_this() style is forbidden)
  • No English-sentence methods (it_should_reject_withdrawals_below_the_minimum() is forbidden)
  • #[Test] attribute on every test method — no test* prefix
  • Class names end in Test and live in tests/{Unit,Feature,Contract,Invariant}/<Domain>/<Subject>Test.php
  • Multiple conditions in one name → separate with _; maximum three underscore-separated segments (Subject_Condition_Outcome) — more than that is a sign the test needs splitting

22.4 Test Layers

Layer Directory Purpose
Unit tests/Unit/<Domain>/ Pure business logic, no DB, no HTTP (policies, fee math, score calc, DTO validation)
Feature tests/Feature/<Domain>/ Full HTTP flow against real DB (RefreshDatabase)
Contract tests/Contract/<Domain>/ Interface-implementation conformance: every impl satisfies the same suite
Invariant tests/Invariant/<Domain>/ Ledger zero-sum, wallet drift, property-style checks
Smoke tests/Smoke/ End-to-end critical path against staging (manually triggered; not in normal CI)

phpunit.xml.dist declares one <testsuite> per layer:

<testsuites>
    <testsuite name="Unit">
        <directory>tests/Unit</directory>
    </testsuite>
    <testsuite name="Feature">
        <directory>tests/Feature</directory>
    </testsuite>
    <testsuite name="Contract">
        <directory>tests/Contract</directory>
    </testsuite>
    <testsuite name="Invariant">
        <directory>tests/Invariant</directory>
    </testsuite>
</testsuites>

22.5 Example — Feature Test

<?php

declare(strict_types=1);

namespace Tests\Feature\Identity;

use App\Identity\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

final class LoginTest extends TestCase
{
    use RefreshDatabase;

    #[Test]
    public function LoginWithValidCredential_Success(): void
    {
        $user = User::factory()->create([
            'password' => bcrypt('correct-horse-battery-staple'),
        ]);

        $response = $this->postJson('/v1/identity/login', [
            'email' => $user->email,
            'password' => 'correct-horse-battery-staple',
        ]);

        $response->assertStatus(200);
        $response->assertJsonPath('message', 'OK');
        $response->assertJsonPath('data.user.email', $user->email);
        $this->assertIsString($response->json('data.accessToken'));
        $this->assertIsString($response->json('meta.requestId'));
        $this->assertIsString($response->json('meta.traceId'));
    }

    #[Test]
    public function LoginWithInvalidPassword_ReturnsUnauthenticated(): void
    {
        $user = User::factory()->create([
            'password' => bcrypt('correct-horse-battery-staple'),
        ]);

        $response = $this->postJson('/v1/identity/login', [
            'email' => $user->email,
            'password' => 'wrong-password',
        ]);

        $response->assertStatus(401);
        $response->assertJsonPath('errorCode', 'UNAUTHENTICATED');
    }

    #[Test]
    public function LoginWithMissingEmail_ReturnsValidationError(): void
    {
        $response = $this->postJson('/v1/identity/login', [
            'password' => 'anything',
        ]);

        $response->assertStatus(422);
        $response->assertJsonStructure([
            'message',
            'errors' => ['email'],
            'meta' => ['requestId', 'traceId', 'timestamp'],
        ]);
    }

    #[Test]
    public function LoginWhenRateLimited_Returns429(): void
    {
        $user = User::factory()->create();

        for ($i = 0; $i < 10; $i++) {
            $this->postJson('/v1/identity/login', [
                'email' => $user->email,
                'password' => 'wrong',
            ]);
        }

        $response = $this->postJson('/v1/identity/login', [
            'email' => $user->email,
            'password' => 'wrong',
        ]);

        $response->assertStatus(429);
        $response->assertJsonPath('errorCode', 'RATE_LIMITED');
    }
}

22.6 Example — Invariant Test (Ledger)

<?php

declare(strict_types=1);

namespace Tests\Invariant\Ledger;

use App\Identity\Models\User;
use App\Ledger\Jobs\ReconcileWalletsJob;
use App\Ledger\Models\Wallet;
use App\Ledger\Models\WalletDriftIncident;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\Support\LedgerScenarios;
use Tests\TestCase;

final class LedgerIntegrityTest extends TestCase
{
    use RefreshDatabase;

    #[Test]
    public function EveryPostingRule_SumsToZero(): void
    {
        $rules = [
            'top_up',
            'post_purchase',
            'tier_subscription_payment',
            'earnings_release',
            'withdrawal',
            'post_purchase_refund',
            'referral_commission',
            'welcome_credit',
            'premium_subscription_payment',
        ];

        foreach ($rules as $rule) {
            $txn = LedgerScenarios::make($rule)->post();
            $sum = $txn->entries->sum('signed_amount_minor_units');

            $this->assertSame(0, $sum, "Rule {$rule} is not balanced.");
        }
    }

    #[Test]
    public function RandomTransactionSequence_NoWalletDrift(): void
    {
        $users = User::factory()->count(10)->create();
        foreach ($users as $user) {
            Wallet::factory()->for($user)->create();
        }

        for ($i = 0; $i < 1000; $i++) {
            LedgerScenarios::random($users)->post();
        }

        ReconcileWalletsJob::dispatchSync();

        $this->assertSame(0, WalletDriftIncident::count());
    }
}

22.7 Example — Contract Test with DataProvider

<?php

declare(strict_types=1);

namespace Tests\Contract\Identity;

use App\Identity\Contracts\IMfaProvider;
use App\Identity\Infrastructure\Mfa\EmailOtpMfaProvider;
use App\Identity\Infrastructure\Mfa\TotpMfaProvider;
use App\Identity\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\Support\MfaTestHelpers;
use Tests\TestCase;

final class MfaProviderContractTest extends TestCase
{
    use RefreshDatabase;

    #[Test]
    #[DataProvider('mfaProvidersSource')]
    public function Challenge_WhenVerifiedWithCorrectCode_ReturnsTrue(string $binding): void
    {
        /** @var IMfaProvider $provider */
        $provider = app($binding);
        $user = User::factory()->create();
        $setup = $provider->initiate($user);
        $challenge = $provider->challenge($user);

        $correctCode = MfaTestHelpers::generateCorrectCode($provider, $setup, $challenge);

        $this->assertTrue($provider->verify($challenge, $correctCode));
    }

    #[Test]
    #[DataProvider('mfaProvidersSource')]
    public function Challenge_WhenVerifiedWithIncorrectCode_ReturnsFalse(string $binding): void
    {
        /** @var IMfaProvider $provider */
        $provider = app($binding);
        $user = User::factory()->create();
        $provider->initiate($user);
        $challenge = $provider->challenge($user);

        $this->assertFalse($provider->verify($challenge, '000000'));
    }

    /** @return array<string, array{0: class-string<IMfaProvider>}> */
    public static function mfaProvidersSource(): array
    {
        return [
            'totp'      => [TotpMfaProvider::class],
            'email_otp' => [EmailOtpMfaProvider::class],
        ];
    }
}

22.8 Example — Unit Test (Domain Exception + Error Code)

<?php

declare(strict_types=1);

namespace Tests\Unit\Payments\Exceptions;

use App\Core\Exceptions\ErrorCode;
use App\Payments\Exceptions\WithdrawalBelowMinimumException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class WithdrawalBelowMinimumExceptionTest extends TestCase
{
    #[Test]
    public function ErrorCode_ReturnsWithdrawalBelowMinimum(): void
    {
        $ex = new WithdrawalBelowMinimumException(100, 500);

        $this->assertSame(ErrorCode::WithdrawalBelowMinimum, $ex->errorCode());
    }

    #[Test]
    public function HttpStatus_Defaults_To430(): void
    {
        $ex = new WithdrawalBelowMinimumException(100, 500);

        $this->assertSame(430, $ex->httpStatus());
    }

    #[Test]
    public function Message_IncludesAttemptedAndMinimumAmounts(): void
    {
        $ex = new WithdrawalBelowMinimumException(100, 500);

        $this->assertStringContainsString('100', $ex->getMessage());
        $this->assertStringContainsString('500', $ex->getMessage());
    }
}

22.9 Required Scenario Tests

Before a PR touching these areas can merge, these suites must exist and pass:

  • Ledger: every posting rule verified zero-sum; 1000-transaction random scenario verified drift-free
  • Payments: every IPaymentProvider impl passes the shared provider behavior suite (mocked); idempotency-key replay returns identical response for top-up, withdrawal, purchase
  • Identity: every IMfaProvider impl passes MfaProviderContractTest; handle cooldown 30-day enforced; MFA required for admin + withdrawal endpoints
  • Access: canView matrix — 4 rule types × 3 viewer states (subscriber / purchaser / active-fan / none) × 2 post statuses (published / draft) covered
  • Response envelope: every 2xx route produces shape A; every ValidationException path produces shape B; every DomainException subclass produces shape C with the correct errorCode

22.10 CI Test Matrix

GitHub Actions ci.yml jobs:

  • Lint — Pint dry-run
  • Static analysis — Larastan level 8
  • Test — PHPUnit across all suites against Postgres + Redis + Mongo service containers, coverage via PCOV, Clover XML output
  • Coverage threshold — inline Clover XML parsing fails if < 80%
  • OpenAPIl5-swagger:generate then JSON schema validation

Target runtime: < 6 minutes on a cached job.

22.11 Running Tests Locally

# All suites
./vendor/bin/phpunit

# One suite
./vendor/bin/phpunit --testsuite=Feature

# One class
./vendor/bin/phpunit tests/Feature/Identity/LoginTest.php

# One method
./vendor/bin/phpunit --filter=LoginWithValidCredential_Success

# With coverage (requires PCOV ext or XDEBUG_MODE=coverage)
./vendor/bin/phpunit --coverage-clover build/coverage.xml --coverage-html build/coverage-html
php -r "... parse Clover and fail below 80% ..."

.env.testing uses ephemeral Postgres/Redis/Mongo via docker compose -f docker-compose.testing.yml for feature/contract/invariant suites. Unit suite runs without containers.


23. Cross-Cutting: Deployment & Operations

23.1 Environments

Env Purpose Infrastructure
local Developer machines Docker Compose
test CI test runs Docker Compose (ephemeral)
hyena Pre-prod validation Single EC2 t3.medium, shared RDS hyena instance
prod Live EC2 t3.xlarge (vertical scale first), RDS PostgreSQL m6i.large, ElastiCache Redis, MongoDB Atlas M10

APP_ENV is boot-validated and must be one of: local, test, hyena, prod.

23.2 Self-Managed EC2 Deployment

Base image: Ubuntu 24.04 LTS (Noble).

Provisioning via Ansible — idempotent, committed at deploy/ansible/:

deploy/ansible/
├── inventory/
│   ├── staging.yml
│   └── production.yml
├── group_vars/
│   ├── all.yml
│   ├── staging.yml
│   └── production.yml
├── roles/
│   ├── base/             # packages, users, UFW, fail2ban, ntp, unattended-upgrades
│   ├── nginx/            # Nginx + TLS cert pickup from ACM-ssl
│   ├── php/              # PHP 8.4 + fpm + extensions (intl, gd, bcmath, redis, mongodb, pcntl, exif)
│   ├── ffmpeg/           # FFmpeg from static build
│   ├── horizon/          # Supervisor config for Horizon workers
│   ├── cloudwatch_agent/ # Log shipping config
│   ├── ssm_params/       # Fetch secrets into /etc/loreax/env
│   └── app_deploy/       # git clone, composer install, migrate, cache, reload php-fpm
├── playbooks/
│   ├── provision.yml     # First-time setup
│   ├── update.yml        # System updates only
│   └── deploy.yml        # App deployment (also invokable by CI)
└── ansible.cfg

First-time provisioning:

ansible-playbook -i deploy/ansible/inventory/production.yml deploy/ansible/playbooks/provision.yml

Ongoing deploys: triggered by GitHub Actions on push to master (see §23.4).

23.3 Docker Parallel Track

Docker support committed alongside Ansible so local dev and v2 migration to ECS/EKS is zero-refactor:

  • Dockerfile — multi-stage: composer-depsphp-fpm-baseapp (final)
  • Dockerfile.worker — extends app, runs php artisan horizon
  • docker-compose.yml — local dev stack (app + nginx + postgres + redis + mongo + mailhog + laravel-echo-server-like)
  • docker-compose.prod.yml — production-shape (minus local dev tooling); run-ready under ECS task definitions

Makefile:

make dev           # docker-compose up
make prod-build    # build prod images
make deploy        # trigger ansible deploy
make test          # run CI-equivalent locally
make logs-app      # tail app logs
make fresh-db      # wipe + migrate + seed local DB

23.4 GitHub Actions CI/CD

.github/workflows/ci.yml — runs on PR + push to any branch:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [checkout, setup-php@8.4, composer install, ./vendor/bin/pint --test]

  static-analysis:
    runs-on: ubuntu-latest
    steps: [checkout, setup-php, composer install, ./vendor/bin/phpstan analyse]

  test:
    runs-on: ubuntu-latest
    services:
      postgres: { image: postgres:16, env: {...} }
      redis: { image: redis:7 }
      mongo: { image: mongo:7 }
    steps:
      - checkout
      - setup-php
      - composer install
      - cp .env.testing .env
      - php artisan key:generate
      - php artisan migrate
      - ./vendor/bin/phpunit --coverage-clover build/coverage.xml
      - php -r "... parse Clover and fail below 80% ..."

  openapi:
    needs: [lint, static-analysis]
    steps: [php artisan l5-swagger:generate, validate-swagger schema]

.github/workflows/deploy-production.yml — runs on push to master:

on:
  push:
    branches: [master]

jobs:
  deploy:
    needs: [ci-pass]   # via workflow_run gate
    runs-on: ubuntu-latest
    steps:
      - checkout
      - configure-aws-credentials (OIDC role, not keys)
      - setup-ssh-agent with ed25519 deploy key
      - run: ansible-playbook -i deploy/ansible/inventory/production.yml deploy/ansible/playbooks/deploy.yml
      - run: curl https://api.loreax.com/ready   # smoke check
      - on-failure: rollback playbook

Deploy playbook steps (zero-downtime):

  1. git fetch && git checkout <sha> into /srv/loreax/releases/<timestamp>
  2. composer install --no-dev --optimize-autoloader
  3. php artisan migrate --force
  4. php artisan config:cache && php artisan route:cache && php artisan view:cache && php artisan event:cache
  5. Symlink /srv/loreax/current → releases/<timestamp>
  6. sudo systemctl reload php-fpm
  7. php artisan horizon:terminate (Horizon restarts via supervisor)
  8. Keep last 5 releases for rollback

23.5 Rollback

ansible-playbook ... playbooks/rollback.yml --extra-vars="target_release=<timestamp>" — flips the current symlink, reloads php-fpm, terminates Horizon. No DB rollback — migrations must be backward-compatible-by-default (see Migration Rules below).

23.6 Migration Rules

Migrations are backward-compatible:

  • New columns: nullable or with default
  • No column drops or renames in a single deploy — expand/contract pattern over two deploys
  • New required columns added nullable first, backfilled via data migration, then marked non-null in a follow-up deploy
  • Indexes created CONCURRENTLY on large tables (DB::unprepared)

23.7 Environment Variables (v1 catalog)

Grouped in .env.example:

  • App: APP_NAME, APP_ENV, APP_KEY, APP_URL, APP_VERSION, APP_DEBUG
  • Database: DB_CONNECTION, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_SSLMODE
  • Redis: REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB
  • Mongo: MONGO_URI, MONGO_DB_REQUEST_LOGS, MONGO_DB_JOB_LOGS
  • S3: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET_PUBLIC, AWS_BUCKET_PRIVATE, AWS_BUCKET_UPLOADS
  • Sanctum: SANCTUM_STATEFUL_DOMAINS
  • Mail: MAIL_MAILER=ses, MAIL_FROM_ADDRESS, MAIL_FROM_NAME, SES region
  • Kashier: KASHIER_ENABLED, KASHIER_URL, KASHIER_MERCHANT_KEY, KASHIER_MERCHANT_SECRET, KASHIER_SERVICE_ID, KASHIER_PAYBILL_NO, KASHIER_PAYBILL_ACCOUNT
  • Mux: MUX_TOKEN_ID, MUX_TOKEN_SECRET, MUX_WEBHOOK_SECRET
  • SMS package: SMS_DRIVER, SMS_API_USERNAME, SMS_API_KEY, SMS_SHORTCODE, plus optional TWILIO_*, BONGA_*, and ENVISAGE_*
  • OAuth: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, APPLE_*, FACEBOOK_*, LINKEDIN_*
  • Provider selection: VIDEO_PROVIDER=ffmpeg_local, AUDIO_PROVIDER=ffmpeg_local, IMAGE_PROVIDER=intervention_local, LIVESTREAM_PROVIDER=mux, STORAGE_DISK=s3, PAYMENT_PROVIDER=kashier
  • Feature flags (bootstrap): FEATURE_EXPLICIT_CONTENT_ENABLED=false, FEATURE_AI_MODERATION_ENABLED=false
  • Logging: LOG_CHANNEL=stack, LOG_LEVEL=info, LOG_MAX_FILE_SIZE_BYTES=10485760, LOG_MAX_FILES=10, REQUEST_LOGGER_DB_CONNECTION=mongo, REQUEST_LOGGER_COLLECTION=request_logs
  • Rate limits: (reference config/rate_limits.php, env-overridable per key)

24. Cross-Cutting: Documentation

24.1 OpenAPI / Swagger

  • zircote/swagger-php attributes on every public controller method
  • darkaonline/l5-swagger generates storage/api-docs/api-docs.json at build time
  • Served at /docs/api (admin-auth gated in production; public in staging)
  • Redoc UI embedded
  • Client SDK generation documented in docs/api/client-sdk.md for future mobile clients

24.2 Domain READMEs

app/<Domain>/README.md — required for every domain, structure:

# <Domain>

## Purpose
<one paragraph: what this domain owns>

## Owned entities
- Entity A — purpose
- Entity B — purpose

## Key invariants
- <each testable invariant>

## External dependencies
- Upstream: <domains this reads from or calls>
- Downstream: <domains that depend on events/APIs from this domain>

## Public contracts
- `I<Something>Service`
- `App\<Domain>\Events\*`

## Key workflows
<concise flow descriptions>

## Known limitations
<v1 stubs, deferred features>

24.3 ADRs

Architecture Decision Records in docs/adr/, format per MADR 3.0:

  • 0001-modular-monolith-over-microservices.md
  • 0002-eloquent-without-repositories.md
  • 0003-double-entry-ledger-design.md
  • 0004-camelcase-api-snakecase-db.md
  • 0005-mongo-for-request-logs.md
  • 0006-realm-enum-separate-auth-guards.md
  • 0007-static-scope-dictionary-dynamic-role-assignment.md
  • 0008-self-managed-ec2-vs-managed-paas.md
  • 0009-custom-discovery-service-over-elasticsearch.md
  • 0010-three-combinable-access-rules.md
  • 0011-mpesa-first-withdrawal-flow.md
  • 0012-active-fan-weighted-engagement-score.md
  • 0013-laravel-actions-for-business-logic.md

Each ADR: status, context, decision, consequences, alternatives considered.

24.4 Runbooks

docs/runbooks/:

  • data-breach.md
  • mpesa-credential-rotation.md
  • wallet-drift-response.md
  • deploy-rollback.md
  • rds-failover.md
  • horizon-backlog-recovery.md
  • stuck-withdrawal.md
  • ledger-manual-adjustment.md

24.5 Code Documentation Standards

  • Every interface method: PHPDoc with purpose, @throws, and contract notes
  • Every Action class: run() docblock with preconditions, postconditions, side effects, exceptions
  • Complex queries or algorithms: inline comments explaining intent, not mechanics
  • Migrations: top-of-file comment explaining the change rationale
  • @internal / @api annotations used to mark public-stable vs. internal classes

25. Appendices

Appendix A — Full Scope Dictionary

Source of truth: App\Core\Authorization\Scope enum (§5.8). This table lists every case's PascalCase identifier, its backed string value (stored in role_scopes.scope), and the description returned by Scope::description().

Enum case Value Description
UserRead User.Read View user accounts (non-PII scope)
UserReadWrite User.ReadWrite Edit user accounts, reset passwords, impersonate
CreatorRead Creator.Read View creator profiles & earnings (redacted)
CreatorReadWrite Creator.ReadWrite Edit creator profile meta
CreatorApproval Creator.Approval Grant/revoke notable verification
PostRead Post.Read View all posts (incl. drafts)
PostReadWrite Post.ReadWrite Edit, takedown, restore posts
PostApproval Post.Approval Approve posts in manual-prescreen mode
CommentRead Comment.Read View comments
CommentReadWrite Comment.ReadWrite Edit, delete, pin/unpin comments
CollectionRead Collection.Read View collections
CollectionReadWrite Collection.ReadWrite Edit collections
TaxonomyRead Taxonomy.Read View niches/categories/tags
TaxonomyReadWrite Taxonomy.ReadWrite Edit niches/categories/tags
PlatformSettingRead PlatformSetting.Read View platform settings
PlatformSettingReadWrite PlatformSetting.ReadWrite Edit platform settings
FeatureFlagRead FeatureFlag.Read View feature flags
FeatureFlagReadWrite FeatureFlag.ReadWrite Edit feature flags
LedgerRead Ledger.Read View ledger transactions & entries
LedgerReadWrite Ledger.ReadWrite Request manual adjustment
LedgerApproval Ledger.Approval Approve manual adjustments (two-person)
WalletRead Wallet.Read View user wallets
TopUpRead TopUp.Read View top-ups
TopUpReadWrite TopUp.ReadWrite Manually reconcile stuck top-ups
WithdrawalRead Withdrawal.Read View withdrawals
WithdrawalReadWrite Withdrawal.ReadWrite Retry failed withdrawals
WithdrawalApproval Withdrawal.Approval Force-fail / manually settle (two-person)
RefundRead Refund.Read View refunds
RefundReadWrite Refund.ReadWrite Initiate refund
RefundApproval Refund.Approval Approve refund (two-person)
TierRead Tier.Read View tiers & subscriptions
TierSubscriptionRead TierSubscription.Read View tier subscriptions
PremiumSubscriptionRead PremiumSubscription.Read View premium subs
PremiumSubscriptionReadWrite PremiumSubscription.ReadWrite Comp/revoke premium
ReportRead Report.Read View reports
ReportReadWrite Report.ReadWrite Resolve/dismiss reports
VerificationRequestRead VerificationRequest.Read View verification queue
VerificationRequestReadWrite VerificationRequest.ReadWrite Process verification submissions
VerificationRequestApproval VerificationRequest.Approval Approve verification
PromoCodeRead PromoCode.Read View promo codes
PromoCodeReadWrite PromoCode.ReadWrite Create/edit platform-wide promo codes
BoostedPostRead BoostedPost.Read View boosts
BoostedPostReadWrite BoostedPost.ReadWrite Pause/cancel boosts
BoostedPostApproval BoostedPost.Approval Approve (if approval required)
ReferralRead Referral.Read View referral program data
AdminUserRead AdminUser.Read View admin users
AdminUserReadWrite AdminUser.ReadWrite Create/edit admin users (Super Admin only)
RoleRead Role.Read View roles & scope mappings
AuditLogRead AuditLog.Read View activity log
RequestLogRead RequestLog.Read View request log (Mongo)
ApprovalRequestRead ApprovalRequest.Read View approval requests
NicheRead Niche.Read View niche taxonomy
NicheReadWrite Niche.ReadWrite Edit niche taxonomy
CategoryRead Category.Read View category taxonomy
CategoryReadWrite Category.ReadWrite Edit category taxonomy
FanClubRead FanClub.Read Fan club admin view
FanClubReadWrite FanClub.ReadWrite Fan club admin edits
NotificationRead Notification.Read Inspect notification queue
NotificationReadWrite Notification.ReadWrite Retry failed notifications
SystemMaintenance System.Maintenance Enter maintenance mode, emergency feature flag toggles

Appendix B — Role → Scope Matrix

Scope Super Admin Platform Manager Content Reviewer Finance Ops Support Read-Only Auditor
User.Read
User.ReadWrite
Creator.Read
Creator.ReadWrite
Creator.Approval
Post.Read
Post.ReadWrite
Post.Approval
Comment.ReadWrite
Taxonomy.ReadWrite
PlatformSetting.ReadWrite
FeatureFlag.ReadWrite
Ledger.Read
Ledger.ReadWrite
Ledger.Approval
Withdrawal.ReadWrite
Withdrawal.Approval
Refund.ReadWrite
Refund.Approval
Report.ReadWrite
VerificationRequest.Approval
BoostedPost.ReadWrite
PromoCode.ReadWrite
AdminUser.ReadWrite
AuditLog.Read
RequestLog.Read
System.Maintenance

(All .Read scopes included in Read-Only Auditor regardless of above.)

Appendix C — Notification Type Catalog

(See §16.4 for full list.)

Appendix D — Platform Settings Keys

(See §5.6 canonical table.)

Appendix E — Ledger Posting Rules Summary

(See §7.3 catalog.)

Appendix F — Glossary

Term Meaning
Active Fan User with engagement score ≥ threshold for a creator, OR active tier subscriber
Boost Paid placement of a post in discovery ranking
C2B Customer-to-Business — MPESA flow for viewers paying platform
B2C Business-to-Customer — MPESA flow for platform paying out to creators
Collection Creator-curated grouping of own posts
Conversation ID Correlates multiple requests belonging to one logical user flow
Creator Any user who has published ≥1 post or created ≥1 tier (is_creator=true)
Kashier Payment gateway layer currently used for MPESA/STK integration in Loreax
Earnings Hold 3-day delay before creator earnings become withdrawable
Fan Club Creator-owned community chat space
Fan Engagement Score Weighted sum of interactions powering active-fan status
Idempotency Key Client-supplied unique key deduplicating retried writes
KDPA Kenya Data Protection Act
Ledger Transaction One business-event posting (contains ≥ 2 balanced entries)
List User-curated grouping of creators (bookmarks of people)
Net model Platform fee deducted from creator's share; viewer pays advertised price
Niche Top-level creator category (e.g. Music, Gaming)
Notable Verified Admin-granted trust badge (is_notable_verified)
Premium Paid viewer subscription (is_premium) with badge, perks
Processor Controller/Action class name recorded in logs
Realm Authentication boundary: user or admin
Request ID ULID assigned per HTTP request, threaded through logs
Saved Post User bookmark of a post
Scenario Client-supplied tag describing current user flow (e.g. onboarding)
Scope Permission token granting access to a resource action (e.g. Ledger.Read)
STK Push MPESA C2B flow where user gets a prompt on their phone
Tier Recurring subscription level offered by a creator
Transaction (ledger) See Ledger Transaction
Wallet Per-user aggregated balance derived from ledger

Appendix G — Entity Relationship Diagram References

Full ERD lives in docs/diagrams/erd.drawio — regenerated per migration. Key cross-domain relationships:

  • User ←→ Wallet (1:1)
  • User ←→ Post (1:N as creator)
  • Post ←→ AccessRule (1:N)
  • Post ←→ PostPurchase (1:N)
  • User ←→ TierSubscription (1:N)
  • Tier ←→ TierSubscription (1:N)
  • PaymentIntent ←→ LedgerTransaction (1:1 after settlement)
  • LedgerTransaction ←→ LedgerEntry (1:N ≥ 2, sum to zero)
  • LedgerEntry ←→ LedgerAccount (N:1)
  • User ←→ LedgerAccount (1:N, per type)
  • Post ←→ BoostedPost (1:1 at a time)
  • ReferralLink ←→ ReferralAttribution (1:N)
  • ReferralAttribution ←→ ReferralCommission (1:N)

Document Control

  • Owner: Backend Engineering
  • Reviewers: Product, Finance Ops, Security, DPO
  • Review cadence: Monthly during MVP; quarterly post-GA
  • Version bumps: Semantic (0.X.Y). Breaking change to domain contracts = major bump.
  • Change log: docs/CHANGELOG.md

End of document.