diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 58538b6..b4aaf05 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -1104,48 +1104,93 @@ func (a *app) offerPremium(s ssh.Session, in *bufio.Reader, u *store.User) { }, "\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]: ") + wish.Print(s, "\n Become a Founding member now? Type \"yes\" for a checkout link [no]: ") line, err := readLine(s, in) if err != nil || !isYes(line) { - wish.Print(s, "\n No problem — you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n\n") + wish.Print(s, "\n No problem, you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n\n") return } - ref := payments.PremiumReference(u.PubKeyFP) - c, ok, err := payments.CreatePremiumCharge(ref) - if !ok || err != nil { + // CoinPay needs a receipt email. join@ verifies one first, but ask if a + // legacy account has none on file. + email := strings.TrimSpace(u.Email) + for tries := 0; !validEmail(email) && tries < 3; tries++ { + wish.Print(s, "\n Email for your receipt: ") + line, err := readLine(s, in) if err != nil { - log.Error("create premium charge", "err", err) + return } - wish.Print(s, "\n Payment is temporarily unavailable — please try again shortly.\n\n") - return + email = strings.TrimSpace(line) } - // Remember the payment id so a later connect can confirm settlement. - if err := a.st.SetPremiumPayment(u.ID, c.ID); err != nil { - log.Error("store premium payment id", "err", err) + if !validEmail(email) { + wish.Print(s, "\n A valid email is needed for checkout. Re-run: ssh join@"+a.host+"\n\n") + return } - amount := "$" + payments.PremiumAmount() + " " + payments.PremiumCurrency() - if c.CryptoAmount != "" { - cur := c.Currency - if cur == "" { - cur = strings.ToUpper(payments.PremiumBlockchain()) + + ses, ok, err := payments.CreatePremiumSession(payments.Buyer{ + AccountID: u.Name, + Email: email, + PubKeyFP: u.PubKeyFP, + UserID: u.ID, + }, "https://"+a.host+"/~"+u.Name) + if !ok || err != nil { + if err != nil { + log.Error("create premium checkout session", "err", err) } - amount += " (≈ " + c.CryptoAmount + " " + cur + ")" + wish.Print(s, "\n Payment is temporarily unavailable, please try again shortly.\n\n") + return + } + // Remember the session id so a later connect can confirm settlement. + if err := a.st.SetPremiumPayment(u.ID, ses.ID); err != nil { + log.Error("store premium checkout session id", "err", err) } lines := []string{ "", - " amount " + amount, - " send to " + c.Address, - } - if c.QR != "" { - lines = append(lines, " qr "+c.QR) - } - lines = append(lines, + " amount $" + payments.PremiumAmount() + " " + payments.PremiumCurrency() + " (pay with any major coin or PayPal)", + " checkout " + ses.URL, "", - " Perks unlock once payment confirms — then re-run: ssh join@"+a.host, + " Open the link, or scan it with your phone:", "", - ) + } wish.Println(s, strings.Join(lines, "\n")) + if q := payments.TerminalQR(ses.URL, " "); q != "" { + wish.Print(s, strings.ReplaceAll(q, "\n", "\r\n")) + } + wish.Print(s, "\r\n Waiting for payment here (press Enter to leave; perks also unlock on your next connect)...\r\n") + + if a.waitForPremium(s, in, u, ses.ID) { + wish.Println(s, "\n Payment received. Welcome, Founding member!") + a.showPremiumWelcome(s, *u) + return + } + wish.Print(s, "\n Once payment confirms, re-run: ssh join@"+a.host+"\n\n") +} + +// waitForPremium polls the checkout session until it is paid, the member +// presses Enter, the session disconnects, or the session would have expired. +func (a *app) waitForPremium(s ssh.Session, in *bufio.Reader, u *store.User, sessionID string) bool { + left := make(chan struct{}) + go func() { + _, _ = readLine(s, in) + close(left) + }() + tick := time.NewTicker(5 * time.Second) + defer tick.Stop() + deadline := time.After(60 * time.Minute) + for { + select { + case <-left: + return false + case <-s.Context().Done(): + return false + case <-deadline: + return false + case <-tick.C: + if a.ensurePremium(u) { + return true + } + } + } } // isYes reports whether a prompt line is an affirmative opt-in. diff --git a/go.mod b/go.mod index 8ccf0a8..b2da27a 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( golang.org/x/crypto v0.58.0 golang.org/x/term v0.47.0 modernc.org/sqlite v1.60.1 + rsc.io/qr v0.2.0 ) require ( diff --git a/go.sum b/go.sum index b761648..ade8194 100644 --- a/go.sum +++ b/go.sum @@ -372,3 +372,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/payments/payments.go b/internal/payments/payments.go index 77158cd..9992d7d 100644 --- a/internal/payments/payments.go +++ b/internal/payments/payments.go @@ -9,7 +9,11 @@ // AGENTBBS_COINPAY_API_URL API base (default https://coinpayportal.com/api) // AGENTBBS_PREMIUM_AMOUNT fiat amount (default 99) // AGENTBBS_PREMIUM_CURRENCY fiat currency (default USD) -// AGENTBBS_PREMIUM_BLOCKCHAIN settlement chain (default eth) +// AGENTBBS_COINPAY_PROPERTY site hostname on the session (default bbs.profullstack.com) +// +// Checkout is a CoinPay hosted session (POST /checkout/sessions): the buyer +// opens its URL (shown with a QR code in the SSH terminal), picks any coin the +// business accepts or PayPal, and we poll GET /checkout/sessions/:id for paid. // // Operators can also grant pod time manually: `agentbbs grant-pod N`. package payments @@ -27,6 +31,7 @@ import ( "net/url" "os" "strconv" + "strings" "time" ) @@ -43,9 +48,8 @@ const FoundingCap = "1,000" // Premium charge parameters — overridable via env so the offer can change // without a rebuild. -func PremiumAmount() string { return envOr("AGENTBBS_PREMIUM_AMOUNT", "99") } -func PremiumCurrency() string { return envOr("AGENTBBS_PREMIUM_CURRENCY", "USD") } -func PremiumBlockchain() string { return envOr("AGENTBBS_PREMIUM_BLOCKCHAIN", "eth") } +func PremiumAmount() string { return envOr("AGENTBBS_PREMIUM_AMOUNT", "99") } +func PremiumCurrency() string { return envOr("AGENTBBS_PREMIUM_CURRENCY", "USD") } // MerchantID is the CoinPay merchant/business id payments are created under. func MerchantID() string { return os.Getenv("AGENTBBS_COINPAY_MERCHANT_ID") } @@ -55,8 +59,9 @@ func apiBase() string { return trimSlash(envOr("AGENTBBS_COINPAY_API_URL", "https://coinpayportal.com/api")) } -// APIConfigured reports whether live CoinPay calls can be made. -func APIConfigured() bool { return apiKey() != "" && MerchantID() != "" } +// APIConfigured reports whether live CoinPay calls can be made. The business +// API key identifies the merchant, so a hosted checkout needs only the key. +func APIConfigured() bool { return apiKey() != "" } func envOr(k, def string) string { if v := os.Getenv(k); v != "" { @@ -72,19 +77,6 @@ func trimSlash(s string) string { return s } -// Charge is a created CoinPay payment a user must fund: a unique deposit -// address, the crypto amount, the fiat amount it settles, and the payment id -// (store it to verify settlement later). -type Charge struct { - Address string - CryptoAmount string - Currency string - FiatAmount string - FiatCurrency string - ID string - QR string -} - // PremiumReference derives a stable memo for a user's lifetime membership from // their key fingerprint; sent as payment metadata for reconciliation. func PremiumReference(pubkeyFP string) string { return Reference("premium", pubkeyFP) } @@ -96,48 +88,60 @@ func Reference(plan, pubkeyFP string) string { return "abbs-" + plan + "-" + hex.EncodeToString(mac.Sum(nil))[:12] } -// flexStr decodes a JSON value that may arrive as either a string or a number -// into a string. CoinPay returns crypto_amount as a bare JSON number (e.g. -// 0.0031), but has sent it quoted in the past — accept both so a representation -// change on their side can't break the charge again. -type flexStr string - -func (f *flexStr) UnmarshalJSON(b []byte) error { - b = bytes.TrimSpace(b) - if len(b) == 0 || string(b) == "null" { - *f = "" - return nil - } - if b[0] == '"' { - var s string - if err := json.Unmarshal(b, &s); err != nil { - return err - } - *f = flexStr(s) - return nil - } - *f = flexStr(b) // number (or other scalar) — keep its literal text - return nil +// Property is the site hostname CoinPay records on the checkout session and +// echoes on every webhook, so the shared business can tell BBS sales apart. +func Property() string { return envOr("AGENTBBS_COINPAY_PROPERTY", "bbs.profullstack.com") } + +// Buyer identifies who a premium checkout session is for. +type Buyer struct { + AccountID string // stable account handle (CoinPay account_id) + Email string // verified member email (CoinPay customer_email) + PubKeyFP string // SSH key fingerprint (metadata only) + UserID int64 // local store id (metadata only) } -// coinpayPayment is the (subset of the) CoinPay payment object, returned -// wrapped as {"payment": {…}}. -type coinpayPayment struct { - ID string `json:"id"` - Status string `json:"status"` - Address string `json:"payment_address"` - CryptoAmount flexStr `json:"crypto_amount"` - CryptoCurr string `json:"crypto_currency"` - QR string `json:"qr_code"` +// Session is a created CoinPay hosted checkout session: the buyer opens URL, +// picks any coin the business accepts or PayPal, and pays there. +type Session struct { + ID string `json:"id"` + URL string `json:"url"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at"` + Methods []string `json:"-"` } -type paymentEnvelope struct { - Payment coinpayPayment `json:"payment"` +// SessionBody builds the POST /checkout/sessions request body. Exposed for tests. +func SessionBody(b Buyer, successURL string) map[string]any { + amount := json.Number(PremiumAmount()) + if f, err := strconv.ParseFloat(PremiumAmount(), 64); err == nil { + amount = json.Number(strconv.FormatFloat(f, 'f', -1, 64)) + } + body := map[string]any{ + "amount": amount, + "currency": PremiumCurrency(), + "description": "AgentBBS Founding Lifetime membership", + "customer_email": b.Email, + "account_id": b.AccountID, + "property": Property(), + "expires_in_minutes": 60, + "metadata": map[string]any{ + "ref": PremiumReference(b.PubKeyFP), + "plan": "premium_lifetime", + "handle": b.AccountID, + "pubkey_fp": b.PubKeyFP, + "user_id": strconv.FormatInt(b.UserID, 10), + "site": Property(), + }, + } + if successURL != "" { + body["success_url"] = successURL + body["cancel_url"] = successURL + } + return body } -// coinpayDo performs an authenticated CoinPay API call and decodes the -// {"payment":{…}} envelope. -func coinpayDo(ctx context.Context, method, path string, body any) (*paymentEnvelope, error) { +// apiDo performs an authenticated CoinPay API call and returns the raw body. +func apiDo(ctx context.Context, method, path string, body any) ([]byte, error) { var rdr io.Reader if body != nil { b, err := json.Marshal(body) @@ -152,6 +156,7 @@ func coinpayDo(ctx context.Context, method, path string, body any) (*paymentEnve } req.Header.Set("Authorization", "Bearer "+apiKey()) req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { @@ -162,57 +167,90 @@ func coinpayDo(ctx context.Context, method, path string, body any) (*paymentEnve if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("coinpay %s %s: %s: %s", method, path, resp.Status, bytesTrim(raw)) } - var env paymentEnvelope - if err := json.Unmarshal(raw, &env); err != nil { - return nil, fmt.Errorf("coinpay %s %s: bad response: %w", method, path, err) - } - return &env, nil + return raw, nil } func bytesTrim(b []byte) string { return string(bytes.TrimSpace(b)) } -// CreatePremiumCharge creates a CoinPay payment for the lifetime membership and -// returns the deposit address, amount, and payment id (store the id to verify -// settlement later). created is false when CoinPay isn't configured. -func CreatePremiumCharge(ref string) (Charge, bool, error) { +// CreatePremiumSession creates a hosted CoinPay checkout session for the +// lifetime membership. created is false when CoinPay isn't configured. Store +// the session id (cs_...) to verify settlement later. +func CreatePremiumSession(b Buyer, successURL string) (Session, bool, error) { if !APIConfigured() { - return Charge{}, false, nil + return Session{}, false, nil } - amount := json.Number(PremiumAmount()) - if f, err := strconv.ParseFloat(PremiumAmount(), 64); err == nil { - amount = json.Number(strconv.FormatFloat(f, 'f', -1, 64)) - } - body := map[string]any{ - "business_id": MerchantID(), - "amount": amount, - "currency": PremiumCurrency(), - "blockchain": toUpper(PremiumBlockchain()), - "description": "AgentBBS Premium membership (lifetime)", - "metadata": map[string]string{"ref": ref}, + if b.Email == "" || b.AccountID == "" { + return Session{}, false, fmt.Errorf("coinpay: checkout needs an email and an account id") } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - env, err := coinpayDo(ctx, http.MethodPost, "/payments/create", body) + raw, err := apiDo(ctx, http.MethodPost, "/checkout/sessions", SessionBody(b, successURL)) + if err != nil { + return Session{}, false, err + } + var out struct { + Session + Methods []json.RawMessage `json:"methods"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return Session{}, false, fmt.Errorf("coinpay: bad session response: %w", err) + } + if out.ID == "" || out.URL == "" { + return Session{}, false, fmt.Errorf("coinpay: session created without an id/url: %s", bytesTrim(raw)) + } + ses := out.Session + for _, m := range out.Methods { + var code string + if json.Unmarshal(m, &code) == nil { + ses.Methods = append(ses.Methods, code) + continue + } + var obj map[string]any + if json.Unmarshal(m, &obj) == nil { + for _, k := range []string{"code", "method", "id", "type"} { + if v, ok := obj[k].(string); ok && v != "" { + ses.Methods = append(ses.Methods, v) + break + } + } + } + } + return ses, true, nil +} + +// paidStatus reports whether a CoinPay session/payment status means settled. +func paidStatus(st string) bool { + switch st { + case "paid", "complete", "completed", "confirmed", "forwarded": + return true + } + return false +} + +// SessionStatus fetches a checkout session's status. +func SessionStatus(ctx context.Context, id string) (string, error) { + raw, err := apiDo(ctx, http.MethodGet, "/checkout/sessions/"+url.PathEscape(id), nil) if err != nil { - return Charge{}, false, err - } - p := env.Payment - if p.Address == "" { - return Charge{}, false, fmt.Errorf("coinpay: payment created without an address") - } - return Charge{ - Address: p.Address, - CryptoAmount: string(p.CryptoAmount), - Currency: p.CryptoCurr, - FiatAmount: PremiumAmount(), - FiatCurrency: PremiumCurrency(), - ID: p.ID, - QR: p.QR, - }, true, nil + return "", err + } + var out struct { + Status string `json:"status"` + Session *struct { + Status string `json:"status"` + } `json:"session"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + if out.Status == "" && out.Session != nil { + out.Status = out.Session.Status + } + return out.Status, nil } // VerifyPremium reports whether a created premium payment has settled. payID is -// the CoinPay payment id from CreatePremiumCharge. checked is false when we +// the CoinPay checkout session id (cs_...) from CreatePremiumSession, or a legacy +// payment id from before hosted checkout. checked is false when we // could not reach CoinPay (caller falls back to store state); paid is true on a // confirmed/forwarded status. func VerifyPremium(payID string) (paid bool, checked bool) { @@ -221,24 +259,25 @@ func VerifyPremium(payID string) (paid bool, checked bool) { } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - env, err := coinpayDo(ctx, http.MethodGet, "/payments/"+url.PathEscape(payID), nil) + if strings.HasPrefix(payID, "cs_") { + st, err := SessionStatus(ctx, payID) + if err != nil { + return false, false // network/API error: fall back to store state + } + return paidStatus(st), true + } + // Legacy single-chain payment ids from before hosted checkout. + raw, err := apiDo(ctx, http.MethodGet, "/payments/"+url.PathEscape(payID), nil) if err != nil { - return false, false // network/API error — fall back to store state + return false, false } - switch env.Payment.Status { - case "confirmed", "forwarded", "completed", "paid": - return true, true - default: - return false, true + var env struct { + Payment struct { + Status string `json:"status"` + } `json:"payment"` } -} - -func toUpper(s string) string { - b := []byte(s) - for i, c := range b { - if c >= 'a' && c <= 'z' { - b[i] = c - 32 - } + if json.Unmarshal(raw, &env) != nil { + return false, false } - return string(b) + return paidStatus(env.Payment.Status), true } diff --git a/internal/payments/payments_test.go b/internal/payments/payments_test.go index d377d61..68066f2 100644 --- a/internal/payments/payments_test.go +++ b/internal/payments/payments_test.go @@ -5,25 +5,27 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" ) -func TestCreateAndVerifyPremium(t *testing.T) { - var gotAuth, gotBlockchain, gotBusiness string +func TestCreateSessionAndVerifyPaid(t *testing.T) { + var gotAuth string + var body map[string]any + status := "open" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") switch { - case r.Method == http.MethodPost && r.URL.Path == "/payments/create": - var body map[string]any + case r.Method == http.MethodPost && r.URL.Path == "/checkout/sessions": b, _ := io.ReadAll(r.Body) _ = json.Unmarshal(b, &body) - gotBlockchain, _ = body["blockchain"].(string) - gotBusiness, _ = body["business_id"].(string) - // crypto_amount comes back as a bare JSON number from the live API. - _, _ = w.Write([]byte(`{"payment":{"id":"pay_1","payment_address":"0xABC","crypto_amount":0.0031,"crypto_currency":"ETH","status":"pending"}}`)) - case r.Method == http.MethodGet && r.URL.Path == "/payments/pay_1": - _, _ = w.Write([]byte(`{"payment":{"id":"pay_1","status":"confirmed"}}`)) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"success":true,"id":"cs_abc","url":"https://coinpayportal.com/checkout/cs_abc","status":"open","methods":["BTC","ETH",{"code":"PAYPAL"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/checkout/sessions/cs_abc": + _, _ = w.Write([]byte(`{"success":true,"session":{"id":"cs_abc","status":"` + status + `"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/payments/pay_old": + _, _ = w.Write([]byte(`{"payment":{"id":"pay_old","status":"forwarded"}}`)) default: http.Error(w, "not found", http.StatusNotFound) } @@ -31,64 +33,82 @@ func TestCreateAndVerifyPremium(t *testing.T) { defer srv.Close() t.Setenv("AGENTBBS_COINPAY_API_URL", srv.URL) - t.Setenv("COINPAY_API_KEY", "cp_test_key") - t.Setenv("AGENTBBS_COINPAY_MERCHANT_ID", "biz_42") + t.Setenv("COINPAY_API_KEY", "cp_live_test") + t.Setenv("AGENTBBS_COINPAY_MERCHANT_ID", "") if !APIConfigured() { - t.Fatal("APIConfigured should be true with key + merchant id") + t.Fatal("a business key alone must configure hosted checkout") } - - c, ok, err := CreatePremiumCharge("abbs-premium-deadbeef") + ses, ok, err := CreatePremiumSession(Buyer{AccountID: "alice", Email: "a@example.com", PubKeyFP: "SHA256:xyz", UserID: 7}, "https://bbs.profullstack.com/~alice") if err != nil || !ok { t.Fatalf("create: ok=%v err=%v", ok, err) } - if c.Address != "0xABC" || c.ID != "pay_1" || c.CryptoAmount != "0.0031" { - t.Fatalf("charge fields wrong: %+v", c) + if ses.ID != "cs_abc" || !strings.HasSuffix(ses.URL, "/checkout/cs_abc") { + t.Fatalf("session fields wrong: %+v", ses) } - if gotAuth != "Bearer cp_test_key" { + if len(ses.Methods) != 3 || ses.Methods[2] != "PAYPAL" { + t.Fatalf("methods = %v", ses.Methods) + } + if gotAuth != "Bearer cp_live_test" { t.Fatalf("auth header = %q", gotAuth) } - if gotBlockchain != "ETH" { - t.Fatalf("blockchain not upper-cased: %q", gotBlockchain) + for k, want := range map[string]any{ + "customer_email": "a@example.com", "account_id": "alice", "property": "bbs.profullstack.com", + "currency": "USD", "amount": float64(99), "expires_in_minutes": float64(60), + "success_url": "https://bbs.profullstack.com/~alice", + } { + if body[k] != want { + t.Errorf("body[%s] = %v, want %v", k, body[k], want) + } + } + if _, has := body["blockchain"]; has { + t.Error("hosted checkout must not pin a blockchain") } - if gotBusiness != "biz_42" { - t.Fatalf("business_id = %q", gotBusiness) + meta, _ := body["metadata"].(map[string]any) + if meta["pubkey_fp"] != "SHA256:xyz" || meta["user_id"] != "7" || meta["ref"] != PremiumReference("SHA256:xyz") { + t.Errorf("metadata = %v", meta) } - if paid, checked := VerifyPremium("pay_1"); !checked || !paid { - t.Fatalf("verify confirmed: paid=%v checked=%v", paid, checked) + if paid, checked := VerifyPremium("cs_abc"); !checked || paid { + t.Fatalf("open session: paid=%v checked=%v", paid, checked) + } + status = "paid" + if paid, checked := VerifyPremium("cs_abc"); !checked || !paid { + t.Fatalf("paid session: paid=%v checked=%v", paid, checked) + } + // A legacy single-chain payment stored before hosted checkout still settles. + if paid, checked := VerifyPremium("pay_old"); !checked || !paid { + t.Fatalf("legacy payment: paid=%v checked=%v", paid, checked) } - // Empty id is a clean "not checked". if _, checked := VerifyPremium(""); checked { - t.Fatal("empty payID must not be checked") + t.Fatal("empty id must not be checked") + } +} + +func TestCreateSessionNeedsEmail(t *testing.T) { + t.Setenv("COINPAY_API_KEY", "cp_live_test") + if _, ok, err := CreatePremiumSession(Buyer{AccountID: "alice"}, ""); ok || err == nil { + t.Fatalf("missing email: ok=%v err=%v", ok, err) } } func TestNotConfigured(t *testing.T) { t.Setenv("COINPAY_API_KEY", "") - t.Setenv("AGENTBBS_COINPAY_MERCHANT_ID", "") if APIConfigured() { - t.Fatal("must not be configured without key/merchant") + t.Fatal("must not be configured without a key") } - if _, ok, err := CreatePremiumCharge("ref"); ok || err != nil { + if _, ok, err := CreatePremiumSession(Buyer{AccountID: "a", Email: "a@b.co"}, ""); ok || err != nil { t.Fatalf("unconfigured create: ok=%v err=%v", ok, err) } - if _, checked := VerifyPremium("pay_1"); checked { + if _, checked := VerifyPremium("cs_abc"); checked { t.Fatal("unconfigured verify must not be checked") } } -func TestFlexStrDecodesNumberOrString(t *testing.T) { - for _, raw := range []string{ - `{"payment":{"crypto_amount":0.0031}}`, // live API: bare number - `{"payment":{"crypto_amount":"0.0031"}}`, // legacy: quoted string - } { - var env paymentEnvelope - if err := json.Unmarshal([]byte(raw), &env); err != nil { - t.Fatalf("unmarshal %s: %v", raw, err) - } - if got := string(env.Payment.CryptoAmount); got != "0.0031" { - t.Fatalf("crypto_amount from %s = %q, want 0.0031", raw, got) - } +func TestTerminalQR(t *testing.T) { + q := TerminalQR("https://coinpayportal.com/checkout/cs_abc", " ") + lines := strings.Split(strings.TrimRight(q, "\n"), "\n") + if len(lines) < 10 || !strings.HasPrefix(lines[0], " █") { + t.Fatalf("unexpected QR:\n%s", q) } } diff --git a/internal/payments/qr.go b/internal/payments/qr.go new file mode 100644 index 0000000..6dd6cb2 --- /dev/null +++ b/internal/payments/qr.go @@ -0,0 +1,45 @@ +package payments + +import ( + "strings" + + "rsc.io/qr" +) + +// TerminalQR renders text as a QR code made of Unicode half blocks, two module +// rows per text line, with a quiet zone. Light modules are drawn as blocks so it +// scans as dark-on-light on the usual light-on-dark terminal. Each line is +// prefixed with indent. Returns "" if the text cannot be encoded. +func TerminalQR(text, indent string) string { + code, err := qr.Encode(text, qr.L) + if err != nil { + return "" + } + const quiet = 2 + n := code.Size + light := func(x, y int) bool { + if x < 0 || y < 0 || x >= n || y >= n { + return true // quiet zone + } + return !code.Black(x, y) + } + var b strings.Builder + for y := -quiet; y < n+quiet; y += 2 { + b.WriteString(indent) + for x := -quiet; x < n+quiet; x++ { + top, bot := light(x, y), light(x, y+1) + switch { + case top && bot: + b.WriteString("█") + case top: + b.WriteString("▀") + case bot: + b.WriteString("▄") + default: + b.WriteString(" ") + } + } + b.WriteString("\n") + } + return b.String() +} diff --git a/setup.sh b/setup.sh index 3ec8cae..e885bae 100755 --- a/setup.sh +++ b/setup.sh @@ -429,8 +429,8 @@ AGENTBBS_FILES_WEB_ADDR=${FILES_WEB_ADDR} # Offered at join@. # Premium payments hit the CoinPay REST API directly (no coinpay CLI needed): -# join@ creates a charge and shows the amount + deposit address; a later connect -# verifies settlement by payment id. COINPAY_API_KEY + the merchant id are +# join@ creates a hosted checkout session (any coin or PayPal) and shows its URL +# + a QR code, then polls it; a later connect also verifies by session id. COINPAY_API_KEY + the merchant id are # injected by the CI deploy from GitHub secrets (COINPAY_API_KEY / # COINPAY_MERCHANT_ID); set them here directly when provisioning by hand: # COINPAY_API_KEY=cp_live_xxx @@ -438,7 +438,7 @@ AGENTBBS_FILES_WEB_ADDR=${FILES_WEB_ADDR} # AGENTBBS_COINPAY_API_URL=https://coinpayportal.com/api # AGENTBBS_PREMIUM_AMOUNT=10 # AGENTBBS_PREMIUM_CURRENCY=USD -# AGENTBBS_PREMIUM_BLOCKCHAIN=eth +# AGENTBBS_COINPAY_PROPERTY=bbs.profullstack.com # Member email (free for every verified member). Addresses are @${DOMAIN} # (the address domain), while the Mailu server lives on the mail host below.