From 7fd10c34366c2a0d0afff1d773d774a6d13916fc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 08:27:33 +0000 Subject: [PATCH] payments: call CoinPay REST API directly (drop coinpay CLI dependency) CreatePremiumCharge/VerifyPremium now POST /payments/create and GET /payments/:id against the CoinPay API (Bearer COINPAY_API_KEY, business_id = AGENTBBS_COINPAY_MERCHANT_ID), so the droplet needs no coinpay CLI installed. The created payment id is stored (store: User.PremiumPayID + premium_pay_id col + SetPremiumPayment); ensurePremium verifies that id on a later connect and grants premium on confirmed/forwarded status. Removed the CLI command-template env knobs; added AGENTBBS_COINPAY_API_URL. Tests: httptest-backed payments_test + store SetPremiumPayment test. Build/vet/gofmt/test green. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 15 +- internal/payments/payments.go | 311 ++++++++++++++------------- internal/payments/payments_test.go | 78 +++++++ internal/store/store.go | 16 +- internal/store/store_premium_test.go | 20 ++ setup.sh | 16 +- 6 files changed, 289 insertions(+), 167 deletions(-) create mode 100644 internal/payments/payments_test.go diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index ac40932..492c905 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -397,11 +397,14 @@ func (a *app) ensurePremium(u *store.User) bool { if u.Premium { return true } - ref := payments.PremiumReference(u.PubKeyFP) - if paid, checked := payments.VerifyPremium(ref); !checked || !paid { + // Verify the CoinPay payment we created for them (if any) has settled. + if u.PremiumPayID == "" { return false } - if err := a.st.GrantPremium(u.ID, ref); err != nil { + if paid, checked := payments.VerifyPremium(u.PremiumPayID); !checked || !paid { + return false + } + if err := a.st.GrantPremium(u.ID, u.PremiumPayID); err != nil { log.Error("grant premium", "err", err) return false } @@ -456,6 +459,10 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) { "", } if c, ok, err := payments.CreatePremiumCharge(ref); ok && err == nil { + // 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) + } amount := "$" + payments.PremiumAmount() + " " + payments.PremiumCurrency() if c.CryptoAmount != "" { cur := c.Currency @@ -475,7 +482,7 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) { if err != nil { log.Error("create premium charge", "err", err) } - lines = append(lines, " pay: "+payments.PremiumPayCommand(ref)) + lines = append(lines, " Payment is temporarily unavailable — please try again shortly.") } lines = append(lines, "", diff --git a/internal/payments/payments.go b/internal/payments/payments.go index cfd9c49..7003d7d 100644 --- a/internal/payments/payments.go +++ b/internal/payments/payments.go @@ -1,63 +1,59 @@ -// Package payments gates paid features (the pod subscription, $1/mo) on -// CoinPay — the default LogicSRC payment/DID/wallet plugin. +// Package payments gates the one-time $10 lifetime Premium membership on +// CoinPay (coinpayportal.com). It talks to the CoinPay REST API directly over +// HTTP — no `coinpay` CLI needs to be installed on the host. // -// v1 integration is CLI-shaped: join@ hands the user a `coinpay` command -// carrying a unique payment reference, and verification shells out to the -// coinpay CLI. The exact command templates are env-configurable so the -// deployed CoinPay surface can evolve without a rebuild: +// Config (env): // -// AGENTBBS_COINPAY_PAY_TMPL e.g. "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s" -// AGENTBBS_COINPAY_VERIFY_CMD e.g. "coinpay verify --memo %s" (exit 0 == paid) +// COINPAY_API_KEY Bearer key for the CoinPay API (cp_live_…) +// AGENTBBS_COINPAY_MERCHANT_ID merchant/business id payments are created under +// AGENTBBS_COINPAY_API_URL API base (default https://coinpayportal.com/api) +// AGENTBBS_PREMIUM_AMOUNT fiat amount (default 10) +// AGENTBBS_PREMIUM_CURRENCY fiat currency (default USD) +// AGENTBBS_PREMIUM_BLOCKCHAIN settlement chain (default eth) // -// Operators can also grant manually: `agentbbs grant-pod --months N`. +// Operators can also grant pod time manually: `agentbbs grant-pod N`. package payments import ( + "bytes" + "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" + "io" + "net/http" + "net/url" "os" - "os/exec" - "strings" + "strconv" "time" ) -// PodPriceLabel is the human-readable price for the pod membership. -const PodPriceLabel = "$1/mo" - -// PodTerm is how much access one payment buys. +// PodTerm is how much access one manual pod grant buys (grant-pod CLI). const PodTerm = 31 * 24 * time.Hour // PremiumPriceLabel is the human-readable price for the one-time lifetime // membership offered at join@. const PremiumPriceLabel = "$10 (lifetime)" -// premium charge defaults — all overridable via env so the CoinPay surface can -// change without a rebuild (mirrors the pod templates above). +// Premium charge parameters — overridable via env so the offer can change +// without a rebuild. func PremiumAmount() string { return envOr("AGENTBBS_PREMIUM_AMOUNT", "10") } func PremiumCurrency() string { return envOr("AGENTBBS_PREMIUM_CURRENCY", "USD") } func PremiumBlockchain() string { return envOr("AGENTBBS_PREMIUM_BLOCKCHAIN", "eth") } -// MerchantID is the CoinPay merchant/business id payments are created under -// (AGENTBBS_COINPAY_MERCHANT_ID). When set it is passed as --business-id. +// MerchantID is the CoinPay merchant/business id payments are created under. func MerchantID() string { return os.Getenv("AGENTBBS_COINPAY_MERCHANT_ID") } -// premiumCreateCmd builds the default `coinpay payment create` command, adding -// --business-id when a merchant id is configured. extra is appended verbatim -// (e.g. " --json --metadata %s"). The coinpay CLI reads COINPAY_API_KEY from -// the environment for auth. -func premiumCreateCmd(extra string) string { - cmd := "coinpay payment create --amount " + PremiumAmount() + - " --currency " + PremiumCurrency() + - " --blockchain " + PremiumBlockchain() - if m := MerchantID(); m != "" { - cmd += " --business-id " + m - } - return cmd + extra +func apiKey() string { return os.Getenv("COINPAY_API_KEY") } +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() != "" } + func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v @@ -65,143 +61,156 @@ func envOr(k, def string) string { return def } -// Charge is a created CoinPay payment a user must fund: a unique deposit -// address plus the crypto amount (and the fiat amount it settles). -type Charge struct { - Address string `json:"payment_address"` - CryptoAmount string `json:"crypto_amount"` - Currency string `json:"crypto_currency"` - FiatAmount string `json:"amount"` - FiatCurrency string `json:"currency"` - ID string `json:"id"` - QR string `json:"qr_code"` +func trimSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s } -// PremiumReference derives the stable CoinPay memo for a user's lifetime -// membership from their key fingerprint. +// 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) } -// CreatePremiumCharge shells out to the CoinPay CLI to mint a payment address -// for the $10 lifetime membership and parses the JSON it prints. created is -// false when no create command is configured or the CLI is unavailable, so the -// caller can fall back to PremiumPayCommand. The reference is passed as the -// payment metadata/memo so the eventual settlement reconciles to the account. -// -// AGENTBBS_COINPAY_PREMIUM_CREATE_CMD -// default: coinpay payment create --amount 10 --currency USD --blockchain eth --json --metadata %s -func CreatePremiumCharge(ref string) (Charge, bool, error) { - tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_CREATE_CMD") - if tmpl == "" { - tmpl = premiumCreateCmd(" --json --metadata %s") - } - line := tmpl - if strings.Contains(tmpl, "%s") { - line = fmt.Sprintf(tmpl, ref) - } else { - line = tmpl + " " + ref - } - parts := strings.Fields(line) - if len(parts) == 0 { - return Charge{}, false, nil - } - if _, err := exec.LookPath(parts[0]); err != nil { - return Charge{}, false, nil // CLI not installed — caller falls back - } - out, err := exec.Command(parts[0], parts[1:]...).Output() - if err != nil { - return Charge{}, false, err - } - var c Charge - if err := json.Unmarshal(out, &c); err != nil { - // Some CLIs wrap the payment under a top-level key, e.g. {"payment":{…}}. - var wrap struct { - Payment Charge `json:"payment"` - } - if json.Unmarshal(out, &wrap) == nil && wrap.Payment.Address != "" { - c = wrap.Payment - } else { - return Charge{}, false, err - } - } - if c.Address == "" { - return Charge{}, false, nil - } - return c, true, nil -} - -// PremiumPayCommand is the manual fallback shown when no charge could be minted -// in-session: the command the user can run themselves to pay. -// -// AGENTBBS_COINPAY_PREMIUM_PAY_TMPL -func PremiumPayCommand(ref string) string { - tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_PAY_TMPL") - if tmpl == "" { - tmpl = premiumCreateCmd(" --metadata %s") - } - if strings.Contains(tmpl, "%s") { - return fmt.Sprintf(tmpl, ref) - } - return tmpl + " " + ref -} - -// VerifyPremium checks whether a premium charge has settled, via the CoinPay -// status command. Like Verify, checked is false when unconfigured/unavailable. -// -// AGENTBBS_COINPAY_PREMIUM_STATUS_CMD e.g. "coinpay payment status %s" (exit 0 == paid) -func VerifyPremium(payRef string) (paid bool, checked bool) { - return runVerify(os.Getenv("AGENTBBS_COINPAY_PREMIUM_STATUS_CMD"), payRef) -} - -// Reference derives a stable, short payment reference for a user+plan from -// the user's key fingerprint, so CoinPay memos can be reconciled to accounts. +// Reference derives a stable, short payment reference for a user+plan. func Reference(plan, pubkeyFP string) string { mac := hmac.New(sha256.New, []byte("agentbbs."+plan)) mac.Write([]byte(pubkeyFP)) return "abbs-" + plan + "-" + hex.EncodeToString(mac.Sum(nil))[:12] } -// PayCommand renders the coinpay command a user should run, with the payment -// reference substituted. -func PayCommand(ref string) string { - tmpl := os.Getenv("AGENTBBS_COINPAY_PAY_TMPL") - if tmpl == "" { - tmpl = "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s" - } - if strings.Contains(tmpl, "%s") { - return fmt.Sprintf(tmpl, ref) - } - return tmpl + " " + ref +// 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 string `json:"crypto_amount"` + CryptoCurr string `json:"crypto_currency"` + QR string `json:"qr_code"` } -// Verify checks a payment reference against the coinpay CLI. It returns -// (paid, checked): checked is false when no verifier is configured or the -// coinpay binary is unavailable, so callers can fall back to store state. -func Verify(ref string) (paid bool, checked bool) { - return runVerify(os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD"), ref) +type paymentEnvelope struct { + Payment coinpayPayment `json:"payment"` } -// runVerify runs a "%s"-templated verify command and maps its exit status to -// (paid, checked): checked is false when the template is empty or the binary is -// absent, so callers fall back to store state. -func runVerify(tmpl, ref string) (paid bool, checked bool) { - if tmpl == "" { +// coinpayDo performs an authenticated CoinPay API call and decodes the +// {"payment":{…}} envelope. +func coinpayDo(ctx context.Context, method, path string, body any) (*paymentEnvelope, error) { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, apiBase()+path, rdr) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey()) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + 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 +} + +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) { + if !APIConfigured() { + return Charge{}, 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}, + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + env, err := coinpayDo(ctx, http.MethodPost, "/payments/create", body) + 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: p.CryptoAmount, + Currency: p.CryptoCurr, + FiatAmount: PremiumAmount(), + FiatCurrency: PremiumCurrency(), + ID: p.ID, + QR: p.QR, + }, true, nil +} + +// VerifyPremium reports whether a created premium payment has settled. payID is +// the CoinPay payment id from CreatePremiumCharge. 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) { + if payID == "" || !APIConfigured() { return false, false } - line := tmpl - if strings.Contains(tmpl, "%s") { - line = fmt.Sprintf(tmpl, ref) - } else { - line = tmpl + " " + ref + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + env, err := coinpayDo(ctx, http.MethodGet, "/payments/"+url.PathEscape(payID), nil) + if err != nil { + return false, false // network/API error — fall back to store state } - parts := strings.Fields(line) - if len(parts) == 0 { - return false, false - } - if _, err := exec.LookPath(parts[0]); err != nil { - return false, false - } - if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil { + switch env.Payment.Status { + case "confirmed", "forwarded", "completed", "paid": + return true, true + default: return false, true } - return true, true +} + +func toUpper(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'a' && c <= 'z' { + b[i] = c - 32 + } + } + return string(b) } diff --git a/internal/payments/payments_test.go b/internal/payments/payments_test.go new file mode 100644 index 0000000..e2dd052 --- /dev/null +++ b/internal/payments/payments_test.go @@ -0,0 +1,78 @@ +package payments + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateAndVerifyPremium(t *testing.T) { + var gotAuth, gotBlockchain, gotBusiness string + 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 + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &body) + gotBlockchain, _ = body["blockchain"].(string) + gotBusiness, _ = body["business_id"].(string) + _, _ = 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"}}`)) + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + 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") + + if !APIConfigured() { + t.Fatal("APIConfigured should be true with key + merchant id") + } + + c, ok, err := CreatePremiumCharge("abbs-premium-deadbeef") + 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 gotAuth != "Bearer cp_test_key" { + t.Fatalf("auth header = %q", gotAuth) + } + if gotBlockchain != "ETH" { + t.Fatalf("blockchain not upper-cased: %q", gotBlockchain) + } + if gotBusiness != "biz_42" { + t.Fatalf("business_id = %q", gotBusiness) + } + + if paid, checked := VerifyPremium("pay_1"); !checked || !paid { + t.Fatalf("verify confirmed: 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") + } +} + +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") + } + if _, ok, err := CreatePremiumCharge("ref"); ok || err != nil { + t.Fatalf("unconfigured create: ok=%v err=%v", ok, err) + } + if _, checked := VerifyPremium("pay_1"); checked { + t.Fatal("unconfigured verify must not be checked") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index dc7379f..fdf2a22 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -18,20 +18,21 @@ type User struct { PubKeyFP string Email string EmailVerified bool - Premium bool // paid the one-time lifetime membership + Premium bool // paid the one-time lifetime membership + PremiumPayID string // CoinPay payment id of the pending/settled premium charge CreatedAt time.Time } // userCols is the column list (in struct order) for every user SELECT, kept in // sync with scanUser. -const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, created_at` +const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, premium_pay_id, created_at` // scanUser reads one user row selected with userCols. func scanUser(sc interface{ Scan(...any) error }) (User, error) { var u User var verified, premium int var created string - if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &created); err != nil { + if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &u.PremiumPayID, &created); err != nil { return User{}, err } u.EmailVerified = verified != 0 @@ -74,6 +75,9 @@ type Store interface { // and clears the code. Returns ok=false on a wrong/empty code. ConfirmEmailCode(userID int64, code string) (User, bool, error) + // SetPremiumPayment records the CoinPay payment id of a pending premium + // charge so a later visit can verify whether it settled. + SetPremiumPayment(userID int64, payID string) error // GrantPremium marks the account as a lifetime premium member (the $10 // one-time membership), recording the CoinPay payment reference. Idempotent. GrantPremium(userID int64, paymentRef string) error @@ -154,6 +158,7 @@ func migrate(db *sql.DB) error { {"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"}, {"premium", "premium INTEGER NOT NULL DEFAULT 0"}, {"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"}, + {"premium_pay_id", "premium_pay_id TEXT NOT NULL DEFAULT ''"}, }) } @@ -312,6 +317,11 @@ func (s *sqliteStore) ConfirmEmailCode(userID int64, code string) (User, bool, e return u, true, nil } +func (s *sqliteStore) SetPremiumPayment(userID int64, payID string) error { + _, err := s.db.Exec(`UPDATE users SET premium_pay_id = ? WHERE id = ?`, payID, userID) + return err +} + func (s *sqliteStore) GrantPremium(userID int64, paymentRef string) error { _, err := s.db.Exec(`UPDATE users SET premium = 1, premium_ref = ? WHERE id = ?`, paymentRef, userID) return err diff --git a/internal/store/store_premium_test.go b/internal/store/store_premium_test.go index 8c30b19..20a9fa6 100644 --- a/internal/store/store_premium_test.go +++ b/internal/store/store_premium_test.go @@ -67,3 +67,23 @@ func TestGrantPremium(t *testing.T) { t.Fatalf("re-grant: %v", err) } } + +func TestSetPremiumPayment(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer st.Close() + + u, _ := st.EnsureUser("erin", "member", "SHA256:eee") + if u.PremiumPayID != "" { + t.Fatal("new user must have no premium pay id") + } + if err := st.SetPremiumPayment(u.ID, "pay_abc123"); err != nil { + t.Fatalf("set: %v", err) + } + got, _, _ := st.UserByFingerprint("SHA256:eee") + if got.PremiumPayID != "pay_abc123" { + t.Fatalf("PremiumPayID = %q", got.PremiumPayID) + } +} diff --git a/setup.sh b/setup.sh index 09b3902..2e96d66 100755 --- a/setup.sh +++ b/setup.sh @@ -186,19 +186,17 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # Premium \$10 one-time, lifetime — a personal @${DOMAIN} email # (forwardemail.net) plus custom domains (ssh domain@). Offered at join@. -# Premium payment via the coinpay CLI: join@ mints a charge and shows the amount -# + deposit address; the status command verifies a later settlement. %s is the -# per-account payment reference. 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: +# 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 +# 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 -# AGENTBBS_COINPAY_MERCHANT_ID= # -> --business-id +# AGENTBBS_COINPAY_MERCHANT_ID= +# AGENTBBS_COINPAY_API_URL=https://coinpayportal.com/api # AGENTBBS_PREMIUM_AMOUNT=10 # AGENTBBS_PREMIUM_CURRENCY=USD # AGENTBBS_PREMIUM_BLOCKCHAIN=eth -# AGENTBBS_COINPAY_PREMIUM_CREATE_CMD=coinpay payment create --amount 10 --currency USD --blockchain eth --json --metadata %s -# AGENTBBS_COINPAY_PREMIUM_PAY_TMPL=coinpay payment create --amount 10 --currency USD --blockchain eth --metadata %s -# AGENTBBS_COINPAY_PREMIUM_STATUS_CMD=coinpay payment status %s # Premium email aliases (@${DOMAIN}) auto-created on forwardemail.net. # Without an API key the address is shown but not created (add it manually).