diff --git a/src/gringotts/admin.py b/src/gringotts/admin.py
index b9efe2d..35421b0 100644
--- a/src/gringotts/admin.py
+++ b/src/gringotts/admin.py
@@ -12,7 +12,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, Response
 from sqlalchemy.orm import Session
 
 from . import auth, crud, pages
-from .config import GringottsConfig
+from .config import GringottsConfig, format_money
 from .db import get_session
 from .dependencies import require_admin
 from .models import User
@@ -66,12 +66,13 @@ def build_admin_router(config: GringottsConfig) -> APIRouter:
         data = crud.aggregate_stats(db)
         if not _is_htmx(request):
             return JSONResponse(data)
+        currency = config.packs[0].currency if config.packs else "usd"
         tiles = (
             pages.tile(str(data["users"]), "users")
             + pages.tile(str(data["credits_outstanding"]), "credits outstanding")
             + pages.tile(str(data["credits_consumed"]), "credits consumed")
             + pages.tile(str(data["credits_purchased"]), "credits purchased")
-            + pages.tile(f"{data['revenue_cents'] / 100:,.2f}", "revenue")
+            + pages.tile(format_money(data["revenue_cents"], currency), "revenue")
         )
         return HTMLResponse(f'<div class="tiles">{tiles}</div>')
 
diff --git a/src/gringotts/billing.py b/src/gringotts/billing.py
index 27fff9a..ef25eba 100644
--- a/src/gringotts/billing.py
+++ b/src/gringotts/billing.py
@@ -31,4 +31,7 @@ def create_checkout_session(
         success_url=config.success_url or f"{buy_url}?status=success",
         cancel_url=config.cancel_url or f"{buy_url}?status=cancelled",
         metadata={"gringotts_user_id": str(user.id), "credits": str(pack.credits)},
+        # copied onto the PaymentIntent and its Charge, so refund/dispute events
+        # carry the user id even without the checkout session
+        payment_intent_data={"metadata": {"gringotts_user_id": str(user.id)}},
     )
diff --git a/src/gringotts/config.py b/src/gringotts/config.py
index 42ca6c5..ba85b18 100644
--- a/src/gringotts/config.py
+++ b/src/gringotts/config.py
@@ -3,10 +3,51 @@
 import os
 from dataclasses import dataclass, field
 
+# Currencies with no minor unit — Stripe's amount is already the whole-unit value
+# (e.g. JPY 500 means ¥500, not ¥5.00). Source of truth:
+# https://docs.stripe.com/currencies#zero-decimal
+_ZERO_DECIMAL_CURRENCIES = frozenset(
+    {
+        "bif",
+        "clp",
+        "djf",
+        "gnf",
+        "jpy",
+        "kmf",
+        "krw",
+        "mga",
+        "pyg",
+        "rwf",
+        "ugx",
+        "vnd",
+        "vuv",
+        "xaf",
+        "xof",
+        "xpf",
+    }
+)
+
+
+def is_zero_decimal(currency: str) -> bool:
+    """Whether `currency` has no minor unit (so `price_cents` is whole units)."""
+    return currency.lower() in _ZERO_DECIMAL_CURRENCIES
+
+
+def format_money(minor_units: int, currency: str) -> str:
+    """Format a Stripe smallest-unit amount for display, currency-aware."""
+    upper = currency.upper()
+    if is_zero_decimal(currency):
+        return f"{minor_units:,} {upper}"
+    return f"{minor_units / 100:,.2f} {upper}"
+
 
 @dataclass(frozen=True)
 class CreditPack:
-    """A purchasable bundle: `credits` for `price_cents` in `currency`."""
+    """A purchasable bundle: `credits` for `price_cents` in `currency`.
+
+    `price_cents` is the amount in the currency's smallest unit — cents for USD,
+    whole yen for JPY and other zero-decimal currencies.
+    """
 
     credits: int
     price_cents: int
diff --git a/src/gringotts/crud.py b/src/gringotts/crud.py
index 1c0d90c..ac864e9 100644
--- a/src/gringotts/crud.py
+++ b/src/gringotts/crud.py
@@ -1,11 +1,15 @@
 """Database operations: users, atomic credit movements, and ledger queries."""
 
+import logging
+
 from sqlalchemy import case, func
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import Session
 
 from . import auth, models
 
+logger = logging.getLogger(__name__)
+
 
 def create_user(
     db: Session,
@@ -128,6 +132,7 @@ def grant_credits(
     kind: str = "grant",
     external_id: str | None = None,
     amount_cents: int | None = None,
+    payment_intent_id: str | None = None,
 ) -> bool:
     """Atomically add credits with a ledger row.
 
@@ -148,6 +153,7 @@ def grant_credits(
             kind=kind,
             external_id=external_id,
             amount_cents=amount_cents,
+            payment_intent_id=payment_intent_id,
             balance_after=user.credits,
         )
     )
@@ -175,6 +181,93 @@ def external_id_exists(db: Session, external_id: str) -> bool:
     )
 
 
+def find_purchase_by_payment_intent(
+    db: Session, payment_intent_id: str
+) -> models.CreditTransaction | None:
+    """Return the earliest purchase row for a Stripe PaymentIntent, or None."""
+    return (
+        db.query(models.CreditTransaction)
+        .filter(
+            models.CreditTransaction.kind == "purchase",
+            models.CreditTransaction.payment_intent_id == payment_intent_id,
+        )
+        .order_by(models.CreditTransaction.id)
+        .first()
+    )
+
+
+def clawback_deducted(db: Session, external_id: str) -> int:
+    """The credits a prior clawback row actually deducted (absolute value)."""
+    row = (
+        db.query(models.CreditTransaction.amount)
+        .filter(models.CreditTransaction.external_id == external_id)
+        .first()
+    )
+    return -int(row[0]) if row is not None else 0
+
+
+def clawback_credits(
+    db: Session,
+    user: models.User,
+    amount: int,
+    *,
+    external_id: str,
+    kind: str = "clawback",
+    endpoint: str | None = None,
+) -> int:
+    """Deduct up to `amount` credits (clamped at zero) with a ledger row.
+
+    Used to reverse a refunded or disputed purchase. Never drives the balance
+    negative — it deducts only what the user still holds. Returns the amount
+    actually deducted. Idempotent on `external_id`: a redelivered event finds the
+    existing row and deducts nothing. A negative `amount` raises ValueError.
+    """
+    if amount < 0:
+        raise ValueError("clawback amount cannot be negative")
+    # Lock the user row so the clamp reads a stable balance (Postgres); SQLite
+    # serializes writers, so the read-decide-write is atomic there too.
+    current = (
+        db.query(models.User.credits)
+        .filter(models.User.id == user.id)
+        .with_for_update()
+        .scalar()
+    )
+    deducted = min(amount, current) if current is not None else 0
+    if deducted:
+        db.query(models.User).filter(models.User.id == user.id).update(
+            {models.User.credits: models.User.credits - deducted}
+        )
+    db.refresh(user)
+    db.add(
+        models.CreditTransaction(
+            user_id=user.id,
+            amount=-deducted,
+            kind=kind,
+            external_id=external_id,
+            endpoint=endpoint,
+            balance_after=user.credits,
+        )
+    )
+    try:
+        db.commit()
+    except IntegrityError:
+        db.rollback()
+        if external_id_exists(db, external_id):
+            return 0  # already processed (idempotent replay)
+        raise
+    db.refresh(user)
+    if deducted < amount:
+        logger.warning(
+            "gringotts clawback clamped: wanted %s, deducted %s for user %s "
+            "(external_id=%s)",
+            amount,
+            deducted,
+            user.id,
+            external_id,
+        )
+    return deducted
+
+
 def list_transactions(
     db: Session,
     user_id: int | None = None,
@@ -194,13 +287,21 @@ def list_transactions(
 
 
 def list_users_with_stats(db: Session) -> list[dict]:
-    """Return, per user, balance plus consumption and last activity from the ledger."""
+    """Return, per user, balance plus consumption and last activity from the ledger.
+
+    `consumed` is net of refunds: a charge adds to it, a refund (which reverses a
+    charge) subtracts, so a fully refunded request counts as zero consumption.
+    """
     consumed = func.sum(
         case(
             (
                 models.CreditTransaction.kind == "charge",
                 -models.CreditTransaction.amount,
             ),
+            (
+                models.CreditTransaction.kind == "refund",
+                -models.CreditTransaction.amount,
+            ),
             else_=0,
         )
     )
@@ -244,10 +345,13 @@ def aggregate_stats(db: Session) -> dict:
         )
         return int(value or 0)
 
+    charged = -_sum_for("charge", models.CreditTransaction.amount)
+    refunded = _sum_for("refund", models.CreditTransaction.amount)
     return {
         "users": int(user_count),
         "credits_outstanding": int(outstanding),
-        "credits_consumed": -_sum_for("charge", models.CreditTransaction.amount),
+        # net of refunds: a fully refunded charge is zero consumption
+        "credits_consumed": charged - refunded,
         "credits_purchased": _sum_for("purchase", models.CreditTransaction.amount),
         "revenue_cents": _sum_for("purchase", models.CreditTransaction.amount_cents),
     }
diff --git a/src/gringotts/migrations.py b/src/gringotts/migrations.py
index d686ca0..d59852d 100644
--- a/src/gringotts/migrations.py
+++ b/src/gringotts/migrations.py
@@ -99,8 +99,23 @@ def _add_balance_after(conn) -> None:
         )
 
 
+def _add_payment_intent_id(conn) -> None:
+    if not _has_column(conn, "credit_transactions", "payment_intent_id"):
+        conn.execute(
+            text("ALTER TABLE credit_transactions ADD COLUMN payment_intent_id VARCHAR")
+        )
+    # matches the index create_all builds for the indexed model column
+    conn.execute(
+        text(
+            "CREATE INDEX IF NOT EXISTS ix_credit_transactions_payment_intent_id "
+            "ON credit_transactions (payment_intent_id)"
+        )
+    )
+
+
 STEPS: list[tuple[int, str, Callable]] = [
     (1, "add balance_after running-balance to credit_transactions", _add_balance_after),
+    (2, "add payment_intent_id to credit_transactions", _add_payment_intent_id),
 ]
 
 HEAD = STEPS[-1][0] if STEPS else 0
diff --git a/src/gringotts/models.py b/src/gringotts/models.py
index 363a695..20de9df 100644
--- a/src/gringotts/models.py
+++ b/src/gringotts/models.py
@@ -56,6 +56,11 @@ class CreditTransaction(Base):
     endpoint: Mapped[str | None] = mapped_column(String, default=None)
     # money actually paid, set only on purchase rows (Checkout amount_total)
     amount_cents: Mapped[int | None] = mapped_column(default=None)
+    # Stripe PaymentIntent id, set on purchase rows; lets refund/dispute events
+    # (which carry payment_intent, not the checkout session) find this purchase
+    payment_intent_id: Mapped[str | None] = mapped_column(
+        String, index=True, default=None
+    )
     created_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=lambda: datetime.now(UTC)
     )
diff --git a/src/gringotts/router.py b/src/gringotts/router.py
index e03cbd0..f355898 100644
--- a/src/gringotts/router.py
+++ b/src/gringotts/router.py
@@ -10,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response
 from sqlalchemy.orm import Session
 
 from . import billing, crud, pages
-from .config import GringottsConfig
+from .config import GringottsConfig, format_money
 from .db import get_session
 from .dependencies import API_KEY_HEADER, authenticate
 
@@ -25,6 +25,88 @@ _FULFILL_EVENTS = {
 }
 _PAID_STATUSES = {"paid", "no_payment_required"}
 
+# Reversal events. A refund (possibly partial) claws back a proportional share
+# of the granted credits; a dispute claws back on funds_withdrawn and re-credits
+# on funds_reinstated. The "warning_*" dispute events move no funds, so ignore.
+_REFUND_EVENTS = {"refund.created", "refund.updated"}
+_DISPUTE_WITHDRAWN = "charge.dispute.funds_withdrawn"
+_DISPUTE_REINSTATED = "charge.dispute.funds_reinstated"
+
+
+def _process_refund(db, event) -> None:
+    """Claw back credits proportional to a Stripe refund (clamped at zero)."""
+    refund = event["data"]["object"]
+    try:
+        payment_intent_id = refund["payment_intent"]
+        refund_id = refund["id"]
+        refund_amount = int(refund["amount"])
+    except (KeyError, TypeError, ValueError):
+        logger.error("gringotts webhook %s: refund missing fields", event["id"])
+        return
+    purchase = crud.find_purchase_by_payment_intent(db, payment_intent_id)
+    if purchase is None or not purchase.amount_cents:
+        logger.warning(
+            "gringotts webhook %s: refund for an unknown or pre-0.3 purchase "
+            "(payment_intent=%s); cannot claw back",
+            event["id"],
+            payment_intent_id,
+        )
+        return
+    user = crud.get_user(db, purchase.user_id)
+    if user is None:
+        logger.error("gringotts webhook %s: refund user missing", event["id"])
+        return
+    # proportional to the fraction of the payment refunded
+    to_claw = round(purchase.amount * refund_amount / purchase.amount_cents)
+    crud.clawback_credits(
+        db, user, to_claw, external_id=refund_id, endpoint="stripe:refund"
+    )
+
+
+def _process_dispute(db, event, *, reinstate: bool) -> None:
+    """Claw back on a lost/withdrawn dispute, re-credit on a reinstated one."""
+    dispute = event["data"]["object"]
+    try:
+        payment_intent_id = dispute["payment_intent"]
+        dispute_id = dispute["id"]
+    except (KeyError, TypeError):
+        logger.error("gringotts webhook %s: dispute missing fields", event["id"])
+        return
+    purchase = crud.find_purchase_by_payment_intent(db, payment_intent_id)
+    if purchase is None:
+        logger.warning(
+            "gringotts webhook %s: dispute for an unknown or pre-0.3 purchase "
+            "(payment_intent=%s); cannot act",
+            event["id"],
+            payment_intent_id,
+        )
+        return
+    user = crud.get_user(db, purchase.user_id)
+    if user is None:
+        logger.error("gringotts webhook %s: dispute user missing", event["id"])
+        return
+    withdrawn_key = f"{dispute_id}:withdrawn"
+    if reinstate:
+        # restore exactly what was clawed back for this dispute (clamp-aware)
+        restored = crud.clawback_deducted(db, withdrawn_key)
+        if restored:
+            crud.grant_credits(
+                db,
+                user,
+                restored,
+                kind="reinstate",
+                external_id=f"{dispute_id}:reinstated",
+            )
+    else:
+        crud.clawback_credits(
+            db,
+            user,
+            purchase.amount,
+            external_id=withdrawn_key,
+            endpoint="stripe:dispute",
+        )
+
+
 _BUY_PAGE = """<!doctype html>
 <html>
 <head><meta charset="utf-8"><title>Buy credits</title>
@@ -145,7 +227,7 @@ def build_router(config: GringottsConfig) -> APIRouter:
             f'<label><input type="radio" name="pack" value="{i}"'
             f" {'checked' if i == 0 else ''}>"
             f" {html.escape(pack.name)} — {pack.credits} credits for "
-            f"{pack.price_cents / 100:.2f} {pack.currency.upper()}</label>"
+            f"{format_money(pack.price_cents, pack.currency)}</label>"
             for i, pack in enumerate(config.packs)
         )
         status_html = ""
@@ -239,6 +321,10 @@ def build_router(config: GringottsConfig) -> APIRouter:
                 amount_cents = int(session["amount_total"])
             except (KeyError, TypeError, ValueError):
                 amount_cents = None
+            try:
+                payment_intent_id = session["payment_intent"]
+            except (KeyError, TypeError):
+                payment_intent_id = None
             user = crud.get_user(db, user_id)
             if user is None:
                 # Usually transient (the user existed when checkout was created;
@@ -272,12 +358,19 @@ def build_router(config: GringottsConfig) -> APIRouter:
                 kind="purchase",
                 external_id=session_id,
                 amount_cents=amount_cents,
+                payment_intent_id=payment_intent_id,
             )
             if not granted:
                 logger.info(
                     "gringotts webhook: checkout session %s already credited",
                     session_id,
                 )
+        elif event["type"] in _REFUND_EVENTS:
+            _process_refund(db, event)
+        elif event["type"] == _DISPUTE_WITHDRAWN:
+            _process_dispute(db, event, reinstate=False)
+        elif event["type"] == _DISPUTE_REINSTATED:
+            _process_dispute(db, event, reinstate=True)
         return {"received": True}
 
     return router
