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]>
512 lines
14 KiB
Go
512 lines
14 KiB
Go
// FIRE fixed-width record marshaling per Publication 1220 Part C.
|
|
//
|
|
// Every record is exactly RecordLen (750) bytes plus CR-LF; numeric
|
|
// fields are right-justified zero-filled, alphanumeric fields are
|
|
// left-justified blank-filled. Dollar amounts are in cents (no decimal
|
|
// point) and take exactly 12 positions, right-justified zero-filled.
|
|
//
|
|
// Source-of-design: Public-Spec
|
|
// Source-ref: IRS Publication 1220 Part C — Record Formats
|
|
package fire
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// jsonUnmarshal is a small wrapper to keep the import set in
|
|
// client.go small.
|
|
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
|
|
|
// recordBuf is a builder for a single fixed-width record. The buffer
|
|
// is pre-allocated to RecordLen bytes of blanks and overwritten
|
|
// positionally; on Bytes() it is returned with a trailing CR-LF.
|
|
type recordBuf struct {
|
|
buf []byte
|
|
}
|
|
|
|
// newRecord returns a recordBuf of RecordLen blank bytes.
|
|
func newRecord() *recordBuf {
|
|
b := make([]byte, RecordLen)
|
|
for i := range b {
|
|
b[i] = ' '
|
|
}
|
|
return &recordBuf{buf: b}
|
|
}
|
|
|
|
// putChar writes one byte at one-based column col (column-1 is the
|
|
// record-type marker). Out-of-range writes are silently dropped to
|
|
// keep the marshal side panic-free.
|
|
func (r *recordBuf) putChar(col int, c byte) {
|
|
idx := col - 1
|
|
if idx < 0 || idx >= len(r.buf) {
|
|
return
|
|
}
|
|
r.buf[idx] = c
|
|
}
|
|
|
|
// putString writes s into the [start, end] one-based inclusive range.
|
|
// Strings are left-justified, blank-padded; longer strings are
|
|
// truncated.
|
|
func (r *recordBuf) putString(start, end int, s string) {
|
|
a := start - 1
|
|
b := end // inclusive
|
|
if a < 0 || b > len(r.buf) || a >= b {
|
|
return
|
|
}
|
|
width := b - a
|
|
if len(s) > width {
|
|
s = s[:width]
|
|
}
|
|
copy(r.buf[a:a+len(s)], s)
|
|
}
|
|
|
|
// putUpper writes s upper-cased into the range.
|
|
func (r *recordBuf) putUpper(start, end int, s string) {
|
|
r.putString(start, end, strings.ToUpper(s))
|
|
}
|
|
|
|
// putAlnum writes s into the range stripped of non-alnum characters,
|
|
// upper-cased.
|
|
func (r *recordBuf) putAlnum(start, end int, s string) {
|
|
var b strings.Builder
|
|
for _, c := range s {
|
|
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
|
b.WriteRune(c)
|
|
}
|
|
}
|
|
r.putUpper(start, end, b.String())
|
|
}
|
|
|
|
// putDigits writes a numeric string into the range, right-justified
|
|
// zero-padded. Non-digit characters are stripped.
|
|
func (r *recordBuf) putDigits(start, end int, s string) {
|
|
a := start - 1
|
|
b := end
|
|
if a < 0 || b > len(r.buf) || a >= b {
|
|
return
|
|
}
|
|
width := b - a
|
|
digits := stripNonDigits(s)
|
|
if len(digits) > width {
|
|
digits = digits[len(digits)-width:]
|
|
}
|
|
// Right-justify zero-padded.
|
|
for i := a; i < a+(width-len(digits)); i++ {
|
|
r.buf[i] = '0'
|
|
}
|
|
copy(r.buf[a+width-len(digits):a+width], digits)
|
|
}
|
|
|
|
// putIntZeroPad writes an integer right-justified zero-padded.
|
|
func (r *recordBuf) putIntZeroPad(start, end int, v int) {
|
|
r.putDigits(start, end, strconv.Itoa(v))
|
|
}
|
|
|
|
// putCents writes a dollar amount (in cents) right-justified zero-
|
|
// padded into a 12-position field by default.
|
|
func (r *recordBuf) putCents(start, end int, cents int64) {
|
|
r.putDigits(start, end, strconv.FormatInt(cents, 10))
|
|
}
|
|
|
|
// putBool writes '1' / blank for true / false at one column.
|
|
func (r *recordBuf) putBool(col int, v bool) {
|
|
if v {
|
|
r.putChar(col, '1')
|
|
}
|
|
}
|
|
|
|
// putBoolMark writes a specific mark char if v is true; blank
|
|
// otherwise. Used for FIRE's overloaded "X" / "T" / "P" / "G"
|
|
// indicators.
|
|
func (r *recordBuf) putBoolMark(col int, v bool, mark byte) {
|
|
if v {
|
|
r.putChar(col, mark)
|
|
}
|
|
}
|
|
|
|
// bytes returns the record followed by CR-LF.
|
|
func (r *recordBuf) bytes() []byte {
|
|
out := make([]byte, 0, len(r.buf)+2)
|
|
out = append(out, r.buf...)
|
|
out = append(out, '\r', '\n')
|
|
return out
|
|
}
|
|
|
|
// stripNonDigits returns s with all non-digit characters removed.
|
|
func stripNonDigits(s string) string {
|
|
var b strings.Builder
|
|
for _, c := range s {
|
|
if c >= '0' && c <= '9' {
|
|
b.WriteRune(c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// amountColRange returns the (start, end) one-based column range for
|
|
// the given FIRE amount code on a B record per Publication 1220 Part
|
|
// C §2 (B record layout). Amount codes are '1' through '9' followed by
|
|
// 'A' through 'I' (18 codes), each occupying 12 positions starting at
|
|
// column 55.
|
|
func amountColRange(code byte) (int, int, bool) {
|
|
var index int
|
|
switch {
|
|
case code >= '1' && code <= '9':
|
|
index = int(code - '1')
|
|
case code >= 'A' && code <= 'I':
|
|
index = 9 + int(code-'A')
|
|
default:
|
|
return 0, 0, false
|
|
}
|
|
start := 55 + index*12
|
|
end := start + 11
|
|
return start, end, true
|
|
}
|
|
|
|
// amountTotalsRange returns the (start, end) one-based column range
|
|
// for the given FIRE amount code on a C or K record. Totals are 18-
|
|
// position fields, 18 codes total, starting at column 16 on a C
|
|
// record.
|
|
func amountTotalsRange(code byte, baseCol int) (int, int, bool) {
|
|
var index int
|
|
switch {
|
|
case code >= '1' && code <= '9':
|
|
index = int(code - '1')
|
|
case code >= 'A' && code <= 'I':
|
|
index = 9 + int(code-'A')
|
|
default:
|
|
return 0, 0, false
|
|
}
|
|
start := baseCol + index*18
|
|
end := start + 17
|
|
return start, end, true
|
|
}
|
|
|
|
// Marshal serializes a FIREFile into the FIRE fixed-width wire
|
|
// format. The output is the concatenation of T + (A + B* + C)* + K* +
|
|
// F records, each terminated with CR-LF.
|
|
func Marshal(f *FIREFile) ([]byte, error) {
|
|
if f == nil {
|
|
return nil, fmt.Errorf("nil file")
|
|
}
|
|
var buf bytes.Buffer
|
|
|
|
// Populate sequence numbers and counters as we render. The T
|
|
// record is always sequence 1.
|
|
seq := 1
|
|
if f.Transmitter.SequenceNum == 0 {
|
|
f.Transmitter.SequenceNum = seq
|
|
}
|
|
seq++
|
|
|
|
tRec, err := marshalTransmitter(&f.Transmitter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("transmitter record: %w", err)
|
|
}
|
|
buf.Write(tRec)
|
|
|
|
totalPayers := 0
|
|
totalPayees := 0
|
|
|
|
for i := range f.PayerGroups {
|
|
grp := &f.PayerGroups[i]
|
|
if grp.Payer.SequenceNum == 0 {
|
|
grp.Payer.SequenceNum = seq
|
|
}
|
|
seq++
|
|
if grp.Payer.PaymentYear == 0 {
|
|
grp.Payer.PaymentYear = f.Transmitter.PaymentYear
|
|
}
|
|
|
|
aRec, err := marshalPayer(&grp.Payer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("payer group %d: %w", i, err)
|
|
}
|
|
buf.Write(aRec)
|
|
totalPayers++
|
|
|
|
// Accumulate per-amount-code totals for the C record.
|
|
totals := make(map[byte]int64)
|
|
for j := range grp.Payees {
|
|
pe := &grp.Payees[j]
|
|
if pe.SequenceNum == 0 {
|
|
pe.SequenceNum = seq
|
|
}
|
|
seq++
|
|
if pe.PaymentYear == 0 {
|
|
pe.PaymentYear = grp.Payer.PaymentYear
|
|
}
|
|
bRec, err := marshalPayee(pe, grp.Payer.AmountCodes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("payee %d in group %d: %w", j, i, err)
|
|
}
|
|
buf.Write(bRec)
|
|
for c, amt := range pe.PaymentAmounts {
|
|
totals[c] += amt
|
|
}
|
|
totalPayees++
|
|
}
|
|
if grp.EndOfPayer.SequenceNum == 0 {
|
|
grp.EndOfPayer.SequenceNum = seq
|
|
}
|
|
seq++
|
|
if grp.EndOfPayer.NumPayees == 0 {
|
|
grp.EndOfPayer.NumPayees = len(grp.Payees)
|
|
}
|
|
if grp.EndOfPayer.AmountTotals == nil {
|
|
grp.EndOfPayer.AmountTotals = totals
|
|
}
|
|
cRec, err := marshalEndOfPayer(&grp.EndOfPayer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("end-of-payer group %d: %w", i, err)
|
|
}
|
|
buf.Write(cRec)
|
|
}
|
|
|
|
for i := range f.StateTotals {
|
|
st := &f.StateTotals[i]
|
|
if st.SequenceNum == 0 {
|
|
st.SequenceNum = seq
|
|
}
|
|
seq++
|
|
kRec, err := marshalStateTotals(st)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("state totals %d: %w", i, err)
|
|
}
|
|
buf.Write(kRec)
|
|
}
|
|
|
|
if f.EndOfFile.SequenceNum == 0 {
|
|
f.EndOfFile.SequenceNum = seq
|
|
}
|
|
if f.EndOfFile.NumPayers == 0 {
|
|
f.EndOfFile.NumPayers = totalPayers
|
|
}
|
|
if f.EndOfFile.NumPayees == 0 {
|
|
f.EndOfFile.NumPayees = totalPayees
|
|
}
|
|
if f.Transmitter.TotalPayees == 0 {
|
|
f.Transmitter.TotalPayees = totalPayees
|
|
}
|
|
// Re-render the T record with the updated TotalPayees count.
|
|
tRec2, err := marshalTransmitter(&f.Transmitter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("transmitter record (rebuild): %w", err)
|
|
}
|
|
// Replace the first record in the buffer (rebuild from scratch).
|
|
out := bytes.NewBuffer(make([]byte, 0, buf.Len()))
|
|
out.Write(tRec2)
|
|
// Append everything after the original T record (skip RecordLen+2
|
|
// bytes for the original T + CR-LF).
|
|
remainder := buf.Bytes()[RecordLen+2:]
|
|
out.Write(remainder)
|
|
|
|
fRec, err := marshalEndOfFile(&f.EndOfFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("end-of-file: %w", err)
|
|
}
|
|
out.Write(fRec)
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
// marshalTransmitter renders a T record.
|
|
func marshalTransmitter(t *TransmitterRecord) ([]byte, error) {
|
|
r := newRecord()
|
|
r.putChar(1, 'T')
|
|
r.putIntZeroPad(2, 5, t.PaymentYear)
|
|
if t.PriorYear {
|
|
r.putChar(6, 'P')
|
|
}
|
|
r.putDigits(7, 15, t.TIN)
|
|
r.putString(16, 20, padOrTrim(t.TCC, 5))
|
|
// Position 21-27 blank.
|
|
if t.TestFileInd {
|
|
r.putChar(28, 'T')
|
|
}
|
|
if t.ForeignEntity {
|
|
r.putChar(29, '1')
|
|
}
|
|
r.putString(30, 69, t.TransmitterName)
|
|
r.putString(70, 109, t.TransmitterNameCont)
|
|
r.putString(110, 149, t.CompanyName)
|
|
r.putString(150, 189, t.CompanyNameCont)
|
|
r.putString(190, 229, t.CompanyMailingAddress)
|
|
r.putString(230, 269, t.CompanyCity)
|
|
r.putUpper(270, 271, t.CompanyState)
|
|
r.putDigits(272, 280, t.CompanyZip)
|
|
// Position 281-295 blank.
|
|
r.putIntZeroPad(296, 303, t.TotalPayees)
|
|
r.putString(304, 343, t.ContactName)
|
|
r.putString(344, 358, t.ContactPhone)
|
|
r.putString(359, 408, t.ContactEmail)
|
|
// Position 409-499 blank.
|
|
r.putIntZeroPad(500, 507, t.SequenceNum)
|
|
// Position 508-517 blank, 518 reserved.
|
|
r.putString(519, 519, t.VendorIndicator)
|
|
r.putString(520, 559, t.VendorName)
|
|
r.putString(560, 599, t.VendorMailingAddress)
|
|
r.putString(600, 639, t.VendorCity)
|
|
r.putUpper(640, 641, t.VendorState)
|
|
r.putDigits(642, 650, t.VendorZip)
|
|
r.putString(651, 690, t.VendorContactName)
|
|
r.putString(691, 705, t.VendorContactPhone)
|
|
// Position 706-739 blank.
|
|
if t.VendorForeignEntityInd {
|
|
r.putChar(740, '1')
|
|
}
|
|
// Position 741-748 reserved, 749-750 blank.
|
|
return r.bytes(), nil
|
|
}
|
|
|
|
// marshalPayer renders an A record.
|
|
func marshalPayer(p *PayerRecord) ([]byte, error) {
|
|
r := newRecord()
|
|
r.putChar(1, 'A')
|
|
r.putIntZeroPad(2, 5, p.PaymentYear)
|
|
if p.CFSFInd {
|
|
r.putChar(6, '1')
|
|
}
|
|
// Position 7-11 blank.
|
|
r.putDigits(12, 20, p.PayerTIN)
|
|
r.putAlnum(21, 24, p.PayerNameControl)
|
|
if p.LastFilingInd {
|
|
r.putChar(25, '1')
|
|
}
|
|
if p.TypeOfReturn == "" {
|
|
return nil, ErrUnsupportedFormCode
|
|
}
|
|
r.putString(26, 27, string(p.TypeOfReturn))
|
|
r.putString(28, 45, p.AmountCodes)
|
|
// Position 46-51 blank.
|
|
r.putString(52, 91, p.PayerName)
|
|
r.putString(92, 131, p.PayerNameCont)
|
|
r.putString(132, 171, p.PayerShippingAddress)
|
|
r.putString(172, 211, p.PayerCity)
|
|
r.putUpper(212, 213, p.PayerState)
|
|
r.putDigits(214, 222, p.PayerZip)
|
|
r.putString(223, 237, p.PayerPhone)
|
|
// Position 238-499 blank / reserved.
|
|
r.putIntZeroPad(500, 507, p.SequenceNum)
|
|
return r.bytes(), nil
|
|
}
|
|
|
|
// marshalPayee renders a B record. The payer's AmountCodes string
|
|
// determines which amount-code positions are populated; unused
|
|
// positions are zero-padded by the field layout.
|
|
func marshalPayee(p *PayeeRecord, amountCodes string) ([]byte, error) {
|
|
r := newRecord()
|
|
r.putChar(1, 'B')
|
|
r.putIntZeroPad(2, 5, p.PaymentYear)
|
|
r.putString(6, 6, p.CorrectedReturnInd)
|
|
r.putAlnum(7, 10, p.NameControl)
|
|
r.putString(11, 11, p.TypeOfTIN)
|
|
r.putDigits(12, 20, p.PayeeTIN)
|
|
r.putString(21, 40, p.PayerAccountNum)
|
|
r.putString(41, 44, p.PayerOfficeCode)
|
|
// Position 45-54 blank/reserved.
|
|
// Amounts at codes 1-9 / A-I.
|
|
for _, code := range amountCodes {
|
|
c := byte(code)
|
|
start, end, ok := amountColRange(c)
|
|
if !ok {
|
|
continue
|
|
}
|
|
amt := p.PaymentAmounts[c]
|
|
r.putCents(start, end, amt)
|
|
}
|
|
if p.ForeignCountryInd {
|
|
r.putChar(247, '1')
|
|
}
|
|
r.putString(248, 287, p.PayeeFirstNameLine)
|
|
r.putString(288, 327, p.PayeeSecondNameLine)
|
|
r.putString(367, 406, p.PayeeMailingAddress)
|
|
r.putString(407, 446, p.PayeeCity)
|
|
r.putUpper(447, 448, p.PayeeState)
|
|
r.putDigits(449, 457, p.PayeeZip)
|
|
r.putIntZeroPad(500, 507, p.SequenceNum)
|
|
if p.SecondTINNotice {
|
|
r.putChar(545, '2')
|
|
}
|
|
// Form-specific variable-position fields.
|
|
for col, val := range p.FormSpecificFields {
|
|
if col >= 1 && col <= RecordLen {
|
|
r.putString(col, col+len(val)-1, val)
|
|
}
|
|
}
|
|
// CFSF state info block.
|
|
if p.StateInfo.SpecialDataEntries != "" || p.StateInfo.CombinedFedStateCode != "" ||
|
|
p.StateInfo.StateIncomeTaxWithheld != 0 || p.StateInfo.LocalIncomeTaxWithheld != 0 {
|
|
r.putString(663, 722, p.StateInfo.SpecialDataEntries)
|
|
r.putCents(723, 734, p.StateInfo.StateIncomeTaxWithheld)
|
|
r.putCents(735, 746, p.StateInfo.LocalIncomeTaxWithheld)
|
|
r.putString(747, 748, p.StateInfo.CombinedFedStateCode)
|
|
}
|
|
return r.bytes(), nil
|
|
}
|
|
|
|
// marshalEndOfPayer renders a C record.
|
|
func marshalEndOfPayer(c *EndOfPayerRecord) ([]byte, error) {
|
|
r := newRecord()
|
|
r.putChar(1, 'C')
|
|
r.putIntZeroPad(2, 9, c.NumPayees)
|
|
// Position 10-15 blank.
|
|
for code, total := range c.AmountTotals {
|
|
start, end, ok := amountTotalsRange(code, 16)
|
|
if !ok {
|
|
continue
|
|
}
|
|
r.putCents(start, end, total)
|
|
}
|
|
// Position 340-499 blank / reserved.
|
|
r.putIntZeroPad(500, 507, c.SequenceNum)
|
|
return r.bytes(), nil
|
|
}
|
|
|
|
// marshalStateTotals renders a K record.
|
|
func marshalStateTotals(k *StateTotalsRecord) ([]byte, error) {
|
|
r := newRecord()
|
|
r.putChar(1, 'K')
|
|
r.putIntZeroPad(2, 9, k.NumPayees)
|
|
// Position 10-15 blank.
|
|
for code, total := range k.AmountTotals {
|
|
start, end, ok := amountTotalsRange(code, 16)
|
|
if !ok {
|
|
continue
|
|
}
|
|
r.putCents(start, end, total)
|
|
}
|
|
r.putIntZeroPad(500, 507, k.SequenceNum)
|
|
r.putCents(707, 724, k.StateIncomeTaxWithheld)
|
|
r.putCents(725, 742, k.LocalIncomeTaxWithheld)
|
|
r.putString(747, 748, k.CombinedFedStateCode)
|
|
return r.bytes(), nil
|
|
}
|
|
|
|
// marshalEndOfFile renders an F record.
|
|
func marshalEndOfFile(f *EndOfFileRecord) ([]byte, error) {
|
|
r := newRecord()
|
|
r.putChar(1, 'F')
|
|
r.putIntZeroPad(2, 9, f.NumPayers)
|
|
// Position 10-21 blank.
|
|
r.putIntZeroPad(22, 29, f.NumPayees)
|
|
// Position 30-499 blank / reserved.
|
|
r.putIntZeroPad(500, 507, f.SequenceNum)
|
|
return r.bytes(), nil
|
|
}
|
|
|
|
// padOrTrim normalizes s to exactly width characters by truncation or
|
|
// right-padding with blanks.
|
|
func padOrTrim(s string, width int) string {
|
|
if len(s) > width {
|
|
return s[:width]
|
|
}
|
|
if len(s) < width {
|
|
return s + strings.Repeat(" ", width-len(s))
|
|
}
|
|
return s
|
|
}
|