PAYSQL/i — Payroll / HR Gross-to-Net

SteelFrame X application operation manual  ·  ← back to Operation Manuals  ·  Sign On

PAYSQL/i is a payroll / HR gross-to-net application: it computes each employee's gross pay, applies pretax and posttax deductions, withholds federal tax through a graduated annual bracket table, arrives at net pay, posts the pay run to a general-ledger distribution, and accrues year-to-date totals. Unlike a classic RPG application, PAYSQL/i is pure SQL PL — the entire domain (schema, functions, procedures, triggers) lives in DB2 for i and is exercised from STRSQL, a batch CALL driver, or a thin SQLRPGLE caller that issues EXEC SQL CALL. There is no 5250 subfile screen; the “online” surface is the callable procedure interface itself. This manual is the reference for the operator who runs the payroll cycle and for the developer who maintains the routines. It is grounded entirely in the committed source (sqlpl-app-pay/src/schema.sql, routines.sql, and the test/pay_battle.mjs, pay_rpg.mjs and pay_volume_sim.mjs drivers).

Contents

A. Overview & Architecture ↑ top

A.1 What it does

PAYSQL/i runs a biweekly payroll from employee master through to a posted general-ledger distribution:

A.2 Pure-SQL-PL architecture: the database is the application

PAYSQL/i has a deliberately different shape from the RPG-orchestrated applications in this estate. There is no RPG business logic and no DDS screen. Every rule — gross, proration, the bracket walk, the deduction cursor, net, posting, GL distribution, YTD accrual — is implemented as DB2 for i SQL PL: compound scalar functions, stored procedures (some nesting CALLs into others), and table triggers. The schema lives in library PAYSQL.

The benefit for operations: the payroll rules are one auditable place (the SQL-PL routines), and the same procedures answer identically from an operator's STRSQL session, a scheduled batch job, and any embedded-SQL caller.

A.3 Component & flow

  CALLERS                     SQL-PL LOGIC (library PAYSQL)          DATA + TRIGGERS
  -------                     ----------------------------          ---------------
  STRSQL   ---CALL--->        PY_RUN_POST(runid)                    PYEMP  (master)
  batch    ---CALL--->          |  loops ACTIVE PYEMP               PYBRK  (brackets)
  SQLRPGLE ---EXEC SQL CALL->    +--> PY_RUN_EMPLOYEE(run,emp)      PYDEDC (ded codes)
  (CALLPAY)                            +--> PY_COMPUTE_GROSS        PYEMPDED (elections)
                                       |      FN_PRORATE/FN_ROUND2       |
                                       |    +--> PY_WITHHOLD             v
                                       |          FN_TAXBRK/FN_ROUND2  PYRUNDT (payslip)
                                       |    INSERT PYRUNDT ------------> TR_PYRUNDT_VETO (net>=0)
                                       |                                 TR_PYRUNDT_ACCR --> PYACCUM (YTD)
                                       +--> (GL pass) INSERT PYGL        (both write PYAUD)
                                       +--> UPDATE PYRUN RSTAT='P'

  ADJUSTMENTS   UPDATE PYSLIPV.NET --> TR_PYSLIPV_UPD (INSTEAD OF) --> PY_ADJUST_NET (net>=0) --> PYRUNDT + PYAUD

A single pay run flows: caller issues CALL PY_RUN_POST('RUN001', ...) → the procedure loops every ACTIVE employee, nesting PY_COMPUTE_GROSS then PY_WITHHOLD and inserting one PYRUNDT payslip row → that INSERT fires the BEFORE veto (net≥0) and the AFTER accrual (YTD into PYACCUM) → a second pass writes the balanced PYGL distribution and flips the run to POSTED.

A.4 Object inventory

ObjectTypeRole
PYEMPPFEmployee master (pay type, salary/rate, filing status, exemptions).
PYBRKPFGraduated annual tax brackets per filing status.
PYDEDCPFDeduction-code catalog (pretax/posttax, flat/percent).
PYEMPDEDPFEmployee deduction elections.
PYRUNPFPay-run header (period, pay date, status).
PYRUNDTPFPay-run detail — one payslip per employee per run.
PYACCUMPFYTD accumulator (trigger-maintained only).
PYGLPFGL distribution (one row per run/employee/account).
PYAUDPFGeneric trigger audit log.
PYSLIPVViewPayslip view over PYRUNDT (INSTEAD OF UPDATE demo).
FN_* (3)SQL functionsCent rounding, graduated tax, proration factor.
PY_* (5)SQL proceduresGross / withhold / per-employee / run-post / net-adjust.
TR_* (3)TriggersNet veto, YTD accrual, view-update reroute.
CALLPAYSQLRPGLEReach-proof caller (EXEC SQL CALL); not part of the cycle.

The full catalogue is 3 functions + 5 procedures + 3 triggers, over 9 PFs and 1 view, reachable from STRSQL, batch, or the CALLPAY SQLRPGLE program. Sections D and F expand each.

B. Online / Access Surface ↑ top

B.1 How it is invoked (honest: there is no 5250 screen)

Honest statement: PAYSQL/i has no interactive 5250 subfile screen, no DDS display file, and no menu program. It is a pure data-layer application. The operator surface is the callable procedure interface, reached identically three ways — interactive SQL, a batch CALL, and an SQLRPGLE caller. Before invoking anything, the job's library list must include PAYSQL; the tested jobs run with LIBL = QSYS QGPL PAYSQL QTEMP and CURLIB = PAYSQL.

To do thisType (STRSQL) / submit (batch)
Compute + post a whole pay runCALL PAYSQL.PY_RUN_POST('RUN001', ?, ?, ?, ?)
Compute one employee only (no post)CALL PAYSQL.PY_RUN_EMPLOYEE('RUN001','E00001', ?)
Inspect a computed gross / withholdingCALL PAYSQL.PY_COMPUTE_GROSS(...) / PY_WITHHOLD(...)
Adjust a posted payslip's net (sanctioned path)UPDATE PAYSQL.PYSLIPV SET NET=<n> WHERE ... (reroutes to PY_ADJUST_NET)
Look at results / reconcileSELECT ... FROM PAYSQL.PYRUNDT / PYGL / PYACCUM
Same, from compiled RPGCALL PAYSQL/CALLPAY (issues the EXEC SQL CALLs)

The batch idiom. PY_RUN_POST is the one-call payroll cycle: it takes the run id IN and four counters OUT (COMPUTED, SKIPPED, VETOED, POSTED). A scheduled submission is therefore a bare procedure call against a run id whose header row already exists in PYRUN:

-- interactive / STRSQL
CALL PAYSQL.PY_RUN_POST('RUN001', ?, ?, ?, ?);

-- batch: wrap the CALL in a program/driver and SBMJOB it
SBMJOB CMD(CALL PGM(PAYSQL/CALLPAY)) JOB(PAYRUN)

The SQLRPGLE caller (CALLPAY)

CALLPAY is the reach-proof driver (test/pay_rpg.mjs). It declares packed host variables, then in sequence: (1) EXEC SQL CALL PY_COMPUTE_GROSS and PY_WITHHOLD to round-trip IN/OUT values through the nested compound procedures; (2) EXEC SQL CALL PY_RUN_POST and reads back the four OUT counters; (3) EXEC SQL CALL PY_ADJUST_NET with a negative amount to confirm the trigger/procedure SIGNAL reaches RPG as a negative SQLCODE (−438, the unhandled-SIGNAL convention), then a valid adjustment to confirm normal success. Each step DSPLYs value/%char(sqlcode).

A=3000.00/0 gross for salaried E00001 B1=225.00/413.10 pretax deductions / federal tax B2=20.00/2341.90/0 posttax deductions / net / SQLCODE 0 C1=1/0 PY_RUN_POST computed / skipped C2=0/1/0 vetoed / posted / SQLCODE 0 D=-438 PY_ADJUST_NET(-5.00) SIGNAL reaches RPG E=0 a valid adjustment afterward succeeds
There are no input screens, function keys, or subfiles to document. Operationally the “controls” are the procedure parameters and the data-layer guards below; the caller (STRSQL session, batch driver, or CALLPAY) checks SQLCODE / the OUT counters after each call.

B.2 Controls & audit

PAYSQL/i does not model a four-eyes maker–checker workflow: a pay run posts in one call and a sanctioned net adjustment applies immediately. The control posture is data-layer guards + automatic audit + idempotency:

In sum, the control model is guarded data + trigger accrual + audit, all enforced in the SQL-PL layer, rather than a segregation-of-duties approval flow.

C. Batch / Cycle Procedures ↑ top

PAYSQL/i's “cycle” is a single per-period event: for each pay period, seed a PYRUN header, then call PY_RUN_POST once. That one call computes every active employee's payslip, posts the GL, accrues YTD, and closes the run. There is no separate daily / weekly / monthly split — the natural cadence is one run per pay period (the tests use consecutive biweekly periods). The section below documents the full routine set the cycle drives.

C.1 Full procedure / function / trigger set

RoutineKindPurposeCalls / firesIn / Out
FN_ROUND2functionRound a DECIMAL to the cent, half-up.(V) → DECIMAL(11,2)
FN_TAXBRKfunctionGraduated federal tax on annual taxable income.cursor over PYBRK; FN_ROUND2(FSTAT, TAXABLE) → DECIMAL(11,2)
FN_PRORATEfunctionPartial-period proration factor, clamped [0,1].(WORKED, PERIOD) → DECIMAL(9,6)
PY_COMPUTE_GROSSprocedureBiweekly gross (salaried ÷26, prorated on term; hourly ×80).FN_PRORATE, FN_ROUND2IN run,emp; OUT gross
PY_WITHHOLDprocedureDeductions → taxable → annualize → bracket → net.cursor PYEMPDED⨯PYDEDC; FN_TAXBRK, FN_ROUND2IN emp,gross; OUT preded,fedtax,postded,net
PY_RUN_EMPLOYEEprocedureOne employee end-to-end; idempotent; UNDO on veto.PY_COMPUTE_GROSS, PY_WITHHOLD; INSERT PYRUNDTIN run,emp; OUT rc
PY_RUN_POSTprocedureLoop ACTIVE employees, post GL, flip run to POSTED.PY_RUN_EMPLOYEE; INSERT PYGL; UPDATE PYRUNIN run; OUT computed,skipped,vetoed,posted
PY_ADJUST_NETprocedureSanctioned net adjustment (net≥0), audited.UPDATE PYRUNDT; INSERT PYAUDIN run,emp,newnet
TR_PYRUNDT_VETOtriggerBEFORE INSERT: veto negative net (75014).on PYRUNDT
TR_PYRUNDT_ACCRtriggerAFTER INSERT: UPSERT YTD, write PYAUD.on PYRUNDT → PYACCUM, PYAUD
TR_PYSLIPV_UPDtriggerINSTEAD OF UPDATE: reroute net change to PY_ADJUST_NET.on PYSLIPV → PY_ADJUST_NET

C.2 The pay-run cycle in detail

Gross — PY_COMPUTE_GROSS

Reads the employee and the run's period. Salaried (PTYPE='S'): gross = ROUND(ANNSAL/26.0); if the employee terminated inside the period (TERMDT within [PSTART,PEND]) it prorates by a simple 14-day model — WORKEDDAYS = day-of-month(TERMDT) − day-of-month(PSTART) + 1 over 14, through FN_PRORATE. Hourly (PTYPE='H'): gross = ROUND(HRATE×80), no proration. An unknown pay type SIGNALs 75012.

Withholding & net — PY_WITHHOLD

Cursor-walks the employee's elected deductions (PYEMPDED joined to PYDEDC), summing pretax (DKIND='B') and posttax ('A') totals, each a flat amount (DBASIS='F') or a percent of gross ('P'). Then: taxable = gross − pretax (floored at 0); annualize annual = taxable×26 − EXEMPT×2000 (floored at 0); look up annualtax = FN_TAXBRK(FSTAT, annual); de-annualize fedtax = ROUND(annualtax/26.0); finally net = ROUND(gross − pretax − fedtax − posttax).

Worked example — E00001 (ann 78000, single, 1 exemption; 401K 5% + MED 75 pretax + UNIO 20 posttax):
  gross    = 78000.00 / 26              = 3000.00
  preded   = round(3000*0.05) + 75.00   = 150.00 + 75.00 = 225.00
  taxable  = 3000.00 - 225.00           = 2775.00
  annual   = 2775*26 - 1*2000           = 72150.00 - 2000 = 70150.00
  bracket S: floor 44725 rate .22 base 5147.00
    annualtax = 5147.00 + .22*(70150-44725) = 10740.50
  fedtax   = round(10740.50 / 26)       = 413.10
  net      = 3000 - 225 - 413.10 - 20   = 2341.90

Per-employee & posting — PY_RUN_EMPLOYEE / PY_RUN_POST

PY_RUN_EMPLOYEE is BEGIN ATOMIC: if a payslip already exists for (RUNID,EMPID) it returns SKIPPED; otherwise it nests the two computes and INSERTs the PYRUNDT row (returning COMPUTED). An UNDO handler on SQLEXCEPTION catches a trigger veto, unwinds that one INSERT, and returns VETOED — the run is not aborted. PY_RUN_POST loops every ESTAT='A' employee through it, tallying the four counters; then, in a second pass over the just-computed (DSTAT='C') rows, writes the four-line GL distribution per employee, flips each row to DSTAT='P', and finally sets PYRUN.RSTAT='P'. If the run is already posted, it returns immediately with all counters 0.

Expected OUT (battle RUN001: 5 salaried + 20 hourly, 1 net-negative vetoed):
  COMPUTED = 24   SKIPPED = 0   VETOED = 1   POSTED = 24
The GL lines per employee are: +GROSS debit to wage-expense 700000, −FEDTAX credit to tax-liability 210000, −(POSTDED+PREDED) credit to benefits-payable 220000, −NET credit to cash 100000 — four lines that net to 0.00 per employee (the balance invariant the tests assert).

C.3 Ordering & idempotency

D. Data Files (data dictionary) ↑ top

All files are in library PAYSQL, grounded in schema.sql. Dates are stored as INT in YYYYMMDD form (or YYYY for the accumulator year); money is DECIMAL; bracket/deduction rates are DECIMAL proportions or percents.

PYEMP — Employee master (PK EMPID)

FieldTypeMeaning
EMPIDCHAR(6)Employee number (PK), e.g. E00001.
ENAMEVARCHAR(30)Employee name.
PTYPECHAR(1)Pay type: S salary, H hourly.
ANNSALDECIMAL(11,2)Annual salary (used when PTYPE=S).
HRATEDECIMAL(9,4)Hourly rate (used when PTYPE=H).
FSTATCHAR(1)Filing status: S single, M married (bracket-table key).
EXEMPTINTExemption count; each reduces annual taxable by $2000.
HIREDTINTHire date (YYYYMMDD).
TERMDTINTTermination date (YYYYMMDD); 0 = not terminated. Drives proration.
ESTATCHAR(1)Employee status: A active, T terminated. Only A employees are run.

PYBRK — Graduated tax brackets (PK FSTAT, BFLOOR)

FieldTypeMeaning
FSTATCHAR(1)Filing status this bracket applies to (part of PK).
BFLOORDECIMAL(11,2)Lowest annual income (inclusive) this bracket covers (part of PK).
BRATEDECIMAL(6,4)Marginal rate as a proportion (0.2200 = 22%).
BBASEDECIMAL(11,2)Cumulative tax already owed at the floor.

Tax on income I in [BFLOOR, next BFLOOR) is BBASE + BRATE×(I−BFLOOR). Seeded (illustrative, not a real-year IRS table): S floors 0/11000/44725/95375/182100 at 10/12/22/24/32%; M floors 0/22000/89450/190750/364200 at the same rates.

PYDEDC — Deduction-code catalog (PK DEDCODE)

FieldTypeMeaning
DEDCODECHAR(4)Deduction code (PK), e.g. 401K, MED .
DDESCVARCHAR(20)Description.
DKINDCHAR(1)B pretax (reduces taxable), A posttax.
DBASISCHAR(1)F flat amount, P percent of gross.
DVALDECIMAL(9,4)Amount (F) or percent 0–100 (P).

Seeded: 401K (B/P 5%), MED (B/F $75), UNIO (A/F $20), GARN (A/F $500).

PYEMPDED — Deduction elections (PK EMPID, DEDCODE)

FieldTypeMeaning
EMPIDCHAR(6)Employee (part of PK).
DEDCODECHAR(4)Elected deduction code (part of PK).

The withhold cursor JOINs this to PYDEDC; an election whose code no longer exists in PYDEDC simply drops out of the join (no run abort).

PYRUN — Pay-run header (PK RUNID)

FieldTypeMeaning
RUNIDCHAR(6)Run identifier (PK), e.g. RUN001.
PSTART / PENDINTPeriod start / end (YYYYMMDD). PEND's year drives the YTD year.
PAYDTINTPay date (YYYYMMDD).
RSTATCHAR(1)O open, P posted (set by PY_RUN_POST).

PYRUNDT — Pay-run detail / payslip (PK RUNID, EMPID)

FieldTypeMeaning
RUNID / EMPIDCHAR(6)/CHAR(6)Run + employee (PK) — one payslip per employee per run.
GROSSDECIMAL(11,2)Computed gross pay.
PREDEDDECIMAL(11,2)Pretax deductions total.
TAXWAGEDECIMAL(11,2)Taxable wages (gross − pretax) this period.
FEDTAXDECIMAL(11,2)Federal withholding.
POSTDEDDECIMAL(11,2)Posttax deductions total.
NETDECIMAL(11,2)Net pay. Vetoed if negative.
DSTATCHAR(1)C computed, P posted (flipped during the GL pass).

PYACCUM — YTD accumulator (PK EMPID, PYEAR)

FieldTypeMeaning
EMPID / PYEARCHAR(6)/INTEmployee + payroll year (PK).
YGROSSDECIMAL(13,2)Year-to-date gross.
YFEDTAXDECIMAL(13,2)Year-to-date federal tax.
YNETDECIMAL(13,2)Year-to-date net.

Maintained only by TR_PYRUNDT_ACCR (AFTER INSERT on PYRUNDT), never written directly by the posting procedure — the accrual is trigger-driven, not application bookkeeping.

PYGL — GL distribution (IDENTITY GSEQ)

FieldTypeMeaning
GSEQINT identityGENERATED ALWAYS AS IDENTITY sequence.
RUNID / EMPIDCHAR(6)/CHAR(6)Run + employee this GL line belongs to.
ACCTNOCHAR(6)GL account: 700000 wage expense, 210000 tax liability, 220000 benefits payable, 100000 cash.
GAMTDECIMAL(11,2)Signed amount: + debit, credit. The four lines per employee net to 0.00.
The IDENTITY column is declared without an explicit PRIMARY KEY clause. Per the source comment (SQLPL-SIM-FINDINGS.md SQLPL-PLAT-PAY-01), the engine's IDENTITY rewrite makes the column INTEGER PRIMARY KEY AUTOINCREMENT on its own, so the PK still exists; this is a deliberate workaround for a rewrite regex bug, not a semantic change.

PYAUD — Trigger audit log (IDENTITY ASEQ)

FieldTypeMeaning
ASEQINT identityGENERATED ALWAYS AS IDENTITY sequence.
OPCHAR(10)Operation: ACCRUE (AFTER-insert accrual) or ADJUST (net adjustment).
RUNID / EMPIDCHAR(6)/CHAR(6)Run + employee affected.
DETAILVARCHAR(60)Human-readable detail written by the trigger/procedure.

PYSLIPV — Payslip view (over PYRUNDT)

Exposes RUNID, EMPID, GROSS, FEDTAX, NET, DSTAT. Updatable only through TR_PYSLIPV_UPD (INSTEAD OF UPDATE): a net change reroutes to PY_ADJUST_NET (which re-validates net≥0); other columns are inert (the “view is otherwise inert” contract).

Relationships

E. Operations Runbook ↑ top

E.1 Running a pay run

  1. Confirm the job's library list includes PAYSQL (LIBL = QSYS QGPL PAYSQL QTEMP, CURLIB = PAYSQL).
  2. Confirm the employee master, deduction codes/elections and brackets are current (a new hire needs a PYEMP row with ESTAT='A' before it is picked up).
  3. Create the pay-run header for the period (status open): INSERT INTO PAYSQL/PYRUN VALUES ('RUN001',<PSTART>,<PEND>,<PAYDT>,'O').
  4. Run the payroll cycle in one call and capture the four OUT counters:
-- STRSQL (or wrap in a batch driver / CALLPAY and SBMJOB it)
CALL PAYSQL.PY_RUN_POST('RUN001', ?, ?, ?, ?);
--   OUT: COMPUTED  SKIPPED  VETOED  POSTED

Post-checks after the run:

Adjusting a posted payslip. Change net only through the view, which reroutes to the validated procedure:

-- sanctioned: reroutes to PY_ADJUST_NET, re-validates net >= 0, audits
UPDATE PAYSQL/PYSLIPV SET NET = 700.00 WHERE RUNID='RUN001' AND EMPID='E00003';
-- a negative net here is REJECTED (SQLSTATE 75013); the row is left unchanged

E.2 Reconciling the figures

These are the same invariants the battle and volume simulations check against an independent hand-derived (Python) oracle — nothing is read back and asserted against itself.

SELECT EMPID FROM PAYSQL/PYRUNDT
  WHERE RUNID='RUN001' AND ROUND(GROSS-PREDED-FEDTAX-POSTDED,2) <> NET;
-- expected: zero rows
SELECT EMPID FROM PAYSQL/PYGL WHERE RUNID='RUN001'
  GROUP BY EMPID HAVING COUNT(*) <> 4 OR ROUND(SUM(GAMT),2) <> 0;
-- expected: zero rows
Rounding is cent-exact everywhere: FN_ROUND2 is HALF-UP at the cent, and the engine's DECIMAL handling was hardened (SQLPL-PLAT-PAY-02, fixed at engine commit 407bee974) so the four former float-boundary employees (E00011/E00013/E00021/E00023) now tie to the oracle with zero tolerance. A re-appearing one-cent mismatch on those is a regression, not a tolerated gap.

E.3 Failure & re-run rules

SituationBehaviourAction
Re-run a posted runPY_RUN_POST early-returns; all counters 0.Safe no-op. YTD is not doubled, GL is not duplicated. Idempotent.
Re-run with new employees addedExisting payslips are SKIPPED; only the header being still 'O' lets new ones compute.Existing rows are never recomputed; a fully posted run won't pick up late additions — use a new run id.
An employee's net would be negativeTR_PYRUNDT_VETO SIGNALs 75014; PY_RUN_EMPLOYEE's UNDO handler unwinds that INSERT, returns VETOED.Run continues; the employee gets no payslip. Correct the deduction elections (e.g. an over-large garnishment) and re-run into a fresh run id.
Unknown filing status / pay typeFN_TAXBRK (75010) / PY_COMPUTE_GROSS (75012) SIGNAL.Fix the PYEMP row; the guard prevents a silent zero-tax or bad gross.
Zero-length pay periodFN_PRORATE SIGNALs 75011.Correct PSTART/PEND on the PYRUN header.
Ad-hoc UPDATE of a payslip netDirect base-table balance edits are not sanctioned; the view reroutes and re-validates.Adjust only via UPDATE PYSLIPV; a negative value is rejected (75013).
Adjustment via CALLPAY / RPGAn unhandled SIGNAL surfaces to RPG as SQLCODE -438.Non-zero SQLCODE means the adjustment did not land; correct the amount and retry.
Because every posted payslip fires the accrual and audit triggers, and every GL line is written per employee, a run's effect is fully reconstructable after the fact from PYRUNDT/PYGL/PYACCUM/PYAUD for reconciliation.

F. Developer Reference ↑ top

The complete SQL-PL surface, from schema.sql and routines.sql. All objects are in library PAYSQL. Signatures are given as declared; IN parameters precede OUT.

F.1 Tables & the view

ObjectKeyNotes
PYEMPEMPIDMaster; PTYPE S/H, FSTAT S/M, ESTAT A/T, TERMDT 0=active.
PYBRKFSTAT, BFLOORSeeded 5 rows per filing status; tax=BBASE+BRATE×(I−BFLOOR).
PYDEDCDEDCODESeeded 401K/MED/UNIO/GARN; DKIND B/A, DBASIS F/P.
PYEMPDEDEMPID, DEDCODEElections; JOINed to PYDEDC in PY_WITHHOLD.
PYRUNRUNIDHeader; RSTAT O→P; PEND year is the YTD year.
PYRUNDTRUNID, EMPIDPayslip; DSTAT C→P; carries all computed money columns.
PYACCUMEMPID, PYEARYTD; trigger-maintained only.
PYGLGSEQ (identity)4 lines/employee netting to 0; accounts 700000/210000/220000/100000.
PYAUDASEQ (identity)OP ACCRUE/ADJUST audit trail.
PYSLIPVview/PYRUNDTSELECT RUNID,EMPID,GROSS,FEDTAX,NET,DSTAT; INSTEAD OF UPDATE on NET.

F.2 Functions (3)

FN_ROUND2 (V DECIMAL(15,6)) RETURNS DECIMAL(11,2)
Compound scalar. Rounds to the cent HALF-UP (away from zero; payroll amounts are ≥0 so half-up and half-away-from-zero agree): scales ×100, takes FLOOR, adds 1 if the fraction ≥0.5, divides by 100.0. The divisor literal carries an explicit decimal point deliberately — an integer-looking operand truncates like C division (SQLPL-PLAT-01 workaround).
FN_TAXBRK (FSTATIN CHAR(1), TAXABLE DECIMAL(11,2)) RETURNS DECIMAL(11,2)
Compound scalar. Cursor-walks PYBRK for the filing status ordered by floor, keeping the highest floor ≤ income (income floored at 0), then returns FN_ROUND2(BESTBS + BESTRT×(INCOME−BESTFLR)). If no bracket matches (unknown filing status) it SIGNALs SQLSTATE 75010 rather than taxing at zero.
FN_PRORATE (DAYSWORKED INT, DAYSINPERIOD INT) RETURNS DECIMAL(9,6)
Compound scalar. Returns DAYSWORKED/DAYSINPERIOD clamped to [0,1]. A non-positive period SIGNALs 75011. Multiplies by 1.0 to force decimal (not integer-truncated) division (SQLPL-PLAT-01 workaround).

F.3 Procedures (5)

PY_COMPUTE_GROSS (IN RUNIDIN CHAR(6), IN EMPIDIN CHAR(6); OUT GROSSOUT DECIMAL(11,2))
Reads PYEMP + the PYRUN period. Salaried: FN_ROUND2(ANNSAL/26.0), prorated by FN_PRORATE(WORKEDDAYS,14) when TERMDT falls in [PSTART,PEND] (WORKEDDAYS = MOD(TERMDT,100)−MOD(PSTART,100)+1). Hourly: FN_ROUND2(HRATE×80). Unknown PTYPE → 75012. Divisor 26.0 (not 26) is a SQLPL-PLAT-01 workaround.
PY_WITHHOLD (IN EMPIDIN CHAR(6), IN GROSSIN DECIMAL(11,2); OUT PREDEDOUT, FEDTAXOUT, POSTDEDOUT, NETOUT DECIMAL(11,2))
Cursor over PYEMPDED⨯PYDEDC accumulating pretax (DKIND B) and posttax (A) totals, flat (DBASIS F) or percent-of-gross (P, via GROSSIN×DVAL/100.0). Then TAXABLE=GROSS−PREDED (≥0); ANNUAL=TAXABLE×26−EXEMPT×2000 (≥0); ANNUALTAX=FN_TAXBRK(FSTAT,ANNUAL); FEDTAX=FN_ROUND2(ANNUALTAX/26.0); NET=FN_ROUND2(GROSS−PREDED−FEDTAX−POSTDED). A NOT-FOUND CONTINUE handler ends the cursor; an orphaned election (code absent from PYDEDC) simply drops from the join.
PY_RUN_EMPLOYEE (IN RUNIDIN CHAR(6), IN EMPIDIN CHAR(6); OUT RCOUT VARCHAR(20))
BEGIN ATOMIC. If a PYRUNDT row exists for (run,emp) → RCOUT='SKIPPED'; else nests PY_COMPUTE_GROSS then PY_WITHHOLD, computes TAXWAGE=GROSS−PREDED, INSERTs the payslip (DSTAT 'C') → RCOUT='COMPUTED'. An UNDO HANDLER FOR SQLEXCEPTION sets RCOUT='VETOED' and unwinds the INSERT (so a trigger veto doesn't abort the caller's loop).
PY_RUN_POST (IN RUNIDIN CHAR(6); OUT COMPUTED, SKIPPED, VETOED, POSTED INT)
Early-returns (all counters 0) if the run is already RSTAT='P'. Cursor C1 over ESTAT='A' employees calls PY_RUN_EMPLOYEE and tallies by return code. Cursor C2 over the just-computed (DSTAT='C') rows writes the 4-line GL distribution per employee (+GROSS/700000, −FEDTAX/210000, −(POSTDED+PREDED)/220000, −NET/100000), flips each row to 'P', increments POSTED; finally UPDATE PYRUN SET RSTAT='P'.
PY_ADJUST_NET (IN RUNIDIN CHAR(6), IN EMPIDIN CHAR(6), IN NEWNET DECIMAL(11,2))
The only sanctioned path to change a posted net (target of TR_PYSLIPV_UPD). Re-validates NEWNET≥0 (else 75013 — defense in depth, never trusting the caller), UPDATEs PYRUNDT.NET, and writes an ADJUST row to PYAUD.

F.4 Triggers (3)

TR_PYRUNDT_VETO — BEFORE INSERT ON PYRUNDT, FOR EACH ROW, BEGIN ATOMIC
If N.NET < 0SIGNAL SQLSTATE '75014'. Rejects any negative-net payslip, including an ad-hoc INSERT that tries to bypass PY_WITHHOLD.
TR_PYRUNDT_ACCR — AFTER INSERT ON PYRUNDT, FOR EACH ROW, BEGIN ATOMIC
Derives the year from the run's PEND (YR = PEND/10000), then UPSERTs PYACCUM(EMPID,YR) (SELECT COUNT INTO → INSERT or additive UPDATE, since MERGE is not assumed available), and writes an ACCRUE row to PYAUD. This is the sole writer of PYACCUM.
TR_PYSLIPV_UPD — INSTEAD OF UPDATE ON PYSLIPV, FOR EACH ROW, BEGIN ATOMIC
Reroutes the update to CALL PY_ADJUST_NET(O.RUNID, O.EMPID, N.NET), so no direct UPDATE of a payslip net can bypass the net≥0 re-validation. Only NET is rerouted; other view columns are inert.

F.5 SQL-PL patterns used

PAYSQL/i is a compact tour of the compound-SQL-PL idioms; a maintainer sees these repeatedly:

F.6 Application SQLSTATE table

SQLSTATERaised byMeaning
75010FN_TAXBRKNo bracket for the filing status (unknown FSTAT) — never silently tax at 0.
75011FN_PRORATENon-positive pay-period length.
75012PY_COMPUTE_GROSSUnknown pay type (PTYPE not S or H).
75013PY_ADJUST_NETNegative net rejected (re-validation).
75014TR_PYRUNDT_VETONegative net pay rejected on INSERT.

When one of these SIGNALs is unhandled by an SQLRPGLE caller, it surfaces as SQLCODE -438 (the platform convention proven in CALLPAY's PY_ADJUST_NET(-5.00) step).

G. Glossary ↑ top

Annualize / de-annualize
Scaling a biweekly figure to a yearly one (×26) to look up graduated tax, then back (÷26) to a per-period withholding — PY_WITHHOLD's core move.
Bracket (graduated tax bracket)
A band of annual income (PYBRK) with a marginal rate and a cumulative base tax at its floor; tax = base + rate×(income−floor).
DSTAT / RSTAT
Payslip status (C computed, P posted) / run status (O open, P posted). The GL pass flips DSTAT and then RSTAT.
Deduction (pretax / posttax)
An amount withheld from pay. Pretax (DKIND B) reduces taxable wages before tax; posttax (A) reduces net after tax. Each is flat (DBASIS F) or a percent of gross (P).
Gross-to-net
The full payroll computation from gross pay through deductions and withholding to net pay.
Idempotent
Safe to run again with the same result. PY_RUN_POST on a posted run is a no-op; already-present payslips are skipped; YTD and GL are not doubled.
INSTEAD OF trigger
A view trigger that replaces the default DML with custom logic. TR_PYSLIPV_UPD reroutes a net update to the validated PY_ADJUST_NET.
Proration
Scaling a salaried gross for a partial period (e.g. a mid-period termination) by FN_PRORATE, clamped to [0,1].
SIGNAL / SQLSTATE
The SQL-PL way to raise an application error with a state code (here 750xx) and message. An unhandled SIGNAL reaches an RPG caller as SQLCODE -438.
SQL PL
DB2's procedural SQL language for functions, procedures and triggers. In PAYSQL/i it holds the entire application — there is no RPG business logic.
SQLRPGLE / EXEC SQL CALL
RPG with embedded SQL. CALLPAY uses EXEC SQL CALL only to prove the SQL-PL procedures are reachable from compiled RPG; it holds no payroll math.
UPSERT
Insert-or-update in one logical step. TR_PYRUNDT_ACCR does it via SELECT COUNT INTO / IF, without MERGE, to maintain the YTD accumulator.
YTD accumulator (PYACCUM)
Per-employee, per-year running totals of gross/fedtax/net, maintained solely by the AFTER-insert trigger so the books cannot drift from the payslips.