diff --git a/app/Http/Controllers/Pos/RegisterController.php b/app/Http/Controllers/Pos/RegisterController.php index 24a9886..b17840f 100644 --- a/app/Http/Controllers/Pos/RegisterController.php +++ b/app/Http/Controllers/Pos/RegisterController.php @@ -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() diff --git a/app/Models/PosSale.php b/app/Models/PosSale.php index 52c70d2..f969502 100644 --- a/app/Models/PosSale.php +++ b/app/Models/PosSale.php @@ -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', diff --git a/app/Services/Crm/CrmClient.php b/app/Services/Crm/CrmClient.php index 8e616b4..5b51bc0 100644 --- a/app/Services/Crm/CrmClient.php +++ b/app/Services/Crm/CrmClient.php @@ -62,6 +62,61 @@ class CrmClient return $this->post('timeline', $data); } + /** @return array */ + public function loyaltyProgram(): array + { + return $this->get('loyalty/program'); + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + public function loyaltyEarn(array $data): array + { + return $this->post('loyalty/earn', $data); + } + + /** + * @param array{external_ref: string, source?: string} $data + * @return array + */ + 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.'); } diff --git a/app/Services/Pos/PosLoyaltyService.php b/app/Services/Pos/PosLoyaltyService.php new file mode 100644 index 0000000..23bd644 --- /dev/null +++ b/app/Services/Pos/PosLoyaltyService.php @@ -0,0 +1,190 @@ +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|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|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|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, + ]; + } +} diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index 8b33122..e3908fe 100644 --- a/app/Services/Pos/PosSaleService.php +++ b/app/Services/Pos/PosSaleService.php @@ -19,11 +19,12 @@ class PosSaleService public function __construct( private MerchantGatewayService $gateway, private PosTimelineService $timeline, + private PosLoyaltyService $loyalty, ) {} /** * @param list $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'); } /** diff --git a/database/migrations/2026_07_15_180000_add_loyalty_to_pos_sales.php b/database/migrations/2026_07_15_180000_add_loyalty_to_pos_sales.php new file mode 100644 index 0000000..4f98778 --- /dev/null +++ b/database/migrations/2026_07_15_180000_add_loyalty_to_pos_sales.php @@ -0,0 +1,30 @@ +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', + ]); + }); + } +}; diff --git a/resources/views/pos/partials/customer-picker.blade.php b/resources/views/pos/partials/customer-picker.blade.php index e4d13e6..626f09e 100644 --- a/resources/views/pos/partials/customer-picker.blade.php +++ b/resources/views/pos/partials/customer-picker.blade.php @@ -1,28 +1,14 @@ @props(['customers' => []]) @if (!empty($customers)) -
+
- - +
@endif diff --git a/resources/views/pos/receipts/print.blade.php b/resources/views/pos/receipts/print.blade.php index 7d3d7a4..e90cfa9 100644 --- a/resources/views/pos/receipts/print.blade.php +++ b/resources/views/pos/receipts/print.blade.php @@ -133,10 +133,22 @@
+ @if ($sale->loyalty_discount_minor) + + + + + @endif + @if ($sale->loyalty_points_earned) + + + + + @endif diff --git a/resources/views/pos/register.blade.php b/resources/views/pos/register.blade.php index be32c31..ba3f418 100644 --- a/resources/views/pos/register.blade.php +++ b/resources/views/pos/register.blade.php @@ -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 @@
  • Cart is empty.
  • -
    +
    +
    + Loyalty discount + +
    Total - +
    @@ -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"> + +
    +
    +

    +

    + pts +

    +
    +

    + This sale earns ~ pts +

    +
    + +
    + + +
    +

    + Max pts + ( off) +

    +
    +
    @@ -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); diff --git a/resources/views/pos/sales/show.blade.php b/resources/views/pos/sales/show.blade.php index 7dfb55a..957b61f 100644 --- a/resources/views/pos/sales/show.blade.php +++ b/resources/views/pos/sales/show.blade.php @@ -27,6 +27,12 @@ @if ($sale->customer_name)
    Customer
    {{ $sale->customer_name }}
    @endif + @if ($sale->loyalty_discount_minor) +
    Loyalty discount
    −{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }} ({{ $sale->loyalty_points_redeemed }} pts)
    + @endif + @if ($sale->loyalty_points_earned) +
    Points earned
    +{{ $sale->loyalty_points_earned }} pts
    + @endif
    Loyalty−{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }}
    Total {{ pos_money($sale->total_minor, $sale->currency) }}
    Points earned+{{ $sale->loyalty_points_earned }}
    Payment {{ ucfirst($sale->payment_method) }}
    diff --git a/routes/web.php b/routes/web.php index 8100eb1..aa7e922 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/PosLoyaltyTest.php b/tests/Feature/PosLoyaltyTest.php new file mode 100644 index 0000000..59d7e57 --- /dev/null +++ b/tests/Feature/PosLoyaltyTest.php @@ -0,0 +1,168 @@ + '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']); + } +}