# Lemon Squeezy orders, invoices and partial refunds, and what each does to access

> The first payment arrives as an order and an invoice, refunded_amount is a running total, and chargebacks send no event. How to record each once and revoke access on the right one.

Lemon Squeezy spreads money over two objects and six events, and two of those
events describe the same dollars. Get the mapping wrong and you either count a
new customer twice or keep a refunded lifetime deal unlocked.

## Two objects carry money

- **Orders.** One per checkout. `order_created` when it is placed (normally
  already `paid`), `order_refunded` on every refund. A subscription's first
  payment is also an order.
- **Subscription invoices.** One per charge on a subscription:
  `billing_reason` is `initial`, `renewal` or `updated` (a proration).
  `subscription_payment_success` when paid, `subscription_payment_failed` on a
  decline, `subscription_payment_refunded` on a refund.

A new subscription produces an order **and** an `initial` invoice for the same
charge. Treat both as money and your revenue for new customers doubles; treat
both as purchases and a $19 monthly plan becomes a lifetime deal.

## One job per event

| Event | What it records |
|---|---|
| `order_created` for a single-payment variant | a one-time purchase, and its receipt |
| `order_created` for a subscription variant | nothing: the subscription events own it |
| `order_refunded` | the purchase's refunded amount and status |
| `subscription_*` | the subscription, re-read from the API |
| `subscription_payment_success` | the subscription, and the receipt for that invoice (initial and renewals) |
| `subscription_payment_failed` | the subscription (now `past_due`), and a warning mail |

The deciding question for an order is "is this variant a one-time price in
my catalogue?". Ask your catalogue, not the payload: custom data can be typed
into a public buy link, the variant that was paid for cannot.

## `refunded_amount` is a running total

Refund $100 of a $299 order, then the rest. You get two `order_refunded`
events. The first says `refunded_amount: 10000`. The second says `29900`, not
`19900`.

Store it as a running total, never add it up:

```ts
function orderPurchaseStatus(order) {
  if (order.refunded_amount > 0) {
    return order.refunded_amount >= order.total ? "refunded" : "partially_refunded";
  }
  if (order.status === "paid") return "paid";
  if (order.status === "pending") return "pending";
  return "failed"; // failed, fraudulent
}
```

Write the row with `amount_refunded = greatest(old, new)` and a status that
only moves forward. A resend of the first refund then changes nothing, and a
late `order_created` retry cannot turn a refunded order back into a paid one.
Decide from the numbers first: the status word has more than one spelling for
a partial refund.

## Which refunds revoke access

- **Full refund:** the purchase is `refunded` and grants nothing. The user
  falls back to the free plan on their next request.
- **Partial refund:** `partially_refunded` keeps the plan. A goodwill refund
  of part of the price is not a request to take the product away. If your
  policy differs, change the entitled statuses in one place, not in a page.
- **Subscription invoice refund:** it does not end the subscription. If the
  merchant also cancels, `subscription_cancelled` arrives on its own.

## Chargebacks send no event

Lemon Squeezy is the merchant of record, so it handles chargebacks itself,
and there is no dispute webhook to act on. Watch disputed orders in the
dashboard. If you decide a customer who disputed should lose access, refund
the order (or cancel the subscription): the refund path revokes it like any
other. Do not write access rules that wait for a "disputed" status: on Lemon
Squeezy it never comes.

## Record gross, reconcile against the payout

Store `total`: what the customer paid, tax included. It matches the order list
in the dashboard, which is what you reconcile against. Your payout is lower by
the tax Lemon Squeezy remits and its fee; that net number lives in the payout
report, not in a column you compute.

## Money guards

- Amounts are integers in cents. Validate with `Number.isInteger`, not
  `Number.isFinite`: 19.99 in an integer column is off by 100 after rounding.
- No defaults. `?? 0` or `?? "usd"` writes a wrong row under a key that will
  refuse the correction. Fail the delivery instead.
- A paid order you cannot match to a user fails the delivery (500), so it goes
  red and gets retried. Quietly answering 200 makes that money unrecordable.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
