Files
dev 2b54eb1527 feat(provider/northcapital): full TransactAPI adapter implementation
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]>
2026-05-26 17:55:39 -07:00

315 lines
9.7 KiB
Go

// KYC / AML / Accredited / Suitability verification — Workstream 2.
//
// Endpoints:
// POST /tapiv3/index.php/v1/performKYC — identity + document
// POST /tapiv3/index.php/v1/performAml — sanctions / OFAC / SDN / PEP
// POST /tapiv3/index.php/v1/performAccredited — Reg D 506(c) accredited
// POST /tapiv3/index.php/v1/recordSuitability — suitability questionnaire
//
// Lux's amld overlay (luxfi/amld) layers an independent screen on top
// of these results, per §6 of the integration brief. Where the two
// disagree, the conservative path (block + escalate) wins.
//
// Source-of-design: Public-Spec
// Source-ref: https://transactapi.readme.io/reference
package northcapital
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"time"
)
// wireKYC mirrors the documented kycDetails block (status / reasons /
// score), independent of the Lux-side normalization.
type wireKYC struct {
PartyID string `json:"partyId"`
Status string `json:"kycStatus"`
Score int `json:"score"`
Reasons []string `json:"reasons"`
RunAt string `json:"runAt"`
ResponseID string `json:"responseId"`
}
type wireAML struct {
PartyID string `json:"partyId"`
Status string `json:"amlStatus"`
Hits []wireAMLHit `json:"hits"`
RunAt string `json:"runAt"`
}
type wireAMLHit struct {
List string `json:"list"`
Name string `json:"name"`
MatchScore float64 `json:"matchScore"`
Reference string `json:"reference"`
}
type wireAccredited struct {
PartyID string `json:"partyId"`
Method string `json:"method"`
Status string `json:"status"`
VerifiedAt string `json:"verifiedAt"`
ExpiresAt string `json:"expiresAt"`
}
type wireSuitability struct {
PartyID string `json:"partyId"`
Status string `json:"status"`
RiskTolerance string `json:"riskTolerance"`
InvestmentHorizon string `json:"investmentHorizon"`
NetWorth string `json:"netWorth"`
AnnualIncome string `json:"annualIncome"`
LiquidityNeeds string `json:"liquidityNeeds"`
Reasons []string `json:"reasons"`
RecordedAt string `json:"recordedAt"`
}
// PerformKYC runs identity + document verification on a Party.
func (p *Provider) PerformKYC(ctx context.Context, partyID string) (*KYCResult, error) {
if partyID == "" {
return nil, errors.New("northcapital: partyID required")
}
form := url.Values{}
form.Set("partyId", partyID)
const path = "/tapiv3/index.php/v1/performKYC"
idemKey := p.resolveIdemKey("", partyID, 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.KYC) == 0 {
return nil, fmt.Errorf("northcapital: performKYC: empty kycDetails")
}
var w wireKYC
if err := json.Unmarshal(env.KYC, &w); err != nil {
// Try array form (legacy variant).
var arr []wireKYC
if err2 := json.Unmarshal(env.KYC, &arr); err2 == nil && len(arr) > 0 {
w = arr[0]
} else {
return nil, fmt.Errorf("northcapital: performKYC decode: %w", err)
}
}
return &KYCResult{
PartyID: w.PartyID,
Status: normalizeStatus(w.Status),
Score: w.Score,
Reasons: w.Reasons,
RunAt: firstParseable(w.RunAt),
}, nil
}
// PerformAML runs sanctions / OFAC / SDN / PEP screening on a Party.
func (p *Provider) PerformAML(ctx context.Context, partyID string) (*AMLResult, error) {
if partyID == "" {
return nil, errors.New("northcapital: partyID required")
}
form := url.Values{}
form.Set("partyId", partyID)
const path = "/tapiv3/index.php/v1/performAml"
idemKey := p.resolveIdemKey("", partyID, 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.AML) == 0 {
return nil, fmt.Errorf("northcapital: performAML: empty amlDetails")
}
var w wireAML
if err := json.Unmarshal(env.AML, &w); err != nil {
var arr []wireAML
if err2 := json.Unmarshal(env.AML, &arr); err2 == nil && len(arr) > 0 {
w = arr[0]
} else {
return nil, fmt.Errorf("northcapital: performAML decode: %w", err)
}
}
hits := make([]AMLHit, 0, len(w.Hits))
for _, h := range w.Hits {
hits = append(hits, AMLHit{
List: h.List,
Name: h.Name,
MatchScore: h.MatchScore,
Reference: h.Reference,
})
}
return &AMLResult{
PartyID: w.PartyID,
Status: normalizeStatus(w.Status),
Hits: hits,
RunAt: firstParseable(w.RunAt),
}, nil
}
// PerformAccredited runs Reg D 506(c) accredited-investor verification.
// The five documented methods (income / net-worth / third-party letter
// / professional license / joint accredited) all flow through the
// single endpoint with the appropriate supporting fields.
func (p *Provider) PerformAccredited(ctx context.Context, partyID string, method AccreditedMethod) (*AccreditedResult, error) {
return p.PerformAccreditedFull(ctx, &AccreditedRequest{
PartyID: partyID,
Method: method,
})
}
// PerformAccreditedFull is the explicit-options variant. Use this when
// supplying documentation refs (third-party letter document ID, income
// year, etc.).
func (p *Provider) PerformAccreditedFull(ctx context.Context, req *AccreditedRequest) (*AccreditedResult, error) {
if req == nil || req.PartyID == "" {
return nil, errors.New("northcapital: partyID required")
}
form := url.Values{}
form.Set("partyId", req.PartyID)
form.Set("method", string(req.Method))
if req.DocumentID != "" {
form.Set("documentId", req.DocumentID)
}
if req.LicenseRef != "" {
form.Set("licenseRef", req.LicenseRef)
}
if req.IncomeYear != 0 {
form.Set("incomeYear", strconv.Itoa(req.IncomeYear))
}
if req.JointSpouseID != "" {
form.Set("jointSpousePartyId", req.JointSpouseID)
}
if req.ThirdPartyEmail != "" {
form.Set("thirdPartyEmail", req.ThirdPartyEmail)
}
const path = "/tapiv3/index.php/v1/performAccredited"
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.Accredited) == 0 {
return nil, fmt.Errorf("northcapital: performAccredited: empty accreditedDetails")
}
var w wireAccredited
if err := json.Unmarshal(env.Accredited, &w); err != nil {
var arr []wireAccredited
if err2 := json.Unmarshal(env.Accredited, &arr); err2 == nil && len(arr) > 0 {
w = arr[0]
} else {
return nil, fmt.Errorf("northcapital: performAccredited decode: %w", err)
}
}
r := &AccreditedResult{
PartyID: w.PartyID,
Method: AccreditedMethod(w.Method),
Status: normalizeStatus(w.Status),
}
if w.VerifiedAt != "" {
t := firstParseable(w.VerifiedAt)
r.VerifiedAt = &t
}
if w.ExpiresAt != "" {
t := firstParseable(w.ExpiresAt)
r.ExpiresAt = &t
}
return r, nil
}
// Suitability records a suitability questionnaire result for a Party.
// Suitability is a discrete workstream from KYC/AML/Accredited — it
// captures the risk-tolerance / investment-horizon / net-worth /
// liquidity-needs profile used for trade-level appropriateness checks.
func (p *Provider) Suitability(ctx context.Context, req *SuitabilityRequest) (*SuitabilityResult, error) {
if req == nil || req.PartyID == "" {
return nil, errors.New("northcapital: partyID required")
}
form := url.Values{}
form.Set("partyId", req.PartyID)
if req.RiskTolerance != "" {
form.Set("riskTolerance", req.RiskTolerance)
}
if req.InvestmentHorizon != "" {
form.Set("investmentHorizon", req.InvestmentHorizon)
}
if req.NetWorth != "" {
form.Set("netWorth", req.NetWorth)
}
if req.AnnualIncome != "" {
form.Set("annualIncome", req.AnnualIncome)
}
if req.LiquidityNeeds != "" {
form.Set("liquidityNeeds", req.LiquidityNeeds)
}
const path = "/tapiv3/index.php/v1/recordSuitability"
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.Suitability) == 0 {
return nil, fmt.Errorf("northcapital: suitability: empty suitabilityDetails")
}
var w wireSuitability
if err := json.Unmarshal(env.Suitability, &w); err != nil {
var arr []wireSuitability
if err2 := json.Unmarshal(env.Suitability, &arr); err2 == nil && len(arr) > 0 {
w = arr[0]
} else {
return nil, fmt.Errorf("northcapital: suitability decode: %w", err)
}
}
return &SuitabilityResult{
PartyID: w.PartyID,
Status: normalizeStatus(w.Status),
RiskTolerance: w.RiskTolerance,
InvestmentHorizon: w.InvestmentHorizon,
NetWorth: w.NetWorth,
AnnualIncome: w.AnnualIncome,
LiquidityNeeds: w.LiquidityNeeds,
Reasons: w.Reasons,
RecordedAt: firstParseable(w.RecordedAt),
}, nil
}
// firstParseable runs parseDate but returns the zero value if every
// layout fails. Public helper to keep the per-endpoint files small.
func firstParseable(s string) time.Time { return parseDate(s) }
// normalizeStatus maps TransactAPI's varied status strings into the
// adapter's canonical (pass / fail / manual_review / pending) set so
// downstream consumers don't write per-endpoint matchers.
func normalizeStatus(s string) string {
switch s {
case "Approved", "approved", "Pass", "pass", "Verified", "verified":
return "pass"
case "Disapproved", "disapproved", "Failed", "failed", "Fail", "fail":
return "fail"
case "Pending", "pending", "Manual Review", "manual_review", "manualReview", "Review":
return "manual_review"
case "Expired", "expired":
return "expired"
case "":
return ""
default:
return s
}
}