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');
}
/**