justinholtweb / craft-pointz
Loyalty points and store credit for Craft Commerce — earning rules, a lot-based ledger that expires and reverses correctly, and redemption at checkout.
Package info
github.com/justinholtweb/craft-pointz
Type:craft-plugin
pkg:composer/justinholtweb/craft-pointz
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
- craftcms/commerce: ^5.0.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-29 13:54:01 UTC
README
Loyalty points and store credit for Craft Commerce 5. Earning rules a merchandiser can write, a ledger that expires and reverses correctly, and redemption at checkout that never spends anything until the order completes.
WooCommerce shops have had Points and Rewards for a decade. Craft has had gift vouchers and wishlists from Verbb and affiliates from Kickback, and nothing at all for loyalty. Pointz is that missing piece.
The one idea
Balances are derived, never typed in.
Every movement of value is a row in an append-only ledger, and every positive movement also creates a lot — an amount with its own remaining figure and its own expiry date. Spending consumes lots soonest-expiry-first and records exactly which lots it took from, so a refund puts value back where it came from instead of minting new value with a new date.
A balance is the sum of those lots. If it ever looks wrong, one command rebuilds every balance in the store from the ledger and proves it.
Requirements
- Craft CMS 5.3+
- Craft Commerce 5.0+
- PHP 8.2+
Installation
composer require justinholtweb/craft-pointz php craft plugin/install pointz
Pointz installs with one disabled earning rule per store. Nothing is awarded until you open Pointz → Earning rules, set a rate and switch it on — adding a loyalty plugin should never start paying out on a live store before anyone has read the numbers.
Editions
| Lite | Pro | |
|---|---|---|
| Price | Free | $129, $99/year renewal |
| One earning rule per store | ✅ | ✅ |
| Rate-per-value and fixed awards | ✅ | ✅ |
| Signup bonus | ✅ | ✅ |
| Redeem points at checkout — rate, minimum, block size, cap | ✅ | ✅ |
| Balances, ledger and manual grants | ✅ | ✅ |
| Holds, and refund reversal | ✅ | ✅ |
| One site-wide expiry policy | ✅ | ✅ |
| Order panel, Twig API, console | ✅ | ✅ |
| Several rules per store, stacked or exclusive | ✅ | |
| Per-line-item rules with product conditions | ✅ | |
| Order and customer conditions | ✅ | |
| Campaign windows and multipliers | ✅ | |
| Per-rule and per-customer caps | ✅ | |
| Store credit — grant, redeem, refund-to-credit | ✅ | |
| Per-rule expiry, inactivity expiry, expiry warnings | ✅ | |
| Backfill with a dry run and an undo | ✅ | |
| Ledger CSV export | ✅ | |
| Liability dashboard widget | ✅ |
Refund reversal and holds are deliberately in Lite. A free edition that over-awards is a bug, not an upsell.
What it looks like
Earning rules are read top to bottom, and each matching rule adds to the award:
| Rule | Rate | Applies | Window |
|---|---|---|---|
| Points on every order | 1 per unit of item subtotal | Once per order | Always |
| Double points on outdoor gear | 1 per unit of line subtotal, ×2 | Per matching line item | Always |
| Launch weekend | 5 per unit of order total | Once per order | Fri–Sun |
Redemption on the front end is one form:
{% set quote = craft.pointz.quote() %}
<form method="post">
{{ csrfInput() }}
{{ actionInput('pointz/cart/redeem') }}
<input type="number" name="points" max="{{ quote.maxPoints }}" value="{{ quote.points }}">
<button>Apply</button>
</form>
<p>You have {{ craft.pointz.formatted(quote.pointsBalance) }}.</p>
And telling customers what they will earn runs the same rule engine the real accrual runs, so the promise on the product page is the promise kept in the ledger:
{% set award = craft.pointz.earnFor(product.defaultVariant) %}
<p>Earn {{ craft.pointz.formatted(award.points) }} with this order.</p>
The parts that are hard, and how they work
Redemption is intent, not a debit. A customer's request is stored against the cart and re-clamped on every recalculation against the live balance and the live order total. An abandoned cart costs nothing; a cart that sits for a week quotes a smaller discount. The debit happens once, at completion, for exactly the amount the adjustment says — the points count travels in the adjustment's snapshot so a rate change between cart and checkout cannot alter what is spent.
Refunds are cumulative, not repeated. Two 20% refunds against one order take 40% in total. What has already been reversed is netted off, and points the customer has already spent are reported as a shortfall rather than pushing a balance negative.
Expiry is a sweep, not a timestamp. pointz/sweep/run releases holds, expires lots and closes
idle balances, and every one of those writes a ledger row saying what happened. Nothing evaporates
without an audit trail.
Awarding is idempotent. An order that has earned cannot earn again, whatever fires the handler — a completion retried after a payment hiccup, a status change that runs twice, a backfill over the same window.
Documentation
Console
php craft pointz/sweep/run # schedule this daily php craft pointz/accounts/recalculate # rebuild every balance from the ledger php craft pointz/grant/to-group customers 250 php craft pointz/backfill/plan --from=2026-01-01
Licence
See LICENSE.md.