Skip to content

Payroll Ledger Data Provenance

Traces every table involved in generating a PayrollRecord (급여대장) — column definitions, data flows, and the AllowanceRecord-as-atom architecture.

Source entities hold observations only. OvertimeRecord, FixedIncentive, EducationSubsidy, LeaveRequest, ResignationRecord, and YearEndSettlement carry no calculated monetary fields. All amounts live on AllowanceRecord / DeductionRecord, which are produced only as the output of a sheet's "✓ 커밋" (commit) action and carry sheet lineage (sourceSheetId / sourceSheetRowId / sourceSheetColumnKey, NOT NULL). Insurance/tax/allowance amounts come from per-column calculators whose parameters live on sheet column metadata + the org parameter store. See Payroll Schema Refactor.

Architecture Principle

AllowanceRecord is the atom of all pay. Every earnings line item — base salary, overtime, allowances, incentives — becomes an individual AllowanceRecord. Every deduction — insurance, tax, settlements — becomes a DeductionRecord. The PayrollRecord is a header that links to these atomic records via FK and stores cached summary totals.

Append-Only Meta Tables

All meta tables (EmploymentContract, OvertimeRecord, FixedIncentive, YearEndSettlement, EducationSubsidy, ResignationRecord) are append-only. Content is never updated in place — a new version row is inserted and the old row is marked with supersededAt. This preserves full change history.

ColumnTypePurpose
chainIduuidGroups all versions of the same logical entity
versionintegerMonotonically increasing within a chain (starts at 1)
supersededAttimestamp, nullableNULL = current version; set when a newer version is inserted
  • Content changes (amounts, dates, rates): create a new version (version + 1, same chainId)
  • Status changes (pending → approved → paid): direct metadata update on the same row
  • Query pattern: always filter WHERE supersededAt IS NULL to get current versions
  • History: query WHERE chainId = :chainId ORDER BY version DESC for full audit trail

Provenance Tracking

AllowanceRecord and DeductionRecord track which source-observation row generated them:

ColumnTypePurpose
sourceTypeenum (PayrollSourceType)Which source module generated this record
sourceIduuid, nullableSpecific source row ID (null for aggregated sources)
inputSnapshotjsonb(planned) Frozen calculator inputs (hours, hourlyRate, multiplier, unitPrice, count) — makes amounts replayable without joining back to source tables
formulaVersionstring(planned) Recipe version at calc time

PayrollSourceType values (packages/core/src/enums/payroll.enum.ts): employment_contract, overtime_record, fixed_incentive, education_subsidy, year_end_settlement, resignation_record, payroll_rate_config, tax_bracket_config, manual

Data Flow Overview

Key invariant (verified 2026-08-13): the money atoms AllowanceRecord/DeductionRecord are created only by sheet commit (commitSheet). generateForPeriod neither creates nor deletes atoms — it builds a PayrollRecord header snapshot and links existing atoms via payrollRecordId. (An earlier version of this doc described generateForPeriod as creating/deleting atoms filtered by isAutoGenerated — that is wrong and dangerous; see the Flow section.)


Flow: Sheet Commit vs Snapshot Generation

Money atoms are created by sheet commit (commitSheet)

  • AllowanceRecord/DeductionRecord are created only on the sheet "✓ 커밋". compute.ts aggregates source observations under an approval-status gate (e.g. overtime status IN ('approved','report_pending','report_approved','report_rejected')), and commitSheet writes the result as AR/DR with isAutoGenerated = true.
  • Re-commit soft-deletes (deletedAt) the existing AR/DR for that sourceSheetId and re-inserts them (still isAutoGenerated = true).
  • Settlement writers (year-end, resignation) are the only writers that set isAutoGenerated = false — this means "settlement output", not "manual entry"; it gates their own cancel-cleanup.

generateForPeriod (PayrollRecord snapshot only — does NOT delete atoms)

  1. If the period is already CONFIRMED, block (PRECONDITION_FAILED).
  2. Delete only the stale PayrollRecord rows for the period. AR/DR are NOT deleted — they are unlinked via payrollRecordId = NULL.
  3. Re-create the PayrollRecord header (cached summary totals).
  4. Re-link all AR/DR for the period via payrollRecordId.

⚠️ Do NOT reintroduce an isAutoGenerated = true filter that deletes/recreates AR/DR inside generateForPeriod. That filter was deliberately removed — AR/DR are sheet-commit outputs, so deleting them here destroys sheet-committed payroll data. Source: the inline comment in packages/api/src/routers/payroll.router.ts generateForPeriod, and .claude/rules/payroll/korean-payroll-compliance.md.


⚠️ 구현 모호점 · 하드닝 후보 (2026-08-13 감사)

이 문서를 근거로 기능을 하드닝할 때 우선 검토할 실코드 취약점:

  1. 초과근무 집계 이중 경로(SSOT 미정) — 실 급여 경로는 sheet-rows/compute.ts(SQL, 상태 게이트)이나, 별도 OvertimeRecordCalculator(packages/api/src/calculators/overtime-record.calculator.ts)는 status 필터 없이 supersededAt IS NULL만으로 합산한다. 이 계산기는 현재 Module Manager 상태 조회(module-registry.router.ts)에서만 생성되어 급여를 만들지 않지만, 이 파이프라인을 급여 경로로 재배선하면 대기·취소 초과근무가 급여에 새어든다. 재배선 시 status 게이트 필수.
  2. compute.tsreport_rejected(보고서반려)를 급여 집계에 포함 — 주석은 "pending/rejected/cancelled 제외"라 하나 IN 목록엔 report_rejected가 있다. 연장 자체는 승인됐으니 근무는 실재하므로 의도일 수 있으나, 정책 문서(docs/ko/policy/.../overtime.md: "보고서 승인 시점 반영")와 어긋난다 → 반영 기준 확정 필요.
  3. isAutoGenerated = false의 의미가 오해되기 쉬움 — "수동 입력"이 아니라 "정산 라이터(연말/퇴직) 출력"이다. 이 플래그로 분기하는 cancel-cleanup 로직 수정 시 주의.
  4. 휴일 마스터 ↔ 휴일수당 계산 미연결OrganizationHoliday(조직 휴일 달력)는 존재하며 연차(leave-request/leave-ledger)·출근율(attendance-rate)은 소비하지만, 휴일수당 계산 경로(sheet-rows/compute.ts·계산기·attendance-finalizer)는 이를 읽지 않는다 — 휴일 수당은 여전히 수동 HOLIDAY 카테고리/Shift.treatAsHoliday로만 발생. 연결 여부 제품 판단 필요.
  5. NIGHT/HOLIDAY_NIGHT 유효 2.0x 이중 버킷holidayMultiplier/nightMultiplier를 org별로 재파라미터화하면 유효배율이 바뀐다. 어느 한 버킷을 누락하면 조용한 과소지급(과거 2회 재발). 모듈 레벨 회귀 가드는 신설됨(packages/api/src/calculators/payroll-correctness.test.ts) — 계산기 레벨 카테고리→버킷 매핑과 국민연금 상·하한(cap/floor)은 여전히 미커버.

1. Employee

Table: employees

ColumnTypePayroll Usage
iduuid (PK)AllowanceRecord.employeeId FK
employeeNumberstring, uniqueUI display, eCount name lookup
firstName / lastNamestringeCount export: ${lastName}${firstName}
statusenumFilter: ACTIVE only
organizationIduuid (FK)Multi-tenancy filter

2. EmploymentContract

Table: employment_contractsMeta table providing input to AllowanceRecord creation.

ColumnTypePayroll Usage
baseSalarydecimal(15,2)→ AllowanceRecord (itemCode 01 월급여)
allowancesjsonb→ AllowanceRecords (meal→19, transportation→14, housing→14)
salaryGradeIduuid (FK)SalaryGrade reference
isActivebooleanOnly active contracts used

3. OvertimeRecord

Table: overtime_recordsSource observation table. Pay amounts are computed by OvertimeRecordCalculator (reads overtimeHours × Contract.baseSalary/209 × the overtime multiplier configured on the sheet column / org parameter store) and written to AllowanceRecord on sheet commit.

ColumnTypePayroll Usage
employeeIduuid (FK)Aggregation key
workDatedatePeriod filter
overtimeHoursdecimal(7,2)→ Calculator input
mealBreakHoursdecimal(7,2)Subtracted for net hours
categoryenumCategory → AllowanceRecord itemCode routing
statusenumSheet aggregation gate: approved / report_pending / report_approved / report_rejected (sheet-rows/compute.ts)
hourlyRatedecimal(15,2)(removing) — recomputed at calc time
multiplierdecimal(5,2)(removed column) — value now on the sheet column / org parameter store
calculatedPaydecimal(15,2)(removing) — lives on AllowanceRecord.amount

Category → AllowanceRecord ItemCode (LEGACY — pre-migration 1816)

⚠️ Legacy mapping. Since migration 1816, AllowanceRecord carries no item-code FK — records target a sheet column via targetSheetSlug/targetColumnKey instead, and eCount A/D-code classification happens at export time. The table below documents the retired fixed mapping (note it predates HOLIDAY_NIGHT, which buckets as holiday 1.5 + night 0.5).

CategoryAllowanceRecord itemCodeName
WEEKDAY10연장근무수당1
NIGHT12연장근무수당2
SATURDAY13야간근무수당1
HOLIDAY09휴일근무수당

4. AllowanceRecord (THE ATOM)

Table: allowance_recordsAtomic source of truth for all earnings.

ColumnTypeUsage
iduuid (PK)Primary key
employeeIduuid (FK)Employee reference
year / monthnumberPeriod
amountdecimal(15,2)The pay amount
typeenumAllowanceType (OTHER for auto-generated)
paymentMethodenumSALARY for payroll inclusion
reasonstringHuman-readable description
statusenumAPPROVED / PAID
itemCodeIduuid (FK)AllowanceItemCode FK (eCount code)
payrollRecordIduuid (FK)PayrollRecord FK (linked on generate)
isAutoGeneratedbooleantrue = sheet-commit output (commitSheet); false = settlement writer (year-end/resignation), NOT "manual"
sourceTypeenumPayrollSourceType — which meta table generated this
sourceIduuid, nullableSpecific meta row ID (null for aggregated)
organizationIduuid (FK)Multi-tenancy

Sheet-committed AllowanceRecords (by commitSheet)

SourceitemCodeReasonAmountsourceTypesourceId
Contract.baseSalary01월급여baseSalaryemployment_contractcontract.id
OvertimeRecord (WEEKDAY)10연장근무수당1Σ calculatedPayovertime_recordnull
OvertimeRecord (NIGHT)12연장근무수당2Σ calculatedPayovertime_recordnull
OvertimeRecord (SATURDAY)13야간근무수당1Σ calculatedPayovertime_recordnull
OvertimeRecord (HOLIDAY)09휴일근무수당Σ calculatedPayovertime_recordnull
Contract.allowances.meal19식대meal amountemployment_contractcontract.id
Contract.allowances.transportation14교통비amountemployment_contractcontract.id
Contract.allowances.housing14주거비amount
FixedIncentive23간호간병평가인센티브Σ amount

5. DeductionRecord (THE ATOM)

Table: deduction_recordsAtomic source of truth for all deductions.

ColumnTypeUsage
iduuid (PK)Primary key
employeeIduuid (FK)Employee reference
year / monthnumberPeriod
amountdecimal(15,2)The deduction amount
reasonstringHuman-readable description
statusenumAPPROVED / APPLIED
itemCodeIduuid (FK)DeductionItemCode FK (eCount code)
payrollRecordIduuid (FK)PayrollRecord FK (linked on generate)
isAutoGeneratedbooleantrue = sheet-commit output (commitSheet); false = settlement writer (year-end/resignation), NOT "manual"
sourceTypeenumPayrollSourceType — which meta table generated this
sourceIduuid, nullableSpecific meta row ID (null for aggregated)
organizationIduuid (FK)Multi-tenancy

Sheet-committed DeductionRecords (by commitSheet)

Insurance rates come from the org parameter store / sheet column bound to InsuranceCalculator, and rows are written on sheet commit.

SourceitemCodeReasonFormulasourceTypesourceId
InsuranceCalculator03국민연금round(clamp(base, floor, cap) × rate)employment_contractcontract.id
InsuranceCalculator04건강보험round(baseSalary × rate)employment_contractcontract.id
InsuranceCalculator05고용보험round(baseSalary × rate)employment_contractcontract.id
InsuranceCalculator20장기요양보험round(baseSalary × rate)employment_contractcontract.id
TaxBracketConfig01소득세round((taxable × rate - deduction) / 12)employment_contractcontract.id
TaxBracketConfig02주민세round(incomeTax × 0.10)

6. FixedIncentive

Table: fixed_incentivesMeta table providing input to AllowanceRecord creation.

ColumnTypePayroll Usage
employeeIduuid (FK)Aggregation key
year / monthnumberPeriod filter
totalAmountdecimal(15,2)→ AllowanceRecord (itemCode 23)
unitPricedecimal(15,2)Unit price per count
unitCountintNumber of units
totalAmountdecimal(15,2)→ AllowanceRecord (itemCode 23)
categoryenumIncentive category (rehabilitation, nursing, etc.)

7. YearEndSettlement (연말정산)

Table: year_end_settlementsSource observation / lifecycle table. Adjustment amounts materialize as DeductionRecord rows on sheet commit. The installment-split plan is configured on the sheet column / org parameter store.

ColumnTypePayroll Usage
employeeIduuid (FK)Employee reference
settlementYearnumberTarget year
applicationDatedate, nullableWhen adjustment applied
applicationMonthnumber, nullablePayroll month for application
statusenumPENDING → CALCULATED → APPROVED → APPLIED → COMPLETED
adjustmentAmountdecimal(15,2)(removing) — lives on DeductionRecord.amount (itemCode 10)
installmentMonthsnumber(removed column) — installment split now on the sheet column / org parameter store
installmentAmountdecimal(15,2)(removing) — derived from adjustmentAmount / installmentMonths

8. EducationSubsidy (교육보조금)

Table: education_subsidiesSource observation / lifecycle table. Subsidy amount materializes as AllowanceRecord via EducationSubsidyCalculator on sheet commit. The recipe (per-course-type unit price) lives on the sheet column / org parameter store.

ColumnTypePayroll Usage
employeeIduuid (FK)Employee reference
courseNamestringCourse identification
paymentDatedatePayment timing
receiptVerifiedbooleanVerification status
statusenumPENDING → APPROVED → PAID → CANCELLED
courseAmountdecimal(15,2)(removing) — lives on AllowanceRecord.amount

9. ResignationRecord (퇴직정산)

Table: resignation_recordsSource observation / lifecycle table. Settlement amounts are decomposed into individual AllowanceRecord / DeductionRecord rows by ResignationRecordCalculator.

ColumnTypePayroll Usage
employeeIduuid (FK)Employee reference
resignationDatedateLast working day
reasonstring, nullableResignation reason
statusenumPENDING → PROCESSING → SETTLED → COMPLETED
settlementAmountdecimal(15,2)(removing) — decomposed into ledger rows
healthInsuranceRecondecimal(15,2)(removing) — → DeductionRecord itemCode 12
yearEndRecondecimal(15,2)(removing) — → DeductionRecord itemCode 10
annualLeavePayoutdecimal(15,2)(removing) — → AllowanceRecord itemCode 17
unusedLeaveDaysdecimal(5,2)(removing) — derived from leave entitlement − usage
totalSettlementdecimal(15,2)(removing) — sum of ledger rows

10. Insurance rates (4대보험)

Insurance rates (국민연금 / 건강보험 / 고용보험 / 장기요양보험 employee rate, national-pension cap/floor) are read by the InsuranceCalculator from the org-wide parameter store via the payroll_rate_config codeModule key, together with the sheet column metadata that binds the deduction column to the insurance calculator. The computed amount is written to DeductionRecord only when the sheet is committed ("✓ 커밋"), carrying the sheet lineage triple. See Payroll Schema Refactor.


11. TaxBracketConfig

Table: tax_bracket_configs — Progressive tax bracket definitions.

ColumnTypePayroll Usage
minIncome / maxIncomedecimal(15,2)Bracket lookup
ratedecimal(8,4)→ DeductionRecord amount calculation
deductiondecimal(15,2)→ Progressive deduction
effectiveYearnumberYear filter

12–16. Reference Tables

SalaryGrade (salary_grades)

Grade-based salary definitions by job category. Indirect reference via EmploymentContract.salaryGradeId.

AllowanceItemCode (allowance_item_codes)

eCount allowance item code master data. Referenced via AllowanceRecord.itemCodeId.

DeductionItemCode (deduction_item_codes)

eCount deduction item code master data. Referenced via DeductionRecord.itemCodeId.


17. PayrollRecord (Header)

Table: payroll_records — Header record linking to AllowanceRecords/DeductionRecords. One per employee per month.

Relationships

RelationshipTypeDescription
allowanceRecordsOneToMany → AllowanceRecordAll earnings for this payroll
deductionRecordsOneToMany → DeductionRecordAll deductions for this payroll
detailsOneToMany → PayrollDetailItemized breakdown (backward compat)

Cached Summary Fields

These fields are computed from linked AllowanceRecords/DeductionRecords during generation:

ColumnTypeComputed From
baseSalarydecimal(15,2)AllowanceRecord (itemCode 01)
overtimePaydecimal(15,2)AllowanceRecord (itemCode 10)
nightDifferentialdecimal(15,2)AllowanceRecord (itemCode 12)
weekendDifferentialdecimal(15,2)AllowanceRecord (itemCode 13)
holidayDifferentialdecimal(15,2)AllowanceRecord (itemCode 09)
allowancesjsonbAllowanceRecords (meal, etc.)
totalEarningsdecimal(15,2)Σ ALL AllowanceRecords
nationalPensiondecimal(15,2)DeductionRecord (itemCode 03)
healthInsurancedecimal(15,2)DeductionRecord (itemCode 04)
employmentInsurancedecimal(15,2)DeductionRecord (itemCode 05)
longTermCareInsurancedecimal(15,2)DeductionRecord (itemCode 20)
incomeTaxdecimal(15,2)DeductionRecord (itemCode 01)
localIncomeTaxdecimal(15,2)DeductionRecord (itemCode 02)
totalDeductionsdecimal(15,2)Σ ALL DeductionRecords
netPaydecimal(15,2)totalEarnings - totalDeductions

Status & Approval

ColumnTypeUsage
statusenumDRAFT→APPROVED→CONFIRMED→PAID
paymentDatedateSet to 25th during generation
approvedBy / approvedAtstring / timestampApproval workflow
confirmedBy / confirmedAtstring / timestampConfirmation workflow
paidAttimestampPayment processing

18. PayrollDetail

Table: payroll_details — Itemized breakdown per PayrollRecord (backward compatibility).

ColumnTypeDescription
payrollRecordIduuid (FK)Parent record
itemCodestringItem code (BASE_SALARY, OVERTIME, etc.)
itemNamestringKorean item name
itemTypeenumEARNING / DEDUCTION / ADJUSTMENT
categoryenumBASE_SALARY / OVERTIME / ALLOWANCE / TAX / INSURANCE etc.
amountdecimal(15,2)Amount

19. eCount Export

The eCount export reads directly from AllowanceRecord and DeductionRecord tables, not from PayrollRecord fields. It groups records by (employeeId, itemCodeId) and uses the item code tables for code/name.

A-Codes (Allowances) — from allowance_item_codes

CodeNameSource
A01월급여AllowanceRecord (itemCode 01)
A02초과수당AllowanceRecord (itemCode 02)
A03직책수당AllowanceRecord (itemCode 03)
A04면허자격수당AllowanceRecord (itemCode 04)
A05위험수당AllowanceRecord (itemCode 05)
A06장기근속수당AllowanceRecord (itemCode 06)
A07나이트수당1 - 간호사AllowanceRecord (itemCode 07)
A08나이트수당2 - 요양보호사AllowanceRecord (itemCode 08)
A09휴일근무수당AllowanceRecord (itemCode 09)
A10연장근무수당1AllowanceRecord (itemCode 10)
A11특별수당AllowanceRecord (itemCode 11)
A12연장근무수당2AllowanceRecord (itemCode 12)
A13야간근무수당1AllowanceRecord (itemCode 13)
A14기타AllowanceRecord (itemCode 14)
A16호봉수당AllowanceRecord (itemCode 16)
A17연차수당 (퇴사자)AllowanceRecord (itemCode 17)
A19식대AllowanceRecord (itemCode 19)
A20상여AllowanceRecord (itemCode 20)
A21특별수당1AllowanceRecord (itemCode 21)
A23간호간병평가인센티브AllowanceRecord (itemCode 23)
A24내일채움공제AllowanceRecord (itemCode 24)

D-Codes (Deductions) — from deduction_item_codes

CodeNameSource
D01소득세DeductionRecord (itemCode 01)
D02주민세DeductionRecord (itemCode 02)
D03국민연금DeductionRecord (itemCode 03)
D04건강보험DeductionRecord (itemCode 04)
D05고용보험DeductionRecord (itemCode 05)
D06퇴직정산DeductionRecord (itemCode 06)
D08기타공제DeductionRecord (itemCode 08)
D10연말정산DeductionRecord (itemCode 10)
D11건강, 장기연말정산DeductionRecord (itemCode 11)
D12건강, 장기퇴직정산DeductionRecord (itemCode 12)
D13고용보험정산DeductionRecord (itemCode 13)
D20장기요양보험DeductionRecord (itemCode 20)

Calculation Formula

EARNINGS (AllowanceRecords)
───────────────────────────────────────
Auto-generated from Contract:
  baseSalary (01)    = Contract.baseSalary
  meal (19)          = Contract.allowances.meal
  transportation (14)= Contract.allowances.transportation
  housing (14)       = Contract.allowances.housing

Auto-generated from OvertimeRecord:
  overtimePay (10)   = Σ OvertimeRecord(WEEKDAY).calculatedPay
  nightPay (12)      = Σ OvertimeRecord(NIGHT).calculatedPay
  weekendPay (13)    = Σ OvertimeRecord(SATURDAY).calculatedPay
  holidayPay (09)    = Σ OvertimeRecord(HOLIDAY).calculatedPay

Auto-generated from FixedIncentive:
  incentive (23)     = Σ FixedIncentive.totalAmount

Manual AllowanceRecords:
  (various itemCodes, created via approval workflows)

totalEarnings = Σ ALL AllowanceRecords (auto + manual)

DEDUCTIONS (DeductionRecords)
───────────────────────────────────────
Auto-generated (insurance):
  National Pension (03)    = round(clamp(baseSalary, floor, cap) × rate)
  Health Insurance (04)    = round(baseSalary × rate)
  Employment Ins. (05)     = round(baseSalary × rate)
  Long-term Care (20)      = round(baseSalary × rate)

Auto-generated (tax):
  Taxable Income           = totalEarnings - insuranceTotal
  Income Tax (01)          = round((taxable × rate - deduction) / 12)
  Local Income Tax (02)    = round(incomeTax × 0.10)

Manual DeductionRecords:
  (various itemCodes, created via approval workflows)

totalDeductions = Σ ALL DeductionRecords (auto + manual)

NET PAY
───────────────────────────────────────
netPay = totalEarnings - totalDeductions