DEV3 Technologies home

Append-only points ledger

Record loyalty points as immutable ledger rows with a running balance, an idempotency key and a locked member row, so totals can always be audited.

LaravelDatabaseledgerloyaltypointsaudit

Code

<?php

namespace App\Loyalty;

use App\Models\LedgerEntry;
use App\Models\Member;
use Illuminate\Support\Facades\DB;
use RuntimeException;

final class PointsLedger
{
    public static function balance(Member $member): int
    {
        return (int) LedgerEntry::where('member_id', $member->id)->latest('id')->value('balance_after');
    }

    /** Positive amount earns, negative spends. Returns the existing entry for a repeated key. */
    public static function post(Member $member, int $amount, string $reason, string $key): LedgerEntry
    {
        return DB::transaction(function () use ($member, $amount, $reason, $key) {
            // Serialises concurrent postings for this member only.
            Member::whereKey($member->id)->lockForUpdate()->first();

            if ($existing = LedgerEntry::where('idempotency_key', $key)->first()) {
                return $existing;
            }

            $balance = self::balance($member) + $amount;

            if ($balance < 0) {
                throw new RuntimeException('Not enough points.');
            }

            return LedgerEntry::create([
                'member_id' => $member->id,
                'amount' => $amount,
                'balance_after' => $balance,
                'reason' => $reason,
                'idempotency_key' => $key,
            ]);
        });
    }
}

How it works

What it solves

A points integer on the member row is easy to write and impossible to audit: when a customer says "I should have 400 points", nobody can show how it got to 350. A ledger records every change as a new row, so the balance is the sum of history and any dispute can be replayed line by line.

How it works

LedgerEntry forbids change. Its updating and deleting model events throw, which stops accidents in application code (database permissions should back this up in production). Corrections are made by appending a reversing entry with a reason.

PointsLedger::post runs inside a transaction and takes lockForUpdate on the member row. That serialises concurrent posts for one member while leaving everybody else untouched. It reads the latest balance_after, refuses a debit that would go negative, and inserts the new row with the new running balance, so reading a balance is a single indexed lookup instead of a SUM.

An idempotency key with a unique index makes a retried request return the original entry rather than posting twice. This matters when a till app resends after a timeout.

Adapting it

Add expiry by posting scheduled negative entries, store a polymorphic source (order, referral, manual) for reporting, and keep a nightly check that each member's last balance equals the sum of their amounts.

Pitfalls

  • Always post through the service, never LedgerEntry::create elsewhere.
  • Use integers; do not store fractional points.

How to use it

  1. Create ledger_entries (member_id, amount signed int, balance_after, reason, idempotency_key unique, created_at).
  2. Copy the model and service.
  3. Earn: PointsLedger::post($member, 120, 'purchase', "order-{$order->id}").
  4. Spend: PointsLedger::post($member, -500, 'reward', "redeem-{$redemption->id}") (throws on shortfall).
  5. Read balance: PointsLedger::balance($member).

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets