Repository files navigation

PayPlan Platform v2

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.


Status

  • ✅ 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 both PgPurchaseWriter (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_jti store, 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.


Quickstart

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 serve

Open 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"}

HTTP API

All endpoints accept/return JSON. Errors come back as {"message": "..."}. Authenticated endpoints expect Authorization: Bearer <access_token>.

MethodPathAuth tierPurpose
GET/healthpublicLiveness + module inventory
POST/api/auth/loginpublicEmail + password → access + refresh pair
POST/api/auth/refreshpublicRotate refresh (single-use), returns new pair
POST/api/auth/logoutauthenticatedRevoke the presented token's jti
POST/api/userspublicSelf-service signup (role forced to user)
POST/api/companiescompany_admin+Create a company
POST/api/catalog_itemscompany_admin+Create a product or service
POST/api/billing_planscompany_admin+Attach one-time or recurring pricing
POST/api/packagescompany_admin+Bundle catalog items + assign a pay plan stack
GET/api/packagesauthenticatedList all packages (across all companies)
POST/api/purchasesauthenticatedRun the full purchase flow (atomic transaction)
POST/admin/jobs/renewals/runplatform_adminManual renewal job
POST/admin/jobs/royal_pot_distribution/runplatform_adminManual Royal Flush weekly pot distribution
POST/admin/jobs/binary_cycle_close/runplatform_adminManual Binary cycle close

Create a company

curl -X POST http://127.0.0.1:3000/api/companies \
-H 'Content-Type: application/json' \
-d '{"name":"Acme MLM","slug":"acme"}'

Register a user

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.

Log in

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.

Purchase a package

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:

  1. Validates the package and billing plans.
  2. Creates subscription(s) for any recurring items.
  3. Grants entitlements for every package item.
  4. Creates the purchase and enrollment records.
  5. Loads the package's pay plan stack and runs every module that handles PackagePurchased / EnrollmentCreated events.
  6. Persists all events + ledger entries.

All steps 1–6 commit in a single Postgres transaction (or roll back together if anything fails).


Architecture

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

Dependency rule

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).

Module contract

pubtraitModule:Send + Sync{fnkey(&self) -> &'staticstr;// e.g. "royal.flushline"fnversion(&self) -> &'staticstr;// e.g. "1.0.0"fnhandles(&self) -> &'static[EventType];// which events trigger this modulefnrun(&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.

Engine cascading loop

When a purchase is made, the engine:

  1. Builds the initial events (PackagePurchased, EnrollmentCreated).
  2. Runs every module that handles the event in stack sort_order.
  3. Captures emitted events + ledger entries + state changes.
  4. Re-runs the stack against each newly-emitted event (cascades).
  5. Caps at 32 iterations to prevent runaway loops.
  6. Calls ModuleProjector and EventProjector to materialise the per-module relational tables and event-derived rows (duplication, pairing, cycle count, pot balances).
  7. Commits all writes in one Postgres transaction.

Projection layer

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 — emitted DomainEvents → rows that can't be reconstructed from a single aggregate's state: duplication (new enrollment + flushline account), pairing results, binary_nodes.cycle_count increments, and cumulative royal_pot_bonus_balances upserts.

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).

Built-in modules

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.

Development

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 /health

Layout

crates/
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

Testing

  • 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 the integration feature flag. Run them with a real Postgres:
make test
make test-integration
make test-ui
make ui-build
make ui-baseline

The payplan_web integration suite is the auth HTTP-level suite (8 tests covering login, refresh-rotation, logout-revocation, role gating).

Testing the admin UI

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:

  1. Dashboard and package data are present on direct page load.
  2. The mobile-menu button changes from “Open menu” to “Close menu.”
  3. Search and pagination work as normal GET requests.
  4. Company/catalog/billing forms submit and redirect back to their SSR list.
  5. /api/packages still 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-baseline

CI

GitHub Actions runs fmt --check, clippy -- -D warnings, cargo test, and the integration suite against a postgres:16 service container on every PR.


License

UNLICENSED — internal project.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

PayPlan Platform v2

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.


Status

  • ✅ 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 both PgPurchaseWriter (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_jti store, 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.


Quickstart

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 serve

Open 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"}

HTTP API

All endpoints accept/return JSON. Errors come back as {"message": "..."}. Authenticated endpoints expect Authorization: Bearer <access_token>.

MethodPathAuth tierPurpose
GET/healthpublicLiveness + module inventory
POST/api/auth/loginpublicEmail + password → access + refresh pair
POST/api/auth/refreshpublicRotate refresh (single-use), returns new pair
POST/api/auth/logoutauthenticatedRevoke the presented token's jti
POST/api/userspublicSelf-service signup (role forced to user)
POST/api/companiescompany_admin+Create a company
POST/api/catalog_itemscompany_admin+Create a product or service
POST/api/billing_planscompany_admin+Attach one-time or recurring pricing
POST/api/packagescompany_admin+Bundle catalog items + assign a pay plan stack
GET/api/packagesauthenticatedList all packages (across all companies)
POST/api/purchasesauthenticatedRun the full purchase flow (atomic transaction)
POST/admin/jobs/renewals/runplatform_adminManual renewal job
POST/admin/jobs/royal_pot_distribution/runplatform_adminManual Royal Flush weekly pot distribution
POST/admin/jobs/binary_cycle_close/runplatform_adminManual Binary cycle close

Create a company

curl -X POST http://127.0.0.1:3000/api/companies \
-H 'Content-Type: application/json' \
-d '{"name":"Acme MLM","slug":"acme"}'

Register a user

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.

Log in

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.

Purchase a package

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:

  1. Validates the package and billing plans.
  2. Creates subscription(s) for any recurring items.
  3. Grants entitlements for every package item.
  4. Creates the purchase and enrollment records.
  5. Loads the package's pay plan stack and runs every module that handles PackagePurchased / EnrollmentCreated events.
  6. Persists all events + ledger entries.

All steps 1–6 commit in a single Postgres transaction (or roll back together if anything fails).


Architecture

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

Dependency rule

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).

Module contract

pubtraitModule:Send + Sync{fnkey(&self) -> &'staticstr;// e.g. "royal.flushline"fnversion(&self) -> &'staticstr;// e.g. "1.0.0"fnhandles(&self) -> &'static[EventType];// which events trigger this modulefnrun(&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.

Engine cascading loop

When a purchase is made, the engine:

  1. Builds the initial events (PackagePurchased, EnrollmentCreated).
  2. Runs every module that handles the event in stack sort_order.
  3. Captures emitted events + ledger entries + state changes.
  4. Re-runs the stack against each newly-emitted event (cascades).
  5. Caps at 32 iterations to prevent runaway loops.
  6. Calls ModuleProjector and EventProjector to materialise the per-module relational tables and event-derived rows (duplication, pairing, cycle count, pot balances).
  7. Commits all writes in one Postgres transaction.

Projection layer

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 — emitted DomainEvents → rows that can't be reconstructed from a single aggregate's state: duplication (new enrollment + flushline account), pairing results, binary_nodes.cycle_count increments, and cumulative royal_pot_bonus_balances upserts.

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).

Built-in modules

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.

Development

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 /health

Layout

crates/
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

Testing

  • 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 the integration feature flag. Run them with a real Postgres:
make test
make test-integration
make test-ui
make ui-build
make ui-baseline

The payplan_web integration suite is the auth HTTP-level suite (8 tests covering login, refresh-rotation, logout-revocation, role gating).

Testing the admin UI

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:

  1. Dashboard and package data are present on direct page load.
  2. The mobile-menu button changes from “Open menu” to “Close menu.”
  3. Search and pagination work as normal GET requests.
  4. Company/catalog/billing forms submit and redirect back to their SSR list.
  5. /api/packages still 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-baseline

CI

GitHub Actions runs fmt --check, clippy -- -D warnings, cargo test, and the integration suite against a postgres:16 service container on every PR.


License

UNLICENSED — internal project.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

PayPlan Platform v2

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.


Status

  • ✅ 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 both PgPurchaseWriter (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_jti store, 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.


Quickstart

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 serve

Open 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"}

HTTP API

All endpoints accept/return JSON. Errors come back as {"message": "..."}. Authenticated endpoints expect Authorization: Bearer <access_token>.

MethodPathAuth tierPurpose
GET/healthpublicLiveness + module inventory
POST/api/auth/loginpublicEmail + password → access + refresh pair
POST/api/auth/refreshpublicRotate refresh (single-use), returns new pair
POST/api/auth/logoutauthenticatedRevoke the presented token's jti
POST/api/userspublicSelf-service signup (role forced to user)
POST/api/companiescompany_admin+Create a company
POST/api/catalog_itemscompany_admin+Create a product or service
POST/api/billing_planscompany_admin+Attach one-time or recurring pricing
POST/api/packagescompany_admin+Bundle catalog items + assign a pay plan stack
GET/api/packagesauthenticatedList all packages (across all companies)
POST/api/purchasesauthenticatedRun the full purchase flow (atomic transaction)
POST/admin/jobs/renewals/runplatform_adminManual renewal job
POST/admin/jobs/royal_pot_distribution/runplatform_adminManual Royal Flush weekly pot distribution
POST/admin/jobs/binary_cycle_close/runplatform_adminManual Binary cycle close

Create a company

curl -X POST http://127.0.0.1:3000/api/companies \
-H 'Content-Type: application/json' \
-d '{"name":"Acme MLM","slug":"acme"}'

Register a user

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.

Log in

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.

Purchase a package

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:

  1. Validates the package and billing plans.
  2. Creates subscription(s) for any recurring items.
  3. Grants entitlements for every package item.
  4. Creates the purchase and enrollment records.
  5. Loads the package's pay plan stack and runs every module that handles PackagePurchased / EnrollmentCreated events.
  6. Persists all events + ledger entries.

All steps 1–6 commit in a single Postgres transaction (or roll back together if anything fails).


Architecture

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

Dependency rule

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).

Module contract

pubtraitModule:Send + Sync{fnkey(&self) -> &'staticstr;// e.g. "royal.flushline"fnversion(&self) -> &'staticstr;// e.g. "1.0.0"fnhandles(&self) -> &'static[EventType];// which events trigger this modulefnrun(&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.

Engine cascading loop

When a purchase is made, the engine:

  1. Builds the initial events (PackagePurchased, EnrollmentCreated).
  2. Runs every module that handles the event in stack sort_order.
  3. Captures emitted events + ledger entries + state changes.
  4. Re-runs the stack against each newly-emitted event (cascades).
  5. Caps at 32 iterations to prevent runaway loops.
  6. Calls ModuleProjector and EventProjector to materialise the per-module relational tables and event-derived rows (duplication, pairing, cycle count, pot balances).
  7. Commits all writes in one Postgres transaction.

Projection layer

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 — emitted DomainEvents → rows that can't be reconstructed from a single aggregate's state: duplication (new enrollment + flushline account), pairing results, binary_nodes.cycle_count increments, and cumulative royal_pot_bonus_balances upserts.

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).

Built-in modules

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.

Development

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 /health

Layout

crates/
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

Testing

  • 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 the integration feature flag. Run them with a real Postgres:
make test
make test-integration
make test-ui
make ui-build
make ui-baseline

The payplan_web integration suite is the auth HTTP-level suite (8 tests covering login, refresh-rotation, logout-revocation, role gating).

Testing the admin UI

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:

  1. Dashboard and package data are present on direct page load.
  2. The mobile-menu button changes from “Open menu” to “Close menu.”
  3. Search and pagination work as normal GET requests.
  4. Company/catalog/billing forms submit and redirect back to their SSR list.
  5. /api/packages still 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-baseline

CI

GitHub Actions runs fmt --check, clippy -- -D warnings, cargo test, and the integration suite against a postgres:16 service container on every PR.


License

UNLICENSED — internal project.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

PayPlan Platform v2

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.


Status

  • ✅ 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 both PgPurchaseWriter (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_jti store, 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.


Quickstart

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 serve

Open 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"}

HTTP API

All endpoints accept/return JSON. Errors come back as {"message": "..."}. Authenticated endpoints expect Authorization: Bearer <access_token>.

MethodPathAuth tierPurpose
GET/healthpublicLiveness + module inventory
POST/api/auth/loginpublicEmail + password → access + refresh pair
POST/api/auth/refreshpublicRotate refresh (single-use), returns new pair
POST/api/auth/logoutauthenticatedRevoke the presented token's jti
POST/api/userspublicSelf-service signup (role forced to user)
POST/api/companiescompany_admin+Create a company
POST/api/catalog_itemscompany_admin+Create a product or service
POST/api/billing_planscompany_admin+Attach one-time or recurring pricing
POST/api/packagescompany_admin+Bundle catalog items + assign a pay plan stack
GET/api/packagesauthenticatedList all packages (across all companies)
POST/api/purchasesauthenticatedRun the full purchase flow (atomic transaction)
POST/admin/jobs/renewals/runplatform_adminManual renewal job
POST/admin/jobs/royal_pot_distribution/runplatform_adminManual Royal Flush weekly pot distribution
POST/admin/jobs/binary_cycle_close/runplatform_adminManual Binary cycle close

Create a company

curl -X POST http://127.0.0.1:3000/api/companies \
-H 'Content-Type: application/json' \
-d '{"name":"Acme MLM","slug":"acme"}'

Register a user

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.

Log in

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.

Purchase a package

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:

  1. Validates the package and billing plans.
  2. Creates subscription(s) for any recurring items.
  3. Grants entitlements for every package item.
  4. Creates the purchase and enrollment records.
  5. Loads the package's pay plan stack and runs every module that handles PackagePurchased / EnrollmentCreated events.
  6. Persists all events + ledger entries.

All steps 1–6 commit in a single Postgres transaction (or roll back together if anything fails).


Architecture

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

Dependency rule

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).

Module contract

pubtraitModule:Send + Sync{fnkey(&self) -> &'staticstr;// e.g. "royal.flushline"fnversion(&self) -> &'staticstr;// e.g. "1.0.0"fnhandles(&self) -> &'static[EventType];// which events trigger this modulefnrun(&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.

Engine cascading loop

When a purchase is made, the engine:

  1. Builds the initial events (PackagePurchased, EnrollmentCreated).
  2. Runs every module that handles the event in stack sort_order.
  3. Captures emitted events + ledger entries + state changes.
  4. Re-runs the stack against each newly-emitted event (cascades).
  5. Caps at 32 iterations to prevent runaway loops.
  6. Calls ModuleProjector and EventProjector to materialise the per-module relational tables and event-derived rows (duplication, pairing, cycle count, pot balances).
  7. Commits all writes in one Postgres transaction.

Projection layer

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 — emitted DomainEvents → rows that can't be reconstructed from a single aggregate's state: duplication (new enrollment + flushline account), pairing results, binary_nodes.cycle_count increments, and cumulative royal_pot_bonus_balances upserts.

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).

Built-in modules

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.

Development

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 /health

Layout

crates/
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

Testing

  • 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 the integration feature flag. Run them with a real Postgres:
make test
make test-integration
make test-ui
make ui-build
make ui-baseline

The payplan_web integration suite is the auth HTTP-level suite (8 tests covering login, refresh-rotation, logout-revocation, role gating).

Testing the admin UI

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:

  1. Dashboard and package data are present on direct page load.
  2. The mobile-menu button changes from “Open menu” to “Close menu.”
  3. Search and pagination work as normal GET requests.
  4. Company/catalog/billing forms submit and redirect back to their SSR list.
  5. /api/packages still 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-baseline

CI

GitHub Actions runs fmt --check, clippy -- -D warnings, cargo test, and the integration suite against a postgres:16 service container on every PR.


License

UNLICENSED — internal project.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

PayPlan Platform v2

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.


Status

  • ✅ 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 both PgPurchaseWriter (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_jti store, 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.


Quickstart

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 serve

Open 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"}

HTTP API

All endpoints accept/return JSON. Errors come back as {"message": "..."}. Authenticated endpoints expect Authorization: Bearer <access_token>.

MethodPathAuth tierPurpose
GET/healthpublicLiveness + module inventory
POST/api/auth/loginpublicEmail + password → access + refresh pair
POST/api/auth/refreshpublicRotate refresh (single-use), returns new pair
POST/api/auth/logoutauthenticatedRevoke the presented token's jti
POST/api/userspublicSelf-service signup (role forced to user)
POST/api/companiescompany_admin+Create a company
POST/api/catalog_itemscompany_admin+Create a product or service
POST/api/billing_planscompany_admin+Attach one-time or recurring pricing
POST/api/packagescompany_admin+Bundle catalog items + assign a pay plan stack
GET/api/packagesauthenticatedList all packages (across all companies)
POST/api/purchasesauthenticatedRun the full purchase flow (atomic transaction)
POST/admin/jobs/renewals/runplatform_adminManual renewal job
POST/admin/jobs/royal_pot_distribution/runplatform_adminManual Royal Flush weekly pot distribution
POST/admin/jobs/binary_cycle_close/runplatform_adminManual Binary cycle close

Create a company

curl -X POST http://127.0.0.1:3000/api/companies \
-H 'Content-Type: application/json' \
-d '{"name":"Acme MLM","slug":"acme"}'

Register a user

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.

Log in

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.

Purchase a package

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:

  1. Validates the package and billing plans.
  2. Creates subscription(s) for any recurring items.
  3. Grants entitlements for every package item.
  4. Creates the purchase and enrollment records.
  5. Loads the package's pay plan stack and runs every module that handles PackagePurchased / EnrollmentCreated events.
  6. Persists all events + ledger entries.

All steps 1–6 commit in a single Postgres transaction (or roll back together if anything fails).


Architecture

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

Dependency rule

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).

Module contract

pubtraitModule:Send + Sync{fnkey(&self) -> &'staticstr;// e.g. "royal.flushline"fnversion(&self) -> &'staticstr;// e.g. "1.0.0"fnhandles(&self) -> &'static[EventType];// which events trigger this modulefnrun(&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.

Engine cascading loop

When a purchase is made, the engine:

  1. Builds the initial events (PackagePurchased, EnrollmentCreated).
  2. Runs every module that handles the event in stack sort_order.
  3. Captures emitted events + ledger entries + state changes.
  4. Re-runs the stack against each newly-emitted event (cascades).
  5. Caps at 32 iterations to prevent runaway loops.
  6. Calls ModuleProjector and EventProjector to materialise the per-module relational tables and event-derived rows (duplication, pairing, cycle count, pot balances).
  7. Commits all writes in one Postgres transaction.

Projection layer

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 — emitted DomainEvents → rows that can't be reconstructed from a single aggregate's state: duplication (new enrollment + flushline account), pairing results, binary_nodes.cycle_count increments, and cumulative royal_pot_bonus_balances upserts.

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).

Built-in modules

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.

Development

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 /health

Layout

crates/
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

Testing

  • 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 the integration feature flag. Run them with a real Postgres:
make test
make test-integration
make test-ui
make ui-build
make ui-baseline

The payplan_web integration suite is the auth HTTP-level suite (8 tests covering login, refresh-rotation, logout-revocation, role gating).

Testing the admin UI

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:

  1. Dashboard and package data are present on direct page load.
  2. The mobile-menu button changes from “Open menu” to “Close menu.”
  3. Search and pagination work as normal GET requests.
  4. Company/catalog/billing forms submit and redirect back to their SSR list.
  5. /api/packages still 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-baseline

CI

GitHub Actions runs fmt --check, clippy -- -D warnings, cargo test, and the integration suite against a postgres:16 service container on every PR.


License

UNLICENSED — internal project.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

PayPlan Platform v2

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.


Status

  • ✅ 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 both PgPurchaseWriter (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_jti store, 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.


Quickstart

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 serve

Open 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"}

HTTP API

All endpoints accept/return JSON. Errors come back as {"message": "..."}. Authenticated endpoints expect Authorization: Bearer <access_token>.

MethodPathAuth tierPurpose
GET/healthpublicLiveness + module inventory
POST/api/auth/loginpublicEmail + password → access + refresh pair
POST/api/auth/refreshpublicRotate refresh (single-use), returns new pair
POST/api/auth/logoutauthenticatedRevoke the presented token's jti
POST/api/userspublicSelf-service signup (role forced to user)
POST/api/companiescompany_admin+Create a company
POST/api/catalog_itemscompany_admin+Create a product or service
POST/api/billing_planscompany_admin+Attach one-time or recurring pricing
POST/api/packagescompany_admin+Bundle catalog items + assign a pay plan stack
GET/api/packagesauthenticatedList all packages (across all companies)
POST/api/purchasesauthenticatedRun the full purchase flow (atomic transaction)
POST/admin/jobs/renewals/runplatform_adminManual renewal job
POST/admin/jobs/royal_pot_distribution/runplatform_adminManual Royal Flush weekly pot distribution
POST/admin/jobs/binary_cycle_close/runplatform_adminManual Binary cycle close

Create a company

curl -X POST http://127.0.0.1:3000/api/companies \
-H 'Content-Type: application/json' \
-d '{"name":"Acme MLM","slug":"acme"}'

Register a user

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.

Log in

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.

Purchase a package

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:

  1. Validates the package and billing plans.
  2. Creates subscription(s) for any recurring items.
  3. Grants entitlements for every package item.
  4. Creates the purchase and enrollment records.
  5. Loads the package's pay plan stack and runs every module that handles PackagePurchased / EnrollmentCreated events.
  6. Persists all events + ledger entries.

All steps 1–6 commit in a single Postgres transaction (or roll back together if anything fails).


Architecture

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

Dependency rule

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).

Module contract

pubtraitModule:Send + Sync{fnkey(&self) -> &'staticstr;// e.g. "royal.flushline"fnversion(&self) -> &'staticstr;// e.g. "1.0.0"fnhandles(&self) -> &'static[EventType];// which events trigger this modulefnrun(&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.

Engine cascading loop

When a purchase is made, the engine:

  1. Builds the initial events (PackagePurchased, EnrollmentCreated).
  2. Runs every module that handles the event in stack sort_order.
  3. Captures emitted events + ledger entries + state changes.
  4. Re-runs the stack against each newly-emitted event (cascades).
  5. Caps at 32 iterations to prevent runaway loops.
  6. Calls ModuleProjector and EventProjector to materialise the per-module relational tables and event-derived rows (duplication, pairing, cycle count, pot balances).
  7. Commits all writes in one Postgres transaction.

Projection layer

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 — emitted DomainEvents → rows that can't be reconstructed from a single aggregate's state: duplication (new enrollment + flushline account), pairing results, binary_nodes.cycle_count increments, and cumulative royal_pot_bonus_balances upserts.

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).

Built-in modules

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.

Development

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 /health

Layout

crates/
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

Testing

  • 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 the integration feature flag. Run them with a real Postgres:
make test
make test-integration
make test-ui
make ui-build
make ui-baseline

The payplan_web integration suite is the auth HTTP-level suite (8 tests covering login, refresh-rotation, logout-revocation, role gating).

Testing the admin UI

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:

  1. Dashboard and package data are present on direct page load.
  2. The mobile-menu button changes from “Open menu” to “Close menu.”
  3. Search and pagination work as normal GET requests.
  4. Company/catalog/billing forms submit and redirect back to their SSR list.
  5. /api/packages still 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-baseline

CI

GitHub Actions runs fmt --check, clippy -- -D warnings, cargo test, and the integration suite against a postgres:16 service container on every PR.


License

UNLICENSED — internal project.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

PayPlan Platform v2

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.


Status

  • ✅ 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 both PgPurchaseWriter (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_jti store, 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.


Quickstart

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 serve

Open 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"}

HTTP API

All endpoints accept/return JSON. Errors come back as {"message": "..."}. Authenticated endpoints expect Authorization: Bearer <access_token>.

MethodPathAuth tierPurpose
GET/healthpublicLiveness + module inventory
POST/api/auth/loginpublicEmail + password → access + refresh pair
POST/api/auth/refreshpublicRotate refresh (single-use), returns new pair
POST/api/auth/logoutauthenticatedRevoke the presented token's jti
POST/api/userspublicSelf-service signup (role forced to user)
POST/api/companiescompany_admin+Create a company
POST/api/catalog_itemscompany_admin+Create a product or service
POST/api/billing_planscompany_admin+Attach one-time or recurring pricing
POST/api/packagescompany_admin+Bundle catalog items + assign a pay plan stack
GET/api/packagesauthenticatedList all packages (across all companies)
POST/api/purchasesauthenticatedRun the full purchase flow (atomic transaction)
POST/admin/jobs/renewals/runplatform_adminManual renewal job
POST/admin/jobs/royal_pot_distribution/runplatform_adminManual Royal Flush weekly pot distribution
POST/admin/jobs/binary_cycle_close/runplatform_adminManual Binary cycle close

Create a company

curl -X POST http://127.0.0.1:3000/api/companies \
-H 'Content-Type: application/json' \
-d '{"name":"Acme MLM","slug":"acme"}'

Register a user

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.

Log in

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.

Purchase a package

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:

  1. Validates the package and billing plans.
  2. Creates subscription(s) for any recurring items.
  3. Grants entitlements for every package item.
  4. Creates the purchase and enrollment records.
  5. Loads the package's pay plan stack and runs every module that handles PackagePurchased / EnrollmentCreated events.
  6. Persists all events + ledger entries.

All steps 1–6 commit in a single Postgres transaction (or roll back together if anything fails).


Architecture

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

Dependency rule

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).

Module contract

pubtraitModule:Send + Sync{fnkey(&self) -> &'staticstr;// e.g. "royal.flushline"fnversion(&self) -> &'staticstr;// e.g. "1.0.0"fnhandles(&self) -> &'static[EventType];// which events trigger this modulefnrun(&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.

Engine cascading loop

When a purchase is made, the engine:

  1. Builds the initial events (PackagePurchased, EnrollmentCreated).
  2. Runs every module that handles the event in stack sort_order.
  3. Captures emitted events + ledger entries + state changes.
  4. Re-runs the stack against each newly-emitted event (cascades).
  5. Caps at 32 iterations to prevent runaway loops.
  6. Calls ModuleProjector and EventProjector to materialise the per-module relational tables and event-derived rows (duplication, pairing, cycle count, pot balances).
  7. Commits all writes in one Postgres transaction.

Projection layer

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 — emitted DomainEvents → rows that can't be reconstructed from a single aggregate's state: duplication (new enrollment + flushline account), pairing results, binary_nodes.cycle_count increments, and cumulative royal_pot_bonus_balances upserts.

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).

Built-in modules

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.

Development

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 /health

Layout

crates/
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

Testing

  • 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 the integration feature flag. Run them with a real Postgres:
make test
make test-integration
make test-ui
make ui-build
make ui-baseline

The payplan_web integration suite is the auth HTTP-level suite (8 tests covering login, refresh-rotation, logout-revocation, role gating).

Testing the admin UI

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:

  1. Dashboard and package data are present on direct page load.
  2. The mobile-menu button changes from “Open menu” to “Close menu.”
  3. Search and pagination work as normal GET requests.
  4. Company/catalog/billing forms submit and redirect back to their SSR list.
  5. /api/packages still 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-baseline

CI

GitHub Actions runs fmt --check, clippy -- -D warnings, cargo test, and the integration suite against a postgres:16 service container on every PR.


License

UNLICENSED — internal project.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

PayPlan Platform v2

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.


Status

  • ✅ 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 both PgPurchaseWriter (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_jti store, 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.


Quickstart

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 serve

Open 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"}

HTTP API

All endpoints accept/return JSON. Errors come back as {"message": "..."}. Authenticated endpoints expect Authorization: Bearer <access_token>.

MethodPathAuth tierPurpose
GET/healthpublicLiveness + module inventory
POST/api/auth/loginpublicEmail + password → access + refresh pair
POST/api/auth/refreshpublicRotate refresh (single-use), returns new pair
POST/api/auth/logoutauthenticatedRevoke the presented token's jti
POST/api/userspublicSelf-service signup (role forced to user)
POST/api/companiescompany_admin+Create a company
POST/api/catalog_itemscompany_admin+Create a product or service
POST/api/billing_planscompany_admin+Attach one-time or recurring pricing
POST/api/packagescompany_admin+Bundle catalog items + assign a pay plan stack
GET/api/packagesauthenticatedList all packages (across all companies)
POST/api/purchasesauthenticatedRun the full purchase flow (atomic transaction)
POST/admin/jobs/renewals/runplatform_adminManual renewal job
POST/admin/jobs/royal_pot_distribution/runplatform_adminManual Royal Flush weekly pot distribution
POST/admin/jobs/binary_cycle_close/runplatform_adminManual Binary cycle close

Create a company

curl -X POST http://127.0.0.1:3000/api/companies \
-H 'Content-Type: application/json' \
-d '{"name":"Acme MLM","slug":"acme"}'

Register a user

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.

Log in

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.

Purchase a package

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:

  1. Validates the package and billing plans.
  2. Creates subscription(s) for any recurring items.
  3. Grants entitlements for every package item.
  4. Creates the purchase and enrollment records.
  5. Loads the package's pay plan stack and runs every module that handles PackagePurchased / EnrollmentCreated events.
  6. Persists all events + ledger entries.

All steps 1–6 commit in a single Postgres transaction (or roll back together if anything fails).


Architecture

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

Dependency rule

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).

Module contract

pubtraitModule:Send + Sync{fnkey(&self) -> &'staticstr;// e.g. "royal.flushline"fnversion(&self) -> &'staticstr;// e.g. "1.0.0"fnhandles(&self) -> &'static[EventType];// which events trigger this modulefnrun(&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.

Engine cascading loop

When a purchase is made, the engine:

  1. Builds the initial events (PackagePurchased, EnrollmentCreated).
  2. Runs every module that handles the event in stack sort_order.
  3. Captures emitted events + ledger entries + state changes.
  4. Re-runs the stack against each newly-emitted event (cascades).
  5. Caps at 32 iterations to prevent runaway loops.
  6. Calls ModuleProjector and EventProjector to materialise the per-module relational tables and event-derived rows (duplication, pairing, cycle count, pot balances).
  7. Commits all writes in one Postgres transaction.

Projection layer

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 — emitted DomainEvents → rows that can't be reconstructed from a single aggregate's state: duplication (new enrollment + flushline account), pairing results, binary_nodes.cycle_count increments, and cumulative royal_pot_bonus_balances upserts.

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).

Built-in modules

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.

Development

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 /health

Layout

crates/
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

Testing

  • 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 the integration feature flag. Run them with a real Postgres:
make test
make test-integration
make test-ui
make ui-build
make ui-baseline

The payplan_web integration suite is the auth HTTP-level suite (8 tests covering login, refresh-rotation, logout-revocation, role gating).

Testing the admin UI

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:

  1. Dashboard and package data are present on direct page load.
  2. The mobile-menu button changes from “Open menu” to “Close menu.”
  3. Search and pagination work as normal GET requests.
  4. Company/catalog/billing forms submit and redirect back to their SSR list.
  5. /api/packages still 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-baseline

CI

GitHub Actions runs fmt --check, clippy -- -D warnings, cargo test, and the integration suite against a postgres:16 service container on every PR.


License

UNLICENSED — internal project.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages