Files

1016 lines
33 KiB
Go

// Package northcapital is the Lux treasury-side adapter for the
// North Capital TransactAPI payments rail (ACH / credit card / wire /
// check / IRA) — Workstream 5 of the integration brief.
//
// Conforms to the provider.Provider interface (sibling to
// wise, mercury, increase, column, unit, marqeta, lithic, mpesa,
// bridge, currencycloud, plaid, square, moderntreasury). All payments
// flow into the same double-entry treasury ledger as every other
// rail; reconciliation, fee attribution, and reporting flow through
// treasuryd unchanged.
//
// The underlying surface is TAPI v3 at
// https://api-sandboxdash.norcapsecurities.com (sandbox) and
// https://tapi-live.norcapsecurities.com (production). Every endpoint
// is POST /tapiv3/index.php/v3/<verb> with a JSON body that carries
// the clientID + developerAPIKey credential pair alongside the call
// parameters. The verbs used here:
//
// createAccount, getAccount, getAllAccounts — accounts
// externalFundMove, getExternalFundMoveInfo — ACH
// ccFundMovement, getCCFundMoveInfo — credit card
// createCustodyFundMove, getCustodyFundMove,
// updateCustodyFundMove — wire / check / IRA
// createParty — counterparty
//
// The PaymentType→endpoint mapping in paymentEndpoint() is the single
// source of truth for payment routing.
//
// Source-of-design: Public-Spec
// Source-ref: https://transactapi.readme.io/reference
//
// Authored under the Independent-Implementation Clean-Room Engineering
// Protocol (legal/INDEPENDENT-IMPLEMENTATION-CLEAN-ROOM-PROTOCOL.md).
// No Counterparty source has been read. The TransactAPI public REST +
// webhook surface is the sole specification consulted, supplemented by
// the integration brief at legal/partnerships/northcapital/
// NorthCapital_Lux_Integration_Brief.md.
package northcapital
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"strconv"
"time"
"github.com/luxfi/treasury/pkg/provider"
"github.com/luxfi/treasury/pkg/types"
)
// Environment base URLs published by NCPS.
const (
ProdURL = "https://tapi-live.norcapsecurities.com"
SandboxURL = "https://api-sandboxdash.norcapsecurities.com"
)
// pathPrefix is the TAPI v3 mount point. Every verb hangs off this.
const pathPrefix = "/tapiv3/index.php/v3"
// providerName is the registry identifier (kept as a const so a typo
// in one method does not silently misroute through the registry).
const providerName = "northcapital"
// ErrNotImplemented is the sentinel reserved for entry points that
// have no real body. Every interface method on Provider has a real
// body — including LoadFromKMS, which now lives in kms.go as a real
// implementation against a KMSGetter interface. The sentinel is
// retained for forward-compat: any future method added to the
// Provider surface that ships its scaffold ahead of its body
// returns this error so callers can errors.Is and degrade
// gracefully.
var ErrNotImplemented = errors.New("northcapital: method not yet implemented")
// Refund is the NCPS-specific extension type returned by
// RefundPayment. Kept local to the adapter (not on the shared
// types.Payment surface) because only NCPS exposes a first-class
// refund object in this adapter set; cross-provider refunds are a
// follow-up workstream on the unified types package.
type Refund struct {
ID string `json:"id"`
Provider string `json:"provider"`
ProviderID string `json:"provider_id"`
PaymentID string `json:"payment_id"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Config carries the per-environment credentials and tuning for the
// TransactAPI treasury-side adapter. Credentials are sealed under
// the per-tenant KEK in luxfi/kms; see LoadFromKMS below.
type Config struct {
BaseURL string
ClientID string
DeveloperAPIKey string
HTTPClient *http.Client
// MaxRetries bounds the 429-backoff loop. Zero defaults to 4.
MaxRetries int
// BackoffBase is the first sleep interval; subsequent retries
// double it with jitter. Zero defaults to 250ms.
BackoffBase time.Duration
}
// Provider implements the treasury Provider interface for TransactAPI.
type Provider struct {
cfg Config
client *http.Client
}
// New constructs a TransactAPI treasury Provider. BaseURL must be set
// (typically to SandboxURL during development).
func New(cfg Config) provider.Provider {
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second}
}
if cfg.MaxRetries == 0 {
cfg.MaxRetries = 4
}
if cfg.BackoffBase == 0 {
cfg.BackoffBase = 250 * time.Millisecond
}
return &Provider{cfg: cfg, client: cfg.HTTPClient}
}
// Name returns the provider identifier.
func (p *Provider) Name() string { return providerName }
// LoadFromKMS is implemented in kms.go. The signature is
// (ctx, kms, env) — see kms.go for the KMSGetter interface, error
// sentinels (ErrKMSKeyMissing, ErrKMSDecryption), and the
// treasuryd-side wiring sketch against luxfi/kms.
// --- HTTP helper ---
// idempotencyHeader carries the Lux-derived idempotency key alongside
// the body-level credentials. TransactAPI does not document a native
// idempotency header, but the request is harmless and gives the Lux
// side a stable retry trace; the body itself also contains a stable
// hash via DeriveIdempotencyKey.
const idempotencyHeader = "X-Idempotency-Key"
// doRequestIdem issues a POST to /tapiv3/index.php/v3/<verb> with the
// caller's body fields merged with the clientID + developerAPIKey
// credentials, retrying on 429 with exponential backoff + jitter and
// honoring Retry-After.
func (p *Provider) doRequestIdem(ctx context.Context, verb string, body map[string]any, idemKey string) ([]byte, int, error) {
// Merge credentials into the body — TAPI v3 carries them as body
// fields, not headers.
if body == nil {
body = map[string]any{}
}
body["clientID"] = p.cfg.ClientID
body["developerAPIKey"] = p.cfg.DeveloperAPIKey
enc, err := json.Marshal(body)
if err != nil {
return nil, 0, fmt.Errorf("northcapital: marshal %s: %w", verb, err)
}
url := p.cfg.BaseURL + pathPrefix + "/" + verb
var lastStatus int
var lastBody []byte
for attempt := 0; attempt <= p.cfg.MaxRetries; attempt++ {
req, rErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(enc))
if rErr != nil {
return nil, 0, rErr
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if idemKey != "" {
req.Header.Set(idempotencyHeader, idemKey)
}
resp, doErr := p.client.Do(req)
if doErr != nil {
// Network error — retryable only if we have budget left.
if attempt < p.cfg.MaxRetries {
if sleepErr := sleepWithJitter(ctx, p.cfg.BackoffBase, attempt, ""); sleepErr != nil {
return nil, 0, sleepErr
}
continue
}
return nil, 0, doErr
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, resp.StatusCode, readErr
}
lastStatus, lastBody = resp.StatusCode, data
if resp.StatusCode == http.StatusTooManyRequests && attempt < p.cfg.MaxRetries {
if sleepErr := sleepWithJitter(ctx, p.cfg.BackoffBase, attempt, resp.Header.Get("Retry-After")); sleepErr != nil {
return nil, resp.StatusCode, sleepErr
}
continue
}
if resp.StatusCode >= 400 {
return nil, resp.StatusCode, fmt.Errorf("northcapital %s: %d: %s", verb, resp.StatusCode, string(data))
}
return data, resp.StatusCode, nil
}
return nil, lastStatus, fmt.Errorf("northcapital %s: exhausted retries; last status %d: %s", verb, lastStatus, string(lastBody))
}
// sleepWithJitter implements the rate-limit backoff. The Retry-After
// header (when present and numeric, seconds) takes precedence;
// otherwise base * 2^attempt with up to 50% jitter.
func sleepWithJitter(ctx context.Context, base time.Duration, attempt int, retryAfter string) error {
var dur time.Duration
if retryAfter != "" {
if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
dur = time.Duration(secs) * time.Second
}
}
if dur == 0 {
mult := 1 << attempt
dur = base * time.Duration(mult)
}
// 50% jitter (deterministic-enough — rand is fine, not crypto).
//nolint:gosec // jitter, not cryptography
jitter := time.Duration(rand.Int63n(int64(dur) / 2))
dur += jitter
t := time.NewTimer(dur)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// canonicalJSON is the stable serialization used as the
// idempotency-key derivation input. Keys are sorted by the standard
// library's json.Marshal of a map[string]any, which iterates in
// sorted order — so the same body produces the same key across
// processes.
func canonicalJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}
// --- provider.Provider interface ---
// CreateAccount opens a TransactAPI account via PUT /createAccount
// (TAPI uses POST for everything in the v3 surface — the documented
// "PUT" in the reference is a writer convention, the verb is POST).
// Idempotency-key follows the standard Lux contract: caller override
// wins; deterministic fallback otherwise.
func (p *Provider) CreateAccount(ctx context.Context, orgID string, req *provider.CreateAccountRequest) (*types.BankAccount, error) {
body := map[string]any{
"accountRegistration": req.AccountName,
"type": req.AccountType,
"domesticYN": "Yes",
"streetAddress1": "—",
"city": "—",
"state": "—",
"zip": "—",
"country": firstNonEmpty(req.Country, "USA"),
"KYCstatus": "Pending",
"AMLstatus": "Pending",
"AccreditedStatus": "Pending",
"approvalStatus": "Pending",
}
idem := req.IdempotencyKey
if idem == "" {
idem = provider.DeriveIdempotencyKey(orgID, "/v3/createAccount", canonicalJSON(body))
}
data, _, err := p.doRequestIdem(ctx, "createAccount", body, idem)
if err != nil {
return nil, err
}
id, status := parseAccountResponse(data)
if id == "" {
return nil, fmt.Errorf("northcapital createAccount: missing accountId in response: %s", string(data))
}
now := time.Now().UTC()
return &types.BankAccount{
ID: id,
Provider: providerName,
ProviderID: id,
OrgID: orgID,
AccountName: req.AccountName,
Currency: firstNonEmpty(req.Currency, "USD"),
AccountType: firstNonEmpty(req.AccountType, "custody"),
Status: firstNonEmpty(status, "pending"),
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// GetAccount fetches a TransactAPI account by its NCPS accountId.
func (p *Provider) GetAccount(ctx context.Context, accountID string) (*types.BankAccount, error) {
body := map[string]any{"accountId": accountID}
data, _, err := p.doRequestIdem(ctx, "getAccount", body, "")
if err != nil {
return nil, err
}
id, status := parseAccountResponse(data)
if id == "" {
id = accountID
}
return &types.BankAccount{
ID: id,
Provider: providerName,
ProviderID: id,
Status: firstNonEmpty(status, "active"),
}, nil
}
// ListAccounts iterates the TAPI v3 paginated /accounts surface,
// fetching every page transparently. orgID is forwarded as a filter
// where supported and as the BankAccount.OrgID tag on each result.
func (p *Provider) ListAccounts(ctx context.Context, orgID string) ([]*types.BankAccount, error) {
const pageSize = 100
var out []*types.BankAccount
offset := 0
for {
body := map[string]any{
"offset": offset,
"limit": pageSize,
"pageSize": pageSize,
}
data, _, err := p.doRequestIdem(ctx, "getAllAccounts", body, "")
if err != nil {
return nil, err
}
accts, more := parseAccountListResponse(data, orgID)
out = append(out, accts...)
if !more || len(accts) == 0 {
return out, nil
}
offset += len(accts)
}
}
// GetBalance fetches the available + current balance for a TransactAPI
// custody account, via /getCustodyBalances.
func (p *Provider) GetBalance(ctx context.Context, accountID string) (*types.Balance, error) {
body := map[string]any{"accountId": accountID}
data, _, err := p.doRequestIdem(ctx, "getCustodyBalances", body, "")
if err != nil {
return nil, err
}
avail, current, currency := parseBalanceResponse(data)
return &types.Balance{
Available: avail,
Current: current,
Currency: firstNonEmpty(currency, "USD"),
UpdatedAt: time.Now().UTC(),
}, nil
}
// CreatePayment routes to the appropriate TAPI v3 verb based on the
// PaymentType. The endpoint is derived deterministically; both the
// path and the canonical body feed the deterministic-fallback
// idempotency-key derivation, so retries collapse safely on the Lux
// side and trace cleanly on the NCPS side.
func (p *Provider) CreatePayment(ctx context.Context, req *types.CreatePaymentRequest) (*types.Payment, error) {
endpoint, err := paymentEndpoint(req.Type)
if err != nil {
return nil, err
}
body := paymentBody(req)
idem := req.IdempotencyKey
if idem == "" {
idem = provider.DeriveIdempotencyKey(req.OrgID, "/v3/"+endpoint, canonicalJSON(body))
}
data, _, err := p.doRequestIdem(ctx, endpoint, body, idem)
if err != nil {
return nil, err
}
refnum, status := parseFundMoveResponse(data)
now := time.Now().UTC()
return &types.Payment{
ID: refnum,
Provider: providerName,
ProviderID: refnum,
OrgID: req.OrgID,
Type: req.Type,
Direction: firstNonEmpty(req.Direction, "credit"),
Amount: req.Amount,
Currency: firstNonEmpty(req.Currency, "USD"),
Status: mapPaymentStatus(status),
SourceAccountID: req.SourceAccountID,
DestAccountID: req.DestAccountID,
Counterparty: req.Counterparty,
Reference: req.Reference,
Description: req.Description,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// GetPayment reads a payment by NCPS reference number. Since TAPI
// segregates external (ACH) fund-moves from credit-card moves from
// custody moves, the lookup falls through each surface until one
// returns a populated record.
func (p *Provider) GetPayment(ctx context.Context, paymentID string) (*types.Payment, error) {
// Try external (ACH) first — that is the highest-volume rail.
if pay, err := p.getFundMove(ctx, "getExternalFundMoveInfo", paymentID); err == nil && pay != nil {
return pay, nil
}
// Then credit card.
if pay, err := p.getFundMove(ctx, "getCCFundMoveInfo", paymentID); err == nil && pay != nil {
return pay, nil
}
// Then custody (wire / check / IRA).
if pay, err := p.getFundMove(ctx, "getCustodyFundMove", paymentID); err == nil && pay != nil {
return pay, nil
}
return nil, fmt.Errorf("northcapital getPayment: no record found for %s", paymentID)
}
func (p *Provider) getFundMove(ctx context.Context, verb, refNum string) (*types.Payment, error) {
body := map[string]any{
"RefNum": refNum,
"referenceNumber": refNum, // custody variant uses lowerCamel
}
data, _, err := p.doRequestIdem(ctx, verb, body, "")
if err != nil {
return nil, err
}
id, status, amount, currency := parseFundMoveDetail(data)
if id == "" {
return nil, fmt.Errorf("no record")
}
return &types.Payment{
ID: id,
Provider: providerName,
ProviderID: id,
Amount: amount,
Currency: firstNonEmpty(currency, "USD"),
Status: mapPaymentStatus(status),
}, nil
}
// ListPayments paginates the external-fund-move surface for an
// account. TAPI does not document a per-account list, so the
// implementation queries getAllExternalFundMoves with an accountId
// filter and pages transparently.
func (p *Provider) ListPayments(ctx context.Context, accountID string) ([]*types.Payment, error) {
const pageSize = 100
var out []*types.Payment
offset := 0
for {
body := map[string]any{
"accountId": accountID,
"offset": offset,
"limit": pageSize,
"pageSize": pageSize,
}
data, _, err := p.doRequestIdem(ctx, "getAllExternalFundMoves", body, "")
if err != nil {
return nil, err
}
pays, more := parsePaymentListResponse(data)
out = append(out, pays...)
if !more || len(pays) == 0 {
return out, nil
}
offset += len(pays)
}
}
// CancelPayment voids a pending fund-move on the custody surface.
// TAPI only permits void of "Pending" transactions (settled or
// processing moves cannot be cancelled); the API surfaces an error
// for non-pending moves, which we pass through to the caller.
func (p *Provider) CancelPayment(ctx context.Context, paymentID string) error {
body := map[string]any{
"referenceNumber": paymentID,
"status": "void",
}
idem := provider.DeriveIdempotencyKey("", "/v3/updateCustodyFundMove", canonicalJSON(body))
_, _, err := p.doRequestIdem(ctx, "updateCustodyFundMove", body, idem)
return err
}
// RefundPayment issues a refund or return on a previously-settled
// fund-move. TAPI exposes refund semantics via the same
// updateCustodyFundMove verb with the refund status flag plus an
// amount field for partial refunds. The amount argument may be empty
// to refund the full original amount.
//
// Source: Investor Refunds and Returns guide
// (https://transactapi.readme.io/docs/investor-refunds-and-returns).
func (p *Provider) RefundPayment(ctx context.Context, paymentID, amount, reason string) (*Refund, error) {
body := map[string]any{
"referenceNumber": paymentID,
"status": "refund",
}
if amount != "" {
body["amount"] = amount
}
if reason != "" {
body["note"] = reason
}
idem := provider.DeriveIdempotencyKey("", "/v3/updateCustodyFundMove#refund", canonicalJSON(body))
data, _, err := p.doRequestIdem(ctx, "updateCustodyFundMove", body, idem)
if err != nil {
return nil, err
}
refundID, status, retAmount, currency := parseFundMoveDetail(data)
if refundID == "" {
refundID = paymentID
}
return &Refund{
ID: refundID,
Provider: providerName,
ProviderID: refundID,
PaymentID: paymentID,
Amount: firstNonEmpty(retAmount, amount),
Currency: firstNonEmpty(currency, "USD"),
Status: firstNonEmpty(status, "pending"),
Reason: reason,
CreatedAt: time.Now().UTC(),
}, nil
}
// --- FX is not a TransactAPI surface (USD-only). ---
//
// Returning ErrNotSupported (NOT ErrNotImplemented) is the deliberate
// contract — the smart-order router uses it to mark TransactAPI as a
// non-FX rail and route any FX leg to currencycloud.
func (p *Provider) GetFXQuote(_ context.Context, _, _, _, _ string) (*types.FXQuote, error) {
return nil, provider.ErrNotSupported
}
func (p *Provider) CreateFXConversion(_ context.Context, _ *types.CreateFXConversionRequest) (*types.FXConversion, error) {
return nil, provider.ErrNotSupported
}
func (p *Provider) ListFXConversions(_ context.Context) ([]*types.FXConversion, error) {
return nil, provider.ErrNotSupported
}
// --- Counterparties (TransactAPI parties) ---
// CreateCounterparty maps a Lux Counterparty onto a TransactAPI Party
// via createParty. Party identity is the canonical NCPS-side handle
// for the counterparty on every subsequent fund-move.
func (p *Provider) CreateCounterparty(ctx context.Context, cp *types.Counterparty) (*types.Counterparty, error) {
// Best-effort name split — Lux's Counterparty.Name is "First Last".
first, last := splitName(cp.Name)
body := map[string]any{
"domicile": "US",
"firstName": first,
"lastName": last,
"dob": "1970-01-01",
"primCountry": firstNonEmpty(cp.BankCountry, "USA"),
"primAddress1": "—",
"primCity": "—",
"primState": "—",
"primZip": "—",
"emailAddress": cp.Email,
"KYCstatus": "Pending",
"AMLstatus": "Pending",
}
idem := cp.IdempotencyKey
if idem == "" {
idem = provider.DeriveIdempotencyKey("", "/v3/createParty", canonicalJSON(body))
}
data, _, err := p.doRequestIdem(ctx, "createParty", body, idem)
if err != nil {
return nil, err
}
id := parsePartyResponse(data)
if id == "" {
return nil, fmt.Errorf("northcapital createParty: missing partyId in response: %s", string(data))
}
out := *cp
out.ID = id
return &out, nil
}
// ListCounterparties paginates the TAPI parties surface.
func (p *Provider) ListCounterparties(ctx context.Context) ([]*types.Counterparty, error) {
const pageSize = 100
var out []*types.Counterparty
offset := 0
for {
body := map[string]any{
"offset": offset,
"limit": pageSize,
"pageSize": pageSize,
}
data, _, err := p.doRequestIdem(ctx, "getAllParties", body, "")
if err != nil {
return nil, err
}
cps, more := parsePartyListResponse(data)
out = append(out, cps...)
if !more || len(cps) == 0 {
return out, nil
}
offset += len(cps)
}
}
// Capabilities declares what this adapter supports.
func (p *Provider) Capabilities() *types.ProviderCapability {
return &types.ProviderCapability{
Name: providerName,
PaymentTypes: []string{"ach", "credit_card", "wire", "check", "ira"},
Currencies: []string{"USD"},
Features: []string{"bd_payments", "ats_payments", "custody_funding", "refunds", "ira"},
Countries: []string{"US"},
SettlementSpeed: "t+0_to_t+2",
Status: "active",
}
}
// paymentEndpoint maps a unified PaymentType to the TransactAPI verb
// used to send that payment. The mapping is the single source of
// truth for payment routing — a regression here silently misroutes
// an ACH onto the credit-card rail (and vice versa), so it is also
// pinned by TestPaymentEndpoint.
func paymentEndpoint(t types.PaymentType) (string, error) {
switch t {
case types.PaymentACH:
return "externalFundMove", nil
case types.PaymentCard:
return "ccFundMovement", nil
case types.PaymentWire, types.PaymentCheck:
return "createCustodyFundMove", nil
case "ira":
// IRA payments settle through the custody fund-move surface
// alongside wire and check; the type discrimination lives in
// the body field (set in paymentBody).
return "createCustodyFundMove", nil
default:
return "", fmt.Errorf("northcapital: unsupported payment type %q", string(t))
}
}
// paymentBody constructs the TAPI v3 body for a payment, filling in
// every field documented as required for the target endpoint.
func paymentBody(req *types.CreatePaymentRequest) map[string]any {
body := map[string]any{
"accountId": req.SourceAccountID,
"amount": req.Amount,
"description": firstNonEmpty(req.Description, req.Reference),
}
if req.Counterparty != nil {
body["partyId"] = req.Counterparty.ID
body["NickName"] = req.Counterparty.Name
}
switch req.Type {
case types.PaymentACH:
// externalFundMove requires offeringId + tradeId + checkNumber
// + createdIpAddress; use sane defaults when callers don't
// surface them via meta.
body["offeringId"] = metaOrDefault(req.Meta, "offeringId", "0")
body["tradeId"] = metaOrDefault(req.Meta, "tradeId", "0")
body["checkNumber"] = metaOrDefault(req.Meta, "checkNumber", "0")
body["createdIpAddress"] = metaOrDefault(req.Meta, "createdIpAddress", "0.0.0.0")
case types.PaymentCard:
// ccFundMovement uses creditCardName, creditCardNumber,
// expirationDate, cvv as the live-card fields, but in
// practice the partyId carries a vaulted card token — surface
// what the caller hands us through meta.
for _, k := range []string{"creditCardName", "creditCardNumber", "expirationDate", "cvv", "billingZip"} {
if v := req.Meta[k]; v != "" {
body[k] = v
}
}
case types.PaymentWire, types.PaymentCheck:
body["fundMoveType"] = string(req.Type)
case "ira":
body["fundMoveType"] = "ira"
}
return body
}
// --- response parsers ---
//
// TAPI v3 response envelopes are not uniformly documented; the
// parsers below accept the documented shapes and the observed
// sandbox shapes (single-object data, array data, status fields with
// either statusCode/statusDesc or "Status"/"description"). They fall
// back to empty values rather than panic on unfamiliar shapes — the
// caller surfaces a clean error in that case.
type tapiEnvelope struct {
StatusCode any `json:"statusCode"`
StatusDesc string `json:"statusDesc"`
Data json.RawMessage `json:"data"`
// Some endpoints return a flat record with no wrapper; we read
// these fields directly off the response body in that case.
AccountID string `json:"accountId"`
PartyID string `json:"partyId"`
RefNum string `json:"RefNum"`
RefNumLC string `json:"referenceNumber"`
Status string `json:"Status"`
StatusDesc2 string `json:"status"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Available string `json:"available"`
Current string `json:"current"`
}
func parseAccountResponse(data []byte) (id, status string) {
var env tapiEnvelope
if err := json.Unmarshal(data, &env); err != nil {
return "", ""
}
id = env.AccountID
if id == "" {
// Some shapes nest under "accountDetails".
var nested struct {
AccountDetails []struct {
AccountID string `json:"accountId"`
Status string `json:"approvalStatus"`
} `json:"accountDetails"`
}
_ = json.Unmarshal(env.Data, &nested)
if len(nested.AccountDetails) > 0 {
id = nested.AccountDetails[0].AccountID
status = nested.AccountDetails[0].Status
}
}
if status == "" {
status = firstNonEmpty(env.Status, env.StatusDesc2)
}
return id, status
}
func parseAccountListResponse(data []byte, orgID string) (accts []*types.BankAccount, more bool) {
// Shape 1: {"data": [ { accountId, approvalStatus, ... }, ... ] }
var shaped struct {
Data []struct {
AccountID string `json:"accountId"`
AccountRegistration string `json:"accountRegistration"`
ApprovalStatus string `json:"approvalStatus"`
} `json:"data"`
AccountDetails []struct {
AccountID string `json:"accountId"`
AccountRegistration string `json:"accountRegistration"`
ApprovalStatus string `json:"approvalStatus"`
} `json:"accountDetails"`
}
_ = json.Unmarshal(data, &shaped)
rows := shaped.Data
if len(rows) == 0 {
rows = shaped.AccountDetails
}
for _, r := range rows {
accts = append(accts, &types.BankAccount{
ID: r.AccountID,
Provider: providerName,
ProviderID: r.AccountID,
OrgID: orgID,
AccountName: r.AccountRegistration,
Status: firstNonEmpty(r.ApprovalStatus, "active"),
})
}
// "more" is true only when a full page came back; TAPI does not
// expose a cursor, so we use length-equals-pageSize as the proxy.
more = len(accts) > 0
return accts, more
}
func parseBalanceResponse(data []byte) (available, current, currency string) {
var env tapiEnvelope
_ = json.Unmarshal(data, &env)
available = env.Available
current = env.Current
currency = env.Currency
if available == "" && current == "" {
// Nested under "balance" or "balances".
var nested struct {
Balance struct {
Available string `json:"available"`
Current string `json:"current"`
Currency string `json:"currency"`
} `json:"balance"`
Balances []struct {
Available string `json:"available"`
Current string `json:"current"`
Currency string `json:"currency"`
} `json:"balances"`
}
_ = json.Unmarshal(data, &nested)
if nested.Balance.Current != "" || nested.Balance.Available != "" {
return nested.Balance.Available, nested.Balance.Current, nested.Balance.Currency
}
if len(nested.Balances) > 0 {
b := nested.Balances[0]
return b.Available, b.Current, b.Currency
}
}
return available, current, currency
}
func parseFundMoveResponse(data []byte) (refNum, status string) {
var env tapiEnvelope
_ = json.Unmarshal(data, &env)
refNum = firstNonEmpty(env.RefNum, env.RefNumLC)
status = firstNonEmpty(env.Status, env.StatusDesc2, env.StatusDesc)
if refNum == "" {
// Try nested data shape.
var nested struct {
RefNum string `json:"RefNum"`
ReferenceNumber string `json:"referenceNumber"`
Status string `json:"status"`
}
_ = json.Unmarshal(env.Data, &nested)
refNum = firstNonEmpty(nested.RefNum, nested.ReferenceNumber)
if status == "" {
status = nested.Status
}
}
return refNum, status
}
func parseFundMoveDetail(data []byte) (id, status, amount, currency string) {
var env tapiEnvelope
_ = json.Unmarshal(data, &env)
id = firstNonEmpty(env.RefNum, env.RefNumLC, env.AccountID)
status = firstNonEmpty(env.Status, env.StatusDesc2, env.StatusDesc)
amount = env.Amount
currency = env.Currency
if id == "" {
var nested struct {
RefNum string `json:"RefNum"`
ReferenceNumber string `json:"referenceNumber"`
Status string `json:"status"`
Amount string `json:"amount"`
Currency string `json:"currency"`
}
_ = json.Unmarshal(env.Data, &nested)
id = firstNonEmpty(nested.RefNum, nested.ReferenceNumber)
if status == "" {
status = nested.Status
}
if amount == "" {
amount = nested.Amount
}
if currency == "" {
currency = nested.Currency
}
}
return id, status, amount, currency
}
func parsePaymentListResponse(data []byte) (pays []*types.Payment, more bool) {
var shaped struct {
Data []struct {
RefNum string `json:"RefNum"`
ReferenceNumber string `json:"referenceNumber"`
Status string `json:"status"`
Amount string `json:"amount"`
Currency string `json:"currency"`
} `json:"data"`
FundMoves []struct {
RefNum string `json:"RefNum"`
ReferenceNumber string `json:"referenceNumber"`
Status string `json:"status"`
Amount string `json:"amount"`
Currency string `json:"currency"`
} `json:"fundMoves"`
}
_ = json.Unmarshal(data, &shaped)
rows := shaped.Data
if len(rows) == 0 {
rows = shaped.FundMoves
}
for _, r := range rows {
id := firstNonEmpty(r.RefNum, r.ReferenceNumber)
pays = append(pays, &types.Payment{
ID: id,
Provider: providerName,
ProviderID: id,
Amount: r.Amount,
Currency: firstNonEmpty(r.Currency, "USD"),
Status: mapPaymentStatus(r.Status),
})
}
more = len(pays) > 0
return pays, more
}
func parsePartyResponse(data []byte) string {
var env tapiEnvelope
_ = json.Unmarshal(data, &env)
if env.PartyID != "" {
return env.PartyID
}
var nested struct {
PartyDetails []struct {
PartyID string `json:"partyId"`
} `json:"partyDetails"`
Party struct {
PartyID string `json:"partyId"`
} `json:"party"`
}
_ = json.Unmarshal(env.Data, &nested)
if nested.Party.PartyID != "" {
return nested.Party.PartyID
}
if len(nested.PartyDetails) > 0 {
return nested.PartyDetails[0].PartyID
}
// Fallback — some sandbox responses return partyId at the root.
var rootMap map[string]any
_ = json.Unmarshal(data, &rootMap)
if v, ok := rootMap["partyId"].(string); ok {
return v
}
return ""
}
func parsePartyListResponse(data []byte) (cps []*types.Counterparty, more bool) {
var shaped struct {
Data []struct {
PartyID string `json:"partyId"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"emailAddress"`
} `json:"data"`
PartyDetails []struct {
PartyID string `json:"partyId"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"emailAddress"`
} `json:"partyDetails"`
}
_ = json.Unmarshal(data, &shaped)
rows := shaped.Data
if len(rows) == 0 {
rows = shaped.PartyDetails
}
for _, r := range rows {
name := r.FirstName
if r.LastName != "" {
if name != "" {
name += " "
}
name += r.LastName
}
cps = append(cps, &types.Counterparty{
ID: r.PartyID,
Name: name,
Email: r.Email,
})
}
more = len(cps) > 0
return cps, more
}
// mapPaymentStatus translates TAPI status strings (Pending, Settled,
// Returned, Submitted, Voided, Declined) onto the unified
// PaymentStatus enum. Anything unrecognized passes through as
// PaymentPending — safer than dropping the payment off the ledger.
func mapPaymentStatus(s string) types.PaymentStatus {
switch s {
case "Settled", "settled", "Completed", "completed":
return types.PaymentCompleted
case "Returned", "returned":
return types.PaymentReturned
case "Submitted", "submitted", "Processing", "processing":
return types.PaymentProcessing
case "Voided", "voided", "Cancelled", "cancelled", "Canceled", "canceled":
return types.PaymentCancelled
case "Declined", "declined", "Failed", "failed":
return types.PaymentFailed
case "Pending", "pending", "":
return types.PaymentPending
default:
return types.PaymentPending
}
}
// --- small string helpers ---
func firstNonEmpty(vs ...string) string {
for _, v := range vs {
if v != "" {
return v
}
}
return ""
}
func metaOrDefault(m map[string]string, k, def string) string {
if v, ok := m[k]; ok && v != "" {
return v
}
return def
}
func splitName(full string) (first, last string) {
for i := 0; i < len(full); i++ {
if full[i] == ' ' {
return full[:i], full[i+1:]
}
}
return full, ""
}