diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df76ba..bc1eef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ 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. +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 diff --git a/src/slack-bot/CHANGELOG.md b/src/slack-bot/CHANGELOG.md index 0df76ba..bc1eef0 100644 --- a/src/slack-bot/CHANGELOG.md +++ b/src/slack-bot/CHANGELOG.md @@ -17,7 +17,8 @@ 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. +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 diff --git a/src/weekly-post/app.py b/src/weekly-post/app.py index 3b8b001..cb9dd2d 100644 --- a/src/weekly-post/app.py +++ b/src/weekly-post/app.py @@ -277,16 +277,18 @@ def build_checkcomponents_payload(pay_record: dict) -> dict: } -def _send_checkcomponents(pay_record: dict) -> None: +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 + return False payload = build_checkcomponents_payload(pay_record) if not payload["lines"]: - return + return False boto3.client("sqs").send_message( QueueUrl=queue_url, MessageBody=json.dumps(payload) ) + return True def handler(event, context): @@ -313,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) @@ -349,14 +354,6 @@ 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") @@ -417,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/tests/weekly_post/test_handler.py b/tests/weekly_post/test_handler.py index 41a07db..b69f73d 100644 --- a/tests/weekly_post/test_handler.py +++ b/tests/weekly_post/test_handler.py @@ -85,6 +85,7 @@ def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env, sqs) 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) @@ -200,6 +201,40 @@ def test_empty_queue_url_skips_checkcomponents( 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):