Stripe Partial Refund: Discount and Tax Reversal

A Stripe partial refund carries no line items. Split one item's discount and tax, reverse its tax record, and track refunds per line, or refund by item.
Refunds7 min readBy Flint Pay, Product & API Team

A buyer ordered trail shoes and two pairs of socks, used a promotion code, paid sales tax, and is sending one pair of socks back. The Stripe call that returns the money takes a payment and a number:

TypeScript
await stripe.refunds.create({
  payment_intent: "pi_...",
  amount: 1771,
});

Stripe sends back whatever amount says. It does not know which item the amount stands for, how much of the promotion code belonged to that item, or how much sales tax to take off your books. You work out all three before the call and record the result after it.

A Stripe refund is an amount, not an item#

Creating a refund takes a charge or a payment_intent and an optional amount. The remaining parameters cover the reason, metadata, and Connect fees and transfers. None of them names a line item or a quantity, and the Refund object that comes back has no line_items.

Stripe's own accounting connector shows what that costs downstream. From the Stripe Connector for NetSuite documentation on partial refunds:

If the invoice contains multiple items, the connector can't determine which line items to refund. Because Stripe doesn't associate a refund with specific line items, the connector represents the refund with a non-inventory sale item called a Stripe Partial Refund Item.

Your books get a placeholder item where the returned socks should be. Any system that reads refunds from Stripe starts from the same position: finance sees that $17.71 went back, and nothing on the refund says what for.

Work out what the item cost the buyer#

The sticker price is the wrong number. Two pairs of socks at $18.00 do not refund at $18.00 a pair once a promotion code and sales tax have touched the line. The amounts the buyer paid are on the Checkout Session's line items:

TypeScript
const { data: lineItems } = await stripe.checkout.sessions.listLineItems(
  sessionId,
  { limit: 100 },
);

Take a socks line that reads like this:

JSON
{
  "description": "Merino socks",
  "quantity": 2,
  "amount_subtotal": 3600,
  "amount_discount": 396,
  "amount_tax": 338,
  "amount_total": 3542
}

The order-level promotion code took $3.96 off this line, and $3.38 of tax was added after the discount. One pair is half of amount_total:

One pair of socksAmount
Price$18.00
Share of the promotion code-$1.98
Sales tax on the discounted price+$1.69
Refund$17.71

Refund $18.00 and you return money the buyer never paid. Refund $16.02 and the buyer is out the tax. Lines that do not divide evenly need a per-unit split that still sums to the line total after several partial refunds. How to refund a line item in Stripe has that helper, and the credit note path for payments that have an invoice behind them.

A PaymentIntent you created yourself has no session and no line items. The cart, the discount split, and the tax per line are wherever your code saved them before the payment.

Reverse the tax on a partial refund#

Sending $1.69 of tax back to the buyer does not take $1.69 off the tax you owe. That is a second record, and how it gets written depends on how you collect tax.

How you collect taxWhat a partial refund does to the tax record
Checkout, Payment Links, or Invoicing with automatic_taxStripe lowers the tax reported when you refund the charge. The reversal is built from the refund, and the refund is an amount.
Tax API linked to a PaymentIntentStripe records a flat amount reversal and spreads it across every line item.
Tax API with your own tax transactionsYou create each reversal yourself, with the line item, the amount, and the tax.

The second row is where returning one item goes wrong. Stripe describes the PaymentIntent integration this way: "Refund reversals use flat-amount distribution." A flat amount is divided across the whole sale:

The amount distributes across each line item and shipping cost proportionally, depending on the remaining amount left to refund on each.

Refund $17.71 for the socks, and about 78 percent of the reversal is recorded against the shoes the buyer kept, because the shoes are 78 percent of the sale. When every line is taxed at one rate, the total tax reversed still comes out right and only the per-item rows in your tax export are wrong. When the lines are taxed differently, such as clothing that is exempt in one state next to accessories that are not, the tax total is wrong too.

For a reversal that names the item, Stripe points you to the third row. You record the sale as a tax transaction, store its ID and its line item IDs with your order, and send the item's numbers after each refund:

cURL
curl https://api.stripe.com/v1/tax/transactions/create_reversal \
  -u "sk_test_...:" \
  -d mode=partial \
  -d original_transaction=tax_... \
  -d reference=order_9127-refund_1 \
  -d "line_items[0][original_line_item]=tax_li_..." \
  -d "line_items[0][reference]=socks-1" \
  -d "line_items[0][amount]=-1602" \
  -d "line_items[0][amount_tax]=-169"

The arithmetic is yours again. Stripe's guide: "If you refund parts of line items, you must calculate the amounts refunded." You get 30 partial reversals per sale, a unique reference to invent for each one, and this warning if the tax you send is not in proportion to the amount:

If you refund a tax amount such that the total tax is no longer proportional to the subtotal, your tax reporting can be unreliable.

Record which item came back#

The refund cannot hold the item, so your system does. The usual convention is fixed keys in the refund's metadata (line_item_id, units_refunded) plus a table of refunded amounts per line in your own database. Stripe checks a refund against the charge's remaining balance and nothing else, so that table is also the only thing that stops two support agents from refunding the same pair of socks twice.

Count what one returned item takes: a line item lookup, a discount and tax calculation, a per-unit split, a tax reversal, a metadata convention, and a ledger of refunds per line. It is returns logic for an order system, written around a payment API that stores amounts.

Refund the item instead of the amount#

Flint is a payments API built around orders. The order holds the line items, each line's share of every discount, and each line's tax by jurisdiction, so a refund can name the item and the quantity.

This is the same cart as a Flint order, with automatic sales tax for a Seattle address:

cURL
curl -X POST https://api.withflintpay.com/v1/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "external_reference_id": "cart_9127",
    "line_items": [
      { "name": "Trail Runner 2", "quantity": 1,
        "unit_price_money": { "amount": 12800, "currency": "USD" },
        "tax": { "taxable": true },
        "fulfillment": { "requirement": "none" } },
      { "name": "Merino socks", "quantity": 2,
        "unit_price_money": { "amount": 1800, "currency": "USD" },
        "tax": { "taxable": true },
        "fulfillment": { "requirement": "none" } }
    ],
    "tax": {
      "enabled": true,
      "calculation": { "mode": "automatic", "price_mode": "additive" },
      "location": {
        "address_source": "provided",
        "address_type": "billing_address",
        "address": { "line1": "920 5th Ave", "city": "Seattle", "state": "WA",
                     "postal_code": "98104", "country": "US" }
      }
    }
  }'

With $18.00 of discounts applied, the order comes to $161.41: a $164.00 subtotal, less $18.00, plus $15.41 of tax across Washington's 6.5 percent and Seattle's 4.05 percent. The socks line carries its own share of each:

Response
{
  "order_line_item_id": "li_1kmn0bExample",
  "name": "Merino socks",
  "quantity": 2,
  "subtotal_money": { "amount": 3600, "currency": "USD" },
  "discount_money": { "amount": 396, "currency": "USD" },
  "tax_money": { "amount": 338, "currency": "USD" },
  "total_money": { "amount": 3542, "currency": "USD" }
}

The buyer pays, then returns one pair. The refund request names the line and the quantity, and sends no amount:

cURL
curl -X POST https://api.withflintpay.com/v1/refunds \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: refund-cart-9127-socks-1" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "reason": "customer_changed_mind",
    "line_items": [
      { "order_line_item_id": "li_1kmn0bExample", "quantity": 1 }
    ]
  }'

Flint refunds $17.71. We ran this order in a Flint sandbox, and that is the amount the API returned: the $18.00 price, less the pair's $1.98 of the discounts, plus its $1.69 of tax. The refund's line_item_allocations shows the working:

Response
"line_item_allocations": [
  {
    "order_line_item_id": "li_1kmn0bExample",
    "quantity": 1,
    "refunded_money": { "amount": 1771, "currency": "USD" },
    "automatic_refund": {
      "subtotal_money": { "amount": 1800, "currency": "USD" },
      "discount_money": { "amount": 198, "currency": "USD" },
      "tax_money": { "amount": 169, "currency": "USD" },
      "total_money": { "amount": 1771, "currency": "USD" }
    },
    "tax_refund_mode": "automatic"
  }
]

The tax reversal happens in the same call. Flint allocates the $1.69 back to the tax rows it came from, Washington and Seattle, so the tax_summarized_v1 report lists refunded tax by jurisdiction without a second request. When a jurisdiction's rules call for a smaller reversal, tax_refund_mode: "explicit" lets you state the tax amount along with a reason code and the person who approved it. Sales tax covers the override.

The order keeps count as well. After the refund, the socks line reads:

JSON
{
  "name": "Merino socks",
  "quantity": 2,
  "refunded_quantity": 1,
  "refunded_money": { "amount": 1771, "currency": "USD" }
}

The order's refund_status becomes partially_refunded, and its settlement_amounts.refunded_money reads 1771. Support, finance, and your storefront read the same line item, and a refund for more socks than the line has left fails with LINE_ITEM_REFUND_QUANTITY_EXCEEDS_REFUNDABLE before any money moves. An order.refunded webhook tells your other systems.

When the socks are physically coming back, a Return wraps this refund with return eligibility, inspection, restocking fees, and the stock update. The Refunds guide covers exact amounts, delivery fees, tips, failed refunds, and orders paid with more than one payment.

Request access, or run flint signup in your terminal, then refund one item from a sandbox order. If you run on Stripe today, Migrating from Stripe maps each Stripe object to its Flint equivalent so you can move one payment flow at a time.

Sources#

Everything attributed to Stripe above comes from their documentation, checked on 2026-09-20.