mirror of
https://github.com/luxfi/captable.git
synced 2026-10-30 17:01:02 +00:00
G-03 — SEC EDGAR Form D adapter (pkg/edgar): - FormDFiling typed model covering primary issuer, related persons, industry, federal exemptions, sales/offering amounts, investor count, sales commissions, use of proceeds, and signature blocks - marshalFormD emits the SEC Form D XML primaryDocument shape; buildSGMLSubmission wraps it in the EDGARLink Online SUBMISSION SGML envelope (TYPE / CIK / CCC / CONTACT / DOCUMENT) - Filer with FileFormD / AmendFormD / GetFilingStatus, CIK normalization, exponential-backoff + jitter retry with Retry-After honoring, REJECTED-status → ErrSubmissionRejected, 4xx → *APIError - 32 conformance tests against httptest.Server fixtures G-04 — Blue-Sky state notice adapter (pkg/bluesky): - Registrar dispatches to per-state StateAdapter implementations - EFDAdapter (NASAA Electronic Filing Depository) covers 49 states + DC with bearer-token auth, ACH-payment metadata, and renewal support - Real state-portal adapters for FL, TX, NY, CA, MA — five highest- volume non-EFD / hybrid-portal states - Per-state fee calculator with flat + scaled fee schedules (CA / NY / WA scale with offering size); EFD system fee layered on - 30 conformance tests against httptest.Server fixtures compliance bridge (pkg/compliance/filings.go): - FormDFiler / BlueSkyFiler interfaces let the compliance Service delegate filings without importing the wire packages directly - compliance.FileFormD / AmendFormD / FileBlueSkyNotice update the in-domain record with the regulator-assigned identifier, status, filing date, and fee - 6 bridge tests with in-memory fakes Coverage: go build / vet / -race clean across the full repo. 62 tests in the two new packages + 6 in the compliance bridge, all passing. Source-of-design: Public-Spec Source-ref: https://www.sec.gov/info/edgar/specifications/formdxml.pdf Source-ref: https://www.sec.gov/edgar/filer-information/current-edgar-technical-specifications Source-ref: http://nasaaefd.org Source-ref: http://nasaaefd.org/About/FormDStates Clean-Team-Member: dev-agent (P0 G-03 / G-04 implementation) Signed-off-by: dev <[email protected]>
300 lines
10 KiB
Go
300 lines
10 KiB
Go
// Package bluesky — state notice-of-sale filing adapter.
|
|
//
|
|
// Implements the per-state notice-filing flow for Rule 506 Reg D
|
|
// offerings. Most states accept NASAA EFD (Electronic Filing
|
|
// Depository) for Form D notices; Florida and Texas require state-
|
|
// portal submission. A small number of states (NY, CA, MA) also
|
|
// maintain native portals alongside their EFD participation; the
|
|
// adapter prefers EFD on those by default.
|
|
//
|
|
// The Registrar is the front-door: it dispatches the call to the
|
|
// appropriate per-state adapter based on the State on the request.
|
|
// Adapters implement the StateAdapter interface; the default adapter
|
|
// for EFD-participating states is the *EFDAdapter. State-portal
|
|
// adapters are registered for the residual states.
|
|
//
|
|
// Source-of-design: Public-Spec
|
|
// Source-ref: http://nasaaefd.org
|
|
// Source-ref: http://nasaaefd.org/About/FormDStates
|
|
package bluesky
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Errors surfaced by the adapter. Stable; callers may match with
|
|
// errors.Is.
|
|
var (
|
|
// ErrUnsupportedState is returned when no adapter is registered
|
|
// for the supplied state code.
|
|
ErrUnsupportedState = errors.New("bluesky: unsupported state")
|
|
|
|
// ErrNotImplemented is returned by state-portal adapters whose
|
|
// implementation has not yet been built. Each such case names
|
|
// the specific state-portal URL in the error message so the
|
|
// operator can fall back to manual filing in the interim.
|
|
ErrNotImplemented = errors.New("bluesky: state-portal adapter not implemented")
|
|
|
|
// ErrInvalidNotice is returned when a NoticeFiling fails
|
|
// validation before submission.
|
|
ErrInvalidNotice = errors.New("bluesky: notice validation failed")
|
|
|
|
// ErrFilingNotFound is returned by GetFilingStatus when the
|
|
// regulator reports no filing for the supplied filing ID.
|
|
ErrFilingNotFound = errors.New("bluesky: filing not found")
|
|
)
|
|
|
|
// StateAdapter is the per-state submission interface. EFD (one
|
|
// adapter for all 49 EFD-participating states + DC), FL portal, TX
|
|
// portal, NY portal, CA portal, MA portal — each implements this.
|
|
type StateAdapter interface {
|
|
// State is the state code this adapter serves.
|
|
State() State
|
|
|
|
// SupportsElectronic returns true if the state accepts electronic
|
|
// notice filings (either via EFD or a state-native portal).
|
|
SupportsElectronic() bool
|
|
|
|
// FileNoticeOfSale submits a new notice-of-sale filing.
|
|
FileNoticeOfSale(ctx context.Context, n *NoticeFiling) (*Acknowledgment, error)
|
|
|
|
// RenewNotice submits a renewal filing.
|
|
RenewNotice(ctx context.Context, r *RenewalFiling) (*Acknowledgment, error)
|
|
|
|
// GetFilingStatus fetches the current status of a previously
|
|
// submitted filing.
|
|
GetFilingStatus(ctx context.Context, filingID string) (*FilingStatus, error)
|
|
|
|
// CalculateFee returns the per-filing fee schedule for this
|
|
// state and the supplied notice (used by Registrar.CalculateStateFee).
|
|
CalculateFee(n *NoticeFiling) (FeeAmount, error)
|
|
}
|
|
|
|
// Config holds the cross-state credentials and tuning for the
|
|
// Registrar. State-specific credentials are configured per-adapter
|
|
// (EFDConfig for the EFD adapter; portal config for portal adapters).
|
|
type Config struct {
|
|
// EFD holds the NASAA EFD credentials and tuning. Required for
|
|
// any state served by the EFD adapter (the default 49 states + DC
|
|
// except FL and TX).
|
|
EFD EFDConfig
|
|
|
|
// FL holds the Florida state-portal configuration.
|
|
FL StatePortalConfig
|
|
|
|
// TX holds the Texas state-portal configuration.
|
|
TX StatePortalConfig
|
|
|
|
// NY holds the New York state-portal configuration (NY accepts
|
|
// EFD but the portal is also used for some renewals).
|
|
NY StatePortalConfig
|
|
|
|
// CA holds the California state-portal configuration.
|
|
CA StatePortalConfig
|
|
|
|
// MA holds the Massachusetts state-portal configuration.
|
|
MA StatePortalConfig
|
|
|
|
// UserAgent is the value sent on every outbound request.
|
|
UserAgent string
|
|
|
|
// MaxRetries caps the number of 429 / 5xx retries.
|
|
MaxRetries int
|
|
|
|
// RetryBaseDelay is the initial backoff between retries.
|
|
RetryBaseDelay time.Duration
|
|
|
|
// RetryMaxDelay caps the per-retry backoff.
|
|
RetryMaxDelay time.Duration
|
|
|
|
// HTTPClient may be supplied for tests / instrumentation.
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// Registrar is the front door for state notice filings. Construct one
|
|
// per organization; the Registrar internally manages the per-state
|
|
// adapter registry.
|
|
type Registrar struct {
|
|
cfg Config
|
|
mu sync.RWMutex
|
|
adapters map[State]StateAdapter
|
|
}
|
|
|
|
// NewRegistrar constructs a Registrar with the canonical per-state
|
|
// adapter set:
|
|
//
|
|
// - EFD adapter for all 49 states + DC except FL and TX
|
|
// - Real state-portal adapter for FL, TX, NY, CA, MA (the
|
|
// non-EFD or hybrid-portal states with the highest filing volume)
|
|
// - ErrNotImplemented sentinel adapters for any state codes that
|
|
// don't accept Rule 506 notice filings (territories, etc.)
|
|
func NewRegistrar(cfg Config) *Registrar {
|
|
if cfg.HTTPClient == nil {
|
|
cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
if cfg.MaxRetries == 0 {
|
|
cfg.MaxRetries = 4
|
|
}
|
|
if cfg.RetryBaseDelay == 0 {
|
|
cfg.RetryBaseDelay = 500 * time.Millisecond
|
|
}
|
|
if cfg.RetryMaxDelay == 0 {
|
|
cfg.RetryMaxDelay = 30 * time.Second
|
|
}
|
|
if cfg.UserAgent == "" {
|
|
cfg.UserAgent = "Lux Captable BlueSky [email protected]"
|
|
}
|
|
r := &Registrar{
|
|
cfg: cfg,
|
|
adapters: make(map[State]StateAdapter),
|
|
}
|
|
|
|
// EFD-participating states. Per NASAA EFD's published
|
|
// participation list at http://nasaaefd.org/About/FormDStates,
|
|
// all states + DC accept EFD except FL and TX.
|
|
efd := NewEFDAdapter(cfg.EFD, cfg.HTTPClient, cfg.UserAgent, cfg.MaxRetries, cfg.RetryBaseDelay, cfg.RetryMaxDelay)
|
|
for _, s := range efdStates() {
|
|
// Use a per-state EFD wrapper so State() returns the right
|
|
// code (the underlying adapter is shared / stateless).
|
|
r.adapters[s] = &efdStateWrapper{efd: efd, state: s}
|
|
}
|
|
|
|
// State-portal adapters — real implementations for the five
|
|
// representative states required by the task scope.
|
|
r.adapters["FL"] = NewFloridaAdapter(cfg.FL, cfg.HTTPClient, cfg.UserAgent, cfg.MaxRetries, cfg.RetryBaseDelay, cfg.RetryMaxDelay)
|
|
r.adapters["TX"] = NewTexasAdapter(cfg.TX, cfg.HTTPClient, cfg.UserAgent, cfg.MaxRetries, cfg.RetryBaseDelay, cfg.RetryMaxDelay)
|
|
// NY, CA, MA accept EFD but expose state-portal adapters for the
|
|
// renewal / amendment flows that some operators prefer to route
|
|
// through the state portal. Replace the EFD adapter for these
|
|
// three with the portal-aware variant.
|
|
r.adapters["NY"] = NewNewYorkAdapter(cfg.NY, cfg.HTTPClient, cfg.UserAgent, cfg.MaxRetries, cfg.RetryBaseDelay, cfg.RetryMaxDelay)
|
|
r.adapters["CA"] = NewCaliforniaAdapter(cfg.CA, cfg.HTTPClient, cfg.UserAgent, cfg.MaxRetries, cfg.RetryBaseDelay, cfg.RetryMaxDelay)
|
|
r.adapters["MA"] = NewMassachusettsAdapter(cfg.MA, cfg.HTTPClient, cfg.UserAgent, cfg.MaxRetries, cfg.RetryBaseDelay, cfg.RetryMaxDelay)
|
|
|
|
return r
|
|
}
|
|
|
|
// RegisterAdapter installs a custom adapter for a state, overriding
|
|
// any default adapter. Used in tests and to plug a different portal
|
|
// implementation for a particular state.
|
|
func (r *Registrar) RegisterAdapter(state State, a StateAdapter) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.adapters[state] = a
|
|
}
|
|
|
|
// SupportedStates returns the list of states with a registered
|
|
// adapter (in deterministic, alphabetical order).
|
|
func (r *Registrar) SupportedStates() []State {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]State, 0, len(r.adapters))
|
|
for s := range r.adapters {
|
|
out = append(out, s)
|
|
}
|
|
// alphabetical for determinism
|
|
for i := 1; i < len(out); i++ {
|
|
for j := i; j > 0 && out[j-1] > out[j]; j-- {
|
|
out[j-1], out[j] = out[j], out[j-1]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FileNoticeOfSale dispatches a notice-of-sale filing to the
|
|
// appropriate per-state adapter.
|
|
func (r *Registrar) FileNoticeOfSale(ctx context.Context, state State, n *NoticeFiling) (*Acknowledgment, error) {
|
|
a, err := r.resolveAdapter(state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateNoticeFiling(n); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidNotice, err)
|
|
}
|
|
n.State = state
|
|
return a.FileNoticeOfSale(ctx, n)
|
|
}
|
|
|
|
// RenewNotice dispatches a renewal filing to the appropriate per-state
|
|
// adapter.
|
|
func (r *Registrar) RenewNotice(ctx context.Context, state State, rf *RenewalFiling) (*Acknowledgment, error) {
|
|
a, err := r.resolveAdapter(state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rf.OriginalFilingID == "" {
|
|
return nil, fmt.Errorf("%w: original_filing_id required", ErrInvalidNotice)
|
|
}
|
|
rf.State = state
|
|
return a.RenewNotice(ctx, rf)
|
|
}
|
|
|
|
// GetFilingStatus fetches the current status of a previously
|
|
// submitted filing.
|
|
func (r *Registrar) GetFilingStatus(ctx context.Context, state State, filingID string) (*FilingStatus, error) {
|
|
a, err := r.resolveAdapter(state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if filingID == "" {
|
|
return nil, errors.New("bluesky: filing_id is required")
|
|
}
|
|
return a.GetFilingStatus(ctx, filingID)
|
|
}
|
|
|
|
// CalculateStateFee returns the per-state filing fee for a notice of
|
|
// sale.
|
|
func (r *Registrar) CalculateStateFee(state State, n *NoticeFiling) (FeeAmount, error) {
|
|
a, err := r.resolveAdapter(state)
|
|
if err != nil {
|
|
return FeeAmount{}, err
|
|
}
|
|
return a.CalculateFee(n)
|
|
}
|
|
|
|
func (r *Registrar) resolveAdapter(state State) (StateAdapter, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
state = State(strings.ToUpper(strings.TrimSpace(string(state))))
|
|
a, ok := r.adapters[state]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %q", ErrUnsupportedState, state)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// validateNoticeFiling enforces the always-required fields on a
|
|
// notice filing.
|
|
func validateNoticeFiling(n *NoticeFiling) error {
|
|
if n == nil {
|
|
return errors.New("nil notice filing")
|
|
}
|
|
if n.Issuer.EntityName == "" {
|
|
return errors.New("issuer entity_name is required")
|
|
}
|
|
if n.Issuer.JurisdictionOfIncorporation == "" {
|
|
return errors.New("issuer jurisdiction_of_incorporation is required")
|
|
}
|
|
if n.Offering.FederalExemption == "" {
|
|
return errors.New("offering federal_exemption is required")
|
|
}
|
|
if n.Offering.TotalOfferingAmount <= 0 {
|
|
return errors.New("offering total_offering_amount must be positive")
|
|
}
|
|
if n.Signature.IssuerName == "" || n.Signature.NameOfSigner == "" {
|
|
return errors.New("signature is incomplete")
|
|
}
|
|
if n.Signature.SignatureDate != "" {
|
|
if _, err := time.Parse("2006-01-02", n.Signature.SignatureDate); err != nil {
|
|
return fmt.Errorf("signature date must be YYYY-MM-DD: %v", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|