Skip to content
11 changes: 11 additions & 0 deletions app/billing/billing_schemas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
from datetime import datetime

get_sms_cost_for_service_schema = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "GET schema for retrieving SMS cost for a service in a date range",
"type": "object",
"properties": {
"start_date": {"type": "string", "format": "date"},
"end_date": {"type": "string", "format": "date"},
},
"required": ["start_date", "end_date"],
}

create_or_update_free_sms_fragment_limit_schema = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "POST annual billing schema",
Expand Down
30 changes: 30 additions & 0 deletions app/billing/rest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from datetime import date

from flask import Blueprint, jsonify, request

from app.billing.billing_schemas import (
create_or_update_free_sms_fragment_limit_schema,
get_sms_cost_for_service_schema,
serialize_ft_billing_remove_emails,
serialize_ft_billing_yearly_totals,
)
Expand All @@ -13,6 +16,7 @@
)
from app.dao.date_util import get_current_financial_year_start_year
from app.dao.fact_billing_dao import (
dao_fetch_sms_cost_for_service_in_range,
fetch_billing_totals_for_year,
fetch_monthly_billing_for_year,
)
Expand Down Expand Up @@ -50,6 +54,32 @@ def get_yearly_billing_usage_summary_from_ft_billing(service_id):
return jsonify(data)


@billing_blueprint.route("/sms-cost", methods=["GET"])
def get_sms_cost_for_service(service_id):
data = {
"start_date": request.args.get("start_date"),
"end_date": request.args.get("end_date"),
}
validate(data, get_sms_cost_for_service_schema)

start_date = date.fromisoformat(data["start_date"])
end_date = date.fromisoformat(data["end_date"])

if start_date > end_date:
raise InvalidRequest("start_date must be before end_date", 400)
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation error message says “start_date must be before end_date”, but the code only rejects start_date > end_date (same-day ranges are allowed). Consider changing the message to reflect the actual constraint (eg “start_date must be on or before end_date”) to avoid confusing API consumers.

Suggested change
raise InvalidRequest("start_date must be before end_date", 400)
raise InvalidRequest("start_date must be on or before end_date", 400)

Copilot uses AI. Check for mistakes.

result = dao_fetch_sms_cost_for_service_in_range(service_id, start_date, end_date)

return jsonify(
{
"start_date": data["start_date"],
"end_date": data["end_date"],
"fragment_count": int(result["fragment_count"]),
"total_cost": float(result["total_cost"]),
}
), 200


@billing_blueprint.route("/free-sms-fragment-limit", methods=["GET"])
def get_free_sms_fragment_limit(service_id):
financial_year_start = request.args.get("financial_year_start")
Expand Down
72 changes: 72 additions & 0 deletions app/dao/fact_billing_dao.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,78 @@
from app.utils import get_local_timezone_midnight_in_utc


def dao_fetch_sms_cost_for_service_in_range(service_id, start_date, end_date):
"""Return the total SMS cost and fragment count for a service in the given date range (inclusive).

FactBilling is populated by a nightly task, so it is always ~1 day behind.
For the current day we fall back to the Notification table and use the
carrier-reported costs (sms_total_carrier_fee + sms_total_message_price).

Minor edge case:
If this API is called at 00:15 EST and prior to `create_nightly_billing_for_day` completing, then
this endpoint could return inaccurate data.

Future improvements could include:
1. Check if `ft_billing` has been populated for the requested date range and if not, fall back to the `notifications` table
2. Always use the `notifications` table for today and yesterday's data, and `ft_billing` for anything older
"""
today = convert_utc_to_local_timezone(datetime.utcnow()).date()

fragment_count = 0
total_cost = Decimal(0)

# Historical data from FactBilling (up to yesterday)
if start_date < today:
fact_end = min(end_date, today - timedelta(days=1))
fact_result = (
db.session.query(
func.coalesce(func.sum(FactBilling.billable_units), 0).label("fragment_count"),
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fragment_count for historical ft_billing rows is currently computed as sum(FactBilling.billable_units), but elsewhere in this module SMS “billable units/fragments” are consistently billable_units * rate_multiplier (see fetch_sms_free_allowance_remainder and fetch_sms_billing_for_all_services). As written, this endpoint undercounts fragments for any row with rate_multiplier != 1 and makes fragment_count inconsistent with the total_cost calculation (which does apply the multiplier). Update the query to sum FactBilling.billable_units * FactBilling.rate_multiplier (with appropriate coalesce) and adjust downstream tests accordingly.

Suggested change
func.coalesce(func.sum(FactBilling.billable_units), 0).label("fragment_count"),
func.coalesce(
func.sum(FactBilling.billable_units * func.coalesce(FactBilling.rate_multiplier, 1)), 0
).label("fragment_count"),

Copilot uses AI. Check for mistakes.
func.coalesce(func.sum(FactBilling.billable_units * FactBilling.rate_multiplier * FactBilling.rate), 0).label(
"total_cost"
),
)
.filter(
FactBilling.service_id == service_id,
FactBilling.bst_date >= start_date,
FactBilling.bst_date <= fact_end,
FactBilling.notification_type == SMS_TYPE,
)
.one()
)
fragment_count += int(fact_result.fragment_count)
total_cost += Decimal(str(fact_result.total_cost))

# Current-day data from Notification table
if end_date >= today:
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Notification-table branch runs whenever end_date >= today, but it doesn’t check start_date. For a future-only range (eg start_date tomorrow, end_date tomorrow), this will still include today’s notifications because it always queries [today_start, today_end). Gate this branch on start_date <= today <= end_date (or start_date <= today in addition to the existing end_date >= today) so results don’t include out-of-range data.

Suggested change
if end_date >= today:
if start_date <= today and end_date >= today:

Copilot uses AI. Check for mistakes.
today_start = convert_local_timezone_to_utc(datetime.combine(today, time.min))
today_end = convert_local_timezone_to_utc(datetime.combine(today + timedelta(days=1), time.min))
notif_result = (
db.session.query(
func.coalesce(func.sum(Notification.billable_units), 0).label("fragment_count"),
func.coalesce(
func.sum(
func.coalesce(Notification.sms_total_carrier_fee, 0)
+ func.coalesce(Notification.sms_total_message_price, 0)
),
0,
).label("total_cost"),
)
.filter(
Notification.service_id == service_id,
Notification.created_at >= today_start,
Notification.created_at < today_end,
Notification.notification_type == SMS_TYPE,
Notification.status.in_(NOTIFICATION_STATUS_TYPES_BILLABLE),
Notification.key_type != KEY_TYPE_TEST,
)
.one()
)
fragment_count += int(notif_result.fragment_count)
total_cost += Decimal(str(notif_result.total_cost))

return {"fragment_count": fragment_count, "total_cost": total_cost}


def fetch_sms_free_allowance_remainder(start_date):
# ASSUMPTION: AnnualBilling has been populated for year.
billing_year = get_financial_year_for_datetime(start_date)
Expand Down
56 changes: 56 additions & 0 deletions tests/app/billing/test_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,62 @@ def test_get_yearly_billing_usage_summary_from_ft_billing(client, notify_db_sess
assert json_response[2]["letter_total"] == 0


class TestGetSmsCostForService:
def test_get_sms_cost_for_service(self, client, notify_db_session):
service = create_service()
template = create_template(service=service, template_type="sms")
create_ft_billing(
utc_date="2024-06-01",
service=service,
template=template,
notification_type="sms",
billable_unit=10,
rate=0.0162,
)
create_ft_billing(
utc_date="2024-06-15",
service=service,
template=template,
notification_type="sms",
billable_unit=5,
rate=0.0162,
)
response = client.get(
"/service/{}/billing/sms-cost?start_date=2024-06-01&end_date=2024-06-30".format(service.id),
headers=[create_authorization_header()],
)
assert response.status_code == 200
json_resp = json.loads(response.get_data(as_text=True))
assert json_resp["start_date"] == "2024-06-01"
assert json_resp["end_date"] == "2024-06-30"
assert json_resp["fragment_count"] == 15
assert json_resp["total_cost"] == 15 * 0.0162
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion compares floats computed via 15 * 0.0162, which can be sensitive to float representation/rounding (and may vary depending on how the DB value is stored/returned). To avoid flaky tests, assert using pytest.approx(...) or compare using Decimal (eg serialize total_cost consistently and compare as a string/Decimal).

Suggested change
assert json_resp["total_cost"] == 15 * 0.0162
assert json_resp["total_cost"] == pytest.approx(15 * 0.0162)

Copilot uses AI. Check for mistakes.

def test_get_sms_cost_for_service_returns_zero_when_no_data(self, client, sample_service):
response = client.get(
"/service/{}/billing/sms-cost?start_date=2024-01-01&end_date=2024-01-31".format(sample_service.id),
headers=[create_authorization_header()],
)
assert response.status_code == 200
json_resp = json.loads(response.get_data(as_text=True))
assert json_resp["fragment_count"] == 0
assert json_resp["total_cost"] == 0

def test_get_sms_cost_for_service_returns_400_if_missing_dates(self, client, sample_service):
response = client.get(
"/service/{}/billing/sms-cost".format(sample_service.id),
headers=[create_authorization_header()],
)
assert response.status_code == 400

def test_get_sms_cost_for_service_returns_400_if_start_after_end(self, client, sample_service):
response = client.get(
"/service/{}/billing/sms-cost?start_date=2024-06-30&end_date=2024-06-01".format(sample_service.id),
headers=[create_authorization_header()],
)
assert response.status_code == 400


def test_get_yearly_usage_by_monthly_from_ft_billing_all_cases(client, notify_db_session):
service = set_up_data_for_all_cases()
response = client.get(
Expand Down
Loading
Loading