diff --git a/functions/aggregate_orders/handler.py b/functions/aggregate_orders/handler.py index ec618f4..7094328 100644 --- a/functions/aggregate_orders/handler.py +++ b/functions/aggregate_orders/handler.py @@ -1,10 +1,11 @@ import csv import io import json +import logging import os from collections import defaultdict from datetime import datetime -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP from zoneinfo import ZoneInfo import boto3 @@ -13,8 +14,12 @@ from shared.db import current_week, get_orders, get_summary, put_summary from shared.pdf import build_weekly_summary_pdf EASTERN = ZoneInfo("America/New_York") +logger = logging.getLogger() +logger.setLevel(logging.INFO) _s3 = boto3.client("s3") _lambda = boto3.client("lambda") +_sqs = boto3.client("sqs") +KIND_MEAL = "meal_deduction" class DecimalEncoder(json.JSONEncoder): @@ -69,6 +74,11 @@ def lambda_handler(event, context): summary["weekly_summary_pdf_s3_key"] = summary_pdf_key put_summary(week, summary) + try: + _send_checkcomponents(week, orders) + except Exception: + logger.exception("checkcomponents send failed week=%s", week) + _lambda.invoke( FunctionName=os.environ["SLACK_NOTIFIER_ARN"], InvocationType="Event", @@ -80,6 +90,41 @@ def lambda_handler(event, context): return {"status": "aggregated", "week": week, "total_employees": len(orders)} +def build_checkcomponents_payload(week: str, orders: list[dict]) -> dict: + """Sibling dollar lines only. No Flex OAuth, no payPeriodId invention.""" + lines = [] + for order in orders: + email = str(order.get("employee_email") or "").strip() + if not email: + continue + amount = Decimal(str(order.get("total") or 0)) + lines.append( + { + "employee_email": email, + "amount": format( + amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), "f" + ), + } + ) + week_token = week if str(week).upper().startswith("WEEK#") else f"WEEK#{week}" + return { + "type": "checkcomponents", + "kind": KIND_MEAL, + "week": week_token, + "lines": lines, + } + + +def _send_checkcomponents(week: str, orders: list[dict]) -> None: + queue_url = os.environ.get("CHECKCOMPONENTS_QUEUE_URL", "").strip() + if not queue_url: + return + payload = build_checkcomponents_payload(week, orders) + if not payload["lines"]: + return + _sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps(payload)) + + def build_summary(orders: list[dict], week: str) -> dict: meal_totals = defaultdict( lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0} diff --git a/terraform/iam.tf b/terraform/iam.tf index 3bb8f16..0b59926 100644 --- a/terraform/iam.tf +++ b/terraform/iam.tf @@ -251,6 +251,13 @@ data "aws_iam_policy_document" "aggregate_orders" { actions = ["lambda:InvokeFunction"] resources = [aws_lambda_function.slack_notifier.arn] } + + statement { + sid = "CheckcomponentsSend" + effect = "Allow" + actions = ["sqs:SendMessage"] + resources = [var.checkcomponents_queue_arn] + } } resource "aws_iam_role_policy" "aggregate_orders" { diff --git a/terraform/lambda.tf b/terraform/lambda.tf index 07d8151..1341325 100644 --- a/terraform/lambda.tf +++ b/terraform/lambda.tf @@ -134,7 +134,8 @@ resource "aws_lambda_function" "aggregate_orders" { environment { variables = merge(local.common_env, { - SLACK_NOTIFIER_ARN = aws_lambda_function.slack_notifier.arn + SLACK_NOTIFIER_ARN = aws_lambda_function.slack_notifier.arn + CHECKCOMPONENTS_QUEUE_URL = var.checkcomponents_queue_url }) } diff --git a/terraform/variables.tf b/terraform/variables.tf index 34fa72e..7258c41 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -42,3 +42,15 @@ variable "slack_channel_id" { description = "Slack channel ID for meal order notifications. Written to /meal-order-manager/slack-channel-id." type = string } + +variable "checkcomponents_queue_url" { + description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage." + type = string + default = "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents" +} + +variable "checkcomponents_queue_arn" { + description = "paychex-checkcomponents SQS ARN for aggregate-orders SendMessage." + type = string + default = "arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents" +} diff --git a/tests/test_aggregate_orders.py b/tests/test_aggregate_orders.py index 0e5058a..d11ab2e 100644 --- a/tests/test_aggregate_orders.py +++ b/tests/test_aggregate_orders.py @@ -64,6 +64,10 @@ def _patch_env(monkeypatch): "SLACK_NOTIFIER_ARN", "arn:aws:lambda:us-east-1:123456789012:function:test-notifier", ) + monkeypatch.setenv( + "CHECKCOMPONENTS_QUEUE_URL", + "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents", + ) @pytest.fixture() @@ -72,9 +76,11 @@ def handler_module(): with patch("boto3.client") as mock_client, patch("boto3.resource"): mock_s3 = MagicMock() mock_lambda = MagicMock() + mock_sqs = MagicMock() mock_client.side_effect = lambda svc, **kw: { "s3": mock_s3, "lambda": mock_lambda, + "sqs": mock_sqs, }[svc] import importlib @@ -85,6 +91,7 @@ def handler_module(): # Inject mocked clients so tests can assert on them mod._s3 = mock_s3 mod._lambda = mock_lambda + mod._sqs = mock_sqs yield mod @@ -406,6 +413,7 @@ class TestAggregateAlreadyAggregated: assert result["week"] == "2026-W20" handler_module._s3.put_object.assert_not_called() mock_orders.assert_not_called() + handler_module._sqs.send_message.assert_not_called() class TestAggregateNoOrders: @@ -427,6 +435,7 @@ class TestAggregateNoOrders: ) assert result["week"] == "2026-W20" handler_module._s3.put_object.assert_not_called() + handler_module._sqs.send_message.assert_not_called() class TestAggregateHappyPath: @@ -515,3 +524,96 @@ class TestAggregateHappyPath: payload = json.loads(invoke_kwargs["Payload"]) assert payload["event"] == "orders_aggregated" assert payload["week"] == "2026-W20" + + handler_module._sqs.send_message.assert_called_once() + send_kwargs = handler_module._sqs.send_message.call_args.kwargs + assert send_kwargs["QueueUrl"] == os.environ["CHECKCOMPONENTS_QUEUE_URL"] + body = json.loads(send_kwargs["MessageBody"]) + assert body["type"] == "checkcomponents" + assert body["kind"] == "meal_deduction" + assert body["week"] == "WEEK#2026-W20" + assert "payPeriodId" not in body + emails = {line["employee_email"] for line in body["lines"]} + assert emails == {"alice@x.com", "bob@x.com"} + + +class TestCheckcomponentsPayload: + def test_prefixes_week_and_omits_flex_fields(self, handler_module): + orders = [ + _make_order("Alice", "alice@x.com", [_make_item("Burger")], total=10.5), + _make_order("Skip", "", [_make_item("Pasta")], total=9), + ] + payload = handler_module.build_checkcomponents_payload("2026-W20", orders) + assert payload == { + "type": "checkcomponents", + "kind": "meal_deduction", + "week": "WEEK#2026-W20", + "lines": [{"employee_email": "alice@x.com", "amount": "10.50"}], + } + + def test_keeps_existing_week_hash_prefix(self, handler_module): + payload = handler_module.build_checkcomponents_payload( + "WEEK#2026-W20", + [_make_order("Alice", "alice@x.com", [_make_item("Burger")], total=1)], + ) + assert payload["week"] == "WEEK#2026-W20" + + def test_rounds_amount_half_up(self, handler_module): + payload = handler_module.build_checkcomponents_payload( + "2026-W20", + [ + _make_order( + "Alice", + "alice@x.com", + [_make_item("Burger")], + total=Decimal("10.025"), + ) + ], + ) + assert payload["lines"][0]["amount"] == "10.03" + + +class TestCheckcomponentsSend: + @patch("functions.aggregate_orders.handler.put_summary") + @patch("functions.aggregate_orders.handler.get_summary") + @patch("functions.aggregate_orders.handler.get_orders") + @patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20") + def test_sqs_failure_still_aggregates_and_notifies( + self, mock_week, mock_orders, mock_get_summary, mock_put_summary, handler_module + ): + mock_get_summary.return_value = None + mock_orders.return_value = [ + _make_order("Alice", "alice@x.com", [_make_item("Burger")], total=10) + ] + handler_module._sqs.send_message.side_effect = Exception("AccessDenied") + + result = handler_module.lambda_handler({}, None) + + assert result["status"] == "aggregated" + mock_put_summary.assert_called_once() + handler_module._lambda.invoke.assert_called_once() + + @patch("functions.aggregate_orders.handler.put_summary") + @patch("functions.aggregate_orders.handler.get_summary") + @patch("functions.aggregate_orders.handler.get_orders") + @patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20") + def test_empty_queue_url_skips_send( + self, + mock_week, + mock_orders, + mock_get_summary, + mock_put_summary, + handler_module, + monkeypatch, + ): + monkeypatch.setenv("CHECKCOMPONENTS_QUEUE_URL", "") + mock_get_summary.return_value = None + mock_orders.return_value = [ + _make_order("Alice", "alice@x.com", [_make_item("Burger")], total=10) + ] + + result = handler_module.lambda_handler({}, None) + + assert result["status"] == "aggregated" + handler_module._sqs.send_message.assert_not_called() + handler_module._lambda.invoke.assert_called_once()