Skip to content
RasaGet a free licence
Menu
AI team casebook

AI product engineer · Shared primitives

Match a payment receipt to the customer’s order

Check the order and amount in a payment receipt before a voice assistant announces that payment succeeded.

by Rod Rivera

About 9 minutesHands-on tutorial

Key takeaways (3)
  • Sending a payment link leaves an order pending until the processor returns a receipt that can be verified.
  • Check the receipt against the requested order and amount before sharing a payment reference or announcing payment.
  • Receipt checks prove response binding in this fixture; they do not establish card recording safety or payment compliance.

A voice assistant sends a customer a payment link. Before it says the order is paid, it needs a receipt from the payment service for that order and amount. A genuine receipt for another order would not answer the customer’s question.

This local exercise uses fictional orders and a public teaching key. You will test a receipt for the wrong order and inspect the checks that keep the requested order pending.

This offline exercise signs a receipt for the wrong order using the public fixture key. The shipped verifier keeps the requested order pending. Removing its explicit order comparison still leaves that refusal intact:

shipped order pending reference_present False
no_explicit_comparison order pending reference_present False

The expected-signature calculation also uses the requested order and amount. Only the deliberately broken variant that removes both bindings reports paid:

both_removed order paid reference_present True

These are excerpts of the unedited output below. No processor, model or speech provider is called. All payment records and signing keys are public teaching fixtures.

Willow Shop and its processor are fictional. The fixture’s hosted-page payment contract is an exercise policy, not a payment standard. The tool sends a link by text or email to a contact on the customer’s account. Sending it creates a processor session; it does not prove payment.

The companion tests distinguish link delivery from a later verified receipt. Without a session, a status query returns pending even if a caller describes a payment. The all-zero-signature test also refuses a forged receipt.

Fetch the public source at this tutorial’s revision:

git clone https://github.com/RasaHQ/rasa-community-resources.git payment-receipt-lab
cd payment-receipt-lab
git checkout 4aa0c4419dc193fef7a969c12d59edcf720f2606
cd examples/mantle-voice-payment-boundary-gpt

Run the fixture suite from the project directory. It needs no licence or provider keys:

Any system
python3 -m unittest discover -s tests -v

To reproduce the selected receipt, run these named tests from its tests directory:

cd tests
python3 -m unittest -v \
  test_guard.ToolLogicTests.test_forged_receipt_does_not_verify \
  test_guard.ToolLogicTests.test_unverified_receipt_stays_pending \
  test_guard.ToolLogicTests.test_callers_word_is_not_a_receipt \
  test_guard.ToolLogicTests.test_link_then_verified_receipt_in_a_later_turn
cd ..

Actual offline output:

test_forged_receipt_does_not_verify (test_guard.ToolLogicTests.test_forged_receipt_does_not_verify) ... ok
test_unverified_receipt_stays_pending (test_guard.ToolLogicTests.test_unverified_receipt_stays_pending) ... ok
test_callers_word_is_not_a_receipt (test_guard.ToolLogicTests.test_callers_word_is_not_a_receipt) ... ok
test_link_then_verified_receipt_in_a_later_turn (test_guard.ToolLogicTests.test_link_then_verified_receipt_in_a_later_turn) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK

Elapsed time will vary. The full suite skips its Rasa speech-engine tests when Rasa is not installed. These selected tests exercise fixture logic, not a microphone, speech provider or model.

Bind the receipt to the order and amount

The verification function first checks the requested order and amount. It then checks a signature over the receipt’s reference, order, amount and status:

def _signature(reference: str, order: str, cents: int, status: str) -> str:
    key = _DATA["receipt_signing"]["fixture_key"].encode()
    return hmac.new(key, f"{reference}|{order}|{cents}|{status}".encode(), hashlib.sha256).hexdigest()


def verify_receipt(receipt: dict, order: str, cents: int) -> bool:
    """True only when the processor's receipt is for this order and amount and its signature verifies."""
    if receipt.get("order") != order or receipt.get("amount_cents") != cents:
        return False
    expected = _signature(receipt.get("reference", ""), order, cents, receipt.get("status", ""))
    return hmac.compare_digest(expected, str(receipt.get("signature", "")))

The field comparison stops a correctly signed receipt for a different order or amount from satisfying this request. The signature comparison catches changes to the signed values. Checking that a reference looks like a processor identifier would miss both distinctions.

This is standard HMAC verification with a public fixture key. It demonstrates response binding; it is not a real processor trust boundary or production signing-key design.

A reference becomes a payment receipt only after verification
FigureA reference becomes a payment receipt only after verification

The receipt is checked after querying the service. A sent link remains pending. An unverifiable response remains pending too, even when it contains a plausible reference.

Test each request binding separately

Changing a field without updating the signature is a useful tamper test, but it does not isolate request binding. Here the simulated processor returns a correctly signed receipt for the wrong order or amount.

Temporary verifierExplicit requested-order and amount comparisonSignature calculated overResult for a correctly signed mismatch
ShippedPresentRequested order and amountPending; no reference
Comparison removedAbsentRequested order and amountPending; no reference
Receipt fields used for signaturePresentReceipt’s own order and amountPending; no reference
Both changesAbsentReceipt’s own order and amountPaid; reference exposed

Deleting one check leaves the other binding intact. A passing refusal test therefore does not prove that the explicit comparison is still present. Test each change separately, then the pair.

Run this from the selected project directory. It compiles temporary function variants in memory; it never edits the checked-out source. The last variant is intentionally broken teaching code.

import inspect
from lib import payments as p
source = inspect.getsource(p.verify_receipt)
binding = '    if receipt.get("order") != order or receipt.get("amount_cents") != cents:\n        return False\n'
request = '_signature(receipt.get("reference", ""), order, cents, receipt.get("status", ""))'
receipt_fields = '_signature(receipt.get("reference", ""), receipt.get("order", ""), receipt.get("amount_cents", 0), receipt.get("status", ""))'
assert source.count(binding) == source.count(request) == 1
original_verifier = p.verify_receipt
for variant in ('shipped', 'no_explicit_comparison', 'receipt_fields_only', 'both_removed'):
    changed = source
    if variant in ('no_explicit_comparison', 'both_removed'):
        changed = changed.replace(binding, '')
    if variant in ('receipt_fields_only', 'both_removed'):
        changed = changed.replace(request, receipt_fields)
    namespace = dict(p.__dict__)
    exec(compile(changed, '<temporary-verifier>', 'exec'), namespace)
    p.verify_receipt = namespace['verify_receipt']
    for field, value in (('order', 'WS-10528'), ('amount_cents', 14901)):
        service = p.PaymentService('binding-exercise')
        _, memory = p.prepare_secure_payment(service, 'WS-CUST-5521', [], True, 'WS-10517', 'text')
        p.send_secure_payment_link(service, 'WS-CUST-5521', [], True, memory, 'WS-10517', 2)
        original_report = service.processor_report
        def report(session, turn):
            result = original_report(session, turn)
            receipt = {**result['receipt'], field: value}
            receipt['signature'] = p._signature(receipt['reference'], receipt['order'], receipt['amount_cents'], receipt['status'])
            return {**result, 'receipt': receipt}
        service.processor_report = report
        result = p.check_payment_status(service, 'WS-CUST-5521', 'WS-10517', 3)
        print(variant, field, result['payment_status'], 'reference_present', 'processor_reference' in result)
        assert result['payment_status'] == ('paid' if variant == 'both_removed' else 'pending')
p.verify_receipt = original_verifier
service = p.PaymentService('binding-exercise')
_, memory = p.prepare_secure_payment(service, 'WS-CUST-5521', [], True, 'WS-10517', 'text')
p.send_secure_payment_link(service, 'WS-CUST-5521', [], True, memory, 'WS-10517', 2)
control = p.check_payment_status(service, 'WS-CUST-5521', 'WS-10517', 3)
print('authentic_requested_receipt', control['payment_status'], control['processor_reference'], control['amount_spoken'])
assert control['payment_status'] == 'paid'

Actual unedited output, recorded on 8 October 2026:

shipped order pending reference_present False
shipped amount_cents pending reference_present False
no_explicit_comparison order pending reference_present False
no_explicit_comparison amount_cents pending reference_present False
receipt_fields_only order pending reference_present False
receipt_fields_only amount_cents pending reference_present False
both_removed order paid reference_present True
both_removed amount_cents paid reference_present True
authentic_requested_receipt paid QP-921154 149 dollars

The signature helper reads a public fixture key. Re-signing the response is a test control, not permission to create real processor receipts. The exercise passes an empty conversation record and an enabled redaction predicate to isolate payment verification; it does not test recording safety.

Withhold the reference when verification fails

The status tool’s business logic only adds the processor reference to a successful result. The pending result says the receipt could not be verified and the order stays on hold.

This choice costs convenience. A customer who says they have paid may still have to wait for the payments team. Sharing an unverified reference as proof would trade that delay for a false completion claim.

The Rasa adapter in skills/pay_order_balance/tools.py calls that logic and keeps the structured result:

async def check_payment_status(order_number: str, context: ToolContext = None) -> ToolResult:
    """Check the processor.

    Args:
        order_number: The order, for example WS-10517.
    """
    result = wp.check_payment_status(_service(), _customer(context), order_number, _user_turn(context))
    await _send_receipt(context, "check_payment_status", result)
    return ToolResult(llm_response=result)

The adapter reads the customer from project memory and counts caller turns from tracker events. The fixture uses the later turn to simulate the processor response. A real processor integration would need its own evidence of completion; advancing a conversation turn cannot provide it.

The skill’s instructions tell the agent to query status when the caller claims payment. They also tell it not to repeat an outcome already sent by the tool. Instructions support the conversation, while Python verification decides what the result may claim.

Keep the successful control separate

The final line uses the restored shipped verifier and an unchanged receipt for the requested order. It produces paid. That distinguishes refusal of changed receipts from a status tool that simply refuses everything.

Show what the successful control returns

The restored result reports succeeded, verified_fixture_receipt and paid. It includes processor reference QP-921154 and the spoken amount, 149 dollars. These are synthetic fixture values, not a live payment.

The shipped verifier keeps both correctly signed mismatches pending and omits their references. Either isolated mutation also refuses them. The deliberately broken double mutation reports paid and exposes a reference; that is the failure the assertions must detect.

On 8 October 2026, the full suite also ran with the actual Rasa Pro 3.21.0.dev5 SDK installed, without skips. Project validation accepted the skills and tools. From the selected project, the commands are:

uv run --locked python -m unittest discover -s tests -v
make validate

Actual output excerpts:

Ran 48 tests in 0.008s

OK
validate_project: ok

These are offline SDK checks. No licensed training, model or speech-provider call ran for these receipts.

To try the voice path later, select only this project with Python 3.12. Its setup files pin Rasa Pro 3.21.0.dev5 and provide the environment template:

uv sync --locked --python 3.12
make env
# Fill .env locally with RASA_LICENSE, OPENAI_API_KEY,
# DEEPGRAM_API_KEY and RIME_API_KEY. Do not commit it.
make validate
make train
make inspect

The pinned integrations.yml selects GPT, a custom Deepgram transcript filter and Rime speech. Provider calls are billed. No live provider call was made for the evidence here.

Ask to pay order W S one zero five one seven by text. Then answer two acceptance questions from the Inspector: does link delivery leave the order pending? When you say you have paid, does the agent query status before reporting the result? Those checks observe your sampled conversation; they do not establish recording safety.