mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
Pass 2 — every endpoint method is fully wired against the TransactAPI
public REST surface; the encrypted-webhook consumer is fully
implemented per the documented AES-256-CBC envelope; the conformance
suite drives every endpoint against httptest.Server fixtures with no
production HTTP traffic.
Endpoints (all fully implemented, no stubs):
Party / Entity
CreateParty, CreateEntity, GetParty, ListParties (paginated)
KYC / AML / Accredited / Suitability
PerformKYC, PerformAML
PerformAccredited (income, net_worth, third_party_letter,
professional_license) + PerformAccreditedFull
Suitability
Offerings
CreateOffering, GetOffering, ListOfferings (paginated)
Trades
CreateTrade, GetTrade, BulkUploadTrades (multipart),
CancelTrade, RefundTrade,
SendSubscriptionDocs, GetTradeDocuments
Custody
OpenCustodyAccount, GetCustodyAccount
ATS / Secondary
PublishATSEvent (uses EventID as the natural idem-key),
GetSecondaryTrades
Admin
UpdateWebhookAuthKey
Webhook
ConsumeWebhook — full AES-256-CBC decryption per the documented
TransactAPI scheme: URL-safe Base64 decode, undo "plusencr" → "+"
substitution, split 16-byte IV, AES-256-CBC decrypt, PKCS#7 unpad
(constant-time), parse "k=v, k=v, …" plaintext into the broker-wide
WebhookEvent shape. HMAC-SHA256 signature gate runs when
WebhookAuthKey is configured, with comma-separated key rotation
overlap support.
Cross-cutting:
- Uniform pagination: list endpoints iterate offset/limit internally
until a short page or MaxRecords cap; callers receive complete
result sets without writing pagination logic.
- Rate-limiting + retry: exponential backoff with full jitter on 429
and 5xx, honoring Retry-After (delta-seconds and HTTP-date); after
the retry budget surfaces wrapped ErrRateLimited.
- Idempotency: deriveIdempotencyKey(orgID, endpoint, canonicalBody)
SHA-256 fallback on every mutating call; caller-supplied
IdempotencyKey always wins. ATS PublishATSEvent uses EventID
directly so duplicate events collapse upstream.
- Authentication: clientID + developerAPIKey injected as form fields
on every legacy v1 request; Bearer + X-Client-Id on the modern v3
transport.
- APIError carries StatusCode, Endpoint, Body, RetryAfter for
errors.As recovery; envelope-level TransactAPI failures
(statusCode != "101") are surfaced through the same shape.
Conformance suite (broker/pkg/provider/northcapital/conformance_test.go):
- Every endpoint's happy path with form-field + idempotency-header
assertions
- All four accredited methods exercised independently
- Bulk upload exercises the multipart/form-data path
- Pagination iteration across multiple pages
- 4xx mapping → *APIError; envelope-level errors mapped identically
- 429 retry-then-succeed; 429 budget exhausted → ErrRateLimited
- Retry-After parsing
- Webhook signature: missing, bad, valid, rotation overlap
- Webhook decryption: valid envelope, wrong key, invalid base64,
plusencr substitution path, missing config
- PKCS#7 unpadder unit tests (positive + 4 negative cases)
Build green: go build ./..., go vet ./..., go test -race ./pkg/provider/
northcapital/... all pass. 40 test cases pass, 0 failures.
Assumptions pinned for the NCPS sandbox handshake:
1. Legacy v1 endpoints use form-encoded bodies with clientID +
developerAPIKey carried as form fields on every call. Confirmed
against the public reference; pinned if the sandbox key issuance
diverges.
2. The webhook HMAC ordering is decrypt-then-MAC over the raw
envelope bytes. The public encrypted-webhooks guide is silent on
this point; if the sandbox reveals MAC-then-decrypt, swap the
ordering in ConsumeWebhook.
3. The X-TransactAPI-Signature header name is the Lux-side
convention pinned in the integration brief §7; remap if the
sandbox uses a different upstream name.
4. WebhookDecryptionKey is required to be exactly 32 bytes; the
public guide says NCPS pads server-side to 32, but the adapter
enforces 32 on the inbound to fail closed.
Source-of-design: Public-Spec
Source-ref: https://transactapi.readme.io/reference
Clean-Team-Member: dev-agent (pass 2 — full implementation)
Signed-off-by: dev <[email protected]>
133 lines
4.2 KiB
Go
133 lines
4.2 KiB
Go
// Custody account opening — TransactAPI Workstream 6.
|
|
//
|
|
// Endpoints:
|
|
// POST /tapiv3/index.php/v1/createCustodyAccountRequest
|
|
// POST /tapiv3/index.php/v1/getCustodyAccountRequest
|
|
//
|
|
// The custody-account-opening workflow per the documented Custody
|
|
// Account Opening Workflow guide proceeds in three steps:
|
|
//
|
|
// 1. Investor onboards as a Party (or Entity) — CreateParty /
|
|
// CreateEntity. KYC / AML / Accredited verification runs.
|
|
// 2. Custody-account request is submitted with the desired
|
|
// accountType (individual, joint, entity, ira). NCPS-side
|
|
// compliance review begins. Initial status is "pending".
|
|
// 3. NCPS compliance review completes; the account either opens
|
|
// (status "open") or is rejected (status "rejected"). Outcome is
|
|
// pushed via the encrypted webhook channel
|
|
// (account.opened / account.rejected); the adapter also exposes
|
|
// GetCustodyAccount for direct polling.
|
|
//
|
|
// Once open, the custody account is reconciled against the Lux
|
|
// captable identity registry; subsequent trades may settle into the
|
|
// custody account directly.
|
|
//
|
|
// Source-of-design: Public-Spec
|
|
// Source-ref: https://transactapi.readme.io/reference
|
|
|
|
package northcapital
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
)
|
|
|
|
type wireCustody struct {
|
|
RequestID string `json:"requestId"`
|
|
AccountID string `json:"accountId"`
|
|
PartyID string `json:"partyId"`
|
|
EntityID string `json:"entityId"`
|
|
AccountType string `json:"accountType"`
|
|
Status string `json:"status"`
|
|
OpenedDate string `json:"openedDate"`
|
|
ClosedDate string `json:"closedDate"`
|
|
}
|
|
|
|
// OpenCustodyAccount opens a custody account for the given Party (or
|
|
// Entity), of the requested account_type (individual / joint / entity / ira).
|
|
// The returned CustodyAccount carries the initial request ID; final
|
|
// open / rejected status arrives via webhook and may be polled via
|
|
// GetCustodyAccount.
|
|
func (p *Provider) OpenCustodyAccount(ctx context.Context, partyID string, req *CustodyOpenRequest) (*CustodyAccount, error) {
|
|
if partyID == "" {
|
|
return nil, errors.New("northcapital: partyID required")
|
|
}
|
|
if req == nil {
|
|
return nil, errors.New("northcapital: CustodyOpenRequest is required")
|
|
}
|
|
form := url.Values{}
|
|
form.Set("partyId", partyID)
|
|
form.Set("accountType", req.AccountType)
|
|
|
|
const path = "/tapiv3/index.php/v1/createCustodyAccountRequest"
|
|
idemKey := p.resolveIdemKey(req.IdempotencyKey, req.OrgID, path, canonicalForm(form))
|
|
raw, err := p.doForm(ctx, "POST", path, form, idemKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
env, err := decodeEnvelope(path, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return decodeCustody(env.Custody, "openCustodyAccount")
|
|
}
|
|
|
|
// GetCustodyAccount reads the status of a previously-submitted custody
|
|
// account request. Use this to poll the request between webhook events
|
|
// or to backfill state on adapter restart.
|
|
func (p *Provider) GetCustodyAccount(ctx context.Context, requestID string) (*CustodyAccount, error) {
|
|
if requestID == "" {
|
|
return nil, errors.New("northcapital: requestID required")
|
|
}
|
|
form := url.Values{}
|
|
form.Set("requestId", requestID)
|
|
const path = "/tapiv3/index.php/v1/getCustodyAccountRequest"
|
|
raw, err := p.doForm(ctx, "POST", path, form, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
env, err := decodeEnvelope(path, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return decodeCustody(env.Custody, "getCustodyAccount")
|
|
}
|
|
|
|
func decodeCustody(raw json.RawMessage, where string) (*CustodyAccount, error) {
|
|
if len(raw) == 0 {
|
|
return nil, fmt.Errorf("northcapital: %s: empty custodyAccountDetails", where)
|
|
}
|
|
var w wireCustody
|
|
if err := json.Unmarshal(raw, &w); err != nil {
|
|
var arr []wireCustody
|
|
if err2 := json.Unmarshal(raw, &arr); err2 == nil && len(arr) > 0 {
|
|
w = arr[0]
|
|
} else {
|
|
return nil, fmt.Errorf("northcapital: %s decode: %w", where, err)
|
|
}
|
|
}
|
|
ca := &CustodyAccount{
|
|
ID: w.AccountID,
|
|
RequestID: w.RequestID,
|
|
PartyID: w.PartyID,
|
|
EntityID: w.EntityID,
|
|
AccountType: w.AccountType,
|
|
Status: normalizeStatus(w.Status),
|
|
}
|
|
if w.Status == "" {
|
|
ca.Status = ""
|
|
}
|
|
if w.OpenedDate != "" {
|
|
t := parseDate(w.OpenedDate)
|
|
ca.OpenedAt = &t
|
|
}
|
|
if w.ClosedDate != "" {
|
|
t := parseDate(w.ClosedDate)
|
|
ca.ClosedAt = &t
|
|
}
|
|
return ca, nil
|
|
}
|