Skip to content

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 ​

bash
pnpm --filter @playpals/anansi dev
pnpm --filter @playpals/anansi test
curl http://localhost:4005/health

The monthly cycle ​

Rendering diagram…

Two cron jobs, both UTC (apps/anansi/src/jobs/monthlyPayout.ts):

ScheduleCronJob
25th, 02:00 UTC0 2 25 * *calculateAndTopUp
1st, 06:00 UTC0 6 1 * *executeTransfers

Two more keep the coin side in step (src/jobs/maturation.ts, src/jobs/reconciliation.ts):

ScheduleCronJob
Hourly at :1515 * * * *runMaturation: earnings past their hold become cashable
Daily, 04:30 UTC30 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 ​

  1. Acquire a Redis distributed lock keyed on the period (calc:2026-01).
  2. Idempotency check — if any pending or completed payout already exists in the period, skip the whole run.
  3. getEligibleCreators() — users with payoutsEnabled = true and a stripeAccountId.
  4. Calculate each creator's earnings, in batches of 10, each inside its own transaction.
  5. Write Payout rows (status: pending) plus matching Ledger entries atomically.
  6. stripe.topups.create for the total net amount, split into top-ups of at most $50,000 (Stripe's limit).

Phase 2 — execute transfers ​

  1. Fetch Payout rows with status = pending for the prior period.
  2. stripe.transfers.create per creator, to their connected account.
  3. Update each row to completed (with stripeTransferId) or failed.

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-outs

Everything 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:

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

MethodPathPurpose
GET/healthHealth check (no auth)
POST/admin/jobs/:job/runStart 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/previewDry run — no DB writes, no Stripe calls
POST/admin/payout/calculateTrigger Phase 1 and wait (not recorded in the history)
POST/admin/payout/transferTrigger Phase 2 and wait (not recorded in the history)
GET/admin/payout/statusPayout records and summary for a period
POST/admin/payout/retryRe-attempt failed transfers; recorded as payout-retry
GET/admin/creators/:id/earningsPreview one creator

Without the panel (port-forward first: kubectl port-forward svc/playpalz-anansi 4005:4005):

bash
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/run

Always 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.ts

Configuration ​

VariableDefaultNotes
PORT4005
DATABASE_URL—Required
REDIS_HOST / REDIS_PORT / REDIS_PASSWORDlocalhostDistributed lock
STRIPE_SECRET_KEY""Platform account
PLATFORM_FEE_PCT20Integer percent. anansi no longer applies it (earnings arrive net); keep it equal to the api's
MIN_PAYOUT_CENTS100$1.00
ADMIN_SECRET""Bearer token for /admin. The API's ANANSI_ADMIN_SECRET must match
PAYOUT_LOCK_TTL_MS3600000Lock 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 ​

PropertyValue
Helm valuesinfra/helm/values/production/anansi.yaml (release playpalz-anansi)
Replicas1 — do not increase casually
ProbesLiveness and readiness on /health
IngressNone — cluster-internal only
bash
./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.

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

Internal documentation — PlayPalz platform