Files
Lux Dev d27ece3b99 feat(edgar,bluesky): add Form D + Blue-Sky state filing adapters
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]>
2026-05-26 18:14:15 -07:00

123 lines
3.6 KiB
Go

package compliance
import (
"context"
"fmt"
"time"
)
// Service handles securities compliance — Form D, blue sky filings, and accreditation checks.
type Service struct {
repo Repository
formDFiler FormDFiler
blueSkyFiler BlueSkyFiler
}
// NewService creates a compliance service.
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
// CreateFormD creates a new Form D filing record.
func (s *Service) CreateFormD(ctx context.Context, f *FormD) error {
if f.CompanyID == "" {
return fmt.Errorf("company_id is required")
}
if f.Exemption == "" {
return fmt.Errorf("exemption is required")
}
if f.TotalOffering <= 0 {
return fmt.Errorf("total_offering must be positive")
}
now := time.Now().UTC()
f.CreatedAt = now
f.UpdatedAt = now
f.TotalRemaining = f.TotalOffering - f.TotalSold
if f.Status == "" {
f.Status = "draft"
}
return s.repo.CreateFormD(ctx, f)
}
// CheckRegDCompliance validates a Reg D offering against SEC rules.
func (s *Service) CheckRegDCompliance(f *FormD) *ComplianceCheckResult {
result := &ComplianceCheckResult{Compliant: true}
switch f.Exemption {
case "506b":
// Rule 506(b): max 35 non-accredited investors.
if f.NumNonAccredited > 35 {
result.Compliant = false
result.Violations = append(result.Violations,
fmt.Sprintf("506(b) allows max 35 non-accredited investors, have %d", f.NumNonAccredited))
}
// Warning at 30+.
if f.NumNonAccredited >= 30 && f.NumNonAccredited <= 35 {
result.Warnings = append(result.Warnings,
fmt.Sprintf("approaching 506(b) non-accredited limit: %d/35", f.NumNonAccredited))
}
case "506c":
// Rule 506(c): ALL investors must be accredited.
if f.NumNonAccredited > 0 {
result.Compliant = false
result.Violations = append(result.Violations,
fmt.Sprintf("506(c) requires all investors to be accredited, have %d non-accredited", f.NumNonAccredited))
}
case "504":
// Rule 504: max $10M in 12 months.
if f.TotalOffering > 10_000_000 {
result.Compliant = false
result.Violations = append(result.Violations,
fmt.Sprintf("Rule 504 max offering is $10M, offering is $%.2f", f.TotalOffering))
}
}
// Form D must be filed within 15 days of first sale.
if f.FirstSaleDate != nil && f.FilingDate == nil {
deadline := f.FirstSaleDate.Add(15 * 24 * time.Hour)
if time.Now().After(deadline) {
result.Compliant = false
result.Violations = append(result.Violations,
"Form D not filed within 15 days of first sale")
} else {
daysLeft := int(time.Until(deadline).Hours() / 24)
result.Warnings = append(result.Warnings,
fmt.Sprintf("Form D filing deadline in %d days", daysLeft))
}
}
return result
}
// CreateBlueSkyFiling creates a new state filing record.
func (s *Service) CreateBlueSkyFiling(ctx context.Context, b *BlueSkyFiling) error {
if b.CompanyID == "" {
return fmt.Errorf("company_id is required")
}
if b.State == "" {
return fmt.Errorf("state is required")
}
if b.FilingType == "" {
return fmt.Errorf("filing_type is required")
}
if b.Status == "" {
b.Status = "pending"
}
return s.repo.CreateBlueSkyFiling(ctx, b)
}
// GetRequiredStates returns the list of US states that typically require blue sky notice filings.
// This is a simplified reference — actual requirements vary by exemption and offering type.
func (s *Service) GetRequiredStates() []string {
return []string{
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA",
"HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD",
"MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC",
"SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY",
"DC",
}
}