mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 03:03:12 +00:00
feat(meals): send weekly deductions to paychex checkcomponents (PLAT-154) (#184)
Some checks failed
Build Lambda Layer / build (push) Has been cancelled
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:
parent
b043b86fc7
commit
62f61f61d1
5 changed files with 169 additions and 2 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue