feat(pay): send after-hours lines to paychex checkcomponents (PLAT-154) (#244)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled

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

* style(pay): drop trailing blank line in weekly post tests

* fix(pay): skip duplicate checkcomponents send on weekly-post retry
This commit is contained in:
Adam Moussa 2026-09-03 22:12:55 +00:00 • committed by GitHub
parent 244ceaff10
commit 4ffa09bd3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 224 additions and 8 deletions

View file

@ -10,6 +10,16 @@ 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. A retry or forced re-run of the same week does
not send the lines twice.
## v1.14.0 — July 2, 2026 ## v1.14.0 — July 2, 2026
**Point-and-click admin actions, right inside Slack.** Admins no longer have to **Point-and-click admin actions, right inside Slack.** Admins no longer have to

View file

@ -10,6 +10,16 @@ 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. A retry or forced re-run of the same week does
not send the lines twice.
## v1.14.0 — July 2, 2026 ## v1.14.0 — July 2, 2026
**Point-and-click admin actions, right inside Slack.** Admins no longer have to **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 """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 emails the pay summary to payroll."""
import json
import logging import logging
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -19,6 +20,7 @@ logger = logging.getLogger()
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
KIND_AFTER_HOURS = "after_hours"
DAY_ORDER = [ DAY_ORDER = [
"Monday", "Monday",
@ -248,6 +250,47 @@ def _send_pay_email(week_label: str, pay_record: dict) -> None:
logger.info("Sent pay email to %s", recipients) 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) -> bool:
"""Enqueue sibling dollar lines. Returns True if a message was sent."""
queue_url = os.environ.get("CHECKCOMPONENTS_QUEUE_URL", "").strip()
if not queue_url:
return False
payload = build_checkcomponents_payload(pay_record)
if not payload["lines"]:
return False
boto3.client("sqs").send_message(
QueueUrl=queue_url, MessageBody=json.dumps(payload)
)
return True
def handler(event, context): def handler(event, context):
now = datetime.now(EASTERN) now = datetime.now(EASTERN)
@ -272,8 +315,11 @@ def handler(event, context):
pay_dm_user = os.environ.get("PAY_REPORT_USER") pay_dm_user = os.environ.get("PAY_REPORT_USER")
week_key = prev_monday.strftime("%Y-%m-%d")
if pay_record["breakdown"]: if pay_record["breakdown"]:
week_key = prev_monday.strftime("%Y-%m-%d") existing = schedule.get_pay_record(week_key)
if existing and existing.get("checkcomponents_sent"):
pay_record["checkcomponents_sent"] = True
schedule.save_pay_record(week_key, pay_record) schedule.save_pay_record(week_key, pay_record)
prev_sunday = prev_monday + timedelta(days=6) prev_sunday = prev_monday + timedelta(days=6)
@ -368,6 +414,29 @@ def handler(event, context):
exc_info=True, exc_info=True,
) )
raise raise
# Enqueue after the schedule post is persisted so an EventBridge retry
# caused by save_schedule_post cannot send the same week twice. A sent
# flag on the pay record covers a later timeout or a forced re-run.
if pay_record["breakdown"] and not pay_record.get("checkcomponents_sent"):
sent = False
try:
sent = _send_checkcomponents(pay_record)
except Exception:
logger.exception(
"checkcomponents send failed week=%s",
week_key,
)
if sent:
try:
pay_record["checkcomponents_sent"] = True
schedule.save_pay_record(week_key, pay_record)
except Exception:
logger.exception(
"checkcomponents sent flag failed week=%s",
week_key,
)
return { return {
"posted": True, "posted": True,
"channel": channel_id, "channel": channel_id,

View file

@ -18,6 +18,14 @@ Parameters:
Default: "" Default: ""
NoEcho: true NoEcho: true
Description: Sentry DSN; empty disables error reporting 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: Globals:
Function: Function:
@ -161,6 +169,7 @@ Resources:
PAYROLL_RECIPIENTS: payroll@seahaven.com PAYROLL_RECIPIENTS: payroll@seahaven.com
PAY_REPORT_USER: U0A3SC48T47 PAY_REPORT_USER: U0A3SC48T47
TZ: !Ref Timezone TZ: !Ref Timezone
CHECKCOMPONENTS_QUEUE_URL: !Ref CheckcomponentsQueueUrl
Policies: Policies:
- DynamoDBCrudPolicy: - DynamoDBCrudPolicy:
TableName: !Ref ShiftTable TableName: !Ref ShiftTable
@ -195,6 +204,11 @@ Resources:
Condition: Condition:
StringEquals: StringEquals:
ses:FromAddress: noreply@seahaven.com ses:FromAddress: noreply@seahaven.com
- Effect: Allow
Action:
- sqs:SendMessage
Resource:
- !Ref CheckcomponentsQueueArn
Events: Events:
# EST: 7am ET = 12:00 UTC (Nov-Mar) # EST: 7am ET = 12:00 UTC (Nov-Mar)
WeeklyPostEST: WeeklyPostEST:

View file

@ -1,5 +1,6 @@
"""Tests for the weekly-post Lambda handler orchestration.""" """Tests for the weekly-post Lambda handler orchestration."""
import json
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
@ -27,10 +28,27 @@ def env(monkeypatch):
) )
monkeypatch.setenv("PAY_REPORT_USER", "U_BOSS") monkeypatch.setenv("PAY_REPORT_USER", "U_BOSS")
monkeypatch.delenv("PAYROLL_RECIPIENTS", raising=False) # skip SES email 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) @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) result = weeklypost_app.handler({"force": True}, None)
assert result["posted"] is True 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. # The new schedule post was persisted for next week's cleanup.
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000" assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
slack.chat_postMessage.assert_called() slack.chat_postMessage.assert_called()
sqs.send_message.assert_not_called()
@freeze_time(MON_0800) @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. # Previous week (Mon 2026-06-01) had Alice on the Monday night shift.
seed.config(shift_rate="50") seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice") seed.weekly("Monday", "114", "Alice")
@ -58,11 +77,20 @@ def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env):
if c.kwargs.get("channel") == "U_BOSS" if c.kwargs.get("channel") == "U_BOSS"
] ]
assert dm_calls 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"}]
assert schedule.get_pay_record("2026-06-01")["checkcomponents_sent"] is True
@freeze_time(MON_0800) @freeze_time(MON_0800)
def test_pay_email_failure_does_not_block_schedule_post( 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. # Previous week has an assigned shift, so the pay/email path runs.
seed.config(shift_rate="50") seed.config(shift_rate="50")
@ -82,7 +110,7 @@ def test_pay_email_failure_does_not_block_schedule_post(
@freeze_time(MON_0800) @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 # 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. # Monday. The old ts is cleaned up and a new one is stored.
seed.schedule_post("C_TEST", "111.111") seed.schedule_post("C_TEST", "111.111")
@ -98,7 +126,7 @@ def test_rolls_existing_post_forward(weeklypost_app, schedule, seed, slack, env)
@freeze_time(MON_0800) @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 # A stored post that can no longer be deleted (e.g. was already removed
# manually) still reposts — the delete failure is non-fatal. # manually) still reposts — the delete failure is non-fatal.
seed.schedule_post("C_TEST", "111.111") seed.schedule_post("C_TEST", "111.111")
@ -113,7 +141,7 @@ def test_reposts_when_delete_fails(weeklypost_app, schedule, seed, slack, env):
@freeze_time(MON_0800) @freeze_time(MON_0800)
def test_rolls_back_repost_when_save_fails( 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 # 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 # landed, the just-posted message is deleted so an async retry can't leave
@ -135,8 +163,93 @@ def test_rolls_back_repost_when_save_fails(
@freeze_time(MON_0800) @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. # Frozen hour is 08:00 ET, not 07:00 → skip unless forced.
result = weeklypost_app.handler({}, None) result = weeklypost_app.handler({}, None)
assert result == {"skipped": True} assert result == {"skipped": True}
slack.chat_postMessage.assert_not_called() 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()
assert "checkcomponents_sent" not in (schedule.get_pay_record("2026-06-01") or {})
@freeze_time(MON_0800)
def test_save_failure_does_not_enqueue_checkcomponents(
weeklypost_app, schedule, seed, slack, env, monkeypatch, sqs
):
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
monkeypatch.setattr(
weeklypost_app.ShiftSchedule,
"save_schedule_post",
MagicMock(side_effect=Exception("dynamo down")),
)
with pytest.raises(Exception, match="dynamo down"):
weeklypost_app.handler({"force": True}, None)
sqs.send_message.assert_not_called()
assert "checkcomponents_sent" not in (schedule.get_pay_record("2026-06-01") or {})
@freeze_time(MON_0800)
def test_retry_does_not_resend_checkcomponents(
weeklypost_app, schedule, seed, slack, env, sqs
):
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
weeklypost_app.handler({"force": True}, None)
weeklypost_app.handler({"force": True}, None)
sqs.send_message.assert_called_once()
assert schedule.get_pay_record("2026-06-01")["checkcomponents_sent"] is True
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