Offer Letter and Renewal System — v0.1 Spec (Code-Ready)
Version: 0.1.0
Stack: Postgres + Next.js (App Router) + VPS (Hostinger) + local filesystem uploads
Auth: Email + Password (no 2FA)
Notifications: In-app + Email
Audit Log: Included
Admin Settings: Included
Current release identity: Login, sidebar, About, and System Health show generated build metadata
Current implementation addendum: 2026-07-14 analytics and AI extensions
Current naming note:
- Product name is now
Offer Letter and Renewal System. - Historical filenames retain the original
ERS-v*labels for traceability.
1) Goal
Internal system to manage employee offer letters and contract renewals:
- HR prepares renewal and new-hire offer-letter cases
- Manager submits renewal justification when requested
- CEO reviews, compares compensation vs peers (same grade) for renewals, then approves/rejects
- CEO can digitally sign the offer letter by placing a signature onto the PDF
- CEO can analyze compensation position, salary increases, budget impact, and decision risks
- AI can suggest signature placement and compare the contract with previous signed contracts
Primary outcomes:
- Track offer-letter cases, renewals, salary, grade
- Compare salary vs peers at same grade
- Capture justification and decision
- Full audit trail
- Management-grade data quality and CSV export controls
2) Roles & Permissions (RBAC)
Roles
- CEO (Admin): full access; final decision; signature management
- HR: create and manage renewal and new-hire cases, upload docs, request manager justification, email notifications
- Manager: respond to assigned justification requests; view assigned packets
Permission Rules (hard)
- HR & Manager cannot edit a case after CEO decision (
locked_atset). - After CEO signs the offer (
status=SIGNED) or marks a signed offer declined (status=DECLINED), case is closed for all roles (read-only). - Renewal CEO decision requires:
- Latest OFFER_LETTER exists
- Real PerformanceSnapshot exists with a review period
- ManagerJustification has
submitted_at
- New-hire CEO decision requires:
- Latest OFFER_LETTER exists
- CEO can access
/dashboardand salary analytics; HR is redirected to/hr/renewals. - Managers cannot access management analytics or its CSV export.
- AI contract results are advisory and do not bypass approval or signing permissions.
3) Key Entities (Data Model)
3.1 User
- id (uuid)
- email (unique)
- name
- role (enum: CEO | HR | MANAGER)
- password_hash
- is_active (bool default true)
- created_at, updated_at
3.2 Employee
- id (uuid)
- employee_code (unique, optional)
- full_name
- email (unique, optional)
- department
- role_title
- grade (free text e.g. "G5")
- manager_user_id (fk users.id)
- employment_type (enum: PERMANENT | CONTRACT)
- status (enum: ACTIVE | INACTIVE)
- created_at, updated_at
3.3 RenewalCase
- id (uuid)
- employee_id (fk)
- contract_start_date, contract_end_date
- renewal_start_date, renewal_end_date
- current_monthly_salary (numeric)
- proposed_monthly_salary (numeric)
- currency (default "MYR")
- proposed_grade (string nullable) // free text; if null use employee.grade
- status (enum):
- DRAFT
- PENDING_MANAGER
- READY_FOR_CEO
- APPROVED
- REJECTED
- SIGNED
- DECLINED
- justification_request_sent_at (timestamptz nullable)
- manager_justification_due_at (timestamptz nullable)
CEO decision:
- ceo_decision (enum: NONE | APPROVE | REJECT)
- ceo_decision_at (timestamptz nullable)
- ceo_decision_notes (text nullable)
- offer_declined_at (timestamptz nullable)
- offer_decline_notes (text nullable)
- offer_decline_recorded_by_user_id (fk users.id nullable)
Locking:
- locked_at (timestamptz nullable)
Meta:
- created_by_user_id (fk users.id)
- created_at, updated_at
Implementation note:
- Current implementation also uses
RenewalCasefor new-hire offer-letter approvals.
3.4 PerformanceSnapshot (per RenewalCase)
- id (uuid)
- renewal_case_id (fk)
- review_period (string e.g. "2025 H2")
- overall_score (numeric nullable)
- score_breakdown (jsonb nullable)
- hr_notes (text nullable)
- manager_comments (text nullable)
- created_at, updated_at
Implementation note:
- New-hire cases currently use a system marker review period of
__NEW_HIRE__so the workflow can branch without a schema change.
3.5 ManagerJustification (one per RenewalCase)
- id (uuid)
- renewal_case_id (fk unique)
- manager_user_id (fk users.id nullable for guest submissions)
- recommendation (enum: RENEW | DO_NOT_RENEW | RENEW_WITH_CONDITIONS)
- justification (text)
- conditions (text nullable)
- submitted_at (timestamptz nullable)
- submitted_by_name (text nullable)
- submitted_by_email (text nullable)
- last_updated_at (timestamptz)
3.5b GuestJustificationToken (one-time external link)
- id (uuid)
- renewal_case_id (fk)
- token_hash (unique)
- manager_name (nullable)
- manager_email
- created_by_user_id (fk users.id)
- expires_at
- used_at (nullable)
- revoked_at (nullable)
- created_at
3.6 Document (versioned)
- id (uuid)
- renewal_case_id (fk)
- doc_type (enum: OFFER_LETTER | SIGNED_OFFER | PERFORMANCE_REPORT | OTHER)
- file_name
- mime_type
- file_size
- storage_key (string) // relative path in VPS filesystem
- uploaded_by_user_id (fk users.id)
- uploaded_at (timestamptz)
- version (int default 1)
- is_latest (bool default true)
Versioning rule:
- Uploading same (renewal_case_id + doc_type) again:
- set previous is_latest=false
- increment version
3.7 EmployeeCompensation (for peer comparison)
We start empty and fill over time from approved renewals.
- id (uuid)
- employee_id (fk)
- effective_date (date)
- monthly_salary (numeric)
- currency (string default
MYR) - grade (string)
- source (enum: RENEWAL_APPROVED | MANUAL)
- renewal_case_id (fk nullable)
- voided_at (timestamptz nullable)
- void_reason (text nullable)
- voided_by_user_id (fk users.id nullable)
- created_at
Creation rule:
- When CEO approves a case:
- insert EmployeeCompensation using proposed salary + effective_date=renewal_start_date
- grade = proposed_grade ?? employee.grade
Analytics selection rule:
- Use at most one record per active employee: latest
effective_date <= as_of_date. - Exclude rows where
voided_atis not null. - Do not count historical records as separate peers.
- Future-effective approved compensation remains projected until effective.
- If no effective compensation exists, latest renewal current salary may be used as an explicitly inferred fallback.
3.7b ContractAiAnalysis
- id (uuid)
- renewal_case_id (fk)
- document_id (fk latest OFFER_LETTER)
- analysis_type (
SIGNATURE_PLACEMENT|CONTRACT_VARIATION) - status, confidence
- result_json (structured analysis output)
- error_message (nullable)
- model (nullable)
- prompt_version
- created_by_user_id (fk users.id)
- created_at
Store each analysis run as an immutable row. UI reads the latest completed row per document and analysis type.
3.8 InAppNotification
- id (uuid)
- user_id (fk)
- type (enum: JUSTIFICATION_REQUESTED | CASE_READY_FOR_CEO | CEO_DECIDED | CASE_SIGNED)
- title (string)
- body (text)
- link_path (string) // e.g. /manager/requests/{id}
- is_read (bool default false)
- created_at
3.9 EmailOutbox (durable outbound queue)
- id (uuid)
- to_email
- subject
- body_html
- body_text
- status (enum: PENDING | SENT | FAILED)
- provider_message_id (string nullable)
- error_message (text nullable)
- attempts (int default 0)
- last_attempt_at (timestamptz nullable)
- created_at
3.10 AuditLog (required)
- id (uuid)
- actor_user_id (fk)
- entity_type (enum: RENEWAL_CASE | DOCUMENT | JUSTIFICATION | SETTINGS | ANALYTICS | EMPLOYEE | USER | SIGNATURE | NOTIFICATION | EMAIL)
- entity_id (uuid)
- action (string)
- before (jsonb nullable)
- after (jsonb nullable)
- created_at (timestamptz)
Minimum audited actions:
- CREATE_CASE, UPDATE_CASE, REQUEST_JUSTIFICATION, SUBMIT_JUSTIFICATION
- UPLOAD_DOC
- CEO_APPROVE, CEO_REJECT
- SIGNATURE_UPLOAD, PDF_SIGNED
- SETTINGS_UPDATE
- RUN_CONTRACT_AI_REVIEW, ANALYTICS_EXPORT_CSV
- ADMIN_CORRECT_CURRENT_SALARY (guarded support correction with before/after and reason)
3.11 AdminSettings (keyed settings)
- id (uuid)
- key (string unique)
- value_json (jsonb)
- updated_by_user_id (fk users.id)
- updated_at
Historical v0 included an editable app_version key. Current releases use immutable build metadata
generated from package.json and do not expose this setting in the UI.
Must include keys:
- company_name
- upload_max_mb
- email_from
- smtp (optional to store; can be env-only)
- manager_justification_default_due_days
- peer_comparison_idr_to_myr_rate
- analytics_large_increase_percent (default 10)
- analytics_large_increase_amount_myr (default 1000)
- analytics_high_curve_percentile (default 90)
4) Digital Signature (PDF signing) — v0 Implementation Plan
4.1 Signature Asset
Store CEO signature as PNG with transparency.
Recommended:
user_signaturetable:- id (uuid)
- user_id (fk users.id unique)
- file_name, mime_type, file_size, storage_key
- created_at, updated_at
4.2 Signing UX
- CEO opens case > Offer Letter preview
- Click “Sign”
- PDF preview with draggable signature overlay
- CEO clicks “Apply Signature”
- AI signature suggestions can be loaded into the same editable placement queue.
- Frontend sends
{ pageNumber, x, y, width, height }in PDF points
Coordinate conversion:
- Convert DOM px coords to PDF points
- PDF origin is bottom-left; DOM origin is top-left
4.3 Backend stamping
Use pdf-lib server-side:
- Load OFFER_LETTER
- Embed signature PNG
- Draw on given page coords
- Save new PDF => SIGNED_OFFER
Rule:
- Must APPROVE before SIGN
- approve => status=APPROVED
- sign => create SIGNED_OFFER + status=SIGNED
4.4 AI contract review
- Analyze the latest offer-letter PDF for signature blocks and safe placement regions.
- Return page, PDF coordinates, dimensions, confidence, rationale, and warnings.
- Find a previous signed contract baseline when available.
- Compare material terms and return risk level, differences, missing clauses, and recommendations.
- Use OpenAI structured output when configured, with deterministic heuristic fallback.
- Persist model and prompt version so results are reproducible and auditable.
5) Core Workflows
5.1 HR creates case
- Choose
RenewalorNew Hire - Upload and validate the offer-letter PDF; evaluation forms and performance reports are rejected
as
OFFER_LETTER - Review the extracted employee profile; extraction does not persist an employee automatically
- Require confirmed employee name, department, role title, current grade, applicable grade, employment type, dates, positive salaries, and a three-letter currency code
- Reject
Unknown,Unassigned, andUnspecifiedplaceholders; foreign contractors without an internal grade use an explicit no-internal-grade category - Create RenewalCase (DRAFT) and attach the validated OFFER_LETTER
5.2 Renewal path
- Upsert PerformanceSnapshot
- Block manager outreach until core HR intake data and the latest OFFER_LETTER are complete
- Request Manager Justification => status=PENDING_MANAGER + notify manager (in-app + email)
5.3 Manager justification
- Manager opens “My Requests”
- Submits recommendation + justification
- Alternative: HR sends one-time guest link; external manager can submit without account
- HR marks READY_FOR_CEO => notify CEO (in-app + email)
- Manager submission alone does not make a renewal ready; performance snapshot and latest offer letter must also be present.
5.4 New-hire path
- Manager justification is skipped
- Performance snapshot is skipped
- Once the latest OFFER_LETTER is present, case can move to READY_FOR_CEO
- Core HR intake completeness is revalidated before the transition
5.5 CEO decision
- Show a Decision Brief before the approval controls. Exclude the reviewed employee and compare the proposal against active same-grade peers, with same-grade plus department and exact-role cohorts where available.
- Show min, P25, median, P75, max, midrank percentile, curve position, peer count, and confidence; also show salary movement, contract term, grade movement, performance position, manager recommendation, required evidence, and contract variation risk.
- Derive an advisory
ALIGNED,REVIEW,CAUTION, orINCOMPLETEposture with readable reasons. It must not execute or replace the CEO decision. - Approve => status=APPROVED + locked_at + insert EmployeeCompensation + notify HR/manager
- Reject => status=REJECTED + locked_at + notify HR/manager
5.6 CEO signs PDF (after approval)
- Stamp one or more signatures across pages => SIGNED_OFFER + status=SIGNED
- On successful sign, case is closed (no further edits)
- Notify HR users (in-app + email via outbox)
- If the person declines after CEO signature, CEO can mark the case DECLINED. ERS voids the linked approved EmployeeCompensation row, keeps the signed document and audit log, and marks new-hire employee records inactive.
5.7 Management analytics
- CEO lands on
/dashboard, HR on/hr/renewals, and Manager on/manager/requests. - Default period is the selected as-of year; filters persist through URL query parameters.
- Filter by case type: combined, Renewal only, or New Hire only.
- Deduplicate cases by employee within the filtered period and case type, then disclose excluded older duplicates.
- Normalize equivalent free-text grades for comparison while preserving separate
Gscales. - Compare proposed salary with the employee's target grade.
- Separate already-effective compensation from future projected changes to avoid double counting.
- Exclude DECLINED signed offers and voided compensation rows from default curves, peer stats, and committed budget impact.
- Exclude open-ended/sentinel terms over 60 months from finite contract-term impact.
- Export filtered rows as CSV and audit the export.
6) UI Pages
- /login
- /dashboard (CEO-only management analytics; Overview, Curve, Increases, Budget; combined or case-type-specific views)
- /hr/renewals, /hr/renewals/new, /hr/renewals/[id]
- /manager/requests, /manager/requests/[id]
- /ceo/renewals, /ceo/renewals/[id], /ceo/signature
- /guest/justification/[token] (public one-time manager link)
- /admin/settings, /admin/system-health, /admin/audit, /admin/users
- /help
- /notifications
Release identity appears on login, in the sidebar, in About ERS, and on System Health.
7) API Routes (high-level)
Auth:
- POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me
Employees:
- GET/POST /api/employees
- GET/PATCH /api/employees/:id
Renewals:
- GET/POST /api/renewals
- GET/PATCH /api/renewals/:id
- POST /api/renewals/:id/request-justification
- POST /api/renewals/:id/mark-ready
- POST /api/renewals/:id/approve
- POST /api/renewals/:id/reject
- POST /api/renewals/:id/mark-declined
Documents:
- POST /api/renewals/:id/documents
- GET /api/documents/:docId/download
Performance snapshot:
- GET/POST /api/renewals/:id/performance-snapshot
Justification:
- GET /api/renewals/:id/justification
- POST/PATCH /api/renewals/:id/justification
- GET/POST /api/guest/justification/:token
Signing:
- POST/GET /api/ceo/signature
- POST /api/renewals/:id/sign-pdf
Contract AI:
- POST /api/renewals/:id/contract-ai-analysis
- GET /api/documents/:docId/signature-suggestions
Analytics:
- GET /api/analytics/export (CEO-only, filtered CSV, audited)
Notifications:
- GET /api/notifications
- POST /api/notifications/:id/read
Admin:
- GET/PATCH /api/admin/settings
- GET /api/admin/audit
- GET/POST /api/admin/users
- PATCH /api/admin/users/:id
Internal:
- POST /api/internal/email/dispatch (secret-protected)
8) Storage Plan (VPS filesystem)
Base: /var/app/ers_uploads/
- documents:
documents/{renewal_case_id}/{doc_type}/v{version}/{filename} - signatures:
signatures/{user_id}/{filename}
Store relative path in DB storage_key.
Operational note:
- Production storage must use a persistent path such as
/var/app/ers_uploads. - Do not use
/tmpor any other temporary directory for documents or signature uploads.
9) Definition of Done (v0)
- HR can create renewal and new-hire cases end-to-end
- HR cannot create or advance cases with placeholder department/grade data, incomplete core case fields, or an evaluation document misclassified as an offer letter
- Manager can submit renewal justification
- CEO can view peer stats, approve/reject, and sign PDF
- CEO case review clearly shows peer position and an explainable advisory Decision Brief before the approval controls
- In-app + email notifications work via outbox + dispatch endpoint
- Audit log populated
- Login, sidebar, About, and System Health show immutable build version and Git SHA
- CEO dashboard shows grade distributions, employee positions, salary increases, budget impact, exceptions, and data coverage.
- Grade Curve supports both table and accessible SVG chart modes.
- CSV export follows dashboard filters, is spreadsheet-safe, and is audited.
- AI contract review persists signature placement and variation analysis.
- Statistical tests cover quartiles, tied percentiles, small peer groups, and grade normalization.
10) Management Analytics Calculation Rules
10.1 Curve calculation
- Comparison currency: MYR. IDR uses the configured conversion rate; unsupported currencies are excluded and flagged.
- Percentile: empirical midrank,
(below + 0.5 * equal) / peer_count * 100. FAR_BELOW: below P10.BELOW_CURVE: P10 to below P25.ON_CURVE: P25 through P75.ABOVE_CURVE: above P75 through P90.FAR_ABOVE: above P90.- Fewer than 5 peers:
INSUFFICIENT_DATA; 5-9 peers: limited confidence.
10.2 Chart definition
- Vertical whisker: current grade minimum to maximum.
- Green box: current P25 to P75.
- Black line: current median.
- Blue diamond: projected grade average after applicable non-rejected, non-declined proposals.
- Colored dots: individual proposed salaries; each dot links to the case.
10.3 Budget definition
- Current payroll: effective salary for the filtered active roster as of the selected date.
- Projected payroll: current payroll plus selected changes not already effective.
- Portfolio increase: proposed minus current salary for non-rejected, non-declined cases in the period.
- Committed: APPROVED or SIGNED; Pending: DRAFT, PENDING_MANAGER, or READY_FOR_CEO.
- 12-month impact is effective-date adjusted.
- Finite contract-term impact excludes invalid/open-ended terms over 60 months and reports their count separately.