Wire POS register loyalty earn and redeem through CRM.
Deploy Ladill POS / deploy (push) Has been cancelled
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:
@@ -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()
|
||||
|
||||
@@ -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,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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user