Turn a real order into a safe refund tool for your AI agent.
Project description
refund-guard
Start here: Step-by-step guide · Integration guide · Contributing · Report an issue
A small library that turns one real order into one safe refund function for your AI agent — so the agent can only refund what your policy allows (window, amount cap, remaining balance).
New here? Read docs/STEP_BY_STEP.md first, then come back for details.
Read this first (1 minute)
| Question | Answer |
|---|---|
| Is this a hosted API or SaaS? | No. It is a package you install (pip / npm) in your server code. |
| Does it run on my phone? | Not inside the app. Your mobile app calls your backend; the backend runs this library. |
| Do I need Python and TypeScript? | No. Pick one — whatever your backend uses. |
| What does it actually do? | Wraps your existing refund call with policy checks before money moves. |
| How is this different from agent guardrail products (Veto, PolicyLayer, Kvlar, etc.)? | Those answer: should this tool run at all? refund-guard answers: for this order and this amount, does our business policy allow it? Use both if you want. |
| Do I have to use a YAML file? | No. Pass a plain object: Refunds({ skus: { my_sku: { refund_window_days: 14 } } }). YAML is available when you have many SKUs or want non-engineers to edit policy. |
| What signature does my provider function use? | (amount, transaction_id, currency) — same for Stripe, PayPal, Shopify, or your own HTTP API. |
The idea in one picture
Your database loads the real order (SKU, txn id, amount, date)
|
v
refund-guard: make_refund_tool(...) <-- closes over that order
|
v
Agent / user only chooses HOW MUCH to refund (within rules)
|
v
validate -> then your provider refund code runs
The agent should not pass transaction IDs or "what was paid" -- your app does.
Install
Python (PyPI)
pip install refund-guard
TypeScript / Node (npm)
npm install @mattmessinger/refund-guard
Both implementations follow the same behavior, enforced by shared tests in contracts/parity/cases.json.
Examples
| Python | examples/minimal-python/ -- fake provider, runs instantly |
| TypeScript | examples/minimal-ts/ -- fake provider, build TS package first |
| Real-world pattern | examples/real-world-ts/ -- annotated reference showing DB fetch, unit conversion, result mapping (not runnable) |
Tutorial (5 minutes)
1. Define your policy
# Inline (simplest)
refunds = Refunds({"skus": {"shampoo": {"refund_window_days": 30}}})
# Or from a YAML file (when you have many SKUs)
refunds = Refunds("refund_policy.yaml")
2. Wire refund-guard
Pick one language.
Python (full example)
from datetime import datetime
from refund_guard import Refunds
refunds = Refunds({"skus": {"shampoo": {"refund_window_days": 30}}})
order = get_order_from_db(order_id) # YOUR database
def my_refund(amount: float, transaction_id: str, currency: str):
# Your existing Stripe / PayPal / Shopify / HTTP call
return stripe.Refund.create(
payment_intent=transaction_id,
amount=int(amount * 100),
)
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 or datetime; blocks double-refunds
provider_refund_fn=my_refund,
)
result = refund_tool(80.00)
print(result)
TypeScript (full example)
import { Refunds } 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, // library divides by 100
purchasedAt: order.purchasedAt,
refundedAt: order.refundedAt, // null/undefined or Date; blocks double-refunds
providerRefundFn: (amount, transactionId, currency) =>
// Your existing Stripe / PayPal / Shopify / HTTP call
stripe.refunds.create({
payment_intent: transactionId,
amount: Math.round(amount * 100),
currency,
}),
});
const result = await refund(80.0);
console.log(result);
- The returned function is async -- use
await. providerRefundFnmay return a Promise or a plain value.- Tests only: pass
nowFn(Python:now_fn) to freeze "today." Omit in production.
API reference (quick)
Refunds(policy)
| Param | Type | Notes |
|---|---|---|
policy |
YAML file path or plain object { skus: { sku_name: { refund_window_days: N } } } |
Loaded once; reuse the instance |
refunds.makeRefundTool(opts) / refunds.make_refund_tool(**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 / null |
currency |
string |
no | "usd" |
provider |
string |
no | "unknown" |
now_fn / nowFn |
() -> datetime/Date |
no | current UTC time |
Provide one of amount_paid (major units, e.g. dollars) or amount_paid_minor_units (e.g. cents -- divided by 100 internally). Providing both raises an error.
Returns a callable (Python) or async function (TypeScript) with signature (amount) -> result.
DENIAL_MESSAGES
from refund_guard import DENIAL_MESSAGES
import { DENIAL_MESSAGES } from "@mattmessinger/refund-guard";
A dict / Record<string, string> mapping every denial reason code to a user-facing message. Use directly or override individual values.
What you get back
Approved
{"status": "approved", "refunded_amount": 80.0, "transaction_id": "pi_abc123"}
Denied (policy blocked -- your refund function was not called)
{"status": "denied", "reason": "amount_exceeds_limit", "requested": 200.0, "max_allowed": 120.0}
Provider error (your provider threw)
{"status": "error", "reason": "provider_error", "detail": "No such payment_intent: pi_xxx"}
Denial reasons
reason |
What it means | Suggested message for users |
|---|---|---|
already_refunded |
refunded_at was set -- order was already refunded |
"This order has already been refunded." |
refund_window_expired |
Purchase is older than the SKU's refund_window_days |
"The refund window for this order has closed." |
amount_exceeds_limit |
Requested more than was originally paid | "Refund amount exceeds the original charge." |
amount_exceeds_remaining |
After partial refunds, not enough balance left | "This order has already been partially refunded." |
invalid_amount |
Amount is zero or negative | "Please enter a valid refund amount." |
provider_error |
Your provider call threw an exception | "Refund could not be processed. Please contact support." |
These messages are available as DENIAL_MESSAGES -- import and use directly instead of building your own map.
When status is "denied", your provider function was never called -- no money moved.
What it checks (in order)
- Already refunded -- if
refunded_atis set, denied immediately - Refund window -- still within
refund_window_daysfor that SKU - Positive amount -- must be > 0
- Amount cap -- cannot exceed what was paid on this order
- Remaining balance -- after partial refunds, cannot exceed what's left
If any check fails, your provider function is never called.
What this library does NOT do
| Prevent double refunds across HTTP requests | Pass refunded_at from your database and the library will deny immediately. If you don't pass it, you must check it yourself. |
| Fetch order data | You load SKU, amount, purchase date, and transaction ID from your database. |
| Replace your payment SDK | It wraps your existing refund call. You still need Stripe / PayPal / etc. |
| Run on the client / frontend | Server-side only. Your mobile or web app calls your backend; your backend runs this. |
| Tell your AI agent when to offer refunds | The library enforces hard limits. Your agent's prompt encodes business rules. See the Integration Guide. |
Troubleshooting
| Symptom | What to do |
|---|---|
SKU 'x' not found in policy |
Add that SKU to your policy object or YAML file. |
Cannot find module (TypeScript) |
Run npm install @mattmessinger/refund-guard in your project folder. |
Forgot await (TypeScript) |
The refund callable is async: const r = await refund(10). |
Every refund denied as amount_exceeds_limit |
You're passing minor units (cents) instead of major units (dollars). Use amount_paid_minor_units / amountPaidMinorUnits and the library converts for you. |
Every refund denied as already_refunded |
You're passing a non-null refunded_at -- this order was already refunded in your database. |
| Policy file not found | Pass an absolute path, or use an inline policy object instead. |
refund_window_expired |
Expected if the purchase is older than the SKU's window. |
Security model
| Layer | Role |
|---|---|
| Your payment provider | Money + payment truth |
| Your app | Order truth (SKU, ids, amounts, dates) |
| Agent / chat | Untrusted -- only chooses refund amount inside the tool |
Logging (Python)
import logging
logging.basicConfig()
logging.getLogger("refund_guard").setLevel(logging.INFO)
Develop / clone this repo
See CONTRIBUTING.md for full setup. Quick start:
git clone https://github.com/MattMessinger1/agentic_refund_guardrail.git
cd agentic_refund_guardrail
pip install -e ".[dev]" && pytest # Python
cd packages/refund-guard-ts && npm ci && npm test # TypeScript
Both languages run the same 17 test scenarios from contracts/parity/cases.json. If you change behavior in one language, the shared tests catch the drift.
FAQ
Why not trust the agent with transaction IDs? Models mix up amounts and ids. This library binds the tool to one order your server loaded.
Does this replace Stripe? No. It sits in front of your existing refund code.
Why Python and TypeScript in one repo? So pip users and npm users get the same behavior -- locked by shared tests, not by vibes.
What do I tell my AI agent about refund policy? The library validates amounts and windows. Your agent's prompt should encode when to offer refunds. See the Integration Guide.
I'm integrating into a real app -- where do I start? Read the Integration Guide.
Security disclosures? See SECURITY.md.
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file refund_guard-0.2.0.tar.gz.
File metadata
- Download URL: refund_guard-0.2.0.tar.gz
- Upload date:
- Size: 16.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
de1f7277c7346257f452378e1d0a7eb1658919bd050491413158681e12e46882
|
|
| MD5 |
c87e25151a2d2307ee4e4d5dffa48a40
|
|
| BLAKE2b-256 |
f5b4cc5fe6566db52aee9c5ea0b6a50c34cdd09061ac788f3cde9928ed18c004
|
Provenance
The following attestation bundles were made for refund_guard-0.2.0.tar.gz:
Publisher:
publish-pypi.yml on MattMessinger1/agentic_refund_guardrail
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
refund_guard-0.2.0.tar.gz -
Subject digest:
de1f7277c7346257f452378e1d0a7eb1658919bd050491413158681e12e46882 - Sigstore transparency entry: 1247591040
- Sigstore integration time:
-
Permalink:
MattMessinger1/agentic_refund_guardrail@93487770a3114c0e0970f6f54cafe26c83186bfb -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/MattMessinger1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@93487770a3114c0e0970f6f54cafe26c83186bfb -
Trigger Event:
release
-
Statement type:
File details
Details for the file refund_guard-0.2.0-py3-none-any.whl.
File metadata
- Download URL: refund_guard-0.2.0-py3-none-any.whl
- Upload date:
- Size: 11.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
43642c0f52c422520b3d44116dbcbc95bd8d907cac9c8f4c2bafdcdc29f04d9e
|
|
| MD5 |
a0985080e5031a3cdbdc74c23ceaf58c
|
|
| BLAKE2b-256 |
52533c37a124f1efe9683acb851c16130412181d123751bf65fc3960de252b65
|
Provenance
The following attestation bundles were made for refund_guard-0.2.0-py3-none-any.whl:
Publisher:
publish-pypi.yml on MattMessinger1/agentic_refund_guardrail
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
refund_guard-0.2.0-py3-none-any.whl -
Subject digest:
43642c0f52c422520b3d44116dbcbc95bd8d907cac9c8f4c2bafdcdc29f04d9e - Sigstore transparency entry: 1247591044
- Sigstore integration time:
-
Permalink:
MattMessinger1/agentic_refund_guardrail@93487770a3114c0e0970f6f54cafe26c83186bfb -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/MattMessinger1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@93487770a3114c0e0970f6f54cafe26c83186bfb -
Trigger Event:
release
-
Statement type: