mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
5c0feda8d4
commit
7fd10c3436
6 changed files with 289 additions and 167 deletions
|
|
@ -397,11 +397,14 @@ func (a *app) ensurePremium(u *store.User) bool {
|
||||||
if u.Premium {
|
if u.Premium {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
ref := payments.PremiumReference(u.PubKeyFP)
|
// Verify the CoinPay payment we created for them (if any) has settled.
|
||||||
if paid, checked := payments.VerifyPremium(ref); !checked || !paid {
|
if u.PremiumPayID == "" {
|
||||||
return false
|
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)
|
log.Error("grant premium", "err", err)
|
||||||
return false
|
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 {
|
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()
|
amount := "$" + payments.PremiumAmount() + " " + payments.PremiumCurrency()
|
||||||
if c.CryptoAmount != "" {
|
if c.CryptoAmount != "" {
|
||||||
cur := c.Currency
|
cur := c.Currency
|
||||||
|
|
@ -475,7 +482,7 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("create premium charge", "err", err)
|
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,
|
lines = append(lines,
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,59 @@
|
||||||
// Package payments gates paid features (the pod subscription, $1/mo) on
|
// Package payments gates the one-time $10 lifetime Premium membership on
|
||||||
// CoinPay — the default LogicSRC payment/DID/wallet plugin.
|
// 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
|
// Config (env):
|
||||||
// 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:
|
|
||||||
//
|
//
|
||||||
// AGENTBBS_COINPAY_PAY_TMPL e.g. "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
|
// COINPAY_API_KEY Bearer key for the CoinPay API (cp_live_…)
|
||||||
// AGENTBBS_COINPAY_VERIFY_CMD e.g. "coinpay verify --memo %s" (exit 0 == paid)
|
// 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 <user> --months N`.
|
// Operators can also grant pod time manually: `agentbbs grant-pod <user> N`.
|
||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PodPriceLabel is the human-readable price for the pod membership.
|
// PodTerm is how much access one manual pod grant buys (grant-pod CLI).
|
||||||
const PodPriceLabel = "$1/mo"
|
|
||||||
|
|
||||||
// PodTerm is how much access one payment buys.
|
|
||||||
const PodTerm = 31 * 24 * time.Hour
|
const PodTerm = 31 * 24 * time.Hour
|
||||||
|
|
||||||
// PremiumPriceLabel is the human-readable price for the one-time lifetime
|
// PremiumPriceLabel is the human-readable price for the one-time lifetime
|
||||||
// membership offered at join@.
|
// membership offered at join@.
|
||||||
const PremiumPriceLabel = "$10 (lifetime)"
|
const PremiumPriceLabel = "$10 (lifetime)"
|
||||||
|
|
||||||
// premium charge defaults — all overridable via env so the CoinPay surface can
|
// Premium charge parameters — overridable via env so the offer can change
|
||||||
// change without a rebuild (mirrors the pod templates above).
|
// without a rebuild.
|
||||||
func PremiumAmount() string { return envOr("AGENTBBS_PREMIUM_AMOUNT", "10") }
|
func PremiumAmount() string { return envOr("AGENTBBS_PREMIUM_AMOUNT", "10") }
|
||||||
func PremiumCurrency() string { return envOr("AGENTBBS_PREMIUM_CURRENCY", "USD") }
|
func PremiumCurrency() string { return envOr("AGENTBBS_PREMIUM_CURRENCY", "USD") }
|
||||||
func PremiumBlockchain() string { return envOr("AGENTBBS_PREMIUM_BLOCKCHAIN", "eth") }
|
func PremiumBlockchain() string { return envOr("AGENTBBS_PREMIUM_BLOCKCHAIN", "eth") }
|
||||||
|
|
||||||
// MerchantID is the CoinPay merchant/business id payments are created under
|
// MerchantID is the CoinPay merchant/business id payments are created under.
|
||||||
// (AGENTBBS_COINPAY_MERCHANT_ID). When set it is passed as --business-id.
|
|
||||||
func MerchantID() string { return os.Getenv("AGENTBBS_COINPAY_MERCHANT_ID") }
|
func MerchantID() string { return os.Getenv("AGENTBBS_COINPAY_MERCHANT_ID") }
|
||||||
|
|
||||||
// premiumCreateCmd builds the default `coinpay payment create` command, adding
|
func apiKey() string { return os.Getenv("COINPAY_API_KEY") }
|
||||||
// --business-id when a merchant id is configured. extra is appended verbatim
|
func apiBase() string {
|
||||||
// (e.g. " --json --metadata %s"). The coinpay CLI reads COINPAY_API_KEY from
|
return trimSlash(envOr("AGENTBBS_COINPAY_API_URL", "https://coinpayportal.com/api"))
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APIConfigured reports whether live CoinPay calls can be made.
|
||||||
|
func APIConfigured() bool { return apiKey() != "" && MerchantID() != "" }
|
||||||
|
|
||||||
func envOr(k, def string) string {
|
func envOr(k, def string) string {
|
||||||
if v := os.Getenv(k); v != "" {
|
if v := os.Getenv(k); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|
@ -65,143 +61,156 @@ func envOr(k, def string) string {
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charge is a created CoinPay payment a user must fund: a unique deposit
|
func trimSlash(s string) string {
|
||||||
// address plus the crypto amount (and the fiat amount it settles).
|
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||||
type Charge struct {
|
s = s[:len(s)-1]
|
||||||
Address string `json:"payment_address"`
|
}
|
||||||
CryptoAmount string `json:"crypto_amount"`
|
return s
|
||||||
Currency string `json:"crypto_currency"`
|
|
||||||
FiatAmount string `json:"amount"`
|
|
||||||
FiatCurrency string `json:"currency"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
QR string `json:"qr_code"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PremiumReference derives the stable CoinPay memo for a user's lifetime
|
// Charge is a created CoinPay payment a user must fund: a unique deposit
|
||||||
// membership from their key fingerprint.
|
// 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) }
|
func PremiumReference(pubkeyFP string) string { return Reference("premium", pubkeyFP) }
|
||||||
|
|
||||||
// CreatePremiumCharge shells out to the CoinPay CLI to mint a payment address
|
// Reference derives a stable, short payment reference for a user+plan.
|
||||||
// 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.
|
|
||||||
func Reference(plan, pubkeyFP string) string {
|
func Reference(plan, pubkeyFP string) string {
|
||||||
mac := hmac.New(sha256.New, []byte("agentbbs."+plan))
|
mac := hmac.New(sha256.New, []byte("agentbbs."+plan))
|
||||||
mac.Write([]byte(pubkeyFP))
|
mac.Write([]byte(pubkeyFP))
|
||||||
return "abbs-" + plan + "-" + hex.EncodeToString(mac.Sum(nil))[:12]
|
return "abbs-" + plan + "-" + hex.EncodeToString(mac.Sum(nil))[:12]
|
||||||
}
|
}
|
||||||
|
|
||||||
// PayCommand renders the coinpay command a user should run, with the payment
|
// coinpayPayment is the (subset of the) CoinPay payment object, returned
|
||||||
// reference substituted.
|
// wrapped as {"payment": {…}}.
|
||||||
func PayCommand(ref string) string {
|
type coinpayPayment struct {
|
||||||
tmpl := os.Getenv("AGENTBBS_COINPAY_PAY_TMPL")
|
ID string `json:"id"`
|
||||||
if tmpl == "" {
|
Status string `json:"status"`
|
||||||
tmpl = "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
|
Address string `json:"payment_address"`
|
||||||
}
|
CryptoAmount string `json:"crypto_amount"`
|
||||||
if strings.Contains(tmpl, "%s") {
|
CryptoCurr string `json:"crypto_currency"`
|
||||||
return fmt.Sprintf(tmpl, ref)
|
QR string `json:"qr_code"`
|
||||||
}
|
|
||||||
return tmpl + " " + ref
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify checks a payment reference against the coinpay CLI. It returns
|
type paymentEnvelope struct {
|
||||||
// (paid, checked): checked is false when no verifier is configured or the
|
Payment coinpayPayment `json:"payment"`
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runVerify runs a "%s"-templated verify command and maps its exit status to
|
// coinpayDo performs an authenticated CoinPay API call and decodes the
|
||||||
// (paid, checked): checked is false when the template is empty or the binary is
|
// {"payment":{…}} envelope.
|
||||||
// absent, so callers fall back to store state.
|
func coinpayDo(ctx context.Context, method, path string, body any) (*paymentEnvelope, error) {
|
||||||
func runVerify(tmpl, ref string) (paid bool, checked bool) {
|
var rdr io.Reader
|
||||||
if tmpl == "" {
|
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
|
return false, false
|
||||||
}
|
}
|
||||||
line := tmpl
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
if strings.Contains(tmpl, "%s") {
|
defer cancel()
|
||||||
line = fmt.Sprintf(tmpl, ref)
|
env, err := coinpayDo(ctx, http.MethodGet, "/payments/"+url.PathEscape(payID), nil)
|
||||||
} else {
|
if err != nil {
|
||||||
line = tmpl + " " + ref
|
return false, false // network/API error — fall back to store state
|
||||||
}
|
}
|
||||||
parts := strings.Fields(line)
|
switch env.Payment.Status {
|
||||||
if len(parts) == 0 {
|
case "confirmed", "forwarded", "completed", "paid":
|
||||||
return false, false
|
return true, true
|
||||||
}
|
default:
|
||||||
if _, err := exec.LookPath(parts[0]); err != nil {
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil {
|
|
||||||
return false, true
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
78
internal/payments/payments_test.go
Normal file
78
internal/payments/payments_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,19 +19,20 @@ type User struct {
|
||||||
Email string
|
Email string
|
||||||
EmailVerified bool
|
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
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// userCols is the column list (in struct order) for every user SELECT, kept in
|
// userCols is the column list (in struct order) for every user SELECT, kept in
|
||||||
// sync with scanUser.
|
// 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.
|
// scanUser reads one user row selected with userCols.
|
||||||
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
||||||
var u User
|
var u User
|
||||||
var verified, premium int
|
var verified, premium int
|
||||||
var created string
|
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
|
return User{}, err
|
||||||
}
|
}
|
||||||
u.EmailVerified = verified != 0
|
u.EmailVerified = verified != 0
|
||||||
|
|
@ -74,6 +75,9 @@ type Store interface {
|
||||||
// and clears the code. Returns ok=false on a wrong/empty code.
|
// and clears the code. Returns ok=false on a wrong/empty code.
|
||||||
ConfirmEmailCode(userID int64, code string) (User, bool, error)
|
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
|
// GrantPremium marks the account as a lifetime premium member (the $10
|
||||||
// one-time membership), recording the CoinPay payment reference. Idempotent.
|
// one-time membership), recording the CoinPay payment reference. Idempotent.
|
||||||
GrantPremium(userID int64, paymentRef string) error
|
GrantPremium(userID int64, paymentRef string) error
|
||||||
|
|
@ -154,6 +158,7 @@ func migrate(db *sql.DB) error {
|
||||||
{"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"},
|
{"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"},
|
||||||
{"premium", "premium INTEGER NOT NULL DEFAULT 0"},
|
{"premium", "premium INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"},
|
{"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
|
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 {
|
func (s *sqliteStore) GrantPremium(userID int64, paymentRef string) error {
|
||||||
_, err := s.db.Exec(`UPDATE users SET premium = 1, premium_ref = ? WHERE id = ?`, paymentRef, userID)
|
_, err := s.db.Exec(`UPDATE users SET premium = 1, premium_ref = ? WHERE id = ?`, paymentRef, userID)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -67,3 +67,23 @@ func TestGrantPremium(t *testing.T) {
|
||||||
t.Fatalf("re-grant: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
16
setup.sh
16
setup.sh
|
|
@ -186,19 +186,17 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
|
||||||
# Premium \$10 one-time, lifetime — a personal <name>@${DOMAIN} email
|
# Premium \$10 one-time, lifetime — a personal <name>@${DOMAIN} email
|
||||||
# (forwardemail.net) plus custom domains (ssh domain@). Offered at join@.
|
# (forwardemail.net) plus custom domains (ssh domain@). Offered at join@.
|
||||||
|
|
||||||
# Premium payment via the coinpay CLI: join@ mints a charge and shows the amount
|
# Premium payments hit the CoinPay REST API directly (no coinpay CLI needed):
|
||||||
# + deposit address; the status command verifies a later settlement. %s is the
|
# join@ creates a charge and shows the amount + deposit address; a later connect
|
||||||
# per-account payment reference. COINPAY_API_KEY + the merchant id are injected
|
# verifies settlement by payment id. COINPAY_API_KEY + the merchant id are
|
||||||
# by the CI deploy from GitHub secrets (COINPAY_API_KEY / COINPAY_MERCHANT_ID);
|
# injected by the CI deploy from GitHub secrets (COINPAY_API_KEY /
|
||||||
# set them here directly when provisioning by hand:
|
# COINPAY_MERCHANT_ID); set them here directly when provisioning by hand:
|
||||||
# COINPAY_API_KEY=cp_live_xxx
|
# COINPAY_API_KEY=cp_live_xxx
|
||||||
# AGENTBBS_COINPAY_MERCHANT_ID=<merchant/business uuid> # -> --business-id
|
# AGENTBBS_COINPAY_MERCHANT_ID=<merchant/business uuid>
|
||||||
|
# AGENTBBS_COINPAY_API_URL=https://coinpayportal.com/api
|
||||||
# AGENTBBS_PREMIUM_AMOUNT=10
|
# AGENTBBS_PREMIUM_AMOUNT=10
|
||||||
# AGENTBBS_PREMIUM_CURRENCY=USD
|
# AGENTBBS_PREMIUM_CURRENCY=USD
|
||||||
# AGENTBBS_PREMIUM_BLOCKCHAIN=eth
|
# 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 (<name>@${DOMAIN}) auto-created on forwardemail.net.
|
# Premium email aliases (<name>@${DOMAIN}) auto-created on forwardemail.net.
|
||||||
# Without an API key the address is shown but not created (add it manually).
|
# Without an API key the address is shown but not created (add it manually).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue