Files
captable/pkg/tax/fire/client_test.go
zeekay 1d69d5b300 feat(tax): G-15 IRS IRIS + FIRE + CFSF + TCC e-file adapters
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]>
2026-05-26 18:24:44 -07:00

481 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package fire
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// --- fixtures ---
func testTransmitter() TransmitterRecord {
return TransmitterRecord{
PaymentYear: 2025,
TIN: "123456789",
TCC: "ABC12",
TestFileInd: true,
TransmitterName: "LUX INDUSTRIES INC",
CompanyName: "LUX INDUSTRIES INC",
CompanyMailingAddress: "1 LUX WAY",
CompanyCity: "WILMINGTON",
CompanyState: "DE",
CompanyZip: "198010000",
ContactName: "TAX OPERATOR",
ContactPhone: "5555551234",
ContactEmail: "[email protected]",
VendorIndicator: "I",
}
}
func testPayerGroup(formCode FormCode, amountCodes string) PayerGroup {
return PayerGroup{
Payer: PayerRecord{
PaymentYear: 2025,
PayerTIN: "987654321",
PayerNameControl: "ACME",
TypeOfReturn: formCode,
AmountCodes: amountCodes,
PayerName: "ACME CORPORATION",
PayerShippingAddress: "100 ACME PLAZA",
PayerCity: "TULSA",
PayerState: "OK",
PayerZip: "741040000",
PayerPhone: "9185551111",
},
Payees: []PayeeRecord{
{
PaymentYear: 2025,
NameControl: "DOE ",
TypeOfTIN: "2",
PayeeTIN: "111223333",
PayerAccountNum: "ACCT-001",
PaymentAmounts: map[byte]int64{'1': 500000, '2': 450000, '4': 75000},
PayeeFirstNameLine: "DOE JANE",
PayeeMailingAddress: "200 MAIN ST",
PayeeCity: "TULSA",
PayeeState: "OK",
PayeeZip: "741030000",
},
},
}
}
// --- marshal ---
func TestMarshal_FileLayout(t *testing.T) {
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{
testPayerGroup(FormCode1099DIV, "124"),
},
}
out, err := Marshal(f)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
// Each record is 750 + 2 bytes (CR-LF). T + A + B + C + F = 5 records.
expected := 5 * (RecordLen + 2)
if len(out) != expected {
t.Fatalf("len(out) = %d, want %d (5 records × 752)", len(out), expected)
}
// First byte of each record must be the record type.
for i, ch := range []byte{'T', 'A', 'B', 'C', 'F'} {
off := i * (RecordLen + 2)
if out[off] != ch {
t.Fatalf("record %d (offset %d) = %c, want %c", i, off, out[off], ch)
}
}
// CR-LF at the end of every record.
for i := 0; i < 5; i++ {
off := i*(RecordLen+2) + RecordLen
if out[off] != '\r' || out[off+1] != '\n' {
t.Fatalf("record %d missing CR-LF terminator", i)
}
}
}
func TestMarshal_TransmitterFields(t *testing.T) {
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{
testPayerGroup(FormCode1099DIV, "1"),
},
}
out, err := Marshal(f)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
tRec := out[:RecordLen]
// Position 1: 'T'
if tRec[0] != 'T' {
t.Fatalf("T[0] = %c", tRec[0])
}
// Position 2-5: PaymentYear "2025"
if string(tRec[1:5]) != "2025" {
t.Fatalf("T[1:5] = %q, want 2025", string(tRec[1:5]))
}
// Position 7-15: TIN
if string(tRec[6:15]) != "123456789" {
t.Fatalf("T[6:15] = %q, want 123456789", string(tRec[6:15]))
}
// Position 16-20: TCC
if string(tRec[15:20]) != "ABC12" {
t.Fatalf("T[15:20] = %q, want ABC12", string(tRec[15:20]))
}
// Position 28: TestFileInd = T
if tRec[27] != 'T' {
t.Fatalf("T[27] = %c, want T", tRec[27])
}
// Position 30-69: TransmitterName left-justified.
got := strings.TrimRight(string(tRec[29:69]), " ")
if got != "LUX INDUSTRIES INC" {
t.Fatalf("transmitter name = %q", got)
}
// Position 296-303: TotalPayees zero-padded right-justified.
if string(tRec[295:303]) != "00000001" {
t.Fatalf("total payees = %q, want 00000001", string(tRec[295:303]))
}
}
func TestMarshal_PayeeAmountCodes(t *testing.T) {
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{
testPayerGroup(FormCode1099DIV, "124"),
},
}
out, err := Marshal(f)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
bRec := out[2*(RecordLen+2) : 2*(RecordLen+2)+RecordLen]
if bRec[0] != 'B' {
t.Fatalf("not a B record")
}
// Amount code '1' at positions 55-66 (12 positions).
// 5000.00 dollars = 500000 cents -> "000000500000"
if string(bRec[54:66]) != "000000500000" {
t.Fatalf("amount[1] = %q, want 000000500000", string(bRec[54:66]))
}
// Amount code '2' at positions 67-78.
if string(bRec[66:78]) != "000000450000" {
t.Fatalf("amount[2] = %q, want 000000450000", string(bRec[66:78]))
}
// Amount code '4' at positions 91-102.
if string(bRec[90:102]) != "000000075000" {
t.Fatalf("amount[4] = %q, want 000000075000", string(bRec[90:102]))
}
}
func TestMarshal_EndOfPayerTotals(t *testing.T) {
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{
testPayerGroup(FormCode1099DIV, "12"),
},
}
// Add a second payee with different amounts.
f.PayerGroups[0].Payees = append(f.PayerGroups[0].Payees, PayeeRecord{
PaymentYear: 2025,
NameControl: "SMIT",
TypeOfTIN: "2",
PayeeTIN: "444556666",
PaymentAmounts: map[byte]int64{'1': 250000, '2': 200000},
PayeeFirstNameLine: "SMITH JOHN",
PayeeMailingAddress: "300 OAK AVE",
PayeeCity: "TULSA",
PayeeState: "OK",
PayeeZip: "741030000",
})
out, err := Marshal(f)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
// Layout: T + A + B + B + C + F = 6 records.
cRec := out[4*(RecordLen+2) : 4*(RecordLen+2)+RecordLen]
if cRec[0] != 'C' {
t.Fatalf("not a C record")
}
// Num payees at 2-9.
if string(cRec[1:9]) != "00000002" {
t.Fatalf("num payees = %q, want 00000002", string(cRec[1:9]))
}
// Amount code '1' total at positions 16-33 (18 positions).
// 500000 + 250000 = 750000 cents
if string(cRec[15:33]) != "000000000000750000" {
t.Fatalf("amount[1] total = %q, want 000000000000750000", string(cRec[15:33]))
}
}
func TestMarshal_EndOfFile(t *testing.T) {
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{
testPayerGroup(FormCode1099DIV, "1"),
testPayerGroup(FormCode1099INT, "1"),
},
}
out, err := Marshal(f)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
// Layout: T + A + B + C + A + B + C + F = 8 records.
// F is the last record.
off := 7 * (RecordLen + 2)
fRec := out[off : off+RecordLen]
if fRec[0] != 'F' {
t.Fatalf("not an F record")
}
if string(fRec[1:9]) != "00000002" {
t.Fatalf("num payers = %q, want 00000002", string(fRec[1:9]))
}
if string(fRec[21:29]) != "00000002" {
t.Fatalf("num payees = %q, want 00000002", string(fRec[21:29]))
}
}
func TestMarshal_AllFormCodes(t *testing.T) {
cases := []FormCode{
FormCode1099DIV,
FormCode1099INT,
FormCode1099B,
FormCode1099MISC,
FormCode1099NEC,
FormCode1099OID,
FormCode1099K,
FormCode1099R,
}
for _, fc := range cases {
t.Run(string(fc), func(t *testing.T) {
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{testPayerGroup(fc, "1")},
}
out, err := Marshal(f)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
// A record carries the form code at positions 26-27.
aRec := out[RecordLen+2 : 2*(RecordLen+2)-2]
if got := string(aRec[25:27]); got != string(fc) {
t.Fatalf("form code = %q, want %q", got, string(fc))
}
})
}
}
func TestMarshal_CFSF(t *testing.T) {
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{
testPayerGroup(FormCode1099DIV, "1"),
},
StateTotals: []StateTotalsRecord{
{
NumPayees: 1,
AmountTotals: map[byte]int64{'1': 500000},
StateIncomeTaxWithheld: 25000,
CombinedFedStateCode: "06", // California
},
},
}
f.PayerGroups[0].Payer.CFSFInd = true
f.PayerGroups[0].Payees[0].StateInfo = PayeeStateInfo{
StateIncomeTaxWithheld: 25000,
CombinedFedStateCode: "06",
}
out, err := Marshal(f)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
// Find the K record.
// Layout: T + A + B + C + K + F.
kOff := 4 * (RecordLen + 2)
kRec := out[kOff : kOff+RecordLen]
if kRec[0] != 'K' {
t.Fatalf("not a K record")
}
// Combined federal/state code at positions 747-748.
if string(kRec[746:748]) != "06" {
t.Fatalf("CFSF state code = %q, want 06", string(kRec[746:748]))
}
}
// --- client / http ---
func newAcceptingServer(t *testing.T) (*httptest.Server, *captured) {
t.Helper()
cap := &captured{}
mux := http.NewServeMux()
mux.HandleFunc("/system/sendFile.aspx", func(w http.ResponseWriter, r *http.Request) {
cap.submitCount++
body, _ := io.ReadAll(r.Body)
cap.lastBody = body
cap.lastTCC = r.Header.Get("X-IRS-TCC")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"filename": "1099_ABC12_2025_test.txt",
"status": "Good",
"submitted_at": time.Now().UTC().Format(time.RFC3339),
})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, cap
}
type captured struct {
submitCount int
lastTCC string
lastBody []byte
}
func TestSubmitFile_HappyPath(t *testing.T) {
srv, cap := newAcceptingServer(t)
c := NewClient("ABC12", EnvTest,
WithBaseURL(srv.URL),
WithCredentials("fire-user", "fire-pass"),
WithMaxRetries(1),
WithClock(func() time.Time { return time.Date(2026, 5, 25, 12, 0, 0, 0, time.UTC) }),
)
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{testPayerGroup(FormCode1099DIV, "1")},
}
ack, err := c.SubmitFile(context.Background(), f)
if err != nil {
t.Fatalf("SubmitFile: %v", err)
}
if ack.Status != "Good" {
t.Fatalf("Status = %q, want Good", ack.Status)
}
if ack.Filename == "" {
t.Fatalf("Filename empty")
}
if cap.lastTCC != "ABC12" {
t.Fatalf("X-IRS-TCC = %q", cap.lastTCC)
}
// Multipart body contains the wire-format file content.
if !bytesContains(cap.lastBody, []byte("LUX INDUSTRIES INC")) {
t.Fatalf("body missing transmitter name")
}
}
func TestSubmitFile_ValidationErrors(t *testing.T) {
c := NewClient("ABC12", EnvTest,
WithCredentials("u", "p"),
)
ctx := context.Background()
_, err := c.SubmitFile(ctx, &FIREFile{
Transmitter: TransmitterRecord{PaymentYear: 2025, TCC: "ABC12"},
})
if err == nil || !strings.Contains(err.Error(), "tin is required") {
t.Fatalf("err = %v, want missing tin", err)
}
_, err = c.SubmitFile(ctx, &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{},
})
if err == nil || !strings.Contains(err.Error(), "at least one payer group") {
t.Fatalf("err = %v, want missing payer group", err)
}
bad := testTransmitter()
bad.TIN = "12"
_, err = c.SubmitFile(ctx, &FIREFile{
Transmitter: bad,
PayerGroups: []PayerGroup{testPayerGroup(FormCode1099DIV, "1")},
})
if err == nil || !strings.Contains(err.Error(), "9 digits") {
t.Fatalf("err = %v, want 9 digits", err)
}
}
func TestSubmitFile_MissingTCC(t *testing.T) {
c := NewClient("", EnvTest)
_, err := c.SubmitFile(context.Background(), &FIREFile{})
if err != ErrMissingTCC {
t.Fatalf("err = %v, want ErrMissingTCC", err)
}
}
func TestEnv_BaseURL(t *testing.T) {
prod := NewClient("X", EnvProduction)
if prod.BaseURL() != ProdURL {
t.Fatalf("prod = %q, want %q", prod.BaseURL(), ProdURL)
}
test := NewClient("X", EnvTest)
if test.BaseURL() != TestURL {
t.Fatalf("test = %q, want %q", test.BaseURL(), TestURL)
}
}
func TestSubmitFile_Rejected(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/system/sendFile.aspx", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"filename": "1099_ABC12_2025.txt",
"status": "Bad",
"errors": []string{"transmitter EIN mismatch"},
})
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewClient("ABC12", EnvTest,
WithBaseURL(srv.URL),
WithCredentials("u", "p"),
)
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{testPayerGroup(FormCode1099DIV, "1")},
}
_, err := c.SubmitFile(context.Background(), f)
if err == nil || !strings.Contains(err.Error(), "rejected") {
t.Fatalf("err = %v, want rejected", err)
}
}
func bytesContains(haystack, needle []byte) bool {
return strings.Contains(string(haystack), string(needle))
}
// --- happy path per form type (8 forms) ---
func TestSubmitFile_AllFormCodes_HappyPaths(t *testing.T) {
srv, _ := newAcceptingServer(t)
cases := []FormCode{
FormCode1099DIV, FormCode1099INT, FormCode1099B,
FormCode1099MISC, FormCode1099NEC, FormCode1099OID,
FormCode1099K, FormCode1099R,
}
c := NewClient("ABC12", EnvTest,
WithBaseURL(srv.URL),
WithCredentials("u", "p"),
)
for _, fc := range cases {
t.Run(string(fc), func(t *testing.T) {
f := &FIREFile{
Transmitter: testTransmitter(),
PayerGroups: []PayerGroup{testPayerGroup(fc, "1")},
}
ack, err := c.SubmitFile(context.Background(), f)
if err != nil {
t.Fatalf("SubmitFile: %v", err)
}
if ack.Status != "Good" {
t.Fatalf("Status = %q", ack.Status)
}
})
}
}