Wire POS register loyalty earn and redeem through CRM.
Deploy Ladill POS / deploy (push) Has been cancelled

Redeem at charge, earn on payment, reverse on cancel, and surface
balances on the till, customer display, sales, and receipts.
This commit is contained in:
isaacclad
2026-07-15 15:46:28 +00:00
parent 38b7f96714
commit 468346b183
12 changed files with 680 additions and 25 deletions
@@ -10,6 +10,7 @@ use App\Models\PosSale;
use App\Services\Pos\CustomerDisplayService;
use App\Services\Pos\PosBarcodeLookupService;
use App\Services\Pos\PosLocationService;
use App\Services\Pos\PosLoyaltyService;
use App\Services\Pos\PosSaleService;
use App\Services\Crm\CrmClient;
use Illuminate\Http\JsonResponse;
@@ -27,6 +28,7 @@ class RegisterController extends Controller
private PosLocationService $locations,
private PosBarcodeLookupService $barcodes,
private CustomerDisplayService $customerDisplays,
private PosLoyaltyService $loyalty,
) {}
public function index(Request $request): View
@@ -34,12 +36,16 @@ class RegisterController extends Controller
$owner = $this->ownerRef($request);
$location = $this->location($request);
$display = $this->customerDisplays->ensureForLocation($location);
$loyaltyProgram = $this->loyalty->program($owner);
return view('pos.register', [
'products' => $this->registerProducts($owner, $location),
'location' => $location,
'crmCustomers' => $this->crmCustomers($owner),
'barcodeLookupUrl' => route('pos.register.lookup'),
'loyaltyUrl' => route('pos.register.loyalty'),
'loyaltyEnabled' => (bool) ($loyaltyProgram['enabled'] ?? false),
'loyaltyProgram' => $loyaltyProgram,
'customerDisplayUrl' => $this->customerDisplays->displayUrl($display),
'customerDisplayPushUrl' => route('pos.customer-display.push'),
'customerDisplayActionsUrl' => route('pos.customer-display.actions'),
@@ -47,6 +53,33 @@ class RegisterController extends Controller
]);
}
/** Live loyalty quote for the register (CRM-backed). */
public function loyalty(Request $request): JsonResponse
{
$data = $request->validate([
'crm_customer_id' => ['required', 'integer'],
'subtotal_minor' => ['nullable', 'integer', 'min:0'],
'redeem_points' => ['nullable', 'integer', 'min:0'],
]);
$owner = $this->ownerRef($request);
$subtotal = (int) ($data['subtotal_minor'] ?? 0);
$redeem = (int) ($data['redeem_points'] ?? 0);
$snapshot = $this->loyalty->snapshot($owner, (int) $data['crm_customer_id'], $subtotal);
if (! $snapshot) {
return response()->json(['enabled' => false, 'message' => 'Loyalty unavailable.'], 200);
}
$quote = $this->loyalty->quote($owner, (int) $data['crm_customer_id'], max(1, $subtotal), $redeem);
return response()->json([
'enabled' => (bool) ($snapshot['program']['enabled'] ?? false),
'snapshot' => $snapshot,
'quote' => $quote,
]);
}
public function lookup(Request $request): JsonResponse
{
$data = $request->validate([
@@ -146,6 +179,7 @@ class RegisterController extends Controller
'customer_email' => ['nullable', 'email', 'max:255'],
'customer_phone' => ['nullable', 'string', 'max:40'],
'crm_customer_id' => ['nullable', 'integer'],
'loyalty_redeem_points' => ['nullable', 'integer', 'min:0'],
'payment_method' => ['required', 'in:pay,cash'],
]);
@@ -166,11 +200,25 @@ class RegisterController extends Controller
'customer_email' => $data['customer_email'] ?? null,
'customer_phone' => $data['customer_phone'] ?? null,
'crm_customer_id' => $data['crm_customer_id'] ?? null,
'loyalty_redeem_points' => (int) ($data['loyalty_redeem_points'] ?? 0),
]);
if ($data['payment_method'] === 'cash') {
$this->sales->recordCashPayment($sale);
$this->customerDisplays->pushReceipt($location, $sale->fresh(['lines']));
$sale = $sale->fresh(['lines']);
$loyaltyPayload = null;
if ($sale->crm_customer_id) {
$snap = $this->loyalty->snapshot($merchant->public_id, (int) $sale->crm_customer_id, (int) $sale->total_minor);
$loyaltyPayload = $this->loyalty->displayPayload(
$snap,
(int) $sale->loyalty_points_redeemed,
(int) $sale->loyalty_points_earned,
);
}
$this->customerDisplays->pushReceipt($location, $sale, array_filter([
'loyalty' => $loyaltyPayload,
'discount_minor' => (int) $sale->loyalty_discount_minor,
]));
if ($location->printer_auto_print) {
return redirect()
+7
View File
@@ -52,6 +52,10 @@ class PosSale extends Model
'customer_phone',
'crm_customer_id',
'subtotal_minor',
'loyalty_discount_minor',
'loyalty_points_redeemed',
'loyalty_points_earned',
'loyalty_external_ref',
'total_minor',
'currency',
'paid_at',
@@ -64,6 +68,9 @@ class PosSale extends Model
{
return [
'subtotal_minor' => 'integer',
'loyalty_discount_minor' => 'integer',
'loyalty_points_redeemed' => 'integer',
'loyalty_points_earned' => 'integer',
'total_minor' => 'integer',
'pay_order_id' => 'integer',
'crm_customer_id' => 'integer',
+62
View File
@@ -62,6 +62,61 @@ class CrmClient
return $this->post('timeline', $data);
}
/** @return array<string, mixed> */
public function loyaltyProgram(): array
{
return $this->get('loyalty/program');
}
/**
* @return array<string, mixed>
*/
public function customerLoyalty(int|string $customerId, ?int $amountMinor = null): array
{
$query = [];
if ($amountMinor !== null) {
$query['amount_minor'] = $amountMinor;
}
return $this->get("customers/{$customerId}/loyalty", $query);
}
/**
* @param array{customer_id: int, subtotal_minor: int, redeem_points?: int} $data
* @return array<string, mixed>
*/
public function loyaltyQuote(array $data): array
{
return $this->post('loyalty/quote', $data);
}
/**
* @param array{customer_id: int, subtotal_minor: int, redeem_points: int, external_ref: string, source?: string, description?: string} $data
* @return array<string, mixed>
*/
public function loyaltyRedeem(array $data): array
{
return $this->post('loyalty/redeem', $data);
}
/**
* @param array{customer_id: int, amount_minor: int, external_ref: string, source?: string, description?: string} $data
* @return array<string, mixed>
*/
public function loyaltyEarn(array $data): array
{
return $this->post('loyalty/earn', $data);
}
/**
* @param array{external_ref: string, source?: string} $data
* @return array<string, mixed>
*/
public function loyaltyReverse(array $data): array
{
return $this->post('loyalty/reverse', $data);
}
private function client(): PendingRequest
{
return Http::baseUrl((string) config('crm.url'))
@@ -98,6 +153,13 @@ class CrmClient
}
if ($response->failed()) {
if ($response->status() === 422) {
$message = (string) ($response->json('message')
?? collect((array) $response->json('errors'))->flatten()->first()
?? 'CRM validation failed.');
throw ValidationException::withMessages(['crm' => [$message]]);
}
abort($response->status() === 404 ? 404 : 502, 'CRM service error.');
}
+190
View File
@@ -0,0 +1,190 @@
<?php
namespace App\Services\Pos;
use App\Models\PosSale;
use App\Services\Crm\CrmClient;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use RuntimeException;
/**
* POS-facing loyalty adapter. CRM is the source of truth for balances and rules.
*/
class PosLoyaltyService
{
public function program(string $ownerRef): ?array
{
try {
return CrmClient::for($ownerRef)->loyaltyProgram();
} catch (\Throwable $e) {
Log::debug('loyalty.program_unavailable', ['owner' => $ownerRef, 'error' => $e->getMessage()]);
return null;
}
}
public function isEnabled(string $ownerRef): bool
{
$program = $this->program($ownerRef);
return (bool) ($program['enabled'] ?? false);
}
/**
* @return array<string, mixed>|null
*/
public function snapshot(string $ownerRef, int $crmCustomerId, int $amountMinor = 0): ?array
{
try {
return CrmClient::for($ownerRef)->customerLoyalty($crmCustomerId, $amountMinor);
} catch (\Throwable $e) {
Log::debug('loyalty.snapshot_failed', [
'owner' => $ownerRef,
'customer' => $crmCustomerId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* @return array<string, mixed>|null
*/
public function quote(string $ownerRef, int $crmCustomerId, int $subtotalMinor, int $redeemPoints = 0): ?array
{
try {
return CrmClient::for($ownerRef)->loyaltyQuote([
'customer_id' => $crmCustomerId,
'subtotal_minor' => $subtotalMinor,
'redeem_points' => $redeemPoints,
]);
} catch (\Throwable $e) {
Log::debug('loyalty.quote_failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Apply redeem against CRM and return discount details for the sale.
*
* @return array{points: int, discount_minor: int, external_ref: string, balance: int}
*/
public function redeemForSale(PosSale $sale, int $redeemPoints): array
{
if (! $sale->crm_customer_id || $redeemPoints <= 0) {
return ['points' => 0, 'discount_minor' => 0, 'external_ref' => '', 'balance' => 0];
}
$externalRef = $sale->loyalty_external_ref ?: ('pos-sale-'.$sale->id.'-'.$sale->reference);
try {
$result = CrmClient::for($sale->owner_ref)->loyaltyRedeem([
'customer_id' => (int) $sale->crm_customer_id,
'subtotal_minor' => (int) $sale->subtotal_minor,
'redeem_points' => $redeemPoints,
'external_ref' => $externalRef,
'source' => 'pos',
'description' => 'POS '.$sale->reference,
]);
} catch (ValidationException $e) {
throw new RuntimeException(collect($e->errors())->flatten()->first() ?: 'Could not redeem loyalty points.');
} catch (\Throwable $e) {
throw new RuntimeException('Could not redeem loyalty points. Check CRM connectivity.');
}
return [
'points' => (int) ($result['points'] ?? 0),
'discount_minor' => (int) ($result['discount_minor'] ?? 0),
'external_ref' => $externalRef,
'balance' => (int) ($result['balance'] ?? 0),
];
}
public function earnForSale(PosSale $sale): int
{
if (! $sale->crm_customer_id) {
return 0;
}
$externalRef = $sale->loyalty_external_ref ?: ('pos-sale-'.$sale->id.'-'.$sale->reference);
$amount = (int) $sale->total_minor;
try {
$result = CrmClient::for($sale->owner_ref)->loyaltyEarn([
'customer_id' => (int) $sale->crm_customer_id,
'amount_minor' => $amount,
'external_ref' => $externalRef,
'source' => 'pos',
'description' => 'POS '.$sale->reference,
]);
} catch (\Throwable $e) {
Log::warning('loyalty.earn_failed', [
'sale' => $sale->id,
'error' => $e->getMessage(),
]);
return 0;
}
$points = (int) ($result['points'] ?? 0);
if ($points > 0 || ! $sale->loyalty_points_earned) {
$sale->forceFill([
'loyalty_points_earned' => $points,
'loyalty_external_ref' => $externalRef,
])->save();
}
return $points;
}
public function reverseForSale(PosSale $sale): void
{
$ref = $sale->loyalty_external_ref;
if (! $ref && ($sale->loyalty_points_redeemed || $sale->loyalty_points_earned)) {
$ref = 'pos-sale-'.$sale->id.'-'.$sale->reference;
}
if (! $ref) {
return;
}
try {
CrmClient::for($sale->owner_ref)->loyaltyReverse([
'external_ref' => $ref,
'source' => 'pos',
]);
} catch (\Throwable $e) {
Log::warning('loyalty.reverse_failed', [
'sale' => $sale->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Payload for customer display.
*
* @return array<string, mixed>|null
*/
public function displayPayload(?array $snapshot, int $redeemPoints = 0, int $earnPoints = 0): ?array
{
if (! $snapshot || ! ($snapshot['program']['enabled'] ?? $snapshot['points_balance'] ?? false)) {
if (! $snapshot) {
return null;
}
}
if (! ($snapshot['program']['enabled'] ?? false)) {
return null;
}
return [
'label' => $snapshot['label'] ?? ($snapshot['program']['name'] ?? 'Rewards'),
'points_balance' => (int) ($snapshot['points_balance'] ?? 0),
'points_earn' => $earnPoints ?: (int) ($snapshot['points_earn'] ?? 0),
'points_redeem' => $redeemPoints,
];
}
}
+33 -3
View File
@@ -19,11 +19,12 @@ class PosSaleService
public function __construct(
private MerchantGatewayService $gateway,
private PosTimelineService $timeline,
private PosLoyaltyService $loyalty,
) {}
/**
* @param list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int}> $lines
* @param array{customer_name?: ?string, customer_email?: ?string, customer_phone?: ?string, crm_customer_id?: ?int, location_id?: ?int, currency?: string} $meta
* @param array{customer_name?: ?string, customer_email?: ?string, customer_phone?: ?string, crm_customer_id?: ?int, location_id?: ?int, currency?: string, loyalty_redeem_points?: int} $meta
*/
public function createSale(User $merchant, array $lines, array $meta = []): PosSale
{
@@ -50,6 +51,9 @@ class PosSaleService
'customer_phone' => $meta['customer_phone'] ?? null,
'crm_customer_id' => $meta['crm_customer_id'] ?? null,
'subtotal_minor' => $subtotal,
'loyalty_discount_minor' => 0,
'loyalty_points_redeemed' => 0,
'loyalty_points_earned' => 0,
'total_minor' => $subtotal,
'currency' => $currency,
]);
@@ -66,6 +70,28 @@ class PosSaleService
]);
}
$sale = $sale->fresh('lines');
$redeemPoints = (int) ($meta['loyalty_redeem_points'] ?? 0);
if ($redeemPoints > 0 && $sale->crm_customer_id) {
$sale = $this->applyLoyaltyRedeem($sale, $redeemPoints);
}
return $sale;
}
public function applyLoyaltyRedeem(PosSale $sale, int $redeemPoints): PosSale
{
$result = $this->loyalty->redeemForSale($sale, $redeemPoints);
$discount = (int) $result['discount_minor'];
$points = (int) $result['points'];
$sale->forceFill([
'loyalty_discount_minor' => $discount,
'loyalty_points_redeemed' => $points,
'loyalty_external_ref' => $result['external_ref'] ?: $sale->loyalty_external_ref,
'total_minor' => max(0, (int) $sale->subtotal_minor - $discount),
])->save();
return $sale->fresh('lines');
}
@@ -451,6 +477,8 @@ class PosSaleService
throw new RuntimeException('Only pending sales can be cancelled.');
}
$this->loyalty->reverseForSale($sale);
$sale->forceFill(['status' => PosSale::STATUS_CANCELLED])->save();
$this->closeTicket($sale);
}
@@ -553,9 +581,10 @@ class PosSaleService
$this->closeTicket($sale);
$sale = $sale->fresh('lines');
$this->loyalty->earnForSale($sale);
$this->timeline->pushPaidSale($sale);
return $sale;
return $sale->fresh('lines');
}
public function completePayCheckout(string $paymentReference): PosSale
@@ -579,9 +608,10 @@ class PosSaleService
$this->closeTicket($sale);
$sale = $sale->fresh('lines');
$this->loyalty->earnForSale($sale);
$this->timeline->pushPaidSale($sale);
return $sale;
return $sale->fresh('lines');
}
/**
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('pos_sales', function (Blueprint $table) {
$table->unsignedInteger('loyalty_discount_minor')->default(0)->after('subtotal_minor');
$table->unsignedInteger('loyalty_points_redeemed')->default(0)->after('loyalty_discount_minor');
$table->unsignedInteger('loyalty_points_earned')->default(0)->after('loyalty_points_redeemed');
$table->string('loyalty_external_ref', 80)->nullable()->after('loyalty_points_earned');
});
}
public function down(): void
{
Schema::table('pos_sales', function (Blueprint $table) {
$table->dropColumn([
'loyalty_discount_minor',
'loyalty_points_redeemed',
'loyalty_points_earned',
'loyalty_external_ref',
]);
});
}
};
@@ -1,28 +1,14 @@
@props(['customers' => []])
@if (!empty($customers))
<div class="rounded-xl bg-indigo-50/60 p-3"
x-data="{
cid: '',
customers: @js(array_values($customers)),
fill() {
const c = this.customers.find((x) => String(x.id) === String(this.cid));
if (!c) return;
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v || ''; };
set('customer_name', c.name);
set('customer_email', c.email || '');
set('customer_phone', c.phone || '');
const hidden = document.getElementById('crm_customer_id');
if (hidden) hidden.value = c.id;
}
}">
<div class="rounded-xl bg-indigo-50/60 p-3">
<label class="text-xs font-medium text-slate-600">CRM customer (optional)</label>
<select x-model="cid" @change="fill()"
<select x-model="crmCustomerId" @change="onCustomerSelected()"
class="mt-1 block w-full rounded-lg border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Walk-in customer</option>
@foreach (array_values($customers) as $c)
<option value="{{ $c['id'] }}">{{ $c['name'] }}@if (!empty($c['email'])) ({{ $c['email'] }})@endif</option>
@endforeach
</select>
<input type="hidden" name="crm_customer_id" id="crm_customer_id" value="">
<input type="hidden" name="crm_customer_id" id="crm_customer_id" :value="crmCustomerId">
</div>
@endif
@@ -133,10 +133,22 @@
<hr class="divider">
<table>
@if ($sale->loyalty_discount_minor)
<tr>
<td colspan="2">Loyalty</td>
<td class="amt">{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }}</td>
</tr>
@endif
<tr class="total-row">
<td colspan="2">Total</td>
<td class="amt">{{ pos_money($sale->total_minor, $sale->currency) }}</td>
</tr>
@if ($sale->loyalty_points_earned)
<tr>
<td colspan="2" class="muted">Points earned</td>
<td class="amt muted">+{{ $sale->loyalty_points_earned }}</td>
</tr>
@endif
<tr>
<td colspan="2" class="muted">Payment</td>
<td class="amt muted">{{ ucfirst($sale->payment_method) }}</td>
+119 -4
View File
@@ -6,6 +6,9 @@
'customerDisplayUrl' => $customerDisplayUrl,
'customerDisplayPushUrl' => $customerDisplayPushUrl,
'customerDisplayActionsUrl' => $customerDisplayActionsUrl,
'loyaltyUrl' => $loyaltyUrl,
'loyaltyEnabled' => $loyaltyEnabled,
'crmCustomers' => array_values($crmCustomers),
'isRestaurant' => $isRestaurant,
'currency' => $location->currency,
]))">
@@ -101,10 +104,14 @@
<li x-show="cart.length === 0" class="text-sm text-slate-500">Cart is empty.</li>
</ul>
<div class="mt-4 border-t border-slate-100 pt-4">
<div class="mt-4 border-t border-slate-100 pt-4 space-y-1.5">
<div class="flex justify-between text-sm" x-show="loyaltyDiscountMinor > 0">
<span class="text-emerald-600">Loyalty discount</span>
<span class="font-medium text-emerald-700" x-text="'' + formatMoney(loyaltyDiscountMinor)"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-slate-500">Total</span>
<span class="font-semibold text-slate-900" x-text="formatMoney(cartTotal())"></span>
<span class="font-semibold text-slate-900" x-text="formatMoney(payableTotal())"></span>
</div>
</div>
@@ -116,6 +123,35 @@
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<input type="tel" id="customer_phone" name="customer_phone" placeholder="Phone (optional)"
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<div x-show="loyaltyEnabled && crmCustomerId" x-cloak
class="rounded-xl border border-amber-200 bg-amber-50/70 p-3 space-y-2">
<div class="flex items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-wide text-amber-800" x-text="loyaltyLabel"></p>
<p class="text-sm font-semibold text-amber-900">
<span x-text="loyaltyBalance"></span> pts
</p>
</div>
<p class="text-xs text-amber-800/80" x-show="loyaltyEarnPreview > 0">
This sale earns ~<strong x-text="loyaltyEarnPreview"></strong> pts
</p>
<div>
<label class="text-xs font-medium text-amber-900">Redeem points</label>
<div class="mt-1 flex gap-2">
<input type="number" min="0" :max="loyaltyMaxRedeem" x-model.number="loyaltyRedeemPoints"
@input="scheduleLoyaltyQuote()"
name="loyalty_redeem_points"
class="block w-full rounded-lg border-amber-200 text-sm shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="0">
<button type="button" @click="redeemMax()"
class="shrink-0 rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-xs font-medium text-amber-900 hover:bg-amber-50">Max</button>
</div>
<p class="mt-1 text-[11px] text-amber-800/70" x-show="loyaltyMaxRedeem > 0">
Max <span x-text="loyaltyMaxRedeem"></span> pts
(<span x-text="formatMoney(loyaltyDiscountMinor)"></span> off)
</p>
</div>
</div>
</div>
<div id="line-fields"></div>
@@ -150,6 +186,17 @@
customerDisplayUrl: config.customerDisplayUrl || '',
customerDisplayPushUrl: config.customerDisplayPushUrl || '',
customerDisplayActionsUrl: config.customerDisplayActionsUrl || '',
loyaltyUrl: config.loyaltyUrl || '',
loyaltyEnabled: !!config.loyaltyEnabled,
crmCustomers: config.crmCustomers || [],
crmCustomerId: '',
loyaltyLabel: 'Rewards',
loyaltyBalance: 0,
loyaltyEarnPreview: 0,
loyaltyRedeemPoints: 0,
loyaltyMaxRedeem: 0,
loyaltyDiscountMinor: 0,
loyaltyQuoteTimer: null,
isRestaurant: !!config.isRestaurant,
currency: config.currency || 'GHS',
cart: [],
@@ -167,13 +214,74 @@
bc: null,
init() {
document.addEventListener('keydown', (e) => this.onGlobalKeydown(e));
this.$watch('cart', () => this.scheduleDisplayPush(), { deep: true });
this.$watch('cart', () => {
this.scheduleDisplayPush();
this.scheduleLoyaltyQuote();
}, { deep: true });
try {
this.bc = new BroadcastChannel('pos-customer-display');
} catch (_) {}
this.actionTimer = setInterval(() => this.pollCustomerActions(), 3000);
this.scheduleDisplayPush();
},
onCustomerSelected() {
const c = this.crmCustomers.find((x) => String(x.id) === String(this.crmCustomerId));
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v || ''; };
if (c) {
set('customer_name', c.name);
set('customer_email', c.email || '');
set('customer_phone', c.phone || '');
}
this.loyaltyRedeemPoints = 0;
this.loyaltyDiscountMinor = 0;
this.scheduleLoyaltyQuote();
this.scheduleDisplayPush();
},
redeemMax() {
this.loyaltyRedeemPoints = this.loyaltyMaxRedeem || 0;
this.scheduleLoyaltyQuote();
},
scheduleLoyaltyQuote() {
clearTimeout(this.loyaltyQuoteTimer);
this.loyaltyQuoteTimer = setTimeout(() => this.fetchLoyaltyQuote(), 250);
},
async fetchLoyaltyQuote() {
if (!this.loyaltyEnabled || !this.crmCustomerId || !this.loyaltyUrl) {
this.loyaltyBalance = 0;
this.loyaltyEarnPreview = 0;
this.loyaltyMaxRedeem = 0;
this.loyaltyDiscountMinor = 0;
return;
}
try {
const params = new URLSearchParams({
crm_customer_id: this.crmCustomerId,
subtotal_minor: String(this.cartTotal()),
redeem_points: String(this.loyaltyRedeemPoints || 0),
});
const res = await fetch(this.loyaltyUrl + '?' + params, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await res.json().catch(() => ({}));
if (!data.enabled) {
this.loyaltyEnabled = false;
return;
}
const snap = data.snapshot || {};
const quote = data.quote || {};
this.loyaltyLabel = snap.label || snap.program?.name || 'Rewards';
this.loyaltyBalance = snap.points_balance || 0;
this.loyaltyEarnPreview = quote.points_earn ?? snap.points_earn ?? 0;
this.loyaltyMaxRedeem = quote.max_points ?? snap.max_redeem_points ?? 0;
this.loyaltyDiscountMinor = quote.discount_minor || 0;
if (this.loyaltyRedeemPoints > this.loyaltyMaxRedeem) {
this.loyaltyRedeemPoints = this.loyaltyMaxRedeem;
}
} catch (_) {}
},
payableTotal() {
return Math.max(0, this.cartTotal() - (this.loyaltyDiscountMinor || 0));
},
onGlobalKeydown(e) {
if (this.shouldIgnoreScan(e)) return;
if (e.key === 'Enter') {
@@ -341,12 +449,19 @@
: {
phase: 'cart',
lines,
discount_minor: this.loyaltyDiscountMinor || 0,
customer_name: document.getElementById('customer_name')?.value || null,
loyalty: (this.loyaltyEnabled && this.crmCustomerId) ? {
label: this.loyaltyLabel,
points_balance: this.loyaltyBalance,
points_earn: this.loyaltyEarnPreview,
points_redeem: this.loyaltyRedeemPoints || 0,
} : null,
tip: this.isRestaurant ? {
enabled: true,
options_percent: [10, 15, 18, 20],
custom_allowed: true,
base_minor: this.cartTotal(),
base_minor: this.payableTotal(),
} : undefined,
};
await this.pushDisplay(payload);
+6
View File
@@ -27,6 +27,12 @@
@if ($sale->customer_name)
<div><dt class="text-slate-400">Customer</dt><dd class="text-slate-800">{{ $sale->customer_name }}</dd></div>
@endif
@if ($sale->loyalty_discount_minor)
<div><dt class="text-slate-400">Loyalty discount</dt><dd class="text-emerald-700">{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }} ({{ $sale->loyalty_points_redeemed }} pts)</dd></div>
@endif
@if ($sale->loyalty_points_earned)
<div><dt class="text-slate-400">Points earned</dt><dd class="text-amber-800">+{{ $sale->loyalty_points_earned }} pts</dd></div>
@endif
</dl>
<table class="mt-6 w-full text-sm">
+1
View File
@@ -77,6 +77,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/register', [RegisterController::class, 'index'])->name('pos.register');
Route::get('/register/lookup', [RegisterController::class, 'lookup'])->name('pos.register.lookup');
Route::get('/register/loyalty', [RegisterController::class, 'loyalty'])->name('pos.register.loyalty');
Route::post('/register/charge', [RegisterController::class, 'charge'])->name('pos.register.charge');
Route::post('/register/mode', [RegisterController::class, 'setMode'])->name('pos.mode.set');
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\PosLocation;
use App\Models\PosSale;
use App\Models\User;
use App\Services\Pos\PosLoyaltyService;
use App\Services\Pos\PosSaleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PosLoyaltyTest extends TestCase
{
use RefreshDatabase;
private function owner(): User
{
return User::create([
'public_id' => 'owner-loyalty',
'name' => 'Owner',
'email' => 'owner-loyalty@example.com',
]);
}
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->withoutVite();
config(['crm.url' => 'https://crm.test/api', 'crm.key' => 'pos-key']);
}
public function test_create_sale_redeems_points_via_crm_and_reduces_total(): void
{
Http::fake([
'crm.test/api/loyalty/redeem*' => Http::response([
'points' => 200,
'discount_minor' => 200,
'ledger_id' => 9,
'balance' => 50,
], 200),
'crm.test/api/loyalty/earn*' => Http::response([
'points' => 8,
'ledger_id' => 10,
'balance' => 58,
], 200),
]);
$owner = $this->owner();
PosLocation::create([
'owner_ref' => $owner->public_id,
'name' => 'Main',
'currency' => 'GHS',
'is_default' => true,
]);
$sales = app(PosSaleService::class);
$sale = $sales->createSale($owner, [
['name' => 'Shirt', 'unit_price_minor' => 1000, 'quantity' => 1],
], [
'crm_customer_id' => 42,
'loyalty_redeem_points' => 200,
'currency' => 'GHS',
]);
$this->assertSame(1000, $sale->subtotal_minor);
$this->assertSame(200, $sale->loyalty_discount_minor);
$this->assertSame(200, $sale->loyalty_points_redeemed);
$this->assertSame(800, $sale->total_minor);
$paid = $sales->recordCashPayment($sale);
$this->assertSame(8, $paid->loyalty_points_earned);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'loyalty/redeem')
&& $request['customer_id'] === 42
&& $request['redeem_points'] === 200;
});
Http::assertSent(function ($request) {
return str_contains($request->url(), 'loyalty/earn')
&& $request['amount_minor'] === 800;
});
}
public function test_cancel_pending_sale_reverses_loyalty(): void
{
Http::fake([
'crm.test/api/loyalty/redeem*' => Http::response([
'points' => 100,
'discount_minor' => 100,
'ledger_id' => 1,
'balance' => 0,
], 200),
'crm.test/api/loyalty/reverse*' => Http::response(['reversed' => 1], 200),
]);
$owner = $this->owner();
$sales = app(PosSaleService::class);
$sale = $sales->createSale($owner, [
['name' => 'Item', 'unit_price_minor' => 500, 'quantity' => 1],
], [
'crm_customer_id' => 7,
'loyalty_redeem_points' => 100,
]);
$sales->cancelSale($sale);
$this->assertSame(PosSale::STATUS_CANCELLED, $sale->fresh()->status);
Http::assertSent(fn ($request) => str_contains($request->url(), 'loyalty/reverse'));
}
public function test_register_loyalty_endpoint_returns_quote(): void
{
Http::fake([
'crm.test/api/customers/5/loyalty*' => Http::response([
'program' => ['enabled' => true, 'name' => 'Rewards'],
'label' => 'Rewards',
'points_balance' => 300,
'points_earn' => 10,
'max_redeem_points' => 200,
], 200),
'crm.test/api/loyalty/quote*' => Http::response([
'points_balance' => 300,
'points' => 100,
'discount_minor' => 100,
'max_points' => 200,
'points_earn' => 9,
'payable_minor' => 900,
], 200),
]);
$owner = $this->owner();
PosLocation::create([
'owner_ref' => $owner->public_id,
'name' => 'Main',
'currency' => 'GHS',
'is_default' => true,
]);
$this->actingAs($owner)
->getJson(route('pos.register.loyalty', [
'crm_customer_id' => 5,
'subtotal_minor' => 1000,
'redeem_points' => 100,
]))
->assertOk()
->assertJsonPath('enabled', true)
->assertJsonPath('quote.discount_minor', 100)
->assertJsonPath('snapshot.points_balance', 300);
}
public function test_display_payload_maps_snapshot(): void
{
$payload = app(PosLoyaltyService::class)->displayPayload([
'program' => ['enabled' => true, 'name' => 'Rewards'],
'label' => 'Rewards',
'points_balance' => 40,
'points_earn' => 5,
], 10, 5);
$this->assertSame(40, $payload['points_balance']);
$this->assertSame(10, $payload['points_redeem']);
$this->assertSame(5, $payload['points_earn']);
}
}