feat(pay): send after-hours lines to paychex checkcomponents (PLAT-154)

This commit is contained in:
Adam Moussa 2026-09-02 21:08:50 -04:00
parent 244ceaff10
commit eb26092fad
No known key found for this signature in database
5 changed files with 167 additions and 7 deletions

View file

@ -10,6 +10,15 @@ fine and still supported.
---
## v1.15.0 — September 2, 2026
**Monday pay totals now queue to Flex payroll posting.** The weekly post still
emails payroll and DMs the pay summary as before. After those go out, it also
sends last week's after-hours dollar lines (by extension, previous Monday
through Sunday) to the paychex-integrations checkcomponents queue. Unassigned
fallback extension 100 and $0 totals are left out. A queue failure does not
block the Monday schedule post.
## v1.14.0 — July 2, 2026
**Point-and-click admin actions, right inside Slack.** Admins no longer have to

View file

@ -10,6 +10,15 @@ fine and still supported.
---
## v1.15.0 — September 2, 2026
**Monday pay totals now queue to Flex payroll posting.** The weekly post still
emails payroll and DMs the pay summary as before. After those go out, it also
sends last week's after-hours dollar lines (by extension, previous Monday
through Sunday) to the paychex-integrations checkcomponents queue. Unassigned
fallback extension 100 and $0 totals are left out. A queue failure does not
block the Monday schedule post.
## v1.14.0 — July 2, 2026
**Point-and-click admin actions, right inside Slack.** Admins no longer have to

View file

@ -1,6 +1,7 @@
"""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."""
import json
import logging
import os
from datetime import datetime, timedelta
@ -19,6 +20,7 @@ logger = logging.getLogger()
logger.setLevel(logging.INFO)
EASTERN = ZoneInfo("America/New_York")
KIND_AFTER_HOURS = "after_hours"
DAY_ORDER = [
"Monday",
@ -248,6 +250,45 @@ def _send_pay_email(week_label: str, pay_record: dict) -> None:
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()
window_end = week_start + timedelta(days=6)
lines = []
for info in pay_record.get("totals", {}).values():
ext = str(info.get("extension") or "").strip()
if not ext or ext == FALLBACK_EXTENSION:
continue
amount = Decimal(str(info.get("total") or 0))
if amount <= 0:
continue
lines.append(
{
"extension": ext,
"amount": format(amount.quantize(Decimal("0.01")), "f"),
}
)
return {
"type": "checkcomponents",
"kind": KIND_AFTER_HOURS,
"windowStart": week_start.isoformat(),
"windowEnd": window_end.isoformat(),
"lines": lines,
}
def _send_checkcomponents(pay_record: dict) -> None:
queue_url = os.environ.get("CHECKCOMPONENTS_QUEUE_URL", "").strip()
if not queue_url:
return
payload = build_checkcomponents_payload(pay_record)
if not payload["lines"]:
return
boto3.client("sqs").send_message(
QueueUrl=queue_url, MessageBody=json.dumps(payload)
)
def handler(event, context):
now = datetime.now(EASTERN)
@ -308,6 +349,14 @@ def handler(event, context):
week_key,
)
try:
_send_checkcomponents(pay_record)
except Exception:
logger.exception(
"checkcomponents send failed week=%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")

View file

@ -18,6 +18,14 @@ Parameters:
Default: ""
NoEcho: true
Description: Sentry DSN; empty disables error reporting
CheckcomponentsQueueUrl:
Type: String
Default: "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents"
Description: paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage.
CheckcomponentsQueueArn:
Type: String
Default: "arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents"
Description: paychex-checkcomponents SQS ARN for WeeklyPost SendMessage.
Globals:
Function:
@ -161,6 +169,7 @@ Resources:
PAYROLL_RECIPIENTS: payroll@seahaven.com
PAY_REPORT_USER: U0A3SC48T47
TZ: !Ref Timezone
CHECKCOMPONENTS_QUEUE_URL: !Ref CheckcomponentsQueueUrl
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ShiftTable
@ -195,6 +204,11 @@ Resources:
Condition:
StringEquals:
ses:FromAddress: noreply@seahaven.com
- Effect: Allow
Action:
- sqs:SendMessage
Resource:
- !Ref CheckcomponentsQueueArn
Events:
# EST: 7am ET = 12:00 UTC (Nov-Mar)
WeeklyPostEST:

View file

@ -1,5 +1,6 @@
"""Tests for the weekly-post Lambda handler orchestration."""
import json
from unittest.mock import MagicMock
import pytest
@ -27,10 +28,27 @@ def env(monkeypatch):
)
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",
)
@pytest.fixture
def sqs(weeklypost_app, monkeypatch):
fake = MagicMock(name="sqs")
def client(svc, **kw):
if svc == "sqs":
return fake
return MagicMock(name=svc)
monkeypatch.setattr(weeklypost_app.boto3, "client", client)
return fake
@freeze_time(MON_0800)
def test_posts_schedule_and_saves_post(weeklypost_app, schedule, seed, slack, env):
def test_posts_schedule_and_saves_post(weeklypost_app, schedule, seed, slack, env, sqs):
result = weeklypost_app.handler({"force": True}, None)
assert result["posted"] is True
@ -38,10 +56,11 @@ def test_posts_schedule_and_saves_post(weeklypost_app, schedule, seed, slack, en
# The new schedule post was persisted for next week's cleanup.
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
slack.chat_postMessage.assert_called()
sqs.send_message.assert_not_called()
@freeze_time(MON_0800)
def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env):
def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env, sqs):
# Previous week (Mon 2026-06-01) had Alice on the Monday night shift.
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
@ -58,11 +77,19 @@ def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env):
if c.kwargs.get("channel") == "U_BOSS"
]
assert dm_calls
sqs.send_message.assert_called_once()
body = json.loads(sqs.send_message.call_args.kwargs["MessageBody"])
assert body["type"] == "checkcomponents"
assert body["kind"] == "after_hours"
assert body["windowStart"] == "2026-06-01"
assert body["windowEnd"] == "2026-06-07"
assert "payPeriodId" not in body
assert body["lines"] == [{"extension": "114", "amount": "50.00"}]
@freeze_time(MON_0800)
def test_pay_email_failure_does_not_block_schedule_post(
weeklypost_app, schedule, seed, slack, env, monkeypatch
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")
@ -82,7 +109,7 @@ def test_pay_email_failure_does_not_block_schedule_post(
@freeze_time(MON_0800)
def test_rolls_existing_post_forward(weeklypost_app, schedule, seed, slack, env):
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
# Monday. The old ts is cleaned up and a new one is stored.
seed.schedule_post("C_TEST", "111.111")
@ -98,7 +125,7 @@ def test_rolls_existing_post_forward(weeklypost_app, schedule, seed, slack, env)
@freeze_time(MON_0800)
def test_reposts_when_delete_fails(weeklypost_app, schedule, seed, slack, env):
def test_reposts_when_delete_fails(weeklypost_app, schedule, seed, slack, env, sqs):
# A stored post that can no longer be deleted (e.g. was already removed
# manually) still reposts — the delete failure is non-fatal.
seed.schedule_post("C_TEST", "111.111")
@ -113,7 +140,7 @@ def test_reposts_when_delete_fails(weeklypost_app, schedule, seed, slack, env):
@freeze_time(MON_0800)
def test_rolls_back_repost_when_save_fails(
weeklypost_app, schedule, seed, slack, env, monkeypatch
weeklypost_app, schedule, seed, slack, env, monkeypatch, sqs
):
# If persisting the fresh post's ts fails after the repost has already
# landed, the just-posted message is deleted so an async retry can't leave
@ -135,8 +162,60 @@ def test_rolls_back_repost_when_save_fails(
@freeze_time(MON_0800)
def test_skips_when_not_7am_and_not_forced(weeklypost_app, schedule, slack, env):
def test_skips_when_not_7am_and_not_forced(weeklypost_app, schedule, slack, env, sqs):
# Frozen hour is 08:00 ET, not 07:00 → skip unless forced.
result = weeklypost_app.handler({}, None)
assert result == {"skipped": True}
slack.chat_postMessage.assert_not_called()
sqs.send_message.assert_not_called()
@freeze_time(MON_0800)
def test_checkcomponents_failure_does_not_block_schedule_post(
weeklypost_app, schedule, seed, slack, env, monkeypatch, sqs
):
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
monkeypatch.setattr(
weeklypost_app,
"_send_checkcomponents",
MagicMock(side_effect=Exception("SQS AccessDenied")),
)
result = weeklypost_app.handler({"force": True}, None)
assert result["posted"] is True
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
@freeze_time(MON_0800)
def test_empty_queue_url_skips_checkcomponents(
weeklypost_app, schedule, seed, slack, env, sqs, monkeypatch
):
monkeypatch.setenv("CHECKCOMPONENTS_QUEUE_URL", "")
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
result = weeklypost_app.handler({"force": True}, None)
assert result["posted"] is True
sqs.send_message.assert_not_called()
def test_payload_skips_fallback_and_zero(weeklypost_app):
payload = weeklypost_app.build_checkcomponents_payload(
{
"week_start": "2026-06-01",
"totals": {
"Alice": {"extension": "114", "total": "75.5"},
"Desk": {"extension": "100", "total": "50"},
"Zero": {"extension": "200", "total": "0"},
},
}
)
assert payload["kind"] == "after_hours"
assert payload["windowStart"] == "2026-06-01"
assert payload["windowEnd"] == "2026-06-07"
assert payload["lines"] == [{"extension": "114", "amount": "75.50"}]
assert "payPeriodId" not in payload