Skip to content

Powered by Grav CMS + Helios Theme

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 needs kahunacart.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

  1. Open KahunaCart → Settings and the Accounts tab.
  2. Under Store Credit and Gift Cards, leave Offer Store Credit at Checkout on.
  3. Set Store Credit Label to whatever your customers call it.
  4. Leave Sell Gift Cards on if you want to sell them.
  5. 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

  1. Go to Products → New.
  2. Set Type to Gift card.
  3. Set the price to the card's value.
  4. 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:

  1. Get the customer's id from Customers.
  2. POST /kahunacart/gift-cards/redeem with code and customer_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

  1. Go to Customers and open the customer.
  2. Read their balance in the Store credit panel.
  3. POST /kahunacart/credit/accounts/{id}/adjust with a signed amount and a note.

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 when order_id is 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.