Design the RMA state machine, partial returns, refund versus store credit, inspection and restocking, idempotent refunds, carrier label webhooks and fraud checks, then implement the flow in TypeScript.

A customer who sends an item back expects a refund after the warehouse has received it, not at the moment they submit the form. If the refund runs when the return is requested, you pay out for parcels that never arrive. If it waits on a manual spreadsheet, the same order is easy to refund twice, a partial return can be over-credited, and a late parcel can be paid out after the request has already expired.
A returns and RMA backend is the system that owns that lifecycle: eligibility, a prepaid label, carrier scans, warehouse receipt, inspection, a single payout or store credit, restocking of sellable units, and a daily sweep of requests that never arrived. The hard parts are the state machine, the money movement and the ways the process is abused, not the choice of cloud resources.
This guide starts with those design decisions, then shows a complete hand-built path on AWS, then the same flow in TypeScript with Encore in your own AWS or GCP account.
Treat every return request as a record that moves through a small set of statuses, with the allowed transitions written down and enforced in SQL or in the application. A loose status text field that any handler can overwrite is how a request is refunded twice, or how a late parcel is paid out after expiry.
A practical set of statuses is:
A few transition rules are easy to get wrong and worth spelling out:
received, because a carrier DELIVERED event means the parcel reached the warehouse address, not that a clerk has opened the box, and refund and restock wait on the dock scan and on inspection.label_issued may jump to received when the warehouse scan arrives before any carrier event. That is normal for short-haul returns.expired is terminal for the automatic path. A parcel that arrives afterwards is recorded, then placed on a manual queue. Reopening the request from the webhook is how you refund a return you already wrote off.rejected and expired free the reserved quantity on each order line, so the customer can try again. refunded and credited do not.refunded or credited, the only safe change is a separately recorded adjustment, not a second pass through the inspect endpoint.Keep the current status and a short movement history, which is what you read when a customer, a warehouse clerk and a payments row disagree.
The remaining-quantity check belongs in SQL so two concurrent requests cannot both take the last unit of a line. Under Read Committed, a second INSERT that reads the same order line waits, then re-checks the remaining quantity, as the transaction isolation docs describe.
-- returns/migrations/1_returns.up.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id TEXT NOT NULL,
delivered_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders (id),
sku TEXT NOT NULL,
qty INT NOT NULL CHECK (qty > 0),
unit_cents INT NOT NULL CHECK (unit_cents >= 0),
window_days INT NOT NULL DEFAULT 30
);
CREATE TABLE stock (
sku TEXT PRIMARY KEY,
on_hand INT NOT NULL CHECK (on_hand >= 0)
);
CREATE TABLE return_requests (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders (id),
customer_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'requested'
CHECK (status IN (
'requested', 'approved', 'label_issued', 'in_transit',
'received', 'refunded', 'credited', 'rejected',
'expired', 'hold'
)),
reason TEXT NOT NULL,
refund_method TEXT NOT NULL DEFAULT 'original_payment'
CHECK (refund_method IN ('original_payment', 'store_credit')),
label_key TEXT,
received_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
hold_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE return_items (
request_id BIGINT NOT NULL REFERENCES return_requests (id),
order_item_id BIGINT NOT NULL REFERENCES order_items (id),
qty INT NOT NULL CHECK (qty > 0),
qty_accepted INT,
disposition TEXT CHECK (disposition IN ('restock', 'damaged', 'reject', 'missing')),
PRIMARY KEY (request_id, order_item_id)
);
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
request_id BIGINT REFERENCES return_requests (id),
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE refunds (
request_id BIGINT PRIMARY KEY REFERENCES return_requests (id),
amount_cents INT NOT NULL,
provider_refund_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE store_credits (
request_id BIGINT PRIMARY KEY REFERENCES return_requests (id),
customer_id TEXT NOT NULL,
amount_cents INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE sent_notices (
request_id BIGINT NOT NULL REFERENCES return_requests (id),
kind TEXT NOT NULL,
PRIMARY KEY (request_id, kind)
);
CREATE INDEX return_requests_expiring ON return_requests (expires_at)
WHERE status IN ('approved', 'label_issued', 'in_transit');
CREATE TABLE warehouse_staff (
user_id TEXT PRIMARY KEY
);
refunds and store_credits both use the request ID as the primary key, so a retry cannot grant the same payout twice, and a request cannot be both refunded to the card and credited to the ledger unless you add an explicit split later. window_days sits on the order line so sale items and international orders can use a different window from the default.
Customers often send back one size or one unit from a multi-item line, and inspection often accepts fewer units than the request claimed. The backend has to handle both without letting the leftover quantity disappear.
When the request is opened, subtract quantities already on non-rejected, non-expired, non-hold returns for that order line, and refuse the request if the remaining quantity is too small. A unique constraint on (order_item_id) is not enough: a rejected request should leave the customer free to try again, and a partial return should leave the leftover units available for a second request.
At inspection, record qty_accepted separately from the requested qty. The payout is qty_accepted * unit_cents for lines that are restocked or written off as damaged, and zero for rejected or missing units. A request can therefore end as a partial refund even when every line on the original request is present in the parcel.
The mixed-parcel policy has to be decided up front: one common rule is to pay the sum of the accepted lines and close the request, which is what the sample code later in this guide does, and the other is to move a mixed parcel to hold for a second review, which is safer for high-value goods and worse for customer waiting time. Missing units, where the carrier delivered a box that does not contain every requested item, should follow the same path as a reject for the absent quantity: no payout, no restock, and a fraud note if it happens often for the same customer.
The inspect step should not assume that every accepted return is a card refund. Two settlement methods show up in almost every returns policy:
store_credits row per request, and the same row is the lock if the grant is retried.Pick the method when the request is opened, or let the customer choose, and store it on the request as refund_method. Do not decide it only at inspect time unless your policy explicitly allows a last-minute switch. Switching after a refund row already exists is how you grant credit on top of a card payout.
A split settlement, part refund and part credit, is rare and should be a deliberate second feature, with two amount columns and two idempotency keys, not a reuse of the single-row tables above.
Inspection is the point where warehouse truth overrides the customer's claim. A clerk, or an inbound-scan flow, marks each line with a disposition:
on_hand in the same transaction that records the payout, so a later payout failure rolls back and does not inflate stock.on_hand is a later oversell.rejected when every line is rejected.Photographing high-value items at intake and storing those images next to the label objects gives you evidence when a customer disputes the disposition. Serial or IMEI comparison against the original shipment belongs in this step, not in the request form, because the customer cannot attest to a match you have not seen.
Do not restock before inspection, and do not restock from a carrier DELIVERED event. The parcel on the dock is not yet a sellable unit.
Double payouts come from retries, not from a second click in a happy path. The inspect handler, the worker that calls the provider, and the provider's own at-least-once webhooks can all run more than once for the same request.
The durable lock is a refunds row whose primary key is the request ID, inserted with ON CONFLICT DO NOTHING before the provider is called. The HTTP call then carries an Idempotency-Key built from that request ID, for example return-1842. Stripe documents that idempotent requests retried with the same key return the saved response of the first one, and that those keys may be pruned after at least 24 hours. Your database row is therefore the lock that still works a week later; the provider key only protects the window in which a crashed worker retries immediately.
A few rules keep that pattern honest:
UPDATE of provider_refund_id fails, the next retry sees the existing refund row and repeats the call with the same key, then stores the returned id.store_credits row first, then update the customer's balance. The primary key, not a unique constraint on a later ledger entry, is what stops a double grant.Issuing the label and hearing back from the carrier are two different jobs. After approval, the service calls the carrier, stores the label PDF under a stable object key derived from the request ID, and sends the customer a signed download URL. A retry overwrites the same object rather than creating a second label that the carrier will also bill.
The carrier then posts tracking events to a webhook: label created, picked up, in transit, delivered, exception, or return to sender. Handle those the way you would handle a payments webhook. Verify the signature over the raw body, insert the event ID into processed_events with ON CONFLICT DO NOTHING, and only then move the request. Stripe's webhook docs describe at-least-once delivery with live-mode retries for up to three days, and a carrier feed should be treated the same way.
Map events to statuses conservatively:
label_issued to in_transit.received and it does not start a refund.hold and notifies a clerk, and does not refund.received, refunded or credited.Label expiry is a separate concern from request expiry: signed download URLs should last long enough for the customer to print the label, often several days, and a dedicated endpoint can mint a fresh URL if the first one lapses. The prepaid label at the carrier may have its own validity window; once that passes, a new label is a new carrier call, still stored at the same object key if your contract allows a replacement, or at a versioned key if it does not.
A returns flow that auto-approves every eligible request will be used as a way to get a free rental, an empty-box refund or a second payout on the same line. The checks do not have to be a machine-learning model. A short list of rules, applied when the request is opened and again at inspection, catches most of the obvious cases and sends the rest to hold.
hold.reject or missing and counted against the customer's velocity.label_issued.Write hold_reason when a rule fires, and keep a person in the loop for holds. Automatic rejects are appropriate for a closed window or a remaining-quantity failure; they are a poor fit for fraud, where a false reject is a lost customer.
The design above does not depend on a particular application framework. A service you build and operate yourself on AWS can implement every part of it, and many teams do.
DATABASE_URL (or a secrets-backed equivalent) that the task uses.processed_events. You terminate TLS, rotate the webhook secret in Secrets Manager and inject it into the task. Warehouse dock scans can use the same endpoint shape with a different signer, or a second path if the warehouse system cannot share the carrier's scheme.refunds or store_credits row, calls the payment provider or updates the credit ledger, and increments on_hand only for restocked units, in one transaction around the database writes. The provider key lives in Secrets Manager.approved, label_issued or in_transit requests past expires_at with no receipt, and marks them expired. Late parcels are written to a manual queue rather than being reopened by the webhook.sent_notices enforces.You also write the Dockerfile (or the Lambda packaging), the load balancer and the deployment pipeline, and you apply migrations as a step in that pipeline so two replicas do not race at startup. This path is explicit and portable. The cost is the connecting tissue: IAM, networking, secret injection, the scheduler and the deploy graph are yours to keep in sync with the schema as the state machine grows.
Encore is a backend framework with built-in infrastructure for TypeScript and Go. The returns database, the labels bucket, the notices topic, the expiry job and the payment and warehouse secrets are declared in the service code. When the app deploys to your own AWS account, Encore provisions RDS, S3, SNS, SQS and Secrets Manager, as the AWS infrastructure docs list. The same code deploys to GCP with Cloud SQL, Cloud Storage, Pub/Sub and Secret Manager. Encore provisions an IAM role for the service, or a service account on GCP. Bookshop.org is an ecommerce marketplace that moved inventory-heavy systems from a Rails monolith to Go microservices with Encore in its own GCP account.

A coding agent working in this repository adds a table, a bucket or a topic in the same change as the handler, and encore run starts Postgres locally so the request, receipt and refund paths can be called before anything is pushed. Cron jobs do not run in local development or preview environments, so the expiry sweep is tested by calling its endpoint from the local dashboard.
Run in your terminal to get started locally.
Secret values stay out of the repository, where a coding agent working in the code sees the secret's name but not its value. Each environment gets its own provider key and webhook secret. Put the schema from the data model section in returns/migrations/1_returns.up.sql.
The handlers below follow the state machine and the remaining-quantity rule, issue a stable label object, record a warehouse receipt once, and settle an original-payment refund idempotently. Store credit uses the same shape against store_credits instead of refunds. Inspection sits behind the same signed-token auth handler as the rest of the warehouse tools:
// returns/auth.ts
import { APIError, Gateway, Header } from "encore.dev/api";
import { authHandler } from "encore.dev/auth";
import { secret } from "encore.dev/config";
import { createHmac, timingSafeEqual } from "node:crypto";
const authTokenHMAC = secret("AuthTokenHMAC");
interface AuthParams {
authorization: Header<"Authorization">;
}
export interface AuthData {
userID: string;
}
export const handler = authHandler<AuthParams, AuthData>(async (params) => {
const token = params.authorization?.replace("Bearer ", "") ?? "";
// Tokens are "<id>.<unix-expiry>.<hex hmac>". In production, verify a JWT
// against your IdP JWKS (signature, issuer, audience, expiry) and return the subject.
if (!authTokenHMAC()) throw APIError.unauthenticated("invalid token");
const parts = token.split(".");
if (parts.length !== 3) throw APIError.unauthenticated("invalid token");
const [userID, expStr, sig] = parts;
const exp = Number(expStr);
if (!userID || userID.includes("/") || userID.includes(" ") || !Number.isFinite(exp) || exp * 1000 <= Date.now()) {
throw APIError.unauthenticated("invalid token");
}
const expected = createHmac("sha256", authTokenHMAC()).update(`${userID}.${expStr}`).digest("hex");
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw APIError.unauthenticated("invalid token");
}
return { userID };
});
export const gateway = new Gateway({ authHandler: handler });
The inspect handler then checks that the caller is warehouse staff before it loads the return:
// returns/returns.ts
import { api, APIError } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { CronJob } from "encore.dev/cron";
import { Subscription, Topic } from "encore.dev/pubsub";
import { Bucket } from "encore.dev/storage/objects";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { getAuthData } from "~encore/auth";
import { createHmac, timingSafeEqual } from "node:crypto";
const db = new SQLDatabase("returns", { migrations: "./migrations" });
const pspApiKey = secret("PspApiKey");
const warehouseSecret = secret("WarehouseWebhookSecret");
// Private bucket in your account. Labels are fetched with a signed URL.
const labels = new Bucket("return-labels", { versioned: false });
export interface ReturnNotice {
kind: "label_ready" | "refunded" | "credited" | "rejected" | "expired";
requestId: number;
customerId: string;
}
export const notices = new Topic<ReturnNotice>("return-notices", {
deliveryGuarantee: "at-least-once",
});
const RETURN_WINDOW_DAYS = 30;
const LABEL_TTL_SECONDS = 7 * 24 * 3600;
interface ReturnItemInput {
orderItemId: number;
qty: number;
}
export const requestReturn = api(
{ expose: true, method: "POST", path: "/orders/:orderId/returns" },
async ({ orderId, customerId, reason, items }: {
orderId: number;
customerId: string;
reason: string;
items: ReturnItemInput[];
}): Promise<{ requestId: number; labelUrl: string }> => {
if (!reason || items.length === 0) {
throw new Error("reason and items are required");
}
await using tx = await db.begin();
const order = await tx.queryRow`
SELECT id, customer_id, delivered_at
FROM orders WHERE id = ${orderId}`;
if (!order || order.customer_id !== customerId) {
throw new Error("order not found");
}
const deliveredAt = new Date(order.delivered_at as string);
const windowMs = RETURN_WINDOW_DAYS * 24 * 3600 * 1000;
if (Date.now() - deliveredAt.getTime() > windowMs) {
throw new Error("return window has closed");
}
const created = await tx.queryRow`
INSERT INTO return_requests (order_id, customer_id, reason, expires_at, status)
VALUES (
${orderId}, ${customerId}, ${reason},
now() + interval '21 days', 'approved'
)
RETURNING id`;
const requestId = Number(created!.id);
for (const item of items) {
if (item.qty <= 0) throw new Error("qty must be positive");
const orderItem = await tx.queryRow`
SELECT id
FROM order_items
WHERE id = ${item.orderItemId} AND order_id = ${orderId}
FOR UPDATE`;
if (!orderItem) throw new Error(`unknown order item ${item.orderItemId}`);
const ok = await tx.queryRow`
INSERT INTO return_items (request_id, order_item_id, qty)
SELECT ${requestId}, ${item.orderItemId}, ${item.qty}
FROM order_items oi
WHERE oi.id = ${item.orderItemId}
AND oi.order_id = ${orderId}
AND oi.qty - COALESCE((
SELECT SUM(ri.qty)
FROM return_items ri
JOIN return_requests rr ON rr.id = ri.request_id
WHERE ri.order_item_id = oi.id
AND rr.status NOT IN ('rejected', 'expired')
AND rr.id <> ${requestId}
), 0) >= ${item.qty}
RETURNING order_item_id`;
if (!ok) {
throw new Error(`cannot return ${item.qty} of order item ${item.orderItemId}`);
}
}
const key = `labels/${requestId}.pdf`;
const pdf = await createLabel(requestId);
await labels.upload(key, pdf, { contentType: "application/pdf" });
await tx.exec`
UPDATE return_requests
SET label_key = ${key}, status = 'label_issued'
WHERE id = ${requestId}`;
await tx.commit();
const signed = await labels.signedDownloadUrl(key, { ttl: LABEL_TTL_SECONDS });
await notices.publish({ kind: "label_ready", requestId, customerId });
return { requestId, labelUrl: signed.url };
},
);
// Warehouse dock-scan webhook. The signature covers the raw body.
export const warehouseWebhook = api.raw(
{ expose: true, method: "POST", path: "/webhooks/warehouse" },
async (req, resp) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks);
const got = Buffer.from(String(req.headers["x-warehouse-signature"] ?? ""), "hex");
const want = createHmac("sha256", warehouseSecret()).update(body).digest();
if (got.length !== want.length || !timingSafeEqual(got, want)) {
resp.writeHead(401).end("invalid signature");
return;
}
let event: { id: string; requestId: number };
try {
event = JSON.parse(body.toString("utf8"));
} catch {
resp.writeHead(400).end("invalid json");
return;
}
await using tx = await db.begin();
const fresh = await tx.queryRow`
INSERT INTO processed_events (event_id, event_type, request_id)
VALUES (${event.id}, 'warehouse_received', ${event.requestId})
ON CONFLICT DO NOTHING RETURNING event_id`;
if (fresh) {
const updated = await tx.queryRow`
UPDATE return_requests
SET status = 'received', received_at = now()
WHERE id = ${event.requestId}
AND status IN ('label_issued', 'in_transit')
RETURNING id`;
if (!updated) {
throw new Error("return is not waiting for a receipt");
}
}
await tx.commit();
resp.writeHead(204).end();
},
);
export const inspect = api(
{ expose: true, auth: true, method: "POST", path: "/returns/:requestId/inspect" },
async ({ requestId, lines }: {
requestId: number;
lines: { orderItemId: number; disposition: "restock" | "damaged" | "reject" }[];
}): Promise<{ status: string; amountCents: number }> => {
const { userID } = getAuthData()!;
await using tx = await db.begin();
const staff = await tx.queryRow`
SELECT user_id FROM warehouse_staff WHERE user_id = ${userID}`;
if (!staff) {
throw APIError.permissionDenied("warehouse role required");
}
const request = await tx.queryRow`
SELECT id, customer_id, status FROM return_requests
WHERE id = ${requestId} FOR UPDATE`;
if (!request || request.status !== "received") {
throw new Error("return is not ready for inspection");
}
const items = await tx.queryAll`
SELECT ri.order_item_id, ri.qty, oi.sku, oi.unit_cents
FROM return_items ri
JOIN order_items oi ON oi.id = ri.order_item_id
WHERE ri.request_id = ${requestId}`;
const submitted = new Set(lines.map((line) => line.orderItemId));
if (submitted.size !== lines.length || lines.length !== items.length) {
throw new Error("every returned line needs a disposition");
}
let amountCents = 0;
let rejected = 0;
for (const line of lines) {
const item = items.find((it) => Number(it.order_item_id) === line.orderItemId);
if (!item) throw new Error("unknown order item on this return");
await tx.exec`
UPDATE return_items
SET disposition = ${line.disposition}, qty_accepted = ${
line.disposition === "reject" ? 0 : Number(item.qty)
}
WHERE request_id = ${requestId} AND order_item_id = ${line.orderItemId}`;
if (line.disposition === "reject") {
rejected += 1;
continue;
}
amountCents += Number(item.unit_cents) * Number(item.qty);
if (line.disposition === "restock") {
await tx.exec`
UPDATE stock SET on_hand = on_hand + ${Number(item.qty)}
WHERE sku = ${item.sku}`;
}
}
if (rejected === items.length) {
await tx.exec`UPDATE return_requests SET status = 'rejected' WHERE id = ${requestId}`;
await tx.commit();
await notices.publish({
kind: "rejected",
requestId,
customerId: String(request.customer_id),
});
return { status: "rejected", amountCents: 0 };
}
const already = await tx.queryRow`
INSERT INTO refunds (request_id, amount_cents)
VALUES (${requestId}, ${amountCents})
ON CONFLICT DO NOTHING RETURNING request_id`;
if (already) {
const refundId = await chargeRefund(requestId, amountCents);
await tx.exec`
UPDATE refunds SET provider_refund_id = ${refundId} WHERE request_id = ${requestId}`;
}
await tx.exec`UPDATE return_requests SET status = 'refunded' WHERE id = ${requestId}`;
await tx.commit();
await notices.publish({
kind: "refunded",
requestId,
customerId: String(request.customer_id),
});
return { status: "refunded", amountCents };
},
);
async function chargeRefund(requestId: number, amountCents: number): Promise<string> {
const resp = await fetch("https://psp.example.com/v1/refunds", {
method: "POST",
headers: {
Authorization: `Bearer ${pspApiKey()}`,
"Idempotency-Key": `return-${requestId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ amount: amountCents, currency: "usd" }),
});
if (!resp.ok) throw new Error(`payment provider returned ${resp.status}`);
const body = (await resp.json()) as { id: string };
return body.id;
}
async function createLabel(requestId: number): Promise<Buffer> {
// Replace with the carrier's label API. The object key is stable, so a
// retry of requestReturn overwrites the same PDF.
return Buffer.from(`%PDF-1.4\n% return ${requestId}\n`, "utf8");
}
requestReturn locks each order line with FOR UPDATE before it checks remaining quantity, so two concurrent requests cannot both take the last unit. It writes the lines in the same transaction that opens the request, so a request that cannot take every item rolls back and leaves no orphan row. It then marks the request label_issued. inspect requires a signed token for a warehouse staff row, rejects payloads that repeat a line ID, marks the refund row first with ON CONFLICT DO NOTHING, then calls the provider with return-${requestId} as the idempotency key, so a retry after a crash sees the existing row and the provider returns the original refund. The HMAC-protected warehouse webhook stays a separate, unauthenticated path for carrier and dock-scan events.
const _sub = new Subscription(notices, "send-return-notice", {
handler: async (event) => {
const saved = await db.queryRow`
INSERT INTO sent_notices (request_id, kind)
VALUES (${event.requestId}, ${event.kind})
ON CONFLICT DO NOTHING RETURNING request_id`;
if (!saved) return; // already sent
// Send the email or push here.
},
});
export const expireUnreceived = api(
{ expose: false, method: "POST", path: "/returns/expire" },
async (): Promise<{ expired: number }> => {
const expired = await db.queryAll`
UPDATE return_requests
SET status = 'expired'
WHERE status IN ('approved', 'label_issued', 'in_transit')
AND expires_at < now()
RETURNING id`;
return { expired: expired.length };
},
);
const _ = new CronJob("expire-unreceived-returns", {
title: "Expire unreceived return requests",
schedule: "0 6 * * *",
endpoint: expireUnreceived,
});
The primary key on sent_notices is what makes each kind of email go out once, even when Pub/Sub redelivers the message. The expiry job is Encore platform Managed, and it does not run locally or in preview environments. Test it by calling expireUnreceived from the local dashboard.
Best fit: teams that want the state machine, labels, notices and daily sweep declared next to the handlers, provisioned in their own AWS or GCP account.
requested and have a reviewer move them to approved. Fraud holds should bypass auto-approval.restock disposition increments on_hand, so a write-off does not inflate available stock.After the warehouse has received the parcel and inspection has accepted it, not when the customer submits the request. Issuing the refund at request time pays out for parcels that never arrive, and running it from a spreadsheet makes it easy to refund the same order twice.
Store one refund row per return request and send the payment provider an idempotency key built from the request ID. Stripe documents that [idempotent requests](https://docs.stripe.com/api/idempotent_requests) retried with the same key return the saved response of the first one, so a crashed worker that repeats the call does not create a second payout. Stripe may prune those keys after at least 24 hours, so the refund row in your database is the long-term lock.
When the request is opened, subtract quantities already on non-rejected, non-expired returns for that order line and refuse the request if nothing remains. A unique constraint alone is not enough, because a rejected request should leave the customer free to try again and a partial return should leave the leftover units available.
Declare the database, the labels bucket, the notices topic, the daily expiry job and the payment and warehouse secrets in the service code. When the app deploys to your own AWS or GCP account, Encore provisions the database, the bucket, the topic and the job, and Encore provisions an IAM role for the service, or a service account on GCP.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.