diff --git a/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_charge.py b/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_charge.py index fbb9de5..4533b7f 100644 --- a/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_charge.py +++ b/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_charge.py @@ -33,7 +33,12 @@ class ProcessChargeInput(BaseModel): booking_reference: str = Field( ..., description="Booking reference number", examples=["BKG-00012345"] ) - charge_amount: Decimal = Field(..., description="Charge amount", examples=["50.00"]) + charge_amount: float = Field( + ..., + description="Charge amount", + examples=[50.00], + allow_inf_nan=False, + ) reason: str = Field( ..., description="Reason for charge", examples=["modification_fee"] ) @@ -87,8 +92,10 @@ async def run( if not booking: raise Tool.ExecutionError(f"Booking not found: {request.booking_reference}") + charge_amount = Decimal(str(request.charge_amount)) + # Validate charge amount - if request.charge_amount <= 0: + if charge_amount <= 0: raise Tool.ExecutionError("Invalid charge amount - must be greater than 0") # Get existing transactions to generate sequential ID @@ -102,7 +109,7 @@ async def run( transaction_id=transaction_id, booking_reference=request.booking_reference, customer_id=booking.customer_id, - amount=request.charge_amount.quantize(TWO_PLACES, rounding=ROUND_HALF_UP), + amount=charge_amount.quantize(TWO_PLACES, rounding=ROUND_HALF_UP), currency="USD", transaction_type=TransactionType.CHARGE, payment_status=PaymentStatus.SUCCESSFUL, diff --git a/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_charge_dispute.py b/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_charge_dispute.py index 719f2e6..109000b 100644 --- a/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_charge_dispute.py +++ b/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_charge_dispute.py @@ -37,8 +37,11 @@ class ProcessChargeDisputeInput(BaseModel): dispute_reason: str = Field( ..., description="Reason for the dispute", examples=["unauthorized_charge"] ) - dispute_amount: Decimal = Field( - ..., description="Amount being disputed", examples=["150.00"] + dispute_amount: float = Field( + ..., + description="Amount being disputed", + examples=[150.00], + allow_inf_nan=False, ) @@ -99,13 +102,15 @@ async def run( dispute_num = len(dispute_transactions) + 1 dispute_case_id = f"DSP-{dispute_num:08d}" + dispute_amount = Decimal(str(request.dispute_amount)) + # Create dispute transaction dispute_transaction = Transaction( id=dispute_case_id, transaction_id=dispute_case_id, booking_reference=original_transaction.booking_reference, customer_id=original_transaction.customer_id, - amount=request.dispute_amount.quantize(TWO_PLACES, rounding=ROUND_HALF_UP), + amount=dispute_amount.quantize(TWO_PLACES, rounding=ROUND_HALF_UP), currency="USD", transaction_type=TransactionType.DISPUTE, payment_status=PaymentStatus.PENDING, diff --git a/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_refund.py b/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_refund.py index bd7bfa2..1ceea60 100644 --- a/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_refund.py +++ b/servers/tb_business_ops_servers_202606/tb_business_ops_servers_202606/toolslib/external_booking/payment_api/tools/process_refund.py @@ -33,8 +33,11 @@ class ProcessRefundInput(BaseModel): booking_reference: str = Field( ..., description="Booking reference number", examples=["BKG-00012345"] ) - refund_amount: Decimal = Field( - ..., description="Refund amount", examples=["250.00"] + refund_amount: float = Field( + ..., + description="Refund amount", + examples=[250.00], + allow_inf_nan=False, ) reason: str = Field(..., description="Reason for refund", examples=["cancellation"]) @@ -89,6 +92,8 @@ async def run( if not booking: raise Tool.ExecutionError(f"Booking not found: {request.booking_reference}") + refund_amount = Decimal(str(request.refund_amount)) + # Get existing transactions to generate sequential ID all_transactions = db.get_all(Transaction) transaction_num = len(all_transactions) + 1 @@ -100,7 +105,7 @@ async def run( transaction_id=transaction_id, booking_reference=request.booking_reference, customer_id=booking.customer_id, - amount=request.refund_amount.quantize(TWO_PLACES, rounding=ROUND_HALF_UP), + amount=refund_amount.quantize(TWO_PLACES, rounding=ROUND_HALF_UP), currency="USD", transaction_type=TransactionType.REFUND, payment_status=PaymentStatus.SUCCESSFUL, diff --git a/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_charge.py b/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_charge.py index 607f8a5..2aaf393 100644 --- a/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_charge.py +++ b/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_charge.py @@ -60,6 +60,29 @@ def db_with_booking(): return db +def test_process_charge_schema_uses_json_number(): + """Test that the tool schema is compatible with the Responses API.""" + amount_schema = ProcessCharge().input_schema["properties"]["charge_amount"] + + assert amount_schema["type"] == "number" + assert "pattern" not in amount_schema + + +@pytest.mark.parametrize("charge_amount", [float("nan"), float("inf"), float("-inf")]) +@pytest.mark.anyio +async def test_process_charge_rejects_non_finite_amount(db_with_booking, charge_amount): + """Test that non-finite amounts fail input validation.""" + with pytest.raises(Exception, match="finite number"): + await ProcessCharge().run_with_validation( + db_with_booking, + { + "booking_reference": "BKG-00012345", + "charge_amount": charge_amount, + "reason": "test", + }, + ) + + @pytest.mark.anyio async def test_process_charge_success(db_with_booking): """Test successfully processing a charge.""" @@ -81,14 +104,18 @@ async def test_process_charge_success(db_with_booking): @pytest.mark.anyio async def test_process_charge_creates_transaction(db_with_booking): - """Test that charge creates a transaction record.""" + """Test that charge creates a transaction record, rounding the amount half-up. + + 2.675 is not exactly representable as a float, so this also pins the + decimal-string conversion that keeps rounding away from the binary value. + """ tool = ProcessCharge() await tool.run_with_validation( db_with_booking, { "booking_reference": "BKG-00012345", - "charge_amount": 75.00, + "charge_amount": 2.675, "reason": "late_checkout_fee", }, ) @@ -99,7 +126,9 @@ async def test_process_charge_creates_transaction(db_with_booking): txn = transactions[0] assert txn.transaction_type == TransactionType.CHARGE - assert txn.amount == Decimal("75.00") + # Decimal equality ignores scale, so also pin the exact 2-decimal-place repr. + assert txn.amount == Decimal("2.68") + assert str(txn.amount) == "2.68" assert txn.payment_status == PaymentStatus.SUCCESSFUL assert txn.reason == "late_checkout_fee" diff --git a/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_charge_dispute.py b/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_charge_dispute.py index cbc1755..4accee8 100644 --- a/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_charge_dispute.py +++ b/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_charge_dispute.py @@ -3,6 +3,8 @@ """Tests for process_charge_dispute tool.""" +from decimal import Decimal + import pytest from tb_business_ops_servers_202606.toolslib.external_booking.payment_api.models import ( PaymentStatus, @@ -48,6 +50,31 @@ def db_with_transaction(): return db +def test_process_charge_dispute_schema_uses_json_number(): + """Test that the tool schema is compatible with the Responses API.""" + amount_schema = ProcessChargeDispute().input_schema["properties"]["dispute_amount"] + + assert amount_schema["type"] == "number" + assert "pattern" not in amount_schema + + +@pytest.mark.parametrize("dispute_amount", [float("nan"), float("inf"), float("-inf")]) +@pytest.mark.anyio +async def test_process_charge_dispute_rejects_non_finite_amount( + db_with_transaction, dispute_amount +): + """Test that non-finite amounts fail input validation.""" + with pytest.raises(Exception, match="finite number"): + await ProcessChargeDispute().run_with_validation( + db_with_transaction, + { + "transaction_id": "TXN-00000001", + "dispute_reason": "test", + "dispute_amount": dispute_amount, + }, + ) + + @pytest.mark.anyio async def test_process_charge_dispute_success(db_with_transaction): """Test successfully processing a charge dispute.""" @@ -69,7 +96,11 @@ async def test_process_charge_dispute_success(db_with_transaction): @pytest.mark.anyio async def test_process_charge_dispute_creates_transaction(db_with_transaction): - """Test that dispute creates a transaction record.""" + """Test that dispute creates a transaction record, rounding the amount half-up. + + 2.675 is not exactly representable as a float, so this also pins the + decimal-string conversion that keeps rounding away from the binary value. + """ tool = ProcessChargeDispute() await tool.run_with_validation( @@ -77,7 +108,7 @@ async def test_process_charge_dispute_creates_transaction(db_with_transaction): { "transaction_id": "TXN-00000001", "dispute_reason": "unrecognized charge", - "dispute_amount": 150.00, + "dispute_amount": 2.675, }, ) @@ -90,7 +121,9 @@ async def test_process_charge_dispute_creates_transaction(db_with_transaction): assert len(dispute_txns) == 1 dispute = dispute_txns[0] assert dispute.transaction_type == TransactionType.DISPUTE - assert dispute.amount == 150.00 + # Decimal equality ignores scale, so also pin the exact 2-decimal-place repr. + assert dispute.amount == Decimal("2.68") + assert str(dispute.amount) == "2.68" assert dispute.payment_status == PaymentStatus.PENDING assert dispute.reason == "unrecognized charge" diff --git a/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_refund.py b/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_refund.py index f055c04..4e0d82a 100644 --- a/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_refund.py +++ b/servers/tb_business_ops_servers_202606/tests/external_booking/payment_api/test_process_refund.py @@ -3,6 +3,8 @@ """Tests for process_refund tool.""" +from decimal import Decimal + import pytest from tb_business_ops_servers_202606.toolslib.external_booking.booking_api.models import ( BoardType, @@ -58,6 +60,29 @@ def db_with_booking(): return db +def test_process_refund_schema_uses_json_number(): + """Test that the tool schema is compatible with the Responses API.""" + amount_schema = ProcessRefund().input_schema["properties"]["refund_amount"] + + assert amount_schema["type"] == "number" + assert "pattern" not in amount_schema + + +@pytest.mark.parametrize("refund_amount", [float("nan"), float("inf"), float("-inf")]) +@pytest.mark.anyio +async def test_process_refund_rejects_non_finite_amount(db_with_booking, refund_amount): + """Test that non-finite amounts fail input validation.""" + with pytest.raises(Exception, match="finite number"): + await ProcessRefund().run_with_validation( + db_with_booking, + { + "booking_reference": "BKG-00012345", + "refund_amount": refund_amount, + "reason": "test", + }, + ) + + @pytest.mark.anyio async def test_process_refund_success(db_with_booking): """Test successfully processing a refund.""" @@ -80,14 +105,18 @@ async def test_process_refund_success(db_with_booking): @pytest.mark.anyio async def test_process_refund_creates_transaction(db_with_booking): - """Test that refund creates a transaction record.""" + """Test that refund creates a transaction record, rounding the amount half-up. + + 2.675 is not exactly representable as a float, so this also pins the + decimal-string conversion that keeps rounding away from the binary value. + """ tool = ProcessRefund() await tool.run_with_validation( db_with_booking, { "booking_reference": "BKG-00012345", - "refund_amount": 150.00, + "refund_amount": 2.675, "reason": "service issue", }, ) @@ -98,7 +127,9 @@ async def test_process_refund_creates_transaction(db_with_booking): txn = transactions[0] assert txn.transaction_type == TransactionType.REFUND - assert txn.amount == 150.00 + # Decimal equality ignores scale, so also pin the exact 2-decimal-place repr. + assert txn.amount == Decimal("2.68") + assert str(txn.amount) == "2.68" assert txn.payment_status == PaymentStatus.SUCCESSFUL assert txn.reason == "service issue"