mirror of
https://github.com/luxfi/captable.git
synced 2026-10-30 17:01:02 +00:00
582 lines
21 KiB
Go
582 lines
21 KiB
Go
// EDGAR Form D marshaling, validation, SGML envelope construction,
|
|
// and acknowledgment parsing.
|
|
//
|
|
// Marshal builds the SEC Form D XML primaryDocument shape; the
|
|
// SGML wrapper provides the EDGAR submission header that names the
|
|
// FORM-TYPE, CIK, CCC, contact, and the embedded DOCUMENT segment.
|
|
// Acknowledgment parsing handles the EDGAR reply envelope (XML
|
|
// submission-receipt) and the older SGML-style reply for older
|
|
// submission endpoints.
|
|
//
|
|
// 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
|
|
|
|
package edgar
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// validateFormD enforces the public Form D schema constraints. The
|
|
// validation is intentionally permissive of optional sub-blocks but
|
|
// strict on the always-required fields: PrimaryIssuer (CIK, name,
|
|
// type, jurisdiction, address, phone), at least one related person,
|
|
// at least one federal exemption, and at least one signature. The
|
|
// amend parameter, when true, additionally requires FileNumber.
|
|
func validateFormD(fd *FormDFiling, amend bool) error {
|
|
if fd == nil {
|
|
return errors.New("nil filing")
|
|
}
|
|
if amend && strings.TrimSpace(fd.FileNumber) == "" {
|
|
return errors.New("amendments require FileNumber")
|
|
}
|
|
if fd.PrimaryIssuer.CIK == "" {
|
|
return errors.New("primary issuer CIK is required")
|
|
}
|
|
if fd.PrimaryIssuer.EntityName == "" {
|
|
return errors.New("primary issuer entity name is required")
|
|
}
|
|
if fd.PrimaryIssuer.EntityType == "" {
|
|
return errors.New("primary issuer entity type is required")
|
|
}
|
|
if fd.PrimaryIssuer.JurisdictionOfIncorporation == "" {
|
|
return errors.New("primary issuer jurisdiction is required")
|
|
}
|
|
if fd.PrimaryIssuer.PrimaryAddress.Street1 == "" ||
|
|
fd.PrimaryIssuer.PrimaryAddress.City == "" ||
|
|
fd.PrimaryIssuer.PrimaryAddress.StateOrCountry == "" ||
|
|
fd.PrimaryIssuer.PrimaryAddress.ZipCode == "" {
|
|
return errors.New("primary issuer address is incomplete")
|
|
}
|
|
if fd.PrimaryIssuer.Phone == "" {
|
|
return errors.New("primary issuer phone number is required")
|
|
}
|
|
if len(fd.RelatedPersons) == 0 {
|
|
return errors.New("at least one related person is required")
|
|
}
|
|
for i, rp := range fd.RelatedPersons {
|
|
if rp.FirstName == "" || rp.LastName == "" {
|
|
return fmt.Errorf("related person %d: name is required", i)
|
|
}
|
|
if len(rp.Relationships) == 0 {
|
|
return fmt.Errorf("related person %d: at least one relationship is required", i)
|
|
}
|
|
}
|
|
if fd.IndustryGroup == "" {
|
|
return errors.New("industry group is required")
|
|
}
|
|
if len(fd.FederalExemptions) == 0 {
|
|
return errors.New("at least one federal exemption is required")
|
|
}
|
|
if len(fd.TypesOfSecurities) == 0 {
|
|
return errors.New("at least one type of security is required")
|
|
}
|
|
if !fd.FirstSaleYetToOccur && fd.DateOfFirstSale == nil && !amend {
|
|
return errors.New("date of first sale or FirstSaleYetToOccur is required")
|
|
}
|
|
if len(fd.Signatures) == 0 {
|
|
return errors.New("at least one signature is required")
|
|
}
|
|
for i, sig := range fd.Signatures {
|
|
if sig.IssuerName == "" || sig.SignatureName == "" || sig.NameOfSigner == "" ||
|
|
sig.SignatureTitle == "" || sig.SignatureDate == "" {
|
|
return fmt.Errorf("signature %d is incomplete", i)
|
|
}
|
|
if _, err := time.Parse("2006-01-02", sig.SignatureDate); err != nil {
|
|
return fmt.Errorf("signature %d: invalid date %q (want YYYY-MM-DD): %v", i, sig.SignatureDate, err)
|
|
}
|
|
}
|
|
// 506(c) hard constraint — no non-accredited investors permitted.
|
|
for _, ex := range fd.FederalExemptions {
|
|
if ex == ExemptionRule506c && fd.InvestorCount.NonAccreditedInvested > 0 {
|
|
return errors.New("Rule 506(c) prohibits non-accredited investors")
|
|
}
|
|
}
|
|
// Rule 504 — max $10M aggregate.
|
|
for _, ex := range fd.FederalExemptions {
|
|
if ex == ExemptionRule504 && fd.OfferingSalesAmount.TotalOfferingAmount > 10_000_000 {
|
|
return errors.New("Rule 504 caps total offering at $10,000,000")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- XML marshaling ---
|
|
|
|
// edgarSubmission is the root of the Form D XML primaryDocument. The
|
|
// element name "edgarSubmission" matches the SEC's published Form D
|
|
// schema.
|
|
type edgarSubmission struct {
|
|
XMLName xml.Name `xml:"edgarSubmission"`
|
|
SchemaVersion string `xml:"schemaVersion,attr"`
|
|
SubmissionType SubmissionType `xml:"submissionType"`
|
|
TestOrLive string `xml:"testOrLive"`
|
|
PrimaryIssuer xmlIssuer `xml:"primaryIssuer"`
|
|
RelatedIssuersList *xmlRelatedIssuers `xml:"relatedIssuersList,omitempty"`
|
|
RelatedPersonsList xmlRelatedPersonsList `xml:"relatedPersonsList"`
|
|
OfferingData xmlOfferingData `xml:"offeringData"`
|
|
IsAmendment bool `xml:"isAmendment"`
|
|
PreviousAccession string `xml:"previousAccessionNumber,omitempty"`
|
|
}
|
|
|
|
type xmlIssuer struct {
|
|
CIK string `xml:"cik"`
|
|
EntityName string `xml:"entityName"`
|
|
IssuerAddress Address `xml:"issuerAddress"`
|
|
IssuerPhoneNumber string `xml:"issuerPhoneNumber"`
|
|
JurisdictionOfInc string `xml:"jurisdictionOfInc"`
|
|
IssuerPreviousNameList *xmlPrevNames `xml:"issuerPreviousNameList,omitempty"`
|
|
EdgarPreviousNameList *xmlPrevNames `xml:"edgarPreviousNameList,omitempty"`
|
|
EntityType EntityType `xml:"entityType"`
|
|
YearOfInc xmlYearOfInc `xml:"yearOfInc"`
|
|
}
|
|
|
|
type xmlPrevNames struct {
|
|
PreviousName []string `xml:"previousName"`
|
|
}
|
|
|
|
type xmlYearOfInc struct {
|
|
WithinFiveYears bool `xml:"withinFiveYears,omitempty"`
|
|
OverFiveYears bool `xml:"overFiveYears,omitempty"`
|
|
YetToBeFormed bool `xml:"yetToBeFormed,omitempty"`
|
|
Value string `xml:"value,omitempty"`
|
|
}
|
|
|
|
type xmlRelatedIssuers struct {
|
|
RelatedIssuer []xmlIssuer `xml:"relatedIssuer"`
|
|
}
|
|
|
|
type xmlRelatedPersonsList struct {
|
|
RelatedPersonInfo []xmlRelatedPerson `xml:"relatedPersonInfo"`
|
|
}
|
|
|
|
type xmlRelatedPerson struct {
|
|
RelatedPersonName xmlPersonName `xml:"relatedPersonName"`
|
|
RelatedPersonAddress Address `xml:"relatedPersonAddress"`
|
|
RelatedPersonRelationshipList xmlRelationshipList `xml:"relatedPersonRelationshipList"`
|
|
RelationshipClarification string `xml:"relationshipClarification,omitempty"`
|
|
}
|
|
|
|
type xmlPersonName struct {
|
|
FirstName string `xml:"firstName"`
|
|
MiddleName string `xml:"middleName,omitempty"`
|
|
LastName string `xml:"lastName"`
|
|
}
|
|
|
|
type xmlRelationshipList struct {
|
|
Relationship []string `xml:"relationship"`
|
|
}
|
|
|
|
type xmlOfferingData struct {
|
|
IndustryGroup xmlIndustryGroup `xml:"industryGroup"`
|
|
IssuerSize xmlIssuerSize `xml:"issuerSize"`
|
|
FederalExemptionsExclusions xmlExemptions `xml:"federalExemptionsExclusions"`
|
|
TypeOfFiling xmlTypeOfFiling `xml:"typeOfFiling"`
|
|
DateOfFirstSale xmlDateOfFirstSale `xml:"dateOfFirstSale"`
|
|
MoreThanOneYear bool `xml:"moreThanOneYear"`
|
|
TypesOfSecuritiesOffered xmlTypesOfSecurities `xml:"typesOfSecuritiesOffered"`
|
|
BusinessCombinationTransaction xmlBusinessCombination `xml:"businessCombinationTransaction"`
|
|
MinimumInvestmentAccepted float64 `xml:"minimumInvestmentAccepted"`
|
|
SalesCompensationList *xmlSalesCompensationList `xml:"salesCompensationList,omitempty"`
|
|
OfferingSalesAmounts xmlOfferingSalesAmounts `xml:"offeringSalesAmounts"`
|
|
InvestorsInfo xmlInvestorsInfo `xml:"investorsInfo"`
|
|
SalesCommissionsFindersFees xmlSalesCommissionsFindersFees `xml:"salesCommissionsFindersFeesExpenses"`
|
|
UseOfProceeds xmlUseOfProceeds `xml:"useOfProceeds"`
|
|
SignatureBlock xmlSignatureBlock `xml:"signatureBlock"`
|
|
}
|
|
|
|
type xmlIndustryGroup struct {
|
|
IndustryGroupType IndustryGroup `xml:"industryGroupType"`
|
|
InvestmentFundType string `xml:"investmentFundType,omitempty"`
|
|
Is40ActInvestmentCompany bool `xml:"is40Act,omitempty"`
|
|
}
|
|
|
|
type xmlIssuerSize struct {
|
|
RevenueRange string `xml:"revenueRange,omitempty"`
|
|
AggregateNAVRange string `xml:"aggregateNetAssetValueRange,omitempty"`
|
|
}
|
|
|
|
type xmlExemptions struct {
|
|
Item06b []FederalExemption `xml:"item06b"`
|
|
}
|
|
|
|
type xmlTypeOfFiling struct {
|
|
NewFiling xmlNewFiling `xml:"newFiling"`
|
|
Amendment xmlAmendment `xml:"amendment"`
|
|
}
|
|
|
|
type xmlNewFiling struct {
|
|
IsNewFiling bool `xml:"newFiling"`
|
|
}
|
|
|
|
type xmlAmendment struct {
|
|
IsAmendment bool `xml:"isAmendment"`
|
|
PreviousAccession string `xml:"previousAccessionNumber,omitempty"`
|
|
}
|
|
|
|
type xmlDateOfFirstSale struct {
|
|
Value string `xml:"value,omitempty"`
|
|
YetToOccur bool `xml:"yetToOccur,omitempty"`
|
|
}
|
|
|
|
type xmlTypesOfSecurities struct {
|
|
IsEquityType bool `xml:"isEquityType,omitempty"`
|
|
IsDebtType bool `xml:"isDebtType,omitempty"`
|
|
IsOptionToAcquireType bool `xml:"isOptionToAcquireType,omitempty"`
|
|
IsSecurityToBeAcquiredType bool `xml:"isSecurityToBeAcquiredType,omitempty"`
|
|
IsPooledInvestmentFundType bool `xml:"isPooledInvestmentFundType,omitempty"`
|
|
IsTenantInCommonType bool `xml:"isTenantInCommonType,omitempty"`
|
|
IsMineralPropertyType bool `xml:"isMineralPropertyType,omitempty"`
|
|
IsOtherType bool `xml:"isOtherType,omitempty"`
|
|
DescriptionOfOther string `xml:"descriptionOfOther,omitempty"`
|
|
}
|
|
|
|
type xmlBusinessCombination struct {
|
|
IsBusinessCombination bool `xml:"isBusinessCombination"`
|
|
Clarification string `xml:"clarificationOfResponse,omitempty"`
|
|
}
|
|
|
|
type xmlSalesCompensationList struct {
|
|
RecipientList []xmlRecipient `xml:"recipientList"`
|
|
}
|
|
|
|
type xmlRecipient struct {
|
|
RecipientName string `xml:"recipientName"`
|
|
RecipientCRDNumber string `xml:"recipientCRDNumber,omitempty"`
|
|
Address Address `xml:"recipientAddress"`
|
|
StatesOfSolicitation []string `xml:"statesOfSolicitationList>state,omitempty"`
|
|
}
|
|
|
|
type xmlOfferingSalesAmounts struct {
|
|
TotalOfferingAmount string `xml:"totalOfferingAmount"`
|
|
TotalAmountSold float64 `xml:"totalAmountSold"`
|
|
TotalRemaining string `xml:"totalRemaining"`
|
|
Clarification string `xml:"clarificationOfResponse,omitempty"`
|
|
}
|
|
|
|
type xmlInvestorsInfo struct {
|
|
HasNonAccreditedInvestors bool `xml:"hasNonAccreditedInvestors"`
|
|
TotalNumberAlreadyInvested int `xml:"totalNumberAlreadyInvested"`
|
|
NonAccreditedInvestorsAlreadyInvested int `xml:"nonAccreditedInvestorsAlreadyInvested,omitempty"`
|
|
}
|
|
|
|
type xmlSalesCommissionsFindersFees struct {
|
|
SalesCommissions float64 `xml:"salesCommissionsAmount"`
|
|
SalesCommissionsEstimate bool `xml:"salesCommissionsEstimate"`
|
|
FindersFees float64 `xml:"findersFeesAmount"`
|
|
FindersFeesEstimate bool `xml:"findersFeesEstimate"`
|
|
}
|
|
|
|
type xmlUseOfProceeds struct {
|
|
GrossProceedsUsed float64 `xml:"grossProceedsUsed"`
|
|
GrossProceedsUsedEstimate bool `xml:"grossProceedsUsedEstimate"`
|
|
Clarification string `xml:"clarificationOfResponse,omitempty"`
|
|
}
|
|
|
|
type xmlSignatureBlock struct {
|
|
AuthorizedRepresentative bool `xml:"authorizedRepresentative"`
|
|
Signature []Signature `xml:"signature"`
|
|
}
|
|
|
|
// marshalFormD renders a FormDFiling into the SEC Form D XML
|
|
// primaryDocument shape. Returns the bytes to be wrapped in the SGML
|
|
// envelope.
|
|
func marshalFormD(fd *FormDFiling) ([]byte, error) {
|
|
es := edgarSubmission{
|
|
SchemaVersion: "X0708",
|
|
SubmissionType: fd.SubmissionType,
|
|
TestOrLive: "LIVE",
|
|
IsAmendment: fd.IsAmendment,
|
|
PreviousAccession: fd.FileNumber,
|
|
}
|
|
if es.IsAmendment {
|
|
es.TestOrLive = "LIVE"
|
|
}
|
|
es.PrimaryIssuer = toXMLIssuer(fd.PrimaryIssuer)
|
|
if len(fd.RelatedIssuers) > 0 {
|
|
var rel []xmlIssuer
|
|
for _, ri := range fd.RelatedIssuers {
|
|
rel = append(rel, toXMLIssuer(ri))
|
|
}
|
|
es.RelatedIssuersList = &xmlRelatedIssuers{RelatedIssuer: rel}
|
|
}
|
|
|
|
var rps []xmlRelatedPerson
|
|
for _, rp := range fd.RelatedPersons {
|
|
rps = append(rps, xmlRelatedPerson{
|
|
RelatedPersonName: xmlPersonName{
|
|
FirstName: rp.FirstName, MiddleName: rp.MiddleName, LastName: rp.LastName,
|
|
},
|
|
RelatedPersonAddress: rp.Address,
|
|
RelatedPersonRelationshipList: xmlRelationshipList{Relationship: rp.Relationships},
|
|
RelationshipClarification: rp.Clarification,
|
|
})
|
|
}
|
|
es.RelatedPersonsList = xmlRelatedPersonsList{RelatedPersonInfo: rps}
|
|
|
|
es.OfferingData = xmlOfferingData{
|
|
IndustryGroup: xmlIndustryGroup{IndustryGroupType: fd.IndustryGroup},
|
|
IssuerSize: xmlIssuerSize{
|
|
RevenueRange: fd.IssuerRevenueRange,
|
|
AggregateNAVRange: fd.AggregateNetAssetValueRange,
|
|
},
|
|
FederalExemptionsExclusions: xmlExemptions{Item06b: fd.FederalExemptions},
|
|
TypeOfFiling: xmlTypeOfFiling{
|
|
NewFiling: xmlNewFiling{IsNewFiling: fd.IsNewFiling},
|
|
Amendment: xmlAmendment{IsAmendment: fd.IsAmendment, PreviousAccession: fd.FileNumber},
|
|
},
|
|
DateOfFirstSale: buildDateOfFirstSale(fd),
|
|
MoreThanOneYear: fd.DurationMoreThanOneYear,
|
|
TypesOfSecuritiesOffered: buildTypesOfSecurities(fd.TypesOfSecurities),
|
|
BusinessCombinationTransaction: xmlBusinessCombination{
|
|
IsBusinessCombination: fd.IsBusinessCombinationTransaction,
|
|
},
|
|
MinimumInvestmentAccepted: fd.MinimumInvestmentAccepted,
|
|
OfferingSalesAmounts: buildOfferingSalesAmounts(fd.OfferingSalesAmount),
|
|
InvestorsInfo: xmlInvestorsInfo{
|
|
HasNonAccreditedInvestors: fd.InvestorCount.NonAccreditedInvested > 0,
|
|
TotalNumberAlreadyInvested: fd.InvestorCount.TotalAlreadyInvested,
|
|
NonAccreditedInvestorsAlreadyInvested: fd.InvestorCount.NonAccreditedInvested,
|
|
},
|
|
SalesCommissionsFindersFees: xmlSalesCommissionsFindersFees{
|
|
SalesCommissions: fd.SalesCommissions.SalesCommissions,
|
|
SalesCommissionsEstimate: fd.SalesCommissions.SalesCommissionsEstimate,
|
|
FindersFees: fd.SalesCommissions.FindersFees,
|
|
FindersFeesEstimate: fd.SalesCommissions.FindersFeesEstimate,
|
|
},
|
|
UseOfProceeds: xmlUseOfProceeds{
|
|
GrossProceedsUsed: fd.UseOfProceeds.GrossProceedsToIssuer,
|
|
GrossProceedsUsedEstimate: fd.UseOfProceeds.GrossProceedsEstimate,
|
|
Clarification: fd.UseOfProceeds.ClarificationOfResponse,
|
|
},
|
|
SignatureBlock: xmlSignatureBlock{
|
|
AuthorizedRepresentative: true,
|
|
Signature: fd.Signatures,
|
|
},
|
|
}
|
|
|
|
out, err := xml.MarshalIndent(es, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prolog := []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
|
return append(prolog, out...), nil
|
|
}
|
|
|
|
func toXMLIssuer(i Issuer) xmlIssuer {
|
|
out := xmlIssuer{
|
|
CIK: normalizeCIK(i.CIK),
|
|
EntityName: i.EntityName,
|
|
IssuerAddress: i.PrimaryAddress,
|
|
IssuerPhoneNumber: i.Phone,
|
|
JurisdictionOfInc: i.JurisdictionOfIncorporation,
|
|
EntityType: i.EntityType,
|
|
}
|
|
switch i.YearOfIncorporation {
|
|
case "WithinLastFiveYears":
|
|
out.YearOfInc = xmlYearOfInc{WithinFiveYears: true}
|
|
case "OverFiveYears":
|
|
out.YearOfInc = xmlYearOfInc{OverFiveYears: true}
|
|
case "YetToBeFormed":
|
|
out.YearOfInc = xmlYearOfInc{YetToBeFormed: true}
|
|
default:
|
|
out.YearOfInc = xmlYearOfInc{Value: i.YearOfIncorporation}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildDateOfFirstSale(fd *FormDFiling) xmlDateOfFirstSale {
|
|
if fd.FirstSaleYetToOccur {
|
|
return xmlDateOfFirstSale{YetToOccur: true}
|
|
}
|
|
if fd.DateOfFirstSale != nil {
|
|
return xmlDateOfFirstSale{Value: fd.DateOfFirstSale.Format("2006-01-02")}
|
|
}
|
|
return xmlDateOfFirstSale{}
|
|
}
|
|
|
|
func buildTypesOfSecurities(types []string) xmlTypesOfSecurities {
|
|
out := xmlTypesOfSecurities{}
|
|
for _, t := range types {
|
|
switch t {
|
|
case "Equity":
|
|
out.IsEquityType = true
|
|
case "Debt":
|
|
out.IsDebtType = true
|
|
case "Option, Warrant or Other Right to Acquire Another Security":
|
|
out.IsOptionToAcquireType = true
|
|
case "Security to be Acquired Upon Exercise of Option, Warrant or Other Right to Acquire Security":
|
|
out.IsSecurityToBeAcquiredType = true
|
|
case "Pooled Investment Fund Interests":
|
|
out.IsPooledInvestmentFundType = true
|
|
case "Tenant-in-Common Securities":
|
|
out.IsTenantInCommonType = true
|
|
case "Mineral Property Securities":
|
|
out.IsMineralPropertyType = true
|
|
case "Other":
|
|
out.IsOtherType = true
|
|
default:
|
|
out.IsOtherType = true
|
|
out.DescriptionOfOther = t
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildOfferingSalesAmounts(osa OfferingSalesAmount) xmlOfferingSalesAmounts {
|
|
out := xmlOfferingSalesAmounts{
|
|
TotalAmountSold: osa.TotalAmountSold,
|
|
}
|
|
if osa.IsIndefinite {
|
|
out.TotalOfferingAmount = "Indefinite"
|
|
out.TotalRemaining = "Indefinite"
|
|
return out
|
|
}
|
|
out.TotalOfferingAmount = strconv.FormatFloat(osa.TotalOfferingAmount, 'f', 2, 64)
|
|
remaining := osa.TotalRemaining
|
|
if remaining == 0 {
|
|
remaining = osa.TotalOfferingAmount - osa.TotalAmountSold
|
|
}
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
out.TotalRemaining = strconv.FormatFloat(remaining, 'f', 2, 64)
|
|
return out
|
|
}
|
|
|
|
// --- SGML submission envelope ---
|
|
|
|
// buildSGMLSubmission wraps the Form D XML payload in the EDGAR
|
|
// SUBMISSION SGML header. The envelope structure follows EDGAR Filer
|
|
// Manual Volume II §3 — opening <SUBMISSION> tag, then header fields
|
|
// (TYPE, CIK, CCC, SROS, FILER, NOTIFY-INTERNET, CONTACT, PERIOD,
|
|
// RETURN-COPY), then one <DOCUMENT> block per attached file, then
|
|
// closing </SUBMISSION>.
|
|
func buildSGMLSubmission(cfg Config, fd *FormDFiling, primaryXML []byte) []byte {
|
|
var b strings.Builder
|
|
b.WriteString("<SUBMISSION>\n")
|
|
fmt.Fprintf(&b, "<TYPE>%s\n", fd.SubmissionType)
|
|
fmt.Fprintf(&b, "<CIK>%s\n", cfg.CIK)
|
|
fmt.Fprintf(&b, "<CCC>%s\n", cfg.CCC)
|
|
if fd.FileNumber != "" {
|
|
fmt.Fprintf(&b, "<SROS>NONE\n")
|
|
fmt.Fprintf(&b, "<FILE-NUMBER>%s\n", fd.FileNumber)
|
|
}
|
|
fmt.Fprintf(&b, "<CONFIRMING-COPY>NO\n")
|
|
if cfg.ContactEmail != "" {
|
|
fmt.Fprintf(&b, "<NOTIFY-INTERNET>%s\n", cfg.ContactEmail)
|
|
fmt.Fprintf(&b, "<CONTACT>\n")
|
|
fmt.Fprintf(&b, "<NAME>%s\n", fd.PrimaryIssuer.EntityName)
|
|
fmt.Fprintf(&b, "<EMAIL>%s\n", cfg.ContactEmail)
|
|
fmt.Fprintf(&b, "</CONTACT>\n")
|
|
}
|
|
fmt.Fprintf(&b, "<FILER>\n")
|
|
fmt.Fprintf(&b, "<CIK>%s\n", cfg.CIK)
|
|
fmt.Fprintf(&b, "<CCC>%s\n", cfg.CCC)
|
|
fmt.Fprintf(&b, "</FILER>\n")
|
|
|
|
// DOCUMENT body — TYPE D, primary_doc.xml.
|
|
fmt.Fprintf(&b, "<DOCUMENT>\n")
|
|
fmt.Fprintf(&b, "<TYPE>%s\n", fd.SubmissionType)
|
|
fmt.Fprintf(&b, "<SEQUENCE>1\n")
|
|
fmt.Fprintf(&b, "<FILENAME>primary_doc.xml\n")
|
|
fmt.Fprintf(&b, "<TEXT>\n")
|
|
b.Write(primaryXML)
|
|
b.WriteString("\n</TEXT>\n")
|
|
b.WriteString("</DOCUMENT>\n")
|
|
|
|
b.WriteString("</SUBMISSION>\n")
|
|
return []byte(b.String())
|
|
}
|
|
|
|
// --- Acknowledgment / status parsing ---
|
|
|
|
// edgarAck is the XML acknowledgment EDGAR returns on a successful
|
|
// submission. The shape matches the canonical SubmissionReceipt
|
|
// returned by the EDGARLink Online endpoint.
|
|
type edgarAck struct {
|
|
XMLName xml.Name `xml:"SubmissionReceipt"`
|
|
SubmissionID string `xml:"submissionId"`
|
|
AccessionNumber string `xml:"accessionNumber"`
|
|
Status string `xml:"status"`
|
|
FileNumber string `xml:"fileNumber"`
|
|
ReceivedAt string `xml:"receivedAt"`
|
|
Messages []string `xml:"messages>message"`
|
|
}
|
|
|
|
// edgarStatus is the XML reply from the status endpoint.
|
|
type edgarStatus struct {
|
|
XMLName xml.Name `xml:"FilingStatus"`
|
|
AccessionNumber string `xml:"accessionNumber"`
|
|
Status string `xml:"status"`
|
|
FileNumber string `xml:"fileNumber"`
|
|
AcceptedAt string `xml:"acceptedAt"`
|
|
Messages []string `xml:"messages>message"`
|
|
}
|
|
|
|
func parseAcknowledgment(body []byte) (*Acknowledgment, error) {
|
|
var ack edgarAck
|
|
if err := xml.Unmarshal(body, &ack); err != nil {
|
|
return nil, fmt.Errorf("decode submission receipt: %w; body=%q", err, truncate(body, 256))
|
|
}
|
|
out := &Acknowledgment{
|
|
AccessionNumber: ack.AccessionNumber,
|
|
SubmissionID: ack.SubmissionID,
|
|
Status: strings.ToUpper(ack.Status),
|
|
FileNumber: ack.FileNumber,
|
|
Messages: ack.Messages,
|
|
}
|
|
if ack.ReceivedAt != "" {
|
|
if t, err := parseEDGARTime(ack.ReceivedAt); err == nil {
|
|
out.ReceivedAt = t
|
|
}
|
|
}
|
|
if out.Status == "" {
|
|
out.Status = "RECEIVED"
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func parseStatusReply(body []byte) (*FilingStatus, error) {
|
|
var st edgarStatus
|
|
if err := xml.Unmarshal(body, &st); err != nil {
|
|
return nil, fmt.Errorf("decode filing status: %w; body=%q", err, truncate(body, 256))
|
|
}
|
|
out := &FilingStatus{
|
|
AccessionNumber: st.AccessionNumber,
|
|
Status: strings.ToUpper(st.Status),
|
|
FileNumber: st.FileNumber,
|
|
Messages: st.Messages,
|
|
}
|
|
if st.AcceptedAt != "" {
|
|
if t, err := parseEDGARTime(st.AcceptedAt); err == nil {
|
|
out.AcceptedAt = t
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseEDGARTime accepts RFC3339, RFC1123, or the EDGAR-native
|
|
// "2006-01-02 15:04:05" form.
|
|
func parseEDGARTime(s string) (time.Time, error) {
|
|
for _, layout := range []string{time.RFC3339, time.RFC1123, "2006-01-02 15:04:05", "2006-01-02T15:04:05"} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("unrecognized time format: %q", s)
|
|
}
|
|
|
|
func truncate(b []byte, n int) string {
|
|
if len(b) <= n {
|
|
return string(b)
|
|
}
|
|
return string(b[:n]) + "..."
|
|
}
|