Show HN: Refund Guard – 환불을 발행할 수 있는 AI 에이전트를 위한 정책 게이트
hackernews
|
|
📦 오픈소스
#ai agent
#openai
#policy
#refund
#review
#safety
#stripe
원문 출처: hackernews · Genesis Park에서 요약 및 분석
요약
AI 에이전트에 무제한적인 결제 환불 권한을 부여하는 위험을 방지하기 위해 설계된 오픈소스 라이브러리인 'refund-guard'가 소개되었습니다. 이 도구는 파이썬과 타입스크립트를 지원하며, AI 모델의 환각 등으로 인한 오류를 막기 위해 에이전트의 입력값을 '금액'과 '사유'로만 제한하고 직접 결제 API를 호출하지 못하게 차단합니다. 특히 결제 시스템 연동 시 환불 가능 기간(SKU별 설정), 부분 환불 한도, 결제 금액 초과 여부 등의 비즈니스 정책을 100% 강력하게 검증하여 기준을 통과하지 못하면 결제 제공자(Stripe, PayPal 등)의 함수를 아예 호출하지 않는 구조를 갖추고 있습니다. 사용자는 이 패키지를 통해 정책 검증 계층을 안전하게 위임하는 대신, 인증 및 권한 부여, 데이터베이스 상태 관리 등 나머지 보안 및 시스템 책임은 개발자가 직접 구현해야 합니다.
본문
If your AI agent can trigger refunds, do not hand it a raw Stripe/PayPal/Shopify refund function. An AI refund agent needs a safety map, not just a refund function. refund-guard fully handles a deterministic series of security responsibilities after trusted order data is loaded: the agent input boundary, refund-policy enforcement, and the no-provider-call-on-denial gate. This repo also names the remaining responsibilities your app, provider, database, and process must own before agents can move money. Design rule: 100% is Pass. 99% is Fail. refund-guard only claims the security responsibilities it can enforce completely. - Agent input boundary: once your app creates the scoped refund tool, the model can request only amount andreason . - Refund-policy enforcement: amount validity, paid/remaining caps, refund windows, final-sale SKUs, allowed reasons, and manual-review thresholds. - Provider invocation gate: your provider function is not called unless policy passes. - Security map: the repo shows the other responsibilities you must own instead of pretending this package handles them. AI agent -> tool handler -> resolve trusted order -> refund-guard -> refund provider -> update DB MECE here means every security category has one clear owner. refund-guard either owns a category at 100%, or it does not own that category. Green nodes are the categories or gates refund-guard enforces. Gray nodes are responsibilities your app, provider, database, or process must own. flowchart LR A["1. Tool access controlApp owns"] --> B["2. Order scope and ownershipApp owns"] B --> C["3. Authoritative refund factsApp/database/provider owns"] C --> D["4. Agent input boundaryrefund-guard owns 100%"] D --> E["5. Refund-policy enforcementrefund-guard owns 100%"] E --> F["6. Provider invocation gaterefund-guard owns denial gate"] F --> G["7. Provider execution safetyApp/provider owns"] G --> H["8. State consistency and persistenceApp/database owns"] H --> I["9. Evidence, exceptions, and human reviewApp/process owns"] I --> J["10. Auditability and accountabilityApp/process owns"] J --> K["11. Fraud, abuse, and compliance riskApp/process owns"] class D,E,F refundGuard class A,B,C,G,H,I,J,K appOwned classDef refundGuard fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d; classDef appOwned fill:#f8fafc,stroke:#cbd5e1,color:#0f172a; - You are prototyping or shipping an AI support agent that can trigger refunds. - Your refund rules live in prompts, scattered if statements, or provider-call code. - Your server can load trusted order data through user, ticket, tenant, admin, or backend scope. - You need a package-enforced policy gate plus a full security map before building a custom refund-policy service. - Your app has refund windows, partial refunds, final-sale SKUs, allowed reasons, or manual-review thresholds. Payment providers protect against technically invalid refunds. They do not know your business rules. refund-guard is the thin layer where those rules live. - Humans approve every refund before money moves. - Your agent is read-only and never triggers refunds. - Refund code runs client-side. Provider secrets and refund calls belong on your server. - Your app cannot verify order scope before refunding, or plans to trust agent-supplied order metadata. - Your backend already has equivalent tested refund-policy enforcement. - You need this package to own auth, order ownership, provider idempotency, persistence, audit, review, fraud, compliance, chargeback, or risk infrastructure. - Pick Python or TypeScript. - Define SKU refund rules. - Resolve the real order through your app's user/ticket/tenant/admin scope. - Create a scoped refund tool with paid/refunded/date/status fields. - Give the agent only amount andreason , or treatorderId as a scoped lookup hint. - Run the minimal example or policy doctor before real money. These are self-contained templates to evaluate and adapt for your app. Prompt 1 includes the discovery and prerequisite checks because many builders will paste only the prompt. Prompt 2 helps you start covering the full agentic refund security map by identifying the non-package responsibilities and blockers before real money moves. Any unauthorized refund or refund fraud is a real problem from day one. Before agents can move money, every category needs a 100% owner. | Category | Owner | Covered by refund-guard? | |---|---|---| | Tool access control | App/framework | No | | Order scope and ownership | App | No | | Authoritative refund facts | App/database/provider | No | | Agent input boundary | refund-guard | Yes, 100% | | Refund-policy enforcement | refund-guard | Yes, 100% | | Provider invocation gate | refund-guard + app | Yes for denial gate; no for provider implementation | | Provider execution safety | App/provider | No | | State consistency and persistence | App/database | No | | Evidence, exceptions, and human review | App/process | No | | Auditability and accountability | App/process | No | | Fraud, abuse, and compliance risk | App/process | No | See the Integration Guide for how vibe builders can solve the responsibilities this package does not cover. pip install refund-guard # Python npm install @mattmessinger/refund-guard # TypeScript / Node This is the safe copy-paste shape: resolve the order yourself, create a scoped tool, and let the agent supply only amount and reason. from refund_guard import Refunds refunds = Refunds({"skus": {"shampoo": {"refund_window_days": 30}}}) order = load_order_for_current_user(order_id, current_user.id) 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 amount_refunded_minor_units=order.refunded_cents, 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(reason="provider_cancelled") # full remaining refund result = refund_tool(50, reason="duplicate_charge") # 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. Given trusted order data, refund-guard enforces these 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 - Finite positive amount -- must be a real number > 0 - Amount cap -- cannot exceed what was paid - Remaining balance -- handles partial refunds (can't refund $60 twice on a $100 order) - Non-refundable SKUs -- final-sale policies deny before the provider call - Allowed reasons -- reason must match your policy enum if one is configured - Policy caps -- optional max refund amount and manual-review threshold If any check fails, your provider function is never called -- no money moves. - Who the user is. - Whether an order belongs to that user, session, ticket, tenant, or admin scope. - Whether the stated refund reason is factually true. - Whether your provider call is idempotent. - Whether database state is fresh across services or processes. - Whether the refund is fraud, chargeback, compliance, tax/accounting, or marketplace safe. orderId is a lookup hint, not proof. If your agent supplies it, your app must resolve it through auth/session/ticket/tenant scope before creating the refund tool. import { Refunds, DENIAL_MESSAGES } from "@mattmessinger/refund-guard"; const refunds = new Refunds({ skus: { shampoo: { refund_window_days: 30 } } }); const currentUser = await requireCurrentUser(); const order = await loadOrderForCurrentUser(orderId, currentUser.id); const refund = refunds.makeRefundTool({ sku: order.sku, transactionId: order.transactionId, amountPaidMinorUnits: order.amountCents, amountRefundedMinorUnits: order.refundedCents, purchasedAt: order.purchasedAt, refundedAt: order.refundedAt, providerRefundFn: myExistingRefundFn, }); const result = await refund(undefined, { reason: "provider_cancelled" }); if (result.status !== "approved") { const message = DENIAL_MESSAGES[result.reason] ?? "Refund not allowed."; return { success: false, message }; } return { success: true, amount: result.refunded_amount }; Both implementations follow the same behavior, enforced by shared parity tests. | Param | Type | Notes | |---|---|---| policy | YAML file path or plain object { skus: { sku_name: { refund_window_days: N } } } | Loaded once; reuse the instance | Optional SKU policy fields: | Field | Type | Meaning | |---|---|---| refundable | boolean | Set false for final-sale SKUs | max_refund_minor_units | int | Per-refund cap in cents/minor units | manual_approval_required_over_minor_units | int | Deny automated refunds above this amount | allowed_reasons | string[] | Allowed reason codes, checked when the tool is called | | 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 | -- | amount_refunded / amountRefunded | number | no | 0 | amount_refunded_minor_units / amountRefundedMinorUnits | int / number | no | 0 | 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. If the order has previous partial refunds, also pass amount_refunded_minor_units or amount_refunded from your database so a fresh per-request tool starts from the persisted remaining balance. | Call | Behavior | |---|---| refund_tool(reason="provider_cancelled") / await refund(undefined, { reason }) | Full refund of the remaining balance (amount_paid - amount_refunded ) | refund_tool(50, reason="duplicate_charge") / await refund(50, { reason }) | 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. from refund_guard import DENIAL_MESSAGES # {"refund_window_expired": "The refund window for this order has closed.", ...} A dict / Record mapping every denial reason to a user-facing message. TypeScript exports RefundResult , ApprovedRefundResult , DeniedRefundResult , ErrorRefundResult , and DenialReason for autocomplete and status narrowing. Python exports matching TypedDict aliases for type checkers. 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 | amount_exceeds_policy_max | Requested more than the SKU policy allows | invalid_amount | Zero or negative | not_refundable | SKU policy has refundable: false | refund_reason_not_allowed | Reason was missing or not in allowed_reasons | manual_approval_required | Amount is above the automated refund threshold | provider_error | Your provider threw an exception | | 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. | | Partial refunds work once but not across requests | Pass amount_refunded_minor_units from your database each time you create the tool. | Refund denied as refund_reason_not_allowed | Pass a reason allowed by that SKU's allowed_reasons policy. | 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. | 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 for fully refunded orders and amount_refunded_minor_units for previous partial refunds. The library denies immediately if refunded_at is set and uses amount_refunded_minor_units to compute the remaining balance for fresh request-scoped tools. What data does the agent control? In the server-scoped pattern, only the refund amount and reason. If your agent supplies orderId , treat it as a lookup hint and resolve the order through your app's scope first. SKU, transaction ID, amount paid, amount already refunded, and purchase date all come from your database -- never from the agent. Is this safe? It covers a narrow set of safety responsibilities, not the whole system. Your app owns auth and scoped order lookup. refund-guard owns the agent input boundary, refund-policy checks, and the no-provider-call-on-denial gate. Your provider and persistence layer own money movement, retries, and records. 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. I'm building with OpenAI, Vercel AI SDK, LangChain, or MCP. Where are the agent examples? Start with Agentic refund flow recipes. Can I test my policy before touching Stripe? Yes. Run the policy doctor with fake provider calls: refund-guard doctor examples/doctor/policy.yaml examples/doctor/scenarios.json See CONTRIBUTING.md for setup, tests, and PR guidelines. Both languages run the same 26 test scenarios from contracts/parity/cases.json . If you change behavior in one language, the shared tests catch the drift. MIT
Genesis Park 편집팀이 AI를 활용하여 작성한 분석입니다. 원문은 출처 링크를 통해 확인할 수 있습니다.
공유