payload-refunds
v1.0.1
Published
Full and partial refunds for Payload ecommerce, per line item, with optional restock and an append only audit trail.
Maintainers
Readme
payload-refunds
Records full and partial refunds against a Payload order and the payment behind it, per line item, with an optional restock, running totals that can never drift from the records, and an audit trail that is never edited.
- Extends
@payloadcms/plugin-ecommerceand works on any collection that holds orders - Every amount is a whole number of minor units, added with integers from beginning to end
- No runtime dependencies, and no runtime import of
payloadeither - No admin components, so it survives minor releases
Install
Requires Payload 3.88 or newer and @payloadcms/plugin-ecommerce 3.88 or newer. Verified against Payload 3.88.0 with the official plugin installed.
pnpm add payload-refundsimport { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { refundsPlugin } from 'payload-refunds'
export default buildConfig({
plugins: [
ecommercePlugin({ /* ... */ }),
refundsPlugin({
restock: true,
updateTransactionStatus: true,
}),
],
})refundsPlugin must come after the plugin that defines the orders collection. It extends what it finds; if the orders collection is not there yet, it returns the config untouched.
A refund is an ordinary document, so it is created the ordinary way:
await payload.create({
collection: 'refunds',
data: {
order: orderID,
amount: 2500,
reason: 'damaged',
lines: [{ item: 'a1b2c3', quantity: 1, amount: 2500 }],
},
req,
})What was measured
Read in the published @payloadcms/[email protected], in the original TypeScript carried by its source maps.
The whole refund surface is two words
refunded appears twice in the plugin's own code, and both are select options:
| File | Field | Where the word sits |
| --- | --- | --- |
| collections/orders/createOrdersCollection.ts | status on orders | last of processing, completed, cancelled, refunded |
| fields/statusField.ts | status on transactions | last of pending, succeeded, failed, cancelled, expired, refunded |
There is no amount, no partial refund, no line, no reason, no record of who did it and no restock. Marking an order refunded is the entire feature.
An order carries no per line price
fields/cartItemsField.ts builds the items array, and collections/orders/createOrdersCollection.ts calls it without a currency configuration. An order line therefore holds exactly this:
| Field | Type | Present on an order line |
| --- | --- | --- |
| product | relationship | yes |
| variant | relationship | only when variants are enabled |
| quantity | number | yes |
| amount | number | no, it needs individualPrices, which orders do not pass |
| currency | select | no, it needs a currency configuration, which orders do not pass |
The only money on an order is amount and currency at the top, written by the payment adapter from the payment intent. That decides the arithmetic below: this package can cap a line by quantity, because the quantity is on the line, and it can cap money at the order level, because that is the only place money exists. It never invents a line price.
Amounts are integers of minor units
ui/utilities.ts converts a typed price with Math.round(value * 10 ** currency.decimals), and currencies/index.ts gives EUR, USD and GBP decimals: 2. The order's amount is the payment intent's amount, which is minor units as well. This package adds, compares and stores integers only, and refuses a fractional amount rather than rounding it.
How the official plugin moves stock
endpoints/confirmOrder.ts decrements after payment, taking item.variant when there is one and item.product otherwise:
187 await payload.db.updateOne({
191 inventory: {
192 $inc: item.quantity * -1,The restock in this package targets the same document for the same line, so a refunded unit goes back where the sale took it from. It does not use $inc; see Honest limits.
Tests
182 unit tests, pnpm test. The refund arithmetic is tested against every boundary: one minor unit under and over the remainder, the last unit of a line, the same line twice, lines that add up to more than the refund, and a whole history of partial refunds checked after every step.
The arithmetic, stated as rules
These hold for every order, whatever order the refunds arrive in.
- Every amount is a whole number of minor units. A fraction is refused, never rounded.
refundedAmountis the sum of theamountof every refund record on the order. It is recomputed from the records on every write, never incremented, so it cannot drift from them.refundableAmountisorder.amount - refundedAmount, and never below zero.refundedAmount + refundableAmount = order.amount.- A refund is refused when its amount is zero, negative, fractional, or above
refundableAmount. - For each order line, the units refunded across all records never exceed the units ordered.
- Within one refund, the line amounts add up to no more than the refund amount. Less is allowed, and is how shipping or tax is returned without a line.
- The same order line may not appear twice in one refund.
- The order status becomes
refundedexactly whenrefundedAmountreachesorder.amount.
A refusal is a RefundError carrying HTTP 400, isPublic, and a code from refusalCodes:
import { RefundError, refusalCodes } from 'payload-refunds'
try {
await payload.create({ collection: 'refunds', data, req })
} catch (error) {
if (error instanceof RefundError && error.code === refusalCodes.OverRefund) {
console.log(error.detail.refundableAmount)
}
}The codes are InvalidAmount, InvalidLine, LineOverRefund, LinesExceedAmount, OrderNotFound, OverRefund, RefundImmutable and WrongCurrency.
Why a partial refund leaves the order status alone
The official vocabulary has four values and none of them means partially refunded. Writing refunded after a refund of one cent would tell the shop, its exports and its own admin filters that the order is finished when it is not, and adding a fifth option would change the shape of the collection for everyone who installs this package. So a partial refund changes the totals and nothing else. If you want a state for it, payload-fulfillment is where extra states belong, and refundedStatus: '' here turns off the full refund write as well.
Options
| Option | Default | Meaning |
| --- | --- | --- |
| createAccess | any signed in user | Who may create a refund record |
| disabled | false | Stops validating, restocking and updating the order, but keeps every field and collection |
| inventoryFieldName | 'inventory' | Numeric field holding stock on products and variants |
| onRefund | none | Called after validation and before the record is written, for the payment provider |
| orderItemsFieldName | 'items' | Array field holding the order lines |
| orderRefundableFieldName | 'refundableAmount' | Order field holding the money still refundable |
| orderRefundedFieldName | 'refundedAmount' | Order field holding the money already refunded |
| ordersSlug | 'orders' | Slug of the orders collection |
| productsSlug | 'products' | Slug of the products collection |
| readAccess | any signed in user | Who may read refund records |
| reasons | six values, below | Options of the reason field |
| refundedStatus | 'refunded' | Status written on a full refund. An empty string leaves the status alone |
| refundsSlug | 'refunds' | Slug of the refunds collection |
| restock | false | Puts refunded units back into stock. A record may override it either way |
| restockAttempts | 10 | How many times a stock write is retried when another writer got there first |
| statusFieldName | 'status' | Name of the status field on the order |
| transactionsSlug | 'transactions' | Slug of the transactions collection |
| updateTransactionStatus | false | Sets the transaction status to refunded on a full refund |
| usersSlug | 'users' | Collection the issuedBy relationship points at |
| variantsSlug | 'variants' | Slug of the variants collection |
A value that cannot be used is replaced by its default rather than being applied. A slug given as an empty string becomes its default, a restockAttempts of 0 or -5 becomes 10, and 4.9 becomes 4. Nothing is silently reinterpreted: -5 never becomes 5.
The default reasons
requested-by-customer, damaged, not-received, returned, duplicate, other. A reason may be a string or { label, value }; a bare string becomes its own label, so 'gift-return' is shown as Gift return. Passing reasons replaces the list, and reasons: [] offers none. They are exported as defaultReasons.
The payment provider hook
This package records refunds. It does not move money, and it contacts no provider. onRefund is where you do that:
refundsPlugin({
onRefund: async ({ input, order, req }) => {
const refund = await stripe.refunds.create({
payment_intent: paymentIntentOf(order),
amount: input.amount,
})
return { reference: refund.id }
},
})It runs after the refund has been checked against the order and before the record is written, so a provider that refuses leaves nothing behind: no record, no totals, no restock. The reference it returns is stored on the record. It is given the validated input, the order document, the totals before this refund, and req, so any call it makes can stay inside the same transaction.
What it adds to your database
| Collection | Field | Type | Notes |
| --- | --- | --- | --- |
| your orders collection | refundedAmount | number | indexed, sidebar, read only, closed to the API |
| your orders collection | refundableAmount | number | sidebar, read only, closed to the API |
| your order lines | refundedQuantity | number | read only, closed to the API |
| your order lines | refundedAmount | number | read only, closed to the API |
| refunds | order | relationship | required, indexed |
| refunds | amount | number | required, minimum 1, minor units |
| refunds | currency | text | taken from the order when absent |
| refunds | reason | select | indexed |
| refunds | note | textarea | free text |
| refunds | lines | array | item, product, variant, quantity, amount |
| refunds | transaction | relationship | optional |
| refunds | reference | text | the provider's own identifier |
| refunds | restock | checkbox | defaults to the restock option |
| refunds | restocked | number | read only, how many lines went back into stock |
| refunds | issuedAt | date | indexed, stamped by the hook |
| refunds | issuedBy | relationship | the user, when they belong to usersSlug |
| refunds | issuedByEmail | text | their email, kept as a copy that survives a deleted user |
The refunds collection is closed to update and delete through the API, and every field is closed to update as well. The stamped fields are closed to create too, so a request cannot forge who issued a refund or how much went back into stock. Update is refused a second time inside the collection's own beforeChange hook, which catches a Local API call that overrides access, because a record that can be edited is not an audit trail.
What it adds to your code
import { refundableFor } from 'payload-refunds'
const totals = await refundableFor(payload, { order: orderID, req })It returns { amount, currency, refundedAmount, refundableAmount, lines }, with each line carrying quantity, refundedQuantity and refundedAmount, or null when the order does not exist. The pure functions behind it are exported as well, so you can check a refund before you send it: refundTotals, validateRefund, matchLine, readOrderLines and isFullyRefunded.
Honest limits
This package records refunds. It does not move money. Nothing here talks to Stripe or any other provider. Creating a refund record changes your database and your stock; it does not put anything back on a card. Wire onRefund to your provider, or your books will disagree with your bank.
A line has no price, so a line cannot be capped in money. The order does not store what a line cost, as measured above. This package refuses more units than were ordered, and more money than the order is worth, but it cannot tell you that 25.00 is the wrong amount for one unit of a 10.00 product. The line amounts you pass are recorded as given and checked only against the refund total. If you need money capped per line, you have to supply the line price yourself, from the product, the cart, or your own snapshot.
Two refunds on the same order in the same instant can both pass. Each request reads the totals inside its own transaction, so two refunds of 60 against an order of 100 can both see 100 refundable. The records stay correct and the running totals stay equal to their sum, so the order will show 120 refunded and 0 refundable, but the over-refund is not refused. If simultaneous refunds on one order are a real risk for you, serialise them in your own code. A single admin clicking twice is not this case: the second write reads the first inside the same connection.
Restock is a read, then a compare and set, not $inc. The official plugin decrements with payload.db.updateOne and a $inc operator, which is outside the stable surface this package is limited to. Instead, the stock is read and then written with a where that requires it to still hold the value that was read; if another writer moved it, the write matches nothing and it is tried again, up to restockAttempts times. That is correct under concurrency and costs one extra read per line. After restockAttempts failures the line is left alone and restocked counts it as not returned; nothing is overwritten.
Restock does not need payload-stock-reservation, and does not know about it. It adds to the same inventory field the official plugin decrements. If you run that package as well, its holds are computed from recorded stock, so a restock raises availability the same way a manual correction would.
An item with no stock value is left alone. A product or variant whose inventory field is absent is treated as not tracking stock, exactly as the official plugin does, and the line counts as not restocked.
The refunded status is set, but no transition is checked. If you use payload-fulfillment with guardTransitions on, a full refund writes refunded through payload.update, which passes through that guard like any other write. A map that refuses the transition will refuse the refund. Set refundedStatus: '' if you would rather move the status yourself.
Order lines must sit at the document root. The plugin finds the items array inside rows, collapsibles and unnamed tabs, which is where the official plugin puts it, and reads it from the top level of the document. Inside a named tab or a group the data path changes, so the fields are not added there.
Existing orders show nothing until they are refunded. refundedAmount defaults to zero, but refundableAmount is written for the first time when the first refund lands. Orders already in the database keep an empty refundableAmount until then.
Deleting an order does not delete its refunds. The records keep pointing at an identifier that is gone. That is deliberate for an audit trail, and it means the refunds collection is the thing to export before you delete anything.
Nothing is refunded automatically. No webhook, no schedule, no cancellation hook. A refund happens when something in your application creates the record.
License
MIT. Copyright George Vasiliades, https://github.com/Poseidonas
