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]>
412 lines
13 KiB
Go
412 lines
13 KiB
Go
// Party / Entity onboarding — TransactAPI Workstream 1.
|
|
//
|
|
// Endpoints (per the integration brief §1 row 1):
|
|
// POST /tapiv3/index.php/v1/createParty — natural-person / joint onboarding
|
|
// POST /tapiv3/index.php/v1/createEntity — legal-entity onboarding
|
|
// GET /tapiv3/index.php/v1/getParty — read by partyId
|
|
// GET /tapiv3/index.php/v1/getAllParties — directory listing, capped at 500/page
|
|
//
|
|
// All four use form-encoded bodies with clientID + developerAPIKey
|
|
// injected by the form transport. Response envelopes are decoded by
|
|
// the local helpers and projected into the package-local Party /
|
|
// Entity types so downstream consumers stay independent of the wire
|
|
// shape.
|
|
//
|
|
// Source-of-design: Public-Spec
|
|
// Source-ref: https://transactapi.readme.io/reference
|
|
|
|
package northcapital
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// Wire shapes for the form-encoded party endpoints. These mirror the
|
|
// documented field names exactly so the form encoding round-trips
|
|
// without translation tables in the call path.
|
|
|
|
type wireParty struct {
|
|
PartyID string `json:"partyId"`
|
|
FirstName string `json:"firstName"`
|
|
LastName string `json:"lastName"`
|
|
EmailAddress string `json:"emailAddress"`
|
|
Phone string `json:"phone"`
|
|
DOB string `json:"dob"`
|
|
SocialSecurityNumber string `json:"socialSecurityNumber"`
|
|
TaxIDNumber string `json:"taxIdNumber"`
|
|
Domicile string `json:"domicile"`
|
|
Citizenship string `json:"citizenship"`
|
|
PrimAddress1 string `json:"primAddress1"`
|
|
PrimAddress2 string `json:"primAddress2"`
|
|
PrimCity string `json:"primCity"`
|
|
PrimState string `json:"primState"`
|
|
PrimZip string `json:"primZip"`
|
|
PrimCountry string `json:"primCountry"`
|
|
KYCStatus string `json:"kycStatus"`
|
|
AMLStatus string `json:"amlStatus"`
|
|
CreatedDate string `json:"createdDate"`
|
|
UpdatedDate string `json:"updatedDate"`
|
|
}
|
|
|
|
type wireEntity struct {
|
|
EntityID string `json:"entityId"`
|
|
EntityName string `json:"entityName"`
|
|
EntityType string `json:"entityType"`
|
|
DomicileCountry string `json:"domicileCountry"`
|
|
DomicileState string `json:"domicileState"`
|
|
EIN string `json:"ein"`
|
|
PrimAddress1 string `json:"primAddress1"`
|
|
PrimAddress2 string `json:"primAddress2"`
|
|
PrimCity string `json:"primCity"`
|
|
PrimState string `json:"primState"`
|
|
PrimZip string `json:"primZip"`
|
|
PrimCountry string `json:"primCountry"`
|
|
KYCStatus string `json:"kycStatus"`
|
|
AMLStatus string `json:"amlStatus"`
|
|
CreatedDate string `json:"createdDate"`
|
|
UpdatedDate string `json:"updatedDate"`
|
|
}
|
|
|
|
// envelope is the TransactAPI standard reply wrapper. statusCode "101"
|
|
// is success; everything else is an application-level error surfaced
|
|
// as APIError via decodeEnvelope.
|
|
type envelope struct {
|
|
StatusCode string `json:"statusCode"`
|
|
StatusDesc string `json:"statusDesc"`
|
|
PartyDetails json.RawMessage `json:"partyDetails"`
|
|
EntityDetails json.RawMessage `json:"entityDetails"`
|
|
Offering json.RawMessage `json:"offeringDetails"`
|
|
Offerings json.RawMessage `json:"offeringsDetails"`
|
|
Trade json.RawMessage `json:"tradeDetails"`
|
|
Trades json.RawMessage `json:"tradesDetails"`
|
|
Custody json.RawMessage `json:"custodyAccountDetails"`
|
|
Secondary json.RawMessage `json:"secondaryTradesDetails"`
|
|
Documents json.RawMessage `json:"documentDetails"`
|
|
KYC json.RawMessage `json:"kycDetails"`
|
|
AML json.RawMessage `json:"amlDetails"`
|
|
Accredited json.RawMessage `json:"accreditedDetails"`
|
|
Suitability json.RawMessage `json:"suitabilityDetails"`
|
|
BulkResult json.RawMessage `json:"bulkResult"`
|
|
ATSResult json.RawMessage `json:"atsResult"`
|
|
Refund json.RawMessage `json:"refundDetails"`
|
|
WebhookKey json.RawMessage `json:"webhookKeyDetails"`
|
|
}
|
|
|
|
// decodeEnvelope unmarshals raw bytes and verifies the TransactAPI
|
|
// status code. Returns the envelope (so the caller can pull the
|
|
// type-specific raw segment) or a wrapped APIError on failure.
|
|
func decodeEnvelope(path string, raw []byte) (*envelope, error) {
|
|
var env envelope
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return nil, fmt.Errorf("northcapital: decode envelope %s: %w", path, err)
|
|
}
|
|
if env.StatusCode != "" && env.StatusCode != "101" {
|
|
return nil, &APIError{
|
|
StatusCode: 400,
|
|
Endpoint: path,
|
|
Body: fmt.Sprintf("statusCode=%s statusDesc=%s", env.StatusCode, env.StatusDesc),
|
|
}
|
|
}
|
|
return &env, nil
|
|
}
|
|
|
|
// CreateParty onboards a natural-person (or joint) investor.
|
|
func (p *Provider) CreateParty(ctx context.Context, req *CreatePartyRequest) (*Party, error) {
|
|
if req == nil {
|
|
return nil, errors.New("northcapital: CreatePartyRequest is required")
|
|
}
|
|
form := url.Values{}
|
|
form.Set("firstName", req.GivenName)
|
|
form.Set("lastName", req.FamilyName)
|
|
form.Set("emailAddress", req.Email)
|
|
if req.Phone != "" {
|
|
form.Set("phone", req.Phone)
|
|
}
|
|
if req.DateOfBirth != "" {
|
|
form.Set("dob", req.DateOfBirth)
|
|
}
|
|
if req.TaxID != "" {
|
|
switch req.TaxIDType {
|
|
case "ssn":
|
|
form.Set("socialSecurityNumber", req.TaxID)
|
|
default:
|
|
form.Set("taxIdNumber", req.TaxID)
|
|
}
|
|
}
|
|
if req.Domicile != "" {
|
|
form.Set("domicile", req.Domicile)
|
|
}
|
|
if req.Citizenship != "" {
|
|
form.Set("citizenship", req.Citizenship)
|
|
}
|
|
if req.Address != nil {
|
|
form.Set("primAddress1", req.Address.Street1)
|
|
if req.Address.Street2 != "" {
|
|
form.Set("primAddress2", req.Address.Street2)
|
|
}
|
|
form.Set("primCity", req.Address.City)
|
|
form.Set("primState", req.Address.State)
|
|
form.Set("primZip", req.Address.PostalCode)
|
|
form.Set("primCountry", req.Address.Country)
|
|
}
|
|
|
|
const path = "/tapiv3/index.php/v1/createParty"
|
|
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
|
|
}
|
|
if len(env.PartyDetails) == 0 {
|
|
return nil, fmt.Errorf("northcapital: createParty: empty partyDetails")
|
|
}
|
|
// partyDetails is sometimes a single object, sometimes a single-element array
|
|
// depending on the legacy endpoint. Try array first.
|
|
var arr []wireParty
|
|
if err := json.Unmarshal(env.PartyDetails, &arr); err == nil && len(arr) > 0 {
|
|
return wirePartyToParty(req.Type, &arr[0]), nil
|
|
}
|
|
var one wireParty
|
|
if err := json.Unmarshal(env.PartyDetails, &one); err != nil {
|
|
return nil, fmt.Errorf("northcapital: createParty: decode party: %w", err)
|
|
}
|
|
return wirePartyToParty(req.Type, &one), nil
|
|
}
|
|
|
|
// CreateEntity onboards a legal-entity (LLC / corp / trust / partnership /
|
|
// SPV) investor. Requires beneficial-owner + control-person partyIds.
|
|
func (p *Provider) CreateEntity(ctx context.Context, req *CreateEntityRequest) (*Entity, error) {
|
|
if req == nil {
|
|
return nil, errors.New("northcapital: CreateEntityRequest is required")
|
|
}
|
|
form := url.Values{}
|
|
form.Set("entityName", req.LegalName)
|
|
form.Set("entityType", req.EntityType)
|
|
form.Set("domicileCountry", req.FormationCountry)
|
|
if req.FormationState != "" {
|
|
form.Set("domicileState", req.FormationState)
|
|
}
|
|
if req.EIN != "" {
|
|
form.Set("ein", req.EIN)
|
|
}
|
|
if req.Address != nil {
|
|
form.Set("primAddress1", req.Address.Street1)
|
|
if req.Address.Street2 != "" {
|
|
form.Set("primAddress2", req.Address.Street2)
|
|
}
|
|
form.Set("primCity", req.Address.City)
|
|
form.Set("primState", req.Address.State)
|
|
form.Set("primZip", req.Address.PostalCode)
|
|
form.Set("primCountry", req.Address.Country)
|
|
}
|
|
for i, bo := range req.Beneficials {
|
|
form.Set("beneficialOwner"+strconv.Itoa(i+1), bo)
|
|
}
|
|
for i, cp := range req.ControlPersons {
|
|
form.Set("controlPerson"+strconv.Itoa(i+1), cp)
|
|
}
|
|
|
|
const path = "/tapiv3/index.php/v1/createEntity"
|
|
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
|
|
}
|
|
if len(env.EntityDetails) == 0 {
|
|
return nil, fmt.Errorf("northcapital: createEntity: empty entityDetails")
|
|
}
|
|
var arr []wireEntity
|
|
if err := json.Unmarshal(env.EntityDetails, &arr); err == nil && len(arr) > 0 {
|
|
return wireEntityToEntity(&arr[0], req.Beneficials, req.ControlPersons), nil
|
|
}
|
|
var one wireEntity
|
|
if err := json.Unmarshal(env.EntityDetails, &one); err != nil {
|
|
return nil, fmt.Errorf("northcapital: createEntity: decode entity: %w", err)
|
|
}
|
|
return wireEntityToEntity(&one, req.Beneficials, req.ControlPersons), nil
|
|
}
|
|
|
|
// GetParty reads a Party by TransactAPI partyId.
|
|
func (p *Provider) GetParty(ctx context.Context, partyID string) (*Party, error) {
|
|
if partyID == "" {
|
|
return nil, errors.New("northcapital: partyID required")
|
|
}
|
|
form := url.Values{}
|
|
form.Set("partyId", partyID)
|
|
const path = "/tapiv3/index.php/v1/getParty"
|
|
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
|
|
}
|
|
if len(env.PartyDetails) == 0 {
|
|
return nil, fmt.Errorf("northcapital: getParty: not found")
|
|
}
|
|
var arr []wireParty
|
|
if err := json.Unmarshal(env.PartyDetails, &arr); err == nil && len(arr) > 0 {
|
|
return wirePartyToParty(PartyIndividual, &arr[0]), nil
|
|
}
|
|
var one wireParty
|
|
if err := json.Unmarshal(env.PartyDetails, &one); err != nil {
|
|
return nil, fmt.Errorf("northcapital: getParty: decode: %w", err)
|
|
}
|
|
return wirePartyToParty(PartyIndividual, &one), nil
|
|
}
|
|
|
|
// ListParties returns all Parties accessible to this clientID. The
|
|
// adapter handles pagination internally per the documented
|
|
// "getAllParties" cap (500 records/page since the Jan 13 change). No
|
|
// caller writes pagination logic.
|
|
func (p *Provider) ListParties(ctx context.Context) ([]*Party, error) {
|
|
return p.ListPartiesPaged(ctx, ListOptions{})
|
|
}
|
|
|
|
// ListPartiesPaged is the explicit-option variant of ListParties.
|
|
func (p *Provider) ListPartiesPaged(ctx context.Context, opts ListOptions) ([]*Party, error) {
|
|
const path = "/tapiv3/index.php/v1/getAllParties"
|
|
pageSize := opts.PageSize
|
|
if pageSize == 0 {
|
|
pageSize = 500 // upstream cap for legacy "get all" endpoints
|
|
}
|
|
|
|
var out []*Party
|
|
for page := 1; ; page++ {
|
|
form := url.Values{}
|
|
form.Set("offset", strconv.Itoa((page-1)*pageSize))
|
|
form.Set("limit", strconv.Itoa(pageSize))
|
|
for k, v := range opts.Filter {
|
|
form.Set(k, v)
|
|
}
|
|
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
|
|
}
|
|
if len(env.PartyDetails) == 0 {
|
|
break
|
|
}
|
|
var arr []wireParty
|
|
if err := json.Unmarshal(env.PartyDetails, &arr); err != nil {
|
|
return nil, fmt.Errorf("northcapital: listParties: decode page %d: %w", page, err)
|
|
}
|
|
if len(arr) == 0 {
|
|
break
|
|
}
|
|
for i := range arr {
|
|
out = append(out, wirePartyToParty(PartyIndividual, &arr[i]))
|
|
if opts.MaxRecords > 0 && len(out) >= opts.MaxRecords {
|
|
return out, nil
|
|
}
|
|
}
|
|
if len(arr) < pageSize {
|
|
break // short page → no more results
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// --- wire ↔ domain projections ---
|
|
|
|
func wirePartyToParty(typ PartyType, w *wireParty) *Party {
|
|
p := &Party{
|
|
ID: w.PartyID,
|
|
Type: typ,
|
|
GivenName: w.FirstName,
|
|
FamilyName: w.LastName,
|
|
Email: w.EmailAddress,
|
|
Phone: w.Phone,
|
|
DateOfBirth: w.DOB,
|
|
Domicile: w.Domicile,
|
|
Citizenship: w.Citizenship,
|
|
KYCStatus: w.KYCStatus,
|
|
AMLStatus: w.AMLStatus,
|
|
}
|
|
if w.SocialSecurityNumber != "" {
|
|
p.TaxIDType = "ssn"
|
|
p.TaxIDLast4 = lastN(w.SocialSecurityNumber, 4)
|
|
} else if w.TaxIDNumber != "" {
|
|
p.TaxIDType = "ein"
|
|
p.TaxIDLast4 = lastN(w.TaxIDNumber, 4)
|
|
}
|
|
if w.PrimAddress1 != "" || w.PrimCity != "" {
|
|
p.Address = &PartyAddress{
|
|
Street1: w.PrimAddress1,
|
|
Street2: w.PrimAddress2,
|
|
City: w.PrimCity,
|
|
State: w.PrimState,
|
|
PostalCode: w.PrimZip,
|
|
Country: w.PrimCountry,
|
|
}
|
|
}
|
|
p.CreatedAt = parseDate(w.CreatedDate)
|
|
p.UpdatedAt = parseDate(w.UpdatedDate)
|
|
return p
|
|
}
|
|
|
|
func wireEntityToEntity(w *wireEntity, beneficials, controls []string) *Entity {
|
|
e := &Entity{
|
|
ID: w.EntityID,
|
|
LegalName: w.EntityName,
|
|
EntityType: w.EntityType,
|
|
FormationCountry: w.DomicileCountry,
|
|
FormationState: w.DomicileState,
|
|
EIN: w.EIN,
|
|
KYCStatus: w.KYCStatus,
|
|
AMLStatus: w.AMLStatus,
|
|
Beneficials: beneficials,
|
|
ControlPersons: controls,
|
|
}
|
|
if w.PrimAddress1 != "" || w.PrimCity != "" {
|
|
e.Address = &PartyAddress{
|
|
Street1: w.PrimAddress1,
|
|
Street2: w.PrimAddress2,
|
|
City: w.PrimCity,
|
|
State: w.PrimState,
|
|
PostalCode: w.PrimZip,
|
|
Country: w.PrimCountry,
|
|
}
|
|
}
|
|
e.CreatedAt = parseDate(w.CreatedDate)
|
|
e.UpdatedAt = parseDate(w.UpdatedDate)
|
|
return e
|
|
}
|
|
|
|
// parseDate accepts both RFC3339 and TransactAPI's MM/DD/YYYY form.
|
|
func parseDate(s string) time.Time {
|
|
if s == "" {
|
|
return time.Time{}
|
|
}
|
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "01/02/2006", "2006-01-02"} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func lastN(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[len(s)-n:]
|
|
}
|