Files
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

484 lines
13 KiB
Go

// Package bitpay implements the BitPay payment processor for Commerce.
// Uses the BitPay REST API v2 directly (no SDK dependency).
package bitpay
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/payment/processor"
)
const (
testBaseURL = "https://test.bitpay.com"
prodBaseURL = "https://bitpay.com"
defaultTimeout = 30 * time.Second
)
// Config holds BitPay API credentials.
type Config struct {
APIToken string
Environment string // "test" or "prod"
}
// Provider implements processor.PaymentProcessor for BitPay API v2.
type Provider struct {
*processor.BaseProcessor
apiToken string
environment string
client *http.Client
}
func init() {
token := os.Getenv("BITPAY_API_TOKEN")
env := os.Getenv("BITPAY_ENVIRONMENT")
if env == "" {
env = "test"
}
p := &Provider{
BaseProcessor: processor.NewBaseProcessor(processor.BitPay, supportedCurrencies()),
apiToken: token,
environment: env,
client: &http.Client{Timeout: defaultTimeout},
}
if token != "" {
p.SetConfigured(true)
}
processor.Register(p)
}
// NewProvider creates a configured BitPay provider instance.
func NewProvider(cfg Config) *Provider {
env := cfg.Environment
if env == "" {
env = "test"
}
p := &Provider{
BaseProcessor: processor.NewBaseProcessor(processor.BitPay, supportedCurrencies()),
apiToken: cfg.APIToken,
environment: env,
client: &http.Client{Timeout: defaultTimeout},
}
if cfg.APIToken != "" {
p.SetConfigured(true)
}
return p
}
func supportedCurrencies() []currency.Type {
return []currency.Type{
currency.BTC, "bch", currency.ETH, "usdc", "gusd", "pax", "busd",
"xrp", "doge", "ltc", "shib",
}
}
// Type returns the processor type.
func (p *Provider) Type() processor.ProcessorType {
return processor.BitPay
}
// IsAvailable reports whether the processor is configured.
func (p *Provider) IsAvailable(ctx context.Context) bool {
return p.apiToken != ""
}
// SupportedCurrencies returns currencies this processor supports.
func (p *Provider) SupportedCurrencies() []currency.Type {
return supportedCurrencies()
}
func (p *Provider) baseURL() string {
if p.environment == "prod" {
return prodBaseURL
}
return testBaseURL
}
// ---------------------------------------------------------------------------
// PaymentProcessor
// ---------------------------------------------------------------------------
// Charge creates a BitPay invoice.
func (p *Provider) Charge(ctx context.Context, req processor.PaymentRequest) (*processor.PaymentResult, error) {
if err := p.ensureAvailable(); err != nil {
return nil, err
}
if err := processor.ValidateRequest(req); err != nil {
return nil, err
}
// json.Number carries the exact digits, and ToStringNoSymbol renders at the
// CURRENCY's scale. The old float64(req.Amount)/100 was wrong twice over: it
// approximated the amount, and it assumed two decimals, so a zero-decimal
// currency was divided by 100 anyway — JPY 500 was sent as 5.00.
body := invoiceRequest{
Price: json.Number(req.Currency.ToStringNoSymbol(req.Amount)),
Currency: strings.ToUpper(string(req.Currency)),
Token: p.apiToken,
}
if req.OrderID != "" {
body.OrderID = req.OrderID
}
if req.Description != "" {
body.ItemDesc = req.Description
}
if req.CustomerID != "" {
body.Buyer.Email = req.CustomerID
}
if email, ok := req.Metadata["email"].(string); ok {
body.Buyer.Email = email
}
if name, ok := req.Metadata["name"].(string); ok {
body.Buyer.Name = name
}
if cbURL, ok := req.Options["notificationURL"].(string); ok {
body.NotificationURL = cbURL
}
if redirURL, ok := req.Options["redirectURL"].(string); ok {
body.RedirectURL = redirURL
}
var resp invoiceResponse
if err := p.post(ctx, "/invoices", body, &resp); err != nil {
return &processor.PaymentResult{
Success: false,
ErrorMessage: err.Error(),
Error: err,
}, err
}
return &processor.PaymentResult{
Success: true,
TransactionID: resp.Data.ID,
ProcessorRef: resp.Data.ID,
Status: mapInvoiceStatus(resp.Data.Status),
Metadata: map[string]interface{}{
"invoiceUrl": resp.Data.URL,
"bitpayStatus": resp.Data.Status,
"expirationTime": resp.Data.ExpirationTime,
},
}, nil
}
// GetTransaction retrieves a BitPay invoice by ID.
func (p *Provider) GetTransaction(ctx context.Context, txID string) (*processor.Transaction, error) {
if err := p.ensureAvailable(); err != nil {
return nil, err
}
if txID == "" {
return nil, processor.NewPaymentError(processor.BitPay, "INVALID_TRANSACTION",
"transaction ID is required", nil)
}
var resp invoiceResponse
if err := p.get(ctx, "/invoices/"+txID, &resp); err != nil {
return nil, err
}
cur := currency.Type(strings.ToLower(resp.Data.Currency))
amount, err := cur.Parse(resp.Data.Price.String())
if err != nil {
return nil, processor.NewPaymentError(processor.BitPay, "INVALID_AMOUNT",
fmt.Sprintf("bitpay returned price %q, which is not a decimal amount", resp.Data.Price), err)
}
return &processor.Transaction{
ID: resp.Data.ID,
ProcessorRef: resp.Data.ID,
Type: "invoice",
Amount: amount,
Currency: cur,
Status: mapInvoiceStatus(resp.Data.Status),
CreatedAt: resp.Data.InvoiceTime / 1000, // ms to s
UpdatedAt: resp.Data.CurrentTime / 1000,
Metadata: map[string]interface{}{
"bitpayStatus": resp.Data.Status,
"orderId": resp.Data.OrderID,
},
}, nil
}
// Refund creates a BitPay refund request.
func (p *Provider) Refund(ctx context.Context, req processor.RefundRequest) (*processor.RefundResult, error) {
if err := p.ensureAvailable(); err != nil {
return nil, err
}
if req.TransactionID == "" {
return nil, processor.NewPaymentError(processor.BitPay, "INVALID_TRANSACTION",
"transaction ID is required for refund", nil)
}
if req.Amount.Sign() <= 0 {
return nil, processor.NewPaymentError(processor.BitPay, "INVALID_AMOUNT",
"refund amount must be positive", nil)
}
body := refundRequest{
Token: p.apiToken,
InvoiceID: req.TransactionID,
Amount: json.Number(req.Amount.MajorString()),
Preview: false,
Immediate: false,
}
var resp refundResponse
if err := p.post(ctx, "/refunds", body, &resp); err != nil {
return &processor.RefundResult{
Success: false,
ErrorMessage: err.Error(),
Error: err,
}, err
}
return &processor.RefundResult{
Success: resp.Data.Status == "created" || resp.Data.Status == "pending",
RefundID: resp.Data.ID,
ProcessorRef: resp.Data.ID,
}, nil
}
// ValidateWebhook verifies a BitPay webhook signature (HMAC-SHA256 with API token).
func (p *Provider) ValidateWebhook(ctx context.Context, payload []byte, signature string) (*processor.WebhookEvent, error) {
if p.apiToken == "" {
return nil, processor.ErrWebhookValidationFailed
}
if signature == "" {
return nil, processor.ErrWebhookValidationFailed
}
mac := hmac.New(sha256.New, []byte(p.apiToken))
mac.Write(payload)
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
return nil, processor.ErrWebhookValidationFailed
}
var evt webhookPayload
if err := json.Unmarshal(payload, &evt); err != nil {
return nil, fmt.Errorf("failed to parse bitpay webhook: %w", err)
}
return &processor.WebhookEvent{
ID: evt.Data.ID,
Type: mapWebhookEventType(evt.Event.Name),
Processor: processor.BitPay,
Data: map[string]interface{}{
"invoiceId": evt.Data.ID,
"status": evt.Data.Status,
"price": evt.Data.Price,
"currency": evt.Data.Currency,
"orderId": evt.Data.OrderID,
},
Timestamp: time.Now().Unix(),
}, nil
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
func (p *Provider) post(ctx context.Context, path string, body interface{}, result interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return processor.NewPaymentError(processor.BitPay, "MARSHAL_ERROR",
"failed to marshal request body", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL()+path, bytes.NewReader(jsonBody))
if err != nil {
return processor.NewPaymentError(processor.BitPay, "REQUEST_ERROR",
"failed to create HTTP request", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-Accept-Version", "2.0.0")
httpReq.Header.Set("X-Identity", "")
return p.doRequest(httpReq, result)
}
func (p *Provider) get(ctx context.Context, path string, result interface{}) error {
u := p.baseURL() + path + "?token=" + p.apiToken
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return processor.NewPaymentError(processor.BitPay, "REQUEST_ERROR",
"failed to create HTTP request", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-Accept-Version", "2.0.0")
return p.doRequest(httpReq, result)
}
func (p *Provider) doRequest(req *http.Request, result interface{}) error {
resp, err := p.client.Do(req)
if err != nil {
return processor.NewPaymentError(processor.BitPay, "NETWORK_ERROR",
"failed to send request to BitPay", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return processor.NewPaymentError(processor.BitPay, "READ_ERROR",
"failed to read BitPay response", err)
}
if resp.StatusCode >= 400 {
var apiErr bitpayErrorResponse
if json.Unmarshal(body, &apiErr) == nil && apiErr.Error != "" {
return processor.NewPaymentError(processor.BitPay, fmt.Sprintf("HTTP_%d", resp.StatusCode),
fmt.Sprintf("bitpay API error: %s", apiErr.Error), nil)
}
return processor.NewPaymentError(processor.BitPay, fmt.Sprintf("HTTP_%d", resp.StatusCode),
fmt.Sprintf("bitpay API error (HTTP %d): %s", resp.StatusCode, string(body)), nil)
}
if result != nil {
if err := json.Unmarshal(body, result); err != nil {
return processor.NewPaymentError(processor.BitPay, "DECODE_ERROR",
"failed to decode BitPay response", err)
}
}
return nil
}
// ---------------------------------------------------------------------------
// BitPay API types
// ---------------------------------------------------------------------------
type invoiceBuyer struct {
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
}
type invoiceRequest struct {
Token string `json:"token"`
Price json.Number `json:"price"`
Currency string `json:"currency"`
OrderID string `json:"orderId,omitempty"`
ItemDesc string `json:"itemDesc,omitempty"`
Buyer invoiceBuyer `json:"buyer,omitempty"`
NotificationURL string `json:"notificationURL,omitempty"`
RedirectURL string `json:"redirectURL,omitempty"`
}
type invoiceData struct {
ID string `json:"id"`
URL string `json:"url"`
Status string `json:"status"`
// json.Number keeps BitPay's decimal as the digits they sent. Decoding it
// into a float64 is where a captured amount loses a cent: 19.99 has no exact
// binary form, so Cents(price*100) yielded 1998.
Price json.Number `json:"price"`
Currency string `json:"currency"`
OrderID string `json:"orderId"`
InvoiceTime int64 `json:"invoiceTime"`
ExpirationTime int64 `json:"expirationTime"`
CurrentTime int64 `json:"currentTime"`
}
type invoiceResponse struct {
Data invoiceData `json:"data"`
}
type refundRequest struct {
Token string `json:"token"`
InvoiceID string `json:"invoiceId"`
Amount json.Number `json:"amount"`
Preview bool `json:"preview"`
Immediate bool `json:"immediate"`
}
type refundData struct {
ID string `json:"id"`
Status string `json:"status"`
}
type refundResponse struct {
Data refundData `json:"data"`
}
type webhookPayload struct {
Event struct {
Name string `json:"name"`
} `json:"event"`
Data invoiceData `json:"data"`
}
type bitpayErrorResponse struct {
Error string `json:"error"`
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func (p *Provider) ensureAvailable() error {
if p.apiToken == "" {
return processor.NewPaymentError(processor.BitPay, "NOT_CONFIGURED",
"bitpay processor not configured", nil)
}
return nil
}
func mapInvoiceStatus(status string) string {
switch status {
case "new":
return "pending"
case "paid":
return "paid"
case "confirmed":
return "confirmed"
case "complete":
return "succeeded"
case "expired":
return "expired"
case "invalid":
return "failed"
default:
return status
}
}
func mapWebhookEventType(name string) string {
switch name {
case "invoice_paidInFull":
return "payment.completed"
case "invoice_confirmed":
return "payment.confirmed"
case "invoice_completed":
return "payment.completed"
case "invoice_expired":
return "payment.expired"
case "invoice_failedToConfirm":
return "payment.failed"
case "invoice_declined":
return "payment.declined"
case "invoice_refundComplete":
return "refund.succeeded"
default:
return "bitpay." + name
}
}
// Compile-time interface check.
var _ processor.PaymentProcessor = (*Provider)(nil)