diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 796581a..b13f695 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: check-latest: true - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: version: v2.11.4 diff --git a/.golangci.yml b/.golangci.yml index f64def6..9718fb9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -47,6 +47,16 @@ linters: - errcheck - gosec - revive + # Example functions in external test packages need an explicit + # `var svc *pkg.Service = client.Field` declaration so `go vet` + # can resolve the Service identifier and attach + # ExampleService_Method to the right method on pkg.go.dev. + # staticcheck flags the annotation as redundant (ST1023); the + # redundancy is intentional here. + - path: example_test\.go + linters: + - staticcheck + text: "(ST1023|QF1011):" formatters: enable: diff --git a/services/accountdetails/service.go b/services/accountdetails/service.go index c3998e4..ed385ea 100644 --- a/services/accountdetails/service.go +++ b/services/accountdetails/service.go @@ -10,6 +10,9 @@ import ( const getPath = "/api/public/v1/account-details" +// Well-known country and currency codes accepted by the API. These are +// plain string constants because the endpoint may add new values over time; +// they exist purely as a documented starting point. const ( CountryUS = "US" CountryEU = "EU" @@ -18,19 +21,25 @@ const ( CurrencyEUR = "EUR" ) +// Service issues requests against the Wallbit account-details endpoint. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// GetRequest parameterises a call to [Service.Get]. Both fields are +// optional; when blank, the server returns the account details configured +// as default for the authenticated user. type GetRequest struct { Country string Currency string } +// AccountAddress is the postal address attached to a set of [AccountDetails]. type AccountAddress struct { StreetLine1 string `json:"street_line_1"` StreetLine2 *string `json:"street_line_2,omitempty"` @@ -40,6 +49,9 @@ type AccountAddress struct { Country string `json:"country"` } +// AccountDetails describes the bank account configured for the caller to +// deposit or withdraw fiat. Optional fields (IBAN, BIC, routing number, …) +// are nil when the destination bank network does not use them. type AccountDetails struct { BankName string `json:"bank_name"` Currency string `json:"currency"` @@ -55,10 +67,13 @@ type AccountDetails struct { Address *AccountAddress `json:"address,omitempty"` } +// GetResponse is the top-level envelope for [Service.Get]. type GetResponse struct { Data AccountDetails `json:"data"` } +// Get returns the bank account details the user should use to fund or +// withdraw from their Wallbit account. A nil req uses server defaults. func (s *Service) Get(ctx context.Context, req *GetRequest) (*transport.Response[GetResponse], error) { path := getPath if req != nil { diff --git a/services/apikey/service.go b/services/apikey/service.go index 08a88db..e9dd146 100644 --- a/services/apikey/service.go +++ b/services/apikey/service.go @@ -9,18 +9,24 @@ import ( const revokePath = "/api/public/v1/api-key" +// Service issues requests against the Wallbit API-key endpoint. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// RevokeResponse is the top-level envelope for [Service.Revoke]. type RevokeResponse struct { Message string `json:"message"` } +// Revoke invalidates the API key carried by the client. Subsequent calls +// with the same key will fail authentication; issue a new key out-of-band +// to keep using the SDK. func (s *Service) Revoke(ctx context.Context) (*transport.Response[RevokeResponse], error) { return transport.SendJSON(ctx, s.sender, http.MethodDelete, revokePath, nil, &RevokeResponse{}) } diff --git a/services/assets/service.go b/services/assets/service.go index e3f8640..a02b08f 100644 --- a/services/assets/service.go +++ b/services/assets/service.go @@ -18,14 +18,19 @@ const listPath = "/api/public/v1/assets" // ErrEmptySymbol is returned by [Service.Get] when symbol is empty or whitespace-only. var ErrEmptySymbol = errors.New("assets: symbol is required") +// Service issues requests against the Wallbit assets endpoints. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// ListRequest parameterises a call to [Service.List]. Every field is +// optional; unset values are omitted from the query string. Use +// [github.com/jeremyjsx/wallbit-go/wallbit.Ptr] to set Page and Limit. type ListRequest struct { Category string Search string @@ -33,6 +38,9 @@ type ListRequest struct { Limit *int } +// Dividend captures the most recent dividend event announced for an asset, +// when available. All fields are nil when the instrument does not pay +// dividends or when the API has not published the event yet. type Dividend struct { Amount *float64 `json:"amount"` Yield *float64 `json:"yield"` @@ -40,6 +48,8 @@ type Dividend struct { PaymentDate *string `json:"payment_date"` } +// Asset is a tradable instrument row. Pointer fields carry the server's +// own omitempty: they are nil whenever the API would omit them. type Asset struct { Symbol string `json:"symbol"` Name string `json:"name"` @@ -57,6 +67,9 @@ type Asset struct { Dividend *Dividend `json:"dividend"` } +// ListResponse is the top-level envelope for [Service.List]. CurrentPage +// equals Pages on the last page; Count is the total number of rows across +// every page. type ListResponse struct { Data []Asset `json:"data"` Pages int `json:"pages"` @@ -64,10 +77,13 @@ type ListResponse struct { Count int `json:"count"` } +// GetResponse is the top-level envelope for [Service.Get]. type GetResponse struct { Data Asset `json:"data"` } +// Get fetches a single asset by its ticker symbol. An empty symbol returns +// [ErrEmptySymbol]. func (s *Service) Get(ctx context.Context, symbol string) (*transport.Response[GetResponse], error) { if strings.TrimSpace(symbol) == "" { return nil, ErrEmptySymbol @@ -76,6 +92,9 @@ func (s *Service) Get(ctx context.Context, symbol string) (*transport.Response[G return transport.SendJSON(ctx, s.sender, http.MethodGet, path, nil, &GetResponse{}) } +// List fetches a single page of assets matching req's filters. A nil req +// returns the first page with server defaults. For lazy iteration over +// every page, use [Service.ListAll]. func (s *Service) List(ctx context.Context, req *ListRequest) (*transport.Response[ListResponse], error) { path := listPath if req != nil { diff --git a/services/balance/service.go b/services/balance/service.go index d039cc2..95db976 100644 --- a/services/balance/service.go +++ b/services/balance/service.go @@ -12,36 +12,46 @@ const ( stocksPath = "/api/public/v1/balance/stocks" ) +// Service issues requests against the Wallbit balance endpoints. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// CheckingBalance is the balance of a single fiat currency held in the +// user's checking account. type CheckingBalance struct { Currency string `json:"currency"` Balance float64 `json:"balance"` } +// CheckingBalanceResponse is the top-level envelope for [Service.GetChecking]. type CheckingBalanceResponse struct { Data []CheckingBalance `json:"data"` } +// StockPosition is a single equity holding in the user's stocks account. type StockPosition struct { Symbol string `json:"symbol"` Shares float64 `json:"shares"` } +// StocksBalanceResponse is the top-level envelope for [Service.GetStocks]. type StocksBalanceResponse struct { Data []StockPosition `json:"data"` } +// GetChecking returns every fiat balance (checking account) held by the +// authenticated user. func (s *Service) GetChecking(ctx context.Context) (*transport.Response[CheckingBalanceResponse], error) { return transport.SendJSON(ctx, s.sender, http.MethodGet, checkingPath, nil, &CheckingBalanceResponse{}) } +// GetStocks returns every equity position held by the authenticated user. func (s *Service) GetStocks(ctx context.Context) (*transport.Response[StocksBalanceResponse], error) { return transport.SendJSON(ctx, s.sender, http.MethodGet, stocksPath, nil, &StocksBalanceResponse{}) } diff --git a/services/cards/service.go b/services/cards/service.go index 048b858..48f4a3f 100644 --- a/services/cards/service.go +++ b/services/cards/service.go @@ -13,6 +13,7 @@ import ( const listPath = "/api/public/v1/cards" const updateStatusPathFormat = "/api/public/v1/cards/%s/status" +// Card status values accepted by the update-status endpoint. const ( StatusActive = "ACTIVE" StatusSuspended = "SUSPENDED" @@ -21,10 +22,12 @@ const ( // ErrEmptyCardUUID is returned by [Service.Block] and [Service.Unblock] when cardUUID is empty or whitespace-only. var ErrEmptyCardUUID = errors.New("cards: card uuid is required") +// Service issues requests against the Wallbit cards endpoints. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } @@ -33,11 +36,14 @@ type updateStatusRequest struct { Status string `json:"status"` } +// CardStatus is the minimal card row returned by the update-status endpoint. type CardStatus struct { UUID string `json:"uuid"` Status string `json:"status"` } +// Card describes a card row as returned by [Service.List]. Expiration is +// nil when the API does not expose it. type Card struct { UUID string `json:"uuid"` Status string `json:"status"` @@ -47,22 +53,31 @@ type Card struct { Expiration *string `json:"expiration"` } +// ListResponse is the top-level envelope for [Service.List]. type ListResponse struct { Data []Card `json:"data"` } +// UpdateStatusResponse is the top-level envelope for [Service.Block] and +// [Service.Unblock]. type UpdateStatusResponse struct { Data CardStatus `json:"data"` } +// List returns every card visible to the authenticated user, regardless of +// status. func (s *Service) List(ctx context.Context) (*transport.Response[ListResponse], error) { return transport.SendJSON(ctx, s.sender, http.MethodGet, listPath, nil, &ListResponse{}) } +// Block suspends the card identified by cardUUID. It returns +// [ErrEmptyCardUUID] if cardUUID is empty or whitespace-only. func (s *Service) Block(ctx context.Context, cardUUID string) (*transport.Response[UpdateStatusResponse], error) { return s.updateStatus(ctx, cardUUID, StatusSuspended) } +// Unblock re-activates the card identified by cardUUID. It returns +// [ErrEmptyCardUUID] if cardUUID is empty or whitespace-only. func (s *Service) Unblock(ctx context.Context, cardUUID string) (*transport.Response[UpdateStatusResponse], error) { return s.updateStatus(ctx, cardUUID, StatusActive) } diff --git a/services/fees/service.go b/services/fees/service.go index bd6911b..60b079b 100644 --- a/services/fees/service.go +++ b/services/fees/service.go @@ -13,18 +13,25 @@ import ( const getPath = "/api/public/v1/fees" +// Service issues requests against the Wallbit fees endpoint. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// GetRequest selects which fee schedule to fetch. Type is the fee type key +// documented by the API (e.g. "card_issuance", "withdrawal_local"). type GetRequest struct { Type string `json:"type"` } +// FeeSetting is the single fee row returned by the API. PercentageFee and +// FixedFeeUSD are kept as strings because the API serializes them that way +// (they are decimals and preserving the server-side precision matters). type FeeSetting struct { FeeType string `json:"fee_type"` Tier *string `json:"tier"` @@ -32,11 +39,16 @@ type FeeSetting struct { FixedFeeUSD string `json:"fixed_fee_usd"` } +// GetData is the union payload for the fees endpoint. The API returns either +// an object (a concrete [FeeSetting], decoded into Row) or an empty array +// (Empty == true) when no rule applies to the requested fee type. type GetData struct { Row *FeeSetting Empty bool } +// UnmarshalJSON decodes either a FeeSetting object or an empty JSON array +// into the receiver, rejecting non-empty arrays and any other shape. func (d *GetData) UnmarshalJSON(b []byte) error { *d = GetData{} b = bytes.TrimSpace(b) @@ -66,10 +78,14 @@ func (d *GetData) UnmarshalJSON(b []byte) error { } } +// GetResponse is the top-level envelope for [Service.Get]. type GetResponse struct { Data GetData `json:"data"` } +// Get fetches the fee schedule for req.Type. The response's Data may be +// either a concrete [FeeSetting] (when a rule exists) or an empty payload +// (when none does); consult [GetData] for the discriminator. func (s *Service) Get(ctx context.Context, req GetRequest) (*transport.Response[GetResponse], error) { return transport.SendJSON(ctx, s.sender, http.MethodPost, getPath, req, &GetResponse{}) } diff --git a/services/operations/service.go b/services/operations/service.go index 194b28b..2222b50 100644 --- a/services/operations/service.go +++ b/services/operations/service.go @@ -10,19 +10,25 @@ import ( const internalPath = "/api/public/v1/operations/internal" +// Well-known source/destination accounts for internal transfers. The API +// may add new values; these constants document the currently supported set. const ( AccountDefault = "DEFAULT" AccountInvestment = "INVESTMENT" ) +// Service issues requests against the Wallbit operations endpoints. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// InternalRequest moves Amount of Currency from the From account to the To +// account, both accounts belonging to the authenticated user. type InternalRequest struct { Currency string `json:"currency"` From string `json:"from"` @@ -30,21 +36,31 @@ type InternalRequest struct { Amount float64 `json:"amount"` } +// InvestmentDepositRequest is the high-level request for +// [Service.DepositInvestment]. It is translated server-side into an +// [InternalRequest] from DEFAULT to INVESTMENT. type InvestmentDepositRequest struct { Currency string `json:"currency"` Amount float64 `json:"amount"` } +// InvestmentWithdrawRequest is the high-level request for +// [Service.WithdrawInvestment]. It is translated server-side into an +// [InternalRequest] from INVESTMENT to DEFAULT. type InvestmentWithdrawRequest struct { Currency string `json:"currency"` Amount float64 `json:"amount"` } +// Currency is the embedded currency descriptor carried on each +// [Transaction]. type Currency struct { Code string `json:"code"` Alias string `json:"alias"` } +// Transaction is the row returned by any operations endpoint. The API +// returns this object unwrapped (no data envelope). type Transaction struct { UUID string `json:"uuid"` Type string `json:"type"` @@ -58,10 +74,15 @@ type Transaction struct { Comment *string `json:"comment"` } +// Internal moves funds between two accounts of the authenticated user. +// For the common "default ↔ investment" cases prefer +// [Service.DepositInvestment] and [Service.WithdrawInvestment]. func (s *Service) Internal(ctx context.Context, req InternalRequest) (*transport.Response[Transaction], error) { return transport.SendJSON(ctx, s.sender, http.MethodPost, internalPath, req, &Transaction{}) } +// DepositInvestment is a shorthand for [Service.Internal] moving funds from +// the caller's default account to their investment account. func (s *Service) DepositInvestment(ctx context.Context, req InvestmentDepositRequest) (*transport.Response[Transaction], error) { return s.Internal(ctx, InternalRequest{ Currency: req.Currency, @@ -71,6 +92,8 @@ func (s *Service) DepositInvestment(ctx context.Context, req InvestmentDepositRe }) } +// WithdrawInvestment is a shorthand for [Service.Internal] moving funds +// from the caller's investment account back to their default account. func (s *Service) WithdrawInvestment(ctx context.Context, req InvestmentWithdrawRequest) (*transport.Response[Transaction], error) { return s.Internal(ctx, InternalRequest{ Currency: req.Currency, diff --git a/services/rates/doc.go b/services/rates/doc.go new file mode 100644 index 0000000..2b1ac06 --- /dev/null +++ b/services/rates/doc.go @@ -0,0 +1,7 @@ +// Package rates exposes the Wallbit exchange-rate endpoint. [Service.Get] +// returns the current conversion rate between a source and destination +// currency pair; identity pairs (e.g. USD→USD) resolve to rate 1.0 with a +// nil UpdatedAt. +// +// See https://developer.wallbit.io/docs/api-reference/rates/get. +package rates diff --git a/services/rates/service.go b/services/rates/service.go index 45eb3b8..7fdf51a 100644 --- a/services/rates/service.go +++ b/services/rates/service.go @@ -16,14 +16,18 @@ const getPath = "/api/public/v1/rates" // ErrEmptyCurrency is returned by [Service.Get] when source_currency or dest_currency is empty or whitespace-only. var ErrEmptyCurrency = errors.New("rates: source_currency and dest_currency are required") +// Service issues requests against the Wallbit rates endpoint. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// GetRequest parameterises a rate lookup by source and destination currency. +// Both fields are required (ISO-like currency codes, e.g. "USD", "ARS"). type GetRequest struct { SourceCurrency string DestCurrency string @@ -39,10 +43,13 @@ type ExchangeRate struct { UpdatedAt *time.Time `json:"updated_at"` } +// GetResponse is the top-level envelope for [Service.Get]. type GetResponse struct { Data ExchangeRate `json:"data"` } +// Get fetches the current exchange rate between req.SourceCurrency and +// req.DestCurrency. It returns [ErrEmptyCurrency] if either field is blank. func (s *Service) Get(ctx context.Context, req GetRequest) (*transport.Response[GetResponse], error) { if strings.TrimSpace(req.SourceCurrency) == "" || strings.TrimSpace(req.DestCurrency) == "" { return nil, ErrEmptyCurrency diff --git a/services/roboadvisor/service.go b/services/roboadvisor/service.go index 8cff598..64dd955 100644 --- a/services/roboadvisor/service.go +++ b/services/roboadvisor/service.go @@ -14,19 +14,25 @@ const ( withdrawPath = "/api/public/v1/roboadvisor/withdraw" ) +// Service issues requests against the Wallbit robo-advisor endpoints. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// RiskProfile describes the portfolio's risk tier as configured on the API +// side. RiskLevel is a numeric bucket, Name is the human label. type RiskProfile struct { RiskLevel int `json:"risk_level"` Name string `json:"name"` } +// Performance aggregates cash flow and P&L for a portfolio over its full +// lifetime, denominated in the portfolio's settlement currency (USD). type Performance struct { NetDeposits float64 `json:"net_deposits"` NetProfits float64 `json:"net_profits"` @@ -34,11 +40,13 @@ type Performance struct { TotalWithdrawals float64 `json:"total_withdrawals"` } +// Allocation is the current cash-vs-securities split of a portfolio. type Allocation struct { Cash float64 `json:"cash"` Securities float64 `json:"securities"` } +// Asset is a single holding within a [Portfolio]. type Asset struct { Symbol string `json:"symbol"` Shares float64 `json:"shares"` @@ -49,6 +57,8 @@ type Asset struct { Logo string `json:"logo"` } +// Portfolio is a managed robo-advisor account. Label and Category are +// user-assigned metadata and may be nil when never set. type Portfolio struct { ID int `json:"id"` Label *string `json:"label"` @@ -65,53 +75,70 @@ type Portfolio struct { HasPendingTransactions bool `json:"has_pending_transactions"` } +// GetBalanceResponse is the top-level envelope for [Service.GetBalance]. type GetBalanceResponse struct { Data []Portfolio `json:"data"` } +// AccountType is the source/destination of a robo-advisor movement: the +// caller's default cash account or a specific investment portfolio. type AccountType string +// Known [AccountType] values. const ( AccountTypeDefault AccountType = "DEFAULT" AccountTypeInvestment AccountType = "INVESTMENT" ) +// DepositRequest funds the portfolio identified by RoboAdvisorID with +// Amount units pulled from the From account. type DepositRequest struct { RoboAdvisorID int `json:"robo_advisor_id"` Amount float64 `json:"amount"` From AccountType `json:"from"` } +// WithdrawRequest moves Amount units out of the portfolio identified by +// RoboAdvisorID into the To account. type WithdrawRequest struct { RoboAdvisorID int `json:"robo_advisor_id"` Amount float64 `json:"amount"` To AccountType `json:"to"` } +// Transaction describes a single deposit or withdrawal against a portfolio. type Transaction struct { - UUID string `json:"uuid"` - Type string `json:"type"` - Amount float64 `json:"amount"` - Status string `json:"status"` + UUID string `json:"uuid"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } +// DepositResponse is the top-level envelope for [Service.Deposit]. type DepositResponse struct { Data Transaction `json:"data"` } +// WithdrawResponse is the top-level envelope for [Service.Withdraw]. type WithdrawResponse struct { Data Transaction `json:"data"` } +// GetBalance returns every robo-advisor portfolio visible to the caller, +// including the cash balance, current valuation and asset breakdown. func (s *Service) GetBalance(ctx context.Context) (*transport.Response[GetBalanceResponse], error) { return transport.SendJSON(ctx, s.sender, http.MethodGet, balancePath, nil, &GetBalanceResponse{}) } +// Deposit moves funds from the caller's source account into the portfolio +// identified in req. func (s *Service) Deposit(ctx context.Context, req DepositRequest) (*transport.Response[DepositResponse], error) { return transport.SendJSON(ctx, s.sender, http.MethodPost, depositPath, req, &DepositResponse{}) } +// Withdraw moves funds out of the portfolio identified in req into the +// caller's destination account. func (s *Service) Withdraw(ctx context.Context, req WithdrawRequest) (*transport.Response[WithdrawResponse], error) { return transport.SendJSON(ctx, s.sender, http.MethodPost, withdrawPath, req, &WithdrawResponse{}) } diff --git a/services/trades/service.go b/services/trades/service.go index a914c05..81aaf07 100644 --- a/services/trades/service.go +++ b/services/trades/service.go @@ -10,14 +10,21 @@ import ( const createPath = "/api/public/v1/trades" +// Service issues requests against the Wallbit trades endpoint. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// CreateRequest parameterises a call to [Service.Create]. Exactly one of +// Amount (notional in Currency) or Shares (quantity) must be provided; the +// other price fields depend on OrderType (e.g. LimitPrice for "LIMIT", +// StopPrice for "STOP"). See the Wallbit docs for the full combination +// matrix. type CreateRequest struct { Symbol string `json:"symbol"` Direction string `json:"direction"` @@ -30,24 +37,30 @@ type CreateRequest struct { TimeInForce *string `json:"time_in_force,omitempty"` } +// Trade is the order row returned by [Service.Create]. UpdatedAt tracks +// the last status transition; for terminal statuses it equals the fill +// timestamp. type Trade struct { - Symbol string `json:"symbol"` - Direction string `json:"direction"` - Amount float64 `json:"amount"` - Shares float64 `json:"shares"` - Status string `json:"status"` - OrderType string `json:"order_type"` - LimitPrice *float64 `json:"limit_price"` - StopPrice *float64 `json:"stop_price"` - TimeInForce *string `json:"time_in_force"` + Symbol string `json:"symbol"` + Direction string `json:"direction"` + Amount float64 `json:"amount"` + Shares float64 `json:"shares"` + Status string `json:"status"` + OrderType string `json:"order_type"` + LimitPrice *float64 `json:"limit_price"` + StopPrice *float64 `json:"stop_price"` + TimeInForce *string `json:"time_in_force"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// CreateResponse is the top-level envelope for [Service.Create]. type CreateResponse struct { Data Trade `json:"data"` } +// Create submits a new equity order for the symbol and order type +// specified in req. func (s *Service) Create(ctx context.Context, req CreateRequest) (*transport.Response[CreateResponse], error) { return transport.SendJSON(ctx, s.sender, http.MethodPost, createPath, req, &CreateResponse{}) } diff --git a/services/transactions/service.go b/services/transactions/service.go index 5656283..667dc77 100644 --- a/services/transactions/service.go +++ b/services/transactions/service.go @@ -13,14 +13,20 @@ import ( const listPath = "/api/public/v1/transactions" +// Service issues requests against the Wallbit transactions endpoint. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// ListRequest parameterises a call to [Service.List]. Every field is +// optional; nil/zero values are omitted from the query string. Page and +// Limit are pointers so the caller can distinguish "unset" from "0"; use +// [github.com/jeremyjsx/wallbit-go/wallbit.Ptr] as a convenience. type ListRequest struct { Page *int Limit *int @@ -33,11 +39,16 @@ type ListRequest struct { ToAmount *float64 } +// CurrencyRef is the embedded currency descriptor carried on each +// [Transaction]. Code is the ISO-like currency identifier, Alias is the +// human-friendly label surfaced by the API. type CurrencyRef struct { Code string `json:"code"` Alias string `json:"alias"` } +// Transaction is a single row returned by [Service.List]. ExternalAddress +// and Comment are nil when the API omits them. type Transaction struct { UUID string `json:"uuid"` Type string `json:"type"` @@ -51,6 +62,9 @@ type Transaction struct { Comment *string `json:"comment"` } +// ListData is the paginated payload embedded in [ListResponse]. CurrentPage +// equals Pages on the last page; Count is the total number of rows across +// every page. type ListData struct { Data []Transaction `json:"data"` Pages int `json:"pages"` @@ -58,10 +72,14 @@ type ListData struct { Count int `json:"count"` } +// ListResponse is the top-level envelope for [Service.List]. type ListResponse struct { Data ListData `json:"data"` } +// List fetches a single page of transactions matching req's filters. A nil +// req returns the first page with server defaults. For lazy iteration over +// every page, use [Service.ListAll]. func (s *Service) List(ctx context.Context, req *ListRequest) (*transport.Response[ListResponse], error) { path := listPath if req != nil { diff --git a/services/wallets/service.go b/services/wallets/service.go index 64f9133..aa5bfdc 100644 --- a/services/wallets/service.go +++ b/services/wallets/service.go @@ -10,29 +10,37 @@ import ( const getPath = "/api/public/v1/wallets" +// Service issues requests against the Wallbit wallets endpoint. type Service struct { sender transport.Sender } +// NewService wires a [Service] to the given [transport.Sender]. func NewService(sender transport.Sender) *Service { return &Service{sender: sender} } +// GetRequest filters the wallet list. Both fields are optional; an empty +// field is omitted from the query string and matches every value. type GetRequest struct { Currency string Network string } +// Wallet is a single deposit address row returned by [Service.Get]. type Wallet struct { Address string `json:"address"` Network string `json:"network"` CurrencyCode string `json:"currency_code"` } +// GetResponse is the top-level envelope for [Service.Get]. type GetResponse struct { Data []Wallet `json:"data"` } +// Get returns the deposit addresses for the authenticated user. A nil req +// returns every wallet; set Currency or Network to narrow the result. func (s *Service) Get(ctx context.Context, req *GetRequest) (*transport.Response[GetResponse], error) { path := getPath if req != nil { diff --git a/wallbit/ptr_test.go b/wallbit/ptr_test.go index 6f97300..644d2f1 100644 --- a/wallbit/ptr_test.go +++ b/wallbit/ptr_test.go @@ -13,9 +13,11 @@ func TestPtrReturnsAddressOfCopy(t *testing.T) { if p == nil || *p != "jeremy" { t.Fatalf("Ptr(%q) dereferenced to %v", src, p) } - src = "mutated" - if *p != "jeremy" { - t.Fatalf("Ptr captured caller's variable by alias: got %q after mutation", *p) + // Mutating the returned pointee must not leak back into the + // caller's variable: Ptr takes its argument by value. + *p = "mutated" + if src != "jeremy" { + t.Fatalf("Ptr aliased caller's variable: src = %q after mutation", src) } } diff --git a/wallbit/retry.go b/wallbit/retry.go index 480fa5b..3ceb2ce 100644 --- a/wallbit/retry.go +++ b/wallbit/retry.go @@ -103,7 +103,10 @@ func jitter(d time.Duration) time.Duration { return d } - return half + time.Duration(rand.Int64N(int64(half)+1)) + // math/rand/v2 is intentional: jitter is not a security primitive, + // just load-spreading across many clients recovering from the same + // upstream incident. + return half + time.Duration(rand.Int64N(int64(half)+1)) //nolint:gosec // G404: non-cryptographic jitter is by design } func (c *Client) maxAttempts() int { diff --git a/wallbit/wallbit.go b/wallbit/wallbit.go index b2c49b1..a10483b 100644 --- a/wallbit/wallbit.go +++ b/wallbit/wallbit.go @@ -25,6 +25,8 @@ import ( "github.com/jeremyjsx/wallbit-go/transport" ) +// ErrMissingAPIKey is returned by [NewClient] and [NewClientFromConfig] +// when the supplied API key is empty or whitespace-only. var ErrMissingAPIKey = errors.New("wallbit client requires a non-empty api key") // ErrResponseTooLarge is returned by the client when an HTTP response body @@ -39,36 +41,66 @@ var ErrResponseTooLarge = errors.New("wallbit client: response body exceeds conf // positive value. const DefaultMaxResponseBytes int64 = 10 << 20 +// Client is the top-level entrypoint for the Wallbit Go SDK. It owns the +// configured [http.Client], retry policy and hooks, and exposes the +// per-endpoint services as public fields. A Client is safe for concurrent +// use once constructed via [NewClient] or [NewClientFromConfig]. type Client struct { apiKey string cfg *Config sender transport.Sender + // Balance fetches fiat and equity balances. Balance *balance.Service + // Transactions lists the authenticated user's transaction history, + // with filtering and lazy pagination via ListAll. Transactions *transactions.Service + // APIKey manages the credential used by the client itself (currently + // only supports revocation). APIKey *apikey.Service + // Trades places equity orders (market, limit, stop, …) against the + // user's stocks account. Trades *trades.Service + // Fees returns the fee schedule for a given fee type. Fees *fees.Service + // AccountDetails returns the bank account details used to fund or + // withdraw fiat from the user's Wallbit account. AccountDetails *accountdetails.Service + // Wallets returns the user's deposit addresses, optionally filtered by + // currency or network. Wallets *wallets.Service + // Assets looks up a single tradable instrument by symbol or lists the + // available catalogue with filters and lazy pagination via ListAll. Assets *assets.Service + // Operations moves funds between the user's own accounts (default, + // investment, …). Operations *operations.Service + // RoboAdvisor reads managed portfolios and moves funds in or out of + // them. RoboAdvisor *roboadvisor.Service + // Cards lists the user's cards and toggles their status between + // ACTIVE and SUSPENDED. Cards *cards.Service + // Rates fetches the current exchange rate for a currency pair. Rates *rates.Service } +// NewClient builds a [Client] authenticated with apiKey and configured by +// the given options. It validates the resulting [Config] (base URL, +// HTTPClient, retry policy) and returns [ErrMissingAPIKey] if apiKey is +// blank. For configuration supplied as a single struct, use +// [NewClientFromConfig] instead. func NewClient(apiKey string, opts ...Option) (*Client, error) { if strings.TrimSpace(apiKey) == "" { return nil, ErrMissingAPIKey @@ -144,6 +176,9 @@ func wireServices(c *Client) { c.Rates = rates.NewService(c.sender) } +// Config returns the effective, validated configuration the client was +// built with. The returned pointer is shared with the client and must not +// be mutated; to change behaviour, build a new client instead. func (c *Client) Config() *Config { return c.cfg } @@ -188,7 +223,10 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { c.emitRequestStart(reqTry, attemptNumber) start := time.Now() - res, err := c.cfg.HTTPClient.Do(reqTry) + // The request URL is always built from a validated base URL + // (WithBaseURL rejects non-http(s)/relative URLs) plus a path + // chosen by the SDK; it is not attacker-controlled input. + res, err := c.cfg.HTTPClient.Do(reqTry) //nolint:gosec // G107: URL is SDK-controlled, not taint-sourced dur := time.Since(start) statusCode := 0