Files
zeekayandHanzo Dev 1977136f52 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers: min, max, slices.Sort, strings.Cut and range over int. omitzero
is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:46:18 -07:00

494 lines
14 KiB
Go

// Package fire — IRS FIRE (Filing Information Returns Electronically)
// e-file adapter for the 1099 series. FIRE is the legacy IRS e-file
// surface; IRIS is the modern replacement and is mandatory going
// forward (TY 2024+). The FIRE adapter is preserved here to file
// corrections against older years that were originally submitted to
// FIRE and remain in IRS's FIRE-side records.
//
// FIRE file format is fixed-width records, 750 bytes per record (left-
// padded with zeros, right-padded with blanks), record types T A B C K
// F. The adapter serializes a FIREFile into the wire bytes and posts
// to the FIRE upload endpoint.
//
// Source-of-design: Public-Spec
// Source-ref: IRS Publication 1220 — Specifications for Electronic Filing of Forms 1097, 1098, 1099, 3921, 3922, 5498, and W-2G
// Source-ref: https://www.irs.gov/pub/irs-pdf/p1220.pdf
package fire
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"math/rand"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
)
// Environment endpoints for the FIRE submission surface. Per
// Publication 1220 Part B §3.
const (
// ProdURL is the FIRE production system endpoint.
ProdURL = "https://fire.irs.gov"
// TestURL is the FIRE test system endpoint.
TestURL = "https://fire.test.irs.gov"
)
// RecordLen is the fixed FIRE record length per Publication 1220 Part
// C §1. Every record (T, A, B, C, K, F) is exactly 750 bytes.
const RecordLen = 750
// Errors surfaced by the adapter.
var (
ErrMissingTCC = errors.New("fire: TCC is required")
ErrInvalidFile = errors.New("fire: file validation failed")
ErrSubmissionRejected = errors.New("fire: submission rejected")
ErrRateLimited = errors.New("fire: rate limited after retry budget exhausted")
ErrUnsupportedFormCode = errors.New("fire: unsupported form code")
)
// APIError is the structured error returned when FIRE replies with a
// non-2xx status.
type APIError struct {
StatusCode int
Endpoint string
Body string
RetryAfter time.Duration
}
// Error implements the error interface.
func (e *APIError) Error() string {
return fmt.Sprintf("fire API %d on %s: %s", e.StatusCode, e.Endpoint, e.Body)
}
// Config holds the per-issuer authentication and tuning for the FIRE
// adapter. TCC + LoginName + PasswordHash are loaded from KMS, never
// logged.
type Config struct {
// TCC is the IRS-assigned Transmitter Control Code (5 chars).
TCC string
// Env is one of EnvProduction or EnvTest.
Env FIREEnv
// LoginName is the FIRE-system user name registered against the
// transmitter.
LoginName string
// PasswordHash is the FIRE-system password (loaded from KMS, never
// logged).
PasswordHash string
// UserAgent is the value sent on every outbound request.
UserAgent string
// MaxRetries caps the number of 429 / 503 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
// BaseURL overrides the env-resolved endpoint. Test-only.
BaseURL string
// Clock returns the current time; defaults to time.Now.
Clock func() time.Time
}
// Client is the FIRE submission client.
type Client struct {
cfg Config
client *http.Client
rand *rand.Rand
baseURL string
}
// NewClient constructs a FIRE Client.
func NewClient(tcc string, env FIREEnv, opts ...Option) *Client {
cfg := Config{
TCC: tcc,
Env: env,
UserAgent: "luxfi-captable-fire/1.0",
MaxRetries: 4,
RetryBaseDelay: 500 * time.Millisecond,
RetryMaxDelay: 30 * time.Second,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
Clock: time.Now,
}
for _, opt := range opts {
opt(&cfg)
}
baseURL := cfg.BaseURL
if baseURL == "" {
switch env {
case EnvProduction:
baseURL = ProdURL
default:
baseURL = TestURL
}
}
return &Client{
cfg: cfg,
client: cfg.HTTPClient,
rand: rand.New(rand.NewSource(cfg.Clock().UnixNano())),
baseURL: baseURL,
}
}
// Option is a functional option to NewClient.
type Option func(*Config)
// WithCredentials sets the FIRE login credentials.
func WithCredentials(login, passHash string) Option {
return func(cfg *Config) {
cfg.LoginName = login
cfg.PasswordHash = passHash
}
}
// WithHTTPClient overrides the default http.Client.
func WithHTTPClient(c *http.Client) Option { return func(cfg *Config) { cfg.HTTPClient = c } }
// WithBaseURL overrides the env-resolved FIRE endpoint. Test-only.
func WithBaseURL(u string) Option { return func(cfg *Config) { cfg.BaseURL = u } }
// WithUserAgent sets a custom User-Agent header.
func WithUserAgent(ua string) Option { return func(cfg *Config) { cfg.UserAgent = ua } }
// WithMaxRetries sets the retry cap on 429 / 503 responses.
func WithMaxRetries(n int) Option { return func(cfg *Config) { cfg.MaxRetries = n } }
// WithClock sets a custom clock (test-only).
func WithClock(fn func() time.Time) Option { return func(cfg *Config) { cfg.Clock = fn } }
// TCC returns the configured Transmitter Control Code.
func (c *Client) TCC() string { return c.cfg.TCC }
// Env returns the configured FIRE environment.
func (c *Client) Env() FIREEnv { return c.cfg.Env }
// BaseURL returns the resolved FIRE base URL.
func (c *Client) BaseURL() string { return c.baseURL }
// SubmitFile uploads a FIREFile to the FIRE system. The file is
// marshaled into fixed-width records, posted as a multipart upload,
// and the FIRE reply parsed into an Acknowledgment.
func (c *Client) SubmitFile(ctx context.Context, f *FIREFile) (*Acknowledgment, error) {
if c.cfg.TCC == "" {
return nil, ErrMissingTCC
}
if err := validateFile(f, c.cfg.Env == EnvTest); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidFile, err)
}
wire, err := Marshal(f)
if err != nil {
return nil, fmt.Errorf("fire: marshal file: %w", err)
}
endpoint := "/system/sendFile.aspx"
uploadName := fmt.Sprintf("1099_%s_%d_%s.txt",
f.Transmitter.TCC, f.Transmitter.PaymentYear, c.fileHash(wire))
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.WriteField("LoginName", c.cfg.LoginName); err != nil {
return nil, fmt.Errorf("fire: write LoginName: %w", err)
}
if err := w.WriteField("Password", c.cfg.PasswordHash); err != nil {
return nil, fmt.Errorf("fire: write Password: %w", err)
}
if err := w.WriteField("TCC", c.cfg.TCC); err != nil {
return nil, fmt.Errorf("fire: write TCC: %w", err)
}
part, err := w.CreateFormFile("file", uploadName)
if err != nil {
return nil, fmt.Errorf("fire: create file part: %w", err)
}
if _, err := part.Write(wire); err != nil {
return nil, fmt.Errorf("fire: write file payload: %w", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("fire: close multipart: %w", err)
}
respBody, statusCode, err := c.doMultipart(ctx, endpoint, w.FormDataContentType(), buf.Bytes())
if err != nil {
// If FIRE returned an HTTP error AND a parseable rejection body,
// prefer the typed ErrSubmissionRejected.
if len(respBody) > 0 {
if ack, perr := parseAcknowledgment(respBody); perr == nil &&
(strings.EqualFold(ack.Status, "Bad") || len(ack.Errors) > 0) {
if ack.Filename == "" {
ack.Filename = uploadName
}
if ack.SubmittedAt.IsZero() {
ack.SubmittedAt = c.cfg.Clock().UTC()
}
return ack, fmt.Errorf("%w: %s", ErrSubmissionRejected, strings.Join(ack.Errors, "; "))
}
}
return nil, err
}
ack, perr := parseAcknowledgment(respBody)
if perr != nil {
return nil, fmt.Errorf("fire: parse acknowledgment: %w", perr)
}
if ack.Filename == "" {
ack.Filename = uploadName
}
if ack.SubmittedAt.IsZero() {
ack.SubmittedAt = c.cfg.Clock().UTC()
}
if statusCode >= 400 || strings.EqualFold(ack.Status, "Bad") {
return ack, fmt.Errorf("%w: %s", ErrSubmissionRejected, strings.Join(ack.Errors, "; "))
}
return ack, nil
}
// --- internals ---
// fileHash returns a short hex hash to disambiguate filenames in the
// FIRE filing UI. Not transmitted to FIRE in semantic-content positions.
func (c *Client) fileHash(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:8])
}
// doMultipart performs a single multipart POST against the FIRE
// surface with the standard 429/5xx retry loop.
func (c *Client) doMultipart(ctx context.Context, path, contentType string, body []byte) ([]byte, int, error) {
var (
lastStatus int
lastBody []byte
lastRetryAfter time.Duration
)
doOnce := func() (*http.Response, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "text/html, application/json, text/plain")
req.Header.Set("User-Agent", c.cfg.UserAgent)
req.Header.Set("X-IRS-TCC", c.cfg.TCC)
resp, err := c.client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
return resp, respBody, err
}
for attempt := 0; attempt <= c.cfg.MaxRetries; attempt++ {
resp, respBody, err := doOnce()
if err != nil {
if attempt == c.cfg.MaxRetries {
return nil, 0, err
}
if waitErr := c.sleepBackoff(ctx, attempt, 0); waitErr != nil {
return nil, 0, waitErr
}
continue
}
lastStatus = resp.StatusCode
lastBody = respBody
if resp.StatusCode == http.StatusTooManyRequests ||
(resp.StatusCode >= 500 && resp.StatusCode != http.StatusNotImplemented) {
lastRetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"))
if attempt == c.cfg.MaxRetries {
break
}
if waitErr := c.sleepBackoff(ctx, attempt, lastRetryAfter); waitErr != nil {
return nil, 0, waitErr
}
continue
}
if resp.StatusCode >= 400 {
return respBody, resp.StatusCode, &APIError{
StatusCode: resp.StatusCode,
Endpoint: path,
Body: string(respBody),
}
}
return respBody, resp.StatusCode, nil
}
apiErr := &APIError{
StatusCode: lastStatus,
Endpoint: path,
Body: string(lastBody),
RetryAfter: lastRetryAfter,
}
if lastStatus == http.StatusTooManyRequests {
return nil, lastStatus, fmt.Errorf("%w: %s", ErrRateLimited, apiErr.Error())
}
return nil, lastStatus, apiErr
}
// sleepBackoff waits between retries.
func (c *Client) sleepBackoff(ctx context.Context, attempt int, retryAfter time.Duration) error {
var wait time.Duration
if retryAfter > 0 {
wait = retryAfter
} else {
exp := c.cfg.RetryBaseDelay << attempt
if exp <= 0 || exp > c.cfg.RetryMaxDelay {
exp = c.cfg.RetryMaxDelay
}
wait = time.Duration(c.rand.Int63n(int64(exp) + 1))
}
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
func parseRetryAfter(s string) time.Duration {
if s == "" {
return 0
}
if secs, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && secs >= 0 {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(s); err == nil {
d := time.Until(t)
if d > 0 {
return d
}
}
return 0
}
// validateFile runs client-side validation against a FIREFile.
func validateFile(f *FIREFile, isTest bool) error {
if f == nil {
return fmt.Errorf("file is nil")
}
if f.Transmitter.TCC == "" {
return fmt.Errorf("transmitter.tcc is required")
}
if len(f.Transmitter.TCC) != 5 {
return fmt.Errorf("transmitter.tcc must be 5 chars, got %d", len(f.Transmitter.TCC))
}
if f.Transmitter.TIN == "" {
return fmt.Errorf("transmitter.tin is required")
}
if len(f.Transmitter.TIN) != 9 {
return fmt.Errorf("transmitter.tin must be 9 digits, got %d", len(f.Transmitter.TIN))
}
if f.Transmitter.PaymentYear < 2000 || f.Transmitter.PaymentYear > time.Now().Year()+1 {
return fmt.Errorf("transmitter.payment_year %d out of range", f.Transmitter.PaymentYear)
}
if isTest && !f.Transmitter.TestFileInd {
return fmt.Errorf("transmitter.test_file_ind must be true for FIRE test environment")
}
if len(f.PayerGroups) == 0 {
return fmt.Errorf("at least one payer group is required")
}
for i, g := range f.PayerGroups {
if g.Payer.PayerTIN == "" {
return fmt.Errorf("payer_groups[%d].payer.tin is required", i)
}
if len(g.Payer.PayerTIN) != 9 {
return fmt.Errorf("payer_groups[%d].payer.tin must be 9 digits", i)
}
if g.Payer.TypeOfReturn == "" {
return fmt.Errorf("payer_groups[%d].payer.type_of_return is required", i)
}
if g.Payer.AmountCodes == "" {
return fmt.Errorf("payer_groups[%d].payer.amount_codes is required", i)
}
if len(g.Payees) == 0 {
return fmt.Errorf("payer_groups[%d] must have at least one payee", i)
}
for j, p := range g.Payees {
if p.PayeeTIN == "" {
return fmt.Errorf("payer_groups[%d].payees[%d].tin is required", i, j)
}
if len(p.PayeeTIN) != 9 {
return fmt.Errorf("payer_groups[%d].payees[%d].tin must be 9 digits", i, j)
}
}
}
return nil
}
// parseAcknowledgment decodes the FIRE acknowledgment reply. FIRE
// returns HTML on the web upload and JSON on the API surface; the
// adapter sniffs the content.
func parseAcknowledgment(body []byte) (*Acknowledgment, error) {
if len(body) == 0 {
return &Acknowledgment{Status: "Good"}, nil
}
s := strings.TrimSpace(string(body))
if strings.HasPrefix(s, "{") {
return parseJSONAck(body)
}
// HTML path — extract the Filename and FileStatus tokens FIRE
// renders in the success-page output.
ack := &Acknowledgment{}
if _, after, ok := strings.Cut(s, "Filename:"); ok {
rest := strings.TrimSpace(after)
if nl := strings.IndexAny(rest, "\r\n<"); nl > 0 {
ack.Filename = strings.TrimSpace(rest[:nl])
}
}
if _, after, ok := strings.Cut(s, "Status:"); ok {
rest := strings.TrimSpace(after)
if nl := strings.IndexAny(rest, "\r\n<"); nl > 0 {
ack.Status = strings.TrimSpace(rest[:nl])
}
}
if ack.Status == "" {
ack.Status = "Good"
}
return ack, nil
}
func parseJSONAck(body []byte) (*Acknowledgment, error) {
type wire struct {
Filename string `json:"filename"`
Status string `json:"status"`
SubmittedAt string `json:"submitted_at"`
Errors []string `json:"errors"`
Messages []string `json:"messages"`
}
var w wire
if err := jsonUnmarshal(body, &w); err != nil {
return nil, err
}
ack := &Acknowledgment{
Filename: w.Filename,
Status: w.Status,
Errors: w.Errors,
Messages: w.Messages,
}
if w.SubmittedAt != "" {
if t, err := time.Parse(time.RFC3339, w.SubmittedAt); err == nil {
ack.SubmittedAt = t
}
}
return ack, nil
}