Files
treasury/pkg/provider/wise/wise.go
zeekay f198543101 feat(idempotency): extend caller-supplied IdempotencyKey to CreateAccount + CreateCounterparty
Follow-up to v1.1.9 (which threaded IdempotencyKey through CreatePayment +
CreateFXConversion). Adds:

  provider.CreateAccountRequest.IdempotencyKey   string
  types.Counterparty.IdempotencyKey              string

Per-adapter forwarding:
  CreateAccount: column, currencycloud, increase, lithic, moderntreasury,
                 stripe, unit  (7 adapters via doRequestIdem or
                 unique_request_id form field for CC)
  CreateCounterparty: column, currencycloud, moderntreasury, wise (4)

Skipped (provider doesn't support the call):
  mercury     — accounts created dashboard-only
  wise        — same for CreateAccount
  bridge      — counterparty not applicable to chain bridge
  mpesa, square — out of scope (no counterparty API)

Stubs (marqeta, plaid) unaffected — deregistered in v1.1.9.

Empty key → adapter derives via provider.DeriveIdempotencyKey
(SHA-256 over canonical request fields). Same retry → same key.
2026-05-25 09:42:12 -07:00

436 lines
12 KiB
Go

package wise
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/luxfi/treasury/pkg/provider"
"github.com/luxfi/treasury/pkg/types"
)
const (
ProdURL = "https://api.transferwise.com"
SandboxURL = "https://api.sandbox.transferwise.tech"
)
type Config struct {
BaseURL string
APIToken string
ProfileID string
}
type Provider struct {
cfg Config
client *http.Client
}
func New(cfg Config) provider.Provider {
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
}
func (p *Provider) Name() string { return "wise" }
func (p *Provider) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
return p.doRequestIdem(ctx, method, path, body, "")
}
// doRequestIdem sends the request with an optional X-idempotence-uuid.
// https://docs.wise.com/api-docs/features/idempotency
func (p *Provider) doRequestIdem(ctx context.Context, method, path string, body interface{}, idemKey string) ([]byte, error) {
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+p.cfg.APIToken)
req.Header.Set("Content-Type", "application/json")
if idemKey != "" {
req.Header.Set("X-idempotence-uuid", idemKey)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("wise API error %d: %s", resp.StatusCode, string(data))
}
return data, nil
}
func (p *Provider) CreateAccount(_ context.Context, _ string, _ *provider.CreateAccountRequest) (*types.BankAccount, error) {
return nil, fmt.Errorf("wise: accounts are created through the Wise dashboard")
}
func (p *Provider) GetAccount(ctx context.Context, accountID string) (*types.BankAccount, error) {
data, err := p.doRequest(ctx, "GET", "/v4/profiles/"+p.cfg.ProfileID+"/balances?types=STANDARD", nil)
if err != nil {
return nil, err
}
var balances []struct {
ID int64 `json:"id"`
Currency string `json:"currency"`
Amount struct {
Value float64 `json:"value"`
Currency string `json:"currency"`
} `json:"amount"`
}
if err := json.Unmarshal(data, &balances); err != nil {
return nil, err
}
for _, b := range balances {
if fmt.Sprintf("%d", b.ID) == accountID {
return &types.BankAccount{
ID: fmt.Sprintf("%d", b.ID),
Provider: "wise",
ProviderID: fmt.Sprintf("%d", b.ID),
AccountName: b.Currency + " Balance",
Currency: b.Currency,
AccountType: "multi_currency",
Status: "active",
Balance: &types.Balance{
Available: fmt.Sprintf("%.2f", b.Amount.Value),
Current: fmt.Sprintf("%.2f", b.Amount.Value),
Currency: b.Currency,
UpdatedAt: time.Now(),
},
}, nil
}
}
return nil, fmt.Errorf("balance %s not found", accountID)
}
func (p *Provider) ListAccounts(ctx context.Context, orgID string) ([]*types.BankAccount, error) {
data, err := p.doRequest(ctx, "GET", "/v4/profiles/"+p.cfg.ProfileID+"/balances?types=STANDARD", nil)
if err != nil {
return nil, err
}
var balances []struct {
ID int64 `json:"id"`
Currency string `json:"currency"`
Amount struct {
Value float64 `json:"value"`
Currency string `json:"currency"`
} `json:"amount"`
}
if err := json.Unmarshal(data, &balances); err != nil {
return nil, err
}
accounts := make([]*types.BankAccount, 0, len(balances))
for _, b := range balances {
accounts = append(accounts, &types.BankAccount{
ID: fmt.Sprintf("%d", b.ID),
Provider: "wise",
ProviderID: fmt.Sprintf("%d", b.ID),
OrgID: orgID,
AccountName: b.Currency + " Balance",
Currency: b.Currency,
AccountType: "multi_currency",
Status: "active",
Balance: &types.Balance{
Available: fmt.Sprintf("%.2f", b.Amount.Value),
Current: fmt.Sprintf("%.2f", b.Amount.Value),
Currency: b.Currency,
},
})
}
return accounts, nil
}
func (p *Provider) GetBalance(ctx context.Context, accountID string) (*types.Balance, error) {
acct, err := p.GetAccount(ctx, accountID)
if err != nil {
return nil, err
}
return acct.Balance, nil
}
func (p *Provider) CreatePayment(ctx context.Context, req *types.CreatePaymentRequest) (*types.Payment, error) {
// Step 1: Create quote
quoteBody := map[string]interface{}{
"sourceCurrency": req.Currency,
"targetCurrency": req.Currency,
"sourceAmount": req.Amount,
"profile": p.cfg.ProfileID,
}
quoteData, err := p.doRequest(ctx, "POST", "/v3/profiles/"+p.cfg.ProfileID+"/quotes", quoteBody)
if err != nil {
return nil, fmt.Errorf("quote creation failed: %w", err)
}
var quote struct {
ID string `json:"id"`
}
if err := json.Unmarshal(quoteData, &quote); err != nil {
return nil, err
}
// Step 2: Create transfer (the money-moving call — idempotent).
transferBody := map[string]interface{}{
"targetAccount": req.DestAccountID,
"quoteUuid": quote.ID,
"details": map[string]string{
"reference": req.Reference,
},
}
const transferEndpoint = "/v1/transfers"
idem := req.IdempotencyKey
if idem == "" {
canonical, _ := json.Marshal(transferBody)
idem = provider.DeriveIdempotencyKey(req.OrgID, transferEndpoint, canonical)
}
data, err := p.doRequestIdem(ctx, "POST", transferEndpoint, transferBody, idem)
if err != nil {
return nil, err
}
var resp struct {
ID int64 `json:"id"`
Status string `json:"status"`
SourceCurrency string `json:"sourceCurrency"`
SourceValue float64 `json:"sourceValue"`
TargetCurrency string `json:"targetCurrency"`
TargetValue float64 `json:"targetValue"`
Rate float64 `json:"rate"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
return &types.Payment{
ID: fmt.Sprintf("%d", resp.ID),
Provider: "wise",
ProviderID: fmt.Sprintf("%d", resp.ID),
Type: types.PaymentSWIFT,
Direction: req.Direction,
Amount: fmt.Sprintf("%.2f", resp.SourceValue),
Currency: resp.SourceCurrency,
Status: types.PaymentStatus(resp.Status),
ExchangeRate: fmt.Sprintf("%.6f", resp.Rate),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
func (p *Provider) GetPayment(ctx context.Context, paymentID string) (*types.Payment, error) {
data, err := p.doRequest(ctx, "GET", "/v1/transfers/"+paymentID, nil)
if err != nil {
return nil, err
}
var resp struct {
ID int64 `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
return &types.Payment{
ID: fmt.Sprintf("%d", resp.ID),
Provider: "wise",
ProviderID: fmt.Sprintf("%d", resp.ID),
Status: types.PaymentStatus(resp.Status),
}, nil
}
func (p *Provider) ListPayments(ctx context.Context, accountID string) ([]*types.Payment, error) {
data, err := p.doRequest(ctx, "GET", "/v1/transfers?profile="+p.cfg.ProfileID, nil)
if err != nil {
return nil, err
}
var items []struct {
ID int64 `json:"id"`
Status string `json:"status"`
Source string `json:"sourceCurrency"`
Amount float64 `json:"sourceValue"`
}
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
payments := make([]*types.Payment, 0, len(items))
for _, item := range items {
payments = append(payments, &types.Payment{
ID: fmt.Sprintf("%d", item.ID),
Provider: "wise",
ProviderID: fmt.Sprintf("%d", item.ID),
Amount: fmt.Sprintf("%.2f", item.Amount),
Currency: item.Source,
Status: types.PaymentStatus(item.Status),
})
}
return payments, nil
}
func (p *Provider) CancelPayment(ctx context.Context, paymentID string) error {
_, err := p.doRequest(ctx, "PUT", "/v1/transfers/"+paymentID+"/cancel", nil)
return err
}
func (p *Provider) GetFXQuote(ctx context.Context, sellCcy, buyCcy, amount, fixedSide string) (*types.FXQuote, error) {
body := map[string]interface{}{
"sourceCurrency": sellCcy,
"targetCurrency": buyCcy,
"sourceAmount": amount,
"profile": p.cfg.ProfileID,
}
data, err := p.doRequest(ctx, "POST", "/v3/profiles/"+p.cfg.ProfileID+"/quotes", body)
if err != nil {
return nil, err
}
var resp struct {
ID string `json:"id"`
Rate float64 `json:"rate"`
SourceAmount float64 `json:"sourceAmount"`
TargetAmount float64 `json:"targetAmount"`
ExpirationTime string `json:"expirationTime"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
exp, _ := time.Parse(time.RFC3339, resp.ExpirationTime)
return &types.FXQuote{
ID: resp.ID,
Provider: "wise",
SellCurrency: sellCcy,
BuyCurrency: buyCcy,
SellAmount: fmt.Sprintf("%.2f", resp.SourceAmount),
BuyAmount: fmt.Sprintf("%.2f", resp.TargetAmount),
Rate: fmt.Sprintf("%.6f", resp.Rate),
FixedSide: fixedSide,
ExpiresAt: exp,
CreatedAt: time.Now(),
}, nil
}
func (p *Provider) CreateFXConversion(ctx context.Context, req *types.CreateFXConversionRequest) (*types.FXConversion, error) {
quote, err := p.GetFXQuote(ctx, req.SellCurrency, req.BuyCurrency, req.Amount, req.FixedSide)
if err != nil {
return nil, err
}
return &types.FXConversion{
Provider: "wise",
QuoteID: quote.ID,
SellCurrency: req.SellCurrency,
BuyCurrency: req.BuyCurrency,
SellAmount: quote.SellAmount,
BuyAmount: quote.BuyAmount,
Rate: quote.Rate,
Status: "quoted",
CreatedAt: time.Now(),
}, nil
}
func (p *Provider) ListFXConversions(_ context.Context) ([]*types.FXConversion, error) {
return nil, provider.ErrNotSupported
}
func (p *Provider) CreateCounterparty(ctx context.Context, cp *types.Counterparty) (*types.Counterparty, error) {
body := map[string]interface{}{
"profile": p.cfg.ProfileID,
"accountHolderName": cp.Name,
"currency": "USD",
"type": "sort_code",
}
if cp.IBAN != "" {
body["type"] = "iban"
body["details"] = map[string]string{"IBAN": cp.IBAN}
} else if cp.AccountNumber != "" {
body["type"] = "aba"
body["details"] = map[string]string{
"abartn": cp.RoutingNumber,
"accountNumber": cp.AccountNumber,
"accountType": "CHECKING",
}
}
idem := cp.IdempotencyKey
if idem == "" {
canonical, _ := json.Marshal(body)
idem = provider.DeriveIdempotencyKey("", "/v1/accounts", canonical)
}
data, err := p.doRequestIdem(ctx, "POST", "/v1/accounts", body, idem)
if err != nil {
return nil, err
}
var resp struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
cp.ID = fmt.Sprintf("%d", resp.ID)
return cp, nil
}
func (p *Provider) ListCounterparties(ctx context.Context) ([]*types.Counterparty, error) {
data, err := p.doRequest(ctx, "GET", "/v1/accounts?profile="+p.cfg.ProfileID, nil)
if err != nil {
return nil, err
}
var items []struct {
ID int64 `json:"id"`
AccountHolderName string `json:"accountHolderName"`
Currency string `json:"currency"`
}
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
cps := make([]*types.Counterparty, 0, len(items))
for _, item := range items {
cps = append(cps, &types.Counterparty{
ID: fmt.Sprintf("%d", item.ID),
Name: item.AccountHolderName,
})
}
return cps, nil
}
func (p *Provider) Capabilities() *types.ProviderCapability {
return &types.ProviderCapability{
Name: "wise",
PaymentTypes: []string{"swift", "sepa", "wire"},
Currencies: []string{"USD", "EUR", "GBP", "JPY", "CHF", "AUD", "CAD", "NZD", "HKD", "SGD", "SEK", "NOK", "DKK", "PLN", "CZK", "HUF", "ZAR", "MXN", "BRL", "INR", "CNY", "THB", "KRW", "TWD", "ILS", "TRY", "AED", "SAR", "PHP", "IDR", "MYR"},
Features: []string{"fx", "multi_currency", "international", "60+_currencies", "cheap_transfers"},
Countries: []string{"US", "GB", "EU", "AU", "CA", "JP", "SG", "HK", "BR", "IN"},
SettlementSpeed: "same_day",
Status: "active",
}
}