Deploy Ladill POS / deploy (push) Successful in 31s
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.
192 lines
6.1 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|