# Match a payment receipt to the customer’s order

Source: https://rasa.community/library/casebook/voice-payment-boundary/
Author: Rod Rivera
Published: 2026-10-21T09:00:00Z

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:

```text
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:

```text
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.

## Sending the link leaves payment pending

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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-payment-boundary-gpt/tests/test_guard.py) 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:

```sh
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:

::run{cmd="python3 -m unittest discover -s tests -v"}

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

```sh
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:

```text
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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-payment-boundary-gpt/lib/payments.py#L229-L239) first checks the requested order and amount. It then checks a signature over the receipt's reference, order, amount and status:

```python
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.

:::diagram{title="A reference becomes a payment receipt only after verification"}

```dot
rankdir=LR;
link [label="Send link: payment pending"];
report [label="Query simulated processor"];
verify [label="Match order and amount; verify signature"];
paid [label="Paid with processor reference", class="ok"];
pending [label="Pending; withhold reference", class="blocked"];
link -> report [label="later status check"];
report -> verify [label="receipt"];
verify -> paid [label="verified"];
verify -> pending [label="field changed"];
```

:::

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 verifier                | Explicit requested-order and amount comparison | Signature calculated over      | Result for a correctly signed mismatch |
| --------------------------------- | ---------------------------------------------- | ------------------------------ | -------------------------------------- |
| Shipped                           | Present                                        | Requested order and amount     | Pending; no reference                  |
| Comparison removed                | Absent                                         | Requested order and amount     | Pending; no reference                  |
| Receipt fields used for signature | Present                                        | Receipt's own order and amount | Pending; no reference                  |
| Both changes                      | Absent                                         | Receipt's own order and amount | Paid; 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.

```python
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:

```text
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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-payment-boundary-gpt/lib/payments.py#L451-L490) 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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-payment-boundary-gpt/skills/pay_order_balance/tools.py) calls that logic and keeps the structured result:

```python
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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-payment-boundary-gpt/skills/pay_order_balance/skill.md) 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.

:::checkpoint{id="payment-reference-is-not-proof" question="A caller reads a processor-looking confirmation number. May the agent announce payment?" options="Yes if the reference looks valid,Only after the processor receipt verifies,Yes if a payment link was sent" answer="1"}
A caller-provided number and a sent link do not verify payment. Query the processor and check the returned receipt against the requested order and amount.
:::

## 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.

:::solution{title="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.
:::

:::callout{type="warn" title="Receipt verification does not prove recording safety"}
The fixture field named `recorder_excluded` is a predicate label, not proof that raw audio was excluded from a recorder. Transcript filtering and a process flag do not establish that guarantee. This exercise makes no PCI DSS compliance claim and tests no real processor or production signing-key management.
:::

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:

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

Actual output excerpts:

```text
Ran 48 tests in 0.008s

OK
```

```text
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](https://github.com/RasaHQ/rasa-community-resources/tree/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-payment-boundary-gpt) pin Rasa Pro `3.21.0.dev5` and provide the environment template:

```sh
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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-payment-boundary-gpt/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.

::cta{href="/library/guides/redact-card-numbers-in-the-speech-to-text-engine/" label="Inspect the separate transcript redaction boundary"}