anansi — Payouts
@playpals/anansi · port 4005 · apps/anansi
Anansi represents intelligence, balance, and the web of interconnected truths — fitting for an accounting service that manages complex financial relationships.
Calculates creator earnings, writes the double-entry ledger, and executes Stripe Connect transfers on a two-phase monthly cycle. One replica, and that is deliberate.
Running it
pnpm --filter @playpals/anansi dev
pnpm --filter @playpals/anansi test
curl http://localhost:4005/healthThe monthly cycle
Two cron jobs, both UTC (apps/anansi/src/jobs/monthlyPayout.ts):
| Schedule | Cron | Job |
|---|---|---|
| 25th, 02:00 UTC | 0 2 25 * * | calculateAndTopUp |
| 1st, 06:00 UTC | 0 6 1 * * | executeTransfers |
Two more keep the coin side in step (src/jobs/maturation.ts, src/jobs/reconciliation.ts):
| Schedule | Cron | Job |
|---|---|---|
| Hourly at :15 | 15 * * * * | runMaturation: earnings past their hold become cashable |
| Daily, 04:30 UTC | 30 4 * * * | runReconciliation: ledger checks plus Stripe transfers, stored as ReconciliationRun |
The gap between them exists because moving money from the PlayPalz bank account into the Stripe platform balance takes days to clear. Phase 1 initiates the top-up early so the funds are settled when Phase 2 pays creators.
Phase 1 — calculate and top up
- Acquire a Redis distributed lock keyed on the period (
calc:2026-01). - Idempotency check — if any
pendingorcompletedpayout already exists in the period, skip the whole run. getEligibleCreators()— users withpayoutsEnabled = trueand astripeAccountId.- Calculate each creator's earnings, in batches of 10, each inside its own transaction.
- Write
Payoutrows (status: pending) plus matchingLedgerentries atomically. stripe.topups.createfor the total net amount, split into top-ups of at most $50,000 (Stripe's limit).
Phase 2 — execute transfers
- Fetch
Payoutrows withstatus = pendingfor the prior period. stripe.transfers.createper creator, to their connected account.- Update each row to
completed(withstripeTransferId) orfailed.
Failures are isolated — one rejected transfer does not abort the run.
Earnings formula
apps/anansi/src/services/earnings.ts:
netCents = subscriptionCents + sessionCents + coinCashoutCents
subscriptionCents = Σ netCents of AVAILABLE VIP pass (and legacy VIP) CreatorEarning rows
sessionCents = Σ netCents of AVAILABLE session / e-date CreatorEarning rows
coinCashoutCents = Σ CoinCashout.cents of the creator's requested cash-outsEverything is already net of fees: each CreatorEarning carries its store and platform fee, and coin cash-outs pay none (the take is in the coin payout rate). The write step attaches both (scheduleCreatorEarnings, scheduleCashouts) and stores the split on the payout (subscriptionCents, sessionCents, coinCents). After a successful transfer the earnings are PAID (settleCreatorEarnings). The hourly maturation job also releases USD earnings past their holding period (matureCreatorEarnings). See Monetization.
A creator is skipped unless payoutsEnabled, stripeAccountId is set, and netCents >= MIN_PAYOUT_CENTS.
Why one replica
Both phases move real money. The Redis lock makes concurrent runs safe, but it is the only thing preventing duplicate Stripe transfers:
const locked = await acquireLock(lockKey, config.lockTtlMs);
if (!locked) throw new Error(`Calculation already in progress for period ${periodKey}`);Defence in depth: the lock, the pre-run idempotency check on existing Payout rows, and the unique idempotencyKey on Ledger. Do not raise replicas without understanding all three.
Admin panel
The admin panel's Payouts page (/payouts) is the normal way in. It lists a month's payouts and the Stripe balance, previews a calculate run, starts either phase for a month, and retries failed transfers. The Jobs page shows anansi's jobs (calculate, transfers, retries, maturation, reconciliation) with their health and last 50 runs, and runs maturation or reconciliation on demand.
The panel goes through the API (/admin/payouts/*, audited as payouts.*), which calls the admin API below with ANANSI_ADMIN_SECRET, the same value as anansi's ADMIN_SECRET. anansi records every run, scheduled or manual, in Redis at jobs:runs:anansi:<job> (src/lib/jobRuns.ts), in the same format as the API's jobs, so the API lists them without asking anansi.
Admin API
Every route requires Authorization: Bearer <ADMIN_SECRET>.
| Method | Path | Purpose |
|---|---|---|
GET | /health | Health check (no auth) |
POST | /admin/jobs/:job/run | Start a job in the background; 202, the run lands in the history. job is payout-calculate, payout-transfer, maturation or reconciliation. Body { period?: "YYYY-MM", creatorIds?: [] } (period defaults to the cron's; creatorIds for calculate only). 409 if that phase's lock is held |
GET | /admin/payout/preview | Dry run — no DB writes, no Stripe calls |
POST | /admin/payout/calculate | Trigger Phase 1 and wait (not recorded in the history) |
POST | /admin/payout/transfer | Trigger Phase 2 and wait (not recorded in the history) |
GET | /admin/payout/status | Payout records and summary for a period |
POST | /admin/payout/retry | Re-attempt failed transfers; recorded as payout-retry |
GET | /admin/creators/:id/earnings | Preview one creator |
Without the panel (port-forward first: kubectl port-forward svc/playpalz-anansi 4005:4005):
curl -H "Authorization: Bearer $ADMIN_SECRET" \
"http://localhost:4005/admin/payout/preview?periodStart=2026-10-01T00:00:00.000Z&periodEnd=2026-10-31T23:59:59.999Z"
curl -X POST -H "Authorization: Bearer $ADMIN_SECRET" -H "Content-Type: application/json" \
-d '{"period":"2026-10"}' http://localhost:4005/admin/jobs/payout-calculate/runAlways preview before a manual calculate.
Source layout
src/
├── server.ts HTTP server + cron registration
├── app.ts Express app
├── config/index.ts typed env config
├── jobs/monthlyPayout.ts the two payout crons
├── jobs/maturation.ts hourly earnings maturation
├── jobs/reconciliation.ts daily reconciliation
├── services/
│ ├── jobs.ts every job, for the crons and POST /admin/jobs/:job/run
│ ├── earnings.ts per-creator earnings calculation
│ ├── payoutRun.ts calculateAndTopUp / executeTransfers
│ ├── stripeTransfer.ts Stripe topups and transfers
│ ├── maturation.ts matures due earning lots per user
│ └── reconciliation.ts ledger + Stripe transfer checks
├── controllers/admin.ts
├── routers/adminRoutes.ts
├── middleware/adminAuth.ts bearer token guard
├── lib/ logger, redis + lock helpers, stripe, errors, jobRuns (run history)
└── types/payout.tsConfiguration
| Variable | Default | Notes |
|---|---|---|
PORT | 4005 | |
DATABASE_URL | — | Required |
REDIS_HOST / REDIS_PORT / REDIS_PASSWORD | localhost | Distributed lock |
STRIPE_SECRET_KEY | "" | Platform account |
PLATFORM_FEE_PCT | 20 | Integer percent. anansi no longer applies it (earnings arrive net); keep it equal to the api's |
MIN_PAYOUT_CENTS | 100 | $1.00 |
ADMIN_SECRET | "" | Bearer token for /admin. The API's ANANSI_ADMIN_SECRET must match |
PAYOUT_LOCK_TTL_MS | 3600000 | Lock TTL |
ADMIN_SECRET defaults to an empty string
An empty token is always rejected, so an unset secret locks the admin API (and the admin panel's Payouts page) rather than opening it. Set it from a real secret in every deployed environment: ADMIN_SECRET in anansi-secrets, which the API also reads.
Deployment
| Property | Value |
|---|---|
| Helm values | infra/helm/values/production/anansi.yaml (release playpalz-anansi) |
| Replicas | 1 — do not increase casually |
| Probes | Liveness and readiness on /health |
| Ingress | None — cluster-internal only |
./scripts/build-anansi.sh [tag]Operating
Start with the admin panel: Jobs for whether the 25th and the 1st ran and what they returned, Payouts for the month's payouts and anything failed.
kubectl logs -l app=playpalz-anansi --tail=200
kubectl logs -l app=playpalz-anansi --since=48h | grep '"job":"payout-'A calculate run is safe to repeat: each earning and cash-out is locked and attached to one payout only, and an interrupted run leaves nothing half-attached. Never edit existing ledger rows; record ADJUSTMENT entries instead. Escalation steps are in Runbooks.
