fix(join): only mint the $99 CoinPay charge on explicit opt-in

offerPremium ran on every join@ and called CreatePremiumCharge just to
show the pitch, so CoinPay minted a $99 payment for everyone who
connected. Show the pitch with no charge, then create the payment only
when the member types "yes" at the prompt (the SSH equivalent of
clicking "Become a paid member").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-24 12:07:34 +00:00
parent 94ac404725
commit 63548bcbbe

View file

@ -707,7 +707,7 @@ func (a *app) handleJoin(s ssh.Session) {
wish.Println(s, "\n"+strings.Join(includes, "\n")) wish.Println(s, "\n"+strings.Join(includes, "\n"))
// 2) Founding Lifetime ($99 one-time): custom domains + Tor shell. // 2) Founding Lifetime ($99 one-time): custom domains + Tor shell.
a.offerPremium(s, &u) a.offerPremium(s, in, &u)
_ = s.Exit(0) _ = s.Exit(0)
} }
@ -921,20 +921,20 @@ func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
} }
// offerPremium pitches the $99 Founding Lifetime membership — custom domains and // offerPremium pitches the $99 Founding Lifetime membership — custom domains and
// the Tor shell. When CoinPay can mint a charge in-session it shows the exact // the Tor shell — and only mints a CoinPay charge if the member explicitly opts
// amount and deposit address; otherwise it falls back to a pay command. // in at the prompt. Showing the pitch must NOT create a payment: minting on
// Non-blocking: the member pays out of band and perks unlock on their next // every join@ produced a $99 invoice for everyone who connected. Non-blocking:
// connect (or re-running join@). // the member pays out of band and perks unlock on their next connect (or
func (a *app) offerPremium(s ssh.Session, u *store.User) { // re-running join@).
func (a *app) offerPremium(s ssh.Session, in *bufio.Reader, u *store.User) {
// Maybe they already paid (e.g. re-ran join@ after paying). // Maybe they already paid (e.g. re-ran join@ after paying).
if a.ensurePremium(u) { if a.ensurePremium(u) {
a.showPremiumWelcome(s, *u) a.showPremiumWelcome(s, *u)
return return
} }
ref := payments.PremiumReference(u.PubKeyFP)
lines := []string{ // Pitch only — no charge is created here.
"", wish.Println(s, "\n"+strings.Join([]string{
" ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time", " ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time",
" Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.", " Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.",
"", "",
@ -944,9 +944,25 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
" • custom domains point yourdomain.com at your homepage", " • custom domains point yourdomain.com at your homepage",
" • Tor a “Tor shell” in your pod — everything over Tor", " • Tor a “Tor shell” in your pod — everything over Tor",
" • locked-in price founding rate is yours for life — never renew, never pay again", " • locked-in price founding rate is yours for life — never renew, never pay again",
"", }, "\n"))
// Explicit opt-in. Anything but yes leaves with no payment created.
wish.Print(s, "\n Become a Founding member now? Type \"yes\" for a payment address [no]: ")
line, err := readLine(s, in)
if err != nil || !isYes(line) {
wish.Println(s, "\n No problem — you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n")
return
}
ref := payments.PremiumReference(u.PubKeyFP)
c, ok, err := payments.CreatePremiumCharge(ref)
if !ok || err != nil {
if err != nil {
log.Error("create premium charge", "err", err)
}
wish.Println(s, "\n Payment is temporarily unavailable — please try again shortly.\n")
return
} }
if c, ok, err := payments.CreatePremiumCharge(ref); ok && err == nil {
// Remember the payment id so a later connect can confirm settlement. // Remember the payment id so a later connect can confirm settlement.
if err := a.st.SetPremiumPayment(u.ID, c.ID); err != nil { if err := a.st.SetPremiumPayment(u.ID, c.ID); err != nil {
log.Error("store premium payment id", "err", err) log.Error("store premium payment id", "err", err)
@ -959,19 +975,14 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
} }
amount += " (≈ " + c.CryptoAmount + " " + cur + ")" amount += " (≈ " + c.CryptoAmount + " " + cur + ")"
} }
lines = append(lines, lines := []string{
" amount "+amount, "",
" send to "+c.Address, " amount " + amount,
) " send to " + c.Address,
}
if c.QR != "" { if c.QR != "" {
lines = append(lines, " qr "+c.QR) lines = append(lines, " qr "+c.QR)
} }
} else {
if err != nil {
log.Error("create premium charge", "err", err)
}
lines = append(lines, " Payment is temporarily unavailable — please try again shortly.")
}
lines = append(lines, lines = append(lines,
"", "",
" Perks unlock once payment confirms — then re-run: ssh join@"+a.host, " Perks unlock once payment confirms — then re-run: ssh join@"+a.host,
@ -980,6 +991,16 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
wish.Println(s, strings.Join(lines, "\n")) wish.Println(s, strings.Join(lines, "\n"))
} }
// isYes reports whether a prompt line is an affirmative opt-in.
func isYes(line string) bool {
switch strings.ToLower(strings.TrimSpace(line)) {
case "yes", "y":
return true
default:
return false
}
}
// notifySignup emails the operator the details of a newly verified signup. // notifySignup emails the operator the details of a newly verified signup.
// No-op when SMTP isn't configured. Subject is "bbs" per the operator's filter. // No-op when SMTP isn't configured. Subject is "bbs" per the operator's filter.
func (a *app) notifySignup(u store.User) { func (a *app) notifySignup(u store.User) {