@hasna/microservices 0.0.4 → 0.0.6

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 (57) hide show
  1. package/bin/index.js +9 -1
  2. package/bin/mcp.js +9 -1
  3. package/dist/index.js +9 -1
  4. package/microservices/microservice-ads/src/cli/index.ts +198 -0
  5. package/microservices/microservice-ads/src/db/campaigns.ts +304 -0
  6. package/microservices/microservice-ads/src/mcp/index.ts +160 -0
  7. package/microservices/microservice-company/package.json +27 -0
  8. package/microservices/microservice-company/src/cli/index.ts +1126 -0
  9. package/microservices/microservice-company/src/db/company.ts +854 -0
  10. package/microservices/microservice-company/src/db/database.ts +93 -0
  11. package/microservices/microservice-company/src/db/migrations.ts +214 -0
  12. package/microservices/microservice-company/src/db/workflow-migrations.ts +44 -0
  13. package/microservices/microservice-company/src/index.ts +60 -0
  14. package/microservices/microservice-company/src/lib/audit.ts +168 -0
  15. package/microservices/microservice-company/src/lib/finance.ts +299 -0
  16. package/microservices/microservice-company/src/lib/settings.ts +85 -0
  17. package/microservices/microservice-company/src/lib/workflows.ts +698 -0
  18. package/microservices/microservice-company/src/mcp/index.ts +991 -0
  19. package/microservices/microservice-contracts/src/cli/index.ts +410 -23
  20. package/microservices/microservice-contracts/src/db/contracts.ts +430 -1
  21. package/microservices/microservice-contracts/src/db/migrations.ts +83 -0
  22. package/microservices/microservice-contracts/src/mcp/index.ts +312 -3
  23. package/microservices/microservice-domains/src/cli/index.ts +673 -0
  24. package/microservices/microservice-domains/src/db/domains.ts +613 -0
  25. package/microservices/microservice-domains/src/index.ts +21 -0
  26. package/microservices/microservice-domains/src/lib/brandsight.ts +285 -0
  27. package/microservices/microservice-domains/src/lib/godaddy.ts +328 -0
  28. package/microservices/microservice-domains/src/lib/namecheap.ts +474 -0
  29. package/microservices/microservice-domains/src/lib/registrar.ts +355 -0
  30. package/microservices/microservice-domains/src/mcp/index.ts +413 -0
  31. package/microservices/microservice-hiring/src/cli/index.ts +318 -8
  32. package/microservices/microservice-hiring/src/db/hiring.ts +503 -0
  33. package/microservices/microservice-hiring/src/db/migrations.ts +21 -0
  34. package/microservices/microservice-hiring/src/index.ts +29 -0
  35. package/microservices/microservice-hiring/src/lib/scoring.ts +206 -0
  36. package/microservices/microservice-hiring/src/mcp/index.ts +245 -0
  37. package/microservices/microservice-payments/src/cli/index.ts +255 -3
  38. package/microservices/microservice-payments/src/db/migrations.ts +18 -0
  39. package/microservices/microservice-payments/src/db/payments.ts +552 -0
  40. package/microservices/microservice-payments/src/mcp/index.ts +223 -0
  41. package/microservices/microservice-payroll/src/cli/index.ts +269 -0
  42. package/microservices/microservice-payroll/src/db/migrations.ts +26 -0
  43. package/microservices/microservice-payroll/src/db/payroll.ts +636 -0
  44. package/microservices/microservice-payroll/src/mcp/index.ts +246 -0
  45. package/microservices/microservice-shipping/src/cli/index.ts +211 -3
  46. package/microservices/microservice-shipping/src/db/migrations.ts +8 -0
  47. package/microservices/microservice-shipping/src/db/shipping.ts +453 -3
  48. package/microservices/microservice-shipping/src/mcp/index.ts +149 -1
  49. package/microservices/microservice-social/src/cli/index.ts +244 -2
  50. package/microservices/microservice-social/src/db/migrations.ts +33 -0
  51. package/microservices/microservice-social/src/db/social.ts +378 -4
  52. package/microservices/microservice-social/src/mcp/index.ts +221 -1
  53. package/microservices/microservice-subscriptions/src/cli/index.ts +315 -0
  54. package/microservices/microservice-subscriptions/src/db/migrations.ts +68 -0
  55. package/microservices/microservice-subscriptions/src/db/subscriptions.ts +567 -3
  56. package/microservices/microservice-subscriptions/src/mcp/index.ts +267 -1
  57. package/package.json +1 -1
@@ -461,6 +461,217 @@ export function deletePayment(id: string): boolean {
461
461
  return result.changes > 0;
462
462
  }
463
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
+
464
675
  // --- Business Logic ---
465
676
 
466
677
  /**
@@ -555,6 +766,13 @@ export function processPayroll(
555
766
 
556
767
  const grossPay = calculateGrossPay(emp, hoursWorked, overtimeHours);
557
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
+
558
776
  const totalDeductions = Object.values(deductions).reduce((sum, d) => sum + d, 0);
559
777
  const netPay = Math.round((grossPay - totalDeductions) * 100) / 100;
560
778
 
@@ -739,3 +957,421 @@ export function getTaxSummary(year: number): TaxSummaryEntry[] {
739
957
 
740
958
  return entries;
741
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
+ }