From 1bcd4c59d3afdfc94195c928cf44bf818512373f Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 16:49:15 +0200 Subject: [PATCH 01/14] =?UTF-8?q?docs(m5-t4):=20design=20spec=20=E2=80=94?= =?UTF-8?q?=20auto=20weekly=20payouts=20+=20lumaline=20connect=20+=20brand?= =?UTF-8?q?ed=20emails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...2026-07-04-publisher-auto-payout-design.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-04-publisher-auto-payout-design.md diff --git a/docs/superpowers/specs/2026-07-04-publisher-auto-payout-design.md b/docs/superpowers/specs/2026-07-04-publisher-auto-payout-design.md new file mode 100644 index 0000000..0a52b12 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-publisher-auto-payout-design.md @@ -0,0 +1,173 @@ +# Publisher auto-payout + `lumaline connect` — design + +**Date:** 2026-07-04 +**Status:** approved (owner), pre-implementation +**Milestone:** M5-T4 (first self-serve payout system) → feeds M6/T8 + +## Goal + +Publishers get paid **without the owner deciding when**. Today the payout rail exists and is +proven, but the only trigger is a hand-run admin batch and there is no publisher-facing surface. +This adds the two missing pieces for a basic-but-proper system: + +1. **`lumaline connect`** — a client command so a publisher self-onboards their bank (IBAN) through + Stripe's existing hosted flow. +2. **Automatic weekly payouts** — a pg_cron job runs the existing payout batch on a schedule; money + flows to every eligible, onboarded publisher with no human in the loop. + +Plus branded email notifications (paid confirmation + connect nudge). + +## Non-goals (explicitly deferred — see "Future work") + +- **On-demand withdraw** (`lumaline withdraw` / `/payout/self`). Owner deferred; scheduled-only for now. +- **Graphical publisher/advertiser portals** and an **owner dashboard** (see Future work). +- Changing the transfer/confirm/reconcile money core — it is proven and stays untouched. + +## Decisions (owner-approved) + +| Decision | Value | +|---|---| +| Payout minimum | **€1** (`LUMALINE_PAYOUT_MIN_MICROS=1000000`, env-tunable, no migration to change) | +| Cadence | **Weekly, Monday 09:00 UTC** (`0 9 * * 1`) | +| Notifications | **Both** — paid confirmation + connect-nudge — as **branded HTML** emails (Resend) | +| Trigger auth | pg_cron → Vault `lumaline_cron_secret` → fn cron-secret path (mirrors the live monitor) | + +## What already exists (reused, not rebuilt) + +- `stripe-connect` fn: `POST /connect/onboard` (publisher-token authed; creates an Express account + + hosted account link, EEA country gate, returns `onboarding_url`), `GET /connect/status`, + `POST /payout/batch` (admin; `payout_batch_reserve` → per-payout Stripe transfer with + `payoutIdemKey(po.id)` + `UNIQUE(stripe_transfer_id)` + ambiguous-error self-heal → `payout_confirm`), + `GET /reconcile`, `POST /webhook`. +- `public.payout_batch_reserve(p_hold, p_min_micros default 25000000, ...)` — reserves one `pending` + payout per publisher where `stripe_account_id IS NOT NULL` **AND** `payable ≥ p_min_micros`. So + **un-onboarded and sub-minimum publishers are already excluded** by construction. +- `app.publisher_payable_micros(id, hold)` — matured (7-day-held) earnings − already-paid, cent-floored. +- The **monitor cron** (`app.run_monitor()` reads Vault `lumaline_cron_secret` → `net.http_post` the + edge fn; `cron.schedule`d by the controller at deploy; no-op if Vault/pg_net absent) — the exact + template this design mirrors. +- Resend email (monitor already POSTs `https://api.resend.com/emails`; env `RESEND_API_KEY`). Verified + sender domain `send.lumaline.dev`. Brand asset: green favicon (`src/assets/lumaline_favicon_green_*`). + +## Architecture + +Three units, each independently testable: + +### Unit 1 — `lumaline connect` (client, zero new deps) + +- `bin/lumaline.mjs`: new `case 'connect'` → `src/client/auth.mjs connect()`. +- `connect()`: obtain a valid access token via the grace-safe `getValidAccessToken()`. Then + `GET {STRIPE_CONNECT_BASE}/connect/status`: + - `onboarded: true` → print `✓ Bank connected — weekly payouts active (€1 minimum).` + - else → `POST {STRIPE_CONNECT_BASE}/connect/onboard` → print the `onboarding_url` with a one-line + instruction ("Open to connect your bank; you'll enter your IBAN on Stripe's secure page"). + On `422 ineligible_country`, print the supported-region note plainly. +- `src/config.mjs`: add `STRIPE_CONNECT_BASE`, default + `https://prmsonskzrubqsazmpwd.supabase.co/functions/v1/stripe-connect` (env-overridable; the + stripe-connect fn is not behind the Cloudflare proxy). +- Update the stale `earnings` copy ("payouts begin only at go-live") → "Weekly auto-payout, €1 + minimum. Run `lumaline connect` to receive it." +- Help text (`lumaline` usage) gains the `connect` line. Client version bump + `npm publish` via the + existing tag→release.yml path. + +### Unit 2 — weekly auto-payout (cron + fn cron-auth) + +- **Migration**: + - `app.run_payout()` — a twin of `app.run_monitor()`: read Vault `lumaline_cron_secret`, + `net.http_post` `https://prmsonskzrubqsazmpwd.supabase.co/functions/v1/stripe-connect/payout/batch` + with `Authorization: Bearer `. Vault/secret/pg_net absent → `RAISE NOTICE` + no-op (so a + fresh local stack is inert). `REVOKE ALL … FROM PUBLIC, anon, authenticated`. + - **NOT** `cron.schedule`d in the migration (same as monitor — no environment coupling in the repo). + The controller runs `select cron.schedule('lumaline-payout-weekly','0 9 * * 1','select app.run_payout()')` + at deploy time. +- **`/payout/batch` auth**: add a **cron-secret path** beside the existing admin gate. A new + `requirePrivileged(req)` returns true when the bearer constant-time-equals `LUMALINE_CRON_SECRET` + (env), else falls back to `requireAdmin(req)`. The transfer→confirm body is **unchanged**. +- **€1 minimum**: `/payout/batch` reads `LUMALINE_PAYOUT_MIN_MICROS` (default `1000000`) and passes it + as `p_min_micros` to `payout_batch_reserve` (currently called with `{}` → €25 default). The DB + default stays €25 as a safe fallback; the fn always overrides. Dry-run (`?dry_run=true`) unchanged. + +### Unit 3 — branded notifications (Resend HTML) + +- New `supabase/functions/_shared/email.mjs`: `sendEmail({to, subject, html, text})` → POST Resend + (`RESEND_API_KEY`), `from: "LumaLine "`. Pure template builders + `paidEmail({handle, amountEur, last4?})` and `connectNudgeEmail({handle, amountEur})` returning + `{subject, html, text}`. **Best-effort**: every send is wrapped so a failure logs and continues — + it must NEVER block, reverse, or fail a payout. +- **Branding ("alive", not plaintext):** self-contained HTML, all CSS inline (email-client safe), no + external image fetches. LumaLine wordmark header, **green accent** (from the brand favicon; confirm + exact hex from `src/assets` / marketing at build), a single clear CTA button, warm human copy + (celebratory for paid: "💸 You just got paid"; inviting for nudge: "You've got €X waiting"), and a + plain-text `text` fallback for every email. Reusable by the monitor later (out of scope to convert now). +- **Paid email**: after each successful `payout_confirm`, resolve the publisher's email and send. +- **Connect-nudge**: after the batch, find publishers with `payable ≥ €1` **and** `stripe_account_id + IS NULL`, not nudged in ≥6 days; email them; stamp `connect_nudge_at`. +- **New SECDEF, service_role-only RPCs** (migration): + - `app.publisher_email(p_publisher_id uuid) returns text` — the publisher's `auth.users.email`. + - `app.payout_nudge_candidates(p_min_micros bigint, p_hold interval)` → rows `{publisher_id, email, + payable_micros}` for un-onboarded, over-min, `connect_nudge_at IS NULL OR < now()-interval '6 days'`. + - `app.mark_connect_nudged(p_ids uuid[])` — set `connect_nudge_at = now()`. + - New column `public.publishers.connect_nudge_at timestamptz` (nullable). + +## Data flow (one weekly run) + +``` +pg_cron (Mon 09:00 UTC) + → app.run_payout() [reads Vault lumaline_cron_secret] + → POST /payout/batch (Bearer = cron secret; requirePrivileged ✓) + → payout_batch_reserve(p_hold, p_min=€1) reserve pending payouts (onboarded + ≥€1) + → for each: stripe.transfers.create(idem=payoutIdemKey(id)) → payout_confirm → paid-email + → payout_nudge_candidates(€1, hold) → connect-nudge-email each → mark_connect_nudged +``` + +The monitor cron already covers `payout_stuck`/`payout_failed`/recon drift; `/reconcile` unchanged. + +## Error handling / money-safety (invariants preserved) + +- **No double-pay**: unchanged. `payoutIdemKey(po.id)` + `UNIQUE(stripe_transfer_id)` + the + one-active-payout index + the ambiguous-error self-heal (never fail after a possibly-successful + transfer). Idempotent re-runs. +- **Cron down / pg_net absent**: `run_payout` no-ops; next week retries; reserve is idempotent. +- **Email failure ≠ payout failure**: emails are post-confirm and try/caught. A dead Resend key delays + nobody's money. +- **Auth**: cron secret compared constant-time; the publisher onboard/status routes stay + publisher-token-scoped; `/payout/batch` still refuses everything that is neither the cron secret nor + an admin JWT. New RPCs are service_role-only (REVOKE anon/authenticated/public). +- **Least data**: emails carry only handle + amount (+ card last4 on paid, already known to the payer); + no PII beyond the recipient's own address. + +## Testing + +- **Unit** (`node --test`, no stack): cron-secret constant-time compare; `min` read from env; nudge + candidate/dedup selection; email template builders (subject/CTA/amount formatting, plain-text + fallback present, no external URLs). Pure functions extracted for this. +- **Integration** (local stack, self-skipping): schedule → `reserve(€1)` → transfer (test Stripe or the + existing harness) → `confirm` → paid-email attempted; seed an un-onboarded over-min publisher → + nudge fired once, second run deduped. Reuse the proven payout integration harness (the real €30 EEA + transfer path). +- **Adversarial review** (workflow, money/trust lenses) before deploy — same bar as the charge fix. + +## Deploy sequence (owner-gated, per step) + +1. Migration (RPCs + `connect_nudge_at` + `run_payout`) applied to prod via the ref-guarded runner + stamp. +2. Set fn env on stripe-connect: `LUMALINE_CRON_SECRET` (= Vault `lumaline_cron_secret`), + `LUMALINE_PAYOUT_MIN_MICROS=1000000`, confirm `RESEND_API_KEY`. +3. Redeploy `stripe-connect` (cron-auth + €1 min + emails + `_shared/email.mjs`). +4. `select cron.schedule('lumaline-payout-weekly','0 9 * * 1','select app.run_payout()')`. +5. Smoke: manual `POST /payout/batch?dry_run=true` (admin) shows the €1-min plan; verify no transfer. +6. Publish client with `lumaline connect`. + +**First real auto-payout** fires when a publisher's matured (7-day-held) payable crosses €1 +(currently €0.66, matures ~2026-07-11) — the rail is live and waiting until then. + +## Future work (documented now, NOT built) + +- **Graphical self-serve portals** — a proper web login + config UI for **publishers** (payout status, + earnings, connect bank, history) and **advertisers** (create/fund campaigns, upload creatives, set + bids/budgets, payment method) instead of CLI + admin-booking. Likely the separate Lovable web repo + (M2-T8/M3-T8), blocked on a UX spec. +- **Owner dashboard** — a graphical operations view (revenue, payouts, fill, fraud, reconciliation) + over the read-only `scripts/ops/dashboard.mjs` data. +- **On-demand withdraw** (`lumaline withdraw` + `/payout/self`) — publisher-initiated payout. +- Raise the €1 launch minimum toward a steady-state policy as volume grows; convert the monitor's + plaintext alerts to the shared branded email module. From 13ea5738950f8e20e1a93ceac0e6bc9476e004a3 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 16:55:38 +0200 Subject: [PATCH 02/14] =?UTF-8?q?docs(m5-t4):=20TDD=20implementation=20pla?= =?UTF-8?q?n=20=E2=80=94=20auto-payout=20(connect=20+=20cron=20+=20branded?= =?UTF-8?q?=20emails)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../plans/2026-07-04-auto-payout.md | 740 ++++++++++++++++++ 1 file changed, 740 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-04-auto-payout.md diff --git a/docs/superpowers/plans/2026-07-04-auto-payout.md b/docs/superpowers/plans/2026-07-04-auto-payout.md new file mode 100644 index 0000000..29d2cfc --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-auto-payout.md @@ -0,0 +1,740 @@ +# M5-T4 Auto-Payout Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publishers self-onboard their bank (`lumaline connect`) and get paid automatically every week, with branded email notifications — no owner in the loop. + +**Architecture:** Reuse the proven `stripe-connect` payout core (reserve→transfer→confirm, double-pay-safe) untouched. Add a pg_cron trigger (twin of the live `app.run_monitor()`), a cron-secret auth path on `/payout/batch`, an env-tunable €1 minimum, a `lumaline connect` client command over the existing hosted onboarding, and best-effort branded Resend emails that can never block a payout. + +**Tech Stack:** Node ≥18 ESM zero-dep client (`src/`, `bin/`); Deno + TypeScript edge fn (`supabase/functions/stripe-connect`); shared pure `.mjs` (`supabase/functions/_shared/`) importable by both Deno and `node --test`; Postgres migrations (pg_cron + pg_net + Vault); Resend HTTP API. + +## Global Constraints + +- Client is **zero runtime deps**, `node:` built-ins only, Node ≥ 18, ESM `.mjs`. No build step. +- Money-safety invariants are **non-negotiable**: no double-pay, house never charged, idempotent re-runs. The transfer/confirm/reconcile core in `stripe-connect/index.ts` is **NOT modified** except to add auth + min-arg + post-loop emails. +- Emails are **best-effort**: every send is wrapped so a failure logs and continues — it must never block, reverse, or fail a payout. +- Prod project ref is **`prmsonskzrubqsazmpwd`** (never the CRM `kvlfpwzmjxuapjheknnj`). All remote writes ref-guarded + owner-gated. +- Payout minimum = **€1** via `LUMALINE_PAYOUT_MIN_MICROS` (default `1000000`). +- Cron cadence = **weekly, Monday 09:00 UTC** (`0 9 * * 1`), scheduled by the controller at deploy (NOT in the migration), mirroring the monitor. +- Cron auth = header **`x-lumaline-cron-secret`** constant-time-compared to fn env `LUMALINE_CRON_SECRET` (= Vault `lumaline_cron_secret`). +- Email `from` = env `LUMALINE_EMAIL_FROM` default `"LumaLine "`; pure HTML, all CSS inline, **no external asset fetches**, plain-text fallback on every email; green brand accent `#16A34A`. +- Pure logic (email builders, constant-time compare, min parse) lives in `_shared/*.mjs` and is unit-tested by `node --test`; the Deno fn imports it. Integration tests self-skip when the local stack is down. + +--- + +### Task 1: `lumaline connect` client command + config + copy fixes + +**Files:** +- Modify: `src/config.mjs` (add `STRIPE_CONNECT_BASE` near `AUTH_BASE`, ~line 59) +- Modify: `src/client/auth.mjs` (add `connect()`; fix stale payout copy in `login`+`earnings`) +- Modify: `bin/lumaline.mjs` (add `case 'connect'`, ~line 41; add help line, ~line 113) +- Test: `test/client-connect.test.mjs` (create) + +**Interfaces:** +- Consumes: `getValidAccessToken({file, authBase, fetchImpl, now, timeoutMs})` → token string | null; `postJson(fetchImpl, url, body, {timeoutMs, bearer})` → `{ok, status, data}`; `out(...)` console writer — all already in `src/client/auth.mjs`. +- Produces: `connect({connectBase?, fetchImpl?, now?, timeoutMs?, out?}) : Promise`; `STRIPE_CONNECT_BASE: string`. + +- [ ] **Step 1: Write the failing test** + +```javascript +// test/client-connect.test.mjs +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { connect } from '../src/client/auth.mjs'; +import { writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +function tokenFile() { + const d = mkdtempSync(join(tmpdir(), 'lumaline-connect-')); + const f = join(d, 'device-token.json'); + writeFileSync(f, JSON.stringify({ + access_token: 'hdr.' + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now()/1000)+3600 })).toString('base64url') + '.sig', + refresh_token: 'r', publisher_id: 'p1', device_id: 'd1', + })); + return f; +} + +test('connect: already onboarded → prints connected, does NOT call onboard', async () => { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + return { ok: true, status: 200, json: async () => ({ ok: true, onboarded: true, payout_status: 'eligible' }) }; + }; + const lines = []; + await connect({ file: tokenFile(), connectBase: 'https://x/stripe-connect', fetchImpl, out: (s) => lines.push(s) }); + assert.ok(calls.some((u) => u.endsWith('/connect/status')), 'checks status'); + assert.ok(!calls.some((u) => u.endsWith('/connect/onboard')), 'must NOT onboard when already onboarded'); + assert.match(lines.join('\n'), /connected|active/i); +}); + +test('connect: not onboarded → posts onboard, prints the onboarding_url', async () => { + const fetchImpl = async (url) => { + if (url.endsWith('/connect/status')) return { ok: true, status: 200, json: async () => ({ ok: true, onboarded: false, payout_status: 'pending' }) }; + return { ok: true, status: 200, json: async () => ({ ok: true, account_id: 'acct_1', onboarding_url: 'https://connect.stripe.com/setup/abc' }) }; + }; + const lines = []; + await connect({ file: tokenFile(), connectBase: 'https://x/stripe-connect', fetchImpl, out: (s) => lines.push(s) }); + assert.match(lines.join('\n'), /connect\.stripe\.com\/setup\/abc/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test test/client-connect.test.mjs` +Expected: FAIL — `connect` is not exported from `src/client/auth.mjs`. + +- [ ] **Step 3: Add `STRIPE_CONNECT_BASE` to `src/config.mjs`** + +Insert immediately after the `AUTH_BASE` export (~line 59): + +```javascript +// Stripe Connect (publisher payout onboarding + status). Same branded host as AUTH_BASE — the +// Cloudflare proxy forwards feed.lumaline.dev//... to /functions/v1//... for any fn. +export const STRIPE_CONNECT_BASE = env.LUMALINE_CONNECT || FEED_BASE.replace(/\/lumaline-feed\/?$/, '/stripe-connect'); +``` + +- [ ] **Step 4: Add `connect()` to `src/client/auth.mjs`** + +Add after `earnings()` (mirrors its shape). If `out` is not already a module-level helper, pass it in (default `console.log`): + +```javascript +// --- connect (self-serve bank onboarding) ---------------------------------------------- +export async function connect({ + file = DEVICE_TOKEN, connectBase = STRIPE_CONNECT_BASE, fetchImpl = fetch, + now = Date.now, timeoutMs = FETCH_TIMEOUT_MS, out = console.log, +} = {}) { + const token = await getValidAccessToken({ file, authBase: AUTH_BASE, fetchImpl, now, timeoutMs }); + if (!token) { out('Not logged in. Run `lumaline login` first.'); return; } + + const st = await postJsonGet(fetchImpl, `${connectBase}/connect/status`, { bearer: token, timeoutMs }); + if (st.ok && st.data?.onboarded) { + out(`✓ Bank connected — weekly payouts active (status: ${st.data.payout_status ?? 'ok'}, €1 minimum).`); + return; + } + const res = await postJson(fetchImpl, `${connectBase}/connect/onboard`, {}, { bearer: token, timeoutMs }); + if (res.status === 422) { out(`Payouts aren't supported in your region yet${res.data?.error ? ': ' + res.data.error : ''}.`); return; } + if (!res.ok || !res.data?.onboarding_url) { out(`Could not start onboarding (HTTP ${res.status}${res.data?.error ? ': ' + res.data.error : ''}).`); return; } + out('Connect your bank to receive payouts — open this secure Stripe page:'); + out(` ${res.data.onboarding_url}`); + out(' (You enter your IBAN on Stripe; LumaLine never sees it. Re-run `lumaline connect` to check status.)'); +} +``` + +`/connect/status` is a GET; add a tiny GET-with-bearer helper next to `postJson`: + +```javascript +async function postJsonGet(fetchImpl, url, { timeoutMs = FETCH_TIMEOUT_MS, bearer } = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetchImpl(url, { method: 'GET', headers: bearer ? { authorization: `Bearer ${bearer}` } : {}, signal: ctrl.signal }); + let data = null; try { data = await res.json(); } catch { /* empty */ } + return { ok: res.ok, status: res.status, data }; + } catch { return { ok: false, status: 0, data: null }; } + finally { clearTimeout(t); } +} +``` + +Ensure `STRIPE_CONNECT_BASE` and `AUTH_BASE` are imported from `../config.mjs` at the top of `auth.mjs`. + +- [ ] **Step 5: Fix the stale go-live copy** + +In `src/client/auth.mjs`, `login()` line ~211, replace: +`out(' (Payouts begin only at the production go-live; until then balances are informational.)');` +with: +`out(' (Run `lumaline connect` to receive weekly automatic payouts, €1 minimum.)');` + +In `earnings()` line ~255, replace: +`out(' Note: earnings ACCRUE now but real payouts begin only at the production go-live.');` +with: +`out(' Paid out automatically each week once you `lumaline connect` your bank (€1 minimum).');` + +- [ ] **Step 6: Wire `bin/lumaline.mjs`** + +Add after the `earnings` case (~line 41): + +```javascript + case 'connect': + await (await import('../src/client/auth.mjs')).connect({}); + break; +``` + +Add to the `help()` usage block after the `earnings` line (~line 113): +```javascript + lumaline connect Connect your bank (Stripe) to receive automatic weekly payouts +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `node --test test/client-connect.test.mjs` +Expected: PASS (2 tests). + +- [ ] **Step 8: Commit** + +```bash +git add src/config.mjs src/client/auth.mjs bin/lumaline.mjs test/client-connect.test.mjs +git commit -m "feat(client): lumaline connect — self-serve bank onboarding + refresh payout copy" +``` + +--- + +### Task 2: Branded HTML email builders + Resend sender (`_shared/email.mjs`) + +**Files:** +- Create: `supabase/functions/_shared/email.mjs` +- Test: `test/email-builders.test.mjs` (create) + +**Interfaces:** +- Produces: + - `paidEmail({handle, amountEur}) : {subject, html, text}` + - `connectNudgeEmail({handle, amountEur}) : {subject, html, text}` + - `sendEmail({to, subject, html, text, apiKey, from, fetchImpl?}) : Promise<'sent'|`failed:${string}`>` + +- [ ] **Step 1: Write the failing test** + +```javascript +// test/email-builders.test.mjs +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { paidEmail, connectNudgeEmail, sendEmail } from '../supabase/functions/_shared/email.mjs'; + +const noExternal = (html) => assert.ok(!/https?:\/\/(?!c\.lumaline|feed\.lumaline)/i.test(html.replace(/mailto:[^"'\s]+/g,'')) || !/ { + const e = paidEmail({ handle: 'degen', amountEur: '1.10' }); + assert.match(e.subject, /paid|payout/i); + assert.match(e.html, /1\.10/); + assert.match(e.html, /degen/); + assert.ok(e.text && e.text.includes('1.10'), 'plaintext fallback present with amount'); + assert.ok(!/ { + const e = connectNudgeEmail({ handle: 'pat', amountEur: '3.00' }); + assert.match(e.subject, /waiting|connect/i); + assert.match(e.html, /lumaline connect/); + assert.match(e.html, /3\.00/); + assert.ok(e.text.includes('lumaline connect')); +}); + +test('escapes handle to prevent HTML injection', () => { + const e = paidEmail({ handle: '', amountEur: '1.00' }); + assert.ok(!e.html.includes(''), 'handle is escaped'); +}); + +test('sendEmail: posts to Resend with from/to/subject; returns sent on 200', async () => { + let body = null; + const fetchImpl = async (url, opts) => { body = JSON.parse(opts.body); return { ok: true, status: 200 }; }; + const r = await sendEmail({ to: 'a@b.c', subject: 's', html: 'h', text: 't', apiKey: 'k', from: 'LumaLine ', fetchImpl }); + assert.equal(r, 'sent'); + assert.equal(body.from, 'LumaLine '); + assert.deepEqual(body.to, ['a@b.c']); + assert.equal(body.html, 'h'); +}); + +test('sendEmail: missing apiKey/to → failed:not_configured, no throw', async () => { + const r = await sendEmail({ to: '', subject: 's', html: 'h', text: 't', apiKey: '', from: 'f' }); + assert.equal(r, 'failed:not_configured'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test test/email-builders.test.mjs` +Expected: FAIL — module `_shared/email.mjs` not found. + +- [ ] **Step 3: Create `supabase/functions/_shared/email.mjs`** + +```javascript +// Branded, self-contained payout emails + a best-effort Resend sender. Zero deps; importable by +// the Deno edge fn and `node --test`. NO external asset fetches (all inline), plain-text fallback. +const GREEN = "#16A34A"; +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + +function shell(innerHtml) { + return ` +
+ + +${innerHtml} + +
+LumaLine +
+Transparent, signed, honest billing. You can audit every impression with lumaline earnings. +
`; +} + +function cta(url, label) { + return `${esc(label)}`; +} + +export function paidEmail({ handle, amountEur }) { + const subject = `💸 You just got paid €${amountEur}`; + const html = shell(` +

💸 €${esc(amountEur)} is on its way

+

Nice work, ${esc(handle)}. Your LumaLine earnings just transferred to your connected bank — it lands in a couple of business days.

+

No action needed. Payouts run automatically every week.

+`); + const text = `You just got paid €${amountEur}\n\nNice work, ${handle}. Your LumaLine earnings transferred to your connected bank and land in a couple of business days. No action needed — payouts run automatically each week.`; + return { subject, html, text }; +} + +export function connectNudgeEmail({ handle, amountEur }) { + const subject = `You've got €${amountEur} waiting — connect your bank`; + const html = shell(` +

You've earned €${esc(amountEur)} 🎉

+

Hi ${esc(handle)} — your earnings are ready, but we don't have anywhere to send them yet. Connect your bank once and weekly payouts turn on automatically.

+

${cta("https://feed.lumaline.dev", "Run: lumaline connect")}

+

In your terminal: lumaline connect — you'll enter your IBAN on Stripe's secure page.

+`); + const text = `You've earned €${amountEur} 🎉\n\nHi ${handle} — your earnings are ready but we have nowhere to send them yet. Run \`lumaline connect\` in your terminal to add your bank (IBAN on Stripe's secure page). Weekly payouts then turn on automatically.`; + return { subject, html, text }; +} + +// Best-effort: NEVER throws. Returns 'sent' or 'failed:'. +export async function sendEmail({ to, subject, html, text, apiKey, from, fetchImpl = fetch }) { + if (!apiKey || !to) return "failed:not_configured"; + try { + const resp = await fetchImpl("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({ from, to: [to], subject, html, text }), + }); + return resp.ok ? "sent" : `failed:${resp.status}`; + } catch (err) { + return `failed:${(err && err.message) ? "network" : "unknown"}`; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test test/email-builders.test.mjs` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add supabase/functions/_shared/email.mjs test/email-builders.test.mjs +git commit -m "feat(payout): branded HTML payout emails (paid + connect-nudge) + best-effort Resend sender" +``` + +--- + +### Task 3: Pure payout helpers — constant-time cron-secret compare + min parse + +**Files:** +- Modify: `supabase/functions/_shared/payout-logic.mjs` (append two pure helpers) +- Test: `test/payout-logic.test.mjs` (append; if absent, create) + +**Interfaces:** +- Produces: `constantTimeEqual(a: string, b: string) : boolean`; `payoutMinMicros(envVal: unknown) : number`. + +- [ ] **Step 1: Write the failing test** + +```javascript +// test/payout-logic.test.mjs (append these) +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { constantTimeEqual, payoutMinMicros } from '../supabase/functions/_shared/payout-logic.mjs'; + +test('constantTimeEqual: equal strings true, any diff false, length-mismatch false', () => { + assert.equal(constantTimeEqual('abc123', 'abc123'), true); + assert.equal(constantTimeEqual('abc123', 'abc124'), false); + assert.equal(constantTimeEqual('abc', 'abcd'), false); + assert.equal(constantTimeEqual('', ''), false); // empty never authorizes +}); + +test('payoutMinMicros: default €1 on absent/garbage/negative, honors valid', () => { + assert.equal(payoutMinMicros(undefined), 1000000); + assert.equal(payoutMinMicros(''), 1000000); + assert.equal(payoutMinMicros('nope'), 1000000); + assert.equal(payoutMinMicros('-5'), 1000000); + assert.equal(payoutMinMicros('0'), 1000000); // 0 would pay dust → clamp to default + assert.equal(payoutMinMicros('5000000'), 5000000); + assert.equal(payoutMinMicros(2500000), 2500000); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test test/payout-logic.test.mjs` +Expected: FAIL — `constantTimeEqual` / `payoutMinMicros` not exported. + +- [ ] **Step 3: Append the helpers to `_shared/payout-logic.mjs`** + +```javascript +/** Constant-time string compare (no early-exit on mismatch). Empty strings never authorize. */ +export function constantTimeEqual(a, b) { + const sa = String(a ?? ""), sb = String(b ?? ""); + if (sa.length === 0 || sb.length === 0 || sa.length !== sb.length) return false; + let diff = 0; + for (let i = 0; i < sa.length; i++) diff |= sa.charCodeAt(i) ^ sb.charCodeAt(i); + return diff === 0; +} + +/** Payout minimum in micro-EUR from env; default €1 (1_000_000). Garbage/≤0 → default. */ +export function payoutMinMicros(envVal) { + const n = Number(envVal); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1000000; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test test/payout-logic.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add supabase/functions/_shared/payout-logic.mjs test/payout-logic.test.mjs +git commit -m "feat(payout): pure cron-secret constant-time compare + env min-micros parse" +``` + +--- + +### Task 4: Migration — nudge column + contact/nudge RPCs + `app.run_payout()` + +**Files:** +- Create: `supabase/migrations/20260704150000_auto_payout.sql` +- Test: `test/auto-payout-sql.integration.mjs` (create — self-skips without local stack) + +**Interfaces:** +- Produces (all `SECURITY DEFINER`, service_role-only): + - `public.publishers.connect_nudge_at timestamptz` + - `app.publisher_contact(p_publisher_id uuid) → TABLE(email text, handle text)` + - `app.payout_nudge_candidates(p_min_micros bigint, p_hold interval) → TABLE(publisher_id uuid, email text, handle text, payable_micros bigint)` + - `app.mark_connect_nudged(p_ids uuid[]) → void` + - `app.run_payout() → void` (pg_cron target) + +- [ ] **Step 1: Write the failing test** + +```javascript +// test/auto-payout-sql.integration.mjs +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const DB = process.env.SUPABASE_DB_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +let pg; try { pg = (await import('node:child_process')); } catch { /* */ } +const psql = (sql) => { + const r = pg.spawnSync('psql', [DB, '-Atc', sql], { encoding: 'utf8' }); + return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; +}; +const up = psql("select 1"); +const SKIP = !up.ok ? 'local DB unreachable — SKIPPING' : false; +if (SKIP) console.log(`[auto-payout-sql] ${SKIP}`); + +test('migration objects exist', { skip: SKIP }, () => { + assert.equal(psql("select count(*) from information_schema.columns where table_schema='public' and table_name='publishers' and column_name='connect_nudge_at'").out, '1'); + for (const fn of ['publisher_contact', 'payout_nudge_candidates', 'mark_connect_nudged', 'run_payout']) { + assert.equal(psql(`select count(*) from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='app' and p.proname='${fn}'`).out, '1', `app.${fn} exists`); + } +}); + +test('run_payout no-ops cleanly when Vault secret absent (fresh stack)', { skip: SKIP }, () => { + const r = psql("select app.run_payout()"); + assert.ok(r.ok, `run_payout ran without error: ${r.err}`); +}); + +test('anon/authenticated cannot execute the new money RPCs', { skip: SKIP }, () => { + for (const sig of ['app.publisher_contact(uuid)', 'app.payout_nudge_candidates(bigint,interval)', 'app.mark_connect_nudged(uuid[])', 'app.run_payout()']) { + assert.equal(psql(`select has_function_privilege('anon','${sig}','execute')`).out, 'f', `anon cannot ${sig}`); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test test/auto-payout-sql.integration.mjs` +Expected: FAIL (objects missing) — or SKIP if the stack is down. Bring the stack up (`supabase start`) if needed to get a real FAIL. + +- [ ] **Step 3: Write the migration** + +```sql +-- supabase/migrations/20260704150000_auto_payout.sql +-- M5-T4 auto-payout: a connect-nudge dedup column, publisher-contact + nudge-candidate RPCs, and a +-- pg_cron target (twin of app.run_monitor) that POSTs the payout batch with the Vault cron secret. +-- All money RPCs are SECURITY DEFINER + service_role-only. run_payout degrades to NOTICE+no-op when +-- Vault/pg_net/secret is absent, so a fresh `supabase db reset` applies and runs cleanly. +set local lock_timeout = '2s'; +set local statement_timeout = '15s'; + +alter table public.publishers add column if not exists connect_nudge_at timestamptz; + +-- Contact for a publisher (email + handle) from auth.users. SECDEF: crosses into the auth schema. +create or replace function app.publisher_contact(p_publisher_id uuid) +returns table(email text, handle text) +language sql security definer set search_path = '' as $$ + select u.email::text, p.handle + from public.publishers p + join auth.users u on u.id = p.auth_user_id + where p.id = p_publisher_id + limit 1; +$$; +revoke all on function app.publisher_contact(uuid) from public, anon, authenticated; +grant execute on function app.publisher_contact(uuid) to service_role; + +-- Publishers who have earned >= the minimum but have NOT onboarded a bank, not nudged in ~a week. +create or replace function app.payout_nudge_candidates(p_min_micros bigint, p_hold interval default interval '7 days') +returns table(publisher_id uuid, email text, handle text, payable_micros bigint) +language plpgsql security definer set search_path = '' as $$ +begin + return query + select p.id, u.email::text, p.handle, app.publisher_payable_micros(p.id, p_hold) + from public.publishers p + join auth.users u on u.id = p.auth_user_id + where p.stripe_account_id is null + and (p.connect_nudge_at is null or p.connect_nudge_at < now() - interval '6 days') + and app.publisher_payable_micros(p.id, p_hold) >= p_min_micros; +end; +$$; +revoke all on function app.payout_nudge_candidates(bigint, interval) from public, anon, authenticated; +grant execute on function app.payout_nudge_candidates(bigint, interval) to service_role; + +create or replace function app.mark_connect_nudged(p_ids uuid[]) +returns void language sql security definer set search_path = '' as $$ + update public.publishers set connect_nudge_at = now() where id = any(p_ids); +$$; +revoke all on function app.mark_connect_nudged(uuid[]) from public, anon, authenticated; +grant execute on function app.mark_connect_nudged(uuid[]) to service_role; + +-- pg_cron target. Reads 'lumaline_cron_secret' from Vault and POSTs /payout/batch with the +-- x-lumaline-cron-secret header via pg_net. Vault/secret/pg_net absent -> NOTICE + no-op. +create or replace function app.run_payout() +returns void language plpgsql security definer set search_path = '' as $$ +declare v_secret text; v_request_id bigint; +begin + if to_regclass('vault.decrypted_secrets') is null then + raise notice 'run_payout: vault.decrypted_secrets missing (fresh local stack?) — no-op'; return; + end if; + begin + execute 'select decrypted_secret from vault.decrypted_secrets where name = $1 limit 1' + into v_secret using 'lumaline_cron_secret'; + exception when others then raise notice 'run_payout: cannot read vault (%) — no-op', sqlerrm; return; end; + if v_secret is null or v_secret = '' then + raise notice 'run_payout: vault secret lumaline_cron_secret absent — no-op'; return; + end if; + begin + execute $q$ + select net.http_post( + url := 'https://prmsonskzrubqsazmpwd.supabase.co/functions/v1/stripe-connect/payout/batch', + body := '{}'::jsonb, + headers := jsonb_build_object('Content-Type','application/json','x-lumaline-cron-secret',$1), + timeout_milliseconds := 120000) + $q$ into v_request_id using v_secret; + exception when others then raise notice 'run_payout: net.http_post unavailable/failed (%) — no-op', sqlerrm; return; end; +end; +$$; +revoke all on function app.run_payout() from public, anon, authenticated; +comment on function app.run_payout is + 'pg_cron target: POST the payout batch with the Vault cron secret. Vault/secret/pg_net absent -> NOTICE + no-op. Controller cron.schedule''s this weekly at deploy.'; +``` + +- [ ] **Step 4: Apply locally + run the test** + +Run: `psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f supabase/migrations/20260704150000_auto_payout.sql` (or `supabase db reset` to apply the whole chain). +Then: `node --test test/auto-payout-sql.integration.mjs` +Expected: PASS (objects exist, run_payout no-ops, anon revoked). + +- [ ] **Step 5: Commit** + +```bash +git add supabase/migrations/20260704150000_auto_payout.sql test/auto-payout-sql.integration.mjs +git commit -m "feat(payout): auto-payout migration — nudge column + contact/nudge RPCs + run_payout cron target" +``` + +--- + +### Task 5: Wire the `stripe-connect` fn — cron auth + €1 min + post-loop emails + +**Files:** +- Modify: `supabase/functions/stripe-connect/index.ts` (import helpers; add `hasValidCronSecret`/`requirePrivileged`; swap the admin gate; pass `p_min_micros`; add a post-loop notify pass) +- Test: `test/auto-payout.integration.mjs` (create — self-skips without the local stack + served fn) + +**Interfaces:** +- Consumes: `constantTimeEqual`, `payoutMinMicros` (Task 3); `paidEmail`, `connectNudgeEmail`, `sendEmail` (Task 2); `app.publisher_contact`, `app.payout_nudge_candidates`, `app.mark_connect_nudged` (Task 4). +- Produces: `/payout/batch` accepts the cron secret; charges the €1 min; emails paid + un-onboarded publishers. + +- [ ] **Step 1: Write the failing integration test** + +```javascript +// test/auto-payout.integration.mjs — needs local stack + `supabase functions serve stripe-connect`. +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const FN = process.env.STRIPE_CONNECT_URL || 'http://127.0.0.1:54321/functions/v1/stripe-connect'; +async function up() { try { const r = await fetch(`${FN}/payout/batch`, { method: 'OPTIONS', signal: AbortSignal.timeout(2000) }); return r.status === 200; } catch { return false; } } +const SKIP = !(await up()) ? 'stripe-connect fn not served — SKIPPING' : false; +if (SKIP) console.log(`[auto-payout.integration] ${SKIP}`); + +test('payout/batch: bad cron secret is rejected (403)', { skip: SKIP }, async () => { + const r = await fetch(`${FN}/payout/batch?dry_run=true`, { method: 'POST', headers: { 'x-lumaline-cron-secret': 'wrong', 'content-type': 'application/json' }, body: '{}' }); + assert.equal(r.status, 403); +}); + +test('payout/batch: valid cron secret authorizes the dry-run', { skip: SKIP || !process.env.LUMALINE_CRON_SECRET }, async () => { + const r = await fetch(`${FN}/payout/batch?dry_run=true`, { method: 'POST', headers: { 'x-lumaline-cron-secret': process.env.LUMALINE_CRON_SECRET, 'content-type': 'application/json' }, body: '{}' }); + assert.equal(r.status, 200); + const b = await r.json(); + assert.equal(b.ok, true); assert.equal(b.dry_run, true); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test test/auto-payout.integration.mjs` +Expected: FAIL on the bad-secret test (currently `/payout/batch` requires an admin JWT and returns 403 for a cron header — so actually it already 403s; the meaningful new assertion is the *valid* secret authorizing, which fails until wired). Serve the fn with `LUMALINE_CRON_SECRET` set to get a real FAIL on the second test. + +- [ ] **Step 3: Add imports + auth helpers to `stripe-connect/index.ts`** + +Extend the existing `_shared/payout-logic.mjs` import to include `constantTimeEqual, payoutMinMicros`, and add: + +```typescript +import { paidEmail, connectNudgeEmail, sendEmail } from "../_shared/email.mjs"; + +function hasValidCronSecret(req: Request): boolean { + const got = req.headers.get("x-lumaline-cron-secret") ?? ""; + const want = Deno.env.get("LUMALINE_CRON_SECRET") ?? ""; + return constantTimeEqual(got, want); +} +``` + +- [ ] **Step 4: Swap the shared admin gate to accept the cron secret** + +At the shared admin gate (~line 293), replace: + +```typescript + const adminAuth = await requireAdmin(req); + if (!adminAuth) return jsonErr("Forbidden", 403); +``` + +with: + +```typescript + // Privileged routes: an admin JWT OR the pg_cron secret (weekly auto-payout). Both are trusted; + // the cron only ever calls /payout/batch, and /reconcile is read-only. + const cron = hasValidCronSecret(req); + const adminAuth = cron ? "cron" : await requireAdmin(req); + if (!adminAuth) return jsonErr("Forbidden", 403); +``` + +- [ ] **Step 5: Pass the €1 min into the reserve** + +In the `/payout/batch` handler, replace: +`const reserve = await serviceRpc("payout_batch_reserve", {});` +with: + +```typescript + const minMicros = payoutMinMicros(Deno.env.get("LUMALINE_PAYOUT_MIN_MICROS")); + const reserve = await serviceRpc("payout_batch_reserve", { p_min_micros: minMicros }); +``` + +- [ ] **Step 6: Add the post-loop notify pass (best-effort, never blocks a payout)** + +Immediately BEFORE the final `const paid = results.filter(...)` line in `/payout/batch`, insert: + +```typescript + // ---- Notifications (best-effort; a failure here never affects a payout) ------------ + try { + const apiKey = Deno.env.get("RESEND_API_KEY") ?? ""; + const from = Deno.env.get("LUMALINE_EMAIL_FROM") ?? "LumaLine "; + const eur = (c: number) => (c / 100).toFixed(2); + + // Paid confirmations + for (const r of results.filter((x) => x.status === "paid")) { + const po = pending.find((p) => p.id === r.payout_id); + if (!po) continue; + const c = await serviceRpc("publisher_contact", { p_publisher_id: po.publisher_id }); + const contact = (c.ok ? c.data : null) as { email?: string; handle?: string } | null; + if (!contact?.email) continue; + const { subject, html, text } = paidEmail({ handle: contact.handle ?? "there", amountEur: eur(microsToCents(po.amount_micros)) }); + await sendEmail({ to: contact.email, subject, html, text, apiKey, from }); + } + + // Connect-nudges (over-min, not onboarded, not nudged in ~a week) + const nudge = await serviceRpc("payout_nudge_candidates", { p_min_micros: minMicros }); + const cands = (nudge.ok && Array.isArray(nudge.data) ? nudge.data : []) as Array<{ publisher_id: string; email: string; handle: string; payable_micros: number }>; + const nudged: string[] = []; + for (const cnd of cands) { + if (!cnd.email) continue; + const { subject, html, text } = connectNudgeEmail({ handle: cnd.handle ?? "there", amountEur: eur(microsToCents(cnd.payable_micros)) }); + const res = await sendEmail({ to: cnd.email, subject, html, text, apiKey, from }); + if (res === "sent") nudged.push(cnd.publisher_id); + } + if (nudged.length > 0) await serviceRpc("mark_connect_nudged", { p_ids: nudged }); + } catch (err) { + console.error(`payout: notify pass failed (non-fatal): ${(err as { message?: string }).message ?? "unknown"}`); + } +``` + +Note: `serviceRpc` unwraps single-row results, so `publisher_contact` returns `{email, handle}` directly; `payout_nudge_candidates` returns an array. `mark_connect_nudged` is only called for publishers whose email actually sent, so a transient email outage re-nudges next week (no lost signal). + +- [ ] **Step 7: Run the integration test + full suite** + +Serve: `SUPABASE_… supabase functions serve stripe-connect --no-verify-jwt --env-file supabase/functions/.env` (set `LUMALINE_CRON_SECRET`, `LUMALINE_PAYOUT_MIN_MICROS=1000000`, `RESEND_API_KEY` in that env file). +Run: `node --test test/auto-payout.integration.mjs` +Expected: PASS (bad secret 403; valid secret 200 dry-run). +Then the pure suite: `node --test test/*.test.mjs` — Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add supabase/functions/stripe-connect/index.ts test/auto-payout.integration.mjs +git commit -m "feat(payout): stripe-connect cron-secret auth + €1 min + best-effort paid/nudge emails" +``` + +--- + +### Task 6: End-to-end local proof + adversarial review + owner-gated deploy runbook + +**Files:** +- Create: `docs/ops/m5-t4-deploy.md` (deploy checklist) +- Modify: `package.json` (client version bump) + +- [ ] **Step 1: Local end-to-end proof (reuse the payout harness)** + +On the local stack, seed a publisher with a connected `stripe_account_id` + matured earnings ≥ €1 (reuse the existing payout integration seed), plus a second publisher with earnings ≥ €1 and `stripe_account_id IS NULL`. Trigger `POST /payout/batch` with the cron secret. Assert: first publisher → `paid` (test-mode transfer) + a paid email attempt logged; second → appears in `payout_nudge_candidates`, gets a nudge, `connect_nudge_at` stamped; a second immediate run does NOT re-nudge (dedup). Record the output in the PR description. + +- [ ] **Step 2: Bump the client version** + +Edit `package.json` `version` (e.g. `0.1.2` → `0.1.3`). Commit: +```bash +git add package.json && git commit -m "chore(client): bump version for lumaline connect" +``` + +- [ ] **Step 3: Adversarial review** + +Run a money/trust adversarial review (workflow, same bar as the charge fix) over the whole branch. Lenses: (a) can the cron path double-pay or bypass a money-safety invariant; (b) can an email failure or a malformed publisher_contact/nudge row block, reverse, or crash a payout; (c) constant-time-compare + secret handling (never logged); (d) nudge dedup correctness (no spam, no lost signal); (e) migration grants (anon/authenticated revoked). Fix any Critical/High before deploy. + +- [ ] **Step 4: Write `docs/ops/m5-t4-deploy.md` (owner-gated sequence)** + +Document, each step ref-guarded to `prmsonskzrubqsazmpwd` + owner GO: +1. Apply migration `20260704150000_auto_payout.sql` (ref-guarded runner) + stamp `schema_migrations`. +2. Set fn env on `stripe-connect`: `LUMALINE_CRON_SECRET` (= Vault `lumaline_cron_secret`), `LUMALINE_PAYOUT_MIN_MICROS=1000000`, `LUMALINE_EMAIL_FROM`, confirm `RESEND_API_KEY`. Confirm the Resend `from` domain (`send.lumaline.dev`) is verified for API sends; else fall back to `onboarding@resend.dev`. +3. Redeploy `stripe-connect` (`supabase functions deploy stripe-connect --project-ref prmsonskzrubqsazmpwd --use-api`). +4. `select cron.schedule('lumaline-payout-weekly','0 9 * * 1','select app.run_payout()')`. +5. Smoke: admin `POST /payout/batch?dry_run=true` → the €1-min plan; verify **no** transfer. +6. Publish the client: tag `vX.Y.Z` → `release.yml` (`npm publish --provenance`). +7. Announce `lumaline connect` to the publisher(s). + +- [ ] **Step 5: Push branch + open PR** + +```bash +git push -u origin feat/m5-t4-auto-payout +gh pr create --base main --title "feat(payout): M5-T4 auto weekly payouts + lumaline connect + branded emails" --body "" +``` + +- [ ] **Step 6: Owner-gated deploy** — execute `docs/ops/m5-t4-deploy.md` step-by-step, each with explicit owner GO. Then M5-T4 = the first real auto-payout once a publisher's matured payable crosses €1. + +--- + +## Self-Review + +**Spec coverage:** `lumaline connect` (T1) ✓ · config base (T1) ✓ · stale copy fix (T1) ✓ · version bump (T6) ✓ · `app.run_payout` cron twin (T4) ✓ · cron-secret auth (T3+T5) ✓ · €1 env min (T3+T5) ✓ · branded paid+nudge emails (T2) ✓ · publisher_contact/nudge_candidates/mark_nudged + `connect_nudge_at` (T4) ✓ · best-effort sends (T5) ✓ · money core untouched (T5 only adds auth/min/post-loop) ✓ · unit tests cron-compare/min/email (T2,T3) + integration (T4,T5) ✓ · owner-gated deploy (T6) ✓. Future portals/dashboard/withdraw are non-goals (documented in spec). + +**Placeholder scan:** deploy PR body `` is an intentional author fill at push time; all code steps carry full code. No TBD/TODO in code. + +**Type consistency:** `connect({file, connectBase, fetchImpl, now, timeoutMs, out})`, `paidEmail({handle, amountEur})`, `connectNudgeEmail({handle, amountEur})`, `sendEmail({to,subject,html,text,apiKey,from,fetchImpl})`, `constantTimeEqual(a,b)`, `payoutMinMicros(envVal)`, `publisher_contact→{email,handle}`, `payout_nudge_candidates→[{publisher_id,email,handle,payable_micros}]` — used identically in T5. `microsToCents`/`serviceRpc`/`svc`/`PAYOUT_CURRENCY` are pre-existing in `stripe-connect/index.ts`. From c365ce66161b05a89e86a47cbb87c63ff17fc0d5 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 17:10:53 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat(client):=20lumaline=20connect=20?= =?UTF-8?q?=E2=80=94=20self-serve=20bank=20onboarding=20+=20refresh=20payo?= =?UTF-8?q?ut=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bin/lumaline.mjs | 4 ++++ src/client/auth.mjs | 38 ++++++++++++++++++++++++++++++++--- src/config.mjs | 4 ++++ test/client-connect.test.mjs | 39 ++++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 test/client-connect.test.mjs diff --git a/bin/lumaline.mjs b/bin/lumaline.mjs index 1c5ef0c..80bb686 100755 --- a/bin/lumaline.mjs +++ b/bin/lumaline.mjs @@ -39,6 +39,9 @@ async function main() { case 'earnings': await (await import('../src/client/auth.mjs')).earnings(); break; + case 'connect': + await (await import('../src/client/auth.mjs')).connect({}); + break; case 'doctor': await doctor(); break; @@ -111,6 +114,7 @@ Usage: lumaline login Log in (device-code) so earnings attribute to your account lumaline logout Log out: revoke this device, revert to the anonymous sentinel lumaline earnings Show your accrued earnings (transparent ledger) + lumaline connect Connect your bank (Stripe) to receive automatic weekly payouts lumaline doctor Show environment + where Claude Code config lives lumaline version Print version diff --git a/src/client/auth.mjs b/src/client/auth.mjs index 9979f57..9746ffd 100644 --- a/src/client/auth.mjs +++ b/src/client/auth.mjs @@ -20,7 +20,7 @@ import { } from 'node:fs'; import path from 'node:path'; import { - DEVICE_TOKEN, AUTH_BASE, TOKEN_REFRESH_SKEW_MS, FETCH_TIMEOUT_MS, + DEVICE_TOKEN, AUTH_BASE, STRIPE_CONNECT_BASE, TOKEN_REFRESH_SKEW_MS, FETCH_TIMEOUT_MS, } from '../config.mjs'; // --- credential store (0600 file) ----------------------------------------------------- @@ -75,6 +75,17 @@ async function postJson(fetchImpl, url, body, { timeoutMs = FETCH_TIMEOUT_MS, be } finally { clearTimeout(timer); } } +async function postJsonGet(fetchImpl, url, { timeoutMs = FETCH_TIMEOUT_MS, bearer } = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetchImpl(url, { method: 'GET', headers: bearer ? { authorization: `Bearer ${bearer}` } : {}, signal: ctrl.signal }); + let data = null; try { data = await res.json(); } catch { /* empty */ } + return { ok: res.ok, status: res.status, data }; + } catch { return { ok: false, status: 0, data: null }; } + finally { clearTimeout(t); } +} + // Shape a server token reply (+ optional carry-over from the prior stored token) into the // stored credential object. exp prefers the JWT's own claim, else now + expires_in. function shapeToken(data, nowMs, prior = {}) { @@ -208,7 +219,7 @@ export async function login({ const tok = shapeToken(poll.data, Date.now()); saveToken(file, tok); out(`\n ✓ Logged in as ${tok.handle ?? tok.publisher_id}. Earnings now attribute to you.`); - out(' (Payouts begin only at the production go-live; until then balances are informational.)'); + out(' (Run `lumaline connect` to receive weekly automatic payouts, €1 minimum.)'); return tok; } const err = poll.data?.error; @@ -252,11 +263,32 @@ export async function earnings({ const windows = Array.isArray(data?.windows) ? data.windows : []; out(` windows : ${windows.length} cleared/booked impression-window(s) on record`); out(''); - out(' Note: earnings ACCRUE now but real payouts begin only at the production go-live.'); + out(' Paid out automatically each week once you `lumaline connect` your bank (€1 minimum).'); out(' Until then these balances are informational. Anonymous/revoked devices accrue €0.'); return data; } +// --- connect (self-serve bank onboarding) ---------------------------------------------- +export async function connect({ + file = DEVICE_TOKEN, connectBase = STRIPE_CONNECT_BASE, fetchImpl = fetch, + now = Date.now(), timeoutMs = FETCH_TIMEOUT_MS, out = console.log, +} = {}) { + const token = await getValidAccessToken({ file, authBase: AUTH_BASE, fetchImpl, now, timeoutMs }); + if (!token) { out('Not logged in. Run `lumaline login` first.'); return; } + + const st = await postJsonGet(fetchImpl, `${connectBase}/connect/status`, { bearer: token, timeoutMs }); + if (st.ok && st.data?.onboarded) { + out(`✓ Bank connected — weekly payouts active (status: ${st.data.payout_status ?? 'ok'}, €1 minimum).`); + return; + } + const res = await postJson(fetchImpl, `${connectBase}/connect/onboard`, {}, { bearer: token, timeoutMs }); + if (res.status === 422) { out(`Payouts aren't supported in your region yet${res.data?.error ? ': ' + res.data.error : ''}.`); return; } + if (!res.ok || !res.data?.onboarding_url) { out(`Could not start onboarding (HTTP ${res.status}${res.data?.error ? ': ' + res.data.error : ''}).`); return; } + out('Connect your bank to receive payouts — open this secure Stripe page:'); + out(` ${res.data.onboarding_url}`); + out(' (You enter your IBAN on Stripe; LumaLine never sees it. Re-run `lumaline connect` to check status.)'); +} + // --- doctor helper -------------------------------------------------------------------- export function authStatus({ file = DEVICE_TOKEN, now = Date.now() } = {}) { const t = loadToken(file); diff --git a/src/config.mjs b/src/config.mjs index e029bb4..0f10ca2 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -58,6 +58,10 @@ export const FEED_BASE = env.LUMALINE_FEED || // the feed serves (branded default → https://feed.lumaline.dev/auth-device). export const AUTH_BASE = env.LUMALINE_AUTH || FEED_BASE.replace(/\/lumaline-feed\/?$/, '/auth-device'); +// Stripe Connect (publisher payout onboarding + status). Same branded host as AUTH_BASE — the +// Cloudflare proxy forwards feed.lumaline.dev//... to /functions/v1//... for any fn. +export const STRIPE_CONNECT_BASE = env.LUMALINE_CONNECT || FEED_BASE.replace(/\/lumaline-feed\/?$/, '/stripe-connect'); + // Canonical branded click host, for reference/tooling only. The CLIENT does NOT build click // URLs from this — it renders the `clickUrl` string carried in the *signed* feed payload verbatim // (statusline.mjs → safeClickUrl). The feed builds `${host}/c/${token}` server-side from its OWN diff --git a/test/client-connect.test.mjs b/test/client-connect.test.mjs new file mode 100644 index 0000000..01da5fc --- /dev/null +++ b/test/client-connect.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { connect } from '../src/client/auth.mjs'; +import { writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +function tokenFile() { + const d = mkdtempSync(join(tmpdir(), 'lumaline-connect-')); + const f = join(d, 'device-token.json'); + writeFileSync(f, JSON.stringify({ + access_token: 'hdr.' + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now()/1000)+3600 })).toString('base64url') + '.sig', + refresh_token: 'r', publisher_id: 'p1', device_id: 'd1', + })); + return f; +} + +test('connect: already onboarded → prints connected, does NOT call onboard', async () => { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + return { ok: true, status: 200, json: async () => ({ ok: true, onboarded: true, payout_status: 'eligible' }) }; + }; + const lines = []; + await connect({ file: tokenFile(), connectBase: 'https://x/stripe-connect', fetchImpl, out: (s) => lines.push(s) }); + assert.ok(calls.some((u) => u.endsWith('/connect/status')), 'checks status'); + assert.ok(!calls.some((u) => u.endsWith('/connect/onboard')), 'must NOT onboard when already onboarded'); + assert.match(lines.join('\n'), /connected|active/i); +}); + +test('connect: not onboarded → posts onboard, prints the onboarding_url', async () => { + const fetchImpl = async (url) => { + if (url.endsWith('/connect/status')) return { ok: true, status: 200, json: async () => ({ ok: true, onboarded: false, payout_status: 'pending' }) }; + return { ok: true, status: 200, json: async () => ({ ok: true, account_id: 'acct_1', onboarding_url: 'https://connect.stripe.com/setup/abc' }) }; + }; + const lines = []; + await connect({ file: tokenFile(), connectBase: 'https://x/stripe-connect', fetchImpl, out: (s) => lines.push(s) }); + assert.match(lines.join('\n'), /connect\.stripe\.com\/setup\/abc/); +}); From 4c8e52f92e119e6c46bbd65d8c82a4079e64f606 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 17:17:24 +0200 Subject: [PATCH 04/14] fix(client): guard connect() onboard network error + consistent help copy + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bin/lumaline.mjs | 2 +- src/client/auth.mjs | 8 +++++++- test/client-connect.test.mjs | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/bin/lumaline.mjs b/bin/lumaline.mjs index 80bb686..435c72c 100755 --- a/bin/lumaline.mjs +++ b/bin/lumaline.mjs @@ -122,7 +122,7 @@ Notes: - Uses only the official statusLine mechanism. No bundle patching. - Wiring happens ONLY when you run \`install\` — never automatically on npm install. - Login is opt-in: before it, the line runs anonymously and is never billed. - Earnings accrue after login but real payouts begin only at the production go-live. + Earnings accrue after login; run \`lumaline connect\` to receive automatic weekly payouts (€1 minimum). - \`login\` registers a device label (defaults to your machine hostname; \`--label \` to override). - Disable clickable links: LUMALINE_HYPERLINKS=0`); } diff --git a/src/client/auth.mjs b/src/client/auth.mjs index 9746ffd..8354cb7 100644 --- a/src/client/auth.mjs +++ b/src/client/auth.mjs @@ -281,7 +281,13 @@ export async function connect({ out(`✓ Bank connected — weekly payouts active (status: ${st.data.payout_status ?? 'ok'}, €1 minimum).`); return; } - const res = await postJson(fetchImpl, `${connectBase}/connect/onboard`, {}, { bearer: token, timeoutMs }); + let res; + try { + res = await postJson(fetchImpl, `${connectBase}/connect/onboard`, {}, { bearer: token, timeoutMs }); + } catch { + out('Could not start onboarding (network error). Try again in a moment.'); + return; + } if (res.status === 422) { out(`Payouts aren't supported in your region yet${res.data?.error ? ': ' + res.data.error : ''}.`); return; } if (!res.ok || !res.data?.onboarding_url) { out(`Could not start onboarding (HTTP ${res.status}${res.data?.error ? ': ' + res.data.error : ''}).`); return; } out('Connect your bank to receive payouts — open this secure Stripe page:'); diff --git a/test/client-connect.test.mjs b/test/client-connect.test.mjs index 01da5fc..8b04d03 100644 --- a/test/client-connect.test.mjs +++ b/test/client-connect.test.mjs @@ -37,3 +37,24 @@ test('connect: not onboarded → posts onboard, prints the onboarding_url', asyn await connect({ file: tokenFile(), connectBase: 'https://x/stripe-connect', fetchImpl, out: (s) => lines.push(s) }); assert.match(lines.join('\n'), /connect\.stripe\.com\/setup\/abc/); }); + +test('connect: onboard fetch throws (network error) → prints graceful message, does not throw', async () => { + const fetchImpl = async (url) => { + if (url.endsWith('/connect/status')) return { ok: true, status: 200, json: async () => ({ ok: true, onboarded: false, payout_status: 'pending' }) }; + throw new Error('fetch failed: ECONNRESET'); + }; + const lines = []; + await assert.doesNotReject(connect({ file: tokenFile(), connectBase: 'https://x/stripe-connect', fetchImpl, out: (s) => lines.push(s) })); + assert.match(lines.join('\n'), /network error/i); +}); + +test('connect: not logged in (no/expired token) → prints "Not logged in", never calls fetch', async () => { + const d = mkdtempSync(join(tmpdir(), 'lumaline-connect-')); + const file = join(d, 'device-token.json'); // never written — loadToken() sees no file + let called = false; + const fetchImpl = async () => { called = true; return { ok: true, status: 200, json: async () => ({}) }; }; + const lines = []; + await connect({ file, connectBase: 'https://x/stripe-connect', fetchImpl, out: (s) => lines.push(s) }); + assert.match(lines.join('\n'), /Not logged in/); + assert.equal(called, false, 'must not call fetch when not logged in'); +}); From 1ea3db8f3c3d87aaf08e22d3b4e31690f28d39ed Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 17:19:39 +0200 Subject: [PATCH 05/14] feat(payout): branded HTML payout emails (paid + connect-nudge) + best-effort Resend sender Co-Authored-By: Claude Opus 4.8 --- supabase/functions/_shared/email.mjs | 60 ++++++++++++++++++++++++++++ test/email-builders.test.mjs | 42 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 supabase/functions/_shared/email.mjs create mode 100644 test/email-builders.test.mjs diff --git a/supabase/functions/_shared/email.mjs b/supabase/functions/_shared/email.mjs new file mode 100644 index 0000000..75917f6 --- /dev/null +++ b/supabase/functions/_shared/email.mjs @@ -0,0 +1,60 @@ +// Branded, self-contained payout emails + a best-effort Resend sender. Zero deps; importable by +// the Deno edge fn and `node --test`. NO external asset fetches (all inline), plain-text fallback. +const GREEN = "#16A34A"; +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + +function shell(innerHtml) { + return ` +
+ + +${innerHtml} + +
+LumaLine +
+Transparent, signed, honest billing. You can audit every impression with lumaline earnings. +
`; +} + +function cta(url, label) { + return `${esc(label)}`; +} + +export function paidEmail({ handle, amountEur }) { + const subject = `💸 You just got paid €${amountEur}`; + const html = shell(` +

💸 €${esc(amountEur)} is on its way

+

Nice work, ${esc(handle)}. Your LumaLine earnings just transferred to your connected bank — it lands in a couple of business days.

+

No action needed. Payouts run automatically every week.

+`); + const text = `You just got paid €${amountEur}\n\nNice work, ${handle}. Your LumaLine earnings transferred to your connected bank and land in a couple of business days. No action needed — payouts run automatically each week.`; + return { subject, html, text }; +} + +export function connectNudgeEmail({ handle, amountEur }) { + const subject = `You've got €${amountEur} waiting — connect your bank`; + const html = shell(` +

You've earned €${esc(amountEur)} 🎉

+

Hi ${esc(handle)} — your earnings are ready, but we don't have anywhere to send them yet. Connect your bank once and weekly payouts turn on automatically.

+

${cta("https://feed.lumaline.dev", "Run: lumaline connect")}

+

In your terminal: lumaline connect — you'll enter your IBAN on Stripe's secure page.

+`); + const text = `You've earned €${amountEur} 🎉\n\nHi ${handle} — your earnings are ready but we have nowhere to send them yet. Run \`lumaline connect\` in your terminal to add your bank (IBAN on Stripe's secure page). Weekly payouts then turn on automatically.`; + return { subject, html, text }; +} + +// Best-effort: NEVER throws. Returns 'sent' or 'failed:'. +export async function sendEmail({ to, subject, html, text, apiKey, from, fetchImpl = fetch }) { + if (!apiKey || !to) return "failed:not_configured"; + try { + const resp = await fetchImpl("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({ from, to: [to], subject, html, text }), + }); + return resp.ok ? "sent" : `failed:${resp.status}`; + } catch (err) { + return `failed:${(err && err.message) ? "network" : "unknown"}`; + } +} diff --git a/test/email-builders.test.mjs b/test/email-builders.test.mjs new file mode 100644 index 0000000..6d7893b --- /dev/null +++ b/test/email-builders.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { paidEmail, connectNudgeEmail, sendEmail } from '../supabase/functions/_shared/email.mjs'; + +const noExternal = (html) => assert.ok(!/https?:\/\/(?!c\.lumaline|feed\.lumaline)/i.test(html.replace(/mailto:[^"'\s]+/g,'')) || !/ { + const e = paidEmail({ handle: 'degen', amountEur: '1.10' }); + assert.match(e.subject, /paid|payout/i); + assert.match(e.html, /1\.10/); + assert.match(e.html, /degen/); + assert.ok(e.text && e.text.includes('1.10'), 'plaintext fallback present with amount'); + assert.ok(!/ { + const e = connectNudgeEmail({ handle: 'pat', amountEur: '3.00' }); + assert.match(e.subject, /waiting|connect/i); + assert.match(e.html, /lumaline connect/); + assert.match(e.html, /3\.00/); + assert.ok(e.text.includes('lumaline connect')); +}); + +test('escapes handle to prevent HTML injection', () => { + const e = paidEmail({ handle: '', amountEur: '1.00' }); + assert.ok(!e.html.includes(''), 'handle is escaped'); +}); + +test('sendEmail: posts to Resend with from/to/subject; returns sent on 200', async () => { + let body = null; + const fetchImpl = async (url, opts) => { body = JSON.parse(opts.body); return { ok: true, status: 200 }; }; + const r = await sendEmail({ to: 'a@b.c', subject: 's', html: 'h', text: 't', apiKey: 'k', from: 'LumaLine ', fetchImpl }); + assert.equal(r, 'sent'); + assert.equal(body.from, 'LumaLine '); + assert.deepEqual(body.to, ['a@b.c']); + assert.equal(body.html, 'h'); +}); + +test('sendEmail: missing apiKey/to → failed:not_configured, no throw', async () => { + const r = await sendEmail({ to: '', subject: 's', html: 'h', text: 't', apiKey: '', from: 'f' }); + assert.equal(r, 'failed:not_configured'); +}); From 38a2ffc7e6aaa9bb233937c660e729a4b1babca8 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 17:25:15 +0200 Subject: [PATCH 06/14] fix(payout): bound sendEmail with a timeout + never-throw on empty args + 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 --- supabase/functions/_shared/email.mjs | 3 ++- test/email-builders.test.mjs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/supabase/functions/_shared/email.mjs b/supabase/functions/_shared/email.mjs index 75917f6..d0e9d55 100644 --- a/supabase/functions/_shared/email.mjs +++ b/supabase/functions/_shared/email.mjs @@ -45,13 +45,14 @@ export function connectNudgeEmail({ handle, amountEur }) { } // Best-effort: NEVER throws. Returns 'sent' or 'failed:'. -export async function sendEmail({ to, subject, html, text, apiKey, from, fetchImpl = fetch }) { +export async function sendEmail({ to, subject, html, text, apiKey, from, fetchImpl = fetch, timeoutMs = 10000 } = {}) { if (!apiKey || !to) return "failed:not_configured"; try { const resp = await fetchImpl("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, body: JSON.stringify({ from, to: [to], subject, html, text }), + signal: AbortSignal.timeout(timeoutMs), }); return resp.ok ? "sent" : `failed:${resp.status}`; } catch (err) { diff --git a/test/email-builders.test.mjs b/test/email-builders.test.mjs index 6d7893b..96c8e1e 100644 --- a/test/email-builders.test.mjs +++ b/test/email-builders.test.mjs @@ -40,3 +40,8 @@ test('sendEmail: missing apiKey/to → failed:not_configured, no throw', async ( const r = await sendEmail({ to: '', subject: 's', html: 'h', text: 't', apiKey: '', from: 'f' }); assert.equal(r, 'failed:not_configured'); }); + +test('sendEmail: no arguments → failed:not_configured, no throw', async () => { + const r = await sendEmail(); + assert.equal(r, 'failed:not_configured'); +}); From f427d14f93be63b700c94ff2648084c4f85757c2 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 17:26:57 +0200 Subject: [PATCH 07/14] feat(payout): pure cron-secret constant-time compare + env min-micros parse --- supabase/functions/_shared/payout-logic.mjs | 15 +++++++++++++++ test/payout-logic.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 test/payout-logic.test.mjs diff --git a/supabase/functions/_shared/payout-logic.mjs b/supabase/functions/_shared/payout-logic.mjs index bb23120..2f511b0 100644 --- a/supabase/functions/_shared/payout-logic.mjs +++ b/supabase/functions/_shared/payout-logic.mjs @@ -62,3 +62,18 @@ export function reversedMicrosFromTransfer(transfer) { const cents = (transfer && (transfer.amount_reversed ?? transfer.amount)) ?? 0; return (Number(cents) || 0) * 10000; } + +/** Constant-time string compare (no early-exit on mismatch). Empty strings never authorize. */ +export function constantTimeEqual(a, b) { + const sa = String(a ?? ""), sb = String(b ?? ""); + if (sa.length === 0 || sb.length === 0 || sa.length !== sb.length) return false; + let diff = 0; + for (let i = 0; i < sa.length; i++) diff |= sa.charCodeAt(i) ^ sb.charCodeAt(i); + return diff === 0; +} + +/** Payout minimum in micro-EUR from env; default €1 (1_000_000). Garbage/≤0 → default. */ +export function payoutMinMicros(envVal) { + const n = Number(envVal); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1000000; +} diff --git a/test/payout-logic.test.mjs b/test/payout-logic.test.mjs new file mode 100644 index 0000000..07055a8 --- /dev/null +++ b/test/payout-logic.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { constantTimeEqual, payoutMinMicros } from '../supabase/functions/_shared/payout-logic.mjs'; + +test('constantTimeEqual: equal strings true, any diff false, length-mismatch false', () => { + assert.equal(constantTimeEqual('abc123', 'abc123'), true); + assert.equal(constantTimeEqual('abc123', 'abc124'), false); + assert.equal(constantTimeEqual('abc', 'abcd'), false); + assert.equal(constantTimeEqual('', ''), false); // empty never authorizes +}); + +test('payoutMinMicros: default €1 on absent/garbage/negative, honors valid', () => { + assert.equal(payoutMinMicros(undefined), 1000000); + assert.equal(payoutMinMicros(''), 1000000); + assert.equal(payoutMinMicros('nope'), 1000000); + assert.equal(payoutMinMicros('-5'), 1000000); + assert.equal(payoutMinMicros('0'), 1000000); // 0 would pay dust → clamp to default + assert.equal(payoutMinMicros('5000000'), 5000000); + assert.equal(payoutMinMicros(2500000), 2500000); +}); From 19e2e809af017c9a26212db306c2983303664171 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 17:33:25 +0200 Subject: [PATCH 08/14] =?UTF-8?q?feat(payout):=20auto-payout=20migration?= =?UTF-8?q?=20=E2=80=94=20nudge=20column=20+=20contact/nudge=20RPCs=20+=20?= =?UTF-8?q?run=5Fpayout=20cron=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/20260704150000_auto_payout.sql | 77 +++++++++++++++++++ test/auto-payout-sql.integration.mjs | 31 ++++++++ 2 files changed, 108 insertions(+) create mode 100644 supabase/migrations/20260704150000_auto_payout.sql create mode 100644 test/auto-payout-sql.integration.mjs diff --git a/supabase/migrations/20260704150000_auto_payout.sql b/supabase/migrations/20260704150000_auto_payout.sql new file mode 100644 index 0000000..817bfac --- /dev/null +++ b/supabase/migrations/20260704150000_auto_payout.sql @@ -0,0 +1,77 @@ +-- supabase/migrations/20260704150000_auto_payout.sql +-- M5-T4 auto-payout: a connect-nudge dedup column, publisher-contact + nudge-candidate RPCs, and a +-- pg_cron target (twin of app.run_monitor) that POSTs the payout batch with the Vault cron secret. +-- All money RPCs are SECURITY DEFINER + service_role-only. run_payout degrades to NOTICE+no-op when +-- Vault/pg_net/secret is absent, so a fresh `supabase db reset` applies and runs cleanly. +set local lock_timeout = '2s'; +set local statement_timeout = '15s'; + +alter table public.publishers add column if not exists connect_nudge_at timestamptz; + +-- Contact for a publisher (email + handle) from auth.users. SECDEF: crosses into the auth schema. +create or replace function app.publisher_contact(p_publisher_id uuid) +returns table(email text, handle text) +language sql security definer set search_path = '' as $$ + select u.email::text, p.handle + from public.publishers p + join auth.users u on u.id = p.auth_user_id + where p.id = p_publisher_id + limit 1; +$$; +revoke all on function app.publisher_contact(uuid) from public, anon, authenticated; +grant execute on function app.publisher_contact(uuid) to service_role; + +-- Publishers who have earned >= the minimum but have NOT onboarded a bank, not nudged in ~a week. +create or replace function app.payout_nudge_candidates(p_min_micros bigint, p_hold interval default interval '7 days') +returns table(publisher_id uuid, email text, handle text, payable_micros bigint) +language plpgsql security definer set search_path = '' as $$ +begin + return query + select p.id, u.email::text, p.handle, app.publisher_payable_micros(p.id, p_hold) + from public.publishers p + join auth.users u on u.id = p.auth_user_id + where p.stripe_account_id is null + and (p.connect_nudge_at is null or p.connect_nudge_at < now() - interval '6 days') + and app.publisher_payable_micros(p.id, p_hold) >= p_min_micros; +end; +$$; +revoke all on function app.payout_nudge_candidates(bigint, interval) from public, anon, authenticated; +grant execute on function app.payout_nudge_candidates(bigint, interval) to service_role; + +create or replace function app.mark_connect_nudged(p_ids uuid[]) +returns void language sql security definer set search_path = '' as $$ + update public.publishers set connect_nudge_at = now() where id = any(p_ids); +$$; +revoke all on function app.mark_connect_nudged(uuid[]) from public, anon, authenticated; +grant execute on function app.mark_connect_nudged(uuid[]) to service_role; + +-- pg_cron target. Reads 'lumaline_cron_secret' from Vault and POSTs /payout/batch with the +-- x-lumaline-cron-secret header via pg_net. Vault/secret/pg_net absent -> NOTICE + no-op. +create or replace function app.run_payout() +returns void language plpgsql security definer set search_path = '' as $$ +declare v_secret text; v_request_id bigint; +begin + if to_regclass('vault.decrypted_secrets') is null then + raise notice 'run_payout: vault.decrypted_secrets missing (fresh local stack?) — no-op'; return; + end if; + begin + execute 'select decrypted_secret from vault.decrypted_secrets where name = $1 limit 1' + into v_secret using 'lumaline_cron_secret'; + exception when others then raise notice 'run_payout: cannot read vault (%) — no-op', sqlerrm; return; end; + if v_secret is null or v_secret = '' then + raise notice 'run_payout: vault secret lumaline_cron_secret absent — no-op'; return; + end if; + begin + execute $q$ + select net.http_post( + url := 'https://prmsonskzrubqsazmpwd.supabase.co/functions/v1/stripe-connect/payout/batch', + body := '{}'::jsonb, + headers := jsonb_build_object('Content-Type','application/json','x-lumaline-cron-secret',$1), + timeout_milliseconds := 120000) + $q$ into v_request_id using v_secret; + exception when others then raise notice 'run_payout: net.http_post unavailable/failed (%) — no-op', sqlerrm; return; end; +end; +$$; +revoke all on function app.run_payout() from public, anon, authenticated; +comment on function app.run_payout is + 'pg_cron target: POST the payout batch with the Vault cron secret. Vault/secret/pg_net absent -> NOTICE + no-op. Controller cron.schedule''s this weekly at deploy.'; diff --git a/test/auto-payout-sql.integration.mjs b/test/auto-payout-sql.integration.mjs new file mode 100644 index 0000000..43e8004 --- /dev/null +++ b/test/auto-payout-sql.integration.mjs @@ -0,0 +1,31 @@ +// test/auto-payout-sql.integration.mjs +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const DB = process.env.SUPABASE_DB_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +let pg; try { pg = (await import('node:child_process')); } catch { /* */ } +const psql = (sql) => { + const r = pg.spawnSync('psql', [DB, '-Atc', sql], { encoding: 'utf8' }); + return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; +}; +const up = psql("select 1"); +const SKIP = !up.ok ? 'local DB unreachable — SKIPPING' : false; +if (SKIP) console.log(`[auto-payout-sql] ${SKIP}`); + +test('migration objects exist', { skip: SKIP }, () => { + assert.equal(psql("select count(*) from information_schema.columns where table_schema='public' and table_name='publishers' and column_name='connect_nudge_at'").out, '1'); + for (const fn of ['publisher_contact', 'payout_nudge_candidates', 'mark_connect_nudged', 'run_payout']) { + assert.equal(psql(`select count(*) from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='app' and p.proname='${fn}'`).out, '1', `app.${fn} exists`); + } +}); + +test('run_payout no-ops cleanly when Vault secret absent (fresh stack)', { skip: SKIP }, () => { + const r = psql("select app.run_payout()"); + assert.ok(r.ok, `run_payout ran without error: ${r.err}`); +}); + +test('anon/authenticated cannot execute the new money RPCs', { skip: SKIP }, () => { + for (const sig of ['app.publisher_contact(uuid)', 'app.payout_nudge_candidates(bigint,interval)', 'app.mark_connect_nudged(uuid[])', 'app.run_payout()']) { + assert.equal(psql(`select has_function_privilege('anon','${sig}','execute')`).out, 'f', `anon cannot ${sig}`); + } +}); From 7b4aeca28db05f02744758290f0efb52032a1580 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 17:46:46 +0200 Subject: [PATCH 09/14] =?UTF-8?q?feat(payout):=20stripe-connect=20cron-sec?= =?UTF-8?q?ret=20auth=20+=20=E2=82=AC1=20min=20+=20best-effort=20paid/nudg?= =?UTF-8?q?e=20emails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- supabase/functions/_shared/email.mjs | 5 +++ supabase/functions/stripe-connect/index.ts | 51 +++++++++++++++++++++- test/auto-payout.integration.mjs | 20 +++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 test/auto-payout.integration.mjs diff --git a/supabase/functions/_shared/email.mjs b/supabase/functions/_shared/email.mjs index d0e9d55..0f81bde 100644 --- a/supabase/functions/_shared/email.mjs +++ b/supabase/functions/_shared/email.mjs @@ -45,6 +45,11 @@ export function connectNudgeEmail({ handle, amountEur }) { } // Best-effort: NEVER throws. Returns 'sent' or 'failed:'. +/** + * @param {{ to?: string, subject?: string, html?: string, text?: string, apiKey?: string, + * from?: string, fetchImpl?: typeof fetch, timeoutMs?: number }} [args] + * @returns {Promise} + */ export async function sendEmail({ to, subject, html, text, apiKey, from, fetchImpl = fetch, timeoutMs = 10000 } = {}) { if (!apiKey || !to) return "failed:not_configured"; try { diff --git a/supabase/functions/stripe-connect/index.ts b/supabase/functions/stripe-connect/index.ts index f6cc624..d34bac7 100644 --- a/supabase/functions/stripe-connect/index.ts +++ b/supabase/functions/stripe-connect/index.ts @@ -39,8 +39,11 @@ import { classifyTransferError, sumLumalineTransfersMicros, reversedMicrosFromTransfer, + constantTimeEqual, + payoutMinMicros, } from "../_shared/payout-logic.mjs"; import { parseWebhookSecrets } from "../_shared/webhook-secrets.mjs"; +import { paidEmail, connectNudgeEmail, sendEmail } from "../_shared/email.mjs"; const cors = { ...corsHeaders, "Access-Control-Allow-Methods": "GET, POST, OPTIONS" } as const; @@ -100,6 +103,14 @@ async function requireAdmin(req: Request): Promise { return status === 200 && text.trim() === "true" ? auth : null; } +// pg_cron auth — the weekly auto-payout job authenticates with a shared secret (Vault-stored, +// see app.run_payout) instead of an admin JWT. Constant-time compare; empty never authorizes. +function hasValidCronSecret(req: Request): boolean { + const got = req.headers.get("x-lumaline-cron-secret") ?? ""; + const want = Deno.env.get("LUMALINE_CRON_SECRET") ?? ""; + return constantTimeEqual(got, want); +} + // Fetch the CALLER's own publisher row via RLS (publishers_select_own). Returns null if the // bearer doesn't resolve to a publisher. We pass the caller's JWT so RLS scopes to their row. async function callerPublisher( @@ -290,7 +301,10 @@ Deno.serve(async (req) => { } // ---- Admin-only routes below -------------------------------------------------------- - const adminAuth = await requireAdmin(req); + // Privileged routes: an admin JWT OR the pg_cron secret (weekly auto-payout). Both are trusted; + // the cron only ever calls /payout/batch, and /reconcile is read-only. + const cron = hasValidCronSecret(req); + const adminAuth = cron ? "cron" : await requireAdmin(req); if (!adminAuth) return jsonErr("Forbidden", 403); // ---- POST /payout/batch[?dry_run=true] (admin) -------------------------------------- @@ -298,7 +312,8 @@ Deno.serve(async (req) => { const dryRun = url.searchParams.get("dry_run") === "true"; // Phase 1: reserve pending payouts (no ledger). Idempotent via the one-active index. - const reserve = await serviceRpc("payout_batch_reserve", {}); + const minMicros = payoutMinMicros(Deno.env.get("LUMALINE_PAYOUT_MIN_MICROS")); + const reserve = await serviceRpc("payout_batch_reserve", { p_min_micros: minMicros }); if (!reserve.ok) return jsonErr("reserve failed", reserve.status, reserve.data); // TRAP #2: transfer EVERY db-pending payout with no transfer id (recovers crashes), @@ -399,6 +414,38 @@ Deno.serve(async (req) => { } } + // ---- Notifications (best-effort; a failure here never affects a payout) ------------ + try { + const apiKey = Deno.env.get("RESEND_API_KEY") ?? ""; + const from = Deno.env.get("LUMALINE_EMAIL_FROM") ?? "LumaLine "; + const eur = (c: number) => (c / 100).toFixed(2); + + // Paid confirmations + for (const r of results.filter((x) => x.status === "paid")) { + const po = pending.find((p) => p.id === r.payout_id); + if (!po) continue; + const c = await serviceRpc("publisher_contact", { p_publisher_id: po.publisher_id }); + const contact = (c.ok ? c.data : null) as { email?: string; handle?: string } | null; + if (!contact?.email) continue; + const { subject, html, text } = paidEmail({ handle: contact.handle ?? "there", amountEur: eur(microsToCents(po.amount_micros)) }); + await sendEmail({ to: contact.email, subject, html, text, apiKey, from }); + } + + // Connect-nudges (over-min, not onboarded, not nudged in ~a week) + const nudge = await serviceRpc("payout_nudge_candidates", { p_min_micros: minMicros }); + const cands = (nudge.ok && Array.isArray(nudge.data) ? nudge.data : []) as Array<{ publisher_id: string; email: string; handle: string; payable_micros: number }>; + const nudged: string[] = []; + for (const cnd of cands) { + if (!cnd.email) continue; + const { subject, html, text } = connectNudgeEmail({ handle: cnd.handle ?? "there", amountEur: eur(microsToCents(cnd.payable_micros)) }); + const res = await sendEmail({ to: cnd.email, subject, html, text, apiKey, from }); + if (res === "sent") nudged.push(cnd.publisher_id); + } + if (nudged.length > 0) await serviceRpc("mark_connect_nudged", { p_ids: nudged }); + } catch (err) { + console.error(`payout: notify pass failed (non-fatal): ${(err as { message?: string }).message ?? "unknown"}`); + } + const paid = results.filter((r) => r.status === "paid").length; const deferred = results.filter((r) => r.status === "deferred").length; const failed = results.filter((r) => r.status === "failed").length; diff --git a/test/auto-payout.integration.mjs b/test/auto-payout.integration.mjs new file mode 100644 index 0000000..acdea27 --- /dev/null +++ b/test/auto-payout.integration.mjs @@ -0,0 +1,20 @@ +// test/auto-payout.integration.mjs — needs local stack + `supabase functions serve stripe-connect`. +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const FN = process.env.STRIPE_CONNECT_URL || 'http://127.0.0.1:54321/functions/v1/stripe-connect'; +async function up() { try { const r = await fetch(`${FN}/payout/batch`, { method: 'OPTIONS', signal: AbortSignal.timeout(2000) }); return r.status === 200; } catch { return false; } } +const SKIP = !(await up()) ? 'stripe-connect fn not served — SKIPPING' : false; +if (SKIP) console.log(`[auto-payout.integration] ${SKIP}`); + +test('payout/batch: bad cron secret is rejected (403)', { skip: SKIP }, async () => { + const r = await fetch(`${FN}/payout/batch?dry_run=true`, { method: 'POST', headers: { 'x-lumaline-cron-secret': 'wrong', 'content-type': 'application/json' }, body: '{}' }); + assert.equal(r.status, 403); +}); + +test('payout/batch: valid cron secret authorizes the dry-run', { skip: SKIP || !process.env.LUMALINE_CRON_SECRET }, async () => { + const r = await fetch(`${FN}/payout/batch?dry_run=true`, { method: 'POST', headers: { 'x-lumaline-cron-secret': process.env.LUMALINE_CRON_SECRET, 'content-type': 'application/json' }, body: '{}' }); + assert.equal(r.status, 200); + const b = await r.json(); + assert.equal(b.ok, true); assert.equal(b.dry_run, true); +}); From 0e555414e80abccbf313a9e11feb4ca137b70443 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 17:52:32 +0200 Subject: [PATCH 10/14] chore(payout): client 0.1.4 + owner-gated M5-T4 deploy runbook 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 --- docs/ops/m5-t4-deploy.md | 81 ++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 docs/ops/m5-t4-deploy.md diff --git a/docs/ops/m5-t4-deploy.md b/docs/ops/m5-t4-deploy.md new file mode 100644 index 0000000..247b287 --- /dev/null +++ b/docs/ops/m5-t4-deploy.md @@ -0,0 +1,81 @@ +# M5-T4 Auto-Payout — owner-gated deploy runbook + +Deploys the auto-payout system (branch `feat/m5-t4-auto-payout`): `lumaline connect`, weekly +`pg_cron → /payout/batch`, €1 minimum, branded paid + connect-nudge emails. + +**Every remote step is ref-guarded to `prmsonskzrubqsazmpwd` (NEVER the CRM `kvlfpwzmjxuapjheknnj`) +and requires explicit per-step owner GO.** The money core (reserve→transfer→confirm) is unchanged; +this deploy adds a cron trigger, a cron-secret auth path, the €1 min, and best-effort emails. + +## Pre-flight (read-only) +- Confirm `main` has merged this branch's PR (or you are deploying from the branch intentionally). +- Confirm the local suite is green: `node --test` (unit) + the integration files pass against the local stack. +- Confirm the Vault secret `lumaline_cron_secret` already exists on prod (the monitor cron uses it) — + the payout cron reuses the SAME secret. `select name from vault.secrets where name='lumaline_cron_secret';` + +## Step 1 — Apply the migration (owner GO) +Apply `supabase/migrations/20260704150000_auto_payout.sql` to prod via the ref-guarded runner, then +stamp `schema_migrations`: +``` +node /sql.mjs @supabase/migrations/20260704150000_auto_payout.sql +node /sql.mjs "insert into supabase_migrations.schema_migrations (version,name) values ('20260704150000','auto_payout') on conflict (version) do nothing returning version;" +``` +Verify: `connect_nudge_at` column exists; the 4 `app.*` functions exist; `anon`/`authenticated` +cannot execute them; `app.run_payout()` runs without error (no-ops or posts). + +## Step 2 — Set fn env on `stripe-connect` (owner GO) +Ensure these are set for the `stripe-connect` function (via `supabase secrets set` / dashboard): +- `LUMALINE_CRON_SECRET` = the SAME value as Vault `lumaline_cron_secret` (so the fn's constant-time + compare matches what `app.run_payout()` sends). +- `LUMALINE_PAYOUT_MIN_MICROS=1000000` (€1). +- `LUMALINE_EMAIL_FROM` (optional; default `LumaLine `). +- Confirm `RESEND_API_KEY` is present, AND that the `from` domain (`send.lumaline.dev`) is verified in + Resend for API sends. If it is NOT verified, set `LUMALINE_EMAIL_FROM="LumaLine "` + as a fallback so paid/nudge emails still deliver. + +## Step 3 — Redeploy the fn (owner GO) +``` +SUPABASE_ACCESS_TOKEN= supabase functions deploy stripe-connect --project-ref prmsonskzrubqsazmpwd --use-api +``` +This bundles the new `_shared/email.mjs` + `_shared/payout-logic.mjs` helpers via the import graph. + +## Step 4 — Schedule the weekly cron (owner GO) +``` +node /sql.mjs "select cron.schedule('lumaline-payout-weekly','0 9 * * 1','select app.run_payout()');" +``` +Verify: `select jobname, schedule, active from cron.job where jobname='lumaline-payout-weekly';` + +## Step 5 — Smoke test (no money moves) +Admin dry-run (uses an admin JWT, NOT the cron secret) — confirms the €1-min plan and that NO transfer +is attempted: +``` +# GET/POST /payout/batch?dry_run=true with an admin bearer → {ok:true, dry_run:true, reserved, would_transfer} +``` +Optionally verify the cron path: `select app.run_payout();` on prod → it POSTs `/payout/batch`; since +nothing is over-min-and-onboarded yet, `paid:0`. Check `net._http_response` / fn logs for a 200. + +## Step 6 — Publish the client (owner GO) +The `lumaline connect` command ships via npm. Bump is already in `package.json` (0.1.4). Tag + release: +``` +git tag v0.1.4 && git push origin v0.1.4 # release.yml → npm publish --provenance +``` +(Installed clients do not self-update — 0.1.4 reaches new installs + `npm update -g lumaline`.) + +## Step 7 — Announce +Tell publisher(s) to run `lumaline connect` to add their bank. Once a publisher's matured (7-day-held) +payable crosses €1, the Monday 09:00 UTC cron pays them automatically and emails a confirmation. +Un-onboarded publishers with ≥ €1 waiting get a weekly connect-nudge email (deduped to ~weekly). + +## Rollback +- Unschedule: `select cron.unschedule('lumaline-payout-weekly');` — stops auto-payouts (the manual + admin `/payout/batch` still works). +- The migration is additive; no data is destroyed. To fully revert the fn, redeploy the prior + `stripe-connect` build. The cron-secret auth + €1 min + emails are inert without the cron schedule. + +## Money-safety notes +- The transfer/confirm core is unchanged and remains double-pay-safe (idempotency key per payout + + `UNIQUE(stripe_transfer_id)` + ambiguous-error self-heal). +- Emails are best-effort (bounded timeout, never throw) and run AFTER the money loop — a Resend outage + never blocks, reverses, or fails a payout. +- `LUMALINE_CRON_SECRET` unset/empty → the cron auth path fails closed (no one is authorized by an + empty secret); the admin JWT path is unaffected. diff --git a/package.json b/package.json index 2e1e5ec..4513163 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumaline", - "version": "0.1.3", + "version": "0.1.4", "description": "Trust-first sponsored status line for Claude Code. Official statusLine only — signed feed, no bundle patching, no CSP changes.", "type": "module", "bin": { From ef93254efdaef35444f7db9248060648c677f536 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 17:54:12 +0200 Subject: [PATCH 11/14] test(payout): e2e nudge candidate + dedup + onboarded-exclusion proof 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). --- test/auto-payout-nudge.integration.mjs | 115 +++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 test/auto-payout-nudge.integration.mjs diff --git a/test/auto-payout-nudge.integration.mjs b/test/auto-payout-nudge.integration.mjs new file mode 100644 index 0000000..fe41e84 --- /dev/null +++ b/test/auto-payout-nudge.integration.mjs @@ -0,0 +1,115 @@ +// test/auto-payout-nudge.integration.mjs — M5-T4 auto-payout nudge candidates + dedup (Stripe-free). +// +// Exercises app.payout_nudge_candidates + app.mark_connect_nudged (20260704150000_auto_payout.sql) +// directly against the local Supabase DB via psql — the connecting role (postgres) is a superuser +// and bypasses the service_role-only grants, same trick test/auto-payout-sql.integration.mjs relies +// on for `select app.run_payout()`. Setup mirrors test/payout-rails.integration.mjs's +// auth.users -> publishers -> matured cleared earnings fixture (addEarningMicros). No Stripe / REST +// involved — this is pure SQL-layer proof. +// +// N1 — un-onboarded (stripe_account_id null), over-minimum publisher is a nudge candidate, with the +// right email/handle/payable_micros. +// N2 — mark_connect_nudged(ARRAY[id]) sets connect_nudge_at. +// N3 — a second candidates call excludes the just-nudged publisher (deduped within 6 days). +// N4 — negative control: an onboarded (stripe_account_id set) over-minimum publisher is never a +// candidate. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; + +const DB_URL = process.env.SUPABASE_DB_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; + +function psql(sql) { + return execFileSync('psql', [DB_URL, '-tAqc', sql], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} +function psqlWorks() { try { return psql('select 1') === '1'; } catch { return false; } } + +const SKIP = psqlWorks() ? false : 'local DB unreachable — SKIPPING'; +if (SKIP) console.log(`[auto-payout-nudge] ${SKIP}`); + +const MIN = 1_000_000; // €1 minimum, per the task spec +const HOLD_SQL = "interval '7 days'"; + +function newPublisher({ withAcct = false, country = 'US' } = {}) { + const authId = randomUUID(), pubId = randomUUID(); + const email = `nudge-${authId}@example.com`; + const handle = `po-${pubId.slice(0, 8)}`; + psql(`insert into auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token, recovery_token, email_change_token_new, email_change) + values ('00000000-0000-0000-0000-000000000000','${authId}','authenticated','authenticated', + '${email}','',now(),'{"provider":"email","providers":["email"]}','{}',now(),now(),'','','','');`); + psql(`insert into public.publishers (id, auth_user_id, handle, country, stripe_account_id, payout_status, status) + values ('${pubId}','${authId}','${handle}','${country}', + ${withAcct ? `'acct_test_${pubId.slice(0, 8)}'` : 'null'}, 'verified', 'active');`); + return { authId, pubId, email, handle }; +} + +/** Add a cleared cpva_accrual earning of `pubMicros`, backed by an impression aged `ageDays`. */ +function addEarningMicros(pubId, pubMicros, ageDays = 10) { + const impId = randomUUID(), winId = randomUUID(), grp = randomUUID(); + const gross = Math.round(pubMicros / 0.6); // doesn't have to balance 60/40 exactly; explicit legs below + const plat = gross - pubMicros; + psql(`insert into public.impressions (id, window_id, publisher_id, attention_seconds, gross_micros, state, created_at) + values ('${impId}','${winId}','${pubId}',5,${gross},'cleared', now() - interval '${ageDays} days');`); + psql(`insert into public.ledger_entries (entry_group_id,event_type,account,amount_micros,state,source_type,source_id,publisher_id) values + ('${grp}','cpva_accrual','advertiser_billing',${gross},'cleared','impression','${impId}',null), + ('${grp}','cpva_accrual','publisher_earnings',${-pubMicros},'cleared','impression','${impId}','${pubId}'), + ('${grp}','cpva_accrual','platform_revenue',${-plat},'cleared','impression','${impId}',null);`); + return { impId, grp }; +} + +const created = []; +function makeFull(opts) { const p = newPublisher(opts); created.push(p); return p; } +function teardown() { + for (const { authId, pubId } of created) { + try { + psql(`delete from public.ledger_entries where publisher_id='${pubId}' + or entry_group_id in (select entry_group_id from public.ledger_entries where source_id in (select id from public.impressions where publisher_id='${pubId}'));`); + psql(`delete from public.impressions where publisher_id='${pubId}';`); + psql(`delete from public.devices where publisher_id='${pubId}';`); + psql(`delete from public.publishers where id='${pubId}';`); + psql(`delete from auth.users where id='${authId}';`); + } catch { /* best-effort */ } + } +} +if (!SKIP) process.on('exit', teardown); + +/** Row (as `id|email|handle|payable`) for `pubId` in the current nudge-candidates result, or '' if absent. */ +function candidateRow(pubId) { + return psql(`select coalesce(t.publisher_id::text,'') ||'|'|| coalesce(t.email,'') ||'|'|| coalesce(t.handle,'') ||'|'|| coalesce(t.payable_micros::text,'') + from app.payout_nudge_candidates(${MIN}, ${HOLD_SQL}) t where t.publisher_id = '${pubId}'::uuid;`); +} + +test('N1/N2/N3: un-onboarded over-min publisher is a nudge candidate; mark_connect_nudged dedupes it', { skip: SKIP }, () => { + const pub = makeFull({ withAcct: false }); + addEarningMicros(pub.pubId, 1_500_000, 10); // matured (past 7d hold), > MIN + + // N1: appears as a candidate with the right contact + payable. + const row = candidateRow(pub.pubId); + assert.notEqual(row, '', 'un-onboarded, over-minimum publisher must appear as a nudge candidate'); + const [id, email, handle, payable] = row.split('|'); + assert.equal(id, pub.pubId, 'candidate publisher_id must match'); + assert.equal(email, pub.email, 'candidate email must match auth.users.email'); + assert.equal(handle, pub.handle, 'candidate handle must match publishers.handle'); + assert.ok(Number(payable) >= MIN, `payable_micros ${payable} must be >= ${MIN}`); + + // N2: mark_connect_nudged sets connect_nudge_at. + const before = psql(`select coalesce(connect_nudge_at::text,'') from public.publishers where id='${pub.pubId}';`); + assert.equal(before, '', 'connect_nudge_at must start unset'); + psql(`select app.mark_connect_nudged(array['${pub.pubId}']::uuid[]);`); + const after = psql(`select coalesce(connect_nudge_at::text,'') from public.publishers where id='${pub.pubId}';`); + assert.notEqual(after, '', 'connect_nudge_at must be set after mark_connect_nudged'); + + // N3: a second candidates call excludes the just-nudged publisher (deduped within 6 days). + const row2 = candidateRow(pub.pubId); + assert.equal(row2, '', 'just-nudged publisher must be excluded from a second candidates call'); +}); + +test('N4: onboarded (stripe_account_id set) over-minimum publisher is never a nudge candidate', { skip: SKIP }, () => { + const pub = makeFull({ withAcct: true }); + addEarningMicros(pub.pubId, 1_500_000, 10); // matured, > MIN, but onboarded + const row = candidateRow(pub.pubId); + assert.equal(row, '', 'onboarded publisher must not be a nudge candidate regardless of payable balance'); +}); From 8f6af6b305ab145cc72d719831b25da26a558367 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 18:13:00 +0200 Subject: [PATCH 12/14] fix(payout): read nudge candidates as rows (serviceRpc collapses arrays) + response counts + min>=1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- supabase/functions/_shared/jwt.ts | 29 +++++ supabase/functions/_shared/payout-logic.mjs | 4 +- supabase/functions/stripe-connect/index.ts | 34 ++++- test/auto-payout-nudge-fn.integration.mjs | 135 ++++++++++++++++++++ test/payout-logic.test.mjs | 1 + 5 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 test/auto-payout-nudge-fn.integration.mjs diff --git a/supabase/functions/_shared/jwt.ts b/supabase/functions/_shared/jwt.ts index cf880fd..ff9dbda 100644 --- a/supabase/functions/_shared/jwt.ts +++ b/supabase/functions/_shared/jwt.ts @@ -95,6 +95,35 @@ export async function serviceRpc( return { ok: resp.ok, status: resp.status, data }; } +/** + * Call an RPC with the SERVICE ROLE key, returning the parsed JSON result UNMODIFIED (no + * array-unwrap). Use this for SET-returning ("returns table(...)") RPCs — PostgREST replies + * with a JSON array of rows, and `serviceRpc`'s `data[0] ?? null` unwrap would silently + * collapse that array to (at most) its first row. Byte-identical to `serviceRpc` otherwise. + */ +export async function serviceRpcRows( + fnName: string, + body: Record, +): Promise<{ ok: boolean; status: number; data: unknown }> { + const resp = await fetch(`${RPC_BASE}/${fnName}`, { + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json", + "apikey": SERVICE_ROLE_KEY, + "authorization": `Bearer ${SERVICE_ROLE_KEY}`, + }, + body: JSON.stringify(body), + }); + let data: unknown = null; + try { + data = await resp.json(); + } catch { + data = null; + } + return { ok: resp.ok, status: resp.status, data }; +} + /** * OPTIONAL, NOT USED BY DEFAULT — HS256 verification of a device JWT against the project * JWT secret, for an edge function that wants to gate before forwarding. Forwarding is diff --git a/supabase/functions/_shared/payout-logic.mjs b/supabase/functions/_shared/payout-logic.mjs index 2f511b0..d607166 100644 --- a/supabase/functions/_shared/payout-logic.mjs +++ b/supabase/functions/_shared/payout-logic.mjs @@ -72,8 +72,8 @@ export function constantTimeEqual(a, b) { return diff === 0; } -/** Payout minimum in micro-EUR from env; default €1 (1_000_000). Garbage/≤0 → default. */ +/** Payout minimum in micro-EUR from env; default €1 (1_000_000). Garbage/<1 → default. */ export function payoutMinMicros(envVal) { const n = Number(envVal); - return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1000000; + return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1000000; } diff --git a/supabase/functions/stripe-connect/index.ts b/supabase/functions/stripe-connect/index.ts index d34bac7..fbaff7e 100644 --- a/supabase/functions/stripe-connect/index.ts +++ b/supabase/functions/stripe-connect/index.ts @@ -30,6 +30,7 @@ import { bearerHeader, forwardRpc, serviceRpc, + serviceRpcRows, SUPABASE_URL, ANON_KEY, SERVICE_ROLE_KEY, @@ -415,6 +416,10 @@ Deno.serve(async (req) => { } // ---- Notifications (best-effort; a failure here never affects a payout) ------------ + // Counters live OUTSIDE the try/catch so they always appear in the response, even if the + // notify pass throws before finishing (review finding 2). + let nudgeCandidates = 0; + let nudged = 0; try { const apiKey = Deno.env.get("RESEND_API_KEY") ?? ""; const from = Deno.env.get("LUMALINE_EMAIL_FROM") ?? "LumaLine "; @@ -431,17 +436,23 @@ Deno.serve(async (req) => { await sendEmail({ to: contact.email, subject, html, text, apiKey, from }); } - // Connect-nudges (over-min, not onboarded, not nudged in ~a week) - const nudge = await serviceRpc("payout_nudge_candidates", { p_min_micros: minMicros }); + // Connect-nudges (over-min, not onboarded, not nudged in ~a week). + // review finding 1: payout_nudge_candidates is SET-returning (a JSON array), so it MUST + // go through serviceRpcRows — serviceRpc unwraps arrays to a single row and would + // silently turn every candidate list into [] (or a lone object), so no nudge would ever + // send and mark_connect_nudged would never be called. + const nudge = await serviceRpcRows("payout_nudge_candidates", { p_min_micros: minMicros }); const cands = (nudge.ok && Array.isArray(nudge.data) ? nudge.data : []) as Array<{ publisher_id: string; email: string; handle: string; payable_micros: number }>; - const nudged: string[] = []; + nudgeCandidates = cands.length; + const nudgedIds: string[] = []; for (const cnd of cands) { if (!cnd.email) continue; const { subject, html, text } = connectNudgeEmail({ handle: cnd.handle ?? "there", amountEur: eur(microsToCents(cnd.payable_micros)) }); const res = await sendEmail({ to: cnd.email, subject, html, text, apiKey, from }); - if (res === "sent") nudged.push(cnd.publisher_id); + if (res === "sent") nudgedIds.push(cnd.publisher_id); } - if (nudged.length > 0) await serviceRpc("mark_connect_nudged", { p_ids: nudged }); + nudged = nudgedIds.length; + if (nudgedIds.length > 0) await serviceRpc("mark_connect_nudged", { p_ids: nudgedIds }); } catch (err) { console.error(`payout: notify pass failed (non-fatal): ${(err as { message?: string }).message ?? "unknown"}`); } @@ -449,7 +460,18 @@ Deno.serve(async (req) => { const paid = results.filter((r) => r.status === "paid").length; const deferred = results.filter((r) => r.status === "deferred").length; const failed = results.filter((r) => r.status === "failed").length; - return jsonOk({ ok: true, dry_run: false, reserved: reserve.data, paid, deferred, failed, processed: results.length, results }); + return jsonOk({ + ok: true, + dry_run: false, + reserved: reserve.data, + paid, + deferred, + failed, + processed: results.length, + nudge_candidates: nudgeCandidates, + nudged, + results, + }); } // ---- GET /reconcile?from&to (admin) ------------------------------------------------- diff --git a/test/auto-payout-nudge-fn.integration.mjs b/test/auto-payout-nudge-fn.integration.mjs new file mode 100644 index 0000000..fc5f979 --- /dev/null +++ b/test/auto-payout-nudge-fn.integration.mjs @@ -0,0 +1,135 @@ +// test/auto-payout-nudge-fn.integration.mjs — proves the FIX for review finding 1 through the +// REAL served function (not psql): app.payout_nudge_candidates is SET-returning (a JSON array), +// and the fix routes it through `serviceRpcRows` (which does NOT unwrap arrays) instead of +// `serviceRpc` (which collapses any array to its first element, so `cands` was always `[]`). +// +// This test hits the live `/payout/batch` endpoint (non-dry-run) and asserts the JSON response +// carries `nudge_candidates >= 1` — that number can ONLY be > 0 if the edge function actually +// read the candidate rows back as an array, which is exactly what `serviceRpc`'s old unwrap +// prevented. `nudged` may legitimately be 0 (no RESEND_API_KEY configured locally) — that's not +// what this test is proving. +// +// SAFETY (money core untouched, but this DOES hit non-dry-run /payout/batch): before seeding +// anything, we run a `dry_run=true` pass first and require `would_transfer` to be EMPTY. That +// dry-run reuses the exact same reserve + "every db-pending payout" logic as the live path +// (traps #2), so an empty `would_transfer` guarantees the live call moves zero Stripe money. +// If it's non-empty (a reservable onboarded publisher, or a leftover pending payout, already +// exists in the local DB) we SKIP rather than risk a real transfer. Our own seeded publisher is +// deliberately UN-ONBOARDED (stripe_account_id NULL), so payout_batch_reserve can never select +// it either way — see 20260629100000_payout_rails.sql's reserve WHERE clause. +// +// Requires: `supabase functions serve stripe-connect --no-verify-jwt --env-file +// supabase/functions/.env` (or equivalent) running locally with LUMALINE_CRON_SECRET set to the +// SAME value this test sends in `x-lumaline-cron-secret`. Self-skips if either is missing. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; + +const FN = process.env.STRIPE_CONNECT_URL || 'http://127.0.0.1:54321/functions/v1/stripe-connect'; +const CRON_SECRET = process.env.LUMALINE_CRON_SECRET || ''; +const DB_URL = process.env.SUPABASE_DB_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; + +async function up() { + try { + const r = await fetch(`${FN}/payout/batch`, { method: 'OPTIONS', signal: AbortSignal.timeout(2000) }); + return r.status === 200; + } catch { + return false; + } +} +const FN_UP = await up(); + +let SKIP = false; +if (!FN_UP) SKIP = 'stripe-connect fn not served — SKIPPING'; +else if (!CRON_SECRET) SKIP = 'LUMALINE_CRON_SECRET not set in this shell — SKIPPING (must match the served fn\'s env)'; +if (SKIP) console.log(`[auto-payout-nudge-fn] ${SKIP}`); + +function psql(sql) { + return execFileSync('psql', [DB_URL, '-tAqc', sql], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +function newPublisher({ withAcct = false, country = 'US' } = {}) { + const authId = randomUUID(), pubId = randomUUID(); + const email = `nudgefn-${authId}@example.com`; + const handle = `po-${pubId.slice(0, 8)}`; + psql(`insert into auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token, recovery_token, email_change_token_new, email_change) + values ('00000000-0000-0000-0000-000000000000','${authId}','authenticated','authenticated', + '${email}','',now(),'{"provider":"email","providers":["email"]}','{}',now(),now(),'','','','');`); + psql(`insert into public.publishers (id, auth_user_id, handle, country, stripe_account_id, payout_status, status) + values ('${pubId}','${authId}','${handle}','${country}', + ${withAcct ? `'acct_test_${pubId.slice(0, 8)}'` : 'null'}, 'verified', 'active');`); + return { authId, pubId, email, handle }; +} + +/** Add a cleared cpva_accrual earning of `pubMicros`, backed by an impression aged `ageDays`. */ +function addEarningMicros(pubId, pubMicros, ageDays = 10) { + const impId = randomUUID(), winId = randomUUID(), grp = randomUUID(); + const gross = Math.round(pubMicros / 0.6); + const plat = gross - pubMicros; + psql(`insert into public.impressions (id, window_id, publisher_id, attention_seconds, gross_micros, state, created_at) + values ('${impId}','${winId}','${pubId}',5,${gross},'cleared', now() - interval '${ageDays} days');`); + psql(`insert into public.ledger_entries (entry_group_id,event_type,account,amount_micros,state,source_type,source_id,publisher_id) values + ('${grp}','cpva_accrual','advertiser_billing',${gross},'cleared','impression','${impId}',null), + ('${grp}','cpva_accrual','publisher_earnings',${-pubMicros},'cleared','impression','${impId}','${pubId}'), + ('${grp}','cpva_accrual','platform_revenue',${-plat},'cleared','impression','${impId}',null);`); +} + +const created = []; +function teardown() { + for (const { authId, pubId } of created) { + try { + psql(`delete from public.ledger_entries where publisher_id='${pubId}' + or entry_group_id in (select entry_group_id from public.ledger_entries where source_id in (select id from public.impressions where publisher_id='${pubId}'));`); + psql(`delete from public.impressions where publisher_id='${pubId}';`); + psql(`delete from public.devices where publisher_id='${pubId}';`); + psql(`delete from public.payouts where publisher_id='${pubId}';`); + psql(`delete from public.publishers where id='${pubId}';`); + psql(`delete from auth.users where id='${authId}';`); + } catch { /* best-effort */ } + } +} +if (!SKIP) process.on('exit', teardown); + +async function postBatch(dryRun) { + const r = await fetch(`${FN}/payout/batch${dryRun ? '?dry_run=true' : ''}`, { + method: 'POST', + headers: { 'x-lumaline-cron-secret': CRON_SECRET, 'content-type': 'application/json' }, + body: '{}', + }); + return { status: r.status, body: await r.json() }; +} + +test('nudge_candidates in /payout/batch response proves serviceRpcRows reads the array (not serviceRpc\'s collapse)', { skip: SKIP }, async () => { + // SAFETY: refuse to risk a live transfer if the DB already has a reservable onboarded + // publisher (or a leftover db-pending payout) sitting around. + const pre = await postBatch(true); + assert.equal(pre.status, 200, `dry-run pre-check must succeed: ${JSON.stringify(pre.body)}`); + assert.equal(pre.body.ok, true, 'dry-run pre-check must report ok:true'); + const wouldTransfer = Array.isArray(pre.body.would_transfer) ? pre.body.would_transfer : []; + if (wouldTransfer.length > 0) { + console.log(`[auto-payout-nudge-fn] SKIPPING: ${wouldTransfer.length} payout(s) would already transfer — refusing to risk a live Stripe transfer in this test`); + return; // bail out of the test body without asserting further (safety over coverage) + } + + // Seed ONLY an un-onboarded publisher (stripe_account_id NULL) with matured, over-min + // earnings. payout_batch_reserve can never select it (requires stripe_account_id IS NOT + // NULL), so this seed cannot itself trigger a transfer — it can only make it a nudge + // candidate. + const pub = newPublisher({ withAcct: false }); + created.push(pub); + addEarningMicros(pub.pubId, 1_500_000, 10); // > €1 min, past the 7-day hold + + const live = await postBatch(false); + assert.equal(live.status, 200, `live /payout/batch must succeed: ${JSON.stringify(live.body)}`); + assert.equal(live.body.ok, true, 'live response must report ok:true'); + assert.equal(live.body.paid, 0, 'no onboarded publisher exists (guarded by the dry-run pre-check) -> paid must be 0'); + assert.equal(typeof live.body.nudge_candidates, 'number', 'response must carry a numeric nudge_candidates counter'); + assert.ok( + live.body.nudge_candidates >= 1, + `nudge_candidates must be >= 1 (got ${live.body.nudge_candidates}) — proves payout_nudge_candidates' array was read via serviceRpcRows, not collapsed by serviceRpc`, + ); + assert.equal(typeof live.body.nudged, 'number', 'response must carry a numeric nudged counter (0 is fine — no RESEND key locally)'); +}); diff --git a/test/payout-logic.test.mjs b/test/payout-logic.test.mjs index 07055a8..ea105da 100644 --- a/test/payout-logic.test.mjs +++ b/test/payout-logic.test.mjs @@ -15,6 +15,7 @@ test('payoutMinMicros: default €1 on absent/garbage/negative, honors valid', ( assert.equal(payoutMinMicros('nope'), 1000000); assert.equal(payoutMinMicros('-5'), 1000000); assert.equal(payoutMinMicros('0'), 1000000); // 0 would pay dust → clamp to default + assert.equal(payoutMinMicros('0.5'), 1000000); // fractional-under-1 would floor to 0 → clamp to default assert.equal(payoutMinMicros('5000000'), 5000000); assert.equal(payoutMinMicros(2500000), 2500000); }); From 85ee08cdae7d02dbd85284a46839411072e02d10 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 18:18:48 +0200 Subject: [PATCH 13/14] fix(payout): move REST-called RPCs from app to public schema (PostgREST 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/. 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. --- .../migrations/20260704150000_auto_payout.sql | 25 ++++++++++++------- test/auto-payout-nudge.integration.mjs | 6 ++--- test/auto-payout-sql.integration.mjs | 9 ++++--- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/supabase/migrations/20260704150000_auto_payout.sql b/supabase/migrations/20260704150000_auto_payout.sql index 817bfac..8bf32a5 100644 --- a/supabase/migrations/20260704150000_auto_payout.sql +++ b/supabase/migrations/20260704150000_auto_payout.sql @@ -8,8 +8,15 @@ set local statement_timeout = '15s'; alter table public.publishers add column if not exists connect_nudge_at timestamptz; +-- Defensive cleanup: earlier revisions of this migration created these three REST-called RPCs in +-- `app`, but PostgREST only resolves the `public` schema (default profile) — POST /rest/v1/rpc/ +-- 404'd with PGRST202 for all three. Drop any misplaced `app` versions before recreating in `public`. +drop function if exists app.publisher_contact(uuid); +drop function if exists app.payout_nudge_candidates(bigint, interval); +drop function if exists app.mark_connect_nudged(uuid[]); + -- Contact for a publisher (email + handle) from auth.users. SECDEF: crosses into the auth schema. -create or replace function app.publisher_contact(p_publisher_id uuid) +create or replace function public.publisher_contact(p_publisher_id uuid) returns table(email text, handle text) language sql security definer set search_path = '' as $$ select u.email::text, p.handle @@ -18,11 +25,11 @@ language sql security definer set search_path = '' as $$ where p.id = p_publisher_id limit 1; $$; -revoke all on function app.publisher_contact(uuid) from public, anon, authenticated; -grant execute on function app.publisher_contact(uuid) to service_role; +revoke all on function public.publisher_contact(uuid) from public, anon, authenticated; +grant execute on function public.publisher_contact(uuid) to service_role; -- Publishers who have earned >= the minimum but have NOT onboarded a bank, not nudged in ~a week. -create or replace function app.payout_nudge_candidates(p_min_micros bigint, p_hold interval default interval '7 days') +create or replace function public.payout_nudge_candidates(p_min_micros bigint, p_hold interval default interval '7 days') returns table(publisher_id uuid, email text, handle text, payable_micros bigint) language plpgsql security definer set search_path = '' as $$ begin @@ -35,15 +42,15 @@ begin and app.publisher_payable_micros(p.id, p_hold) >= p_min_micros; end; $$; -revoke all on function app.payout_nudge_candidates(bigint, interval) from public, anon, authenticated; -grant execute on function app.payout_nudge_candidates(bigint, interval) to service_role; +revoke all on function public.payout_nudge_candidates(bigint, interval) from public, anon, authenticated; +grant execute on function public.payout_nudge_candidates(bigint, interval) to service_role; -create or replace function app.mark_connect_nudged(p_ids uuid[]) +create or replace function public.mark_connect_nudged(p_ids uuid[]) returns void language sql security definer set search_path = '' as $$ update public.publishers set connect_nudge_at = now() where id = any(p_ids); $$; -revoke all on function app.mark_connect_nudged(uuid[]) from public, anon, authenticated; -grant execute on function app.mark_connect_nudged(uuid[]) to service_role; +revoke all on function public.mark_connect_nudged(uuid[]) from public, anon, authenticated; +grant execute on function public.mark_connect_nudged(uuid[]) to service_role; -- pg_cron target. Reads 'lumaline_cron_secret' from Vault and POSTs /payout/batch with the -- x-lumaline-cron-secret header via pg_net. Vault/secret/pg_net absent -> NOTICE + no-op. diff --git a/test/auto-payout-nudge.integration.mjs b/test/auto-payout-nudge.integration.mjs index fe41e84..bddb821 100644 --- a/test/auto-payout-nudge.integration.mjs +++ b/test/auto-payout-nudge.integration.mjs @@ -1,6 +1,6 @@ // test/auto-payout-nudge.integration.mjs — M5-T4 auto-payout nudge candidates + dedup (Stripe-free). // -// Exercises app.payout_nudge_candidates + app.mark_connect_nudged (20260704150000_auto_payout.sql) +// Exercises public.payout_nudge_candidates + public.mark_connect_nudged (20260704150000_auto_payout.sql) // directly against the local Supabase DB via psql — the connecting role (postgres) is a superuser // and bypasses the service_role-only grants, same trick test/auto-payout-sql.integration.mjs relies // on for `select app.run_payout()`. Setup mirrors test/payout-rails.integration.mjs's @@ -79,7 +79,7 @@ if (!SKIP) process.on('exit', teardown); /** Row (as `id|email|handle|payable`) for `pubId` in the current nudge-candidates result, or '' if absent. */ function candidateRow(pubId) { return psql(`select coalesce(t.publisher_id::text,'') ||'|'|| coalesce(t.email,'') ||'|'|| coalesce(t.handle,'') ||'|'|| coalesce(t.payable_micros::text,'') - from app.payout_nudge_candidates(${MIN}, ${HOLD_SQL}) t where t.publisher_id = '${pubId}'::uuid;`); + from public.payout_nudge_candidates(${MIN}, ${HOLD_SQL}) t where t.publisher_id = '${pubId}'::uuid;`); } test('N1/N2/N3: un-onboarded over-min publisher is a nudge candidate; mark_connect_nudged dedupes it', { skip: SKIP }, () => { @@ -98,7 +98,7 @@ test('N1/N2/N3: un-onboarded over-min publisher is a nudge candidate; mark_conne // N2: mark_connect_nudged sets connect_nudge_at. const before = psql(`select coalesce(connect_nudge_at::text,'') from public.publishers where id='${pub.pubId}';`); assert.equal(before, '', 'connect_nudge_at must start unset'); - psql(`select app.mark_connect_nudged(array['${pub.pubId}']::uuid[]);`); + psql(`select public.mark_connect_nudged(array['${pub.pubId}']::uuid[]);`); const after = psql(`select coalesce(connect_nudge_at::text,'') from public.publishers where id='${pub.pubId}';`); assert.notEqual(after, '', 'connect_nudge_at must be set after mark_connect_nudged'); diff --git a/test/auto-payout-sql.integration.mjs b/test/auto-payout-sql.integration.mjs index 43e8004..e85a6e0 100644 --- a/test/auto-payout-sql.integration.mjs +++ b/test/auto-payout-sql.integration.mjs @@ -14,9 +14,12 @@ if (SKIP) console.log(`[auto-payout-sql] ${SKIP}`); test('migration objects exist', { skip: SKIP }, () => { assert.equal(psql("select count(*) from information_schema.columns where table_schema='public' and table_name='publishers' and column_name='connect_nudge_at'").out, '1'); - for (const fn of ['publisher_contact', 'payout_nudge_candidates', 'mark_connect_nudged', 'run_payout']) { - assert.equal(psql(`select count(*) from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='app' and p.proname='${fn}'`).out, '1', `app.${fn} exists`); + // PostgREST only resolves the `public` schema, so these three REST-called RPCs live in `public`. + for (const fn of ['publisher_contact', 'payout_nudge_candidates', 'mark_connect_nudged']) { + assert.equal(psql(`select count(*) from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname='${fn}'`).out, '1', `public.${fn} exists`); } + // run_payout is a pg_cron target invoked via SQL (select app.run_payout()), not via REST — stays in app. + assert.equal(psql(`select count(*) from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='app' and p.proname='run_payout'`).out, '1', `app.run_payout exists`); }); test('run_payout no-ops cleanly when Vault secret absent (fresh stack)', { skip: SKIP }, () => { @@ -25,7 +28,7 @@ test('run_payout no-ops cleanly when Vault secret absent (fresh stack)', { skip: }); test('anon/authenticated cannot execute the new money RPCs', { skip: SKIP }, () => { - for (const sig of ['app.publisher_contact(uuid)', 'app.payout_nudge_candidates(bigint,interval)', 'app.mark_connect_nudged(uuid[])', 'app.run_payout()']) { + for (const sig of ['public.publisher_contact(uuid)', 'public.payout_nudge_candidates(bigint,interval)', 'public.mark_connect_nudged(uuid[])', 'app.run_payout()']) { assert.equal(psql(`select has_function_privilege('anon','${sig}','execute')`).out, 'f', `anon cannot ${sig}`); } }); From 35b261830ade5ff0b2d6692679bf7a6e44e88475 Mon Sep 17 00:00:00 2001 From: JaamesBond Date: Sat, 4 Jul 2026 18:25:53 +0200 Subject: [PATCH 14/14] =?UTF-8?q?docs(payout):=20runbook=20Step=201=20veri?= =?UTF-8?q?fy=20=E2=80=94=203=20RPCs=20in=20public,=20run=5Fpayout=20in=20?= =?UTF-8?q?app=20(post=20schema=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ops/m5-t4-deploy.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/ops/m5-t4-deploy.md b/docs/ops/m5-t4-deploy.md index 247b287..fabd8b2 100644 --- a/docs/ops/m5-t4-deploy.md +++ b/docs/ops/m5-t4-deploy.md @@ -20,8 +20,16 @@ stamp `schema_migrations`: node /sql.mjs @supabase/migrations/20260704150000_auto_payout.sql node /sql.mjs "insert into supabase_migrations.schema_migrations (version,name) values ('20260704150000','auto_payout') on conflict (version) do nothing returning version;" ``` -Verify: `connect_nudge_at` column exists; the 4 `app.*` functions exist; `anon`/`authenticated` -cannot execute them; `app.run_payout()` runs without error (no-ops or posts). +Verify: `connect_nudge_at` column exists; the **three REST-called** functions +`public.publisher_contact` / `public.payout_nudge_candidates` / `public.mark_connect_nudged` exist +in the **`public`** schema (they MUST be in `public` — `serviceRpc` reaches them via PostgREST, which +only resolves `public`; an `app.*` copy would 404 and silently kill the notify pass); `app.run_payout` +exists in the **`app`** schema (pg_cron calls it via SQL, not REST); `anon`/`authenticated` cannot +execute any of them; `app.run_payout()` runs without error (no-ops or posts). Quick check: +``` +node /sql.mjs "select p.proname||' -> '||n.nspname from pg_proc p join pg_namespace n on n.oid=p.pronamespace where p.proname in ('publisher_contact','payout_nudge_candidates','mark_connect_nudged','run_payout') order by 1;" +# expect: mark_connect_nudged->public, payout_nudge_candidates->public, publisher_contact->public, run_payout->app +``` ## Step 2 — Set fn env on `stripe-connect` (owner GO) Ensure these are set for the `stripe-connect` function (via `supabase secrets set` / dashboard):