Skip to content

Commit 5b25aed

Browse files
committed
feat(billing-docker): affiliates connect their own payouts; fix argv leak; role labels in public docs
Payout dead-end (found in the rehearsal): after signing, an affiliate was told 'WeOwn will send your onboarding link' and nothing happened — the link had to be minted by an operator. Onboarding links are single-use and expire in minutes, so emailing them ahead of time cannot work either. The affiliate page now shows a real button that creates their Express account on demand and drops them into Stripe's hosted onboarding, plus accurate state (connected / unfinished / not started) read from Stripe, and a 'Manage payout account' link into the Express dashboard once complete. Review fixes from PR #158: the minted OpenRouter key was passed as a command line argument — visible in ps/proc for the life of the call, contradicting the script's own 'never on argv' header. Now uses the repo's established NAME=@file pattern (umask 077 temp file, shredded by the EXIT trap). Public handoff docs now use role labels instead of naming individuals.
1 parent 3a3a1b6 commit 5b25aed

14 files changed

Lines changed: 191 additions & 18 deletions

File tree

billing-docker/sites/billing/app/billing/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
path("suspended/", views.suspended, name="suspended"),
1717
path("affiliate/", views.affiliate_home, name="affiliate_home"),
1818
path("affiliate/join/", views.affiliate_join, name="affiliate_join"),
19+
path("affiliate/payouts/", views.connect_payouts, name="connect_payouts"),
1920
path("affiliate/check/", views.check_affiliate_code, name="check_affiliate_code"),
2021
path("affiliate/contract/", views.affiliate_contract, name="affiliate_contract"),
2122
path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"),

billing-docker/sites/billing/app/core/stripe_svc.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,42 @@ def pay_affiliate_splits(invoice: dict, subscription) -> None:
123123
log.info("Split paid: %s T%s -> %s (%s%% of profit %sc = %sc)",
124124
invoice["id"], tier, leg_aff.code, pct, profit, cut)
125125
row.save()
126+
127+
128+
def connect_account_status(affiliate) -> dict:
129+
"""What Stripe thinks of this affiliate's payout account. Cheap enough to
130+
call on the affiliate page; degrades to 'unknown' rather than erroring."""
131+
if not affiliate.stripe_connect_account_id:
132+
return {"exists": False, "payouts_enabled": False, "details_submitted": False}
133+
try:
134+
acct = _client().Account.retrieve(affiliate.stripe_connect_account_id)
135+
return {"exists": True,
136+
"payouts_enabled": bool(acct.get("payouts_enabled")),
137+
"details_submitted": bool(acct.get("details_submitted"))}
138+
except Exception: # noqa: BLE001 — never break the page over a Stripe hiccup
139+
log.exception("Connect account lookup failed for %s", affiliate.code)
140+
return {"exists": True, "payouts_enabled": False, "details_submitted": False}
141+
142+
143+
def connect_onboarding_url(affiliate, return_url: str) -> str:
144+
"""Create the affiliate's Express account if needed and return a fresh
145+
onboarding link. Links are single-use and expire in minutes, which is
146+
exactly why this is generated on demand from a button rather than emailed."""
147+
s = _client()
148+
if not affiliate.stripe_connect_account_id:
149+
acct = s.Account.create(
150+
type="express", email=affiliate.user.email,
151+
capabilities={"transfers": {"requested": True}},
152+
business_type="individual", metadata={"affiliate_code": affiliate.code},
153+
)
154+
affiliate.stripe_connect_account_id = acct["id"]
155+
affiliate.save(update_fields=["stripe_connect_account_id"])
156+
log.info("Connect account created for %s", affiliate.code)
157+
status = connect_account_status(affiliate)
158+
if status["details_submitted"]:
159+
# Already onboarded — send them to their Express dashboard instead.
160+
return s.Account.create_login_link(affiliate.stripe_connect_account_id)["url"]
161+
return s.AccountLink.create(
162+
account=affiliate.stripe_connect_account_id,
163+
refresh_url=return_url, return_url=return_url, type="account_onboarding",
164+
)["url"]

billing-docker/sites/billing/app/core/templates/core/affiliate.html

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@ <h1>Affiliate dashboard</h1>
1313
<div class="card">
1414
<p>✅ Agreement signed. Your referral link:</p>
1515
<p><code>https://{{ request.get_host }}/?ref={{ affiliate.code }}</code></p>
16-
{% if not affiliate.stripe_connect_account_id %}
17-
<p class="err">⚠️ Payouts not connected yet — your earnings below are accruing and
18-
will transfer automatically once your Stripe payout account is connected.
19-
WeOwn will send your onboarding link.</p>
16+
{% if connect.payouts_enabled %}
17+
<p class="muted">✅ Payouts connected — splits transfer automatically on each paid
18+
invoice. <a href="{% url 'connect_payouts' %}">Manage payout account ↗</a></p>
19+
{% elif connect.exists %}
20+
<p class="err">⚠️ Payout setup unfinished — your earnings below are accruing safely
21+
and will transfer as soon as Stripe has what it needs.</p>
22+
<p><a class="btn" href="{% url 'connect_payouts' %}">Finish connecting payouts</a></p>
2023
{% else %}
21-
<p class="muted">Payouts connected — splits transfer automatically on each paid invoice.</p>
24+
<p class="err">⚠️ Payouts not connected yet — your earnings below are accruing and
25+
will transfer automatically once you connect a payout account.</p>
26+
<p><a class="btn" href="{% url 'connect_payouts' %}">Connect payouts</a></p>
2227
{% endif %}
2328
</div>
2429

billing-docker/sites/billing/app/core/views.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def affiliate_home(request):
106106
"sub_affiliates": Affiliate.objects.filter(parent=aff).count(),
107107
"paid_dollars": f"{(agg['paid'] or 0) / 100:,.2f}",
108108
"pending_dollars": f"{(agg['pending'] or 0) / 100:,.2f}",
109+
"connect": stripe_svc.connect_account_status(aff),
109110
})
110111
return render(request, "core/affiliate.html", ctx)
111112

@@ -294,7 +295,7 @@ def new_instance(request):
294295
base = f"https://{settings.ALLOWED_HOSTS[0]}"
295296
session = stripe_svc.create_checkout_session(
296297
customer, success_url=f"{base}/subscribe/success/", cancel_url=f"{base}/",
297-
instance=instance,
298+
instance=instance, ref_code=ref,
298299
)
299300
return redirect(session.url, permanent=False)
300301

@@ -368,3 +369,24 @@ def affiliate_join(request):
368369
log.info("AFFILIATE-JOIN code=%s user=%s sponsor=%s", aff.code, request.user.email,
369370
sponsor.code if sponsor else "-")
370371
return redirect("affiliate_home")
372+
373+
374+
@login_required
375+
def connect_payouts(request):
376+
"""Self-serve payout connection: the affiliate presses a button and lands in
377+
Stripe's hosted onboarding. Replaces 'WeOwn will send your onboarding link',
378+
which was a dead end — links expire in minutes so they cannot be emailed
379+
ahead of time anyway."""
380+
aff = Affiliate.objects.filter(user=request.user).first()
381+
if not aff:
382+
return redirect("affiliate_home")
383+
base = f"https://{settings.ALLOWED_HOSTS[0]}"
384+
try:
385+
url = stripe_svc.connect_onboarding_url(aff, return_url=f"{base}/affiliate/")
386+
except Exception: # noqa: BLE001
387+
log.exception("Connect onboarding failed for %s", aff.code)
388+
return render(request, "core/affiliate.html",
389+
{"affiliate": aff, "signed": True,
390+
"error": "Could not reach Stripe just now — please try again in a moment."},
391+
status=502)
392+
return redirect(url, permanent=False)

billing-docker/template/app/billing/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
path("suspended/", views.suspended, name="suspended"),
1717
path("affiliate/", views.affiliate_home, name="affiliate_home"),
1818
path("affiliate/join/", views.affiliate_join, name="affiliate_join"),
19+
path("affiliate/payouts/", views.connect_payouts, name="connect_payouts"),
1920
path("affiliate/check/", views.check_affiliate_code, name="check_affiliate_code"),
2021
path("affiliate/contract/", views.affiliate_contract, name="affiliate_contract"),
2122
path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"),

billing-docker/template/app/core/stripe_svc.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,42 @@ def pay_affiliate_splits(invoice: dict, subscription) -> None:
123123
log.info("Split paid: %s T%s -> %s (%s%% of profit %sc = %sc)",
124124
invoice["id"], tier, leg_aff.code, pct, profit, cut)
125125
row.save()
126+
127+
128+
def connect_account_status(affiliate) -> dict:
129+
"""What Stripe thinks of this affiliate's payout account. Cheap enough to
130+
call on the affiliate page; degrades to 'unknown' rather than erroring."""
131+
if not affiliate.stripe_connect_account_id:
132+
return {"exists": False, "payouts_enabled": False, "details_submitted": False}
133+
try:
134+
acct = _client().Account.retrieve(affiliate.stripe_connect_account_id)
135+
return {"exists": True,
136+
"payouts_enabled": bool(acct.get("payouts_enabled")),
137+
"details_submitted": bool(acct.get("details_submitted"))}
138+
except Exception: # noqa: BLE001 — never break the page over a Stripe hiccup
139+
log.exception("Connect account lookup failed for %s", affiliate.code)
140+
return {"exists": True, "payouts_enabled": False, "details_submitted": False}
141+
142+
143+
def connect_onboarding_url(affiliate, return_url: str) -> str:
144+
"""Create the affiliate's Express account if needed and return a fresh
145+
onboarding link. Links are single-use and expire in minutes, which is
146+
exactly why this is generated on demand from a button rather than emailed."""
147+
s = _client()
148+
if not affiliate.stripe_connect_account_id:
149+
acct = s.Account.create(
150+
type="express", email=affiliate.user.email,
151+
capabilities={"transfers": {"requested": True}},
152+
business_type="individual", metadata={"affiliate_code": affiliate.code},
153+
)
154+
affiliate.stripe_connect_account_id = acct["id"]
155+
affiliate.save(update_fields=["stripe_connect_account_id"])
156+
log.info("Connect account created for %s", affiliate.code)
157+
status = connect_account_status(affiliate)
158+
if status["details_submitted"]:
159+
# Already onboarded — send them to their Express dashboard instead.
160+
return s.Account.create_login_link(affiliate.stripe_connect_account_id)["url"]
161+
return s.AccountLink.create(
162+
account=affiliate.stripe_connect_account_id,
163+
refresh_url=return_url, return_url=return_url, type="account_onboarding",
164+
)["url"]

billing-docker/template/app/core/templates/core/affiliate.html

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@ <h1>Affiliate dashboard</h1>
1313
<div class="card">
1414
<p>✅ Agreement signed. Your referral link:</p>
1515
<p><code>https://{{ request.get_host }}/?ref={{ affiliate.code }}</code></p>
16-
{% if not affiliate.stripe_connect_account_id %}
17-
<p class="err">⚠️ Payouts not connected yet — your earnings below are accruing and
18-
will transfer automatically once your Stripe payout account is connected.
19-
WeOwn will send your onboarding link.</p>
16+
{% if connect.payouts_enabled %}
17+
<p class="muted">✅ Payouts connected — splits transfer automatically on each paid
18+
invoice. <a href="{% url 'connect_payouts' %}">Manage payout account ↗</a></p>
19+
{% elif connect.exists %}
20+
<p class="err">⚠️ Payout setup unfinished — your earnings below are accruing safely
21+
and will transfer as soon as Stripe has what it needs.</p>
22+
<p><a class="btn" href="{% url 'connect_payouts' %}">Finish connecting payouts</a></p>
2023
{% else %}
21-
<p class="muted">Payouts connected — splits transfer automatically on each paid invoice.</p>
24+
<p class="err">⚠️ Payouts not connected yet — your earnings below are accruing and
25+
will transfer automatically once you connect a payout account.</p>
26+
<p><a class="btn" href="{% url 'connect_payouts' %}">Connect payouts</a></p>
2227
{% endif %}
2328
</div>
2429

billing-docker/template/app/core/views.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def affiliate_home(request):
106106
"sub_affiliates": Affiliate.objects.filter(parent=aff).count(),
107107
"paid_dollars": f"{(agg['paid'] or 0) / 100:,.2f}",
108108
"pending_dollars": f"{(agg['pending'] or 0) / 100:,.2f}",
109+
"connect": stripe_svc.connect_account_status(aff),
109110
})
110111
return render(request, "core/affiliate.html", ctx)
111112

@@ -294,7 +295,7 @@ def new_instance(request):
294295
base = f"https://{settings.ALLOWED_HOSTS[0]}"
295296
session = stripe_svc.create_checkout_session(
296297
customer, success_url=f"{base}/subscribe/success/", cancel_url=f"{base}/",
297-
instance=instance,
298+
instance=instance, ref_code=ref,
298299
)
299300
return redirect(session.url, permanent=False)
300301

@@ -368,3 +369,24 @@ def affiliate_join(request):
368369
log.info("AFFILIATE-JOIN code=%s user=%s sponsor=%s", aff.code, request.user.email,
369370
sponsor.code if sponsor else "-")
370371
return redirect("affiliate_home")
372+
373+
374+
@login_required
375+
def connect_payouts(request):
376+
"""Self-serve payout connection: the affiliate presses a button and lands in
377+
Stripe's hosted onboarding. Replaces 'WeOwn will send your onboarding link',
378+
which was a dead end — links expire in minutes so they cannot be emailed
379+
ahead of time anyway."""
380+
aff = Affiliate.objects.filter(user=request.user).first()
381+
if not aff:
382+
return redirect("affiliate_home")
383+
base = f"https://{settings.ALLOWED_HOSTS[0]}"
384+
try:
385+
url = stripe_svc.connect_onboarding_url(aff, return_url=f"{base}/affiliate/")
386+
except Exception: # noqa: BLE001
387+
log.exception("Connect onboarding failed for %s", aff.code)
388+
return render(request, "core/affiliate.html",
389+
{"affiliate": aff, "signed": True,
390+
"error": "Could not reach Stripe just now — please try again in a moment."},
391+
status=502)
392+
return redirect(url, permanent=False)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# HANDOFF — Keycloak + Gitea for the WeOwn stack (overnight drive)
2+
3+
> Status: **handoff** · Author: ncimino (via Claude Code) · Date: 2026-07-17
4+
> `main` HEAD at handoff: **`50d298c`** (PR #96 merged — customer dashboard).
5+
> **Operating mode: Nik is ASLEEP.** Drive as far as you can autonomously
6+
> tonight; anything human-gated goes on the morning list (§6), not a blocker to
7+
> stop on. Check things before acting — do not get stuck on one path; if a path
8+
> needs Nik, park it and advance another. Public repo: no secrets, no real IPs
9+
> (RFC 5737), no customer identifiers. Commits: ncimino only, no AI trailers.
10+
> Land work as conformant branch → auto-PR → merge (§5 mechanics).
11+
12+
## 1. Mission
13+
14+
Stand up **Gitea + Keycloak (SSO) for WeOwn** in the `ai` repo pattern —
15+
Jason approved both **in writing** (vault D452). Requirements:
16+
17+
1. **Same principles as Perpetuator Platform internal deployments**, but on the
18+
WeOwn stack with **Infisical** as the secret store (not OpenBao): IaC-only
19+
(tofu + ansible, Path C + Layer 2), runtime secret injection (no secrets on
20+
disk), copier-template form (`<service>-docker/`), hardened per
21+
SOP-INFRA-012 Phase-A (CIS play + encrypted data volume — both now in
22+
`anythingllm-docker` as the reference), skinny GPG-encrypted backups,
23+
OTel observability.
24+
2. **Evaluate Peter/MOT's deployed work for alignment** — his keycloak-SSO
25+
(PRJ-003), owncloud-oCIS, and smoke-test code are MERGED but he offboarded
26+
W28 (vault: `Projects/MOT Offboarding Runbook - 2026-07-06.md`). How close
27+
is what he built to the principles above? What drifts? Are his live
28+
instances findable/verifiable?
29+
3. **Tonight's concrete goal:** a **local docker-compose proof** that Gitea
30+
authenticates against Keycloak via OIDC end-to-end (login → Gitea account
31+
provisioned), using the repo's template

docs/handoff/MORNING-BRIEF-demo-readiness.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ flip.
6969

7070
## Docs that exist for the team
7171

72-
- `docs/handoff/TESTER-GUIDE-billing-flow.md` — for Tyler, Patrick, Coach LFG.
72+
- `docs/handoff/TESTER-GUIDE-billing-flow.md` — for the testing team.
7373
- `docs/handoff/OWNER-GUIDE-stripe-connect-and-splits.md` — money model, COGS,
7474
attribution rules, tax questions.
7575
- `docs/handoff/OWNER-GUIDE-stripe-dashboard.md` — reading payments in Stripe.

0 commit comments

Comments
 (0)