feat(meals): send weekly deductions to paychex checkcomponents (PLAT-154) (#184)
Some checks failed
Build Lambda Layer / build (push) Has been cancelled

* feat(meals): send weekly deductions to paychex checkcomponents (PLAT-154)

* fix(meals): round checkcomponents amounts half-up

Keep SQS deduction amounts on the same rounding path as submit_order so extra-precision totals cannot diverge by a cent.
This commit is contained in:
Adam Moussa 2026-09-03 22:04:02 +00:00 • committed by GitHub
parent b043b86fc7
commit 62f61f61d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 169 additions and 2 deletions

View file

@ -1,10 +1,11 @@
import csv import csv
import io import io
import json import json
import logging
import os import os
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal, ROUND_HALF_UP
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import boto3 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 from shared.pdf import build_weekly_summary_pdf
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
logger = logging.getLogger()
logger.setLevel(logging.INFO)
_s3 = boto3.client("s3") _s3 = boto3.client("s3")
_lambda = boto3.client("lambda") _lambda = boto3.client("lambda")
_sqs = boto3.client("sqs")
KIND_MEAL = "meal_deduction"
class DecimalEncoder(json.JSONEncoder): class DecimalEncoder(json.JSONEncoder):
@ -69,6 +74,11 @@ def lambda_handler(event, context):
summary["weekly_summary_pdf_s3_key"] = summary_pdf_key summary["weekly_summary_pdf_s3_key"] = summary_pdf_key
put_summary(week, summary) put_summary(week, summary)
try:
_send_checkcomponents(week, orders)
except Exception:
logger.exception("checkcomponents send failed week=%s", week)
_lambda.invoke( _lambda.invoke(
FunctionName=os.environ["SLACK_NOTIFIER_ARN"], FunctionName=os.environ["SLACK_NOTIFIER_ARN"],
InvocationType="Event", InvocationType="Event",
@ -80,6 +90,41 @@ def lambda_handler(event, context):
return {"status": "aggregated", "week": week, "total_employees": len(orders)} 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: def build_summary(orders: list[dict], week: str) -> dict:
meal_totals = defaultdict( meal_totals = defaultdict(
lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0} lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0}

View file

@ -251,6 +251,13 @@ data "aws_iam_policy_document" "aggregate_orders" {
actions = ["lambda:InvokeFunction"] actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.slack_notifier.arn] 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" { resource "aws_iam_role_policy" "aggregate_orders" {

View file

@ -134,7 +134,8 @@ resource "aws_lambda_function" "aggregate_orders" {
environment { environment {
variables = merge(local.common_env, { 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
}) })
} }

View file

@ -42,3 +42,15 @@ variable "slack_channel_id" {
description = "Slack channel ID for meal order notifications. Written to /meal-order-manager/slack-channel-id." description = "Slack channel ID for meal order notifications. Written to /meal-order-manager/slack-channel-id."
type = string 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"
}

View file

@ -64,6 +64,10 @@ def _patch_env(monkeypatch):
"SLACK_NOTIFIER_ARN", "SLACK_NOTIFIER_ARN",
"arn:aws:lambda:us-east-1:123456789012:function:test-notifier", "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() @pytest.fixture()
@ -72,9 +76,11 @@ def handler_module():
with patch("boto3.client") as mock_client, patch("boto3.resource"): with patch("boto3.client") as mock_client, patch("boto3.resource"):
mock_s3 = MagicMock() mock_s3 = MagicMock()
mock_lambda = MagicMock() mock_lambda = MagicMock()
mock_sqs = MagicMock()
mock_client.side_effect = lambda svc, **kw: { mock_client.side_effect = lambda svc, **kw: {
"s3": mock_s3, "s3": mock_s3,
"lambda": mock_lambda, "lambda": mock_lambda,
"sqs": mock_sqs,
}[svc] }[svc]
import importlib import importlib
@ -85,6 +91,7 @@ def handler_module():
# Inject mocked clients so tests can assert on them # Inject mocked clients so tests can assert on them
mod._s3 = mock_s3 mod._s3 = mock_s3
mod._lambda = mock_lambda mod._lambda = mock_lambda
mod._sqs = mock_sqs
yield mod yield mod
@ -406,6 +413,7 @@ class TestAggregateAlreadyAggregated:
assert result["week"] == "2026-W20" assert result["week"] == "2026-W20"
handler_module._s3.put_object.assert_not_called() handler_module._s3.put_object.assert_not_called()
mock_orders.assert_not_called() mock_orders.assert_not_called()
handler_module._sqs.send_message.assert_not_called()
class TestAggregateNoOrders: class TestAggregateNoOrders:
@ -427,6 +435,7 @@ class TestAggregateNoOrders:
) )
assert result["week"] == "2026-W20" assert result["week"] == "2026-W20"
handler_module._s3.put_object.assert_not_called() handler_module._s3.put_object.assert_not_called()
handler_module._sqs.send_message.assert_not_called()
class TestAggregateHappyPath: class TestAggregateHappyPath:
@ -515,3 +524,96 @@ class TestAggregateHappyPath:
payload = json.loads(invoke_kwargs["Payload"]) payload = json.loads(invoke_kwargs["Payload"])
assert payload["event"] == "orders_aggregated" assert payload["event"] == "orders_aggregated"
assert payload["week"] == "2026-W20" 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()