2026-05-12 18:25:15 -04:00
|
|
|
import csv
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
|
import boto3
|
|
|
|
|
|
|
|
|
|
from shared.db import current_week, get_orders, get_summary, put_summary
|
2026-06-01 18:49:58 -04:00
|
|
|
from shared.pdf import build_weekly_summary_pdf
|
2026-05-12 18:25:15 -04:00
|
|
|
|
|
|
|
|
EASTERN = ZoneInfo("America/New_York")
|
|
|
|
|
_s3 = boto3.client("s3")
|
|
|
|
|
_lambda = boto3.client("lambda")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DecimalEncoder(json.JSONEncoder):
|
|
|
|
|
def default(self, o):
|
|
|
|
|
if isinstance(o, Decimal):
|
|
|
|
|
return float(o)
|
|
|
|
|
return super().default(o)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lambda_handler(event, context):
|
|
|
|
|
week = event.get("week", current_week())
|
|
|
|
|
|
|
|
|
|
existing = get_summary(week)
|
|
|
|
|
if existing:
|
|
|
|
|
return {"status": "already_aggregated", "week": week}
|
|
|
|
|
|
|
|
|
|
orders = get_orders(week)
|
|
|
|
|
if not orders:
|
|
|
|
|
return {"status": "no_orders", "week": week}
|
|
|
|
|
|
|
|
|
|
summary = build_summary(orders, week)
|
|
|
|
|
order_csv = build_order_summary_csv(summary)
|
|
|
|
|
payroll_csv = build_payroll_csv(orders)
|
2026-06-01 18:49:58 -04:00
|
|
|
summary_pdf = build_weekly_summary_pdf(
|
|
|
|
|
orders,
|
|
|
|
|
week=week,
|
|
|
|
|
generated_at=summary["generated_at"],
|
|
|
|
|
total_employees=summary["total_employees"],
|
|
|
|
|
total_meals=summary["total_meals"],
|
|
|
|
|
)
|
2026-05-12 18:25:15 -04:00
|
|
|
|
|
|
|
|
bucket = os.environ["REPORTS_BUCKET"]
|
|
|
|
|
order_csv_key = f"reports/{week}/order-summary.csv"
|
|
|
|
|
payroll_csv_key = f"reports/{week}/payroll-deductions.csv"
|
2026-06-01 18:49:58 -04:00
|
|
|
summary_pdf_key = f"reports/{week}/weekly-summary-{week}.pdf"
|
2026-05-12 18:25:15 -04:00
|
|
|
|
2026-05-12 19:31:27 -04:00
|
|
|
_s3.put_object(
|
|
|
|
|
Bucket=bucket, Key=order_csv_key, Body=order_csv, ContentType="text/csv"
|
|
|
|
|
)
|
|
|
|
|
_s3.put_object(
|
|
|
|
|
Bucket=bucket, Key=payroll_csv_key, Body=payroll_csv, ContentType="text/csv"
|
|
|
|
|
)
|
2026-06-01 18:49:58 -04:00
|
|
|
_s3.put_object(
|
|
|
|
|
Bucket=bucket,
|
|
|
|
|
Key=summary_pdf_key,
|
|
|
|
|
Body=summary_pdf,
|
|
|
|
|
ContentType="application/pdf",
|
|
|
|
|
)
|
2026-05-12 18:25:15 -04:00
|
|
|
|
|
|
|
|
summary["order_csv_s3_key"] = order_csv_key
|
|
|
|
|
summary["payroll_csv_s3_key"] = payroll_csv_key
|
2026-06-01 18:49:58 -04:00
|
|
|
summary["weekly_summary_pdf_s3_key"] = summary_pdf_key
|
2026-05-12 18:25:15 -04:00
|
|
|
put_summary(week, summary)
|
|
|
|
|
|
|
|
|
|
_lambda.invoke(
|
|
|
|
|
FunctionName=os.environ["SLACK_NOTIFIER_ARN"],
|
|
|
|
|
InvocationType="Event",
|
2026-05-12 19:31:27 -04:00
|
|
|
Payload=json.dumps(
|
|
|
|
|
{"event": "orders_aggregated", "week": week}, cls=DecimalEncoder
|
|
|
|
|
),
|
2026-05-12 18:25:15 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {"status": "aggregated", "week": week, "total_employees": len(orders)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_summary(orders: list[dict], week: str) -> dict:
|
Add discount pricing, Google auth, and order hardening (#10)
* Add discount settings and two-tier pricing to order aggregation
Settings CRUD (get_settings/put_settings) for DynamoDB CONFIG#SETTINGS item.
Aggregation now tracks bulk_price and employee_price separately, with
grand_total (company cost) and employee_total (payroll deductions).
* Add Google OAuth, server-side discounts, and Slack order confirmations
Submit order Lambda now verifies Google ID tokens via tokeninfo endpoint,
calculates two-tier discount pricing server-side, and async-invokes the
Slack notifier for per-employee order confirmation DMs. Deadlines updated
to Thursday 11:59pm across all Slack messages.
* Update SAM template for Google auth, Slack invocation, and deadline change
Add SLACK_NOTIFIER_ARN and GOOGLE_CLIENT_ID_PARAM env vars to submit order
function with lambda:InvokeFunction policy. Move close-form schedule to
Thursday 11:59pm EST/EDT.
* Update order form UI and CI workflow for new features
Form now shows discount pricing, responsive grid layout, Google Sign-In
overlay, and closed-orders page with countdown timer. CI workflow fetches
discount settings from DynamoDB and Google Client ID from SSM.
* Add SSM GetParameter permission to submit order Lambda
Required for reading the Google Client ID from Parameter Store
at /meal-order-manager/google-client-id.
* Harden auth, pricing, and reliability in order handlers
Enforce Google auth when configured (reject missing tokens with 403),
return 503 on token verification outages, switch to Decimal with
ROUND_HALF_UP for financial precision, clamp discount bounds 0-100,
use email-based slugs, add 5-min cache TTL with time.monotonic(),
wrap Slack invocation in try/except, add reopen_at timestamp to
closed form status, add reminder dedup guards for dual EST/EDT crons,
escape Slack mrkdwn special characters, and handle empty employee names.
* Fix XSS risks and add closed-form UX to order page
Add escapeHtml() for all scraped content in innerHTML, fix script
injection via </script> in JSON, fix JWT base64url decoding, match
backend two-step rounding in JS employeePrice(), disable qty buttons
and submit when form is closed, add server-driven countdown from
reopen_at, add duplicate order warning via localStorage, add back
button after submission, embed favicon, use :g format for fractional
discounts, and exclude dead loadRoster code when Google auth enabled.
* Document CORS, cron idempotency, and SSM config in template
Add comments explaining CORS dev server strategy, dual EST/EDT cron
idempotency, and manual SSM parameter creation for Google Client ID.
* Add unit tests for submit, notify, and aggregate handlers
50 tests covering pricing pipeline (Decimal rounding, clamping, totals),
Google auth (enforcement, bypass prevention, audience/domain validation,
503 on outage), email slug generation, form status with reopen_at,
input validation, Slack failure resilience, reminder dedup guards,
order confirmation DMs, aggregated summaries, CSV generation, and
mrkdwn escaping.
* Use full email as order slug for defense-in-depth
Replace email-prefix slug with full lowercase email to eliminate any
possibility of cross-domain collisions, per senior review sign-off.
* Remove unused imports flagged by ruff
* Apply ruff formatting
* Fix PR review findings: auth, rounding, and close-form guard
- Remove dead elif branch in submit_order auth (always returned 403)
- Catch HTTPError before URLError so expired tokens return 403 not 503
- Wrap SSM get_parameter in try/except for fresh deployments
- Add wall-clock guard to close_form handler (Friday >= 11 PM ET)
- Add epsilon nudge to JS employeePrice for IEEE 754 boundary match
- Switch Flask dev server from round() to Decimal ROUND_HALF_UP
- Add tests for HTTPError handling and close_form guard (6 new tests)
* Fix close-form weekday guard and SSM auth fail-open
- Close form guard: check weekday == 3 (Thursday), not 4 (Friday) — the
crons fire at Thursday 11:59 PM ET, when weekday() is 3
- SSM fail-closed: separate _google_auth_configured() (checks env var) from
_get_google_client_id() (fetches value). If auth is configured but the SSM
fetch fails, return 503 instead of silently falling back to manual auth
- Update close_form tests to use Thursday dates
- Add test_ssm_failure_fails_closed
* Harden Flask dev server auth and escaping
- Add hosted domain check to _verify_google_token (mirror Lambda)
- Gate auth on config (client_id presence), not request body — prevents
bypass by omitting google_id_token when auth is configured
- Add discount percentage clamping to match Lambda handler
- Add </script> escaping to google_client_id_json
* fix: Email order filenames, SSM param TTL, DST-safe reopen_at
- Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo)
- shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes
- form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta)
- Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* Apply ruff formatting to submit_order handler
* fix(server): retry SSM for Google client id after TTL on failure
Transient SSM errors no longer cache empty client id for the process lifetime;
matches Lambda handler refresh behavior (300s TTL).
Co-authored-by: Cursor <cursoragent@cursor.com>
* style(server): ruff-format Google client id cache helper
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(close-form): accept Fri 00–03 ET catch-up after Thu close cron
EventBridge can deliver past midnight ET; widen the wall-clock guard so a
delayed Thursday 23:59 UTC cron still closes the form. Idempotent when already
closed. Adds test for early Friday; past-window skip now starts Fri 04:00 ET.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix(submit-order): bill from Dynamo menu retail, not client JSON
Load authoritative meal prices from get_menu(week); reject unknown meal names
and return 503 when the menu has no priced meals. Use meal_name in the pricing
loop to avoid shadowing the employee name. Adds regression tests for tampering,
unknown meals, and empty menu meals.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix: use single braces in loadRoster JS nested string
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix Eastern fallback countdown
* Fix pricing validation and JWT display decoding
* Fix optional Google auth detection
* Format app.py line length for ruff compliance
* Fix auth config check and URL escaping in form
- _google_auth_configured() now checks env var presence (intent), not
the fetched SSM value — prevents silent auth bypass if SSM param is
deleted
- Add </script> escaping to URL values in generate_form.py for
consistency with other injected values
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-05-13 18:00:21 -04:00
|
|
|
meal_totals = defaultdict(
|
|
|
|
|
lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0}
|
|
|
|
|
)
|
2026-05-12 18:25:15 -04:00
|
|
|
for order in orders:
|
|
|
|
|
for item in order.get("items", []):
|
|
|
|
|
name = item["name"]
|
|
|
|
|
qty = int(item.get("quantity", 0))
|
|
|
|
|
meal_totals[name]["quantity"] += qty
|
Add discount pricing, Google auth, and order hardening (#10)
* Add discount settings and two-tier pricing to order aggregation
Settings CRUD (get_settings/put_settings) for DynamoDB CONFIG#SETTINGS item.
Aggregation now tracks bulk_price and employee_price separately, with
grand_total (company cost) and employee_total (payroll deductions).
* Add Google OAuth, server-side discounts, and Slack order confirmations
Submit order Lambda now verifies Google ID tokens via tokeninfo endpoint,
calculates two-tier discount pricing server-side, and async-invokes the
Slack notifier for per-employee order confirmation DMs. Deadlines updated
to Thursday 11:59pm across all Slack messages.
* Update SAM template for Google auth, Slack invocation, and deadline change
Add SLACK_NOTIFIER_ARN and GOOGLE_CLIENT_ID_PARAM env vars to submit order
function with lambda:InvokeFunction policy. Move close-form schedule to
Thursday 11:59pm EST/EDT.
* Update order form UI and CI workflow for new features
Form now shows discount pricing, responsive grid layout, Google Sign-In
overlay, and closed-orders page with countdown timer. CI workflow fetches
discount settings from DynamoDB and Google Client ID from SSM.
* Add SSM GetParameter permission to submit order Lambda
Required for reading the Google Client ID from Parameter Store
at /meal-order-manager/google-client-id.
* Harden auth, pricing, and reliability in order handlers
Enforce Google auth when configured (reject missing tokens with 403),
return 503 on token verification outages, switch to Decimal with
ROUND_HALF_UP for financial precision, clamp discount bounds 0-100,
use email-based slugs, add 5-min cache TTL with time.monotonic(),
wrap Slack invocation in try/except, add reopen_at timestamp to
closed form status, add reminder dedup guards for dual EST/EDT crons,
escape Slack mrkdwn special characters, and handle empty employee names.
* Fix XSS risks and add closed-form UX to order page
Add escapeHtml() for all scraped content in innerHTML, fix script
injection via </script> in JSON, fix JWT base64url decoding, match
backend two-step rounding in JS employeePrice(), disable qty buttons
and submit when form is closed, add server-driven countdown from
reopen_at, add duplicate order warning via localStorage, add back
button after submission, embed favicon, use :g format for fractional
discounts, and exclude dead loadRoster code when Google auth enabled.
* Document CORS, cron idempotency, and SSM config in template
Add comments explaining CORS dev server strategy, dual EST/EDT cron
idempotency, and manual SSM parameter creation for Google Client ID.
* Add unit tests for submit, notify, and aggregate handlers
50 tests covering pricing pipeline (Decimal rounding, clamping, totals),
Google auth (enforcement, bypass prevention, audience/domain validation,
503 on outage), email slug generation, form status with reopen_at,
input validation, Slack failure resilience, reminder dedup guards,
order confirmation DMs, aggregated summaries, CSV generation, and
mrkdwn escaping.
* Use full email as order slug for defense-in-depth
Replace email-prefix slug with full lowercase email to eliminate any
possibility of cross-domain collisions, per senior review sign-off.
* Remove unused imports flagged by ruff
* Apply ruff formatting
* Fix PR review findings: auth, rounding, and close-form guard
- Remove dead elif branch in submit_order auth (always returned 403)
- Catch HTTPError before URLError so expired tokens return 403 not 503
- Wrap SSM get_parameter in try/except for fresh deployments
- Add wall-clock guard to close_form handler (Friday >= 11 PM ET)
- Add epsilon nudge to JS employeePrice for IEEE 754 boundary match
- Switch Flask dev server from round() to Decimal ROUND_HALF_UP
- Add tests for HTTPError handling and close_form guard (6 new tests)
* Fix close-form weekday guard and SSM auth fail-open
- Close form guard: check weekday == 3 (Thursday), not 4 (Friday) — the
crons fire at Thursday 11:59 PM ET, when weekday() is 3
- SSM fail-closed: separate _google_auth_configured() (checks env var) from
_get_google_client_id() (fetches value). If auth is configured but the SSM
fetch fails, return 503 instead of silently falling back to manual auth
- Update close_form tests to use Thursday dates
- Add test_ssm_failure_fails_closed
* Harden Flask dev server auth and escaping
- Add hosted domain check to _verify_google_token (mirror Lambda)
- Gate auth on config (client_id presence), not request body — prevents
bypass by omitting google_id_token when auth is configured
- Add discount percentage clamping to match Lambda handler
- Add </script> escaping to google_client_id_json
* fix: Email order filenames, SSM param TTL, DST-safe reopen_at
- Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo)
- shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes
- form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta)
- Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* Apply ruff formatting to submit_order handler
* fix(server): retry SSM for Google client id after TTL on failure
Transient SSM errors no longer cache empty client id for the process lifetime;
matches Lambda handler refresh behavior (300s TTL).
Co-authored-by: Cursor <cursoragent@cursor.com>
* style(server): ruff-format Google client id cache helper
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(close-form): accept Fri 00–03 ET catch-up after Thu close cron
EventBridge can deliver past midnight ET; widen the wall-clock guard so a
delayed Thursday 23:59 UTC cron still closes the form. Idempotent when already
closed. Adds test for early Friday; past-window skip now starts Fri 04:00 ET.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix(submit-order): bill from Dynamo menu retail, not client JSON
Load authoritative meal prices from get_menu(week); reject unknown meal names
and return 503 when the menu has no priced meals. Use meal_name in the pricing
loop to avoid shadowing the employee name. Adds regression tests for tampering,
unknown meals, and empty menu meals.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix: use single braces in loadRoster JS nested string
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix Eastern fallback countdown
* Fix pricing validation and JWT display decoding
* Fix optional Google auth detection
* Format app.py line length for ruff compliance
* Fix auth config check and URL escaping in form
- _google_auth_configured() now checks env var presence (intent), not
the fetched SSM value — prevents silent auth bypass if SSM param is
deleted
- Add </script> escaping to URL values in generate_form.py for
consistency with other injected values
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-05-13 18:00:21 -04:00
|
|
|
meal_totals[name]["bulk_price"] = float(
|
|
|
|
|
item.get("bulk_price", item.get("price", 0))
|
|
|
|
|
)
|
|
|
|
|
meal_totals[name]["employee_price"] = float(item.get("price", 0))
|
2026-05-12 18:25:15 -04:00
|
|
|
|
|
|
|
|
meals = []
|
|
|
|
|
for name, data in sorted(meal_totals.items()):
|
2026-05-12 19:31:27 -04:00
|
|
|
meals.append(
|
|
|
|
|
{
|
|
|
|
|
"meal": name,
|
|
|
|
|
"quantity": data["quantity"],
|
Add discount pricing, Google auth, and order hardening (#10)
* Add discount settings and two-tier pricing to order aggregation
Settings CRUD (get_settings/put_settings) for DynamoDB CONFIG#SETTINGS item.
Aggregation now tracks bulk_price and employee_price separately, with
grand_total (company cost) and employee_total (payroll deductions).
* Add Google OAuth, server-side discounts, and Slack order confirmations
Submit order Lambda now verifies Google ID tokens via tokeninfo endpoint,
calculates two-tier discount pricing server-side, and async-invokes the
Slack notifier for per-employee order confirmation DMs. Deadlines updated
to Thursday 11:59pm across all Slack messages.
* Update SAM template for Google auth, Slack invocation, and deadline change
Add SLACK_NOTIFIER_ARN and GOOGLE_CLIENT_ID_PARAM env vars to submit order
function with lambda:InvokeFunction policy. Move close-form schedule to
Thursday 11:59pm EST/EDT.
* Update order form UI and CI workflow for new features
Form now shows discount pricing, responsive grid layout, Google Sign-In
overlay, and closed-orders page with countdown timer. CI workflow fetches
discount settings from DynamoDB and Google Client ID from SSM.
* Add SSM GetParameter permission to submit order Lambda
Required for reading the Google Client ID from Parameter Store
at /meal-order-manager/google-client-id.
* Harden auth, pricing, and reliability in order handlers
Enforce Google auth when configured (reject missing tokens with 403),
return 503 on token verification outages, switch to Decimal with
ROUND_HALF_UP for financial precision, clamp discount bounds 0-100,
use email-based slugs, add 5-min cache TTL with time.monotonic(),
wrap Slack invocation in try/except, add reopen_at timestamp to
closed form status, add reminder dedup guards for dual EST/EDT crons,
escape Slack mrkdwn special characters, and handle empty employee names.
* Fix XSS risks and add closed-form UX to order page
Add escapeHtml() for all scraped content in innerHTML, fix script
injection via </script> in JSON, fix JWT base64url decoding, match
backend two-step rounding in JS employeePrice(), disable qty buttons
and submit when form is closed, add server-driven countdown from
reopen_at, add duplicate order warning via localStorage, add back
button after submission, embed favicon, use :g format for fractional
discounts, and exclude dead loadRoster code when Google auth enabled.
* Document CORS, cron idempotency, and SSM config in template
Add comments explaining CORS dev server strategy, dual EST/EDT cron
idempotency, and manual SSM parameter creation for Google Client ID.
* Add unit tests for submit, notify, and aggregate handlers
50 tests covering pricing pipeline (Decimal rounding, clamping, totals),
Google auth (enforcement, bypass prevention, audience/domain validation,
503 on outage), email slug generation, form status with reopen_at,
input validation, Slack failure resilience, reminder dedup guards,
order confirmation DMs, aggregated summaries, CSV generation, and
mrkdwn escaping.
* Use full email as order slug for defense-in-depth
Replace email-prefix slug with full lowercase email to eliminate any
possibility of cross-domain collisions, per senior review sign-off.
* Remove unused imports flagged by ruff
* Apply ruff formatting
* Fix PR review findings: auth, rounding, and close-form guard
- Remove dead elif branch in submit_order auth (always returned 403)
- Catch HTTPError before URLError so expired tokens return 403 not 503
- Wrap SSM get_parameter in try/except for fresh deployments
- Add wall-clock guard to close_form handler (Friday >= 11 PM ET)
- Add epsilon nudge to JS employeePrice for IEEE 754 boundary match
- Switch Flask dev server from round() to Decimal ROUND_HALF_UP
- Add tests for HTTPError handling and close_form guard (6 new tests)
* Fix close-form weekday guard and SSM auth fail-open
- Close form guard: check weekday == 3 (Thursday), not 4 (Friday) — the
crons fire at Thursday 11:59 PM ET, when weekday() is 3
- SSM fail-closed: separate _google_auth_configured() (checks env var) from
_get_google_client_id() (fetches value). If auth is configured but the SSM
fetch fails, return 503 instead of silently falling back to manual auth
- Update close_form tests to use Thursday dates
- Add test_ssm_failure_fails_closed
* Harden Flask dev server auth and escaping
- Add hosted domain check to _verify_google_token (mirror Lambda)
- Gate auth on config (client_id presence), not request body — prevents
bypass by omitting google_id_token when auth is configured
- Add discount percentage clamping to match Lambda handler
- Add </script> escaping to google_client_id_json
* fix: Email order filenames, SSM param TTL, DST-safe reopen_at
- Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo)
- shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes
- form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta)
- Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* Apply ruff formatting to submit_order handler
* fix(server): retry SSM for Google client id after TTL on failure
Transient SSM errors no longer cache empty client id for the process lifetime;
matches Lambda handler refresh behavior (300s TTL).
Co-authored-by: Cursor <cursoragent@cursor.com>
* style(server): ruff-format Google client id cache helper
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(close-form): accept Fri 00–03 ET catch-up after Thu close cron
EventBridge can deliver past midnight ET; widen the wall-clock guard so a
delayed Thursday 23:59 UTC cron still closes the form. Idempotent when already
closed. Adds test for early Friday; past-window skip now starts Fri 04:00 ET.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix(submit-order): bill from Dynamo menu retail, not client JSON
Load authoritative meal prices from get_menu(week); reject unknown meal names
and return 503 when the menu has no priced meals. Use meal_name in the pricing
loop to avoid shadowing the employee name. Adds regression tests for tampering,
unknown meals, and empty menu meals.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix: use single braces in loadRoster JS nested string
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix Eastern fallback countdown
* Fix pricing validation and JWT display decoding
* Fix optional Google auth detection
* Format app.py line length for ruff compliance
* Fix auth config check and URL escaping in form
- _google_auth_configured() now checks env var presence (intent), not
the fetched SSM value — prevents silent auth bypass if SSM param is
deleted
- Add </script> escaping to URL values in generate_form.py for
consistency with other injected values
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-05-13 18:00:21 -04:00
|
|
|
"unit_price": data["bulk_price"],
|
|
|
|
|
"employee_unit_price": data["employee_price"],
|
|
|
|
|
"line_total": round(data["bulk_price"] * data["quantity"], 2),
|
|
|
|
|
"employee_line_total": round(
|
|
|
|
|
data["employee_price"] * data["quantity"], 2
|
|
|
|
|
),
|
2026-05-12 19:31:27 -04:00
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 18:25:15 -04:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"week": week,
|
|
|
|
|
"generated_at": datetime.now(EASTERN).isoformat(),
|
|
|
|
|
"total_employees": len(orders),
|
|
|
|
|
"total_meals": sum(m["quantity"] for m in meals),
|
|
|
|
|
"grand_total": round(sum(m["line_total"] for m in meals), 2),
|
Add discount pricing, Google auth, and order hardening (#10)
* Add discount settings and two-tier pricing to order aggregation
Settings CRUD (get_settings/put_settings) for DynamoDB CONFIG#SETTINGS item.
Aggregation now tracks bulk_price and employee_price separately, with
grand_total (company cost) and employee_total (payroll deductions).
* Add Google OAuth, server-side discounts, and Slack order confirmations
Submit order Lambda now verifies Google ID tokens via tokeninfo endpoint,
calculates two-tier discount pricing server-side, and async-invokes the
Slack notifier for per-employee order confirmation DMs. Deadlines updated
to Thursday 11:59pm across all Slack messages.
* Update SAM template for Google auth, Slack invocation, and deadline change
Add SLACK_NOTIFIER_ARN and GOOGLE_CLIENT_ID_PARAM env vars to submit order
function with lambda:InvokeFunction policy. Move close-form schedule to
Thursday 11:59pm EST/EDT.
* Update order form UI and CI workflow for new features
Form now shows discount pricing, responsive grid layout, Google Sign-In
overlay, and closed-orders page with countdown timer. CI workflow fetches
discount settings from DynamoDB and Google Client ID from SSM.
* Add SSM GetParameter permission to submit order Lambda
Required for reading the Google Client ID from Parameter Store
at /meal-order-manager/google-client-id.
* Harden auth, pricing, and reliability in order handlers
Enforce Google auth when configured (reject missing tokens with 403),
return 503 on token verification outages, switch to Decimal with
ROUND_HALF_UP for financial precision, clamp discount bounds 0-100,
use email-based slugs, add 5-min cache TTL with time.monotonic(),
wrap Slack invocation in try/except, add reopen_at timestamp to
closed form status, add reminder dedup guards for dual EST/EDT crons,
escape Slack mrkdwn special characters, and handle empty employee names.
* Fix XSS risks and add closed-form UX to order page
Add escapeHtml() for all scraped content in innerHTML, fix script
injection via </script> in JSON, fix JWT base64url decoding, match
backend two-step rounding in JS employeePrice(), disable qty buttons
and submit when form is closed, add server-driven countdown from
reopen_at, add duplicate order warning via localStorage, add back
button after submission, embed favicon, use :g format for fractional
discounts, and exclude dead loadRoster code when Google auth enabled.
* Document CORS, cron idempotency, and SSM config in template
Add comments explaining CORS dev server strategy, dual EST/EDT cron
idempotency, and manual SSM parameter creation for Google Client ID.
* Add unit tests for submit, notify, and aggregate handlers
50 tests covering pricing pipeline (Decimal rounding, clamping, totals),
Google auth (enforcement, bypass prevention, audience/domain validation,
503 on outage), email slug generation, form status with reopen_at,
input validation, Slack failure resilience, reminder dedup guards,
order confirmation DMs, aggregated summaries, CSV generation, and
mrkdwn escaping.
* Use full email as order slug for defense-in-depth
Replace email-prefix slug with full lowercase email to eliminate any
possibility of cross-domain collisions, per senior review sign-off.
* Remove unused imports flagged by ruff
* Apply ruff formatting
* Fix PR review findings: auth, rounding, and close-form guard
- Remove dead elif branch in submit_order auth (always returned 403)
- Catch HTTPError before URLError so expired tokens return 403 not 503
- Wrap SSM get_parameter in try/except for fresh deployments
- Add wall-clock guard to close_form handler (Friday >= 11 PM ET)
- Add epsilon nudge to JS employeePrice for IEEE 754 boundary match
- Switch Flask dev server from round() to Decimal ROUND_HALF_UP
- Add tests for HTTPError handling and close_form guard (6 new tests)
* Fix close-form weekday guard and SSM auth fail-open
- Close form guard: check weekday == 3 (Thursday), not 4 (Friday) — the
crons fire at Thursday 11:59 PM ET, when weekday() is 3
- SSM fail-closed: separate _google_auth_configured() (checks env var) from
_get_google_client_id() (fetches value). If auth is configured but the SSM
fetch fails, return 503 instead of silently falling back to manual auth
- Update close_form tests to use Thursday dates
- Add test_ssm_failure_fails_closed
* Harden Flask dev server auth and escaping
- Add hosted domain check to _verify_google_token (mirror Lambda)
- Gate auth on config (client_id presence), not request body — prevents
bypass by omitting google_id_token when auth is configured
- Add discount percentage clamping to match Lambda handler
- Add </script> escaping to google_client_id_json
* fix: Email order filenames, SSM param TTL, DST-safe reopen_at
- Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo)
- shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes
- form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta)
- Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* Apply ruff formatting to submit_order handler
* fix(server): retry SSM for Google client id after TTL on failure
Transient SSM errors no longer cache empty client id for the process lifetime;
matches Lambda handler refresh behavior (300s TTL).
Co-authored-by: Cursor <cursoragent@cursor.com>
* style(server): ruff-format Google client id cache helper
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(close-form): accept Fri 00–03 ET catch-up after Thu close cron
EventBridge can deliver past midnight ET; widen the wall-clock guard so a
delayed Thursday 23:59 UTC cron still closes the form. Idempotent when already
closed. Adds test for early Friday; past-window skip now starts Fri 04:00 ET.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix(submit-order): bill from Dynamo menu retail, not client JSON
Load authoritative meal prices from get_menu(week); reject unknown meal names
and return 503 when the menu has no priced meals. Use meal_name in the pricing
loop to avoid shadowing the employee name. Adds regression tests for tampering,
unknown meals, and empty menu meals.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix: use single braces in loadRoster JS nested string
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix Eastern fallback countdown
* Fix pricing validation and JWT display decoding
* Fix optional Google auth detection
* Format app.py line length for ruff compliance
* Fix auth config check and URL escaping in form
- _google_auth_configured() now checks env var presence (intent), not
the fetched SSM value — prevents silent auth bypass if SSM param is
deleted
- Add </script> escaping to URL values in generate_form.py for
consistency with other injected values
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-05-13 18:00:21 -04:00
|
|
|
"employee_total": round(sum(m["employee_line_total"] for m in meals), 2),
|
2026-05-12 18:25:15 -04:00
|
|
|
"meals": meals,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_order_summary_csv(summary: dict) -> str:
|
|
|
|
|
buf = io.StringIO()
|
|
|
|
|
writer = csv.writer(buf)
|
|
|
|
|
writer.writerow(["Meal", "Quantity", "Unit Price", "Line Total"])
|
|
|
|
|
for meal in summary["meals"]:
|
2026-05-12 19:31:27 -04:00
|
|
|
writer.writerow(
|
|
|
|
|
[
|
|
|
|
|
meal["meal"],
|
|
|
|
|
meal["quantity"],
|
|
|
|
|
f"${meal['unit_price']:.2f}",
|
|
|
|
|
f"${meal['line_total']:.2f}",
|
|
|
|
|
]
|
|
|
|
|
)
|
2026-05-12 18:25:15 -04:00
|
|
|
writer.writerow([])
|
2026-05-12 19:31:27 -04:00
|
|
|
writer.writerow(
|
|
|
|
|
["TOTAL", summary["total_meals"], "", f"${summary['grand_total']:.2f}"]
|
|
|
|
|
)
|
2026-05-12 18:25:15 -04:00
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_payroll_csv(orders: list[dict]) -> str:
|
|
|
|
|
buf = io.StringIO()
|
|
|
|
|
writer = csv.writer(buf)
|
2026-05-12 19:31:27 -04:00
|
|
|
writer.writerow(
|
|
|
|
|
["Employee Name", "Employee Email", "Items Ordered", "Total Deduction"]
|
|
|
|
|
)
|
2026-05-12 18:25:15 -04:00
|
|
|
for order in sorted(orders, key=lambda o: o["employee_name"]):
|
|
|
|
|
items_str = "; ".join(
|
|
|
|
|
f"{item['name']} x{int(item['quantity'])} (${float(item.get('subtotal', float(item.get('price', 0)) * int(item.get('quantity', 0)))):.2f})"
|
|
|
|
|
for item in order.get("items", [])
|
|
|
|
|
)
|
2026-05-12 19:31:27 -04:00
|
|
|
writer.writerow(
|
|
|
|
|
[
|
|
|
|
|
order["employee_name"],
|
|
|
|
|
order["employee_email"],
|
|
|
|
|
items_str,
|
|
|
|
|
f"${float(order['total']):.2f}",
|
|
|
|
|
]
|
|
|
|
|
)
|
2026-05-12 18:25:15 -04:00
|
|
|
return buf.getvalue()
|