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 |
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.
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:
subscriptionCents = activeSubscribers × creator.monthlyPrice
coinGiftCents = Σ Ledger.usdDelta where type = CREATOR_REDEEM
and createdAt ∈ [periodStart, periodEnd)
grossCents = subscriptionCents + coinGiftCents
netCents = grossCents × (1 − PLATFORM_FEE_PCT / 100)A creator is skipped unless payoutsEnabled, stripeAccountId is set, and netCents >= MIN_PAYOUT_CENTS.
Subscription revenue is estimated, not settled
It is derived from the current count of active subscribers times the creator's current price, not from actual RevenueCat transactions in the period. A mid-period churn, a mid-period price change, or a failed renewal all produce a wrong number. Acceptable at current volume; replace with ledger-derived revenue before it matters. See Monetization.
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 API
Every route requires Authorization: Bearer <ADMIN_SECRET>.
| Method | Path | Purpose |
|---|---|---|
GET | /health | Health check (no auth) |
GET | /admin/payout/preview | Dry run — no DB writes, no Stripe calls |
POST | /admin/payout/calculate | Trigger Phase 1 |
POST | /admin/payout/transfer | Trigger Phase 2 |
GET | /admin/payout/status | Payout records and summary for a period |
POST | /admin/payout/retry | Re-attempt failed transfers |
GET | /admin/creators/:id/earnings | Preview one creator |
curl -H "Authorization: Bearer $ADMIN_SECRET" \
https://anansi.internal/admin/payout/previewAlways 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 cron definitions
├── services/
│ ├── earnings.ts per-creator earnings calculation
│ ├── payoutRun.ts calculateAndTopUp / executeTransfers
│ └── stripeTransfer.ts Stripe topups and transfers
├── controllers/admin.ts
├── routers/adminRoutes.ts
├── middleware/adminAuth.ts bearer token guard
├── lib/ logger, redis + lock helpers, stripe, errors
├── types/payout.ts
└── constants/conversion.ts COIN_VALUE_USDConfiguration
| 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 |
MIN_PAYOUT_CENTS | 100 | $1.00 |
COST_PER_COIN | 0.02 | USD per Play Coin |
ADMIN_SECRET | "" | Bearer token for /admin |
PAYOUT_LOCK_TTL_MS | 3600000 | Lock TTL |
ADMIN_SECRET defaults to an empty string
An empty secret means the bearer check compares against "". Verify it is set from a real secret in every deployed environment before this service handles live money.
Deployment
| Property | Value |
|---|---|
| Manifest | infra/k8s/anansi-deployment.yaml |
| Replicas | 1 — do not increase casually |
| Probes | Liveness and readiness on /health |
| Ingress | None — cluster-internal only |
./scripts/build-anansi.sh [tag]Operating
kubectl logs -l app=playpalz-anansi --tail=200
# Did the 25th run?
kubectl logs -l app=playpalz-anansi --since=48h | grep "25th cron"
# Any failed payouts?
curl -H "Authorization: Bearer $ADMIN_SECRET" \
http://localhost:4005/admin/payout/status?period=2026-01If a run is interrupted mid-way, the idempotency check prevents a clean re-run of Phase 1 — existing pending rows cause the whole run to skip. Inspect and reconcile the partial state before re-triggering, and add ADJUSTMENT ledger entries rather than editing existing rows. Escalation steps are in Runbooks.
