A Rust + Leptos Spin SSR platform for configurable MLM pay plans. Companies create products and services, bundle them into packages, and attach compensation pay plan stacks (Royal Flush, Binary, or a custom hybrid). All rewards flow through an append-only event log + reward ledger.
This is v2 — the platform is no longer a single MLM app. Royal Flush and Binary are both first-class built-in module families, and the engine is a generic pay plan runner that any company can configure.
- ✅ Phase 0–7 of the PRD plan are complete.
- ✅ Royal Flush modules (sponsor, flushline, matrix, pot bonus, duplication) implemented.
- ✅ Binary modules (tree, volume, pairing, carryover) implemented.
- ✅ Module trait +
ModuleRegistry+StackRunner+ cascading event loop. - ✅ Postgres persistence with sqlx.
- ✅ Atomic purchase flow (single transaction).
- ✅
ModuleProjector(state → per-module tables) +EventProjector(emitted events → duplication / pairing / cycle_count / pot balances), wired into bothPgPurchaseWriter(transactional) and operations jobs (best-effort). - ✅ 19 property tests in
payplan_core(volume, carryover, pot bonus, duplication invariants). - ✅ JWT auth (HS256, 15 min access + 7 day refresh, single-use refresh
rotation,
revoked_jtistore, 4-tier route gating). - ✅ argon2 password hashing.
- ✅ Dev seed + Makefile + GitHub Actions CI.
- ✅ Leptos SSR admin UI with isolated WASM islands.
Test counts: 96 in payplan_core (77 unit + 19 property),
62 lib tests across the workspace, 33 infra integration tests,
8 web auth integration tests.
Prereqs: Rust stable, Postgres 14+, the WASM Rust target, and
cargo-leptos 0.3.6.
# 1. Install the UI build prerequisites once.
rustup target add wasm32-unknown-unknown
cargo install --locked cargo-leptos --version 0.3.6
# 2. Start a local Postgres on the unix socket at /tmp (any Postgres works).
pg_isready -h /tmp
# 3. Apply the dev seed (creates Acme + Royal Flush + Binary packages).
make seed
# 4. Build the server and browser assets, then watch for changes.
make serveOpen http://127.0.0.1:3000/login. The server binds to
0.0.0.0:3000 by default. Override with BIND_ADDR=....
# Health check
curl http://127.0.0.1:3000/health
# {"modules":[["binary.carryover","1.0.0"], ...], "status":"ok"}All endpoints accept/return JSON. Errors come back as {"message": "..."}.
Authenticated endpoints expect Authorization: Bearer <access_token>.
| Method | Path | Auth tier | Purpose |
|---|---|---|---|
| GET | /health |
public | Liveness + module inventory |
| POST | /api/auth/login |
public | Email + password → access + refresh pair |
| POST | /api/auth/refresh |
public | Rotate refresh (single-use), returns new pair |
| POST | /api/auth/logout |
authenticated | Revoke the presented token's jti |
| POST | /api/users |
public | Self-service signup (role forced to user) |
| POST | /api/companies |
company_admin+ | Create a company |
| POST | /api/catalog_items |
company_admin+ | Create a product or service |
| POST | /api/billing_plans |
company_admin+ | Attach one-time or recurring pricing |
| POST | /api/packages |
company_admin+ | Bundle catalog items + assign a pay plan stack |
| GET | /api/packages |
authenticated | List all packages (across all companies) |
| POST | /api/purchases |
authenticated | Run the full purchase flow (atomic transaction) |
| POST | /admin/jobs/renewals/run |
platform_admin | Manual renewal job |
| POST | /admin/jobs/royal_pot_distribution/run |
platform_admin | Manual Royal Flush weekly pot distribution |
| POST | /admin/jobs/binary_cycle_close/run |
platform_admin | Manual Binary cycle close |
curl -X POST http://127.0.0.1:3000/api/companies \
-H 'Content-Type: application/json' \
-d '{"name":"Acme MLM","slug":"acme"}'curl -X POST http://127.0.0.1:3000/api/users \
-H 'Content-Type: application/json' \
-d '{
"email": "buyer@acme.local",
"password": "correct horse battery staple",
"role": "user",
"company_id": "11111111-1111-1111-1111-111111111111"
}'Passwords are hashed with argon2id (memory=19 MiB, t=2, p=1) before storage.
The server forces role to user server-side — clients cannot escalate to
company_admin or platform_admin through signup.
curl -X POST http://127.0.0.1:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"buyer@acme.local","password":"correct horse battery staple"}'
# 200 OK
# {
# "access_token": "<15-min HS256 JWT>",
# "refresh_token": "<7-day HS256 JWT>",
# "token_type": "Bearer",
# "expires_in": 900
# }Pass the access_token as Authorization: Bearer <access_token> on every
authenticated request. The refresh token is single-use — posting it to
/api/auth/refresh rotates both tokens and revokes the old jti.
curl -X POST http://127.0.0.1:3000/api/purchases \
-H 'Content-Type: application/json' \
-d '{
"company_id": "11111111-1111-1111-1111-111111111111",
"user_id": "<user-uuid>",
"package_id": "55555555-5555-5555-5555-555555555551"
}'
# The price and currency are derived server-side from the package's billing
# plans (sum of price × quantity); the client cannot set the amount.
# 201 Created
# {
# "purchase_id": "...",
# "enrollment_id": "...",
# "subscription_ids": ["..."],
# "entitlement_ids": ["..."],
# "events_emitted": 5,
# "ledger_entries": 0
# }This single call:
- Validates the package and billing plans.
- Creates subscription(s) for any recurring items.
- Grants entitlements for every package item.
- Creates the purchase and enrollment records.
- Loads the package's pay plan stack and runs every module that handles
PackagePurchased/EnrollmentCreatedevents. - Persists all events + ledger entries.
All steps 1–6 commit in a single Postgres transaction (or roll back together if anything fails).
payplan_core pure domain: entities, events, ledger, all modules,
Module trait, ModuleRegistry, StackRunner, StateCache
payplan_app workflows: commands, port traits, PurchaseDeps,
cascading event loop, ModuleProjector + EventProjector
port traits
payplan_infra Postgres impls: repos, EventStore, LedgerStore,
PgProjections + PgEventProjector, PgPurchaseWriter
(atomic), JwtService + PgRevokedJtiStore, ops jobs
payplan_web axum routes + handlers + AppContext composition root,
session extractor + auth middleware (4-tier route gating)
payplan_server payplan-server binary
payplan_web ──> payplan_app ──> payplan_core
payplan_infra ──> payplan_app (implements its port traits)
payplan_server ──> payplan_web + payplan_infra + payplan_app
payplan_core never imports payplan_infra (except via the optional sqlx
feature flag for the newtype sqlx::Type impls).
pub trait Module: Send + Sync {
fn key(&self) -> &'static str; // e.g. "royal.flushline"
fn version(&self) -> &'static str; // e.g. "1.0.0"
fn handles(&self) -> &'static [EventType]; // which events trigger this module
fn run(&self, ctx: &ModuleContext) -> CoreResult<ModuleResult>;
}ModuleResult carries emitted events, ledger entries, optional state change,
and warnings. State changes are persisted as opaque JSON via the engine.
When a purchase is made, the engine:
- Builds the initial events (
PackagePurchased,EnrollmentCreated). - Runs every module that handles the event in stack
sort_order. - Captures emitted events + ledger entries + state changes.
- Re-runs the stack against each newly-emitted event (cascades).
- Caps at 32 iterations to prevent runaway loops.
- Calls
ModuleProjectorandEventProjectorto materialise the per-module relational tables and event-derived rows (duplication, pairing, cycle count, pot balances). - Commits all writes in one Postgres transaction.
The engine writes canonical state to module_state (opaque JSON) and emits
DomainEvents. Two projector traits turn those into the per-module
relational tables the rest of the system reads from:
ModuleProjector—(module_key, module_version, aggregate_id, state)changes →royal_flushline_accounts/binary_nodes/binary_volume_ledger/binary_carryover.EventProjector— emittedDomainEvents → rows that can't be reconstructed from a single aggregate's state: duplication (new enrollment + flushline account), pairing results,binary_nodes.cycle_countincrements, and cumulativeroyal_pot_bonus_balancesupserts.
Both projectors are wired into PgPurchaseWriter::write (transactional
Path A) and operations::run_stack_against_event (best-effort Path B
for renewals and job retries).
Royal Flush stack (sponsor.allocation, royal.flushline,
royal.matrix, royal.pot_bonus, royal.account_duplication):
- Flushline: Ten→Jack→Queen→King→Ace with per-tier thresholds (1/2/3/4/5); graduates at 15 cumulative points; weekly reset moves graduated accounts back to King.
- Matrix: 7-slot binary-shaped matrix with sponsor-first placement; cycles when full.
- Pot bonus: 75/25 split; user-level qualification (≥1 graduation AND ≥1 cycle).
- Duplication: gated on both signals; emits a new Royal account.
Binary stack (sponsor.allocation, binary.tree, binary.volume,
binary.pairing_bonus, binary.carryover):
- Tree: 4 placement strategies (Manual/SponsorPreference/AutoBalance/ OutsideLegPreference).
- Volume: from package purchases + renewals.
- Pairing: ratio-based matching with configurable commission % and cap.
- Carryover: per-leg carryover between cycles.
make help # Show all targets
make test # Workspace tests
make test-integration # Postgres integration tests
make test-ui # Leptos app/client/server tests
make ci # Full local CI suite
make seed # Apply dev seed
make serve # Build UI + run server + live reload
make serve-release # Optimized local release server
make reset # Wipe all data (DANGER)
make health # curl /healthcrates/
payplan_core/ pure domain
payplan_app/ workflows
payplan_infra/ Postgres + auth + ops
payplan_web/ axum routes + handlers + AppContext
payplan_server/ payplan-server binary
payplan_ui/ SSR admin pages + isolated browser islands
docs/ PRD + architecture + module contract
migrations/ Postgres schema (also embedded in payplan_infra)
seeds/ dev seed
.github/workflows/ CI
- 96 tests in
payplan_core(77 unit + 19 property) run on every commit. - 62 lib tests across the rest of the workspace.
- Integration tests live under
crates/*/tests/and are gated behind theintegrationfeature flag. Run them with a real Postgres:
make test
make test-integration
make test-ui
make ui-build
make ui-baselineThe payplan_web integration suite is the auth HTTP-level suite (8 tests
covering login, refresh-rotation, logout-revocation, role gating).
make serve is the supported development command. Plain cargo run starts
only the backend and does not build or watch the JS/WASM/CSS assets.
Create a local login once while the server is running:
curl --fail-with-body \
-H 'Content-Type: application/json' \
--data '{"email":"admin@local.test","password":"change-me-local","company_id":null}' \
http://127.0.0.1:3000/api/users
psql "$DATABASE_URL" \
-c "UPDATE users SET role = 'platform_admin' WHERE email = 'admin@local.test'"Then sign in at http://127.0.0.1:3000/login.
Manual smoke checks:
- Dashboard and package data are present on direct page load.
- The mobile-menu button changes from “Open menu” to “Close menu.”
- Search and pagination work as normal GET requests.
- Company/catalog/billing forms submit and redirect back to their SSR list.
/api/packagesstill rejects cookie-only authentication.
To measure authenticated SSR route timings, export a curl cookie jar and run:
COOKIE_FILE=/tmp/payplan-ui-cookies.txt make ui-route-baselineGitHub Actions runs fmt --check, clippy -- -D warnings, cargo test, and
the integration suite against a postgres:16 service container on every PR.
UNLICENSED — internal project.


