Pointz for Craft CMS

Twig and events

craft.pointz

Every method defaults to the logged-in customer and the current cart, and takes them explicitly when it should not. Nothing on this variable writes. A preview cannot spend a point, a quote cannot move a balance, and no amount of template refreshing can award anything twice.

Balances

craft.pointz.balance()                    {# spendable points #}
craft.pointz.balance(user, storeId)
craft.pointz.pendingBalance()             {# earned but still on hold #}
craft.pointz.creditBalance()
craft.pointz.account()                    {# the whole Account model, or null #}
craft.pointz.ledger(25)                   {# the customer's history, newest first #}
craft.pointz.expiring(30)                 {# what is about to expire, and when #}

Redeeming

craft.pointz.quote()                      {# what the cart's intent comes to #}
craft.pointz.quote(cart, 500)             {# a hypothetical, applying nothing #}
craft.pointz.maxRedeemable()              {# most points this cart could take #}
craft.pointz.cart()                       {# the cart, without creating one #}

A quote carries points, pointsValue, credit, maxPoints, maxCredit, pointsBalance, creditBalance, base, cap, totalDiscount, wasClamped and notices — the last of which is already written in the customer's language, ready to print.

The balances and maxima are only filled in for the cart's own customer, signed in. Anyone else — including a guest who has typed a registered customer's email at checkout — sees zeroes, and can't redeem.

Earning

craft.pointz.willEarn()                   {# an Award for the current cart #}
craft.pointz.earnFor(variant, 2)          {# an Award for a product page #}
craft.pointz.activeRules()                {# the rules a shop might want to advertise #}
craft.pointz.activeRules(null, 'birthday') {# the same, for another trigger #}

An Award has points, credit, lines (one per contributing rule, with ruleName, amount, basis and any note explaining a cap) and notices.

Coupons (Pro)

craft.pointz.coupons()                    {# the codes the customer can still use #}
craft.pointz.coupons(null)                {# all of them: used, expired and revoked too #}
craft.pointz.coupons('used', user, storeId)
{% for coupon in craft.pointz.coupons() %}
    <p>
        <code>{{ coupon.code }}</code>: {{ coupon.valueLabel }} off
        {%- if coupon.dateExpires %}, until {{ coupon.dateExpires|date('long') }}{% endif %}
    </p>
{% endfor %}

A Coupon has code, valueLabel ("10%" or "€5.00"), type, amount, status, statusLabel, dateExpires, dateUsed, rule, order (the order it was used on) and isUsable.

Formatting

craft.pointz.format(1240)                 {# "1,240" #}
craft.pointz.formatted(1240)              {# "1,240 points" — the store's own word #}
craft.pointz.label(1)                     {# "point" #}
craft.pointz.formatMoney(12.5)
craft.pointz.value(1000)                  {# points as money #}
craft.pointz.points(10)                   {# money as points #}
craft.pointz.settings
craft.pointz.isPro

Site actions

ActionBodyAnswers
pointz/cart/redeempoints, credit (either may be max)JSON with success, message, quote; or a redirect with a flash. Needs the cart's customer to be signed in.
pointz/cart/remove—The same
pointz/cart/quotepoints, credit as query paramsJSON quote, applying nothing

Events

Two, both in PHP, for the logic a rule builder cannot express.

Changing an award before it is written

use justinholtweb\pointz\events\AwardEvent;
use justinholtweb\pointz\models\AwardLine;
use justinholtweb\pointz\services\Earning;
use yii\base\Event;

Event::on(
    Earning::class,
    Earning::EVENT_BEFORE_AWARD,
    function(AwardEvent $event) {
        // A tier a rule cannot see: partners earn 50% more.
        if ($event->user?->isInGroup('partners')) {
            foreach ($event->award->lines as $line) {
                $line->amount = floor($line->amount * 1.5);
            }
        }

        // A preview must not have side effects.
        if ($event->isPreview) {
            return;
        }
    }
);

$event->award->clear() skips the order entirely. The event fires for previews as well as for real accruals, which is exactly what keeps the two agreeing — check $event->isPreview before doing anything with a side effect.

Reacting to a movement

use justinholtweb\pointz\events\TransactionEvent;
use justinholtweb\pointz\services\Ledger;
use yii\base\Event;

Event::on(
    Ledger::class,
    Ledger::EVENT_AFTER_TRANSACTION,
    function(TransactionEvent $event) {
        if ($event->transaction->kind === 'expire') {
            // Tell them what they lost.
        }
    }
);

The ledger is append-only, so this event is read-only by design: there is nothing to change after the fact, and a correction is written as its opposite.

Paying out only when you say so

Every rule that is not an order (a signup, a review, a birthday, a threshold, a custom event) raises Rewards::EVENT_BEFORE_REWARD before it hands anything out. Set isValid to false to skip it. Nothing is recorded, so the same trigger can still pay later.

use justinholtweb\pointz\events\RewardEvent;
use justinholtweb\pointz\services\Rewards;
use yii\base\Event;

Event::on(
    Rewards::class,
    Rewards::EVENT_BEFORE_REWARD,
    function(RewardEvent $event) {
        // $event->rule, $event->user, $event->storeId, $event->reference, $event->context
        if ($event->rule->event === 'review' && str_ends_with($event->user->email, '@ourshop.com')) {
            $event->isValid = false; // staff don't earn for reviews
        }
    }
);

Sending the coupon emails yourself

Coupons::EVENT_AFTER_ISSUE fires after a coupon is issued. Coupons::EVENT_BEFORE_REMIND fires before its expiry reminder: set isValid to false and Pointz won't send its own email. The coupon is marked reminded either way, so the reminder goes out once.

use justinholtweb\pointz\events\CouponEvent;
use justinholtweb\pointz\services\Coupons;

Event::on(
    Coupons::class,
    Coupons::EVENT_BEFORE_REMIND,
    function(CouponEvent $event) {
        MyMailer::send($event->user, 'coupon-expiring', ['code' => $event->coupon->code]);
        $event->isValid = false;
    }
);

Moving value in response to a movement

Ledger::EVENT_AFTER_TRANSACTION fires inside the account's lock, so a handler there must not move value itself. Ledger::EVENT_AFTER_COMMIT fires after a positive movement, once the lock is released and the transaction committed. That's where to react with a movement of your own. It's how threshold rules spend what just arrived.

Services

use justinholtweb\pointz\Plugin;

$plugin = Plugin::getInstance();

$plugin->getAccounts()->getBalance($userId, $storeId);
$plugin->getLedger()->credit($userId, $storeId, 'points', 500, ['note' => 'Survey']);
$plugin->getLedger()->debit($userId, $storeId, 'points', 100);
$plugin->getGrants()->grantMany($userIds, $storeId, 'points', 250, 'Launch');
$plugin->getEarning()->evaluateOrder($order, true);
$plugin->getRedemption()->quote($cart, 500);
$plugin->getLifecycle()->expireDueLots();
$plugin->getRewards()->awardEvent($user, 'attendedWorkshop');
$plugin->getCoupons()->getCoupons(['userId' => $userId, 'status' => 'active']);

Ledger is the only writer. Anything that moves value goes through credit(), debit(), restore() or revoke(), each of which holds a per-account lock and writes inside a database transaction — so a half-written movement is never visible and two checkouts cannot spend the same lot.