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.
- Navigate to Payroll → Frappe HR Settings
- Confirm the grey connection panel shows
frappe_company_name& base URL - Tick Enable HR sync, pick a default payroll frequency + holiday list, Save
- 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.
- On HR Settings page, click Test Connection
- Expected toast: “Frappe HR connection OK.”
- 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 AUTOMissio "Team" becomes Frappe "Department". Poster adopts any existing Department with the same name + company before creating.
- Go to HR → Departments and create a new department (e.g. “QA Test Dept”)
- Verify
teams.frappe_department_namepopulated,frappe_sync_status = 'synced' - Rename it — confirm
updatedfires and Frappe reflects the new name - 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 AUTOJob title. Frappe Designation is a simple global master — adopt-existing by exact name.
- Go to HR → Positions, create “QA Lead”
- Verify
designations.frappe_designation_name≈ “QA Lead” - 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 AUTOFrappe 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.- Add two holidays in the same year (e.g., Labor Day, Thanksgiving)
- Expected: ONE Frappe Holiday List with both dates. Both
holidaysrows share the samefrappe_holiday_list_name - 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 AUTOCore 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.- Create a new hire (HR → New Hire Onboarding or Employees → Add)
- Confirm
employee_details.frappe_employee_nameset (usuallyHR-EMP-XXXXX) - Run a salary change and re-check the Frappe Employee — fields should update, not duplicate
- 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 AUTOName is scoped with the company abbreviation to avoid collisions across multi-company Frappe instances.
- Go to HR → Shifts, add a shift (e.g., “Morning 7am-3pm”)
- 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 AUTOAdopt-existing by exact
leave_type_name.- Go to HR → Leave → Types, add “Jury Duty”
- 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 AUTODerives the fiscal year range from
now()->startOfYear(). Submittable doctype — docstatus=1 on save. Adopt-existing by (employee, leave_type, year).- Set an employee's annual PTO quota (e.g., 15 days)
- Confirm
employee_leave_quotas.frappe_leave_allocation_nameset - 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 AUTOSubmittable. Missio status maps:
approved → docstatus=1 (Approved), rejected → status=Rejected, pending → draft. Half-day flag honored.- Submit a leave request — confirm draft Leave Application in Frappe
- Approve it — Frappe doc should submit (docstatus=1) and status=Approved
- Edit a half-day leave —
half_day=1in Frappe
POST /api/resource/Leave Application (create draft)
PUT /api/resource/Leave Application/{name} (approve & submit)
Shift Assignment —
employee_shift_schedules → Frappe Shift Assignment AUTOSubmittable. 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).- Assign an employee to a shift — HR → Employee Shifts
- Confirm
employee_shift_schedules.frappe_shift_assignment_namepopulated - Cancel the assignment — Frappe doc cancelled
POST /api/resource/Shift Assignment
POST /api/method/frappe.client.cancel
Attendance —
attendances → Frappe Attendance AUTOSubmittable. Dedupes on (employee, attendance_date, docstatus≠2) before creating — Frappe allows only one non-cancelled attendance per (employee, date).
- Clock in/out for an employee for today's date
- Confirm one Attendance doc in Frappe, status Present
- 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 AUTOType =
Deduction. Frappe abbr generated from the code (5 chars max). Adopt-existing by (salary_component, company).- Create a deduction code — Payroll → Deduction Codes (e.g.,
HEALTH) - Confirm
deduction_codes.frappe_salary_component_name= “HEALTH” - 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 AUTOOne structure per schedule (decision: step amount carried on the Assignment as
base). Adds a single "base" earning formula. Submittable.- Create a salary schedule (e.g., “Certified Teachers FY26”)
- Confirm one Frappe Salary Structure exists, submitted, with
baseearning 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 AUTOUses
placement.total_salary as the base. Submittable.- Place an employee on a schedule/step — HR → Salary Placements
- Confirm Frappe Assignment doc submitted with the right
baseamount &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 AUTOBonuses / one-off pay / deductions outside the structure. Salary component mapped from
payment_type. Submittable.- Add a bonus for an employee — Payroll → Additional Payments
- Confirm Frappe Additional Salary created & submitted for that employee
- 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 MANUALNOT auto-synced. Pushed only when the user clicks Submit to Frappe on an approved payroll run. Uses the payPeriod start/end.
- Complete a payroll run, approve it
- On the run detail, click Submit to Frappe
- Expected: one Payroll Entry created (draft), plus one Salary Slip per paycheck (see next row)
- Run status moves to
submitted
POST /api/resource/Payroll Entry
Salary Slip —
paychecks → Frappe Salary Slip MANUALDispatched 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.
- Open a Salary Slip in Frappe after a submit
- Verify: gross_pay matches paycheck, tax rows present (Federal, State, SS, Medicare), net matches
- 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 AUTOAdopt-existing by (project_name, company). Status maps:
in_progress→Open, on_hold→On Hold, completed→Completed, cancelled→Cancelled.- Create a project — Projects → Create
- Confirm
projects.frappe_project_nameset - 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 AUTOParented to the synced Frappe Project. Status:
completed_on set → Completed, board column 0 → Open, else Working.- Add a task to a synced project
- Confirm Frappe Task created under that project
- Move it across the board — status updates
POST /api/resource/Task
PUT /api/resource/Task/{name}
Timesheet —
project_time_logs → Frappe Timesheet AUTOOne Missio time log = one Frappe Timesheet with a single
time_logs child row. Submittable. Skipped if start_time or end_time is null.- Log time against a task — start + stop the timer
- 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.- Approve a payroll run
- Click Submit to Frappe
- Verify in Frappe: 1 Payroll Entry + N Salary Slips (one per paycheck)
- 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.- On HR Settings, look for the 19 section rows (teams … project_time_logs)
- Click Bulk Import against Designations first (smallest, lowest-risk)
- Confirm counts: pending → synced as jobs drain
- 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.- Cause a failure (e.g., temporarily wrong URL)
- Note the
failedcount on the dashboard - Restore URL, click Retry Failed
- Failed count drains;
frappe_sync_errorcleared 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.
- Hit
GET /account/payroll/frappe-hr-settings/reconciliation - Expected shape:
{ failures: { attendances: [...], paychecks: [...] } } - 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=synctoday — move todatabase/redisso 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.