@hasna/microservices 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +63 -0
- package/bin/mcp.js +63 -0
- package/dist/index.js +63 -0
- package/microservices/microservice-ads/package.json +27 -0
- package/microservices/microservice-ads/src/cli/index.ts +605 -0
- package/microservices/microservice-ads/src/db/campaigns.ts +797 -0
- package/microservices/microservice-ads/src/db/database.ts +93 -0
- package/microservices/microservice-ads/src/db/migrations.ts +60 -0
- package/microservices/microservice-ads/src/index.ts +39 -0
- package/microservices/microservice-ads/src/mcp/index.ts +480 -0
- package/microservices/microservice-contracts/package.json +27 -0
- package/microservices/microservice-contracts/src/cli/index.ts +770 -0
- package/microservices/microservice-contracts/src/db/contracts.ts +925 -0
- package/microservices/microservice-contracts/src/db/database.ts +93 -0
- package/microservices/microservice-contracts/src/db/migrations.ts +141 -0
- package/microservices/microservice-contracts/src/index.ts +43 -0
- package/microservices/microservice-contracts/src/mcp/index.ts +617 -0
- package/microservices/microservice-domains/package.json +27 -0
- package/microservices/microservice-domains/src/cli/index.ts +691 -0
- package/microservices/microservice-domains/src/db/database.ts +93 -0
- package/microservices/microservice-domains/src/db/domains.ts +1164 -0
- package/microservices/microservice-domains/src/db/migrations.ts +60 -0
- package/microservices/microservice-domains/src/index.ts +65 -0
- package/microservices/microservice-domains/src/mcp/index.ts +536 -0
- package/microservices/microservice-hiring/package.json +27 -0
- package/microservices/microservice-hiring/src/cli/index.ts +741 -0
- package/microservices/microservice-hiring/src/db/database.ts +93 -0
- package/microservices/microservice-hiring/src/db/hiring.ts +1085 -0
- package/microservices/microservice-hiring/src/db/migrations.ts +89 -0
- package/microservices/microservice-hiring/src/index.ts +80 -0
- package/microservices/microservice-hiring/src/lib/scoring.ts +206 -0
- package/microservices/microservice-hiring/src/mcp/index.ts +709 -0
- package/microservices/microservice-payments/package.json +27 -0
- package/microservices/microservice-payments/src/cli/index.ts +609 -0
- package/microservices/microservice-payments/src/db/database.ts +93 -0
- package/microservices/microservice-payments/src/db/migrations.ts +81 -0
- package/microservices/microservice-payments/src/db/payments.ts +1204 -0
- package/microservices/microservice-payments/src/index.ts +51 -0
- package/microservices/microservice-payments/src/mcp/index.ts +683 -0
- package/microservices/microservice-payroll/package.json +27 -0
- package/microservices/microservice-payroll/src/cli/index.ts +643 -0
- package/microservices/microservice-payroll/src/db/database.ts +93 -0
- package/microservices/microservice-payroll/src/db/migrations.ts +95 -0
- package/microservices/microservice-payroll/src/db/payroll.ts +1377 -0
- package/microservices/microservice-payroll/src/index.ts +48 -0
- package/microservices/microservice-payroll/src/mcp/index.ts +666 -0
- package/microservices/microservice-shipping/package.json +27 -0
- package/microservices/microservice-shipping/src/cli/index.ts +606 -0
- package/microservices/microservice-shipping/src/db/database.ts +93 -0
- package/microservices/microservice-shipping/src/db/migrations.ts +69 -0
- package/microservices/microservice-shipping/src/db/shipping.ts +1093 -0
- package/microservices/microservice-shipping/src/index.ts +53 -0
- package/microservices/microservice-shipping/src/mcp/index.ts +533 -0
- package/microservices/microservice-social/package.json +27 -0
- package/microservices/microservice-social/src/cli/index.ts +689 -0
- package/microservices/microservice-social/src/db/database.ts +93 -0
- package/microservices/microservice-social/src/db/migrations.ts +88 -0
- package/microservices/microservice-social/src/db/social.ts +1046 -0
- package/microservices/microservice-social/src/index.ts +46 -0
- package/microservices/microservice-social/src/mcp/index.ts +655 -0
- package/microservices/microservice-subscriptions/package.json +27 -0
- package/microservices/microservice-subscriptions/src/cli/index.ts +715 -0
- package/microservices/microservice-subscriptions/src/db/database.ts +93 -0
- package/microservices/microservice-subscriptions/src/db/migrations.ts +125 -0
- package/microservices/microservice-subscriptions/src/db/subscriptions.ts +1256 -0
- package/microservices/microservice-subscriptions/src/index.ts +41 -0
- package/microservices/microservice-subscriptions/src/mcp/index.ts +631 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payroll CRUD and business logic operations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getDatabase } from "./database.js";
|
|
6
|
+
|
|
7
|
+
// --- Types ---
|
|
8
|
+
|
|
9
|
+
export interface Employee {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
email: string | null;
|
|
13
|
+
type: "employee" | "contractor";
|
|
14
|
+
status: "active" | "terminated";
|
|
15
|
+
department: string | null;
|
|
16
|
+
title: string | null;
|
|
17
|
+
pay_rate: number;
|
|
18
|
+
pay_type: "salary" | "hourly";
|
|
19
|
+
currency: string;
|
|
20
|
+
tax_info: Record<string, unknown>;
|
|
21
|
+
start_date: string | null;
|
|
22
|
+
end_date: string | null;
|
|
23
|
+
created_at: string;
|
|
24
|
+
updated_at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface EmployeeRow {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
email: string | null;
|
|
31
|
+
type: string;
|
|
32
|
+
status: string;
|
|
33
|
+
department: string | null;
|
|
34
|
+
title: string | null;
|
|
35
|
+
pay_rate: number;
|
|
36
|
+
pay_type: string;
|
|
37
|
+
currency: string;
|
|
38
|
+
tax_info: string;
|
|
39
|
+
start_date: string | null;
|
|
40
|
+
end_date: string | null;
|
|
41
|
+
created_at: string;
|
|
42
|
+
updated_at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rowToEmployee(row: EmployeeRow): Employee {
|
|
46
|
+
return {
|
|
47
|
+
...row,
|
|
48
|
+
type: row.type as Employee["type"],
|
|
49
|
+
status: row.status as Employee["status"],
|
|
50
|
+
pay_type: row.pay_type as Employee["pay_type"],
|
|
51
|
+
tax_info: JSON.parse(row.tax_info || "{}"),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PayPeriod {
|
|
56
|
+
id: string;
|
|
57
|
+
start_date: string;
|
|
58
|
+
end_date: string;
|
|
59
|
+
status: "draft" | "processing" | "completed";
|
|
60
|
+
created_at: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PayStub {
|
|
64
|
+
id: string;
|
|
65
|
+
employee_id: string;
|
|
66
|
+
pay_period_id: string;
|
|
67
|
+
gross_pay: number;
|
|
68
|
+
deductions: Record<string, number>;
|
|
69
|
+
net_pay: number;
|
|
70
|
+
hours_worked: number | null;
|
|
71
|
+
overtime_hours: number;
|
|
72
|
+
created_at: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface PayStubRow {
|
|
76
|
+
id: string;
|
|
77
|
+
employee_id: string;
|
|
78
|
+
pay_period_id: string;
|
|
79
|
+
gross_pay: number;
|
|
80
|
+
deductions: string;
|
|
81
|
+
net_pay: number;
|
|
82
|
+
hours_worked: number | null;
|
|
83
|
+
overtime_hours: number;
|
|
84
|
+
created_at: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function rowToPayStub(row: PayStubRow): PayStub {
|
|
88
|
+
return {
|
|
89
|
+
...row,
|
|
90
|
+
deductions: JSON.parse(row.deductions || "{}"),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Payment {
|
|
95
|
+
id: string;
|
|
96
|
+
pay_stub_id: string;
|
|
97
|
+
method: "direct_deposit" | "check" | "wire";
|
|
98
|
+
status: "pending" | "paid" | "failed";
|
|
99
|
+
paid_at: string | null;
|
|
100
|
+
reference: string | null;
|
|
101
|
+
created_at: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Employee CRUD ---
|
|
105
|
+
|
|
106
|
+
export interface CreateEmployeeInput {
|
|
107
|
+
name: string;
|
|
108
|
+
email?: string;
|
|
109
|
+
type?: "employee" | "contractor";
|
|
110
|
+
status?: "active" | "terminated";
|
|
111
|
+
department?: string;
|
|
112
|
+
title?: string;
|
|
113
|
+
pay_rate: number;
|
|
114
|
+
pay_type?: "salary" | "hourly";
|
|
115
|
+
currency?: string;
|
|
116
|
+
tax_info?: Record<string, unknown>;
|
|
117
|
+
start_date?: string;
|
|
118
|
+
end_date?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createEmployee(input: CreateEmployeeInput): Employee {
|
|
122
|
+
const db = getDatabase();
|
|
123
|
+
const id = crypto.randomUUID();
|
|
124
|
+
const taxInfo = JSON.stringify(input.tax_info || {});
|
|
125
|
+
|
|
126
|
+
db.prepare(
|
|
127
|
+
`INSERT INTO employees (id, name, email, type, status, department, title, pay_rate, pay_type, currency, tax_info, start_date, end_date)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
129
|
+
).run(
|
|
130
|
+
id,
|
|
131
|
+
input.name,
|
|
132
|
+
input.email || null,
|
|
133
|
+
input.type || "employee",
|
|
134
|
+
input.status || "active",
|
|
135
|
+
input.department || null,
|
|
136
|
+
input.title || null,
|
|
137
|
+
input.pay_rate,
|
|
138
|
+
input.pay_type || "salary",
|
|
139
|
+
input.currency || "USD",
|
|
140
|
+
taxInfo,
|
|
141
|
+
input.start_date || null,
|
|
142
|
+
input.end_date || null
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return getEmployee(id)!;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getEmployee(id: string): Employee | null {
|
|
149
|
+
const db = getDatabase();
|
|
150
|
+
const row = db.prepare("SELECT * FROM employees WHERE id = ?").get(id) as EmployeeRow | null;
|
|
151
|
+
return row ? rowToEmployee(row) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface ListEmployeesOptions {
|
|
155
|
+
search?: string;
|
|
156
|
+
status?: string;
|
|
157
|
+
department?: string;
|
|
158
|
+
type?: string;
|
|
159
|
+
limit?: number;
|
|
160
|
+
offset?: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function listEmployees(options: ListEmployeesOptions = {}): Employee[] {
|
|
164
|
+
const db = getDatabase();
|
|
165
|
+
const conditions: string[] = [];
|
|
166
|
+
const params: unknown[] = [];
|
|
167
|
+
|
|
168
|
+
if (options.search) {
|
|
169
|
+
conditions.push("(name LIKE ? OR email LIKE ? OR department LIKE ?)");
|
|
170
|
+
const q = `%${options.search}%`;
|
|
171
|
+
params.push(q, q, q);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (options.status) {
|
|
175
|
+
conditions.push("status = ?");
|
|
176
|
+
params.push(options.status);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (options.department) {
|
|
180
|
+
conditions.push("department = ?");
|
|
181
|
+
params.push(options.department);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (options.type) {
|
|
185
|
+
conditions.push("type = ?");
|
|
186
|
+
params.push(options.type);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let sql = "SELECT * FROM employees";
|
|
190
|
+
if (conditions.length > 0) {
|
|
191
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
192
|
+
}
|
|
193
|
+
sql += " ORDER BY name";
|
|
194
|
+
|
|
195
|
+
if (options.limit) {
|
|
196
|
+
sql += " LIMIT ?";
|
|
197
|
+
params.push(options.limit);
|
|
198
|
+
}
|
|
199
|
+
if (options.offset) {
|
|
200
|
+
sql += " OFFSET ?";
|
|
201
|
+
params.push(options.offset);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rows = db.prepare(sql).all(...params) as EmployeeRow[];
|
|
205
|
+
return rows.map(rowToEmployee);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface UpdateEmployeeInput {
|
|
209
|
+
name?: string;
|
|
210
|
+
email?: string;
|
|
211
|
+
type?: "employee" | "contractor";
|
|
212
|
+
status?: "active" | "terminated";
|
|
213
|
+
department?: string;
|
|
214
|
+
title?: string;
|
|
215
|
+
pay_rate?: number;
|
|
216
|
+
pay_type?: "salary" | "hourly";
|
|
217
|
+
currency?: string;
|
|
218
|
+
tax_info?: Record<string, unknown>;
|
|
219
|
+
start_date?: string;
|
|
220
|
+
end_date?: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function updateEmployee(id: string, input: UpdateEmployeeInput): Employee | null {
|
|
224
|
+
const db = getDatabase();
|
|
225
|
+
const existing = getEmployee(id);
|
|
226
|
+
if (!existing) return null;
|
|
227
|
+
|
|
228
|
+
const sets: string[] = [];
|
|
229
|
+
const params: unknown[] = [];
|
|
230
|
+
|
|
231
|
+
if (input.name !== undefined) { sets.push("name = ?"); params.push(input.name); }
|
|
232
|
+
if (input.email !== undefined) { sets.push("email = ?"); params.push(input.email); }
|
|
233
|
+
if (input.type !== undefined) { sets.push("type = ?"); params.push(input.type); }
|
|
234
|
+
if (input.status !== undefined) { sets.push("status = ?"); params.push(input.status); }
|
|
235
|
+
if (input.department !== undefined) { sets.push("department = ?"); params.push(input.department); }
|
|
236
|
+
if (input.title !== undefined) { sets.push("title = ?"); params.push(input.title); }
|
|
237
|
+
if (input.pay_rate !== undefined) { sets.push("pay_rate = ?"); params.push(input.pay_rate); }
|
|
238
|
+
if (input.pay_type !== undefined) { sets.push("pay_type = ?"); params.push(input.pay_type); }
|
|
239
|
+
if (input.currency !== undefined) { sets.push("currency = ?"); params.push(input.currency); }
|
|
240
|
+
if (input.tax_info !== undefined) { sets.push("tax_info = ?"); params.push(JSON.stringify(input.tax_info)); }
|
|
241
|
+
if (input.start_date !== undefined) { sets.push("start_date = ?"); params.push(input.start_date); }
|
|
242
|
+
if (input.end_date !== undefined) { sets.push("end_date = ?"); params.push(input.end_date); }
|
|
243
|
+
|
|
244
|
+
if (sets.length === 0) return existing;
|
|
245
|
+
|
|
246
|
+
sets.push("updated_at = datetime('now')");
|
|
247
|
+
params.push(id);
|
|
248
|
+
|
|
249
|
+
db.prepare(`UPDATE employees SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
250
|
+
|
|
251
|
+
return getEmployee(id);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function deleteEmployee(id: string): boolean {
|
|
255
|
+
const db = getDatabase();
|
|
256
|
+
const result = db.prepare("DELETE FROM employees WHERE id = ?").run(id);
|
|
257
|
+
return result.changes > 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function terminateEmployee(id: string, endDate?: string): Employee | null {
|
|
261
|
+
return updateEmployee(id, {
|
|
262
|
+
status: "terminated",
|
|
263
|
+
end_date: endDate || new Date().toISOString().split("T")[0],
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function countEmployees(): number {
|
|
268
|
+
const db = getDatabase();
|
|
269
|
+
const row = db.prepare("SELECT COUNT(*) as count FROM employees").get() as { count: number };
|
|
270
|
+
return row.count;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Pay Period CRUD ---
|
|
274
|
+
|
|
275
|
+
export interface CreatePayPeriodInput {
|
|
276
|
+
start_date: string;
|
|
277
|
+
end_date: string;
|
|
278
|
+
status?: "draft" | "processing" | "completed";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function createPayPeriod(input: CreatePayPeriodInput): PayPeriod {
|
|
282
|
+
const db = getDatabase();
|
|
283
|
+
const id = crypto.randomUUID();
|
|
284
|
+
|
|
285
|
+
db.prepare(
|
|
286
|
+
`INSERT INTO pay_periods (id, start_date, end_date, status) VALUES (?, ?, ?, ?)`
|
|
287
|
+
).run(id, input.start_date, input.end_date, input.status || "draft");
|
|
288
|
+
|
|
289
|
+
return getPayPeriod(id)!;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function getPayPeriod(id: string): PayPeriod | null {
|
|
293
|
+
const db = getDatabase();
|
|
294
|
+
const row = db.prepare("SELECT * FROM pay_periods WHERE id = ?").get(id) as PayPeriod | null;
|
|
295
|
+
return row || null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function listPayPeriods(status?: string): PayPeriod[] {
|
|
299
|
+
const db = getDatabase();
|
|
300
|
+
if (status) {
|
|
301
|
+
return db.prepare("SELECT * FROM pay_periods WHERE status = ? ORDER BY start_date DESC").all(status) as PayPeriod[];
|
|
302
|
+
}
|
|
303
|
+
return db.prepare("SELECT * FROM pay_periods ORDER BY start_date DESC").all() as PayPeriod[];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function updatePayPeriodStatus(id: string, status: "draft" | "processing" | "completed"): PayPeriod | null {
|
|
307
|
+
const db = getDatabase();
|
|
308
|
+
const existing = getPayPeriod(id);
|
|
309
|
+
if (!existing) return null;
|
|
310
|
+
|
|
311
|
+
db.prepare("UPDATE pay_periods SET status = ? WHERE id = ?").run(status, id);
|
|
312
|
+
return getPayPeriod(id);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function deletePayPeriod(id: string): boolean {
|
|
316
|
+
const db = getDatabase();
|
|
317
|
+
const result = db.prepare("DELETE FROM pay_periods WHERE id = ?").run(id);
|
|
318
|
+
return result.changes > 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Pay Stub CRUD ---
|
|
322
|
+
|
|
323
|
+
export interface CreatePayStubInput {
|
|
324
|
+
employee_id: string;
|
|
325
|
+
pay_period_id: string;
|
|
326
|
+
gross_pay: number;
|
|
327
|
+
deductions?: Record<string, number>;
|
|
328
|
+
net_pay: number;
|
|
329
|
+
hours_worked?: number;
|
|
330
|
+
overtime_hours?: number;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function createPayStub(input: CreatePayStubInput): PayStub {
|
|
334
|
+
const db = getDatabase();
|
|
335
|
+
const id = crypto.randomUUID();
|
|
336
|
+
const deductions = JSON.stringify(input.deductions || {});
|
|
337
|
+
|
|
338
|
+
db.prepare(
|
|
339
|
+
`INSERT INTO pay_stubs (id, employee_id, pay_period_id, gross_pay, deductions, net_pay, hours_worked, overtime_hours)
|
|
340
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
341
|
+
).run(
|
|
342
|
+
id,
|
|
343
|
+
input.employee_id,
|
|
344
|
+
input.pay_period_id,
|
|
345
|
+
input.gross_pay,
|
|
346
|
+
deductions,
|
|
347
|
+
input.net_pay,
|
|
348
|
+
input.hours_worked ?? null,
|
|
349
|
+
input.overtime_hours ?? 0
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
return getPayStub(id)!;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function getPayStub(id: string): PayStub | null {
|
|
356
|
+
const db = getDatabase();
|
|
357
|
+
const row = db.prepare("SELECT * FROM pay_stubs WHERE id = ?").get(id) as PayStubRow | null;
|
|
358
|
+
return row ? rowToPayStub(row) : null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function listPayStubs(options: { employee_id?: string; pay_period_id?: string } = {}): PayStub[] {
|
|
362
|
+
const db = getDatabase();
|
|
363
|
+
const conditions: string[] = [];
|
|
364
|
+
const params: unknown[] = [];
|
|
365
|
+
|
|
366
|
+
if (options.employee_id) {
|
|
367
|
+
conditions.push("employee_id = ?");
|
|
368
|
+
params.push(options.employee_id);
|
|
369
|
+
}
|
|
370
|
+
if (options.pay_period_id) {
|
|
371
|
+
conditions.push("pay_period_id = ?");
|
|
372
|
+
params.push(options.pay_period_id);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let sql = "SELECT * FROM pay_stubs";
|
|
376
|
+
if (conditions.length > 0) {
|
|
377
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
378
|
+
}
|
|
379
|
+
sql += " ORDER BY created_at DESC";
|
|
380
|
+
|
|
381
|
+
const rows = db.prepare(sql).all(...params) as PayStubRow[];
|
|
382
|
+
return rows.map(rowToPayStub);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function deletePayStub(id: string): boolean {
|
|
386
|
+
const db = getDatabase();
|
|
387
|
+
const result = db.prepare("DELETE FROM pay_stubs WHERE id = ?").run(id);
|
|
388
|
+
return result.changes > 0;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Payment CRUD ---
|
|
392
|
+
|
|
393
|
+
export interface CreatePaymentInput {
|
|
394
|
+
pay_stub_id: string;
|
|
395
|
+
method?: "direct_deposit" | "check" | "wire";
|
|
396
|
+
status?: "pending" | "paid" | "failed";
|
|
397
|
+
reference?: string;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function createPayment(input: CreatePaymentInput): Payment {
|
|
401
|
+
const db = getDatabase();
|
|
402
|
+
const id = crypto.randomUUID();
|
|
403
|
+
|
|
404
|
+
db.prepare(
|
|
405
|
+
`INSERT INTO payments (id, pay_stub_id, method, status, reference)
|
|
406
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
407
|
+
).run(
|
|
408
|
+
id,
|
|
409
|
+
input.pay_stub_id,
|
|
410
|
+
input.method || "direct_deposit",
|
|
411
|
+
input.status || "pending",
|
|
412
|
+
input.reference || null
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
return getPayment(id)!;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function getPayment(id: string): Payment | null {
|
|
419
|
+
const db = getDatabase();
|
|
420
|
+
const row = db.prepare("SELECT * FROM payments WHERE id = ?").get(id) as Payment | null;
|
|
421
|
+
return row || null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function listPayments(options: { pay_stub_id?: string; status?: string } = {}): Payment[] {
|
|
425
|
+
const db = getDatabase();
|
|
426
|
+
const conditions: string[] = [];
|
|
427
|
+
const params: unknown[] = [];
|
|
428
|
+
|
|
429
|
+
if (options.pay_stub_id) {
|
|
430
|
+
conditions.push("pay_stub_id = ?");
|
|
431
|
+
params.push(options.pay_stub_id);
|
|
432
|
+
}
|
|
433
|
+
if (options.status) {
|
|
434
|
+
conditions.push("status = ?");
|
|
435
|
+
params.push(options.status);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let sql = "SELECT * FROM payments";
|
|
439
|
+
if (conditions.length > 0) {
|
|
440
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
441
|
+
}
|
|
442
|
+
sql += " ORDER BY created_at DESC";
|
|
443
|
+
|
|
444
|
+
return db.prepare(sql).all(...params) as Payment[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function updatePaymentStatus(id: string, status: "pending" | "paid" | "failed"): Payment | null {
|
|
448
|
+
const db = getDatabase();
|
|
449
|
+
const existing = getPayment(id);
|
|
450
|
+
if (!existing) return null;
|
|
451
|
+
|
|
452
|
+
const paidAt = status === "paid" ? new Date().toISOString() : null;
|
|
453
|
+
db.prepare("UPDATE payments SET status = ?, paid_at = ? WHERE id = ?").run(status, paidAt, id);
|
|
454
|
+
|
|
455
|
+
return getPayment(id);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function deletePayment(id: string): boolean {
|
|
459
|
+
const db = getDatabase();
|
|
460
|
+
const result = db.prepare("DELETE FROM payments WHERE id = ?").run(id);
|
|
461
|
+
return result.changes > 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// --- Benefits CRUD ---
|
|
465
|
+
|
|
466
|
+
export interface Benefit {
|
|
467
|
+
id: string;
|
|
468
|
+
employee_id: string;
|
|
469
|
+
type: "health" | "dental" | "vision" | "retirement" | "hsa" | "other";
|
|
470
|
+
description: string | null;
|
|
471
|
+
amount: number;
|
|
472
|
+
frequency: "per_period" | "monthly" | "annual";
|
|
473
|
+
active: boolean;
|
|
474
|
+
created_at: string;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
interface BenefitRow {
|
|
478
|
+
id: string;
|
|
479
|
+
employee_id: string;
|
|
480
|
+
type: string;
|
|
481
|
+
description: string | null;
|
|
482
|
+
amount: number;
|
|
483
|
+
frequency: string;
|
|
484
|
+
active: number;
|
|
485
|
+
created_at: string;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function rowToBenefit(row: BenefitRow): Benefit {
|
|
489
|
+
return {
|
|
490
|
+
...row,
|
|
491
|
+
type: row.type as Benefit["type"],
|
|
492
|
+
frequency: row.frequency as Benefit["frequency"],
|
|
493
|
+
active: row.active === 1,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export interface CreateBenefitInput {
|
|
498
|
+
employee_id: string;
|
|
499
|
+
type: "health" | "dental" | "vision" | "retirement" | "hsa" | "other";
|
|
500
|
+
description?: string;
|
|
501
|
+
amount: number;
|
|
502
|
+
frequency?: "per_period" | "monthly" | "annual";
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function createBenefit(input: CreateBenefitInput): Benefit {
|
|
506
|
+
const db = getDatabase();
|
|
507
|
+
const id = crypto.randomUUID();
|
|
508
|
+
|
|
509
|
+
db.prepare(
|
|
510
|
+
`INSERT INTO benefits (id, employee_id, type, description, amount, frequency)
|
|
511
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
512
|
+
).run(
|
|
513
|
+
id,
|
|
514
|
+
input.employee_id,
|
|
515
|
+
input.type,
|
|
516
|
+
input.description || null,
|
|
517
|
+
input.amount,
|
|
518
|
+
input.frequency || "per_period"
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
return getBenefit(id)!;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function getBenefit(id: string): Benefit | null {
|
|
525
|
+
const db = getDatabase();
|
|
526
|
+
const row = db.prepare("SELECT * FROM benefits WHERE id = ?").get(id) as BenefitRow | null;
|
|
527
|
+
return row ? rowToBenefit(row) : null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function listBenefits(employeeId?: string): Benefit[] {
|
|
531
|
+
const db = getDatabase();
|
|
532
|
+
if (employeeId) {
|
|
533
|
+
const rows = db.prepare("SELECT * FROM benefits WHERE employee_id = ? ORDER BY created_at DESC").all(employeeId) as BenefitRow[];
|
|
534
|
+
return rows.map(rowToBenefit);
|
|
535
|
+
}
|
|
536
|
+
const rows = db.prepare("SELECT * FROM benefits ORDER BY created_at DESC").all() as BenefitRow[];
|
|
537
|
+
return rows.map(rowToBenefit);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function removeBenefit(id: string): boolean {
|
|
541
|
+
const db = getDatabase();
|
|
542
|
+
const result = db.prepare("UPDATE benefits SET active = 0 WHERE id = ?").run(id);
|
|
543
|
+
return result.changes > 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Get benefit deductions for an employee, normalized to the given period type.
|
|
548
|
+
* Converts monthly/annual amounts into per-period equivalents based on periodsPerYear.
|
|
549
|
+
*/
|
|
550
|
+
export function getBenefitDeductions(
|
|
551
|
+
employeeId: string,
|
|
552
|
+
periodType: "weekly" | "biweekly" | "semimonthly" | "monthly" = "semimonthly"
|
|
553
|
+
): Record<string, number> {
|
|
554
|
+
const db = getDatabase();
|
|
555
|
+
const rows = db.prepare(
|
|
556
|
+
"SELECT * FROM benefits WHERE employee_id = ? AND active = 1"
|
|
557
|
+
).all(employeeId) as BenefitRow[];
|
|
558
|
+
|
|
559
|
+
const periodsPerYear: Record<string, number> = {
|
|
560
|
+
weekly: 52,
|
|
561
|
+
biweekly: 26,
|
|
562
|
+
semimonthly: 24,
|
|
563
|
+
monthly: 12,
|
|
564
|
+
};
|
|
565
|
+
const periods = periodsPerYear[periodType] || 24;
|
|
566
|
+
|
|
567
|
+
const deductions: Record<string, number> = {};
|
|
568
|
+
for (const row of rows) {
|
|
569
|
+
let perPeriodAmount: number;
|
|
570
|
+
if (row.frequency === "per_period") {
|
|
571
|
+
perPeriodAmount = row.amount;
|
|
572
|
+
} else if (row.frequency === "monthly") {
|
|
573
|
+
perPeriodAmount = (row.amount * 12) / periods;
|
|
574
|
+
} else {
|
|
575
|
+
// annual
|
|
576
|
+
perPeriodAmount = row.amount / periods;
|
|
577
|
+
}
|
|
578
|
+
const key = `benefit_${row.type}`;
|
|
579
|
+
deductions[key] = Math.round(((deductions[key] || 0) + perPeriodAmount) * 100) / 100;
|
|
580
|
+
}
|
|
581
|
+
return deductions;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// --- Payroll Schedule ---
|
|
585
|
+
|
|
586
|
+
export interface PayrollSchedule {
|
|
587
|
+
id: string;
|
|
588
|
+
frequency: "weekly" | "biweekly" | "semimonthly" | "monthly";
|
|
589
|
+
anchor_date: string;
|
|
590
|
+
created_at: string;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function setSchedule(frequency: PayrollSchedule["frequency"], anchorDate: string): PayrollSchedule {
|
|
594
|
+
const db = getDatabase();
|
|
595
|
+
// Only one schedule at a time — delete existing and insert new
|
|
596
|
+
db.prepare("DELETE FROM payroll_schedule").run();
|
|
597
|
+
const id = crypto.randomUUID();
|
|
598
|
+
db.prepare(
|
|
599
|
+
"INSERT INTO payroll_schedule (id, frequency, anchor_date) VALUES (?, ?, ?)"
|
|
600
|
+
).run(id, frequency, anchorDate);
|
|
601
|
+
return getSchedule()!;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function getSchedule(): PayrollSchedule | null {
|
|
605
|
+
const db = getDatabase();
|
|
606
|
+
const row = db.prepare("SELECT * FROM payroll_schedule ORDER BY created_at DESC LIMIT 1").get() as PayrollSchedule | null;
|
|
607
|
+
return row || null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Calculate the next pay period based on the payroll schedule.
|
|
612
|
+
* Returns {start_date, end_date} for the next upcoming period from today.
|
|
613
|
+
*/
|
|
614
|
+
export function getNextPayPeriod(fromDate?: string): { start_date: string; end_date: string } | null {
|
|
615
|
+
const schedule = getSchedule();
|
|
616
|
+
if (!schedule) return null;
|
|
617
|
+
|
|
618
|
+
const today = fromDate ? new Date(fromDate) : new Date();
|
|
619
|
+
const anchor = new Date(schedule.anchor_date);
|
|
620
|
+
|
|
621
|
+
switch (schedule.frequency) {
|
|
622
|
+
case "weekly": {
|
|
623
|
+
// Find the next weekly start from anchor
|
|
624
|
+
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
|
|
625
|
+
const diffMs = today.getTime() - anchor.getTime();
|
|
626
|
+
const weeksSinceAnchor = Math.ceil(diffMs / msPerWeek);
|
|
627
|
+
const start = new Date(anchor.getTime() + weeksSinceAnchor * msPerWeek);
|
|
628
|
+
const end = new Date(start.getTime() + 6 * 24 * 60 * 60 * 1000);
|
|
629
|
+
return { start_date: fmtDate(start), end_date: fmtDate(end) };
|
|
630
|
+
}
|
|
631
|
+
case "biweekly": {
|
|
632
|
+
const msPerTwoWeeks = 14 * 24 * 60 * 60 * 1000;
|
|
633
|
+
const diffMs = today.getTime() - anchor.getTime();
|
|
634
|
+
const biweeksSinceAnchor = Math.ceil(diffMs / msPerTwoWeeks);
|
|
635
|
+
const start = new Date(anchor.getTime() + biweeksSinceAnchor * msPerTwoWeeks);
|
|
636
|
+
const end = new Date(start.getTime() + 13 * 24 * 60 * 60 * 1000);
|
|
637
|
+
return { start_date: fmtDate(start), end_date: fmtDate(end) };
|
|
638
|
+
}
|
|
639
|
+
case "semimonthly": {
|
|
640
|
+
// Periods: 1st-15th and 16th-end of month
|
|
641
|
+
const year = today.getFullYear();
|
|
642
|
+
const month = today.getMonth();
|
|
643
|
+
const day = today.getDate();
|
|
644
|
+
if (day <= 15) {
|
|
645
|
+
return {
|
|
646
|
+
start_date: fmtDate(new Date(year, month, 1)),
|
|
647
|
+
end_date: fmtDate(new Date(year, month, 15)),
|
|
648
|
+
};
|
|
649
|
+
} else {
|
|
650
|
+
const lastDay = new Date(year, month + 1, 0).getDate();
|
|
651
|
+
return {
|
|
652
|
+
start_date: fmtDate(new Date(year, month, 16)),
|
|
653
|
+
end_date: fmtDate(new Date(year, month, lastDay)),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
case "monthly": {
|
|
658
|
+
const year = today.getFullYear();
|
|
659
|
+
const month = today.getMonth();
|
|
660
|
+
const lastDay = new Date(year, month + 1, 0).getDate();
|
|
661
|
+
return {
|
|
662
|
+
start_date: fmtDate(new Date(year, month, 1)),
|
|
663
|
+
end_date: fmtDate(new Date(year, month, lastDay)),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
default:
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function fmtDate(d: Date): string {
|
|
672
|
+
return d.toISOString().split("T")[0];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// --- Business Logic ---
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Standard deduction rates for payroll processing
|
|
679
|
+
*/
|
|
680
|
+
const DEFAULT_DEDUCTIONS = {
|
|
681
|
+
federal_tax: 0.22, // 22% federal income tax bracket
|
|
682
|
+
state_tax: 0.05, // 5% state tax
|
|
683
|
+
social_security: 0.062, // 6.2% social security
|
|
684
|
+
medicare: 0.0145, // 1.45% medicare
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Calculate deductions for a given gross pay amount.
|
|
689
|
+
* Contractors only pay federal and state tax (no FICA).
|
|
690
|
+
*/
|
|
691
|
+
export function calculateDeductions(
|
|
692
|
+
grossPay: number,
|
|
693
|
+
employeeType: "employee" | "contractor",
|
|
694
|
+
customRates?: Record<string, number>
|
|
695
|
+
): Record<string, number> {
|
|
696
|
+
const rates = customRates || DEFAULT_DEDUCTIONS;
|
|
697
|
+
const deductions: Record<string, number> = {};
|
|
698
|
+
|
|
699
|
+
if (employeeType === "contractor") {
|
|
700
|
+
// Contractors: only federal + state tax
|
|
701
|
+
deductions.federal_tax = Math.round(grossPay * (rates.federal_tax || 0.22) * 100) / 100;
|
|
702
|
+
deductions.state_tax = Math.round(grossPay * (rates.state_tax || 0.05) * 100) / 100;
|
|
703
|
+
} else {
|
|
704
|
+
// Employees: full deductions
|
|
705
|
+
for (const [key, rate] of Object.entries(rates)) {
|
|
706
|
+
deductions[key] = Math.round(grossPay * rate * 100) / 100;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return deductions;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Calculate gross pay for an employee over a pay period.
|
|
715
|
+
* For salary employees: pay_rate / 24 (semi-monthly).
|
|
716
|
+
* For hourly employees: pay_rate * hours + overtime.
|
|
717
|
+
*/
|
|
718
|
+
export function calculateGrossPay(
|
|
719
|
+
employee: Employee,
|
|
720
|
+
hoursWorked?: number,
|
|
721
|
+
overtimeHours?: number
|
|
722
|
+
): number {
|
|
723
|
+
if (employee.pay_type === "salary") {
|
|
724
|
+
// Semi-monthly: annual salary / 24
|
|
725
|
+
return Math.round((employee.pay_rate / 24) * 100) / 100;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Hourly
|
|
729
|
+
const regularHours = hoursWorked || 0;
|
|
730
|
+
const overtime = overtimeHours || 0;
|
|
731
|
+
const regularPay = regularHours * employee.pay_rate;
|
|
732
|
+
const overtimePay = overtime * employee.pay_rate * 1.5;
|
|
733
|
+
return Math.round((regularPay + overtimePay) * 100) / 100;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Process payroll for a given pay period.
|
|
738
|
+
* Auto-generates pay stubs for all active employees.
|
|
739
|
+
* Returns the generated pay stubs.
|
|
740
|
+
*/
|
|
741
|
+
export function processPayroll(
|
|
742
|
+
periodId: string,
|
|
743
|
+
hoursMap?: Record<string, { hours: number; overtime?: number }>
|
|
744
|
+
): PayStub[] {
|
|
745
|
+
const db = getDatabase();
|
|
746
|
+
const period = getPayPeriod(periodId);
|
|
747
|
+
if (!period) throw new Error(`Pay period '${periodId}' not found`);
|
|
748
|
+
if (period.status === "completed") throw new Error("Pay period already completed");
|
|
749
|
+
|
|
750
|
+
// Mark as processing
|
|
751
|
+
updatePayPeriodStatus(periodId, "processing");
|
|
752
|
+
|
|
753
|
+
const activeEmployees = listEmployees({ status: "active" });
|
|
754
|
+
const stubs: PayStub[] = [];
|
|
755
|
+
|
|
756
|
+
for (const emp of activeEmployees) {
|
|
757
|
+
// Check if stub already exists for this employee+period
|
|
758
|
+
const existing = db.prepare(
|
|
759
|
+
"SELECT id FROM pay_stubs WHERE employee_id = ? AND pay_period_id = ?"
|
|
760
|
+
).get(emp.id, periodId) as { id: string } | null;
|
|
761
|
+
if (existing) continue;
|
|
762
|
+
|
|
763
|
+
const empHours = hoursMap?.[emp.id];
|
|
764
|
+
const hoursWorked = empHours?.hours;
|
|
765
|
+
const overtimeHours = empHours?.overtime || 0;
|
|
766
|
+
|
|
767
|
+
const grossPay = calculateGrossPay(emp, hoursWorked, overtimeHours);
|
|
768
|
+
const deductions = calculateDeductions(grossPay, emp.type);
|
|
769
|
+
|
|
770
|
+
// Auto-apply benefit deductions for this employee
|
|
771
|
+
const benefitDeds = getBenefitDeductions(emp.id);
|
|
772
|
+
for (const [key, amount] of Object.entries(benefitDeds)) {
|
|
773
|
+
deductions[key] = (deductions[key] || 0) + amount;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const totalDeductions = Object.values(deductions).reduce((sum, d) => sum + d, 0);
|
|
777
|
+
const netPay = Math.round((grossPay - totalDeductions) * 100) / 100;
|
|
778
|
+
|
|
779
|
+
const stub = createPayStub({
|
|
780
|
+
employee_id: emp.id,
|
|
781
|
+
pay_period_id: periodId,
|
|
782
|
+
gross_pay: grossPay,
|
|
783
|
+
deductions,
|
|
784
|
+
net_pay: netPay,
|
|
785
|
+
hours_worked: hoursWorked,
|
|
786
|
+
overtime_hours: overtimeHours,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
stubs.push(stub);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Mark as completed
|
|
793
|
+
updatePayPeriodStatus(periodId, "completed");
|
|
794
|
+
|
|
795
|
+
return stubs;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Get a payroll report for a pay period.
|
|
800
|
+
*/
|
|
801
|
+
export interface PayrollReport {
|
|
802
|
+
period: PayPeriod;
|
|
803
|
+
stubs: PayStub[];
|
|
804
|
+
total_gross: number;
|
|
805
|
+
total_deductions: number;
|
|
806
|
+
total_net: number;
|
|
807
|
+
employee_count: number;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
export function getPayrollReport(periodId: string): PayrollReport | null {
|
|
811
|
+
const period = getPayPeriod(periodId);
|
|
812
|
+
if (!period) return null;
|
|
813
|
+
|
|
814
|
+
const stubs = listPayStubs({ pay_period_id: periodId });
|
|
815
|
+
|
|
816
|
+
const totalGross = stubs.reduce((sum, s) => sum + s.gross_pay, 0);
|
|
817
|
+
const totalDeductions = stubs.reduce((sum, s) => {
|
|
818
|
+
const d = Object.values(s.deductions).reduce((a, b) => a + b, 0);
|
|
819
|
+
return sum + d;
|
|
820
|
+
}, 0);
|
|
821
|
+
const totalNet = stubs.reduce((sum, s) => sum + s.net_pay, 0);
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
period,
|
|
825
|
+
stubs,
|
|
826
|
+
total_gross: Math.round(totalGross * 100) / 100,
|
|
827
|
+
total_deductions: Math.round(totalDeductions * 100) / 100,
|
|
828
|
+
total_net: Math.round(totalNet * 100) / 100,
|
|
829
|
+
employee_count: stubs.length,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Get year-to-date report for an employee.
|
|
835
|
+
*/
|
|
836
|
+
export interface YtdReport {
|
|
837
|
+
employee: Employee;
|
|
838
|
+
year: number;
|
|
839
|
+
total_gross: number;
|
|
840
|
+
total_deductions: Record<string, number>;
|
|
841
|
+
total_net: number;
|
|
842
|
+
pay_stubs_count: number;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
export function getYtdReport(employeeId: string, year?: number): YtdReport | null {
|
|
846
|
+
const employee = getEmployee(employeeId);
|
|
847
|
+
if (!employee) return null;
|
|
848
|
+
|
|
849
|
+
const targetYear = year || new Date().getFullYear();
|
|
850
|
+
const db = getDatabase();
|
|
851
|
+
|
|
852
|
+
const rows = db.prepare(
|
|
853
|
+
`SELECT ps.* FROM pay_stubs ps
|
|
854
|
+
JOIN pay_periods pp ON ps.pay_period_id = pp.id
|
|
855
|
+
WHERE ps.employee_id = ?
|
|
856
|
+
AND pp.start_date >= ? AND pp.end_date <= ?`
|
|
857
|
+
).all(
|
|
858
|
+
employeeId,
|
|
859
|
+
`${targetYear}-01-01`,
|
|
860
|
+
`${targetYear}-12-31`
|
|
861
|
+
) as PayStubRow[];
|
|
862
|
+
|
|
863
|
+
const stubs = rows.map(rowToPayStub);
|
|
864
|
+
|
|
865
|
+
const totalGross = stubs.reduce((sum, s) => sum + s.gross_pay, 0);
|
|
866
|
+
const totalNet = stubs.reduce((sum, s) => sum + s.net_pay, 0);
|
|
867
|
+
|
|
868
|
+
// Aggregate deductions by category
|
|
869
|
+
const totalDeductions: Record<string, number> = {};
|
|
870
|
+
for (const stub of stubs) {
|
|
871
|
+
for (const [key, value] of Object.entries(stub.deductions)) {
|
|
872
|
+
totalDeductions[key] = (totalDeductions[key] || 0) + value;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
// Round all deduction totals
|
|
876
|
+
for (const key of Object.keys(totalDeductions)) {
|
|
877
|
+
totalDeductions[key] = Math.round(totalDeductions[key] * 100) / 100;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
employee,
|
|
882
|
+
year: targetYear,
|
|
883
|
+
total_gross: Math.round(totalGross * 100) / 100,
|
|
884
|
+
total_deductions: totalDeductions,
|
|
885
|
+
total_net: Math.round(totalNet * 100) / 100,
|
|
886
|
+
pay_stubs_count: stubs.length,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Get tax summary for all employees for a given year.
|
|
892
|
+
*/
|
|
893
|
+
export interface TaxSummaryEntry {
|
|
894
|
+
employee_id: string;
|
|
895
|
+
employee_name: string;
|
|
896
|
+
total_gross: number;
|
|
897
|
+
total_federal_tax: number;
|
|
898
|
+
total_state_tax: number;
|
|
899
|
+
total_social_security: number;
|
|
900
|
+
total_medicare: number;
|
|
901
|
+
total_deductions: number;
|
|
902
|
+
total_net: number;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
export function getTaxSummary(year: number): TaxSummaryEntry[] {
|
|
906
|
+
const db = getDatabase();
|
|
907
|
+
|
|
908
|
+
const employees = listEmployees();
|
|
909
|
+
const entries: TaxSummaryEntry[] = [];
|
|
910
|
+
|
|
911
|
+
for (const emp of employees) {
|
|
912
|
+
const rows = db.prepare(
|
|
913
|
+
`SELECT ps.* FROM pay_stubs ps
|
|
914
|
+
JOIN pay_periods pp ON ps.pay_period_id = pp.id
|
|
915
|
+
WHERE ps.employee_id = ?
|
|
916
|
+
AND pp.start_date >= ? AND pp.end_date <= ?`
|
|
917
|
+
).all(
|
|
918
|
+
emp.id,
|
|
919
|
+
`${year}-01-01`,
|
|
920
|
+
`${year}-12-31`
|
|
921
|
+
) as PayStubRow[];
|
|
922
|
+
|
|
923
|
+
if (rows.length === 0) continue;
|
|
924
|
+
|
|
925
|
+
const stubs = rows.map(rowToPayStub);
|
|
926
|
+
|
|
927
|
+
let totalGross = 0;
|
|
928
|
+
let totalFederalTax = 0;
|
|
929
|
+
let totalStateTax = 0;
|
|
930
|
+
let totalSocialSecurity = 0;
|
|
931
|
+
let totalMedicare = 0;
|
|
932
|
+
let totalNet = 0;
|
|
933
|
+
|
|
934
|
+
for (const stub of stubs) {
|
|
935
|
+
totalGross += stub.gross_pay;
|
|
936
|
+
totalNet += stub.net_pay;
|
|
937
|
+
totalFederalTax += stub.deductions.federal_tax || 0;
|
|
938
|
+
totalStateTax += stub.deductions.state_tax || 0;
|
|
939
|
+
totalSocialSecurity += stub.deductions.social_security || 0;
|
|
940
|
+
totalMedicare += stub.deductions.medicare || 0;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const totalDeductions = totalFederalTax + totalStateTax + totalSocialSecurity + totalMedicare;
|
|
944
|
+
|
|
945
|
+
entries.push({
|
|
946
|
+
employee_id: emp.id,
|
|
947
|
+
employee_name: emp.name,
|
|
948
|
+
total_gross: Math.round(totalGross * 100) / 100,
|
|
949
|
+
total_federal_tax: Math.round(totalFederalTax * 100) / 100,
|
|
950
|
+
total_state_tax: Math.round(totalStateTax * 100) / 100,
|
|
951
|
+
total_social_security: Math.round(totalSocialSecurity * 100) / 100,
|
|
952
|
+
total_medicare: Math.round(totalMedicare * 100) / 100,
|
|
953
|
+
total_deductions: Math.round(totalDeductions * 100) / 100,
|
|
954
|
+
total_net: Math.round(totalNet * 100) / 100,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return entries;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// --- ACH/NACHA File Generation ---
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Generate a NACHA-format ACH file for a completed pay period.
|
|
965
|
+
* Returns the file content as a string.
|
|
966
|
+
*/
|
|
967
|
+
export function generateAchFile(
|
|
968
|
+
periodId: string,
|
|
969
|
+
bankRouting: string,
|
|
970
|
+
bankAccount: string,
|
|
971
|
+
companyName: string = "PAYROLL CO"
|
|
972
|
+
): string {
|
|
973
|
+
const period = getPayPeriod(periodId);
|
|
974
|
+
if (!period) throw new Error(`Pay period '${periodId}' not found`);
|
|
975
|
+
|
|
976
|
+
const stubs = listPayStubs({ pay_period_id: periodId });
|
|
977
|
+
if (stubs.length === 0) throw new Error("No pay stubs found for this period");
|
|
978
|
+
|
|
979
|
+
const now = new Date();
|
|
980
|
+
const fileDate = now.toISOString().slice(2, 10).replace(/-/g, ""); // YYMMDD
|
|
981
|
+
const fileTime = now.toTimeString().slice(0, 5).replace(":", ""); // HHMM
|
|
982
|
+
const batchDate = now.toISOString().slice(0, 10).replace(/-/g, ""); // YYYYMMDD → used as effective date
|
|
983
|
+
|
|
984
|
+
const lines: string[] = [];
|
|
985
|
+
|
|
986
|
+
// File Header Record (type 1)
|
|
987
|
+
const fhr = [
|
|
988
|
+
"1", // Record Type Code
|
|
989
|
+
"01", // Priority Code
|
|
990
|
+
` ${bankRouting.padStart(9, "0")}`, // Immediate Destination (10 chars, leading space)
|
|
991
|
+
` ${bankRouting.padStart(9, "0")}`, // Immediate Origin (10 chars)
|
|
992
|
+
fileDate, // File Creation Date
|
|
993
|
+
fileTime, // File Creation Time
|
|
994
|
+
"A", // File ID Modifier
|
|
995
|
+
"094", // Record Size
|
|
996
|
+
"10", // Blocking Factor
|
|
997
|
+
"1", // Format Code
|
|
998
|
+
"DEST BANK".padEnd(23, " "), // Immediate Destination Name
|
|
999
|
+
companyName.padEnd(23, " ").slice(0, 23), // Immediate Origin Name
|
|
1000
|
+
"".padEnd(8, " "), // Reference Code
|
|
1001
|
+
].join("");
|
|
1002
|
+
lines.push(fhr.padEnd(94, " "));
|
|
1003
|
+
|
|
1004
|
+
// Batch Header Record (type 5)
|
|
1005
|
+
const bhr = [
|
|
1006
|
+
"5", // Record Type Code
|
|
1007
|
+
"200", // Service Class Code (mixed debits/credits)
|
|
1008
|
+
companyName.padEnd(16, " ").slice(0, 16), // Company Name
|
|
1009
|
+
"".padEnd(20, " "), // Company Discretionary Data
|
|
1010
|
+
bankRouting.padStart(10, "0").slice(0, 10), // Company Identification
|
|
1011
|
+
"PPD", // Standard Entry Class Code
|
|
1012
|
+
"PAYROLL".padEnd(10, " ").slice(0, 10), // Company Entry Description
|
|
1013
|
+
batchDate.slice(2), // Company Descriptive Date
|
|
1014
|
+
batchDate.slice(2), // Effective Entry Date
|
|
1015
|
+
"".padEnd(3, " "), // Settlement Date
|
|
1016
|
+
"1", // Originator Status Code
|
|
1017
|
+
bankRouting.padStart(8, "0").slice(0, 8), // Originating DFI Identification
|
|
1018
|
+
"0000001", // Batch Number
|
|
1019
|
+
].join("");
|
|
1020
|
+
lines.push(bhr.padEnd(94, " "));
|
|
1021
|
+
|
|
1022
|
+
// Entry Detail Records (type 6)
|
|
1023
|
+
let entryCount = 0;
|
|
1024
|
+
let totalDebit = 0;
|
|
1025
|
+
let totalCredit = 0;
|
|
1026
|
+
let entryHash = 0;
|
|
1027
|
+
|
|
1028
|
+
for (const stub of stubs) {
|
|
1029
|
+
const emp = getEmployee(stub.employee_id);
|
|
1030
|
+
if (!emp) continue;
|
|
1031
|
+
|
|
1032
|
+
entryCount++;
|
|
1033
|
+
const amount = Math.round(stub.net_pay * 100); // Amount in cents
|
|
1034
|
+
totalCredit += amount;
|
|
1035
|
+
const routingForHash = parseInt(bankRouting.slice(0, 8)) || 0;
|
|
1036
|
+
entryHash += routingForHash;
|
|
1037
|
+
|
|
1038
|
+
const entry = [
|
|
1039
|
+
"6", // Record Type Code
|
|
1040
|
+
"22", // Transaction Code (checking credit)
|
|
1041
|
+
bankRouting.padStart(8, "0").slice(0, 8), // Receiving DFI Identification
|
|
1042
|
+
bankRouting.slice(-1) || "0", // Check Digit
|
|
1043
|
+
bankAccount.padEnd(17, " ").slice(0, 17), // DFI Account Number
|
|
1044
|
+
amount.toString().padStart(10, "0"), // Amount
|
|
1045
|
+
emp.id.slice(0, 15).padEnd(15, " "), // Individual ID Number
|
|
1046
|
+
(emp.name || "EMPLOYEE").padEnd(22, " ").slice(0, 22), // Individual Name
|
|
1047
|
+
" ", // Discretionary Data
|
|
1048
|
+
"0", // Addenda Record Indicator
|
|
1049
|
+
bankRouting.padStart(8, "0").slice(0, 8), // Trace Number (routing)
|
|
1050
|
+
entryCount.toString().padStart(7, "0"), // Trace Number (sequence)
|
|
1051
|
+
].join("");
|
|
1052
|
+
lines.push(entry.padEnd(94, " "));
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Batch Control Record (type 8)
|
|
1056
|
+
const bcr = [
|
|
1057
|
+
"8", // Record Type Code
|
|
1058
|
+
"200", // Service Class Code
|
|
1059
|
+
entryCount.toString().padStart(6, "0"), // Entry/Addenda Count
|
|
1060
|
+
(entryHash % 10000000000).toString().padStart(10, "0"), // Entry Hash
|
|
1061
|
+
totalDebit.toString().padStart(12, "0"), // Total Debit
|
|
1062
|
+
totalCredit.toString().padStart(12, "0"), // Total Credit
|
|
1063
|
+
bankRouting.padStart(10, "0").slice(0, 10), // Company Identification
|
|
1064
|
+
"".padEnd(19, " "), // Message Authentication Code
|
|
1065
|
+
"".padEnd(6, " "), // Reserved
|
|
1066
|
+
bankRouting.padStart(8, "0").slice(0, 8), // Originating DFI Identification
|
|
1067
|
+
"0000001", // Batch Number
|
|
1068
|
+
].join("");
|
|
1069
|
+
lines.push(bcr.padEnd(94, " "));
|
|
1070
|
+
|
|
1071
|
+
// File Control Record (type 9)
|
|
1072
|
+
const blockCount = Math.ceil((lines.length + 1) / 10);
|
|
1073
|
+
const fcr = [
|
|
1074
|
+
"9", // Record Type Code
|
|
1075
|
+
"000001", // Batch Count
|
|
1076
|
+
blockCount.toString().padStart(6, "0"), // Block Count
|
|
1077
|
+
entryCount.toString().padStart(8, "0"), // Entry/Addenda Count
|
|
1078
|
+
(entryHash % 10000000000).toString().padStart(10, "0"), // Entry Hash
|
|
1079
|
+
totalDebit.toString().padStart(12, "0"), // Total Debit
|
|
1080
|
+
totalCredit.toString().padStart(12, "0"), // Total Credit
|
|
1081
|
+
"".padEnd(39, " "), // Reserved
|
|
1082
|
+
].join("");
|
|
1083
|
+
lines.push(fcr.padEnd(94, " "));
|
|
1084
|
+
|
|
1085
|
+
// Pad to block of 10
|
|
1086
|
+
while (lines.length % 10 !== 0) {
|
|
1087
|
+
lines.push("9".repeat(94));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return lines.join("\n");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// --- W-2 Generation ---
|
|
1094
|
+
|
|
1095
|
+
export interface W2Data {
|
|
1096
|
+
employee_id: string;
|
|
1097
|
+
employee_name: string;
|
|
1098
|
+
year: number;
|
|
1099
|
+
gross: number;
|
|
1100
|
+
federal_withheld: number;
|
|
1101
|
+
state_withheld: number;
|
|
1102
|
+
social_security: number;
|
|
1103
|
+
medicare: number;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Generate W-2 data for an employee for a given year.
|
|
1108
|
+
* Sums all pay stubs for the year.
|
|
1109
|
+
*/
|
|
1110
|
+
export function generateW2(employeeId: string, year: number): W2Data | null {
|
|
1111
|
+
const db = getDatabase();
|
|
1112
|
+
const employee = getEmployee(employeeId);
|
|
1113
|
+
if (!employee) return null;
|
|
1114
|
+
|
|
1115
|
+
// Only W-2 employees (not contractors)
|
|
1116
|
+
if (employee.type === "contractor") return null;
|
|
1117
|
+
|
|
1118
|
+
const rows = db.prepare(
|
|
1119
|
+
`SELECT ps.* FROM pay_stubs ps
|
|
1120
|
+
JOIN pay_periods pp ON ps.pay_period_id = pp.id
|
|
1121
|
+
WHERE ps.employee_id = ?
|
|
1122
|
+
AND pp.start_date >= ? AND pp.end_date <= ?`
|
|
1123
|
+
).all(
|
|
1124
|
+
employeeId,
|
|
1125
|
+
`${year}-01-01`,
|
|
1126
|
+
`${year}-12-31`
|
|
1127
|
+
) as PayStubRow[];
|
|
1128
|
+
|
|
1129
|
+
const stubs = rows.map(rowToPayStub);
|
|
1130
|
+
if (stubs.length === 0) return null;
|
|
1131
|
+
|
|
1132
|
+
let gross = 0;
|
|
1133
|
+
let federalWithheld = 0;
|
|
1134
|
+
let stateWithheld = 0;
|
|
1135
|
+
let socialSecurity = 0;
|
|
1136
|
+
let medicare = 0;
|
|
1137
|
+
|
|
1138
|
+
for (const stub of stubs) {
|
|
1139
|
+
gross += stub.gross_pay;
|
|
1140
|
+
federalWithheld += stub.deductions.federal_tax || 0;
|
|
1141
|
+
stateWithheld += stub.deductions.state_tax || 0;
|
|
1142
|
+
socialSecurity += stub.deductions.social_security || 0;
|
|
1143
|
+
medicare += stub.deductions.medicare || 0;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
return {
|
|
1147
|
+
employee_id: employee.id,
|
|
1148
|
+
employee_name: employee.name,
|
|
1149
|
+
year,
|
|
1150
|
+
gross: Math.round(gross * 100) / 100,
|
|
1151
|
+
federal_withheld: Math.round(federalWithheld * 100) / 100,
|
|
1152
|
+
state_withheld: Math.round(stateWithheld * 100) / 100,
|
|
1153
|
+
social_security: Math.round(socialSecurity * 100) / 100,
|
|
1154
|
+
medicare: Math.round(medicare * 100) / 100,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// --- 1099-NEC Generation ---
|
|
1159
|
+
|
|
1160
|
+
export interface Form1099Data {
|
|
1161
|
+
employee_id: string;
|
|
1162
|
+
employee_name: string;
|
|
1163
|
+
year: number;
|
|
1164
|
+
total_compensation: number;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Generate 1099-NEC data for contractors with total compensation > $600.
|
|
1169
|
+
* Returns data for a single contractor, or all eligible contractors if no employeeId given.
|
|
1170
|
+
*/
|
|
1171
|
+
export function generate1099(employeeId: string | null, year: number): Form1099Data[] {
|
|
1172
|
+
const db = getDatabase();
|
|
1173
|
+
const contractors = employeeId
|
|
1174
|
+
? listEmployees({ type: "contractor" }).filter((e) => e.id === employeeId)
|
|
1175
|
+
: listEmployees({ type: "contractor" });
|
|
1176
|
+
|
|
1177
|
+
const results: Form1099Data[] = [];
|
|
1178
|
+
|
|
1179
|
+
for (const contractor of contractors) {
|
|
1180
|
+
const rows = db.prepare(
|
|
1181
|
+
`SELECT ps.* FROM pay_stubs ps
|
|
1182
|
+
JOIN pay_periods pp ON ps.pay_period_id = pp.id
|
|
1183
|
+
WHERE ps.employee_id = ?
|
|
1184
|
+
AND pp.start_date >= ? AND pp.end_date <= ?`
|
|
1185
|
+
).all(
|
|
1186
|
+
contractor.id,
|
|
1187
|
+
`${year}-01-01`,
|
|
1188
|
+
`${year}-12-31`
|
|
1189
|
+
) as PayStubRow[];
|
|
1190
|
+
|
|
1191
|
+
const stubs = rows.map(rowToPayStub);
|
|
1192
|
+
const total = stubs.reduce((sum, s) => sum + s.gross_pay, 0);
|
|
1193
|
+
|
|
1194
|
+
if (total > 600) {
|
|
1195
|
+
results.push({
|
|
1196
|
+
employee_id: contractor.id,
|
|
1197
|
+
employee_name: contractor.name,
|
|
1198
|
+
year,
|
|
1199
|
+
total_compensation: Math.round(total * 100) / 100,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return results;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// --- Audit Report ---
|
|
1208
|
+
|
|
1209
|
+
export interface AuditResult {
|
|
1210
|
+
period_id: string;
|
|
1211
|
+
issues: string[];
|
|
1212
|
+
passed: boolean;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Audit a payroll period for common issues:
|
|
1217
|
+
* - All active employees have stubs
|
|
1218
|
+
* - net_pay > 0 for all stubs
|
|
1219
|
+
* - Deductions sum correctly (gross - deductions = net)
|
|
1220
|
+
* - No duplicate stubs per employee
|
|
1221
|
+
*/
|
|
1222
|
+
export function auditPayroll(periodId: string): AuditResult {
|
|
1223
|
+
const period = getPayPeriod(periodId);
|
|
1224
|
+
if (!period) throw new Error(`Pay period '${periodId}' not found`);
|
|
1225
|
+
|
|
1226
|
+
const issues: string[] = [];
|
|
1227
|
+
const stubs = listPayStubs({ pay_period_id: periodId });
|
|
1228
|
+
const activeEmployees = listEmployees({ status: "active" });
|
|
1229
|
+
|
|
1230
|
+
// Check: all active employees have stubs
|
|
1231
|
+
if (period.status === "completed") {
|
|
1232
|
+
for (const emp of activeEmployees) {
|
|
1233
|
+
const hasStub = stubs.some((s) => s.employee_id === emp.id);
|
|
1234
|
+
if (!hasStub) {
|
|
1235
|
+
issues.push(`Active employee '${emp.name}' (${emp.id}) missing pay stub`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Check: net_pay > 0
|
|
1241
|
+
for (const stub of stubs) {
|
|
1242
|
+
if (stub.net_pay <= 0) {
|
|
1243
|
+
issues.push(`Pay stub ${stub.id} has non-positive net_pay: $${stub.net_pay}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Check: deductions sum correctly (gross - sum(deductions) = net, within $0.02 tolerance)
|
|
1248
|
+
for (const stub of stubs) {
|
|
1249
|
+
const deductionsTotal = Object.values(stub.deductions).reduce((sum, d) => sum + d, 0);
|
|
1250
|
+
const expectedNet = Math.round((stub.gross_pay - deductionsTotal) * 100) / 100;
|
|
1251
|
+
const diff = Math.abs(expectedNet - stub.net_pay);
|
|
1252
|
+
if (diff > 0.02) {
|
|
1253
|
+
issues.push(
|
|
1254
|
+
`Pay stub ${stub.id}: deduction mismatch — gross=$${stub.gross_pay}, deductions=$${deductionsTotal.toFixed(2)}, expected_net=$${expectedNet}, actual_net=$${stub.net_pay}`
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Check: no duplicate stubs per employee
|
|
1260
|
+
const empStubCount = new Map<string, number>();
|
|
1261
|
+
for (const stub of stubs) {
|
|
1262
|
+
empStubCount.set(stub.employee_id, (empStubCount.get(stub.employee_id) || 0) + 1);
|
|
1263
|
+
}
|
|
1264
|
+
for (const [empId, count] of empStubCount) {
|
|
1265
|
+
if (count > 1) {
|
|
1266
|
+
issues.push(`Employee ${empId} has ${count} duplicate stubs in this period`);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
return {
|
|
1271
|
+
period_id: periodId,
|
|
1272
|
+
issues,
|
|
1273
|
+
passed: issues.length === 0,
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// --- Cost Forecast ---
|
|
1278
|
+
|
|
1279
|
+
export interface ForecastResult {
|
|
1280
|
+
months: number;
|
|
1281
|
+
periods: { month: string; estimated_gross: number; estimated_deductions: number; estimated_net: number }[];
|
|
1282
|
+
total_estimated_gross: number;
|
|
1283
|
+
total_estimated_deductions: number;
|
|
1284
|
+
total_estimated_net: number;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Forecast future payroll costs based on current active employees.
|
|
1289
|
+
* Assumes semi-monthly pay periods (2 per month).
|
|
1290
|
+
*/
|
|
1291
|
+
export function forecastPayroll(months: number): ForecastResult {
|
|
1292
|
+
const activeEmployees = listEmployees({ status: "active" });
|
|
1293
|
+
|
|
1294
|
+
let monthlyGross = 0;
|
|
1295
|
+
let monthlyDeductions = 0;
|
|
1296
|
+
|
|
1297
|
+
for (const emp of activeEmployees) {
|
|
1298
|
+
// Calculate per-period gross
|
|
1299
|
+
const periodGross = calculateGrossPay(emp);
|
|
1300
|
+
const deductions = calculateDeductions(periodGross, emp.type);
|
|
1301
|
+
const benefitDeds = getBenefitDeductions(emp.id);
|
|
1302
|
+
const totalDeds = Object.values(deductions).reduce((s, d) => s + d, 0)
|
|
1303
|
+
+ Object.values(benefitDeds).reduce((s, d) => s + d, 0);
|
|
1304
|
+
|
|
1305
|
+
// 2 periods per month (semi-monthly)
|
|
1306
|
+
monthlyGross += periodGross * 2;
|
|
1307
|
+
monthlyDeductions += totalDeds * 2;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
monthlyGross = Math.round(monthlyGross * 100) / 100;
|
|
1311
|
+
monthlyDeductions = Math.round(monthlyDeductions * 100) / 100;
|
|
1312
|
+
const monthlyNet = Math.round((monthlyGross - monthlyDeductions) * 100) / 100;
|
|
1313
|
+
|
|
1314
|
+
const periods: ForecastResult["periods"] = [];
|
|
1315
|
+
const now = new Date();
|
|
1316
|
+
|
|
1317
|
+
for (let i = 0; i < months; i++) {
|
|
1318
|
+
const d = new Date(now.getFullYear(), now.getMonth() + i + 1, 1);
|
|
1319
|
+
const monthStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
1320
|
+
periods.push({
|
|
1321
|
+
month: monthStr,
|
|
1322
|
+
estimated_gross: monthlyGross,
|
|
1323
|
+
estimated_deductions: monthlyDeductions,
|
|
1324
|
+
estimated_net: monthlyNet,
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
return {
|
|
1329
|
+
months,
|
|
1330
|
+
periods,
|
|
1331
|
+
total_estimated_gross: Math.round(monthlyGross * months * 100) / 100,
|
|
1332
|
+
total_estimated_deductions: Math.round(monthlyDeductions * months * 100) / 100,
|
|
1333
|
+
total_estimated_net: Math.round(monthlyNet * months * 100) / 100,
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// --- Overtime Alerts ---
|
|
1338
|
+
|
|
1339
|
+
export interface OvertimeAlert {
|
|
1340
|
+
employee_id: string;
|
|
1341
|
+
employee_name: string;
|
|
1342
|
+
total_hours: number;
|
|
1343
|
+
overtime_hours: number;
|
|
1344
|
+
threshold: number;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Check all pay stubs in the most recent period(s) for employees exceeding a weekly hours threshold.
|
|
1349
|
+
* Looks at hours_worked on stubs, flags those above threshold.
|
|
1350
|
+
*/
|
|
1351
|
+
export function checkOvertime(threshold: number = 40): OvertimeAlert[] {
|
|
1352
|
+
const db = getDatabase();
|
|
1353
|
+
// Get the most recent completed period
|
|
1354
|
+
const periods = listPayPeriods("completed");
|
|
1355
|
+
if (periods.length === 0) return [];
|
|
1356
|
+
|
|
1357
|
+
const latestPeriod = periods[0];
|
|
1358
|
+
const stubs = listPayStubs({ pay_period_id: latestPeriod.id });
|
|
1359
|
+
|
|
1360
|
+
const alerts: OvertimeAlert[] = [];
|
|
1361
|
+
|
|
1362
|
+
for (const stub of stubs) {
|
|
1363
|
+
const totalHours = (stub.hours_worked || 0) + stub.overtime_hours;
|
|
1364
|
+
if (totalHours > threshold) {
|
|
1365
|
+
const emp = getEmployee(stub.employee_id);
|
|
1366
|
+
alerts.push({
|
|
1367
|
+
employee_id: stub.employee_id,
|
|
1368
|
+
employee_name: emp?.name || "Unknown",
|
|
1369
|
+
total_hours: totalHours,
|
|
1370
|
+
overtime_hours: Math.max(0, totalHours - threshold),
|
|
1371
|
+
threshold,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return alerts;
|
|
1377
|
+
}
|