A Digital Product Refund Policy You Can Enforce in Code
A 14-day, no-download refund policy and the four-state engine behind it — including the attribution 'fix' that opens a download-then-refund bypass.
A refund policy for digital products is prose until something enforces it. "Refunds before download" is a clear sentence and a hard engineering problem: it commits you to answering has this order been downloaded? — a question with a surprising number of wrong answers, one of which quietly opens a download-then-refund bypass.
Search this topic and you get policy generators: fill in a company name, pick 14 or 30 days, paste the output into a page. That is the easy half. This post is the other half — the four-state eligibility engine behind our own refund policy, the coverage rule that decides what "downloaded" means, and the mapping from each sentence of policy to the code that makes it true. It is not legal advice; it is what enforcement looks like once a lawyer has told you what to enforce.
The policy, in one paragraph
Full refund within 14 days of purchase, provided you have not downloaded any premium item in that order. Downloading counts as delivery and ends eligibility for that order. Refunds are processed by LemonSqueezy, our Merchant of Record, back to the original payment method. Once refunded, download access is revoked and the licence granted by that purchase is void.
Four sentences. Each one is a constraint some code has to hold.
Policy sentence → the code that enforces it
| Policy says | Enforced by | If you skip it |
|---|---|---|
| "within 14 days" | REFUND_WINDOW_DAYS = 14, compared against orders.created_at | The window is whatever support feels like that day |
| "have not downloaded" | Coverage check against download_deliveries | Buyers keep the files and the money |
| "one request per order" | Partial unique index on pending requests | Duplicate tickets, double refunds |
| "your order" | Query scoped by session user id | Any user can refund any order |
| "access is revoked" | order_refunded webhook deactivates the slots | Refunded buyers keep downloading |
The engine that answers the first four is pure, and it is 30 lines.
A four-state answer, not a boolean
The single most useful decision was to make ineligibility typed rather than a false:
export type RefundIneligibleReason =
| "already_refunded"
| "request_pending"
| "downloaded"
| "window_expired";
export function refundEligibility(
input: RefundEligibilityInput,
): { eligible: true } | { eligible: false; reason: RefundIneligibleReason } {
A boolean forces the UI to guess. A reason lets the dashboard button say why it is disabled — "you've already downloaded this" is a support ticket that never gets filed, where a greyed-out button with no explanation is one that always does. It also means the server action returns something the client can render without a second lookup.
The order of the checks is the policy's precedence, and it is deliberate:
if (input.orderStatus === "refunded") return { eligible: false, reason: "already_refunded" };
if (input.hasPendingRequest) return { eligible: false, reason: "request_pending" };
// ...downloaded check...
if (input.now.getTime() - input.createdAt.getTime() > windowMs) {
return { eligible: false, reason: "window_expired" };
}
return { eligible: true };
State before action: an already-refunded order and an in-flight request are facts about the order, so they answer first and produce the most useful message. The window is checked last on purpose — telling someone on day 20 who already downloaded that they downloaded is more useful than telling them they're late, because the download is the reason they'd have been refused on day 3 too.
now is a parameter, not Date.now(). That is what makes "the day after the window closes" a unit test rather than a clock-mocking exercise.
What "downloaded" actually means
Here is the part no policy generator will tell you.
The naive implementation attributes each download to the order that paid for it, and asks whether this order has any. It is the obvious model, it is what a normalized schema nudges you toward, and it is exploitable.
Consider a buyer who owns the same product through two orders. Under per-order attribution, they download once — attributed to order A — and then request a refund on order B, which by that model has no downloads. They keep the files and get half their money back. Buy it a third time and the arithmetic gets worse.
Our check is coverage-based instead. An order counts as downloaded if any of the buyer's successful deliveries is covered by any of that order's entitlement slots:
const downloaded = input.deliveries.some((d) =>
input.slots.some((s) =>
slotCovers(s, { productSlug: d.productSlug, framework: d.framework }),
),
);
slotCovers is the same function the download endpoint uses to authorize the download in the first place. One definition of "does this entitlement cover this file", used to grant access and to decide whether access was used — they cannot drift apart. (The grant side of that chain is the gated downloads post; this post consumes the download_deliveries rows it writes.)
The consequence is that in the two-orders case, both orders read as downloaded, and neither is refundable. That over-blocks — and it over-blocks in the money-safe direction, which is the correct direction for a rule that decides whether files already in someone's hands can be un-sold. The comment we left in that file says so explicitly, because it looks like a bug to anyone reading it fresh:
Do NOT "fix" this into exclusive per-slot attribution: that would let a buyer download via one order and refund the other, opening a download-then-refund bypass.
If you write one comment in your refund code, write that one. The "fix" is a plausible pull request.
One engine, two callers
The dashboard's refund button is disabled when an order isn't eligible. The server action re-runs the identical check before inserting anything:
const eligibility = refundEligibility({
orderStatus: order.status,
createdAt: order.createdAt,
slots, deliveries, hasPendingRequest,
now: new Date(),
});
The button is UX; the action is enforcement. A disabled button is a rendering decision made from data the client can see — trivially bypassed by anyone who opens dev tools, and stale the moment another tab downloads something. Because both call the same pure function, the greyed-out state and the refusal can never give different answers, which is the failure that produces "the button was enabled, so why did it fail?"
Ownership never comes from the request:
const userId = await deps.getUserId(); // verified session
if (!userId) return { ok: false, reason: "unauthenticated" };
// ...
.eq("id", id).eq("user_id", userId) // both, always
orderId is caller-controlled. Without that second .eq(), any signed-in user can request a refund against any order id they can guess.
Double-clicks are not errors
Users double-click. Networks retry. A refund request that fires twice must not create two tickets:
const { error } = await admin
.from("refund_requests")
.insert({ order_id: id, user_id: userId, status: "pending" });
if (error) {
// Partial-unique-index violation = an identical pending request already
// exists (idempotency arbiter) — not an error.
if ((error as { code?: string }).code === "23505") return "already_pending";
throw error;
}
The database is the arbiter, not the application. A partial unique index over (order_id) where status = 'pending' makes the second insert impossible rather than merely unlikely — a check-then-insert in application code loses that race under concurrency, and refunds are exactly where you find out.
The duplicate then surfaces as success, not failure:
if (outcome === "already_pending") return { ok: true, alreadyRequested: true };
From the user's side, "your request is in" is true both times. The alreadyRequested flag lets the UI say "you've already asked" without turning a harmless retry into an error state. Only the first insert triggers the support notification, so double-clicking cannot double-email the team.
The parts you can't put in code
Merchant of Record. LemonSqueezy is the seller of record for these transactions, which means it collects and remits VAT and sales tax, and it owns chargeback handling. Refunds go back through it to the original payment method. If you sell internationally without an MoR, tax registration in every jurisdiction you sell into becomes your problem, and refunds become your problem twice — the money and the tax already remitted.
EU and UK withdrawal rights. Consumers there have a statutory 14-day right to withdraw from a digital-content purchase. It can be waived, but only with express consent to immediate delivery and acknowledgement that the right ends when delivery begins — which is precisely why "download ends eligibility" is worth building the coverage check for. The download is the delivery event, so the policy, the terms and the code all agree on one moment. A US-style "all sales are final" clause does not survive contact with those rules.
Abuse. A policy this generous exists to remove purchase risk, not to provide free access. The download line does most of the work — you cannot take the files and the money — so what's left is fraud: unauthorized payments and repeat buy-and-refund cycles. That stays a human decision, with chargebacks handled by the MoR.
Mistakes table
| Mistake | Consequence | Fix |
|---|---|---|
| Per-order download attribution | Download once, refund the duplicate order | Coverage-based check across all the order's slots |
| Different logic behind the button and the action | "It let me click it" tickets | One pure function, two callers |
Trusting orderId from the client | Any user refunds any order | Scope every query by the session user id |
| Check-then-insert for pending requests | Duplicate tickets under a double-click | Partial unique index; treat 23505 as success |
| Returning an error on a duplicate request | Users retry harder, support gets three tickets | Return success with alreadyRequested |
Date.now() inside the engine | Window logic can't be tested | Pass now in |
| Window measured from delivery, not purchase | Doesn't match what the policy page says | Measure from orders.created_at, like the prose |
| Refund processed, entitlement left active | Refunded buyers keep downloading | Deactivate slots on the order_refunded webhook |
| "All sales are final" for EU/UK consumers | Unenforceable against statutory rights | Waiver tied to the delivery moment |
FAQ
Is 14 days the right window? It is the number EU and UK withdrawal rights already establish, so matching it means one rule instead of two. The window matters far less than the delivery condition — for instant-download products, almost every legitimate refund request arrives within hours, and almost every illegitimate one arrives after a download.
Should downloading really end eligibility? For non-returnable digital goods, yes, and it is the trade that lets the policy be generous before that point. It also has to be stated everywhere the buyer might read it: the policy page, the terms, and the dashboard at the moment they click download. A condition discovered after the fact is a chargeback.
What if someone downloads by accident? The reason string makes this a conversation rather than a wall — support can see exactly which condition failed. Keep a human override; just make the default automatic and consistent, so exceptions are decisions rather than the norm.
Do I need a refund request table at all — why not refund immediately?
You could, and the eligibility engine is the same either way. A pending-request row buys you an audit trail, a place to hang the notification, and a human check before money moves. It also gives you the request_pending state, which stops a buyer from stacking requests while one is being reviewed.
Does a refund revoke the licence? It should, and ours does — access is revoked and the licence granted by that purchase is void. Licence tiers and what each one permits are their own post; this one only covers what a refund does to whatever tier you bought.
Where this shows up in a template
Any storefront selling instant downloads inherits this whole problem the day it takes its first payment — sale events and promo pricing raise both the volume and the refund rate. The templates below ship the storefront half; the policy above is what to build behind it, and our pricing page shows how the tiers it applies to are framed.
