Skip to main content

Turn a real order into a safe refund tool for your AI agent.

Project description

refund-guard

CI PyPI npm License: MIT

If your AI agent can call stripe.Refund.create(), it can try to refund anything -- wrong transaction, wrong amount, hallucinated order.

refund-guard adds one step: load a real order from your database, create a scoped refund tool, and give that tool to the agent. The agent can only refund that order, within your rules.

Install

pip install refund-guard            # Python
npm install @mattmessinger/refund-guard  # TypeScript / Node

Quickstart

from refund_guard import Refunds

refunds = Refunds({"skus": {"shampoo": {"refund_window_days": 30}}})

order = get_order_from_db(order_id)   # YOUR database, not the agent

refund_tool = refunds.make_refund_tool(
    sku=order.sku,
    transaction_id=order.transaction_id,
    amount_paid_minor_units=order.amount_cents,  # library divides by 100
    purchased_at=order.purchased_at,
    refunded_at=order.refunded_at,               # None = not yet refunded
    provider_refund_fn=my_existing_refund_fn,     # your Stripe / PayPal / Shopify call
)

result = refund_tool()      # full refund (no amount needed)
result = refund_tool(50)    # or partial refund
# {"status": "approved", "refunded_amount": 100.0, ...}
# {"status": "denied", "reason": "refund_window_expired", ...}

That's it. Call with no argument for a full refund, or pass an amount for a partial refund. Your provider function is only called if every check passes.

What it checks (before your refund function runs)

  • Already refunded -- if refunded_at is set, denied immediately
  • Refund window -- still within refund_window_days for that SKU
  • Positive amount -- must be > 0
  • Amount cap -- cannot exceed what was paid
  • Remaining balance -- handles partial refunds (can't refund $60 twice on a $100 order)

If any check fails, your provider function is never called -- no money moves.

TypeScript

import { Refunds, DENIAL_MESSAGES } from "@mattmessinger/refund-guard";

const refunds = new Refunds({ skus: { shampoo: { refund_window_days: 30 } } });
const order = await loadOrderFromDb(orderId);

const refund = refunds.makeRefundTool({
  sku: order.sku,
  transactionId: order.transactionId,
  amountPaidMinorUnits: order.amountCents,
  purchasedAt: order.purchasedAt,
  refundedAt: order.refundedAt,
  providerRefundFn: myExistingRefundFn,
});

const result = await refund();       // full refund -- or refund(50) for partial
const message = DENIAL_MESSAGES[result.reason as string] ?? "Refund processed.";

Both implementations follow the same behavior, enforced by shared parity tests.


API reference

Refunds(policy)

Param Type Notes
policy YAML file path or plain object { skus: { sku_name: { refund_window_days: N } } } Loaded once; reuse the instance

make_refund_tool(**opts) / makeRefundTool(opts)

Option Type Required Default
sku string yes --
transaction_id / transactionId string yes --
amount_paid / amountPaid number one of these --
amount_paid_minor_units / amountPaidMinorUnits int / number one of these --
purchased_at / purchasedAt datetime / Date yes --
provider_refund_fn / providerRefundFn (amount, txn_id, currency) -> any yes --
refunded_at / refundedAt datetime / Date or None/null no None
currency string no "usd"
provider string no "unknown"

Provide one of amount_paid (dollars) or amount_paid_minor_units (cents -- divided by 100 internally). Providing both raises an error.

The refund callable: refund_tool(amount?) / await refund(amount?)

Call Behavior
refund_tool() Full refund of the remaining balance (amount_paid - total_refunded)
refund_tool(50) Partial refund of $50

Important: The library passes the validated amount to your provider_refund_fn. If your provider function ignores the amount parameter, the amount checks provide no protection. Always forward the amount to your payment API.

DENIAL_MESSAGES

from refund_guard import DENIAL_MESSAGES
# {"refund_window_expired": "The refund window for this order has closed.", ...}

A dict / Record<string, string> mapping every denial reason to a user-facing message.


Denial reasons

reason Meaning
already_refunded refunded_at was set -- already refunded
refund_window_expired Purchase older than the SKU's window
amount_exceeds_limit Requested more than was paid
amount_exceeds_remaining Not enough balance after partial refunds
invalid_amount Zero or negative
provider_error Your provider threw an exception

Troubleshooting

Symptom Fix
Every refund denied as amount_exceeds_limit You're passing cents to amount_paid. Use amount_paid_minor_units instead.
Every refund denied as already_refunded You're passing a non-null refunded_at. This order is already refunded in your DB.
SKU 'x' not found in policy Add that SKU to your policy object or YAML file.
Forgot await (TypeScript) The callable is async: const r = await refund().
Refunds go through but amount is wrong Your providerRefundFn must forward the amount parameter to your payment API.

FAQ

Why not just trust the agent? Models hallucinate transaction IDs, mix up amounts, and retry incorrectly. This library binds the tool to one real order your server loaded.

Does this replace Stripe / PayPal / Shopify? No. It wraps your existing refund call with policy checks.

Do I need Python and TypeScript? No. Pick whichever your backend uses.

What does my provider function look like? (amount, transaction_id, currency) -> anything. Same for Stripe, PayPal, Shopify, or your own API.

What about double refunds across HTTP requests? Pass refunded_at from your database. The library denies immediately if it's set. If you don't pass it, you need to check it yourself.

What data does the agent control? The refund amount (or nothing at all -- call with no argument for a full refund). SKU, transaction ID, amount paid, and purchase date all come from your database -- never from the agent.

Is this safe? The agent is untrusted. Your app provides order truth (SKU, IDs, amounts, dates). Your payment provider handles money. The agent only chooses how much to refund, inside the bounds you set.

How do I enable logging? (Python)

import logging
logging.basicConfig()
logging.getLogger("refund_guard").setLevel(logging.INFO)

What do I tell my AI agent about refund policy? The library enforces hard limits (window, amount, balance). Your agent's system prompt should encode when to offer refunds. See the Integration Guide.

I'm wiring this into a real app with a database and Stripe. Where do I start? Read the Integration Guide -- a walkthrough based on actual production usage.


Contributing

See CONTRIBUTING.md for setup, tests, and PR guidelines.

Both languages run the same 20 test scenarios from contracts/parity/cases.json. If you change behavior in one language, the shared tests catch the drift.


License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

refund_guard-0.3.0.tar.gz (14.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

refund_guard-0.3.0-py3-none-any.whl (9.6 kB view details)

Uploaded Python 3

File details

Details for the file refund_guard-0.3.0.tar.gz.

File metadata

  • Download URL: refund_guard-0.3.0.tar.gz
  • Upload date:
  • Size: 14.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for refund_guard-0.3.0.tar.gz
Algorithm Hash digest
SHA256 bbfec4a8664eea167deaded3d26b71dad6f00594f6593d5bd8730f5b8d4ba1bf
MD5 5c8d054db26ef7344086405a053704fd
BLAKE2b-256 7cff9b4622ff1be2c217eb30d0f2cc3d1ddca22285a2fa4e9be24ceb29e71a90

See more details on using hashes here.

Provenance

The following attestation bundles were made for refund_guard-0.3.0.tar.gz:

Publisher: publish-pypi.yml on MattMessinger1/agentic_refund_guardrail

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file refund_guard-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: refund_guard-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 9.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for refund_guard-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 25898d659c77a4ef4c48af8c6ce4efc746daeb7c93223727ef4399d5af2b325e
MD5 1d2fb7e4f3dfe76e887de6069fb3ff95
BLAKE2b-256 7aaf7c956b6ed7c4b324db5d2b2b1bc0e5e81e82e866ca940ea4aafbb297498a

See more details on using hashes here.

Provenance

The following attestation bundles were made for refund_guard-0.3.0-py3-none-any.whl:

Publisher: publish-pypi.yml on MattMessinger1/agentic_refund_guardrail

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page