init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
234
internal/billing/lemon.go
Normal file
234
internal/billing/lemon.go
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
package billing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
const lemonBaseURL = "https://api.lemonsqueezy.com/v1"
|
||||
|
||||
type LemonClient struct {
|
||||
apiKey string
|
||||
storeID string
|
||||
webhookSecret string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewLemonClient() *LemonClient {
|
||||
return &LemonClient{
|
||||
apiKey: os.Getenv("LEMON_API_KEY"),
|
||||
storeID: os.Getenv("LEMON_STORE_ID"),
|
||||
webhookSecret: os.Getenv("LEMON_WEBHOOK_SECRET"),
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LemonClient) IsConfigured() bool {
|
||||
return c.apiKey != "" && c.storeID != ""
|
||||
}
|
||||
|
||||
type CheckoutRequest struct {
|
||||
StoreID string `json:"store_id"`
|
||||
VariantID string `json:"variant_id"`
|
||||
CustomPrice *int `json:"custom_price,omitempty"`
|
||||
CheckoutOptions *CheckoutOptions `json:"checkout_options,omitempty"`
|
||||
CheckoutData *CheckoutData `json:"checkout_data,omitempty"`
|
||||
}
|
||||
|
||||
type CheckoutOptions struct {
|
||||
Embed bool `json:"embed,omitempty"`
|
||||
}
|
||||
|
||||
type CheckoutData struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Custom map[string]string `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
type CheckoutResponse struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Attributes struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"attributes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *LemonClient) CreateCheckout(ctx context.Context, req *CheckoutRequest) (*CheckoutResponse, error) {
|
||||
if req.StoreID == "" {
|
||||
req.StoreID = c.storeID
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "checkouts",
|
||||
"attributes": req,
|
||||
"relationships": map[string]any{
|
||||
"store": map[string]any{
|
||||
"data": map[string]any{"type": "stores", "id": req.StoreID},
|
||||
},
|
||||
"variant": map[string]any{
|
||||
"data": map[string]any{"type": "variants", "id": req.VariantID},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", lemonBaseURL+"/checkouts", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
httpReq.Header.Set("Content-Type", "application/vnd.api+json")
|
||||
httpReq.Header.Set("Accept", "application/vnd.api+json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("checkout creation failed (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var checkoutResp CheckoutResponse
|
||||
if err := json.Unmarshal(respBody, &checkoutResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &checkoutResp, nil
|
||||
}
|
||||
|
||||
func (c *LemonClient) CreateSubscriptionCheckout(ctx context.Context, variantID, email, name string, custom map[string]string) (string, error) {
|
||||
req := &CheckoutRequest{
|
||||
VariantID: variantID,
|
||||
CheckoutData: &CheckoutData{
|
||||
Email: email,
|
||||
Name: name,
|
||||
Custom: custom,
|
||||
},
|
||||
CheckoutOptions: &CheckoutOptions{Embed: true},
|
||||
}
|
||||
|
||||
resp, err := c.CreateCheckout(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resp.Data.Attributes.URL, nil
|
||||
}
|
||||
|
||||
func (c *LemonClient) CreateDonationCheckout(ctx context.Context, variantID string, amountCents int, email, name, message string, custom map[string]string) (string, error) {
|
||||
if custom == nil {
|
||||
custom = make(map[string]string)
|
||||
}
|
||||
if message != "" {
|
||||
custom["message"] = message
|
||||
}
|
||||
|
||||
req := &CheckoutRequest{
|
||||
VariantID: variantID,
|
||||
CustomPrice: &amountCents,
|
||||
CheckoutData: &CheckoutData{
|
||||
Email: email,
|
||||
Name: name,
|
||||
Custom: custom,
|
||||
},
|
||||
CheckoutOptions: &CheckoutOptions{Embed: true},
|
||||
}
|
||||
|
||||
resp, err := c.CreateCheckout(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resp.Data.Attributes.URL, nil
|
||||
}
|
||||
|
||||
func (c *LemonClient) VerifyWebhook(payload []byte, signature string) bool {
|
||||
if c.webhookSecret == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(c.webhookSecret))
|
||||
mac.Write(payload)
|
||||
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(signature), []byte(expectedSig))
|
||||
}
|
||||
|
||||
type WebhookEvent struct {
|
||||
Meta struct {
|
||||
EventName string `json:"event_name"`
|
||||
CustomData map[string]string `json:"custom_data"`
|
||||
} `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type SubscriptionData struct {
|
||||
ID string `json:"id"`
|
||||
Attributes struct {
|
||||
CustomerID int `json:"customer_id"`
|
||||
VariantName string `json:"variant_name"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
Status string `json:"status"`
|
||||
RenewsAt string `json:"renews_at"`
|
||||
FirstSubscriptionItem struct {
|
||||
Price int `json:"price"`
|
||||
} `json:"first_subscription_item"`
|
||||
} `json:"attributes"`
|
||||
}
|
||||
|
||||
type OrderData struct {
|
||||
ID string `json:"id"`
|
||||
Attributes struct {
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
TotalUsd int `json:"total_usd"`
|
||||
} `json:"attributes"`
|
||||
}
|
||||
|
||||
func (c *LemonClient) ParseWebhookEvent(payload []byte) (*WebhookEvent, error) {
|
||||
var event WebhookEvent
|
||||
if err := json.Unmarshal(payload, &event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func (e *WebhookEvent) GetSubscriptionData() (*SubscriptionData, error) {
|
||||
var data SubscriptionData
|
||||
if err := json.Unmarshal(e.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func (e *WebhookEvent) GetOrderData() (*OrderData, error) {
|
||||
var data OrderData
|
||||
if err := json.Unmarshal(e.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
126
internal/billing/payout.go
Normal file
126
internal/billing/payout.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PayoutWorker struct {
|
||||
store *Store
|
||||
wise *WiseClient
|
||||
thresholdCents int
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func NewPayoutWorker(store *Store, wiseClient *WiseClient) *PayoutWorker {
|
||||
threshold := 1000
|
||||
if v := os.Getenv("PAYOUT_THRESHOLD_CENTS"); v != "" {
|
||||
if t, err := strconv.Atoi(v); err == nil {
|
||||
threshold = t
|
||||
}
|
||||
}
|
||||
|
||||
return &PayoutWorker{
|
||||
store: store,
|
||||
wise: wiseClient,
|
||||
thresholdCents: threshold,
|
||||
interval: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PayoutWorker) Start(ctx context.Context) {
|
||||
if !w.wise.IsConfigured() {
|
||||
slog.Warn("wise not configured, payouts disabled")
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
slog.Info("payout worker started", "threshold_cents", w.thresholdCents, "interval", w.interval)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("payout worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.processPayouts(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PayoutWorker) processPayouts(ctx context.Context) {
|
||||
balances, err := w.store.GetTenantsReadyForPayout(ctx, w.thresholdCents)
|
||||
if err != nil {
|
||||
slog.Error("failed to get tenants ready for payout", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, balance := range balances {
|
||||
if err := w.processPayout(ctx, balance.TenantID, balance.AvailableCents); err != nil {
|
||||
slog.Error("failed to process payout",
|
||||
"tenant_id", balance.TenantID,
|
||||
"amount_cents", balance.AvailableCents,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PayoutWorker) processPayout(ctx context.Context, tenantID string, amountCents int) error {
|
||||
settings, err := w.store.GetPayoutSettings(ctx, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get payout settings: %w", err)
|
||||
}
|
||||
if settings == nil || settings.WiseRecipientID == "" {
|
||||
return fmt.Errorf("payout not configured for tenant")
|
||||
}
|
||||
|
||||
recipientID, err := strconv.ParseInt(settings.WiseRecipientID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid recipient ID: %w", err)
|
||||
}
|
||||
|
||||
quote, err := w.wise.CreateQuote(ctx, "USD", settings.Currency, amountCents)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create quote: %w", err)
|
||||
}
|
||||
|
||||
reference := fmt.Sprintf("WK-%s-%d", tenantID[:8], time.Now().Unix())
|
||||
|
||||
transfer, err := w.wise.CreateTransfer(ctx, quote.ID, recipientID, reference)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create transfer: %w", err)
|
||||
}
|
||||
|
||||
payoutID, err := w.store.CreatePayout(ctx, tenantID, amountCents, settings.Currency, fmt.Sprintf("%d", transfer.ID), quote.ID, "pending")
|
||||
if err != nil {
|
||||
return fmt.Errorf("record payout: %w", err)
|
||||
}
|
||||
|
||||
if err := w.wise.FundTransfer(ctx, transfer.ID); err != nil {
|
||||
w.store.UpdatePayoutStatus(ctx, payoutID, "failed", nil, err.Error())
|
||||
return fmt.Errorf("fund transfer: %w", err)
|
||||
}
|
||||
|
||||
if err := w.store.DeductBalance(ctx, tenantID, amountCents); err != nil {
|
||||
return fmt.Errorf("deduct balance: %w", err)
|
||||
}
|
||||
|
||||
w.store.UpdatePayoutStatus(ctx, payoutID, "processing", nil, "")
|
||||
|
||||
slog.Info("payout initiated",
|
||||
"tenant_id", tenantID,
|
||||
"amount_cents", amountCents,
|
||||
"transfer_id", transfer.ID,
|
||||
"payout_id", payoutID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
333
internal/billing/store.go
Normal file
333
internal/billing/store.go
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
type Tier struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Name string
|
||||
PriceCents int
|
||||
Description string
|
||||
LemonVariantID string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ID string
|
||||
TenantID string
|
||||
UserID string
|
||||
TierID string
|
||||
TierName string
|
||||
Status string
|
||||
LemonSubscriptionID string
|
||||
LemonCustomerID string
|
||||
AmountCents int
|
||||
CurrentPeriodStart time.Time
|
||||
CurrentPeriodEnd time.Time
|
||||
CancelledAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Donation struct {
|
||||
ID string
|
||||
TenantID string
|
||||
UserID string
|
||||
DonorEmail string
|
||||
DonorName string
|
||||
AmountCents int
|
||||
LemonOrderID string
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Balance struct {
|
||||
TenantID string
|
||||
AvailableCents int
|
||||
LifetimeEarningsCents int
|
||||
LifetimePaidCents int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PayoutSettings struct {
|
||||
TenantID string
|
||||
WiseRecipientID string
|
||||
AccountHolderName string
|
||||
Currency string
|
||||
PayoutEmail string
|
||||
}
|
||||
|
||||
func (s *Store) CreateTier(ctx context.Context, tier *Tier) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO membership_tiers (tenant_id, name, price_cents, description, lemon_variant_id, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, tier.TenantID, tier.Name, tier.PriceCents, tier.Description, tier.LemonVariantID, tier.Active)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetTiersByTenant(ctx context.Context, tenantID string) ([]Tier, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, tenant_id, name, price_cents, description, lemon_variant_id, active, created_at
|
||||
FROM membership_tiers
|
||||
WHERE tenant_id = $1 AND active = TRUE
|
||||
ORDER BY price_cents ASC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tiers []Tier
|
||||
for rows.Next() {
|
||||
var t Tier
|
||||
if err := rows.Scan(&t.ID, &t.TenantID, &t.Name, &t.PriceCents, &t.Description, &t.LemonVariantID, &t.Active, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tiers = append(tiers, t)
|
||||
}
|
||||
return tiers, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateSubscription(ctx context.Context, sub *Subscription) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO subscriptions (tenant_id, user_id, tier_id, tier_name, status, lemon_subscription_id, lemon_customer_id, amount_cents, current_period_start, current_period_end)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`, sub.TenantID, sub.UserID, sub.TierID, sub.TierName, sub.Status, sub.LemonSubscriptionID, sub.LemonCustomerID, sub.AmountCents, sub.CurrentPeriodStart, sub.CurrentPeriodEnd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetSubscriptionByLemonID(ctx context.Context, lemonSubID string) (*Subscription, error) {
|
||||
var sub Subscription
|
||||
var userID, tierID sql.NullString
|
||||
var cancelledAt sql.NullTime
|
||||
var periodStart, periodEnd sql.NullTime
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, user_id, tier_id, tier_name, status, lemon_subscription_id, lemon_customer_id,
|
||||
amount_cents, current_period_start, current_period_end, cancelled_at, created_at
|
||||
FROM subscriptions WHERE lemon_subscription_id = $1
|
||||
`, lemonSubID).Scan(&sub.ID, &sub.TenantID, &userID, &tierID, &sub.TierName, &sub.Status,
|
||||
&sub.LemonSubscriptionID, &sub.LemonCustomerID, &sub.AmountCents, &periodStart, &periodEnd,
|
||||
&cancelledAt, &sub.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sub.UserID = userID.String
|
||||
sub.TierID = tierID.String
|
||||
if periodStart.Valid {
|
||||
sub.CurrentPeriodStart = periodStart.Time
|
||||
}
|
||||
if periodEnd.Valid {
|
||||
sub.CurrentPeriodEnd = periodEnd.Time
|
||||
}
|
||||
if cancelledAt.Valid {
|
||||
sub.CancelledAt = &cancelledAt.Time
|
||||
}
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSubscriptionStatus(ctx context.Context, lemonSubID, status string, renewsAt *time.Time) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE subscriptions
|
||||
SET status = $1, current_period_end = $2, updated_at = NOW()
|
||||
WHERE lemon_subscription_id = $3
|
||||
`, status, renewsAt, lemonSubID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CancelSubscription(ctx context.Context, lemonSubID string) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE subscriptions
|
||||
SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW()
|
||||
WHERE lemon_subscription_id = $1
|
||||
`, lemonSubID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateDonation(ctx context.Context, donation *Donation) error {
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO donations (tenant_id, user_id, donor_email, donor_name, amount_cents, lemon_order_id, message)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`, donation.TenantID, donation.UserID, donation.DonorEmail, donation.DonorName, donation.AmountCents, donation.LemonOrderID, donation.Message).Scan(&donation.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AddEarnings(ctx context.Context, tenantID, sourceType, sourceID, description string, grossCents, platformFeeCents, processorFeeCents, netCents int) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO earnings (tenant_id, source_type, source_id, description, gross_cents, platform_fee_cents, processor_fee_cents, net_cents)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, tenantID, sourceType, sourceID, description, grossCents, platformFeeCents, processorFeeCents, netCents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO balances (tenant_id, available_cents, lifetime_earnings_cents, updated_at)
|
||||
VALUES ($1, $2, $2, NOW())
|
||||
ON CONFLICT (tenant_id) DO UPDATE
|
||||
SET available_cents = balances.available_cents + $2,
|
||||
lifetime_earnings_cents = balances.lifetime_earnings_cents + $2,
|
||||
updated_at = NOW()
|
||||
`, tenantID, netCents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) GetBalance(ctx context.Context, tenantID string) (*Balance, error) {
|
||||
var b Balance
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT tenant_id, available_cents, lifetime_earnings_cents, lifetime_paid_cents, updated_at
|
||||
FROM balances WHERE tenant_id = $1
|
||||
`, tenantID).Scan(&b.TenantID, &b.AvailableCents, &b.LifetimeEarningsCents, &b.LifetimePaidCents, &b.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return &Balance{TenantID: tenantID}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTenantsReadyForPayout(ctx context.Context, thresholdCents int) ([]Balance, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT b.tenant_id, b.available_cents, b.lifetime_earnings_cents, b.lifetime_paid_cents, b.updated_at
|
||||
FROM balances b
|
||||
JOIN payout_settings ps ON b.tenant_id = ps.tenant_id
|
||||
WHERE b.available_cents >= $1 AND ps.wise_recipient_id IS NOT NULL
|
||||
`, thresholdCents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var balances []Balance
|
||||
for rows.Next() {
|
||||
var b Balance
|
||||
if err := rows.Scan(&b.TenantID, &b.AvailableCents, &b.LifetimeEarningsCents, &b.LifetimePaidCents, &b.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
balances = append(balances, b)
|
||||
}
|
||||
return balances, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeductBalance(ctx context.Context, tenantID string, amountCents int) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE balances
|
||||
SET available_cents = available_cents - $1,
|
||||
lifetime_paid_cents = lifetime_paid_cents + $1,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = $2 AND available_cents >= $1
|
||||
`, amountCents, tenantID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreatePayout(ctx context.Context, tenantID string, amountCents int, currency, wiseTransferID, wiseQuoteID, status string) (string, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO payouts (tenant_id, amount_cents, currency, wise_transfer_id, wise_quote_id, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`, tenantID, amountCents, currency, wiseTransferID, wiseQuoteID, status).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePayoutStatus(ctx context.Context, payoutID, status string, completedAt *time.Time, failureReason string) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE payouts
|
||||
SET status = $1, completed_at = $2, failure_reason = $3
|
||||
WHERE id = $4
|
||||
`, status, completedAt, failureReason, payoutID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetPayoutSettings(ctx context.Context, tenantID string) (*PayoutSettings, error) {
|
||||
var ps PayoutSettings
|
||||
var recipientID, holderName, currency, email sql.NullString
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT tenant_id, wise_recipient_id, account_holder_name, currency, payout_email
|
||||
FROM payout_settings WHERE tenant_id = $1
|
||||
`, tenantID).Scan(&ps.TenantID, &recipientID, &holderName, ¤cy, &email)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ps.WiseRecipientID = recipientID.String
|
||||
ps.AccountHolderName = holderName.String
|
||||
ps.Currency = currency.String
|
||||
if ps.Currency == "" {
|
||||
ps.Currency = "USD"
|
||||
}
|
||||
ps.PayoutEmail = email.String
|
||||
|
||||
return &ps, nil
|
||||
}
|
||||
|
||||
func (s *Store) SavePayoutSettings(ctx context.Context, ps *PayoutSettings) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO payout_settings (tenant_id, wise_recipient_id, account_holder_name, currency, payout_email)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (tenant_id) DO UPDATE
|
||||
SET wise_recipient_id = $2,
|
||||
account_holder_name = $3,
|
||||
currency = $4,
|
||||
payout_email = $5,
|
||||
updated_at = NOW()
|
||||
`, ps.TenantID, ps.WiseRecipientID, ps.AccountHolderName, ps.Currency, ps.PayoutEmail)
|
||||
return err
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, email, COALESCE(name, '') FROM users WHERE id = $1`,
|
||||
id).Scan(&u.ID, &u.Email, &u.Name)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
303
internal/billing/webhook.go
Normal file
303
internal/billing/webhook.go
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MemberSyncer interface {
|
||||
SyncMember(ctx context.Context, tenantID, userID, email, name, tier, status string, expiresAt *time.Time) error
|
||||
}
|
||||
|
||||
type WebhookHandler struct {
|
||||
store *Store
|
||||
lemon *LemonClient
|
||||
memberSyncer MemberSyncer
|
||||
}
|
||||
|
||||
func NewWebhookHandler(store *Store, lemonClient *LemonClient, syncer MemberSyncer) *WebhookHandler {
|
||||
return &WebhookHandler{
|
||||
store: store,
|
||||
lemon: lemonClient,
|
||||
memberSyncer: syncer,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
slog.Error("failed to read webhook body", "error", err)
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
signature := r.Header.Get("X-Signature")
|
||||
if !h.lemon.VerifyWebhook(body, signature) {
|
||||
slog.Warn("invalid webhook signature")
|
||||
http.Error(w, "Invalid signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := h.lemon.ParseWebhookEvent(body)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse webhook event", "error", err)
|
||||
http.Error(w, "Failed to parse event", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
switch event.Meta.EventName {
|
||||
case "subscription_created":
|
||||
err = h.handleSubscriptionCreated(ctx, event)
|
||||
case "subscription_updated":
|
||||
err = h.handleSubscriptionUpdated(ctx, event)
|
||||
case "subscription_cancelled":
|
||||
err = h.handleSubscriptionCancelled(ctx, event)
|
||||
case "subscription_payment_success":
|
||||
err = h.handleSubscriptionPayment(ctx, event)
|
||||
case "order_created":
|
||||
err = h.handleOrderCreated(ctx, event)
|
||||
default:
|
||||
slog.Debug("unhandled webhook event", "event", event.Meta.EventName)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("webhook handler error", "event", event.Meta.EventName, "error", err)
|
||||
http.Error(w, "Handler error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handleSubscriptionCreated(ctx context.Context, event *WebhookEvent) error {
|
||||
data, err := event.GetSubscriptionData()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse subscription data: %w", err)
|
||||
}
|
||||
|
||||
tenantID := event.Meta.CustomData["tenant_id"]
|
||||
userID := event.Meta.CustomData["user_id"]
|
||||
tierID := event.Meta.CustomData["tier_id"]
|
||||
|
||||
if tenantID == "" {
|
||||
return fmt.Errorf("missing tenant_id in custom data")
|
||||
}
|
||||
|
||||
sub := &Subscription{
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
TierID: tierID,
|
||||
TierName: data.Attributes.VariantName,
|
||||
Status: normalizeStatus(data.Attributes.Status),
|
||||
LemonSubscriptionID: data.ID,
|
||||
LemonCustomerID: strconv.Itoa(data.Attributes.CustomerID),
|
||||
AmountCents: data.Attributes.FirstSubscriptionItem.Price,
|
||||
}
|
||||
|
||||
if data.Attributes.RenewsAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, data.Attributes.RenewsAt); err == nil {
|
||||
sub.CurrentPeriodEnd = t
|
||||
}
|
||||
}
|
||||
sub.CurrentPeriodStart = time.Now()
|
||||
|
||||
if err := h.store.CreateSubscription(ctx, sub); err != nil {
|
||||
return fmt.Errorf("create subscription: %w", err)
|
||||
}
|
||||
|
||||
if h.memberSyncer != nil && userID != "" {
|
||||
user, _ := h.store.GetUserByID(ctx, userID)
|
||||
email, name := "", ""
|
||||
if user != nil {
|
||||
email, name = user.Email, user.Name
|
||||
}
|
||||
if err := h.memberSyncer.SyncMember(ctx, tenantID, userID, email, name, sub.TierName, "active", &sub.CurrentPeriodEnd); err != nil {
|
||||
slog.Error("sync member failed", "tenant_id", tenantID, "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("subscription created",
|
||||
"tenant_id", tenantID,
|
||||
"lemon_id", data.ID,
|
||||
"tier", data.Attributes.VariantName,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handleSubscriptionUpdated(ctx context.Context, event *WebhookEvent) error {
|
||||
data, err := event.GetSubscriptionData()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse subscription data: %w", err)
|
||||
}
|
||||
|
||||
var renewsAt *time.Time
|
||||
if data.Attributes.RenewsAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, data.Attributes.RenewsAt); err == nil {
|
||||
renewsAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
status := normalizeStatus(data.Attributes.Status)
|
||||
|
||||
if err := h.store.UpdateSubscriptionStatus(ctx, data.ID, status, renewsAt); err != nil {
|
||||
return fmt.Errorf("update subscription: %w", err)
|
||||
}
|
||||
|
||||
if h.memberSyncer != nil {
|
||||
sub, _ := h.store.GetSubscriptionByLemonID(ctx, data.ID)
|
||||
if sub != nil && sub.UserID != "" {
|
||||
user, _ := h.store.GetUserByID(ctx, sub.UserID)
|
||||
email, name := "", ""
|
||||
if user != nil {
|
||||
email, name = user.Email, user.Name
|
||||
}
|
||||
if err := h.memberSyncer.SyncMember(ctx, sub.TenantID, sub.UserID, email, name, sub.TierName, status, renewsAt); err != nil {
|
||||
slog.Error("sync member failed", "tenant_id", sub.TenantID, "user_id", sub.UserID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("subscription updated", "lemon_id", data.ID, "status", status)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handleSubscriptionCancelled(ctx context.Context, event *WebhookEvent) error {
|
||||
data, err := event.GetSubscriptionData()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse subscription data: %w", err)
|
||||
}
|
||||
|
||||
sub, _ := h.store.GetSubscriptionByLemonID(ctx, data.ID)
|
||||
|
||||
if err := h.store.CancelSubscription(ctx, data.ID); err != nil {
|
||||
return fmt.Errorf("cancel subscription: %w", err)
|
||||
}
|
||||
|
||||
if h.memberSyncer != nil && sub != nil && sub.UserID != "" {
|
||||
user, _ := h.store.GetUserByID(ctx, sub.UserID)
|
||||
email, name := "", ""
|
||||
if user != nil {
|
||||
email, name = user.Email, user.Name
|
||||
}
|
||||
if err := h.memberSyncer.SyncMember(ctx, sub.TenantID, sub.UserID, email, name, sub.TierName, "cancelled", nil); err != nil {
|
||||
slog.Error("sync member failed", "tenant_id", sub.TenantID, "user_id", sub.UserID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("subscription cancelled", "lemon_id", data.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handleSubscriptionPayment(ctx context.Context, event *WebhookEvent) error {
|
||||
data, err := event.GetSubscriptionData()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse subscription data: %w", err)
|
||||
}
|
||||
|
||||
sub, err := h.store.GetSubscriptionByLemonID(ctx, data.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get subscription: %w", err)
|
||||
}
|
||||
if sub == nil {
|
||||
return fmt.Errorf("subscription not found: %s", data.ID)
|
||||
}
|
||||
|
||||
grossCents := data.Attributes.FirstSubscriptionItem.Price
|
||||
platformFeeCents := grossCents * 5 / 100
|
||||
processorFeeCents := grossCents * 5 / 100
|
||||
netCents := grossCents - platformFeeCents - processorFeeCents
|
||||
|
||||
description := fmt.Sprintf("%s subscription - %s", sub.TierName, time.Now().Format("January 2006"))
|
||||
|
||||
if err := h.store.AddEarnings(ctx, sub.TenantID, "subscription_payment", sub.ID, description, grossCents, platformFeeCents, processorFeeCents, netCents); err != nil {
|
||||
return fmt.Errorf("add earnings: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("subscription payment recorded",
|
||||
"tenant_id", sub.TenantID,
|
||||
"gross_cents", grossCents,
|
||||
"net_cents", netCents,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handleOrderCreated(ctx context.Context, event *WebhookEvent) error {
|
||||
data, err := event.GetOrderData()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse order data: %w", err)
|
||||
}
|
||||
|
||||
tenantID := event.Meta.CustomData["tenant_id"]
|
||||
userID := event.Meta.CustomData["user_id"]
|
||||
message := event.Meta.CustomData["message"]
|
||||
|
||||
if tenantID == "" {
|
||||
return fmt.Errorf("missing tenant_id in custom data")
|
||||
}
|
||||
|
||||
if event.Meta.CustomData["type"] != "donation" {
|
||||
return nil
|
||||
}
|
||||
|
||||
donation := &Donation{
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
DonorEmail: data.Attributes.UserEmail,
|
||||
DonorName: data.Attributes.UserName,
|
||||
AmountCents: data.Attributes.TotalUsd,
|
||||
LemonOrderID: data.ID,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if err := h.store.CreateDonation(ctx, donation); err != nil {
|
||||
return fmt.Errorf("create donation: %w", err)
|
||||
}
|
||||
|
||||
grossCents := data.Attributes.TotalUsd
|
||||
platformFeeCents := grossCents * 5 / 100
|
||||
processorFeeCents := grossCents * 5 / 100
|
||||
netCents := grossCents - platformFeeCents - processorFeeCents
|
||||
|
||||
description := fmt.Sprintf("Donation from %s", data.Attributes.UserName)
|
||||
|
||||
if err := h.store.AddEarnings(ctx, tenantID, "donation", donation.ID, description, grossCents, platformFeeCents, processorFeeCents, netCents); err != nil {
|
||||
return fmt.Errorf("add earnings: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("donation recorded",
|
||||
"tenant_id", tenantID,
|
||||
"donor", data.Attributes.UserEmail,
|
||||
"gross_cents", grossCents,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeStatus(lemonStatus string) string {
|
||||
switch lemonStatus {
|
||||
case "on_trial", "active":
|
||||
return "active"
|
||||
case "paused", "past_due", "unpaid":
|
||||
return "past_due"
|
||||
case "cancelled", "expired":
|
||||
return "cancelled"
|
||||
default:
|
||||
return lemonStatus
|
||||
}
|
||||
}
|
||||
198
internal/billing/wise.go
Normal file
198
internal/billing/wise.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package billing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
const wiseBaseURL = "https://api.transferwise.com"
|
||||
|
||||
type WiseClient struct {
|
||||
apiKey string
|
||||
profileID string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewWiseClient() *WiseClient {
|
||||
return &WiseClient{
|
||||
apiKey: os.Getenv("WISE_API_KEY"),
|
||||
profileID: os.Getenv("WISE_PROFILE_ID"),
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WiseClient) IsConfigured() bool {
|
||||
return c.apiKey != "" && c.profileID != ""
|
||||
}
|
||||
|
||||
type Quote struct {
|
||||
ID string `json:"id"`
|
||||
SourceAmount float64 `json:"sourceAmount"`
|
||||
TargetAmount float64 `json:"targetAmount"`
|
||||
Rate float64 `json:"rate"`
|
||||
Fee float64 `json:"fee"`
|
||||
SourceCurrency string `json:"sourceCurrency"`
|
||||
TargetCurrency string `json:"targetCurrency"`
|
||||
}
|
||||
|
||||
type Transfer struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
SourceValue float64 `json:"sourceValue"`
|
||||
TargetValue float64 `json:"targetValue"`
|
||||
}
|
||||
|
||||
func (c *WiseClient) CreateQuote(ctx context.Context, sourceCurrency, targetCurrency string, sourceAmountCents int) (*Quote, error) {
|
||||
payload := map[string]any{
|
||||
"sourceCurrency": sourceCurrency,
|
||||
"targetCurrency": targetCurrency,
|
||||
"sourceAmount": float64(sourceAmountCents) / 100,
|
||||
"profile": c.profileID,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", wiseBaseURL+"/v3/quotes", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("quote creation failed (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var quote Quote
|
||||
if err := json.Unmarshal(respBody, "e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return "e, nil
|
||||
}
|
||||
|
||||
func (c *WiseClient) CreateTransfer(ctx context.Context, quoteID string, recipientID int64, reference string) (*Transfer, error) {
|
||||
payload := map[string]any{
|
||||
"targetAccount": recipientID,
|
||||
"quoteUuid": quoteID,
|
||||
"customerTransactionId": reference,
|
||||
"details": map[string]any{"reference": reference},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", wiseBaseURL+"/v1/transfers", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("transfer creation failed (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var transfer Transfer
|
||||
if err := json.Unmarshal(respBody, &transfer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &transfer, nil
|
||||
}
|
||||
|
||||
func (c *WiseClient) FundTransfer(ctx context.Context, transferID int64) error {
|
||||
url := fmt.Sprintf("%s/v3/profiles/%s/transfers/%d/payments", wiseBaseURL, c.profileID, transferID)
|
||||
|
||||
payload := map[string]any{"type": "BALANCE"}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("funding failed (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WiseClient) GetTransferStatus(ctx context.Context, transferID int64) (string, error) {
|
||||
url := fmt.Sprintf("%s/v1/transfers/%d", wiseBaseURL, transferID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("get transfer failed (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var transfer Transfer
|
||||
if err := json.Unmarshal(respBody, &transfer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return transfer.Status, nil
|
||||
}
|
||||
|
||||
func (c *WiseClient) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue