mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 05:33:12 +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
|
||||
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 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:
|
||||
"""Sibling dollar lines only. No Flex OAuth, no payPeriodId invention."""
|
||||
week_start = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
|
||||
|
|
@ -343,17 +257,6 @@ def handler(event, context):
|
|||
else:
|
||||
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) ---
|
||||
this_monday = now - timedelta(days=now.weekday())
|
||||
week_start = this_monday.strftime("%Y-%m-%d")
|
||||
|
|
|
|||
|
|
@ -165,8 +165,6 @@ Resources:
|
|||
SHIFT_TABLE: !Ref ShiftTable
|
||||
SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token
|
||||
SHIFT_CHANNEL: !Ref ShiftChannel
|
||||
SES_SENDER: noreply@seahaven.com
|
||||
PAYROLL_RECIPIENTS: ""
|
||||
PAY_REPORT_USER: U0A3SC48T47
|
||||
TZ: !Ref Timezone
|
||||
CHECKCOMPONENTS_QUEUE_URL: !Ref CheckcomponentsQueueUrl
|
||||
|
|
@ -600,44 +598,6 @@ Resources:
|
|||
AlarmActions:
|
||||
- !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) ---
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ def env(monkeypatch):
|
|||
"SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token"
|
||||
)
|
||||
monkeypatch.setenv("PAY_REPORT_USER", "U_BOSS")
|
||||
monkeypatch.delenv("PAYROLL_RECIPIENTS", raising=False) # skip SES email
|
||||
monkeypatch.setenv(
|
||||
"CHECKCOMPONENTS_QUEUE_URL",
|
||||
"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
|
||||
|
||||
|
||||
@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)
|
||||
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
|
||||
|
|
@ -256,22 +234,11 @@ def test_payload_skips_fallback_and_zero(weeklypost_app):
|
|||
assert "payPeriodId" not in payload
|
||||
|
||||
|
||||
def test_send_pay_email_skips_when_recipients_empty(weeklypost_app, monkeypatch):
|
||||
monkeypatch.setenv("PAYROLL_RECIPIENTS", "")
|
||||
ses = MagicMock(name="ses")
|
||||
|
||||
def client(svc, **kw):
|
||||
if svc == "ses":
|
||||
return ses
|
||||
return MagicMock(name=svc)
|
||||
|
||||
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
|
||||
def test_weekly_post_has_no_payroll_email_path():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
template = (root / "template.yaml").read_text()
|
||||
for token in ("PAYROLL_RECIPIENTS", "SES_SENDER", "ses:", "PayrollEmailFailure"):
|
||||
assert token not in template, token
|
||||
source = (root / "src" / "weekly-post" / "app.py").read_text()
|
||||
for token in ("_send_pay_email", "_build_pay_email_html", 'boto3.client("ses")'):
|
||||
assert token not in source, token
|
||||
|
|
|
|||
|
|
@ -207,64 +207,3 @@ class TestHolidayPay:
|
|||
assert breakdown[0]["name"] == "Alice"
|
||||
assert breakdown[0].get("is_holiday") is None
|
||||
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