HextaUI

Refund

A refund sheet for issuing full or partial refunds on an order. Handles prorated discounts, restocking, shipping, split payments and failed or pending refunds.

Open in new tab

Refund is the screen a merchant opens from an order to send money back. RefundSheet wraps it in a wide sheet on desktop and a full-height sheet on phones, and RefundForm is the same form for embedding in your own page. Each line has a quantity stepper from 0 to what’s still refundable, already refunded lines are listed as Refunded, and a summary on the right recalculates as you go.

The math lives in calculateRefund(order, selection), a pure function with tests. Order-level discounts are split across lines in proportion to each line’s price, and tax in proportion to each line’s price after discount, with the largest-remainder method so the parts add up to the cent. Each unit then takes a cumulative slice of its line’s share, so refunding units one at a time adds up to exactly what refunding them together would. When the selection takes everything that’s left, the total is set to the amount still available, so earlier rounding never strands a cent.

Shipping can be refunded up to what was paid minus what was already refunded. The total can be edited by hand, showing how far it is from the calculated amount, and is checked against what’s available. Money goes back to the payment methods in the order you pass them, so a gift card split shows both parts. Currencies without decimals, like yen, are handled in minor units throughout.

onRefund receives a RefundRequest and can return a Refund. A succeeded refund closes the sheet, a pending one shows “Refund pending with the bank”, and a failed one or a thrown error shows its message next to the button with everything still filled in. The button guards against double submits, and refunds over half the order or the whole order ask for confirmation first.

  1. HextaUI Pro holen

    Eine einmalige Zahlung schaltet jeden Block frei, auch künftige. Preise ansehen.

  2. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  4. Den Block hinzufügen

    pnpm dlx shadcn@latest add @hextaui-pro/refund

Shopify refundCreate

Ask Shopify’s suggestedRefund for the parent transactions, then send refundCreate with the line items, restock location and shipping from the request. A FAILURE or PENDING transaction comes back as a failed or pending Refund, so the sheet shows the right state.

"use client"

import type { Order, Refund } from "../order-status/order"
import { RefundSheet } from "@/components/blocks/refund/refund-sheet"
import type { RefundRequest } from "@/components/blocks/refund/refund-form"

type ShopifyMoneyBag = { shopMoney: { amount: string; currencyCode: string } }

type RefundCreateResponse = {
  refund: {
    id: string
    createdAt: string
    totalRefundedSet: ShopifyMoneyBag
    transactions: { nodes: { status: "SUCCESS" | "PENDING" | "FAILURE" | "ERROR"; errorCode: string | null }[] }
  } | null
  userErrors: { field: string[] | null; message: string }[]
}

async function admin<T>(query: string, variables: Record<string, unknown>): Promise<T> {
  const response = await fetch("/api/shopify/admin", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  })
  const { data, errors } = await response.json()
  if (errors?.length) throw new Error(errors[0].message)
  return data
}

const SUGGESTED_REFUND = `
  query Suggested($id: ID!, $lines: [RefundLineItemInput!], $shipping: Money) {
    order(id: $id) {
      suggestedRefund(refundLineItems: $lines, shippingAmount: $shipping) {
        suggestedTransactions { gateway parentTransaction { id } amountSet { shopMoney { amount } } }
      }
    }
  }
`

const REFUND_CREATE = `
  mutation Refund($input: RefundInput!) {
    refundCreate(input: $input) {
      refund {
        id
        createdAt
        totalRefundedSet { shopMoney { amount currencyCode } }
        transactions(first: 5) { nodes { status errorCode } }
      }
      userErrors { field message }
    }
  }
`

async function refundOnShopify(request: RefundRequest): Promise<Refund> {
  const lines = request.lines.map((line) => ({
    lineItemId: line.lineId,
    quantity: line.quantity,
    restockType: line.restock ? "RETURN" : "NO_RESTOCK",
    locationId: line.restock ? request.restockLocationId : undefined,
  }))
  const suggested = await admin<{
    order: {
      suggestedRefund: {
        suggestedTransactions: {
          gateway: string
          parentTransaction: { id: string }
          amountSet: { shopMoney: { amount: string } }
        }[]
      }
    }
  }>(SUGGESTED_REFUND, { id: request.orderId, lines, shipping: request.shipping.amount })

  let left = Number(request.amount.amount)
  const transactions = suggested.order.suggestedRefund.suggestedTransactions.map((item) => {
    const amount = Math.min(left, Number(item.amountSet.shopMoney.amount))
    left -= amount
    return {
      orderId: request.orderId,
      gateway: item.gateway,
      kind: "REFUND",
      parentId: item.parentTransaction.id,
      amount: amount.toFixed(2),
    }
  })

  const { refundCreate } = await admin<{ refundCreate: RefundCreateResponse }>(REFUND_CREATE, {
    input: {
      orderId: request.orderId,
      note: [request.reason, request.note].filter(Boolean).join(": "),
      notify: request.notify,
      refundLineItems: lines,
      shipping: { amount: request.shipping.amount },
      transactions,
    },
  })
  if (refundCreate.userErrors.length > 0) throw new Error(refundCreate.userErrors[0].message)
  const refund = refundCreate.refund!
  const states = refund.transactions.nodes.map((item) => item.status)
  return {
    id: refund.id,
    createdAt: refund.createdAt,
    status: states.includes("FAILURE") || states.includes("ERROR")
      ? "failed"
      : states.includes("PENDING")
        ? "pending"
        : "succeeded",
    amount: {
      amount: refund.totalRefundedSet.shopMoney.amount,
      currencyCode: refund.totalRefundedSet.shopMoney.currencyCode,
    },
    shipping: request.shipping,
    lines: request.lines,
    reason: request.reason ?? undefined,
    failureMessage: refund.transactions.nodes.find((item) => item.errorCode)?.errorCode ?? undefined,
  }
}

export function ShopifyRefund({ order, onRefunded }: { order: Order; onRefunded: (refund: Refund) => void }) {
  return (
    <RefundSheet
      order={order}
      locations={[
        { id: "gid://shopify/Location/1", name: "Hayes Valley store" },
        { id: "gid://shopify/Location/2", name: "Oakland warehouse" },
      ]}
      onRefund={refundOnShopify}
      onComplete={onRefunded}
    />
  )
}

Stripe refunds API

A server action that creates one Stripe refund per transaction in minor units, with an idempotency key so a retry never refunds twice. Stripe’s failure_reason turns into a message the merchant can act on.

"use server"

import { toMinorUnits } from "../price/money"
import type { Refund } from "../order-status/order"
import type { RefundRequest } from "@/components/blocks/refund/refund-form"

type StripeRefund = {
  id: string
  amount: number
  currency: string
  created: number
  status: "pending" | "requires_action" | "succeeded" | "failed" | "canceled"
  failure_reason?: "charge_for_pending_refund_disputed" | "declined" | "expired_or_canceled_card" | "insufficient_funds" | "lost_or_stolen_card" | "merchant_request" | "unknown"
}

const failureMessages: Record<NonNullable<StripeRefund["failure_reason"]>, string> = {
  charge_for_pending_refund_disputed: "The customer disputed this charge, so the refund was canceled.",
  declined: "The card issuer declined the refund. Try store credit instead.",
  expired_or_canceled_card: "The card has expired or was canceled. Refund to store credit or by bank transfer.",
  insufficient_funds: "Your Stripe balance is too low to cover this refund.",
  lost_or_stolen_card: "The card was reported lost or stolen. Refund another way.",
  merchant_request: "The refund was canceled from your Stripe dashboard.",
  unknown: "Stripe couldn’t complete the refund. Nothing was charged.",
}

async function stripeRefund(paymentIntent: string, amount: number, idempotencyKey: string, metadata: Record<string, string>) {
  const body = new URLSearchParams({ payment_intent: paymentIntent, amount: String(amount) })
  for (const [key, value] of Object.entries(metadata)) body.append(`metadata[${key}]`, value)
  const response = await fetch("https://api.stripe.com/v1/refunds", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
      "Content-Type": "application/x-www-form-urlencoded",
      "Idempotency-Key": idempotencyKey,
    },
    body,
  })
  const json = await response.json()
  if (!response.ok) throw new Error(json.error?.message ?? "Stripe couldn’t create the refund.")
  return json as StripeRefund
}

export async function refundWithStripe(
  request: RefundRequest,
  paymentIntents: Record<string, string>,
  idempotencyKey: string
): Promise<Refund> {
  const results = await Promise.all(
    request.transactions.map((transaction, index) =>
      stripeRefund(
        paymentIntents[transaction.paymentId],
        toMinorUnits(transaction.amount),
        `${idempotencyKey}-${index}`,
        { order_id: request.orderId, reason: request.reason ?? "" }
      )
    )
  )
  const failed = results.find((item) => item.status === "failed" || item.status === "canceled")
  return {
    id: results.map((item) => item.id).join(","),
    createdAt: new Date(Math.max(...results.map((item) => item.created)) * 1000).toISOString(),
    status: failed ? "failed" : results.every((item) => item.status === "succeeded") ? "succeeded" : "pending",
    amount: request.amount,
    shipping: request.shipping,
    lines: request.lines,
    reason: request.reason ?? undefined,
    note: request.note || undefined,
    failureMessage: failed ? failureMessages[failed.failure_reason ?? "unknown"] : undefined,
  }
}

Medusa payments

Map Medusa payments to refund sources and call the admin refund route for each. Reasons come from your refund reason IDs.

"use client"

import { money, toNumber } from "../price/money"
import type { Order, Refund } from "../order-status/order"
import { RefundForm, type RefundRequest } from "@/components/blocks/refund/refund-form"

type MedusaPayment = { id: string; amount: number; refunded_amount?: number; provider_id: string; currency_code: string }

async function medusa<T>(path: string, body: unknown): Promise<T> {
  const response = await fetch(`${process.env.NEXT_PUBLIC_MEDUSA_URL}/admin${path}`, {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  })
  const json = await response.json()
  if (!response.ok) throw new Error(json.message ?? "Medusa couldn’t refund this payment.")
  return json as T
}

export function MedusaRefund({
  order,
  payments,
  reasonIds,
}: {
  order: Order
  payments: MedusaPayment[]
  reasonIds: Record<string, string>
}) {
  const refund = async (request: RefundRequest): Promise<Refund> => {
    for (const transaction of request.transactions) {
      await medusa(`/payments/${transaction.paymentId}/refund`, {
        amount: toNumber(transaction.amount),
        refund_reason_id: request.reason ? reasonIds[request.reason] : undefined,
        note: request.note || undefined,
      })
    }
    return {
      id: `refund_${Date.now()}`,
      createdAt: new Date().toISOString(),
      status: "succeeded",
      amount: request.amount,
      shipping: request.shipping,
      lines: request.lines,
      reason: request.reason ?? undefined,
    }
  }

  return (
    <RefundForm
      order={order}
      reasons={Object.keys(reasonIds)}
      payments={payments.map((payment) => ({
        id: payment.id,
        label: payment.provider_id === "pp_system_default" ? "Manual payment" : payment.provider_id,
        amount: money(payment.amount, payment.currency_code),
        refunded: money(payment.refunded_amount ?? 0, payment.currency_code),
      }))}
      onRefund={refund}
    />
  )
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
RefundSheetThe sheet with the order name, customer and what they paid, wrapping RefundForm.
RefundFormLine steppers, restock, shipping, reason, note, notification and the sticky summary with the submit button.
RefundQuantityThe 0 to max stepper used on each line.
RefundHistoryEarlier refunds with amount, date, items and status.
calculateRefundPure refund math: prorated discount and tax, shipping, manual amount, available amount and payment split.
parseAmountReads a typed amount like 1,234.50 or 12,50 into Money, respecting the currency’s decimals.

RefundSheet

Takes every RefundForm prop except onClose.

PropTypStandard
openControlled open state.
boolean–
defaultOpenInitial open state when uncontrolled.
booleanfalse
onOpenChangeCalled when the sheet opens or closes. Closing is ignored while a refund is processing.
(open: boolean) => void–
triggerAn element that opens the sheet, such as a Refund button.
React.ReactElement–

RefundForm

Also takes form props.

PropTypStandard
orderThe order to refund. Lines, discounts, tax, shipping and earlier refunds drive the math.
Order–
onRefundIssues the refund. Return a Refund with status pending or failed to show those states, or throw to show the error.
(request: RefundRequest) => Promise<Refund | void>–
onCompleteCalled after a refund succeeds or is pending.
(refund: Refund) => void–
paymentsWhere the money goes back, in order. Defaults to the order’s payment method.
RefundPayment[]–
locationsRestock locations. Hidden when empty.
{ id: string; name: string }[][]
defaultLocationIdThe location selected first. Defaults to the first one.
string–
reasonsReasons in the select.
string[]Customer changed their mind, Damaged, Wrong item, Didn’t arrive, Other
noteRequiredForReasons that need a note.
string[]["Other"]
restockableReturn false for items that aren’t physical, such as gift cards or downloads.
(line: OrderLine) => boolean() => true
defaultRestockWhether restock starts on.
booleantrue
defaultNotifyWhether the customer notification starts on.
booleantrue
confirmAboveAsk for confirmation above this share of the order total. Refunding everything always asks.
number0.5
localeLocale for money and dates.
Intl.LocalesArgument"en-US"
labelsEvery user-facing string.
Partial<RefundFormLabels>–

RefundRequest

What onRefund receives.

PropTypStandard
linesSelected lines and whether each is restocked.
{ lineId; quantity; restock }[]–
restockLocationIdWhere items are restocked, or null.
string | null–
shippingShipping being refunded.
Money–
amountThe refund total, calculated or typed.
Money–
calculatedThe calculated total, for auditing a manual amount.
Money–
transactionsThe split across payment methods.
{ paymentId: string; amount: Money }[]–
reason, note, notifyReason, staff note and whether to email the customer.
string | null, string, boolean–

calculateRefund

calculateRefund(order, selection, { payments }) returns RefundCalculation.

PropTypStandard
selection.linesUnits to refund per line ID. Clamped to what’s refundable and flagged in errors.
Record<string, number>–
selection.shippingShipping to refund. Capped at shipping paid minus shipping refunded.
Money | null–
selection.amountA manual total. The result reports the difference from the calculated amount.
Money | null–
resultitemsSubtotal, discount, tax, shipping, calculated, total, available, maxShipping, allocations, share, everything and errors.
RefundCalculation–
TasteAktion
ArrowUpArrowDownIn a quantity field, refund one more or one less unit.
HomeEndIn a quantity field, jump to none or all refundable units.
EnterSubmit the refund, or confirm it in the dialog.
EscapeClose the confirmation or the sheet, unless a refund is processing.
  • Each quantity field is a labelled spinbutton with its range, and the buttons say which item they change.
  • The selection and total are announced politely as they change. A failed refund is an alert next to the button, and pending or success results are announced as status.
  • Refunded lines are marked with text, not only dimmed. Totals roll per digit with tabular figures and stay still with reduced motion.
  • Focus moves to the invalid field when submitting with a problem, and returns to the submit button after the confirmation closes.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Refund besteht. Jede lässt sich einzeln installieren.

Code

7 Dateien, hinzugefügt zu components/blocks/refund.