Frappe HR Integration — Test & Workflow Guide

One-way sync: Missio → Frappe • Replaces Gusto for HR + Payroll • Branch csdecatur

🔗 19 record types ⚙ 6 phases ✓ Check off as you verify

Before you test anything — APP_KEY health

Stored Frappe API secrets are encrypted with Laravel APP_KEY. If the key has changed since credentials were saved, every API call fails with The MAC is invalid.

  • Verify on this local env: php artisan tinker ——execute="echo \App\Services\Frappe\FrappeClient::forCompany(105)->listDocuments('Employee',[],['name'],1) ? 'OK' : 'FAIL';"
  • If it fails — open Frappe Settings and re-enter the API Key + Secret. Credentials will be re-encrypted with the current APP_KEY.
  • Separately from HR, this also blocks finance sync (invoices, payments, JE) — so fix it first.

How sync fires

Every write to a participating table dispatches App\Jobs\SyncToFrappe (queue frappe-sync). In this env QUEUE_CONNECTION=sync, so jobs run inline — the Frappe API call happens during the same request. Switch to a real queue driver before go-live.

Sync metadata per row: frappe_<doctype>_name (Frappe document ID), frappe_sync_status (pending/synced/failed), frappe_last_synced_at, frappe_sync_error.

AUTO — observer fires on save MANUAL — button press only BULK — via settings page import

Block 0: Pre-Flight Setup

Enable HR sync on the Frappe HR Settings page
Finance integration (Frappe base URL, API key/secret, company) is shared with HR. Only the HR flag needs to be flipped here.
  1. Navigate to Payroll → Frappe HR Settings
  2. Confirm the grey connection panel shows frappe_company_name & base URL
  3. Tick Enable HR sync, pick a default payroll frequency + holiday list, Save
  4. Expected: success toast, DB row company_frappe_settings.hr_enabled = 1
No Frappe call at this step — toggles local config only.
Smoke-test the Frappe connection
Verifies credentials decrypt, base URL is reachable, and the token has read access to HR doctypes.
  1. On HR Settings page, click Test Connection
  2. Expected toast: “Frappe HR connection OK.”
  3. On failure: check storage/logs/frappe-sync-*.log
GET /api/resource/Employee?limit_page_length=1

Block 1: Foundational Masters (Phase 1)

Department — Missio teams → Frappe Department AUTO
Missio "Team" becomes Frappe "Department". Poster adopts any existing Department with the same name + company before creating.
  1. Go to HR → Departments and create a new department (e.g. “QA Test Dept”)
  2. Verify teams.frappe_department_name populated, frappe_sync_status = 'synced'
  3. Rename it — confirm updated fires and Frappe reflects the new name
  4. Soft-delete it — confirm Frappe Department is marked disabled=1
GET /api/resource/Department?filters=[[department_name,=,...]] POST /api/resource/Department (if none found) PUT /api/resource/Department/{name}
Designation — Missio designations → Frappe Designation AUTO
Job title. Frappe Designation is a simple global master — adopt-existing by exact name.
  1. Go to HR → Positions, create “QA Lead”
  2. Verify designations.frappe_designation_name ≈ “QA Lead”
  3. Re-use an existing name — poster should link rather than duplicate
GET /api/resource/Designation?filters=[[designation_name,=,...]] POST /api/resource/Designation
Holiday — Missio holidays → Frappe Holiday List AUTO
Frappe Holiday List is a parent-with-child-table; the poster groups every Missio holiday in the same year into one list named {Company} {YYYY} and replaces the child rows on each sync.
  1. Add two holidays in the same year (e.g., Labor Day, Thanksgiving)
  2. Expected: ONE Frappe Holiday List with both dates. Both holidays rows share the same frappe_holiday_list_name
  3. Edit one holiday date — confirm list re-built
GET /api/resource/Holiday List?filters=[[holiday_list_name,=,...]] PUT /api/resource/Holiday List/{name} (replace child table) POST /api/resource/Holiday List (if first of its year)
Employee — Missio employee_details → Frappe Employee AUTO
Core master. Poster splits the user's name into first/middle/last, maps gender + employment type + marital status, and looks for an existing Frappe Employee first by employee_number, then by personal_email.
  1. Create a new hire (HR → New Hire Onboarding or Employees → Add)
  2. Confirm employee_details.frappe_employee_name set (usually HR-EMP-XXXXX)
  3. Run a salary change and re-check the Frappe Employee — fields should update, not duplicate
  4. Terminate employee — Frappe Employee moves to Left / date_of_leaving set
GET /api/resource/Employee?filters=[[employee_number,=,...]] GET /api/resource/Employee?filters=[[personal_email,=,...]] (fallback) POST /api/resource/Employee PUT /api/resource/Employee/{name}
Shift Type — Missio employee_shifts → Frappe Shift Type AUTO
Name is scoped with the company abbreviation to avoid collisions across multi-company Frappe instances.
  1. Go to HR → Shifts, add a shift (e.g., “Morning 7am-3pm”)
  2. Expected Frappe name: {company_abbr} Morning 7am-3pm
GET /api/resource/Shift Type?filters=[[name,=,...]] POST /api/resource/Shift Type

Block 2: Leave & Attendance (Phase 2)

Leave Type — leave_types → Frappe Leave Type AUTO
Adopt-existing by exact leave_type_name.
  1. Go to HR → Leave → Types, add “Jury Duty”
  2. Confirm leave_types.frappe_leave_type_name = “Jury Duty”
GET /api/resource/Leave Type?filters=[[leave_type_name,=,...]] POST /api/resource/Leave Type
Leave Allocation — employee_leave_quotas → Frappe Leave Allocation AUTO
Derives the fiscal year range from now()->startOfYear(). Submittable doctype — docstatus=1 on save. Adopt-existing by (employee, leave_type, year).
  1. Set an employee's annual PTO quota (e.g., 15 days)
  2. Confirm employee_leave_quotas.frappe_leave_allocation_name set
  3. In Frappe, verify docstatus = Submitted and date range = Jan 1 – Dec 31
GET /api/resource/Leave Allocation?filters=[[employee,=,...],[leave_type,=,...]] POST /api/resource/Leave Allocation (docstatus=0) PUT /api/resource/Leave Allocation/{name} (docstatus=1)
Leave Application — leaves → Frappe Leave Application AUTO
Submittable. Missio status maps: approved → docstatus=1 (Approved), rejected → status=Rejected, pending → draft. Half-day flag honored.
  1. Submit a leave request — confirm draft Leave Application in Frappe
  2. Approve it — Frappe doc should submit (docstatus=1) and status=Approved
  3. Edit a half-day leave — half_day=1 in Frappe
POST /api/resource/Leave Application (create draft) PUT /api/resource/Leave Application/{name} (approve & submit)
Shift Assignment — employee_shift_schedules → Frappe Shift Assignment AUTO
Submittable. Links an employee to a Shift Type for a date range. Company is derived from the shift (the schedule row itself has no company_id).
  1. Assign an employee to a shift — HR → Employee Shifts
  2. Confirm employee_shift_schedules.frappe_shift_assignment_name populated
  3. Cancel the assignment — Frappe doc cancelled
POST /api/resource/Shift Assignment POST /api/method/frappe.client.cancel
Attendance — attendances → Frappe Attendance AUTO
Submittable. Dedupes on (employee, attendance_date, docstatus≠2) before creating — Frappe allows only one non-cancelled attendance per (employee, date).
  1. Clock in/out for an employee for today's date
  2. Confirm one Attendance doc in Frappe, status Present
  3. Update the same attendance — Frappe doc should be updated (not duplicated)
GET /api/resource/Attendance?filters=[[employee,=,...],[attendance_date,=,...]] POST /api/resource/Attendance PUT /api/resource/Attendance/{name} (submit)

Block 3: Compensation (Phase 3)

Salary Component — deduction_codes → Frappe Salary Component AUTO
Type = Deduction. Frappe abbr generated from the code (5 chars max). Adopt-existing by (salary_component, company).
  1. Create a deduction code — Payroll → Deduction Codes (e.g., HEALTH)
  2. Confirm deduction_codes.frappe_salary_component_name = “HEALTH”
  3. In Frappe, verify salary_component_abbr = “HEALT” (5 chars)
GET /api/resource/Salary Component?filters=[[salary_component,=,...],[company,=,...]] POST /api/resource/Salary Component
Salary Structure — salary_schedules → Frappe Salary Structure AUTO
One structure per schedule (decision: step amount carried on the Assignment as base). Adds a single "base" earning formula. Submittable.
  1. Create a salary schedule (e.g., “Certified Teachers FY26”)
  2. Confirm one Frappe Salary Structure exists, submitted, with base earning row
GET /api/resource/Salary Structure?filters=[[name,=,...]] POST /api/resource/Salary Structure PUT /api/resource/Salary Structure/{name} (submit)
Salary Structure Assignment — employee_salary_placements → Frappe Salary Structure Assignment AUTO
Uses placement.total_salary as the base. Submittable.
  1. Place an employee on a schedule/step — HR → Salary Placements
  2. Confirm Frappe Assignment doc submitted with the right base amount & from_date
POST /api/resource/Salary Structure Assignment PUT /api/resource/Salary Structure Assignment/{name} (submit)

Block 4: Payroll Transactions (Phase 4)

Additional Salary — additional_payments → Frappe Additional Salary AUTO
Bonuses / one-off pay / deductions outside the structure. Salary component mapped from payment_type. Submittable.
  1. Add a bonus for an employee — Payroll → Additional Payments
  2. Confirm Frappe Additional Salary created & submitted for that employee
  3. Cancel the payment — Frappe doc cancels
POST /api/resource/Additional Salary POST /api/method/frappe.client.cancel (on cancel)
Payroll Entry — payroll_runs → Frappe Payroll Entry MANUAL
NOT auto-synced. Pushed only when the user clicks Submit to Frappe on an approved payroll run. Uses the payPeriod start/end.
  1. Complete a payroll run, approve it
  2. On the run detail, click Submit to Frappe
  3. Expected: one Payroll Entry created (draft), plus one Salary Slip per paycheck (see next row)
  4. Run status moves to submitted
POST /api/resource/Payroll Entry
Salary Slip — paychecks → Frappe Salary Slip MANUAL
Dispatched per paycheck by the same Submit to Frappe button (5-second stagger to avoid rate limits). Carries gross, federal/state/SS/medicare tax breakout, plus earnings/deductions child tables.
  1. Open a Salary Slip in Frappe after a submit
  2. Verify: gross_pay matches paycheck, tax rows present (Federal, State, SS, Medicare), net matches
  3. Earnings/deductions detail arrays should include per-code line items
POST /api/resource/Salary Slip PUT /api/resource/Salary Slip/{name} (submit)

Block 5: Project & Time (Phase 5)

Project — projects → Frappe Project AUTO
Adopt-existing by (project_name, company). Status maps: in_progress→Open, on_hold→On Hold, completed→Completed, cancelled→Cancelled.
  1. Create a project — Projects → Create
  2. Confirm projects.frappe_project_name set
  3. Change status to Completed — reflected in Frappe
GET /api/resource/Project?filters=[[project_name,=,...],[company,=,...]] POST /api/resource/Project PUT /api/resource/Project/{name}
Task — tasks → Frappe Task AUTO
Parented to the synced Frappe Project. Status: completed_on set → Completed, board column 0 → Open, else Working.
  1. Add a task to a synced project
  2. Confirm Frappe Task created under that project
  3. Move it across the board — status updates
POST /api/resource/Task PUT /api/resource/Task/{name}
Timesheet — project_time_logs → Frappe Timesheet AUTO
One Missio time log = one Frappe Timesheet with a single time_logs child row. Submittable. Skipped if start_time or end_time is null.
  1. Log time against a task — start + stop the timer
  2. Confirm Frappe Timesheet submitted with matching from/to and hours
POST /api/resource/Timesheet PUT /api/resource/Timesheet/{name} (submit)

Block 6: Ops & Cutover (Phase 6)

"Submit to Frappe" button on approved payroll runs MANUAL
Added next to the existing “Submit to Bank” (Gusto) button. Only renders when hr_enabled=1. Dispatches the Payroll Entry poster and one Salary Slip poster per paycheck (5s stagger). Run status moves to submitted.
  1. Approve a payroll run
  2. Click Submit to Frappe
  3. Verify in Frappe: 1 Payroll Entry + N Salary Slips (one per paycheck)
  4. Gusto button still visible — both providers can coexist during cutover
Dispatches: POST /api/resource/Payroll Entry POST /api/resource/Salary Slip (per paycheck)
Bulk Import — backfill all 19 tables BULK
Per-table button on the HR Settings dashboard. Chunks through rows with frappe_sync_status NULL or 'failed' and dispatches the poster job for each. Safe to re-run.
  1. On HR Settings, look for the 19 section rows (teams … project_time_logs)
  2. Click Bulk Import against Designations first (smallest, lowest-risk)
  3. Confirm counts: pending → synced as jobs drain
  4. Repeat for progressively larger tables; employees + paychecks last
POST /account/payroll/frappe-hr-settings/bulk-import/{type}
Retry Failed — per-table recovery BULK
Dispatches only rows with frappe_sync_status = 'failed'. Chooses action=update when frappe_<doctype>_name is already set, else create.
  1. Cause a failure (e.g., temporarily wrong URL)
  2. Note the failed count on the dashboard
  3. Restore URL, click Retry Failed
  4. Failed count drains; frappe_sync_error cleared on success
POST /account/payroll/frappe-hr-settings/retry-failed/{type}
Reconciliation Report — triage failures without the queue log
JSON endpoint listing every HR/payroll table that currently has failed rows for this company — with IDs, error text, and last_synced_at.
  1. Hit GET /account/payroll/frappe-hr-settings/reconciliation
  2. Expected shape: { failures: { attendances: [...], paychecks: [...] } }
  3. Use the row IDs to jump straight to the failing record and fix the underlying data
GET /account/payroll/frappe-hr-settings/reconciliation

Known gaps before cutover (not implemented here)

  • US tax filings. Frappe HR does not file 941 / W-2 / 1095 / ACH. Pick one: Symmetry, Vertex, ADP SmartCompliance, or keep Gusto tax-only.
  • Garnishment remittance. Tracked in Missio but not pushed to Frappe in any phase.
  • Queue driver. QUEUE_CONNECTION=sync today — move to database/redis so observer writes don't block UI requests on Frappe latency.
  • Gusto dual-write. Both “Submit to Bank” and “Submit to Frappe” buttons are live. Run at least one full cycle in both before decommissioning Gusto.