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 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}

View file

@ -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" {

View file

@ -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
})
}

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."
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",
"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()