Store Credit, Gift Cards & Credit Notes
Store credit is money a customer holds with the store: a refund they took as credit, a gift card they redeemed, a goodwill gesture. A gift card is a code a customer redeems for account credit. A credit note is the numbered document issued for a refund. This page is for a store administrator running any of the three.
Before you begin
- Reading the ledger needs
kahunacart.orders.view. Moving credit needskahunacart.orders.refund. - Store credit is offered at checkout to signed-in customers only, because credit belongs to an account.
One ledger, three features
All three are the same table. kahunacart_credit_transactions is an append-only list of movements, each pointing at an account.
| Table | Holds |
|---|---|
kahunacart_credit_accounts |
Who the credit belongs to. One per customer, plus an anonymous one per order that issued a gift card. |
kahunacart_credit_transactions |
Every movement, with what produced it. Append-only. |
kahunacart_gift_cards |
A hashed code, its value, and the account that value is sitting in. |
kahunacart_credit_notes |
The numbered document a confirmed refund earns. |
The balance is summed, never stored
There is no balance column anywhere in KahunaCart. A balance is SUM(amount_minor) over the account's rows, calculated when requested, so it always matches the recorded transactions.
Positive amounts credit the account. Negative amounts debit it. A debit that would take the balance below zero is refused before anything is written.
Every movement says what produced it
A row cannot be written without three things: a reference naming the kind of thing that produced it, a reference_id naming which one, and a source_json snapshot of what the producing rule saw.
reference |
reference_id points at |
|---|---|
gift_card |
The card that was issued, redeemed or voided. |
refund |
The refund transaction the credit came from. |
order |
The order the credit was spent on. |
adjustment |
The account itself. The snapshot names the person who made it and their reason. |
kind says what the movement is, and is what the account statement prints.
kind |
Means |
|---|---|
gift_card_issued |
A card was minted and its value put aside. |
gift_card_redeemed |
That value left the account it was sitting in. |
gift_card_credit |
The same value arrived in the redeemer's account. |
gift_card_voided |
A merchant cancelled an unredeemed card. |
order_spend |
Credit paid for an order. |
order_refund |
An order paid with credit was reversed. |
refund_credit |
A refund was sent to credit rather than to the card. |
adjustment |
A person moved credit by hand, with a note. |
Set up store credit
- Open KahunaCart → Settings and the Accounts tab.
- Under Store Credit and Gift Cards, leave Offer Store Credit at Checkout on.
- Set Store Credit Label to whatever your customers call it.
- Leave Sell Gift Cards on if you want to sell them.
- Click Save.
Store credit now appears as a payment method at checkout, for signed-in customers whose balance covers the whole order.
Spend credit at checkout
A customer picks Store credit in the payment list and places the order. It runs through the same flow every payment provider runs through, so the order gets one transaction row, one settle and one completion.
The debit happens inside the completion transaction, beside the stock deduction and the order number. If order completion is rolled back, the customer's credit is not spent.
Important
Store credit is offered only when the balance covers the whole order. One order is one payment to one provider, so a balance that covers part of a basket pays for none of it. The checkout page tells the shopper what they have and that it is not enough.
The debit is keyed on the order and the account, so a provider redelivering a webhook debits the account once. If the balance has moved between the page being drawn and the button being pressed, the payment is refused before any money moves. The shopper is told and the order stays a cart.
Sell gift cards
- Go to Products → New.
- Set Type to Gift card.
- Set the price to the card's value.
- Publish.
A gift card does not ship and holds no stock. When an order containing one completes, KahunaCart mints one card per unit bought and emails each code to the buyer.
Each card is worth that unit's share of what was actually paid for the line, allocated so the cards add back up to the line total. Three cards bought for 10.00 come out as 3.34, 3.33 and 3.33.
Configure the code format
gift_cards.code_pattern decides what a code looks like. # is a random character and everything else is printed literally, so ####-####-####-#### produces 4K7Q-9XM2-PB3T-HD6V.
Characters come from the same alphabet coupon codes use, which has no I, L, O or U, so a code read down a phone survives being written down. Sixteen characters is eighty bits of randomness.
Set an expiry
gift_cards.expiry_days is how many days a card stays redeemable. 0, the default, means it never expires.
Warning
Several jurisdictions regulate gift-card expiry, and some forbid it outright. Check what applies to you before setting a number.
Expiry is checked when a code is redeemed, not stamped onto rows by a nightly job. A card that ran out an hour ago is refused now.
Where the code lives
The card row stores a SHA-256 of the code plus its last four characters. Nothing in KahunaCart can print a code back after issuing it.
The one plaintext copy goes to the buyer in the gift-card email. The job that sends that email carries the code in its payload and removes it as soon as the message has left. The code is never written into the order's history.
POST /kahunacart/gift-cards is the one exception: minting a card by hand returns the code once, in that response, so the merchant can hand it over.
Caution
A gift-card email cannot be resent, and Gift card is absent from the admin's Send email… menu. A customer who lost the email is helped by voiding their card and issuing a new one.
Redeem a gift card
A customer redeems their own card at checkout. The gift-card box is under the payment methods; they type the code and its value is added to their store credit.
Redeeming needs an account. A guest sees the box with a line telling them to sign in first.
To redeem one on a customer's behalf:
- Get the customer's id from Customers.
POST /kahunacart/gift-cards/redeemwithcodeandcustomer_id.
Redemption writes two rows: a debit from the account the card was issued into, and a credit to the redeemer's. Both name the card and the order. Redeeming the same card twice credits it once, whichever path is used, and the second attempt is told the card has already been used.
Adjust a balance by hand
- Go to Customers and open the customer.
- Read their balance in the Store credit panel.
POST /kahunacart/credit/accounts/{id}/adjustwith a signedamountand anote.
The note is required. This is the only movement in the ledger a person makes rather than a rule. The person who made it is recorded alongside.
A negative adjustment larger than the balance is refused before anything is written.
Refund to credit
A refund can go to the provider, to store credit, or be split between them.
To refund to credit, call POST /kahunacart/credit/refunds with the order id and either an amount or a list of lines. It behaves as a refund in every other way: it counts against the same refundable balance, restocks the same lines, revokes the same download grants, fires onKahunaCartRefundApplied, earns a credit note, and sends the refund email.
To split one, call the order's own refund endpoint for the card's share and this one for the rest. The order then carries two refund rows, each saying where its money went.
Note
An order paid with store credit is refunded back to store credit. There is no provider to ask, so the store-credit payment method declares no refund capability and the admin's provider refund button is not offered for it.
Credit notes
A credit note is the other half of an invoice. The invoice says what a customer owed; the credit note says what you gave back. It is legally required across most of the EU.
One is issued automatically whenever a refund is confirmed, never when a refund is merely submitted. A provider that queues a refund can still reject it days later, and a numbered fiscal document cannot be taken back.
The number
Credit notes are numbered from their own sequence, separate from your invoice numbers, allocated atomically inside the same transaction that writes the document. Two refunds confirming at the same instant take two different numbers with nothing skipped between them. Deleting a row cannot renumber the ones around it.
| Config key | Default | Does |
|---|---|---|
credit_note.sequence_start |
1 |
The number given to the first credit note. Can be raised, never lowered. |
credit_note.format |
CN-{number} |
{number}, {number:6} to zero-pad, {year} for the UTC year. |
credit_note.reset_yearly |
false |
Start again each January. Put {year} in the format as well, or numbers repeat. |
The default format starts with CN- so a credit note and an invoice in the same folder are easy to tell apart.
What the document says
The credit note prints the store's identity, the customer's billing address, the lines the refund named, the tax being credited and the total. Everything comes off the note's own frozen snapshot, so a note reprinted next year says what it said when it was issued.
The tax figure is the refund's proportional share of the order's tax, allocated so that several partial refunds against one order credit exactly the order's tax between them. Tax that was included in the price counts too.
Download one
GET /kahunacart/orders/{id}/credit-notes lists an order's notes. GET /kahunacart/orders/{id}/credit-notes/{cnid}.pdf renders one as a PDF.
The PDF is rendered on the same vendored dompdf every other KahunaCart document uses, with remote assets disabled. Override templates/kahunacart-credit-note-pdf.html.twig in your theme to restyle it.
Storefront templates
| Template | Renders |
|---|---|
partials/kahunacart-gift-card.html.twig |
The balance line and the redemption box on the checkout page. |
partials/kahunacart-credit-balance.html.twig |
The balance panel and the last ten movements, in the account area and on the admin's customer record. |
templates/emails/gift-card.html.twig and .txt.twig |
The message carrying a card's code. |
templates/kahunacart-credit-note-pdf.html.twig |
The credit-note document. |
Copy any of them into your theme's templates/ to replace it. The checkout page is handed credit_balance_minor, credit_covers, credit_enabled and gift_cards_enabled.
Configuration
| Config key | Default | Does |
|---|---|---|
credit.enabled |
true |
Offer store credit as a payment method at checkout. |
credit.label |
Store credit |
What the payment method is called on the checkout page. |
gift_cards.enabled |
true |
Publish the gift-card product type and the redemption box. |
gift_cards.code_pattern |
####-####-####-#### |
# is a random character; everything else is literal. |
gift_cards.expiry_days |
0 |
Days a card stays redeemable. 0 never expires. |
credit_note.sequence_start |
1 |
The first credit-note number. |
credit_note.format |
CN-{number} |
How the number prints. |
credit_note.reset_yearly |
false |
Restart numbering each January. |
In another currency
A credit account and a gift card are each in one currency, the one they were made in, and nothing converts between currencies. A customer with 25.00 dollars of credit has 25.00 dollars of credit and no euro. Checkout says so instead of leaving the option out:
- Store credit is offered only when the customer holds credit in the order's currency and it covers the whole order. A customer who holds credit only in another currency sees "You have $25.00 of store credit. It can be used on orders in USD." (
PLUGIN_KAHUNACART.CREDIT_IN_OTHER_CURRENCY) on a store that sells in more than one currency. - A gift card in the wrong currency is refused by name, with "This gift card is in USD and this order is in EUR.", and its value is left where it was. The shopper can switch to the card's currency and redeem it there.
- The account panel lists one balance for each currency the customer holds, with a heading for each. A customer with one account sees the same panel as before.
- Issuing a card by hand takes an optional
currency(a currency the store sells in) and defaults to the base. A card sold through the catalogue is issued in the currency of the order that bought it, for what the line was paid, so a gift card sold in euro issues a euro card. - Redeeming on someone's behalf,
POST /kahunacart/gift-cards/redeem, uses the currency of the order whenorder_idis sent, and a 404 says so when no such order exists. Without an order it uses the card's own currency. A code that is not a card is refused as it always was.
A cart never holds a gift card, because redeeming one puts its value straight into the customer's credit. A cart currency switch has no gift card to drop for that reason; it drops discount codes. See Coupons.
Admin API
| Route | Does |
|---|---|
GET /kahunacart/credit/accounts |
Every account with its summed balance. customer_id narrows to one person. |
GET /kahunacart/credit/accounts/{id} |
One account, its balance and its owner. |
GET /kahunacart/credit/accounts/{id}/transactions |
That account's statement, newest first, with provenance on every row. |
POST /kahunacart/credit/accounts/{id}/adjust |
A signed amount and a required note. |
POST /kahunacart/credit/refunds |
Refund an order to store credit. Same body as the order refund route, plus order_id. |
GET /kahunacart/gift-cards |
Every card with its value, state and last four characters. status narrows. |
POST /kahunacart/gift-cards |
Mint a card. The response carries the code, once. currency is optional on a store that sells in more than one currency. |
POST /kahunacart/gift-cards/redeem |
Redeem a code into a customer's account. |
POST /kahunacart/gift-cards/{id}/void |
Cancel an unredeemed card and take its value back. |
GET /kahunacart/orders/{id}/credit-notes |
The credit notes an order has earned. |
GET /kahunacart/orders/{id}/credit-notes/{cnid}.pdf |
One of them as a PDF. |
Every route above except the PDF is also an MCP tool: list_credit_accounts, get_credit_account, list_credit_transactions, adjust_store_credit, refund_to_credit, list_gift_cards, create_gift_card, redeem_gift_card, void_gift_card and list_credit_notes.
Send amount as a decimal string or amount_minor in minor units; reads return both.
Extending it
onKahunaCartCreditPosted fires after every movement, once per movement rather than once per call.
| Key | Is |
|---|---|
account_id |
The account that moved. |
transaction_id |
The row that records it. |
amount_minor |
Signed. Positive credits, negative debits. |
currency |
ISO 4217. |
kind |
One of the kinds in the table above. |
reference, reference_id |
What produced it, and which one. |
order_id |
The order, or null. |
balance_minor |
The account's balance after this movement. |
The row is already written by the time the event fires, so a listener that throws affects nothing else.
To move credit from an add-on, call KahunaCart::instance()->ledger()->post() or ->spend(). Both require the provenance above, and both refuse a debit larger than the balance inside the transaction that would have written it.
Concurrency
Two things have to be exactly-once, and neither relies on SELECT … FOR UPDATE, which only two of the three supported engines understand.
A debit stamps the account row before it sums the ledger. MySQL and PostgreSQL block a second transaction from touching that row until this one commits, and SQLite serializes writers, so checking the balance and taking it is one decision.
A redemption claims the card with a conditional UPDATE that only one caller can win, and both movements carry a dedupe key the schema holds unique. Two concurrent redemptions of one card credit it once, and the loser is told it lost.
Sorting credit accounts by Balance or gift cards by Value groups records by currency first, then sorts amounts within each currency. This also applies to historical records after extra currencies are disabled.
Related
- Orders and the admin — refunds, and where credit notes appear on an order
- Customer accounts — the account area the balance panel renders into
- Emails — the gift-card message and how to restyle it
- Money conventions — why every amount here is minor units
- Configuration — every config key in one table
- Events —
onKahunaCartCreditPosted - Currencies — selling in more than one currency