STAFFPRO/i is a staffing-agency application: it places contractors with
clients on job-order placements (each carrying a distinct pay-rate the agency pays the
contractor and a distinct bill-rate the agency charges the client), captures weekly timesheets
against a placement, then runs two parallel calculations off the same validated hours —
contractor gross-to-net pay and client billing — whose spread, after employer
burden, is the agency's gross margin. Everything posts to a balanced GL feed so
billed − paid − burden reconciles to the margin the report shows. The logic is classic
externally-described RPG IV over keyed physical/logical files, with one ILE COBOL trial balance and one
SQLRPGLE GL-distribution program; CL drives four job cycles. This manual is the reference for the operator
who runs the online screens and the periodic cycles, and for the developer maintaining the application. It
is grounded entirely in the committed source (staffing-app/src/sources.mjs,
src/seed.mjs, and the test/sp_build.mjs / sp_daily.mjs /
sp_cycles.mjs drivers). Everything runs in library STAFFPRO.
STAFFPRO/i services the full weekly life of a staffing engagement:
SPCLNT), contractors
(SPCONT) and placements (SPPLACE). A placement binds one contractor
to one client at an agreed pay-rate, bill-rate and OT factor, over a per-placement standard week.The central design idea is that one validated timesheet drives two independent ledgers. From a
single (REGHRS, OTHRS) split the weekly engine SPCALC derives:
SPPAYH header + SPPAYD lines): regular
pay = REGHRS×PAYRATE, overtime pay = OTHRS×PAYRATE×OTFACT (OTFACT seeded
1.5000), gross = regular+OT; then flat tax withheld = gross×WHRATE, an optional
flat admin-fee deduction, and net = gross − tax − fee. Employer burden =
gross×12.00% is carried as an informational line (LTYPE='B') — it is never
subtracted from what the contractor is paid; it is the agency's own cost.SPINVH header + SPINVD lines): regular
billed = REGHRS×BILLRATE, OT billed = OTHRS×BILLRATE (no OT premium passed to the
client — that 0.5× premium is the agency's cost), billed = regular+OT.The gross margin is billed − gross pay − burden. Because gross pay =
net + tax + fee by construction and billed = AR = revenue by construction, the GL batch balances and the
same margin figure is recoverable straight from the GL feed (revenue − wage expense − burden
expense). The pay side and the bill side are genuinely separate ledgers keyed alike on
(week, PLNO) so they join trivially, but each is its own set of records: one is what the contractor is
paid, the other is what the client owes.
REFERENCE DAILY WEEKLY MONTHLY / ANNUAL
--------- ----- ------ ----------------
SPREFLD (seed) SPTIMLD (load raw) SPCALC (pay + bill) SPARAGE (AR aging)
SPCLNT/SPCONT/ SPTIMED (edit/split) SPPAYPRT (pay reg.) SPMARGIN (margin+YTD)
SPPLACE SPTIMRP (reject rpt) SPINVPRT (inv. reg.) SPGLDST (GL feed)
SPGLRPT (trial bal.) SPGLRPT (trial bal.)
\ / SPYRRPT (annual roll)
\ /
v v
SPTIME (timesheet: E raw -> V valid / R reject -> P processed)
|
+--> SPPAYH/SPPAYD (contractor pay) +--> SPINVH/SPINVD (client invoice)
\ /
+--> SPAR (AR ledger) ------+
+--> SPGLFEED (SQL GL, balanced DR/CR)
+--> SPYTD (fiscal YTD margin by client)
ONLINE: SPMENU --> SPPLCIQ (placement inquiry) / SPTIMIQ (timesheet pay inquiry, subfile)
A week's flow: SPTIMLD writes raw (TSTAT='E') timesheets →
SPTIMED validates and splits them to V (or rejects to R) →
SPCALC reads each V card, writes the pay header/lines and the invoice
header/lines, posts one AR row, and flips the card to P (processed) → the print,
aging, margin, GL and annual programs read those posted records.
| Object | Type | Role |
|---|---|---|
| SPCLNT | PF | Client master (spine CLNO). |
| SPCONT | PF | Contractor master (spine CONO); pay-rate default, withholding, admin fee. |
| SPPLACE | PF | Placement / job order (spine PLNO): pay-rate, bill-rate, OT factor, standard week. |
| SPPLACLF | LF | Placement access path keyed CONO (what a contractor is placed on). |
| SPTIME | PF | Weekly timesheet (key TWEEK+PLNO); status E/V/R/P. |
| SPTIMLF | LF | Timesheet access path keyed TSTAT (status enquiry / reject report). |
| SPPAYH / SPPAYD | PF | Contractor pay header / itemised pay lines. |
| SPINVH / SPINVD | PF | Client invoice header / itemised invoice lines. |
| SPINVLF | LF | Invoice access path keyed CLNO+ISTAT (client AR aging). |
| SPAR | PF | AR ledger — durable audit trail, stable business key. |
| SPYTD | PF | Fiscal YTD margin accumulator, one row per client/year. |
| SPGLFEED | SQL table | GL distribution feed (double-entry DR/CR); index SPGLFACC. |
| SPPLACD / SPTIMD / SPMENUD | DSPF | Placement inquiry / subfile pay inquiry / main menu. |
| SPPAYP / SPINVP | PRTF | Pay register / invoice register printer files. |
| SPREFLD | RPGLE | Seed reference data (clients/contractors/placements). |
| SPTIMLD / SPTIMED / SPTIMRP | RPGLE | Daily: raw load / edit-split / reject report. |
| SPCALC | RPGLE | Weekly pay + bill engine (the heart of the app). |
| SPPAYPRT / SPINVPRT | RPGLE | Weekly pay register / invoice register print. |
| SPARAGE / SPMARGIN | RPGLE | Monthly AR aging / gross-margin + YTD roll. |
| SPGLDST | SQLRPGLE | Monthly GL distribution (embedded EXEC SQL INSERT). |
| SPYRRPT | RPGLE | Annual margin summary + YTD reset. |
| SPPLCIQ / SPTIMIQ / SPMENU | RPGLE | Online placement inquiry / subfile pay inquiry / menu. |
| SPGLRPT | CBLLE | ILE COBOL trial balance (reconciliation proof). |
| SPSETUP | CLP | Create every object and compile every program. |
| SPDAILY / SPWEEK / SPMONTH / SPYEAR | CLP | The four job cycles. |
The full catalogue is 10 PFs + 3 LFs + 1 SQL table (+ index), 3 DSPF and 2 PRTF, driven by 14 RPG programs, 1 ILE COBOL program and 5 CL programs. Sections D and F expand each.
STAFFPRO/i has no CICS transaction identifiers and no menu-driven transid switch. On IBM i,
each program is reached by name from a 5250 command-entry line (or a JOBQ/scheduler for the batch
cycles). The operator equivalent of "type a transid and Enter" is "type a CALL command and
Enter". Before invoking anything, the job's library list must include STAFFPRO — the
tested jobs run with LIBL = QSYS QGPL STAFFPRO QTEMP and CURLIB = STAFFPRO.
| To do this | Type on the command line |
|---|---|
| Open the main menu (both online inquiries) | CALL STAFFPRO/SPMENU |
| Open placement inquiry directly | CALL STAFFPRO/SPPLCIQ |
| Open the timesheet pay inquiry (subfile) directly | CALL STAFFPRO/SPTIMIQ |
| Run the daily timesheet cycle | CALL STAFFPRO/SPDAILY (or SBMJOB it) |
| Run the weekly pay + bill cycle | CALL STAFFPRO/SPWEEK |
| Run the monthly AR/margin/GL cycle | CALL STAFFPRO/SPMONTH |
| Run the annual roll | CALL STAFFPRO/SPYEAR |
| Build the whole application from source | CALL STAFFPRO/SPSETUP then CALL STAFFPRO/SPREFLD |
None of the programs take CALL parameters. The batch cycles process a fixed work week —
the tested run uses week 202631, an as-of/aging date of 20260831 and a GL batch
of 202608, all coded as literals in the driver source (the same fixed-period convention the
sibling PAYCHEK app uses). A scheduled submission is therefore a bare CALL.
Three interactive programs share three display files. SPMENU (over SPMENUD)
is a plain menu that CALLs the two inquiries; each inquiry can also be called directly.
Key 1 or 2 and Enter; any other non-blank option
shows Invalid option.. F3 exits.
Type a placement number and Enter. SPPLCIQ CHAINs
SPPLACE for the placement, then SPCONT and SPCLNT for the
contractor and client names, and derives the standard-week margin before burden as
STDHRS × (BILLRATE − PAYRATE).
A not-found placement blanks the detail fields and shows Placement not found.. The
standard-week margin shown is a reference figure (STDHRS×rate-spread, before burden), not
the actual weekly margin, which depends on real hours and burden (section A.2).
SPTIMIQ is the app's subfile program. It presents a subfile (DDS record
SSFL under control record SCTL, SFLPAG(5) per page,
SFLSIZ(20)) of a placement's itemised pay lines for the fixed week 202631. Type
a placement number; it CHAINs SPPAYH for the gross/net header and SPINVH for the
billed figure, then loads the SPPAYD lines on a partial (PWEEK, PLNO) key so exactly one pay
stub loads in LSEQ order. The subfile is cleared and reloaded on each inquiry, so a fresh key
shows a fresh stub (real subfile-refresh discipline).
| Field | Type (DDS) | Shows |
|---|---|---|
| IPLNO | 6A input/output | Placement number keyed by the operator. |
| DPGROSS / DPNET | 13A output | Gross / net pay from SPPAYH. |
| DPBILL | 13A output | Billed from SPINVH. |
| SSEQ / STYP / SDESC / SHRS / SAMT | subfile | Pay-line seq, type (E/T/F/B), description, hours, amount from SPPAYD. |
No pay record for this
placement. and an empty subfile. The SFLCLR/rewrite of SCTL happens
after the EXFMT returns the new key, immediately before the reload — so paging
(Roll Up/Roll Down) works within a stub and a new key
replaces it cleanly.Honest statement: STAFFPRO/i does not model a true four-eyes maker–checker / separate-authorization workflow. There is no "one user enters, a second user approves" step in the code: a timesheet is validated by the daily edit and paid+billed by the weekly run without a human approval gate, and the inquiry screens are strictly read-only. The manual documents the control model the application does have:
E (entered, raw) →
V (validated) or R (rejected, with a reason code) →
P (processed). The weekly engine only ever pays a V card, and a
V card that has been paid is flipped to P, so it cannot be paid again.
Rejected cards carry zero payable hours and a reason code (E1–E4, section F.4).PSTAT='E') is rejected
by the edit (E2) and skipped by the weekly engine; an on-hold client
(CSTAT='H') takes no new placements while its existing placements still bill; a
terminated contractor (WSTAT='T') is off the active roster.20000000 + placement×100 + week-offset for an invoice; a 40000000
base is reserved for cash applications), so a re-run regenerates the same key and is refused
rather than double-posting.SPGLRPT) proves both that the
batch balances and that gross pay reconciles to net + tax + fee (its PAYDIFF line is
exactly 0.00).GLSEQ primary key each make
their program safely re-runnable (section F.3).In sum, the control posture is a timesheet state machine + master-status gating + a durable, stably-keyed AR ledger + a self-proving balanced GL, rather than a segregation-of-duties approval workflow.
STAFFPRO/i runs as four CL-driven cycles rather than one monolithic nightly job: a daily cycle
(timesheet capture + edit), a weekly cycle (pay + bill + registers), a monthly cycle
(AR aging + margin + GL + trial balance), and an annual cycle (fiscal-year margin summary + YTD
reset). Each CL program does an ADDLIBLE LIB(STAFFPRO) (guarded by
MONMSG SFF0000), CALLs its programs in order, and ends with a completion banner via
SNDPGMMSG. None takes CALL parameters; the processing week, aging date and GL batch are
literals in the program source.
-- the four cycles are bare, parameterless submissions
SBMJOB CMD(CALL PGM(STAFFPRO/SPDAILY)) JOB(SPDAILY)
SBMJOB CMD(CALL PGM(STAFFPRO/SPWEEK)) JOB(SPWEEK)
SBMJOB CMD(CALL PGM(STAFFPRO/SPMONTH)) JOB(SPMONTH)
SBMJOB CMD(CALL PGM(STAFFPRO/SPYEAR)) JOB(SPYEAR)
| Program | Cycle | Purpose | Reads | Writes / DSPLY |
|---|---|---|---|---|
| SPTIMLD | Daily | Load one week of raw timesheets (incl. deliberate exception cards). | — | SPTIME (E rows); SPTIMLD CARDS=n SKIP=n. |
| SPTIMED | Daily | Edit/validate every E card; split REG/OT against STDHRS. | SPTIME, SPPLACE | SPTIME (V or R + reason); SPTIMED OK=n REJ=n. |
| SPTIMRP | Daily | Reject report over the STATUS logical (SPTIMLF), reason-code breakdown. | SPTIMLF | SPTIMRP REJ=n E1=n E2=n E3=n. |
| SPCALC | Weekly | Pay + bill each V card; post AR; flip card to P. | SPTIME, SPPLACE, SPCONT | SPPAYH/D, SPINVH/D, SPAR, SPTIME(P); SPCALC PAID=n SKIP=n. |
| SPPAYPRT | Weekly | Print the contractor pay register (PRTF, page overflow). | SPPAYH/D, SPCONT | SPPAYP spool; SPPAYPRT SLIPS=n NET=n. |
| SPINVPRT | Weekly | Print the client invoice register (PRTF, page overflow). | SPINVH/D, SPCLNT | SPINVP spool; SPINVPRT INVS=n BILLED=n. |
| SPARAGE | Monthly | Age open invoices into CURRENT/B31/B61/B90 buckets (as of 20260831). | SPINVH | SPARAGE INVS=/CUR=/B31=/B61=/B90=/TOT=. |
| SPMARGIN | Monthly | Margin (billed−gross−burden) per client; roll SPYTD. | SPINVH, SPPAYH, SPYTD | SPYTD; SPMARGIN INVS=/BILLED=/PAID=/BURDEN=/MARGIN=. |
| SPGLDST | Monthly | Balanced double-entry GL distribution into the SQL feed. | SPINVH, SPPAYH | SPGLFEED (SQL); SPGLDST BATCH=/ROWS=/BILLED=. |
| SPGLRPT | Weekly & Monthly | ILE COBOL trial balance: billed/gross/net/tax/fee/burden/paydiff/margin. | SPPAYH, SPINVH | SPGLRPT BILLED/GROSS/.../MARGIN. |
| SPYRRPT | Annual | Fiscal-year margin summary per client; reset SPYTD. | SPYTD, SPCLNT | SPYTD(reset); SPYRRPT CLIENTS=/TOTBILL=/TOTMARGIN=. |
| SPREFLD | Setup | Seed clients / contractors / placements. | — | SPCLNT/SPCONT/SPPLACE; SPREFLD CLI=4 CON=5 PLC=5. |
SPTIMLD writes seven raw cards for week 202631: four clean
(P00001 40.00, P00002 45.00, P00003 35.00, P00004 50.00 raw hours), plus three
exception cards (an unknown placement P00009, an ended placement P00005, and a duplicate P00003 which the
UNIQUE (TWEEK, PLNO) key refuses) — so it lands 6 cards written, 1 skipped. SPTIMED
then edits every E card: it CHAINs SPPLACE, rejects unknown (E1), ended (E2),
over-80.00-hour (E3) and non-positive (E4) cards, and for a valid card splits raw hours —
anything above the placement's own STDHRS becomes overtime.
Expected DSPLY (the seeded week): SPTIMLD CARDS=6 SKIP=1 SPTIMED OK=4 REJ=2 4 validated, 2 rejected (E1 unknown, E2 ended) SPTIMRP REJ=2 E1=1 E2=1 E3=0 The REG/OT split (raw vs the placement's own standard week): P00001 40.00 raw, std 40.00 -> REG 40.00 OT 0.00 P00002 45.00 raw, std 40.00 -> REG 40.00 OT 5.00 P00003 35.00 raw, std 35.00 -> REG 35.00 OT 0.00 (shorter standard week) P00004 50.00 raw, std 40.00 -> REG 40.00 OT 10.00 totals: 155.00 regular, 15.00 overtime
STDHRS 35.00 while every other placement uses 40.00. The edit reads each placement's own
ceiling, so 35.00 raw against a 35.00 standard week is all regular, no OT.SPCALC is the twin engine. For each V card it computes the pay side and the
bill side (section F.2), writes the pay header (SPPAYH) and itemised lines
(SPPAYD: REG earning, OT earning if any, TAX, FEE if any, BURDEN), the invoice header
(SPINVH) and lines (SPINVD: REGULAR, OVERTIME if any), posts one
INVC row to the AR ledger under its stable key, and flips the timesheet to P.
SPPAYPRT and SPINVPRT then print the registers, and SPGLRPT runs
the trial balance.
Expected DSPLY (the seeded week): SPCALC PAID=4 SKIP=2 4 validated cards paid+billed, 2 rejects skipped SPPAYPRT SLIPS=4 NET=3477.75 SPINVPRT INVS=4 BILLED=6840.00 SPGLRPT MARGIN 2060.40 PAYDIFF 0.00
SPARAGE walks open invoices and buckets the outstanding balance
(BILLED − PAIDAMT) by age from INVDT to the fixed as-of date
20260831, using a 30-day-month julian approximation good enough for same-year deltas:
0-30→CURRENT, 31-60→B31, 61-90→B61, 90+→B90. SPMARGIN joins each invoice
header to its pay header on the shared (week, PLNO) key, computes
billed − gross − burden, and rolls the figures into SPYTD per client.
SPGLDST sums the period's billing and pay and writes the balanced GL batch (below).
SPGLRPT re-proves the reconciliation.
Expected DSPLY (seeded week + one injected aged 500.00 orphan invoice):
SPARAGE INVS=5 CUR=6840.00 B61=500.00 TOT=7340.00
SPMARGIN INVS=5 BILLED=7340.00 PAID=4267.50 BURDEN=512.10 MARGIN=2560.40
SPGLDST BATCH=202608 ROWS=8 BILLED=7340.00
SPGLRPT MARGIN 2560.40 PAYDIFF 0.00
SPGLDST posts three balanced journals with free-form
exec sql insert into STAFFPRO.SPGLFEED ...;:
| Journal (GLREF) | Debit | Credit(s) |
|---|---|---|
| BILLING | 1200-AR = billed | 4100-REV = billed |
| CONTRPAY | 6100-CPAY = gross | 2100-NETP net + 2200-TAXP tax + 2400-FEEP fee |
| BURDEN | 6200-BURD = burden | 2500-BURP = burden |
Billed = AR = revenue by construction, and gross = net + tax + fee by construction, so total debits
equal total credits. Margin (billed − paid − burden) is simply
4100-REV − 6100-CPAY − 6200-BURD read back from the same feed.
SPYRRPT prints one summary block per client from the YTD accumulator, totals billed /
paid / margin, then zeroes the accumulator for the new fiscal year (the invoice headers themselves
are left alone). An already-reset accumulator (YBILLED ≤ 0) is skipped, so a second run finds
nothing to summarise.
Expected DSPLY: SPYRRPT CLIENTS=4 SKIP=0 TOTBILL=7340.00 SPYRRPT TOTPAID=4267.50 TOTMARGIN=2560.40 re-run: SPYRRPT CLIENTS=0 SKIP=4 (all accumulators already reset)
V (validated) cards; only the
daily edit produces them (and the REG/OT split it computes is the input to every pay/bill figure).
Run the daily cycle first.SPPAYH/SPINVH/SPAR records the weekly engine writes. The
monthly cycle over an unrun week reports nothing.SPYTD
accumulator that the monthly SPMARGIN fills. Run it after the fiscal year's months are
closed, or it summarises an incomplete YTD.All objects live in library STAFFPRO, grounded in the DDS/SQL in
staffing-app/src/sources.mjs. The physical/logical files are externally-described DDS; the GL
feed is an SQL table. RPG packed-decimal notation is written nPd (n digits,
d after the point, e.g. 11P 2); zoned is nSd. Dates are
stored as 8S 0 in YYYYMMDD form (weeks as 6S 0
YYYYWW); rates are 7P 4 (so 0.1500 = 15%, and
1.5000 is the OT factor); money is packed 11P 2 on the pay/invoice
ledgers and 13P 2 on the YTD/GL aggregates. Every physical file below is UNIQUE
on its key.
| Field | Type | Meaning |
|---|---|---|
| CLNO | 6A | Client number (key), K00001 style (letter + 5 digits). |
| CNAME | 30A | Client name. |
| CADDR | 30A | Client address. |
| CTERMS | 2S 0 | Payment terms in days (e.g. 30, 15). |
| CSTAT | 1A | A active, H on hold (no new placements, existing ones still bill), C closed. |
| CSTARTDT | 8S 0 | Client relationship start date (YYYYMMDD). |
| Field | Type | Meaning |
|---|---|---|
| CONO | 6A | Contractor number (key), W00001 style. |
| WNAME | 30A | Contractor name. |
| SKILL | 4A | Skill code (MACH, WHSE, ENGR…). |
| PAYRATE | 7P 4 | Contractor default hourly pay rate (reference only — the placement is authoritative). |
| WHRATE | 7P 4 | Flat withholding-election rate (e.g. 0.1500 = 15%). |
| ADMINFEE | 9P 2 | Flat weekly post-tax deduction (e.g. equipment rental). Note the width — see F.5. |
| WSTAT | 1A | A active, T terminated, L leave of absence. |
| HIREDT | 8S 0 | Hire date (YYYYMMDD). |
| Field | Type | Meaning |
|---|---|---|
| PLNO | 6A | Placement number (key), P00001 style. |
| CONO | 6A | Contractor placed (→ SPCONT). |
| CLNO | 6A | Client served (→ SPCLNT). |
| PAYRATE | 7P 4 | Authoritative pay rate the agency pays the contractor on this placement. |
| BILLRATE | 7P 4 | Bill rate the agency charges the client on this placement. |
| OTFACT | 7P 4 | OT premium factor on the PAY side (seeded 1.5000); the bill side carries no OT premium. |
| STDHRS | 5P 2 | Per-placement standard week; raw hours above this become overtime. |
| PSTARTDT / PENDDT | 8S 0 | Placement start / end dates (PENDDT 0 when open). |
| PSTAT | 1A | A active, E ended (E is skipped by the edit and the weekly engine). |
Seeded placements: P00001 W00001→K00001 pay 20/bill 35; P00002 W00002→K00001 pay 25/bill 42; P00003 W00003→K00002 pay 18/bill 30 (STDHRS 35.00); P00004 W00004→K00003 pay 30/bill 50; P00005 W00005→K00002 pay 19/bill 32 ENDED. OTFACT 1.5000 throughout.
Non-unique logical keyed by contractor then placement, so “what is this contractor placed on” is a keyed read rather than a full-file scan.
| Field | Type | Meaning |
|---|---|---|
| TWEEK | 6S 0 | Work week YYYYWW (key part 1); the tested week is 202631. |
| PLNO | 6A | Placement (key part 2). |
| REGHRS / OTHRS | 5P 2 | Regular / overtime hours after the daily edit's split (both 0 until validated). |
| RAWHRS | 5P 2 | Raw hours as loaded, before the REG/OT split. |
| TDT | 8S 0 | Timesheet date (YYYYMMDD). |
| TSTAT | 1A | E entered/raw, V validated, R rejected, P processed (paid+billed). |
| TERRCD | 2A | Reject reason code on an R card (E1–E4, section F.4); blank otherwise. |
Non-unique logical keyed by status then placement, so the reject report reads only the R
rows as a keyed range (proving the access path is really keyed on TSTAT).
| Field | Type | Meaning |
|---|---|---|
| PWEEK / PLNO | 6S 0 / 6A | Pay week + placement (composite key; joins to SPINVH on the same pair). |
| CONO | 6A | Contractor paid. |
| REGPAY / OTPAY / GROSS | 11P 2 | Regular pay, OT pay, gross = regular + OT. |
| TAXWH | 11P 2 | Flat tax withheld = gross × WHRATE. |
| PADMFEE | 11P 2 | Admin-fee deduction posted this week. Renamed from ADMINFEE — see F.5. |
| NETPAY | 11P 2 | Net = gross − tax − fee. |
| BURDEN | 11P 2 | Employer burden = gross × 12% (informational; the agency's cost, never netted from pay). |
| PAYDT | 8S 0 | Pay date (YYYYMMDD). |
| HSTAT | 1A | Header status (P processed). |
| Field | Type | Meaning |
|---|---|---|
| PWEEK / PLNO / LSEQ | 6S 0 / 6A / 3S 0 | Header key + line sequence. |
| LTYPE | 1A | E earning, T tax, F post-tax flat deduction, B employer burden (informational). |
| LDESC | 20A | Line description (REGULAR HOURS, OVERTIME HOURS, TAX WITHHELD, ADMIN FEE, EMPLOYER BURDEN). |
| LHRS | 5P 2 | Hours on this line (0 on TAX/FEE/BURDEN). |
| LRATE | 7P 4 | Rate applied (pay-rate, OT effective rate, WH rate, burden rate). |
| LAMT | 11P 2 | Line amount. |
| Field | Type | Meaning |
|---|---|---|
| IWEEK / PLNO | 6S 0 / 6A | Billing week + placement (mirrors SPPAYH's key so the two ledgers join trivially). |
| CLNO | 6A | Client billed. |
| REGBILL / OTBILL / BILLED | 11P 2 | Regular billed, OT billed (no premium), billed = regular + OT. |
| INVDT / DUEDT | 8S 0 | Invoice / due dates (YYYYMMDD). |
| PAIDAMT | 11P 2 | Cash applied so far (0 when open; drives the AR-aging outstanding balance). |
| ISTAT | 1A | O open, P paid. |
| Field | Type | Meaning |
|---|---|---|
| IWEEK / PLNO / LSEQ | 6S 0 / 6A / 3S 0 | Header key + line sequence. |
| LDESC | 20A | REGULAR HOURS / OVERTIME HOURS. |
| LHRS / LRATE / LAMT | 5P 2 / 7P 4 / 11P 2 | Hours, bill-rate, line amount. |
Non-unique logical keyed client / invoice-status / week, so the AR-aging run reads one client's open invoices as a keyed range rather than a full-file scan.
| Field | Type | Meaning |
|---|---|---|
| ARSEQ | 8S 0 | Stable business key (not an identity): invoice = 20000000 + placement×100 + week-offset; cash = 40000000 + …. |
| CLNO / PLNO | 6A | Client / placement the entry concerns. |
| ARTYP | 4A | Entry type (INVC invoice posting; cash applications reserved on the 40000000 base). |
| ARAMT | 11P 2 | Amount (billed, for an INVC row). |
| ARDT | 8S 0 | Entry date (YYYYMMDD). |
| ARMEMO | 25A | Free-text memo (e.g. WEEKLY CLIENT INVOICE). |
| Field | Type | Meaning |
|---|---|---|
| FISCYR / CLNO | 4S 0 / 6A | Fiscal year + client (one accumulator row each). |
| YBILLED / YPAID | 13P 2 | YTD billed / YTD gross pay for the client. |
| YBURDEN / YMARGIN | 13P 2 | YTD burden / YTD margin (billed − paid − burden). |
| YWEEKS | 3S 0 | Weeks rolled into the accumulator (the annual roll zeroes all five money/count fields). |
| Column | Type | Meaning |
|---|---|---|
| GLSEQ | DECIMAL(8,0) | Journal-row sequence (PK). |
| GLBATCH | DECIMAL(6,0) | GL batch YYYYMM (the tested batch is 202608). |
| ACCT | CHAR(9) | Account (1200-AR, 4100-REV, 6100-CPAY, 2100-NETP, 2200-TAXP, 2400-FEEP, 6200-BURD, 2500-BURP). |
| DRCR | CHAR(1) | D debit, C credit. |
| AMT | DECIMAL(13,2) | Journal amount. |
| GLREF | CHAR(8) | Journal reference (BILLING, CONTRPAY, BURDEN). |
| GLDT | DECIMAL(8,0) | GL date (YYYYMMDD). |
Index SPGLFACC on (ACCT, DRCR) supports reading the margin back per account.
STAFFPRO/i has no control row to advance: the processing week (202631), the AR
as-of date (20260831) and the GL batch (202608) are literals in the driver
source (section B.1). An operator runs the four cycles in order and checks the DSPLY banner each program
emits. The figures below are the seeded reference dataset; on a live period they change, but the
reconciliation relationships (net + tax + fee = gross; billed − gross − burden =
margin; total debits = total credits; PAYDIFF = 0.00) must hold every time.
STAFFPRO (the tested job runs
LIBL = QSYS QGPL STAFFPRO QTEMP, CURLIB = STAFFPRO).SBMJOB CMD(CALL PGM(STAFFPRO/SPDAILY)). It loads the
week's raw cards, edits/splits them, and reports the rejects.SBMJOB CMD(CALL PGM(STAFFPRO/SPWEEK)). Collect the two spooled registers (SPPAYP pay,
SPINVP invoice) and read the trial-balance banner.CALL STAFFPRO/SPMENU (placement inquiry / timesheet pay
inquiry) as they arrive — both are read-only.Post-checks after the daily cycle (seeded week):
SPTIMLD CARDS=6 SKIP=1 — six raw cards written, one refused by the UNIQUE
(TWEEK, PLNO) key (the duplicate P00003).SPTIMED OK=4 REJ=2 — four validated, two rejected (P00009 unknown → E1;
P00005 ended → E2).SPTIMRP REJ=2 E1=1 E2=1 E3=0 — the reject breakdown over the status logical.Post-checks after the weekly cycle (seeded week):
SPCALC PAID=4 SKIP=2 — four validated cards paid+billed, the two rejects skipped;
all four timesheets flip to P.SPPAYPRT SLIPS=4 NET=3477.75 and SPINVPRT INVS=4 BILLED=6840.00.SPGLRPT MARGIN 2060.40 PAYDIFF 0.00 — the trial balance proves the reconciliation.Monthly: SBMJOB CMD(CALL PGM(STAFFPRO/SPMONTH)) runs aging → margin → GL
→ trial balance. On the seeded run one aged orphan invoice (client K00002, 500.00, dated
20260617) is injected so the aging buckets and the “invoice with no matching pay
header” path are both exercised:
Expected monthly DSPLY:
SPARAGE INVS=5 CUR=6840.00 B61=500.00 TOT=7340.00
SPMARGIN INVS=5 BILLED=7340.00 PAID=4267.50 BURDEN=512.10 MARGIN=2560.40
SPGLDST BATCH=202608 ROWS=8 BILLED=7340.00
SPGLRPT MARGIN 2560.40 PAYDIFF 0.00
The margin reconciliation, step by step. The whole point of the app is that two independent ledgers reconcile to one margin figure, provable three different ways:
3477.75 + 759.75 + 30.00 = 4267.50 = gross pay — so SPGLRPT PAYDIFF
is exactly 0.00. If PAYDIFF is non-zero, a pay line was mis-posted; stop and investigate.6840.00 − 4267.50 − 512.10 = 2060.40. Monthly (+ the 500.00 orphan, which
adds billing but no pay/burden): 7340.00 − 4267.50 − 512.10 = 2560.40 —
the orphan flows straight through to margin because it has no matching pay header (burden is
unchanged at 512.10).4100-REV − 6100-CPAY − 6200-BURD. Because billed = AR = revenue and gross =
net + tax + fee by construction, total debits equal total credits, and this equals the same
2560.40.Per-client the monthly margin lands as K00001 1064.00 (P00001 504.00 + P00002 560.00), K00002 344.40 +
the 500.00 orphan, K00003 652.00 — rolled into SPYTD per client.
Annual: once the fiscal year's months are closed, SBMJOB CMD(CALL PGM(STAFFPRO/SPYEAR))
summarises the YTD accumulator per client and zeroes it:
Expected annual DSPLY: SPYRRPT CLIENTS=4 SKIP=0 TOTBILL=7340.00 SPYRRPT TOTPAID=4267.50 TOTMARGIN=2560.40 re-run: SPYRRPT CLIENTS=0 SKIP=4 (all accumulators already reset)
Every program is written to be safely re-runnable; a re-run reports zero new work rather than double-posting. If a cycle aborts partway, just run it again — the guards below make it a no-op for whatever already committed.
CARDS=0 SKIP=7 the second time).E rows; once a card is V/R/P a re-run edits nothing.SPPAYH skips an
already-paid placement, and the stable ARSEQ key refuses a duplicate AR posting, so a
re-run reports PAID=0 SKIP=6 and never double-pays or double-bills.GLSEQ is the primary key; re-posting the same batch collides on
the key rather than silently doubling the journal.YBILLED ≤ 0), so a second
run finds nothing to summarise (CLIENTS=0 SKIP=n).14 RPG programs (13 RPGLE + 1 SQLRPGLE), 1 ILE COBOL, 5 CL, over 10 PF + 3 LF + 1 SQL table, seeded as
source-physical-file members by src/seed.mjs (seedStaffpro()) and built by
SPSETUP. All are classic externally-described I/O: F-specs open the DDS files with the
E extension; the RPG is a mix of fixed-form C-specs (KLIST/CHAIN/SETLL/READE loops) and
/free blocks. Language split by kind: RPGLE for the online + batch logic, SQLRPGLE
(SPGLDST) for the embedded-SQL GL distribution, ILE COBOL (SPGLRPT) for the
trial balance reading the PFs directly, CL for setup and the four cycles.
SPCALC is the twin engine. All money is computed half-adjusted (eval(h)) so a
rate like 20.0000 × 40.00 hours lands on exact cents. For each V timesheet it uses the
placement's pay-rate/bill-rate/OT-factor (not the contractor default) and the contractor's
withholding rate + admin fee:
-- PAY SIDE regular pay = REGHRS * PAYRATE overtime pay = OTHRS * PAYRATE * OTFACT (OTFACT 1.5000) gross pay = regular pay + overtime pay tax withheld = gross pay * WHRATE (flat, from SPCONT) admin fee = SPCONT.ADMINFEE (if > 0) -> PADMFEE on SPPAYH net pay = gross pay - tax withheld - admin fee burden = gross pay * 0.1200 (WBRT; informational, agency cost) -- BILL SIDE (no OT premium to the client) regular billed = REGHRS * BILLRATE overtime billed= OTHRS * BILLRATE billed = regular billed + overtime billed -- so, by construction: margin = billed - gross pay - burden
Pay lines are emitted in order REG earning → OT earning (only if OTHRS > 0) → TAX →
FEE (only if fee > 0) → BURDEN, each with its own LSEQ; invoice lines are REGULAR
→ OVERTIME (only if OTHRS > 0). The burden rate WBRT is a hard-coded
0.1200 in the program (not a master field).
SPTIME is keyed (TWEEK, PLNO) and
SPPAYH/SPINVH (week, PLNO); a CHAIN on the first key part alone would match
the wrong placement, so every guard builds a full KLIST (TIMKEY,
PAYKEY, SLPKEY…) from work fields.SPCALC/SPTIMED the placement/
contractor CHAINs move the file cursor, so the code re-CHAINs the timesheet on its composite key
before the final UPDATE that flips TSTAT — otherwise it would update
the wrong (or no) row.SPPAYD/SPINVD are keyed
(week, PLNO, LSEQ); a READE on the first two parts walks exactly one stub/invoice in
line-sequence order (used by SPPAYPRT, SPINVPRT, and the
SPTIMIQ subfile).CHAIN(EN) turns the
result indicator ON when the record is NOT found; so in SPCALC's AR guard
*IN96=*ON is the “not yet posted, proceed to write” case (documented inline
in the source).ARSEQ is derived, not identity:
20000000 + %dec(%subst(PLNO:2:5)):8:0 * 100 + (week − 202600) for an invoice
(40000000 base reserved for cash), so a re-run regenerates the same key and CHAINs a hit rather than
double-posting.| Code | Meaning | Set when |
|---|---|---|
| E1 | No such placement | The CHAIN on SPPLACE misses (unknown PLNO, e.g. P00009). |
| E2 | Placement not active | The placement exists but PSTAT ≠ 'A' (ended, e.g. P00005). |
| E3 | Hours over ceiling | Raw hours > 80.00 for one week. |
| E4 | Non-positive hours | Raw hours ≤ 0. |
A rejected card gets REGHRS = OTHRS = 0, TSTAT = 'R' and the reason in
TERRCD; the weekly engine only ever pays a V card, so a reject never bills.
ibmi/rpg.js keeps one flat symbol map keyed only on the uppercased field name (no per-file
qualification); the F-spec registration loop registers each name only once, so whichever file is listed
first in the F-specs “wins” the shared symbol, and the load/unload MOVEs then run
through that (wrongly-sized) shadow variable, corrupting the value.The concrete case this app hit. SPCONT declared ADMINFEE as
9P 2 (the contractor's default fee election). SPPAYH originally also
declared a field named ADMINFEE, as 11P 2 (the fee posted to a pay period).
SPCALC opens FSPCONT before FSPPAYH, so the narrower
9P 2 layout won the shared symbol. Reading a $15.00 fee from SPCONT and writing
it into what should have been SPPAYH.ADMINFEE before WRITE SPPAYHR produced a
value the downstream COBOL trial balance (SPGLRPT, which reads SPPAYH straight
from disk, independent of the RPG compiler) read back 1000× too large — two $15.00 fees
became 30000.00 instead of 30.00, i.e. $30 became $30,000. Because
SPGLRPT read the corruption straight off the physical file's stored bytes, the bad value was
baked into the PF, not merely an RPG-side display artifact.
Why it matters: real IBM i RPG IV also auto-creates one global field per unique name across
E-extension files, but the real compiler validates length/type/decimals across every
definition and issues an SNF0512/SNF0620-class diagnostic on a conflict (or requires RENAME/PREFIX). This
emulator neither checks compatibility nor raises any diagnostic — it silently drops the second
definition and reuses the first file's layout for both, so a legitimate same-name coincidence turns into
silent, plausible-looking data corruption (worse than a hard failure).
App-level fix (already in the source): the pay-header field was renamed
ADMINFEE → PADMFEE (DDS SPPAYH, plus SPCALC and
SPGLDST) so the two fields no longer collide — a legitimate rename, independent of the
platform gap. The platform gap itself (no compile-time conflict check, and no DDS RENAME/PREFIX
support in rpg.js to safely open two files that share a field name by coincidence) remains the
finding; the recommendation is to either raise a diagnostic on a conflicting shared field or support
DDS-side qualification.
SPPLACE, spine PLNO). The placement — not the contractor
master — is authoritative for the rates used in pay and billing.SPTIMED) partitions a card's RAWHRS into REGHRS (up to STDHRS) and OTHRS
(the remainder), which are the inputs to every pay and bill figure.SPPAYH; itemised on SPPAYD.7P 4) applied to gross pay —
deliberately flat, unlike the sibling PAYCHEK app's graduated bands.ADMINFEE
on SPCONT is the election; PADMFEE on SPPAYH is the amount
posted (renamed to avoid the collision in F.5).LTYPE='B'): never subtracted from what the contractor is paid, but
subtracted when computing margin.SPINVH). A genuinely
separate ledger from pay, though keyed alike on (week, PLNO).ARSEQ so a re-run is refused rather than double-posting.SPARAGE), using a 30-day-month julian
approximation.SPPAYH/SPINVH straight from disk:
its PAYDIFF line is exactly 0.00 and its MARGIN matches the reported margin.