Files
ladill-pos/app/Services/Pos/PosLoyaltyService.php
T
isaacclad 600aedb59d
Deploy Ladill POS / deploy (push) Successful in 31s
Email digital receipts, add promo settings, and apply tips at charge.
Send real receipt emails from the customer display and sale page, store
branch promo content for the idle screen, and fold selected tips into totals.
2026-07-15 16:10:20 +00:00

192 lines
6.1 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);
// Earn on net merchandise (exclude tip); tips are staff gratuity.
$amount = max(0, (int) $sale->subtotal_minor - (int) $sale->loyalty_discount_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,
];
}
}