@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.
Files changed (68) hide show
  1. package/bin/index.js +63 -0
  2. package/bin/mcp.js +63 -0
  3. package/dist/index.js +63 -0
  4. package/microservices/microservice-ads/package.json +27 -0
  5. package/microservices/microservice-ads/src/cli/index.ts +605 -0
  6. package/microservices/microservice-ads/src/db/campaigns.ts +797 -0
  7. package/microservices/microservice-ads/src/db/database.ts +93 -0
  8. package/microservices/microservice-ads/src/db/migrations.ts +60 -0
  9. package/microservices/microservice-ads/src/index.ts +39 -0
  10. package/microservices/microservice-ads/src/mcp/index.ts +480 -0
  11. package/microservices/microservice-contracts/package.json +27 -0
  12. package/microservices/microservice-contracts/src/cli/index.ts +770 -0
  13. package/microservices/microservice-contracts/src/db/contracts.ts +925 -0
  14. package/microservices/microservice-contracts/src/db/database.ts +93 -0
  15. package/microservices/microservice-contracts/src/db/migrations.ts +141 -0
  16. package/microservices/microservice-contracts/src/index.ts +43 -0
  17. package/microservices/microservice-contracts/src/mcp/index.ts +617 -0
  18. package/microservices/microservice-domains/package.json +27 -0
  19. package/microservices/microservice-domains/src/cli/index.ts +691 -0
  20. package/microservices/microservice-domains/src/db/database.ts +93 -0
  21. package/microservices/microservice-domains/src/db/domains.ts +1164 -0
  22. package/microservices/microservice-domains/src/db/migrations.ts +60 -0
  23. package/microservices/microservice-domains/src/index.ts +65 -0
  24. package/microservices/microservice-domains/src/mcp/index.ts +536 -0
  25. package/microservices/microservice-hiring/package.json +27 -0
  26. package/microservices/microservice-hiring/src/cli/index.ts +741 -0
  27. package/microservices/microservice-hiring/src/db/database.ts +93 -0
  28. package/microservices/microservice-hiring/src/db/hiring.ts +1085 -0
  29. package/microservices/microservice-hiring/src/db/migrations.ts +89 -0
  30. package/microservices/microservice-hiring/src/index.ts +80 -0
  31. package/microservices/microservice-hiring/src/lib/scoring.ts +206 -0
  32. package/microservices/microservice-hiring/src/mcp/index.ts +709 -0
  33. package/microservices/microservice-payments/package.json +27 -0
  34. package/microservices/microservice-payments/src/cli/index.ts +609 -0
  35. package/microservices/microservice-payments/src/db/database.ts +93 -0
  36. package/microservices/microservice-payments/src/db/migrations.ts +81 -0
  37. package/microservices/microservice-payments/src/db/payments.ts +1204 -0
  38. package/microservices/microservice-payments/src/index.ts +51 -0
  39. package/microservices/microservice-payments/src/mcp/index.ts +683 -0
  40. package/microservices/microservice-payroll/package.json +27 -0
  41. package/microservices/microservice-payroll/src/cli/index.ts +643 -0
  42. package/microservices/microservice-payroll/src/db/database.ts +93 -0
  43. package/microservices/microservice-payroll/src/db/migrations.ts +95 -0
  44. package/microservices/microservice-payroll/src/db/payroll.ts +1377 -0
  45. package/microservices/microservice-payroll/src/index.ts +48 -0
  46. package/microservices/microservice-payroll/src/mcp/index.ts +666 -0
  47. package/microservices/microservice-shipping/package.json +27 -0
  48. package/microservices/microservice-shipping/src/cli/index.ts +606 -0
  49. package/microservices/microservice-shipping/src/db/database.ts +93 -0
  50. package/microservices/microservice-shipping/src/db/migrations.ts +69 -0
  51. package/microservices/microservice-shipping/src/db/shipping.ts +1093 -0
  52. package/microservices/microservice-shipping/src/index.ts +53 -0
  53. package/microservices/microservice-shipping/src/mcp/index.ts +533 -0
  54. package/microservices/microservice-social/package.json +27 -0
  55. package/microservices/microservice-social/src/cli/index.ts +689 -0
  56. package/microservices/microservice-social/src/db/database.ts +93 -0
  57. package/microservices/microservice-social/src/db/migrations.ts +88 -0
  58. package/microservices/microservice-social/src/db/social.ts +1046 -0
  59. package/microservices/microservice-social/src/index.ts +46 -0
  60. package/microservices/microservice-social/src/mcp/index.ts +655 -0
  61. package/microservices/microservice-subscriptions/package.json +27 -0
  62. package/microservices/microservice-subscriptions/src/cli/index.ts +715 -0
  63. package/microservices/microservice-subscriptions/src/db/database.ts +93 -0
  64. package/microservices/microservice-subscriptions/src/db/migrations.ts +125 -0
  65. package/microservices/microservice-subscriptions/src/db/subscriptions.ts +1256 -0
  66. package/microservices/microservice-subscriptions/src/index.ts +41 -0
  67. package/microservices/microservice-subscriptions/src/mcp/index.ts +631 -0
  68. 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
+ }