feat(payout): M5-T4 auto weekly payouts + lumaline connect + branded emails#32
Merged
Conversation
…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>
…Cs + run_payout cron target
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.pg_cron → app.run_payout() → /payout/batch(Mon 09:00 UTC), twin of the live monitor cron. €1 minimum (LUMALINE_PAYOUT_MIN_MICROS, env-tunable).lumaline connect" nudge to un-onboarded publishers. Best-effort.Money-safety (non-negotiable, all preserved)
UNIQUE(stripe_transfer_id)+ one-active-payout guard + ambiguous-error self-heal. No double-pay; house/un-onboarded never paid.x-lumaline-cron-secretconstant-time-compared toLUMALINE_CRON_SECRET; empty/unset never authorizes.search_path=''+ REVOKE anon/authenticated + GRANT service_role.Built subagent-driven (6 TDD tasks), reviewed hard — two real bugs caught + fixed
serviceRpcunwraps any array to its first element, so the set-returningpayout_nudge_candidatesproduced dead-code nudges. Fixed withserviceRpcRows(no unwrap) + response counts.app, butserviceRpccalls PostgREST which only resolvespublic→ 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 topublic(run_payoutstaysapp, it's SQL-called).Tests
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 → weeklycron.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/postJsonGetdedup;sendEmailerror-bucket precision;payout_nudge_candidatesdouble-evaluates payable; SQL test assertsanononly (revoke coversauthenticated).🤖 Generated with Claude Code