Skip to content

feat(payout): M5-T4 auto weekly payouts + lumaline connect + branded emails#32

Merged
JaamesBond merged 15 commits into
mainfrom
feat/m5-t4-auto-payout
Jul 4, 2026
Merged

feat(payout): M5-T4 auto weekly payouts + lumaline connect + branded emails#32
JaamesBond merged 15 commits into
mainfrom
feat/m5-t4-auto-payout

Conversation

@JaamesBond

Copy link
Copy Markdown
Owner

What & why (M5-T4 — the payout half of go-live)

Publishers get paid without the owner deciding when. Adds the two missing self-serve pieces on top of the already-proven payout rail (transfer/confirm/reconcile, real €30 EEA transfer proven in M3):

  • lumaline connect — surfaces the existing hosted Stripe onboarding so a publisher self-adds their IBAN.
  • Weekly auto-payoutpg_cron → app.run_payout() → /payout/batch (Mon 09:00 UTC), twin of the live monitor cron. €1 minimum (LUMALINE_PAYOUT_MIN_MICROS, env-tunable).
  • Branded emails (Resend) — a "💸 you got paid" confirmation and a "€X waiting — lumaline connect" nudge to un-onboarded publishers. Best-effort.

Money-safety (non-negotiable, all preserved)

  • The transfer/confirm money core is byte-identical (opus-verified): per-payout idempotency key + UNIQUE(stripe_transfer_id) + one-active-payout guard + ambiguous-error self-heal. No double-pay; house/un-onboarded never paid.
  • Cron auth is fail-closed: x-lumaline-cron-secret constant-time-compared to LUMALINE_CRON_SECRET; empty/unset never authorizes.
  • Emails are best-effort: bounded timeout, never throw, run after the money loop — a Resend outage can never block, reverse, or fail a payout.
  • New RPCs are SECURITY DEFINER + search_path='' + REVOKE anon/authenticated + GRANT service_role.

Built subagent-driven (6 TDD tasks), reviewed hard — two real bugs caught + fixed

  • Per-task reviews (spec + quality) on every task; opus whole-branch review; a live REST probe by the controller.
  • Important (array-collapse): serviceRpc unwraps any array to its first element, so the set-returning payout_nudge_candidates produced dead-code nudges. Fixed with serviceRpcRows (no unwrap) + response counts.
  • Critical (wrong schema): the 3 fn-called RPCs were created in app, but serviceRpc calls PostgREST which only resolves public → they 404'd at runtime (whole notify pass silently dead). The live REST probe caught what the psql test + 8 reviews missed. Fixed by moving them to public (run_payout stays app, it's SQL-called).
  • Re-review: both CLOSED, approved, money core untouched.

Tests

  • Unit node --test: 253/253. Auto-payout integration (SQL nudge/dedup/onboarded-exclusion + cron-secret auth) 7/7 on the local stack. Live REST probe: all 3 RPCs return JSON arrays (not PGRST202).

Deploy = owner-gated

Nothing is deployed by this PR. The ref-guarded, per-step-owner-GO sequence is in docs/ops/m5-t4-deploy.md (migration → fn env → redeploy → weekly cron.schedule → smoke → npm publish v0.1.4 → announce) + rollback. First real auto-payout fires once a publisher's matured (7-day-held) payable crosses €1.

Fast-follows (non-blocking, logged)

postJson/postJsonGet dedup; sendEmail error-bucket precision; payout_nudge_candidates double-evaluates payable; SQL test asserts anon only (revoke covers authenticated).

🤖 Generated with Claude Code

JaamesBond and others added 15 commits July 4, 2026 16:49
…randed emails

Basic self-serve publisher payout system (M5-T4): lumaline connect surfaces the existing hosted
Stripe onboarding; weekly pg_cron -> /payout/batch (cron-secret auth mirroring the live monitor);
EUR 1 min (env-tunable); branded Resend paid + connect-nudge HTML emails. Reuses the proven
transfer/confirm/reconcile core untouched. Future (deferred): graphical publisher/advertiser
portals + owner dashboard + on-demand withdraw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…branded emails)

6 tasks: (1) lumaline connect + config + copy; (2) branded Resend email builders + sender;
(3) pure cron-secret compare + min parse; (4) migration nudge column + contact/nudge RPCs +
run_payout; (5) wire stripe-connect cron-auth + €1 min + post-loop emails; (6) e2e proof +
adversarial review + owner-gated deploy. Money core untouched; TDD; best-effort emails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… payout copy

Fix now=Date.now (function ref, not invoked) that broke getValidAccessToken's
numeric now-vs-exp arithmetic; matches the Date.now() pattern login/earnings/
logout already use in this file.
…copy + tests

connect() called postJson() for /connect/onboard unguarded, so a thrown fetch
rejection (timeout/DNS/abort) escaped as a raw unhandled-rejection stack trace
instead of the graceful message it already prints for a non-ok response.
Wrap that one call in try/catch (postJsonGet's sibling pattern), leaving the
shared postJson helper (used by login/earnings) untouched.

Also updates bin/lumaline.mjs help() copy, which still said payouts begin
"only at the production go-live" — now points at `lumaline connect`.

Adds two tests to test/client-connect.test.mjs: onboard fetch throws -> graceful
message, no throw; and not-logged-in -> "Not logged in", fetch never called.
…t-effort Resend sender

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gs + test

Add a 10s timeout (AbortSignal.timeout) to prevent hung requests blocking the
payout flow, and default the params destructuring to {} so sendEmail() with no
arguments returns failed:not_configured instead of throwing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…paid/nudge emails

Wires Task 3/2/4's helpers into the money path: /payout/batch now accepts
either an admin JWT or a constant-time-compared pg_cron secret
(x-lumaline-cron-secret), passes an env-driven €1 minimum into
payout_batch_reserve, and — strictly after the transfer/confirm loop —
sends best-effort paid confirmations and connect-nudge emails via Resend,
never blocking or reversing a payout on email failure.

Also adds a JSDoc param type to email.mjs's sendEmail so `deno check`
can type its destructured object param (previously untyped, first
exposed by these new .ts call sites).
Bump lumaline 0.1.3->0.1.4 for the new `lumaline connect` command. Add docs/ops/m5-t4-deploy.md:
ref-guarded, per-step-owner-GO sequence (migration -> fn env -> redeploy -> weekly cron.schedule ->
smoke -> npm publish -> announce) + rollback + money-safety notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds test/auto-payout-nudge.integration.mjs proving app.payout_nudge_candidates
and app.mark_connect_nudged (20260704150000_auto_payout.sql) against the local
Supabase DB: un-onboarded over-min publisher surfaces with correct contact info,
nudging sets connect_nudge_at and dedupes the next candidates call, and an
onboarded publisher is never a candidate regardless of balance. Stripe-free,
psql-driven, hermetic (fresh UUIDs, full FK-safe teardown).
…ys) + response counts + min>=1

serviceRpc() unconditionally unwraps any array response to its first element, which is
correct for its single-row callers but silently turned app.payout_nudge_candidates' JSON
array of candidates into null/one-row — so the connect-nudge pass never sent an email and
never called mark_connect_nudged. Add a sibling serviceRpcRows() (no unwrap) for
SET-returning RPCs and route payout_nudge_candidates through it; serviceRpc itself and its
other 10 call sites are untouched.

Also surface nudge_candidates/nudged counters in the /payout/batch response (initialized
outside the notify-pass try/catch so they're always present), and fix payoutMinMicros to
reject fractional-under-1 env values (e.g. '0.5') instead of Math.floor-ing them to 0.

Adds test/auto-payout-nudge-fn.integration.mjs, which proves the fix through the served
stripe-connect fn (not psql): asserts nudge_candidates >= 1 in a live /payout/batch
response, gated by a dry-run pre-check that skips the live call entirely if any reservable
onboarded publisher already exists (never risks a real Stripe transfer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ST only resolves public); run_payout stays app

publisher_contact, payout_nudge_candidates, and mark_connect_nudged are invoked by the
stripe-connect edge fn via serviceRpc/serviceRpcRows, which POST to PostgREST's
/rest/v1/rpc/<name>. PostgREST only resolves the public schema, so all three 404'd with
PGRST202 at runtime and the paid/nudge email notify pass was silently dead. Move the three
function definitions (and their grants) to public, matching every other REST-called money RPC.
run_payout is a pg_cron target invoked via plain SQL (select app.run_payout()), not REST, so it
stays in app, as does publisher_payable_micros which the moved functions still call into.

Added a defensive drop-if-exists for the app-schema versions so re-applying the migration cleans
up any previously-created misplaced functions. Updated the two integration test files to assert
against public for the moved functions.

Verified locally: POST /rest/v1/rpc/payout_nudge_candidates now returns [] instead of PGRST202;
all 7 tests in auto-payout-sql/nudge/payout-logic pass.
… app (post schema fix)

The three REST-called RPCs must be in public (serviceRpc → PostgREST resolves public only); an app.*
copy 404s and silently kills the notify pass. Stops an operator re-creating the app.* versions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JaamesBond JaamesBond merged commit 369bd47 into main Jul 4, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant