Files
ladill-pos/app/Services/Pos/PosLoyaltyService.php
T
isaacclad 468346b183
Deploy Ladill POS / deploy (push) Has been cancelled
Wire POS register loyalty earn and redeem through CRM.
Redeem at charge, earn on payment, reverse on cancel, and surface
balances on the till, customer display, sales, and receipts.
2026-07-15 15:46:28 +00:00

191 lines
6.0 KiB
PHP

<?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,
];
}
}