mirror of
https://github.com/luxfi/captable.git
synced 2026-10-30 17:01:02 +00:00
G-15 — IRS FIRE + IRIS e-file adapter for 1099 series, with CFSF state routing and per-issuer TCC management. pkg/tax/iris — IRIS A2A adapter (mandatory for TY 2024+): - Client with JWT bearer auth (OAuth2 password-grant exchange against IRS Identity), env switch (Production / AATS sandbox), per-call re-auth on 401, exponential-backoff + jitter retry with Retry-After honoring, X-IRS-TCC header on every submission - SubmitForm / GetSubmissionStatus / ListSubmissions / CorrectSubmission cover all 8 form types: 1099-DIV / B / INT / MISC / NEC / OID / K / R - Typed Form1099*Data per form covering every IRS-published box - IRIS schema XML marshaling (header + per-payee Form1099* element with per-form payload element name; FormTypeCd discriminator; correction envelope with PaymentYearTypeCd=G and OriginalReceiptID) - Validation: TCC format, EIN 9-digit, TIN 9-digit per payee, payee data type compatible with FormType, max 100,000 payees per submission (Pub 5717 §3.1), original_receipt_id required for corrections - Acknowledgment + Status parsing for both XML and JSON wire formats pkg/tax/fire — FIRE legacy adapter (pre-TY-2024 corrections): - Client with multipart upload, env switch (Production / Test) - Fixed-width 750-byte records per Publication 1220 Part C — T (Transmitter), A (Payer), B (Payee), C (End-of-A), F (End-of-File), K (State Totals) with positional layout: numeric right-justified zero-padded, alphanumeric left-justified blank-padded, dollar amounts in cents - Per-payee amount codes mapped to positions 55..187 (12-position fields, codes '1'..'9' / 'A'..'I' = 18 codes total) and per-amount- code totals carried on the C and K records - All 8 form codes: 1099-DIV (1), 1099-INT (6), 1099-B (B), 1099-MISC (A), 1099-NEC (NE), 1099-OID (D), 1099-K (MC), 1099-R (9) - CFSF state-totals block on B records (positions 663-748) and per- state K records with CombinedFedStateCode pkg/tax/cfsf — Combined Federal/State Filing routing: - StateProfile registry covering 32 CFSF participants + DC + WV with their IRS-assigned numeric codes (CA=06, NY=n/a, MA=25, etc.) plus 19 non-CFSF states with the per-state DOR portal references (PortalDORWebsite / PortalFSET / PortalNone for no-income-tax states) - RouteToStates(submission, []State) -> []*StateFiling distinguishing cfsf (federal filing auto-routes), direct (operator hand-off to named portal), and none (no broad state income tax) - IRISWrapper adapts *iris.FormSubmission to FormSubmissionForRouting by reading StateCd / StateTaxWithheld / StateIncome from each payee's typed Form1099*Data pkg/tax/tcc — Transmitter Control Code registry: - Per-(issuer, system, env) scoping: same issuer may hold separate IRIS / FIRE TCCs and separate Sandbox / Production TCCs - Agency split: AgencyIssuer (issuer files own returns) vs AgencyTransmitter (Lux files as agent under transmitter TCC) - Validation: TCC 5-char, EIN 9-digit, agency requires entity id when transmitter pkg/tax — integration hook (submit.go): - tax.SubmitForm + tax.SubmitForm1099B route by default to IRIS for TY >= 2024 (IRISMandatoryTaxYear per IRS final regs), fall back to FIRE only when ForceLegacyFIRE is set or TaxYear < 2024 - SubmissionOptions binds the chosen IRIS / FIRE clients plus a TCCRegistry for per-issuer TCC resolution - Builders translate captable Form1099DIV / Form1099B into the transport's typed submission shape Coverage: go build / vet / -race clean across the full module. 68 tests in the four new packages (16 IRIS happy paths × form types + validation suite + correction round trip + 429 retry + re-auth + XML ack parsing; 8 FIRE happy paths × form codes + record-layout tests + CFSF K-record test + rejection path; 9 CFSF routing tests covering the spec-required CA/MA/OH/OK/IN CFSF participants + NY/IL direct + TX/FL no-tax; 9 TCC scoping + validation tests), all passing. Source-of-design: Public-Spec Source-ref: https://www.irs.gov/e-file-providers/iris-online-portal Source-ref: IRS Publication 5717 (IRIS A2A Specifications) Source-ref: IRS Publication 5718 (IRIS Electronic Filing Application Guide) Source-ref: IRS Publication 1220 (FIRE Specifications) Source-ref: https://www.irs.gov/pub/irs-pdf/p1220.pdf Clean-Team-Member: dev-agent (P0 G-15 implementation) Signed-off-by: dev <[email protected]>
428 lines
13 KiB
Go
428 lines
13 KiB
Go
// Tax e-file submission entrypoint. SubmitForm routes a generated
|
|
// 1099 form to IRIS by default (mandatory for TY 2024+) with FIRE as
|
|
// the explicit fallback for legacy-year submissions.
|
|
//
|
|
// Source-of-design: Public-Spec
|
|
// Source-ref: IRS Publication 5717 — IRIS A2A Specifications
|
|
// Source-ref: IRS Publication 1220 — FIRE Specifications
|
|
package tax
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/luxfi/captable/pkg/tax/fire"
|
|
"github.com/luxfi/captable/pkg/tax/iris"
|
|
"github.com/luxfi/captable/pkg/tax/tcc"
|
|
)
|
|
|
|
// IRISClient is the minimal interface SubmitForm needs from the IRIS
|
|
// adapter. The package-level *iris.Client satisfies this; the
|
|
// interface lets callers stub for tests.
|
|
type IRISClient interface {
|
|
SubmitForm(ctx context.Context, fs *iris.FormSubmission) (*iris.Acknowledgment, error)
|
|
}
|
|
|
|
// FIREClient is the minimal interface SubmitForm needs from the FIRE
|
|
// adapter. The package-level *fire.Client satisfies this.
|
|
type FIREClient interface {
|
|
SubmitFile(ctx context.Context, f *fire.FIREFile) (*fire.Acknowledgment, error)
|
|
}
|
|
|
|
// SubmissionResult unifies the IRIS and FIRE acknowledgment surfaces
|
|
// so callers see one return type regardless of which transport was
|
|
// used.
|
|
type SubmissionResult struct {
|
|
// Transport is one of "iris" or "fire".
|
|
Transport string
|
|
|
|
// ReceiptID is the IRIS Receipt ID (for IRIS submissions) or the
|
|
// FIRE filename (for FIRE submissions).
|
|
ReceiptID string
|
|
|
|
// Status is the e-file system's acceptance status.
|
|
Status string
|
|
}
|
|
|
|
// SubmissionOptions modifies the SubmitForm routing decision.
|
|
type SubmissionOptions struct {
|
|
// ForceLegacyFIRE forces routing to FIRE regardless of tax year.
|
|
// Use only for corrections of older years that were originally
|
|
// filed to FIRE and remain in FIRE-side records.
|
|
ForceLegacyFIRE bool
|
|
|
|
// IRIS is the IRIS client; required unless ForceLegacyFIRE is
|
|
// true.
|
|
IRIS IRISClient
|
|
|
|
// FIRE is the FIRE client; required if ForceLegacyFIRE is true or
|
|
// the form's tax year is below IRISMandatoryTaxYear.
|
|
FIRE FIREClient
|
|
|
|
// TCCRegistry resolves the issuer's TCC for the chosen transport
|
|
// and env. Optional; callers may pre-populate the IRIS / FIRE
|
|
// transmitter on the wire instead.
|
|
TCCRegistry *tcc.Registry
|
|
|
|
// IssuerID identifies the issuer in the TCC registry.
|
|
IssuerID string
|
|
|
|
// IRISEnv selects the IRIS env to route to.
|
|
IRISEnv iris.IRISEnv
|
|
|
|
// FIREEnv selects the FIRE env to route to.
|
|
FIREEnv fire.FIREEnv
|
|
}
|
|
|
|
// IRISMandatoryTaxYear is the first tax year for which IRIS is the
|
|
// mandatory transport. Forms with TaxYear >= this value MUST file to
|
|
// IRIS unless ForceLegacyFIRE is set. (IRIS became mandatory for TY
|
|
// 2024 per IRS final regs published Feb 2023.)
|
|
const IRISMandatoryTaxYear = 2024
|
|
|
|
// ErrNoTransportConfigured is returned when SubmitForm cannot pick a
|
|
// route because neither IRIS nor FIRE was configured.
|
|
var ErrNoTransportConfigured = errors.New("tax: no e-file transport configured")
|
|
|
|
// SubmitForm routes a Form1099 to the IRS e-file system. The default
|
|
// transport is IRIS (the modernized, mandatory-for-TY-2024+ system);
|
|
// FIRE is selected only when SubmissionOptions.ForceLegacyFIRE is
|
|
// true or the form's TaxYear is below IRISMandatoryTaxYear.
|
|
//
|
|
// The function builds a typed iris.FormSubmission or fire.FIREFile
|
|
// from the supplied tax form and delegates to the transport's
|
|
// adapter. Callers retain control over advanced fields (multi-payee
|
|
// submissions, corrections, custom transmitter info) by constructing
|
|
// iris.FormSubmission / fire.FIREFile directly and calling
|
|
// SubmitForm on the chosen client.
|
|
func SubmitForm(ctx context.Context, form *Form1099DIV, opts SubmissionOptions) (*SubmissionResult, error) {
|
|
if form == nil {
|
|
return nil, fmt.Errorf("tax: form is nil")
|
|
}
|
|
useFIRE := opts.ForceLegacyFIRE || form.TaxYear < IRISMandatoryTaxYear
|
|
if useFIRE {
|
|
if opts.FIRE == nil {
|
|
return nil, fmt.Errorf("%w: FIRE client required for TY %d submission", ErrNoTransportConfigured, form.TaxYear)
|
|
}
|
|
f, err := buildFIREFileFromDIV(form, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ack, err := opts.FIRE.SubmitFile(ctx, f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SubmissionResult{
|
|
Transport: "fire",
|
|
ReceiptID: ack.Filename,
|
|
Status: ack.Status,
|
|
}, nil
|
|
}
|
|
|
|
if opts.IRIS == nil {
|
|
return nil, fmt.Errorf("%w: IRIS client required for TY %d submission", ErrNoTransportConfigured, form.TaxYear)
|
|
}
|
|
fs, err := buildIRISSubmissionFromDIV(form, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ack, err := opts.IRIS.SubmitForm(ctx, fs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SubmissionResult{
|
|
Transport: "iris",
|
|
ReceiptID: ack.ReceiptID,
|
|
Status: ack.Status,
|
|
}, nil
|
|
}
|
|
|
|
// SubmitForm1099B is the 1099-B variant of SubmitForm.
|
|
func SubmitForm1099B(ctx context.Context, form *Form1099B, opts SubmissionOptions) (*SubmissionResult, error) {
|
|
if form == nil {
|
|
return nil, fmt.Errorf("tax: form is nil")
|
|
}
|
|
useFIRE := opts.ForceLegacyFIRE || form.TaxYear < IRISMandatoryTaxYear
|
|
if useFIRE {
|
|
if opts.FIRE == nil {
|
|
return nil, fmt.Errorf("%w: FIRE client required for TY %d submission", ErrNoTransportConfigured, form.TaxYear)
|
|
}
|
|
f, err := buildFIREFileFromB(form, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ack, err := opts.FIRE.SubmitFile(ctx, f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SubmissionResult{Transport: "fire", ReceiptID: ack.Filename, Status: ack.Status}, nil
|
|
}
|
|
|
|
if opts.IRIS == nil {
|
|
return nil, fmt.Errorf("%w: IRIS client required for TY %d submission", ErrNoTransportConfigured, form.TaxYear)
|
|
}
|
|
fs, err := buildIRISSubmissionFromB(form, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ack, err := opts.IRIS.SubmitForm(ctx, fs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SubmissionResult{Transport: "iris", ReceiptID: ack.ReceiptID, Status: ack.Status}, nil
|
|
}
|
|
|
|
// --- builders ---
|
|
|
|
// buildIRISSubmissionFromDIV constructs an iris.FormSubmission from a
|
|
// captable Form1099DIV.
|
|
func buildIRISSubmissionFromDIV(form *Form1099DIV, opts SubmissionOptions) (*iris.FormSubmission, error) {
|
|
transmitterTCC := ""
|
|
transmitterEIN := ""
|
|
if opts.TCCRegistry != nil && opts.IssuerID != "" {
|
|
entry, err := opts.TCCRegistry.Resolve(opts.IssuerID, tcc.SystemIRIS, mapIRISEnv(opts.IRISEnv))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tax: resolve TCC for issuer %q: %w", opts.IssuerID, err)
|
|
}
|
|
transmitterTCC = entry.TCC
|
|
transmitterEIN = entry.EIN
|
|
}
|
|
return &iris.FormSubmission{
|
|
FormType: iris.Form1099DIV,
|
|
TaxYear: form.TaxYear,
|
|
Transmitter: iris.Transmitter{
|
|
TCC: transmitterTCC,
|
|
Name: form.PayerName,
|
|
EIN: pickEIN(transmitterEIN, form.PayerEIN),
|
|
},
|
|
Payer: iris.Payer{
|
|
Name: form.PayerName,
|
|
EIN: form.PayerEIN,
|
|
},
|
|
Payees: []iris.PayeeBlock{
|
|
{
|
|
Payee: iris.Payee{
|
|
TIN: stripTIN(form.RecipientTIN),
|
|
TINType: "S",
|
|
Name: form.RecipientName,
|
|
},
|
|
Data: &iris.Form1099DIVData{
|
|
OrdinaryDividends: form.OrdinaryDividends,
|
|
QualifiedDividends: form.QualifiedDividends,
|
|
TotalCapGainDist: form.CapitalGainDist,
|
|
Section199ADividends: form.Section199ADividends,
|
|
FederalTaxWithheld: form.FederalTaxWithheld,
|
|
StateTaxWithheld: form.StateTaxWithheld,
|
|
StateCd: form.State,
|
|
},
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// buildIRISSubmissionFromB constructs an iris.FormSubmission from a
|
|
// captable Form1099B.
|
|
func buildIRISSubmissionFromB(form *Form1099B, opts SubmissionOptions) (*iris.FormSubmission, error) {
|
|
transmitterTCC := ""
|
|
transmitterEIN := ""
|
|
if opts.TCCRegistry != nil && opts.IssuerID != "" {
|
|
entry, err := opts.TCCRegistry.Resolve(opts.IssuerID, tcc.SystemIRIS, mapIRISEnv(opts.IRISEnv))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tax: resolve TCC for issuer %q: %w", opts.IssuerID, err)
|
|
}
|
|
transmitterTCC = entry.TCC
|
|
transmitterEIN = entry.EIN
|
|
}
|
|
st := "S"
|
|
switch form.ShortTermLongTerm {
|
|
case "short":
|
|
st = "S"
|
|
case "long":
|
|
st = "L"
|
|
}
|
|
return &iris.FormSubmission{
|
|
FormType: iris.Form1099B,
|
|
TaxYear: form.TaxYear,
|
|
Transmitter: iris.Transmitter{
|
|
TCC: transmitterTCC,
|
|
Name: form.PayerName,
|
|
EIN: pickEIN(transmitterEIN, form.PayerEIN),
|
|
},
|
|
Payer: iris.Payer{
|
|
Name: form.PayerName,
|
|
EIN: form.PayerEIN,
|
|
},
|
|
Payees: []iris.PayeeBlock{
|
|
{
|
|
Payee: iris.Payee{
|
|
TIN: stripTIN(form.RecipientTIN),
|
|
TINType: "S",
|
|
Name: form.RecipientName,
|
|
},
|
|
Data: &iris.Form1099BData{
|
|
Description: form.Description,
|
|
DateAcquired: form.DateAcquired,
|
|
DateSold: form.DateSold,
|
|
Proceeds: form.Proceeds,
|
|
CostBasis: form.CostBasis,
|
|
ShortTermLongTerm: st,
|
|
FederalTaxWithheld: form.FederalTaxWithheld,
|
|
},
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// buildFIREFileFromDIV constructs a fire.FIREFile carrying a single
|
|
// 1099-DIV payee record from a captable Form1099DIV.
|
|
func buildFIREFileFromDIV(form *Form1099DIV, opts SubmissionOptions) (*fire.FIREFile, error) {
|
|
transmitterTCC := ""
|
|
transmitterEIN := ""
|
|
if opts.TCCRegistry != nil && opts.IssuerID != "" {
|
|
entry, err := opts.TCCRegistry.Resolve(opts.IssuerID, tcc.SystemFIRE, mapFIREEnv(opts.FIREEnv))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tax: resolve TCC for issuer %q: %w", opts.IssuerID, err)
|
|
}
|
|
transmitterTCC = entry.TCC
|
|
transmitterEIN = entry.EIN
|
|
}
|
|
return &fire.FIREFile{
|
|
Transmitter: fire.TransmitterRecord{
|
|
PaymentYear: form.TaxYear,
|
|
TIN: pickEIN(transmitterEIN, form.PayerEIN),
|
|
TCC: transmitterTCC,
|
|
TestFileInd: opts.FIREEnv == fire.EnvTest,
|
|
TransmitterName: form.PayerName,
|
|
CompanyName: form.PayerName,
|
|
CompanyMailingAddress: form.RecipientAddress,
|
|
ContactName: form.PayerName,
|
|
},
|
|
PayerGroups: []fire.PayerGroup{
|
|
{
|
|
Payer: fire.PayerRecord{
|
|
PaymentYear: form.TaxYear,
|
|
PayerTIN: form.PayerEIN,
|
|
TypeOfReturn: fire.FormCode1099DIV,
|
|
AmountCodes: "124",
|
|
PayerName: form.PayerName,
|
|
},
|
|
Payees: []fire.PayeeRecord{
|
|
{
|
|
PaymentYear: form.TaxYear,
|
|
TypeOfTIN: "2",
|
|
PayeeTIN: stripTIN(form.RecipientTIN),
|
|
PaymentAmounts: map[byte]int64{
|
|
'1': dollarsToCents(form.OrdinaryDividends),
|
|
'2': dollarsToCents(form.QualifiedDividends),
|
|
'4': dollarsToCents(form.FederalTaxWithheld),
|
|
},
|
|
PayeeFirstNameLine: form.RecipientName,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// buildFIREFileFromB constructs a fire.FIREFile carrying a 1099-B
|
|
// payee record.
|
|
func buildFIREFileFromB(form *Form1099B, opts SubmissionOptions) (*fire.FIREFile, error) {
|
|
transmitterTCC := ""
|
|
transmitterEIN := ""
|
|
if opts.TCCRegistry != nil && opts.IssuerID != "" {
|
|
entry, err := opts.TCCRegistry.Resolve(opts.IssuerID, tcc.SystemFIRE, mapFIREEnv(opts.FIREEnv))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tax: resolve TCC for issuer %q: %w", opts.IssuerID, err)
|
|
}
|
|
transmitterTCC = entry.TCC
|
|
transmitterEIN = entry.EIN
|
|
}
|
|
return &fire.FIREFile{
|
|
Transmitter: fire.TransmitterRecord{
|
|
PaymentYear: form.TaxYear,
|
|
TIN: pickEIN(transmitterEIN, form.PayerEIN),
|
|
TCC: transmitterTCC,
|
|
TestFileInd: opts.FIREEnv == fire.EnvTest,
|
|
TransmitterName: form.PayerName,
|
|
CompanyName: form.PayerName,
|
|
ContactName: form.PayerName,
|
|
},
|
|
PayerGroups: []fire.PayerGroup{
|
|
{
|
|
Payer: fire.PayerRecord{
|
|
PaymentYear: form.TaxYear,
|
|
PayerTIN: form.PayerEIN,
|
|
TypeOfReturn: fire.FormCode1099B,
|
|
AmountCodes: "23A",
|
|
PayerName: form.PayerName,
|
|
},
|
|
Payees: []fire.PayeeRecord{
|
|
{
|
|
PaymentYear: form.TaxYear,
|
|
TypeOfTIN: "2",
|
|
PayeeTIN: stripTIN(form.RecipientTIN),
|
|
PaymentAmounts: map[byte]int64{
|
|
'2': dollarsToCents(form.Proceeds),
|
|
'3': dollarsToCents(form.CostBasis),
|
|
'A': dollarsToCents(form.FederalTaxWithheld),
|
|
},
|
|
PayeeFirstNameLine: form.RecipientName,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// stripTIN removes hyphens and spaces from a TIN.
|
|
func stripTIN(s string) string {
|
|
out := make([]byte, 0, len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c >= '0' && c <= '9' {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// pickEIN returns the first non-empty EIN from the supplied
|
|
// alternatives.
|
|
func pickEIN(alts ...string) string {
|
|
for _, e := range alts {
|
|
s := stripTIN(e)
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// dollarsToCents converts USD dollars (as float) to integer cents
|
|
// with half-up rounding.
|
|
func dollarsToCents(v float64) int64 {
|
|
if v < 0 {
|
|
return -int64(-v*100 + 0.5)
|
|
}
|
|
return int64(v*100 + 0.5)
|
|
}
|
|
|
|
// mapIRISEnv converts an iris.IRISEnv to the tcc.Env used by the TCC
|
|
// registry.
|
|
func mapIRISEnv(e iris.IRISEnv) tcc.Env {
|
|
if e == iris.EnvProduction {
|
|
return tcc.EnvProduction
|
|
}
|
|
return tcc.EnvSandbox
|
|
}
|
|
|
|
// mapFIREEnv converts a fire.FIREEnv to the tcc.Env used by the TCC
|
|
// registry.
|
|
func mapFIREEnv(e fire.FIREEnv) tcc.Env {
|
|
if e == fire.EnvProduction {
|
|
return tcc.EnvProduction
|
|
}
|
|
return tcc.EnvSandbox
|
|
}
|