mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 04:33:10 +00:00
chore(pay): remove weekly-post payroll email code, env, and failure alarm (#249)
PR #248 disabled the SES send by blanking PAYROLL_RECIPIENTS and dropping the ses:SendEmail grant. This removes the now-dead path: _send_pay_email and _build_pay_email_html, the SES_SENDER and PAYROLL_RECIPIENTS env, and the PayrollEmailFailure metric filter and alarm that only fired on that path. Slack schedule post, pay-summary DM, and checkcomponents enqueue unchanged.
This commit is contained in:
parent
2dbe99ef0b
commit
dbef3bda52
4 changed files with 9 additions and 240 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
"""Lambda handler — posts the weekly on-call schedule and previous week's pay summary
|
"""Lambda handler — posts the weekly on-call schedule and previous week's pay summary
|
||||||
to Slack every Monday at 7am ET, and emails the pay summary to payroll."""
|
to Slack every Monday at 7am ET, and enqueues after-hours dollars for Flex."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -164,92 +164,6 @@ def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_pay_email_html(week_label: str, pay_record: dict) -> str:
|
|
||||||
"""Build an HTML email body for the weekly pay summary.
|
|
||||||
|
|
||||||
Holiday shifts are rendered distinctly: a per-shift breakdown section
|
|
||||||
highlights holiday rows (shaded, showing the multiplier and label) and the
|
|
||||||
totals table flags any employee who worked a holiday during the week.
|
|
||||||
"""
|
|
||||||
breakdown = pay_record.get("breakdown", [])
|
|
||||||
totals = pay_record.get("totals", {})
|
|
||||||
|
|
||||||
holiday_names = {line["name"] for line in breakdown if line.get("is_holiday")}
|
|
||||||
|
|
||||||
totals_rows = ""
|
|
||||||
for name, info in sorted(totals.items()):
|
|
||||||
holiday_tag = (
|
|
||||||
' <span style="color:#b8860b;">★ holiday</span>'
|
|
||||||
if name in holiday_names
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
totals_rows += (
|
|
||||||
f"<tr><td>{name}{holiday_tag}</td>"
|
|
||||||
f"<td>${info.get('rate', 0):.2f}</td><td><strong>${info['total']:.2f}</strong></td></tr>\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
breakdown_rows = ""
|
|
||||||
for line in breakdown:
|
|
||||||
if line.get("is_holiday"):
|
|
||||||
mult = line.get("multiplier", Decimal("1"))
|
|
||||||
label = line.get("holiday_label", "") or "Holiday"
|
|
||||||
base = line.get("base_rate", line["rate"])
|
|
||||||
row_style = ' style="background:#fff8e1;"'
|
|
||||||
detail = f"{label} — ${base:.2f} × {mult:.2f}x"
|
|
||||||
else:
|
|
||||||
row_style = ""
|
|
||||||
detail = f"${line['rate']:.2f}"
|
|
||||||
breakdown_rows += (
|
|
||||||
f"<tr{row_style}><td>{line['date_label']}</td>"
|
|
||||||
f"<td>{line['day']}</td>"
|
|
||||||
f"<td>{line['name']}</td>"
|
|
||||||
f"<td>{detail}</td>"
|
|
||||||
f"<td><strong>${line['amount']:.2f}</strong></td></tr>\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"""<html>
|
|
||||||
<body style="font-family: Arial, sans-serif; color: #333;">
|
|
||||||
<h2>Bonus Pay Summary — {week_label}</h2>
|
|
||||||
|
|
||||||
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
|
|
||||||
<tr style="background: #f0f0f0;"><th>Name</th><th>Rate</th><th>Total</th></tr>
|
|
||||||
{totals_rows}</table>
|
|
||||||
|
|
||||||
<h3>Shift Breakdown</h3>
|
|
||||||
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
|
|
||||||
<tr style="background: #f0f0f0;"><th>Date</th><th>Day</th><th>Name</th><th>Detail</th><th>Amount</th></tr>
|
|
||||||
{breakdown_rows}</table>
|
|
||||||
<p style="color: #888; font-size: 12px;">Holiday shifts are shaded and paid at the listed multiplier.</p>
|
|
||||||
|
|
||||||
<p style="color: #888; font-size: 12px;">This is an automated report from Sea Haven Industries.</p>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _send_pay_email(week_label: str, pay_record: dict) -> None:
|
|
||||||
"""Send the weekly pay summary email via SES."""
|
|
||||||
sender = os.environ.get("SES_SENDER", "noreply@seahaven.com")
|
|
||||||
recipients = os.environ.get("PAYROLL_RECIPIENTS", "").split(",")
|
|
||||||
recipients = [r.strip() for r in recipients if r.strip()]
|
|
||||||
|
|
||||||
if not recipients:
|
|
||||||
logger.warning("No PAYROLL_RECIPIENTS configured — skipping email")
|
|
||||||
return
|
|
||||||
|
|
||||||
ses = boto3.client("ses")
|
|
||||||
html_body = _build_pay_email_html(week_label, pay_record)
|
|
||||||
|
|
||||||
ses.send_email(
|
|
||||||
Source=sender,
|
|
||||||
Destination={"ToAddresses": recipients},
|
|
||||||
Message={
|
|
||||||
"Subject": {"Data": f"Bonus Pay Summary — {week_label}"},
|
|
||||||
"Body": {"Html": {"Data": html_body}},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.info("Sent pay email to %s", recipients)
|
|
||||||
|
|
||||||
|
|
||||||
def build_checkcomponents_payload(pay_record: dict) -> dict:
|
def build_checkcomponents_payload(pay_record: dict) -> dict:
|
||||||
"""Sibling dollar lines only. No Flex OAuth, no payPeriodId invention."""
|
"""Sibling dollar lines only. No Flex OAuth, no payPeriodId invention."""
|
||||||
week_start = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
|
week_start = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
|
||||||
|
|
@ -343,17 +257,6 @@ def handler(event, context):
|
||||||
else:
|
else:
|
||||||
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
|
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
|
||||||
|
|
||||||
# Email pay summary to payroll. Isolated so a delivery failure (e.g.
|
|
||||||
# an SES permission/identity issue) can never abort the rest of the
|
|
||||||
# handler — the Slack schedule post below must still go out.
|
|
||||||
try:
|
|
||||||
_send_pay_email(week_label, pay_record)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to send pay summary email to payroll for week of %s",
|
|
||||||
week_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Two-week schedule (always starts on Monday of this week) ---
|
# --- Two-week schedule (always starts on Monday of this week) ---
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
this_monday = now - timedelta(days=now.weekday())
|
||||||
week_start = this_monday.strftime("%Y-%m-%d")
|
week_start = this_monday.strftime("%Y-%m-%d")
|
||||||
|
|
|
||||||
|
|
@ -165,8 +165,6 @@ Resources:
|
||||||
SHIFT_TABLE: !Ref ShiftTable
|
SHIFT_TABLE: !Ref ShiftTable
|
||||||
SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token
|
SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token
|
||||||
SHIFT_CHANNEL: !Ref ShiftChannel
|
SHIFT_CHANNEL: !Ref ShiftChannel
|
||||||
SES_SENDER: noreply@seahaven.com
|
|
||||||
PAYROLL_RECIPIENTS: ""
|
|
||||||
PAY_REPORT_USER: U0A3SC48T47
|
PAY_REPORT_USER: U0A3SC48T47
|
||||||
TZ: !Ref Timezone
|
TZ: !Ref Timezone
|
||||||
CHECKCOMPONENTS_QUEUE_URL: !Ref CheckcomponentsQueueUrl
|
CHECKCOMPONENTS_QUEUE_URL: !Ref CheckcomponentsQueueUrl
|
||||||
|
|
@ -600,44 +598,6 @@ Resources:
|
||||||
AlarmActions:
|
AlarmActions:
|
||||||
- !Sub "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts"
|
- !Sub "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts"
|
||||||
|
|
||||||
# --- Payroll email delivery failure (log-metric alarm) ---
|
|
||||||
# The pay-summary email send is wrapped in try/except so a delivery failure
|
|
||||||
# never aborts the schedule post — which means it does NOT surface on the
|
|
||||||
# Lambda Errors metric above (the handler exits "success"). This filter turns
|
|
||||||
# the "Failed to send pay summary" log line into a metric so a silent payroll
|
|
||||||
# delivery failure still pages site-alerts. DefaultValue 0 keeps the metric
|
|
||||||
# reporting on every run so the alarm sits in OK, not INSUFFICIENT_DATA.
|
|
||||||
PayrollEmailFailureMetricFilter:
|
|
||||||
Type: AWS::Logs::MetricFilter
|
|
||||||
Properties:
|
|
||||||
# !Ref the stack-managed log group (not a literal name) so CloudFormation
|
|
||||||
# orders this filter after the group exists.
|
|
||||||
LogGroupName: !Ref WeeklyPostLogGroup
|
|
||||||
FilterPattern: '"Failed to send pay summary"'
|
|
||||||
MetricTransformations:
|
|
||||||
- MetricName: PayrollEmailFailures
|
|
||||||
MetricNamespace: AfterHours/WeeklyPost
|
|
||||||
MetricValue: "1"
|
|
||||||
# DefaultValue 0 means non-matching runs emit 0, so the metric stays
|
|
||||||
# populated and the alarm rests in OK rather than INSUFFICIENT_DATA.
|
|
||||||
DefaultValue: 0
|
|
||||||
|
|
||||||
PayrollEmailFailureAlarm:
|
|
||||||
Type: AWS::CloudWatch::Alarm
|
|
||||||
Properties:
|
|
||||||
AlarmName: PayrollEmailFailure-afterhours-weekly-post
|
|
||||||
AlarmDescription: "Weekly-post failed to email the pay summary to payroll (SES send error)"
|
|
||||||
Namespace: AfterHours/WeeklyPost
|
|
||||||
MetricName: PayrollEmailFailures
|
|
||||||
Statistic: Sum
|
|
||||||
Period: 300
|
|
||||||
EvaluationPeriods: 1
|
|
||||||
Threshold: 1
|
|
||||||
ComparisonOperator: GreaterThanOrEqualToThreshold
|
|
||||||
TreatMissingData: notBreaching
|
|
||||||
AlarmActions:
|
|
||||||
- !Sub "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts"
|
|
||||||
|
|
||||||
# --- Lambda Duration alarms (Maximum, ms; 2-of-3 evaluation) ---
|
# --- Lambda Duration alarms (Maximum, ms; 2-of-3 evaluation) ---
|
||||||
# Thresholds set to ~80% of each function's timeout, with 2-of-3 evaluation.
|
# Thresholds set to ~80% of each function's timeout, with 2-of-3 evaluation.
|
||||||
# Timeouts: slack-bot/weekly-post/release-notifier/roster-api = 30s (global
|
# Timeouts: slack-bot/weekly-post/release-notifier/roster-api = 30s (global
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ def env(monkeypatch):
|
||||||
"SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token"
|
"SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token"
|
||||||
)
|
)
|
||||||
monkeypatch.setenv("PAY_REPORT_USER", "U_BOSS")
|
monkeypatch.setenv("PAY_REPORT_USER", "U_BOSS")
|
||||||
monkeypatch.delenv("PAYROLL_RECIPIENTS", raising=False) # skip SES email
|
|
||||||
monkeypatch.setenv(
|
monkeypatch.setenv(
|
||||||
"CHECKCOMPONENTS_QUEUE_URL",
|
"CHECKCOMPONENTS_QUEUE_URL",
|
||||||
"https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents",
|
"https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents",
|
||||||
|
|
@ -89,27 +88,6 @@ def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env, sqs)
|
||||||
assert schedule.get_pay_record("2026-06-01")["checkcomponents_sent"] is True
|
assert schedule.get_pay_record("2026-06-01")["checkcomponents_sent"] is True
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(MON_0800)
|
|
||||||
def test_pay_email_failure_does_not_block_schedule_post(
|
|
||||||
weeklypost_app, schedule, seed, slack, env, monkeypatch, sqs
|
|
||||||
):
|
|
||||||
# Previous week has an assigned shift, so the pay/email path runs.
|
|
||||||
seed.config(shift_rate="50")
|
|
||||||
seed.weekly("Monday", "114", "Alice")
|
|
||||||
# SES delivery blows up (e.g. a permission/identity issue).
|
|
||||||
monkeypatch.setattr(
|
|
||||||
weeklypost_app,
|
|
||||||
"_send_pay_email",
|
|
||||||
MagicMock(side_effect=Exception("SES AccessDenied")),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = weeklypost_app.handler({"force": True}, None)
|
|
||||||
|
|
||||||
# The email failure is swallowed; the schedule post still goes out.
|
|
||||||
assert result["posted"] is True
|
|
||||||
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(MON_0800)
|
@freeze_time(MON_0800)
|
||||||
def test_rolls_existing_post_forward(weeklypost_app, schedule, seed, slack, env, sqs):
|
def test_rolls_existing_post_forward(weeklypost_app, schedule, seed, slack, env, sqs):
|
||||||
# An existing post is deleted + reposted so it lands at the bottom every
|
# An existing post is deleted + reposted so it lands at the bottom every
|
||||||
|
|
@ -256,22 +234,11 @@ def test_payload_skips_fallback_and_zero(weeklypost_app):
|
||||||
assert "payPeriodId" not in payload
|
assert "payPeriodId" not in payload
|
||||||
|
|
||||||
|
|
||||||
def test_send_pay_email_skips_when_recipients_empty(weeklypost_app, monkeypatch):
|
def test_weekly_post_has_no_payroll_email_path():
|
||||||
monkeypatch.setenv("PAYROLL_RECIPIENTS", "")
|
root = Path(__file__).resolve().parents[2]
|
||||||
ses = MagicMock(name="ses")
|
template = (root / "template.yaml").read_text()
|
||||||
|
for token in ("PAYROLL_RECIPIENTS", "SES_SENDER", "ses:", "PayrollEmailFailure"):
|
||||||
def client(svc, **kw):
|
assert token not in template, token
|
||||||
if svc == "ses":
|
source = (root / "src" / "weekly-post" / "app.py").read_text()
|
||||||
return ses
|
for token in ("_send_pay_email", "_build_pay_email_html", 'boto3.client("ses")'):
|
||||||
return MagicMock(name=svc)
|
assert token not in source, token
|
||||||
|
|
||||||
monkeypatch.setattr(weeklypost_app.boto3, "client", client)
|
|
||||||
weeklypost_app._send_pay_email("Jun 1 to Jun 7", {"totals": {}, "breakdown": []})
|
|
||||||
ses.send_email.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_weekly_post_payroll_recipients_empty_and_no_ses_grant():
|
|
||||||
text = (Path(__file__).resolve().parents[2] / "template.yaml").read_text()
|
|
||||||
assert 'PAYROLL_RECIPIENTS: ""' in text
|
|
||||||
assert "PAYROLL_RECIPIENTS: payroll@seahaven.com" not in text
|
|
||||||
assert "ses:SendEmail" not in text
|
|
||||||
|
|
|
||||||
|
|
@ -207,64 +207,3 @@ class TestHolidayPay:
|
||||||
assert breakdown[0]["name"] == "Alice"
|
assert breakdown[0]["name"] == "Alice"
|
||||||
assert breakdown[0].get("is_holiday") is None
|
assert breakdown[0].get("is_holiday") is None
|
||||||
assert breakdown[0]["amount"] == Decimal("50")
|
assert breakdown[0]["amount"] == Decimal("50")
|
||||||
|
|
||||||
|
|
||||||
class TestBuildPayEmailHtml:
|
|
||||||
def test_renders_totals_rows(self, weeklypost_app):
|
|
||||||
pay_record = {
|
|
||||||
"totals": {
|
|
||||||
"Alice": {"total": Decimal("100"), "rate": Decimal("50"), "shifts": 2}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
html = weeklypost_app._build_pay_email_html("Jun 1 to Jun 7", pay_record)
|
|
||||||
assert "Jun 1 to Jun 7" in html
|
|
||||||
assert "Alice" in html
|
|
||||||
assert "$100.00" in html
|
|
||||||
|
|
||||||
def test_holiday_rows_rendered_distinctly(self, weeklypost_app):
|
|
||||||
pay_record = {
|
|
||||||
"totals": {
|
|
||||||
"Alice": {
|
|
||||||
"total": Decimal("75"),
|
|
||||||
"rate": Decimal("50"),
|
|
||||||
"shifts": 1,
|
|
||||||
"holiday_shifts": 1,
|
|
||||||
},
|
|
||||||
"Bob": {
|
|
||||||
"total": Decimal("50"),
|
|
||||||
"rate": Decimal("50"),
|
|
||||||
"shifts": 1,
|
|
||||||
"holiday_shifts": 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"breakdown": [
|
|
||||||
{
|
|
||||||
"date_label": "Jun 3",
|
|
||||||
"day": "Wed (Day)",
|
|
||||||
"name": "Alice",
|
|
||||||
"rate": Decimal("75"),
|
|
||||||
"base_rate": Decimal("50"),
|
|
||||||
"amount": Decimal("75"),
|
|
||||||
"is_holiday": True,
|
|
||||||
"multiplier": Decimal("1.5"),
|
|
||||||
"holiday_label": "Founders Day",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date_label": "Jun 6",
|
|
||||||
"day": "Sat (Day)",
|
|
||||||
"name": "Bob",
|
|
||||||
"rate": Decimal("50"),
|
|
||||||
"base_rate": Decimal("50"),
|
|
||||||
"amount": Decimal("50"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
html = weeklypost_app._build_pay_email_html("Jun 1 to Jun 7", pay_record)
|
|
||||||
# Holiday label, multiplier, and shading appear for the holiday row.
|
|
||||||
assert "Founders Day" in html
|
|
||||||
assert "1.50x" in html
|
|
||||||
assert "background:#fff8e1" in html
|
|
||||||
# Holiday worker is flagged in the totals table; non-holiday isn't.
|
|
||||||
assert "holiday" in html
|
|
||||||
assert "$75.00" in html
|
|
||||||
assert "$50.00" in html
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue