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

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.

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:

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:

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 API

Every route requires Authorization: Bearer <ADMIN_SECRET>.

MethodPathPurpose
GET/healthHealth check (no auth)
GET/admin/payout/previewDry run — no DB writes, no Stripe calls
POST/admin/payout/calculateTrigger Phase 1
POST/admin/payout/transferTrigger Phase 2
GET/admin/payout/statusPayout records and summary for a period
POST/admin/payout/retryRe-attempt failed transfers
GET/admin/creators/:id/earningsPreview one creator
bash
curl -H "Authorization: Bearer $ADMIN_SECRET" \
  https://anansi.internal/admin/payout/preview

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

Configuration

VariableDefaultNotes
PORT4005
DATABASE_URLRequired
REDIS_HOST / REDIS_PORT / REDIS_PASSWORDlocalhostDistributed lock
STRIPE_SECRET_KEY""Platform account
PLATFORM_FEE_PCT20Integer percent
MIN_PAYOUT_CENTS100$1.00
COST_PER_COIN0.02USD per Play Coin
ADMIN_SECRET""Bearer token for /admin
PAYOUT_LOCK_TTL_MS3600000Lock 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

PropertyValue
Manifestinfra/k8s/anansi-deployment.yaml
Replicas1 — do not increase casually
ProbesLiveness and readiness on /health
IngressNone — cluster-internal only
bash
./scripts/build-anansi.sh [tag]

Operating

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

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

Internal documentation — PlayPalz platform