diff --git a/CHANGELOG.md b/CHANGELOG.md index b2cd6e4..bc1eef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 **Point-and-click admin actions, right inside Slack.** Admins no longer have to diff --git a/src/slack-bot/CHANGELOG.md b/src/slack-bot/CHANGELOG.md index b2cd6e4..bc1eef0 100644 --- a/src/slack-bot/CHANGELOG.md +++ b/src/slack-bot/CHANGELOG.md @@ -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 **Point-and-click admin actions, right inside Slack.** Admins no longer have to diff --git a/src/weekly-post/app.py b/src/weekly-post/app.py index b17c9ab..cb9dd2d 100644 --- a/src/weekly-post/app.py +++ b/src/weekly-post/app.py @@ -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,47 @@ 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) -> 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): now = datetime.now(EASTERN) @@ -272,8 +315,11 @@ def handler(event, context): pay_dm_user = os.environ.get("PAY_REPORT_USER") + week_key = prev_monday.strftime("%Y-%m-%d") 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) prev_sunday = prev_monday + timedelta(days=6) @@ -368,6 +414,29 @@ def handler(event, context): exc_info=True, ) 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 { "posted": True, "channel": channel_id, diff --git a/template.yaml b/template.yaml index 3c43a92..13d62a9 100644 --- a/template.yaml +++ b/template.yaml @@ -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: diff --git a/tests/weekly_post/test_handler.py b/tests/weekly_post/test_handler.py index 82f31cc..b69f73d 100644 --- a/tests/weekly_post/test_handler.py +++ b/tests/weekly_post/test_handler.py @@ -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,20 @@ 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"}] + 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 + 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 +110,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 +126,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 +141,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 +163,93 @@ 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() + 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