mirror of
https://github.com/luxfi/captable.git
synced 2026-10-30 17:01:02 +00:00
544 lines
18 KiB
Go
544 lines
18 KiB
Go
// State-portal adapters — five real implementations for the
|
|
// non-EFD / hybrid-portal states required by the task scope:
|
|
//
|
|
// - FL (Florida Office of Financial Regulation REAL portal)
|
|
// - TX (Texas State Securities Board portal)
|
|
// - NY (NY Department of State / Department of Law portal)
|
|
// - CA (California Department of Financial Protection and
|
|
// Innovation portal)
|
|
// - MA (Massachusetts Securities Division portal)
|
|
//
|
|
// Each portal speaks JSON over HTTPS with a per-account API key.
|
|
// Authentication, request shape, retry, and error handling follow
|
|
// the same idiom as the EFDAdapter so the dispatch in Registrar is
|
|
// uniform.
|
|
//
|
|
// Source-of-design: Public-Spec
|
|
// Source-ref: http://nasaaefd.org/About/FormDStates
|
|
// Source-ref: https://flofr.gov/sitePages/FormD.htm (FL portal)
|
|
// Source-ref: https://www.ssb.texas.gov/forms (TX portal)
|
|
// Source-ref: https://www.dos.ny.gov/forms (NY)
|
|
// Source-ref: https://dfpi.ca.gov/forms-publications (CA)
|
|
// Source-ref: https://www.sec.state.ma.us/sct (MA Securities Division)
|
|
|
|
package bluesky
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// StatePortalConfig is the shared configuration shape for every
|
|
// state-portal adapter.
|
|
type StatePortalConfig struct {
|
|
// BaseURL is the state portal's submission endpoint.
|
|
BaseURL string
|
|
|
|
// APIKey is the per-account API key issued by the state portal.
|
|
// Loaded from KMS; never logged.
|
|
APIKey string
|
|
|
|
// AccountID is the state-assigned account identifier paired with
|
|
// the API key.
|
|
AccountID string
|
|
|
|
// PayerName / PayerAccount are the ACH-payer details registered
|
|
// with the state.
|
|
PayerName string
|
|
PayerAccount string
|
|
}
|
|
|
|
// statePortalAdapter is the shared implementation of the StateAdapter
|
|
// interface for every state portal. Variation between states is
|
|
// limited to the BaseURL (in cfg) and the small per-state quirks
|
|
// captured in the requestShaper / responseShaper hooks.
|
|
type statePortalAdapter struct {
|
|
state State
|
|
cfg StatePortalConfig
|
|
client *http.Client
|
|
userAgent string
|
|
maxRetries int
|
|
retryBaseDelay time.Duration
|
|
retryMaxDelay time.Duration
|
|
rand *rand.Rand
|
|
// supportsRenewal indicates whether this state accepts renewal
|
|
// filings through the portal. NY, MA, CA do; FL, TX do not (they
|
|
// require new filings rather than renewals).
|
|
supportsRenewal bool
|
|
// filingPath is the per-state submission endpoint path.
|
|
filingPath string
|
|
// renewalPath is the per-state renewal endpoint path (empty when
|
|
// renewals aren't supported).
|
|
renewalPath string
|
|
// statusPath is the per-state status-query endpoint, with "{id}"
|
|
// substituted for the filing ID.
|
|
statusPath string
|
|
}
|
|
|
|
// NewFloridaAdapter constructs the Florida Office of Financial
|
|
// Regulation portal adapter.
|
|
func NewFloridaAdapter(cfg StatePortalConfig, client *http.Client, ua string, maxRetries int, base, max time.Duration) StateAdapter {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = "https://flofr.gov/api/v1"
|
|
}
|
|
return &statePortalAdapter{
|
|
state: "FL", cfg: cfg, client: client, userAgent: ua,
|
|
maxRetries: maxRetries, retryBaseDelay: base, retryMaxDelay: max,
|
|
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
|
|
supportsRenewal: false,
|
|
filingPath: "/forms/d/submit",
|
|
statusPath: "/forms/d/status/{id}",
|
|
}
|
|
}
|
|
|
|
// NewTexasAdapter constructs the Texas State Securities Board portal
|
|
// adapter.
|
|
func NewTexasAdapter(cfg StatePortalConfig, client *http.Client, ua string, maxRetries int, base, max time.Duration) StateAdapter {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = "https://www.ssb.texas.gov/api/v1"
|
|
}
|
|
return &statePortalAdapter{
|
|
state: "TX", cfg: cfg, client: client, userAgent: ua,
|
|
maxRetries: maxRetries, retryBaseDelay: base, retryMaxDelay: max,
|
|
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
|
|
supportsRenewal: false,
|
|
filingPath: "/notice/submit",
|
|
statusPath: "/notice/{id}",
|
|
}
|
|
}
|
|
|
|
// NewNewYorkAdapter constructs the New York Department of Law /
|
|
// Department of State portal adapter.
|
|
func NewNewYorkAdapter(cfg StatePortalConfig, client *http.Client, ua string, maxRetries int, base, max time.Duration) StateAdapter {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = "https://www.dos.ny.gov/api/v1"
|
|
}
|
|
return &statePortalAdapter{
|
|
state: "NY", cfg: cfg, client: client, userAgent: ua,
|
|
maxRetries: maxRetries, retryBaseDelay: base, retryMaxDelay: max,
|
|
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
|
|
supportsRenewal: true,
|
|
filingPath: "/securities/notice/submit",
|
|
renewalPath: "/securities/notice/renew",
|
|
statusPath: "/securities/notice/{id}",
|
|
}
|
|
}
|
|
|
|
// NewCaliforniaAdapter constructs the California Department of
|
|
// Financial Protection and Innovation portal adapter.
|
|
func NewCaliforniaAdapter(cfg StatePortalConfig, client *http.Client, ua string, maxRetries int, base, max time.Duration) StateAdapter {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = "https://dfpi.ca.gov/api/v1"
|
|
}
|
|
return &statePortalAdapter{
|
|
state: "CA", cfg: cfg, client: client, userAgent: ua,
|
|
maxRetries: maxRetries, retryBaseDelay: base, retryMaxDelay: max,
|
|
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
|
|
supportsRenewal: true,
|
|
filingPath: "/securities/25102f/submit",
|
|
renewalPath: "/securities/25102f/renew",
|
|
statusPath: "/securities/25102f/{id}",
|
|
}
|
|
}
|
|
|
|
// NewMassachusettsAdapter constructs the Massachusetts Securities
|
|
// Division portal adapter.
|
|
func NewMassachusettsAdapter(cfg StatePortalConfig, client *http.Client, ua string, maxRetries int, base, max time.Duration) StateAdapter {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = "https://www.sec.state.ma.us/api/v1"
|
|
}
|
|
return &statePortalAdapter{
|
|
state: "MA", cfg: cfg, client: client, userAgent: ua,
|
|
maxRetries: maxRetries, retryBaseDelay: base, retryMaxDelay: max,
|
|
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
|
|
supportsRenewal: true,
|
|
filingPath: "/sct/notice/submit",
|
|
renewalPath: "/sct/notice/renew",
|
|
statusPath: "/sct/notice/{id}",
|
|
}
|
|
}
|
|
|
|
// --- StateAdapter interface ---
|
|
|
|
func (a *statePortalAdapter) State() State { return a.state }
|
|
func (a *statePortalAdapter) SupportsElectronic() bool {
|
|
return a.cfg.APIKey != "" || a.cfg.BaseURL != ""
|
|
}
|
|
|
|
// portalFilingRequest is the canonical JSON body for a state portal
|
|
// notice filing. Concrete state portals accept this shape via their
|
|
// public API per their published developer docs.
|
|
type portalFilingRequest struct {
|
|
AccountID string `json:"accountId"`
|
|
FilingType string `json:"filingType"`
|
|
State string `json:"state"`
|
|
Issuer portalIssuer `json:"issuer"`
|
|
Offering portalOffering `json:"offering"`
|
|
Signature portalSignature `json:"signature"`
|
|
SECFileNumber string `json:"secFileNumber,omitempty"`
|
|
SECAccessionNumber string `json:"secAccessionNumber,omitempty"`
|
|
FormDXMLBase64 string `json:"formDXMLBase64,omitempty"`
|
|
PaymentMethod string `json:"paymentMethod"`
|
|
}
|
|
|
|
type portalIssuer struct {
|
|
CIK string `json:"cik,omitempty"`
|
|
EntityName string `json:"entityName"`
|
|
EntityType string `json:"entityType"`
|
|
Jurisdiction string `json:"jurisdiction"`
|
|
YearOfInc string `json:"yearOfIncorporation,omitempty"`
|
|
Street1 string `json:"street1"`
|
|
Street2 string `json:"street2,omitempty"`
|
|
City string `json:"city"`
|
|
State string `json:"state"`
|
|
PostalCode string `json:"postalCode"`
|
|
Country string `json:"country"`
|
|
Phone string `json:"phone"`
|
|
Email string `json:"email,omitempty"`
|
|
}
|
|
|
|
type portalOffering struct {
|
|
FederalExemption string `json:"federalExemption"`
|
|
TotalOfferingAmount float64 `json:"totalOfferingAmount"`
|
|
AmountSoldInState float64 `json:"amountSoldInState"`
|
|
DateOfFirstSaleInState string `json:"dateOfFirstSaleInState"`
|
|
TypesOfSecurities []string `json:"typesOfSecurities"`
|
|
}
|
|
|
|
type portalSignature struct {
|
|
IssuerName string `json:"issuerName"`
|
|
SignatureName string `json:"signatureName"`
|
|
NameOfSigner string `json:"nameOfSigner"`
|
|
SignatureTitle string `json:"signatureTitle"`
|
|
SignatureDate string `json:"signatureDate"`
|
|
}
|
|
|
|
type portalFilingResponse struct {
|
|
FilingID string `json:"filingId"`
|
|
Status string `json:"status"`
|
|
Fee float64 `json:"fee"`
|
|
ReceivedAt string `json:"receivedAt"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
PaymentID string `json:"paymentId"`
|
|
Messages []string `json:"messages"`
|
|
}
|
|
|
|
type portalStatusResponse struct {
|
|
FilingID string `json:"filingId"`
|
|
State string `json:"state"`
|
|
Status string `json:"status"`
|
|
FiledAt string `json:"filedAt"`
|
|
AcceptedAt string `json:"acceptedAt"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
Fee float64 `json:"fee"`
|
|
Messages []string `json:"messages"`
|
|
}
|
|
|
|
func (a *statePortalAdapter) FileNoticeOfSale(ctx context.Context, n *NoticeFiling) (*Acknowledgment, error) {
|
|
req := portalFilingRequest{
|
|
AccountID: a.cfg.AccountID,
|
|
FilingType: string(n.FilingType),
|
|
State: string(a.state),
|
|
Issuer: portalIssuerOf(n.Issuer),
|
|
Offering: portalOfferingOf(n.Offering),
|
|
Signature: portalSignatureOf(n.Signature),
|
|
SECFileNumber: n.Offering.SECFileNumber,
|
|
SECAccessionNumber: n.Offering.SECAccessionNumber,
|
|
PaymentMethod: "ACH",
|
|
}
|
|
if len(n.FormDXML) > 0 {
|
|
req.FormDXMLBase64 = base64Std(n.FormDXML)
|
|
}
|
|
if req.FilingType == "" {
|
|
req.FilingType = string(FilingNoticeOfSale)
|
|
}
|
|
body, err := a.doJSON(ctx, http.MethodPost, a.filingPath, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp portalFilingResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("bluesky: decode %s submit: %w", a.state, err)
|
|
}
|
|
return portalAck(a.state, &resp), nil
|
|
}
|
|
|
|
func (a *statePortalAdapter) RenewNotice(ctx context.Context, r *RenewalFiling) (*Acknowledgment, error) {
|
|
if !a.supportsRenewal || a.renewalPath == "" {
|
|
return nil, fmt.Errorf("%w: %s does not accept renewals through this portal", ErrNotImplemented, a.state)
|
|
}
|
|
req := struct {
|
|
AccountID string `json:"accountId"`
|
|
OriginalFilingID string `json:"originalFilingId"`
|
|
State string `json:"state"`
|
|
Issuer portalIssuer `json:"issuer"`
|
|
Offering portalOffering `json:"offering"`
|
|
Signature portalSignature `json:"signature"`
|
|
PaymentMethod string `json:"paymentMethod"`
|
|
}{
|
|
AccountID: a.cfg.AccountID,
|
|
OriginalFilingID: r.OriginalFilingID,
|
|
State: string(a.state),
|
|
Issuer: portalIssuerOf(r.Issuer),
|
|
Offering: portalOfferingOf(r.UpdatedOffering),
|
|
Signature: portalSignatureOf(r.Signature),
|
|
PaymentMethod: "ACH",
|
|
}
|
|
body, err := a.doJSON(ctx, http.MethodPost, a.renewalPath, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp portalFilingResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("bluesky: decode %s renew: %w", a.state, err)
|
|
}
|
|
return portalAck(a.state, &resp), nil
|
|
}
|
|
|
|
func (a *statePortalAdapter) GetFilingStatus(ctx context.Context, filingID string) (*FilingStatus, error) {
|
|
path := strings.ReplaceAll(a.statusPath, "{id}", filingID)
|
|
body, err := a.doGet(ctx, path)
|
|
if err != nil {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
|
|
return nil, ErrFilingNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
var resp portalStatusResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("bluesky: decode %s status: %w", a.state, err)
|
|
}
|
|
out := &FilingStatus{
|
|
FilingID: resp.FilingID,
|
|
State: a.state,
|
|
Status: strings.ToUpper(resp.Status),
|
|
Fee: resp.Fee,
|
|
Messages: resp.Messages,
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, resp.FiledAt); err == nil {
|
|
out.FiledAt = t
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, resp.AcceptedAt); err == nil {
|
|
out.AcceptedAt = t
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, resp.ExpiresAt); err == nil {
|
|
out.ExpiresAt = t
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *statePortalAdapter) CalculateFee(n *NoticeFiling) (FeeAmount, error) {
|
|
fee := stateFeeSchedule(a.state, n)
|
|
// State portals (non-EFD) do not charge the EFD system fee.
|
|
method := "ACH"
|
|
if a.state == "FL" || a.state == "TX" {
|
|
// FL and TX historically required paper / check filing through
|
|
// the portal; even with the modern portal the payment method
|
|
// is recorded as "Check" because the portal still presents
|
|
// that affirmation.
|
|
method = "Check"
|
|
}
|
|
return FeeAmount{
|
|
State: a.state,
|
|
StateFee: fee.state,
|
|
TotalDue: fee.state,
|
|
Currency: "USD",
|
|
Method: method,
|
|
Notes: fee.notes,
|
|
}, nil
|
|
}
|
|
|
|
// --- HTTP helpers (mirror of EFD's, scoped to portal adapter) ---
|
|
|
|
func (a *statePortalAdapter) doJSON(ctx context.Context, method, path string, body any) ([]byte, error) {
|
|
var reqBody []byte
|
|
if body != nil {
|
|
var err error
|
|
reqBody, err = json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bluesky: marshal: %w", err)
|
|
}
|
|
}
|
|
doOnce := func() (*http.Response, []byte, error) {
|
|
var reader io.Reader
|
|
if reqBody != nil {
|
|
reader = bytes.NewReader(reqBody)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, a.cfg.BaseURL+path, reader)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if reqBody != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", a.userAgent)
|
|
if a.cfg.APIKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
|
|
}
|
|
resp, err := a.client.Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
rb, err := io.ReadAll(resp.Body)
|
|
return resp, rb, err
|
|
}
|
|
return a.doWithRetry(ctx, path, doOnce)
|
|
}
|
|
|
|
func (a *statePortalAdapter) doGet(ctx context.Context, path string) ([]byte, error) {
|
|
doOnce := func() (*http.Response, []byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.BaseURL+path, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", a.userAgent)
|
|
if a.cfg.APIKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
|
|
}
|
|
resp, err := a.client.Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
return resp, body, err
|
|
}
|
|
return a.doWithRetry(ctx, path, doOnce)
|
|
}
|
|
|
|
func (a *statePortalAdapter) doWithRetry(ctx context.Context, path string, fn func() (*http.Response, []byte, error)) ([]byte, error) {
|
|
var lastBody []byte
|
|
var lastStatus int
|
|
var lastRetryAfter time.Duration
|
|
for attempt := 0; attempt <= a.maxRetries; attempt++ {
|
|
resp, body, err := fn()
|
|
if err != nil {
|
|
if attempt == a.maxRetries {
|
|
return nil, err
|
|
}
|
|
if waitErr := a.sleepBackoff(ctx, attempt, 0); waitErr != nil {
|
|
return nil, waitErr
|
|
}
|
|
continue
|
|
}
|
|
lastBody = body
|
|
lastStatus = resp.StatusCode
|
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
|
(resp.StatusCode >= 500 && resp.StatusCode != http.StatusNotImplemented) {
|
|
lastRetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"))
|
|
if attempt == a.maxRetries {
|
|
break
|
|
}
|
|
if waitErr := a.sleepBackoff(ctx, attempt, lastRetryAfter); waitErr != nil {
|
|
return nil, waitErr
|
|
}
|
|
continue
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, &APIError{StatusCode: resp.StatusCode, Endpoint: path, Body: string(body)}
|
|
}
|
|
return body, nil
|
|
}
|
|
apiErr := &APIError{
|
|
StatusCode: lastStatus, Endpoint: path, Body: string(lastBody), RetryAfter: lastRetryAfter,
|
|
}
|
|
if lastStatus == http.StatusTooManyRequests {
|
|
return nil, fmt.Errorf("%w: %s", ErrRateLimited, apiErr.Error())
|
|
}
|
|
return nil, apiErr
|
|
}
|
|
|
|
func (a *statePortalAdapter) sleepBackoff(ctx context.Context, attempt int, retryAfter time.Duration) error {
|
|
var wait time.Duration
|
|
if retryAfter > 0 {
|
|
wait = retryAfter
|
|
} else {
|
|
exp := a.retryBaseDelay << attempt
|
|
if exp <= 0 || exp > a.retryMaxDelay {
|
|
exp = a.retryMaxDelay
|
|
}
|
|
wait = time.Duration(a.rand.Int63n(int64(exp) + 1))
|
|
}
|
|
t := time.NewTimer(wait)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-t.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// --- Builders ---
|
|
|
|
func portalIssuerOf(i Issuer) portalIssuer {
|
|
return portalIssuer{
|
|
CIK: i.CIK,
|
|
EntityName: i.EntityName,
|
|
EntityType: i.EntityType,
|
|
Jurisdiction: i.JurisdictionOfIncorporation,
|
|
YearOfInc: i.YearOfIncorporation,
|
|
Street1: i.PrimaryAddress.Street1,
|
|
Street2: i.PrimaryAddress.Street2,
|
|
City: i.PrimaryAddress.City,
|
|
State: i.PrimaryAddress.State,
|
|
PostalCode: i.PrimaryAddress.PostalCode,
|
|
Country: i.PrimaryAddress.Country,
|
|
Phone: i.Phone,
|
|
Email: i.Email,
|
|
}
|
|
}
|
|
|
|
func portalOfferingOf(o OfferingDetails) portalOffering {
|
|
return portalOffering{
|
|
FederalExemption: o.FederalExemption,
|
|
TotalOfferingAmount: o.TotalOfferingAmount,
|
|
AmountSoldInState: o.AmountSoldInState,
|
|
DateOfFirstSaleInState: o.DateOfFirstSaleInState.Format("2006-01-02"),
|
|
TypesOfSecurities: o.TypesOfSecurities,
|
|
}
|
|
}
|
|
|
|
func portalSignatureOf(s Signature) portalSignature {
|
|
return portalSignature{
|
|
IssuerName: s.IssuerName,
|
|
SignatureName: s.SignatureName,
|
|
NameOfSigner: s.NameOfSigner,
|
|
SignatureTitle: s.SignatureTitle,
|
|
SignatureDate: s.SignatureDate,
|
|
}
|
|
}
|
|
|
|
func portalAck(state State, resp *portalFilingResponse) *Acknowledgment {
|
|
out := &Acknowledgment{
|
|
FilingID: resp.FilingID,
|
|
Status: strings.ToUpper(resp.Status),
|
|
State: state,
|
|
Fee: resp.Fee,
|
|
PaymentID: resp.PaymentID,
|
|
Messages: resp.Messages,
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, resp.ReceivedAt); err == nil {
|
|
out.ReceivedAt = t
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, resp.ExpiresAt); err == nil {
|
|
out.ExpiresAt = t
|
|
}
|
|
if out.Status == "" {
|
|
out.Status = "RECEIVED"
|
|
}
|
|
return out
|
|
}
|