@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.
- package/bin/index.js +9 -1
- package/bin/mcp.js +9 -1
- package/dist/index.js +9 -1
- package/microservices/microservice-ads/src/cli/index.ts +198 -0
- package/microservices/microservice-ads/src/db/campaigns.ts +304 -0
- package/microservices/microservice-ads/src/mcp/index.ts +160 -0
- package/microservices/microservice-company/package.json +27 -0
- package/microservices/microservice-company/src/cli/index.ts +1126 -0
- package/microservices/microservice-company/src/db/company.ts +854 -0
- package/microservices/microservice-company/src/db/database.ts +93 -0
- package/microservices/microservice-company/src/db/migrations.ts +214 -0
- package/microservices/microservice-company/src/db/workflow-migrations.ts +44 -0
- package/microservices/microservice-company/src/index.ts +60 -0
- package/microservices/microservice-company/src/lib/audit.ts +168 -0
- package/microservices/microservice-company/src/lib/finance.ts +299 -0
- package/microservices/microservice-company/src/lib/settings.ts +85 -0
- package/microservices/microservice-company/src/lib/workflows.ts +698 -0
- package/microservices/microservice-company/src/mcp/index.ts +991 -0
- package/microservices/microservice-contracts/src/cli/index.ts +410 -23
- package/microservices/microservice-contracts/src/db/contracts.ts +430 -1
- package/microservices/microservice-contracts/src/db/migrations.ts +83 -0
- package/microservices/microservice-contracts/src/mcp/index.ts +312 -3
- package/microservices/microservice-domains/src/cli/index.ts +673 -0
- package/microservices/microservice-domains/src/db/domains.ts +613 -0
- package/microservices/microservice-domains/src/index.ts +21 -0
- package/microservices/microservice-domains/src/lib/brandsight.ts +285 -0
- package/microservices/microservice-domains/src/lib/godaddy.ts +328 -0
- package/microservices/microservice-domains/src/lib/namecheap.ts +474 -0
- package/microservices/microservice-domains/src/lib/registrar.ts +355 -0
- package/microservices/microservice-domains/src/mcp/index.ts +413 -0
- package/microservices/microservice-hiring/src/cli/index.ts +318 -8
- package/microservices/microservice-hiring/src/db/hiring.ts +503 -0
- package/microservices/microservice-hiring/src/db/migrations.ts +21 -0
- package/microservices/microservice-hiring/src/index.ts +29 -0
- package/microservices/microservice-hiring/src/lib/scoring.ts +206 -0
- package/microservices/microservice-hiring/src/mcp/index.ts +245 -0
- package/microservices/microservice-payments/src/cli/index.ts +255 -3
- package/microservices/microservice-payments/src/db/migrations.ts +18 -0
- package/microservices/microservice-payments/src/db/payments.ts +552 -0
- package/microservices/microservice-payments/src/mcp/index.ts +223 -0
- package/microservices/microservice-payroll/src/cli/index.ts +269 -0
- package/microservices/microservice-payroll/src/db/migrations.ts +26 -0
- package/microservices/microservice-payroll/src/db/payroll.ts +636 -0
- package/microservices/microservice-payroll/src/mcp/index.ts +246 -0
- package/microservices/microservice-shipping/src/cli/index.ts +211 -3
- package/microservices/microservice-shipping/src/db/migrations.ts +8 -0
- package/microservices/microservice-shipping/src/db/shipping.ts +453 -3
- package/microservices/microservice-shipping/src/mcp/index.ts +149 -1
- package/microservices/microservice-social/src/cli/index.ts +244 -2
- package/microservices/microservice-social/src/db/migrations.ts +33 -0
- package/microservices/microservice-social/src/db/social.ts +378 -4
- package/microservices/microservice-social/src/mcp/index.ts +221 -1
- package/microservices/microservice-subscriptions/src/cli/index.ts +315 -0
- package/microservices/microservice-subscriptions/src/db/migrations.ts +68 -0
- package/microservices/microservice-subscriptions/src/db/subscriptions.ts +567 -3
- package/microservices/microservice-subscriptions/src/mcp/index.ts +267 -1
- 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
|
+
}
|