diff --git a/app/Http/Controllers/Pos/RegisterController.php b/app/Http/Controllers/Pos/RegisterController.php index b17840f..19e1aa8 100644 --- a/app/Http/Controllers/Pos/RegisterController.php +++ b/app/Http/Controllers/Pos/RegisterController.php @@ -180,6 +180,7 @@ class RegisterController extends Controller 'customer_phone' => ['nullable', 'string', 'max:40'], 'crm_customer_id' => ['nullable', 'integer'], 'loyalty_redeem_points' => ['nullable', 'integer', 'min:0'], + 'tip_minor' => ['nullable', 'integer', 'min:0'], 'payment_method' => ['required', 'in:pay,cash'], ]); @@ -201,6 +202,7 @@ class RegisterController extends Controller 'customer_phone' => $data['customer_phone'] ?? null, 'crm_customer_id' => $data['crm_customer_id'] ?? null, 'loyalty_redeem_points' => (int) ($data['loyalty_redeem_points'] ?? 0), + 'tip_minor' => (int) ($data['tip_minor'] ?? 0), ]); if ($data['payment_method'] === 'cash') { diff --git a/app/Http/Controllers/Pos/SaleController.php b/app/Http/Controllers/Pos/SaleController.php index 6bda6b3..6c6e703 100644 --- a/app/Http/Controllers/Pos/SaleController.php +++ b/app/Http/Controllers/Pos/SaleController.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Pos\Concerns\ScopesToAccount; use App\Models\PosSale; use App\Services\CrossApp\CrossAppLinkService; use App\Services\Pos\PosSaleService; +use App\Services\Pos\ReceiptEmailService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -19,6 +20,7 @@ class SaleController extends Controller public function __construct( private PosSaleService $sales, private CrossAppLinkService $links, + private ReceiptEmailService $receiptEmail, ) {} public function index(Request $request): View @@ -69,6 +71,27 @@ class SaleController extends Controller return redirect()->route('pos.sales.show', $sale)->with('success', 'Sale cancelled.'); } + public function emailReceipt(Request $request, PosSale $sale): RedirectResponse + { + $this->authorizeOwner($request, $sale); + + if (! $sale->isPaid()) { + return back()->with('error', 'Only paid sales can be emailed as receipts.'); + } + + $data = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + ]); + + try { + $result = $this->receiptEmail->send($sale, $data['email']); + } catch (RuntimeException $e) { + return back()->with('error', $e->getMessage()); + } + + return back()->with('success', $result['message']); + } + public function destroy(Request $request, PosSale $sale): RedirectResponse { $this->authorizeOwner($request, $sale); diff --git a/app/Http/Controllers/Pos/SettingsController.php b/app/Http/Controllers/Pos/SettingsController.php index 1c197da..aa2ad3f 100644 --- a/app/Http/Controllers/Pos/SettingsController.php +++ b/app/Http/Controllers/Pos/SettingsController.php @@ -68,6 +68,10 @@ class SettingsController extends Controller 'remove_receipt_logo' => ['sometimes', 'boolean'], 'printer_paper_mm' => ['required', 'in:58,80'], 'printer_auto_print' => ['sometimes', 'boolean'], + 'customer_promo_headline' => ['nullable', 'string', 'max:160'], + 'customer_promo_body' => ['nullable', 'string', 'max:500'], + 'customer_promo_image' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'], + 'remove_customer_promo_image' => ['sometimes', 'boolean'], 'gateway_provider' => ['nullable', Rule::in([ PaymentGatewaySetting::PROVIDER_PAYSTACK, PaymentGatewaySetting::PROVIDER_FLUTTERWAVE, @@ -103,6 +107,21 @@ class SettingsController extends Controller ); } + if ($request->boolean('remove_customer_promo_image') && $location->customer_promo_image_path) { + Storage::disk('public')->delete($location->customer_promo_image_path); + $location->customer_promo_image_path = null; + } + + if ($request->hasFile('customer_promo_image')) { + if ($location->customer_promo_image_path) { + Storage::disk('public')->delete($location->customer_promo_image_path); + } + $location->customer_promo_image_path = $request->file('customer_promo_image')->store( + 'pos/customer-promo/'.$this->ownerRef($request), + 'public' + ); + } + $location->update([ 'name' => $data['name'], 'currency' => strtoupper($data['currency']), @@ -112,6 +131,9 @@ class SettingsController extends Controller 'printer_paper_mm' => (int) $data['printer_paper_mm'], 'printer_auto_print' => $request->boolean('printer_auto_print'), 'receipt_logo_path' => $location->receipt_logo_path, + 'customer_promo_headline' => $data['customer_promo_headline'] ?? null, + 'customer_promo_body' => $data['customer_promo_body'] ?? null, + 'customer_promo_image_path' => $location->customer_promo_image_path, ]); $this->persistGateway($request, $user); diff --git a/app/Http/Controllers/Public/CustomerDisplayController.php b/app/Http/Controllers/Public/CustomerDisplayController.php index 8b27759..3aa5b69 100644 --- a/app/Http/Controllers/Public/CustomerDisplayController.php +++ b/app/Http/Controllers/Public/CustomerDisplayController.php @@ -4,15 +4,21 @@ namespace App\Http\Controllers\Public; use App\Http\Controllers\Controller; use App\Models\PosCustomerDisplay; +use App\Models\PosSale; use App\Services\Pos\CustomerDisplayService; +use App\Services\Pos\ReceiptEmailService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; +use RuntimeException; use Symfony\Component\HttpFoundation\StreamedResponse; class CustomerDisplayController extends Controller { - public function __construct(private CustomerDisplayService $displays) {} + public function __construct( + private CustomerDisplayService $displays, + private ReceiptEmailService $receiptEmail, + ) {} public function show(string $token): View { @@ -122,6 +128,9 @@ class CustomerDisplayController extends Controller if ($data['type'] === 'receipt_email') { $action['email'] = $data['email'] ?? null; + $emailResult = $this->emailReceiptForDisplay($display, (string) ($data['email'] ?? '')); + $action['email_sent'] = $emailResult['ok']; + $action['email_message'] = $emailResult['message']; } if ($data['type'] === 'payment_option') { @@ -133,9 +142,43 @@ class CustomerDisplayController extends Controller return response()->json([ 'ok' => true, 'state' => $this->displays->publicState($display->fresh()), + 'email_sent' => $action['email_sent'] ?? null, + 'message' => $action['email_message'] ?? null, ]); } + /** @return array{ok: bool, message: string} */ + private function emailReceiptForDisplay(PosCustomerDisplay $display, string $email): array + { + $payload = is_array($display->payload) ? $display->payload : []; + $saleId = (int) ($payload['sale_id'] ?? $payload['receipt']['sale_id'] ?? 0); + $reference = (string) ($payload['sale_reference'] ?? $payload['receipt']['reference'] ?? ''); + + $sale = null; + if ($saleId > 0) { + $sale = PosSale::query() + ->where('id', $saleId) + ->where('location_id', $display->location_id) + ->first(); + } + if (! $sale && $reference !== '') { + $sale = PosSale::query() + ->where('reference', $reference) + ->where('location_id', $display->location_id) + ->first(); + } + + if (! $sale) { + return ['ok' => false, 'message' => 'No receipt is ready to email yet.']; + } + + try { + return $this->receiptEmail->send($sale, $email); + } catch (RuntimeException $e) { + return ['ok' => false, 'message' => $e->getMessage()]; + } + } + private function find(string $token): PosCustomerDisplay { return PosCustomerDisplay::query() diff --git a/app/Mail/DigitalReceiptMail.php b/app/Mail/DigitalReceiptMail.php new file mode 100644 index 0000000..a4efe5d --- /dev/null +++ b/app/Mail/DigitalReceiptMail.php @@ -0,0 +1,48 @@ +sale->loadMissing(['lines', 'location']); + } + + public function envelope(): Envelope + { + $location = $this->sale->location; + $fromName = $location?->receipt_header + ?: $location?->name + ?: (string) config('mail.from.name', 'Ladill POS'); + + return new Envelope( + from: new Address( + (string) config('mail.from.address', 'receipts@ladill.com'), + $fromName, + ), + subject: 'Your receipt '.$this->sale->reference.' from '.$fromName, + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.digital-receipt', + with: [ + 'sale' => $this->sale, + 'location' => $this->sale->location, + ], + ); + } +} diff --git a/app/Models/PosLocation.php b/app/Models/PosLocation.php index 54b69ba..d4b4d6b 100644 --- a/app/Models/PosLocation.php +++ b/app/Models/PosLocation.php @@ -24,6 +24,9 @@ class PosLocation extends Model 'receipt_logo_path', 'printer_paper_mm', 'printer_auto_print', + 'customer_promo_headline', + 'customer_promo_body', + 'customer_promo_image_path', ]; protected function casts(): array @@ -49,6 +52,25 @@ class PosLocation extends Model return Storage::disk('public')->url($this->receipt_logo_path); } + public function customerPromoImageUrl(): ?string + { + if ($this->customer_promo_image_path) { + return Storage::disk('public')->url($this->customer_promo_image_path); + } + + return $this->receiptLogoUrl(); + } + + /** Default idle-screen promo for the customer-facing display. */ + public function customerPromo(): array + { + return [ + 'headline' => $this->customer_promo_headline ?: $this->name, + 'body' => $this->customer_promo_body ?: 'Thank you for shopping with us.', + 'image_url' => $this->customerPromoImageUrl(), + ]; + } + public function products(): HasMany { return $this->hasMany(PosProduct::class, 'location_id'); diff --git a/app/Models/PosSale.php b/app/Models/PosSale.php index f969502..f5bd717 100644 --- a/app/Models/PosSale.php +++ b/app/Models/PosSale.php @@ -56,6 +56,7 @@ class PosSale extends Model 'loyalty_points_redeemed', 'loyalty_points_earned', 'loyalty_external_ref', + 'tip_minor', 'total_minor', 'currency', 'paid_at', @@ -71,6 +72,7 @@ class PosSale extends Model 'loyalty_discount_minor' => 'integer', 'loyalty_points_redeemed' => 'integer', 'loyalty_points_earned' => 'integer', + 'tip_minor' => 'integer', 'total_minor' => 'integer', 'pay_order_id' => 'integer', 'crm_customer_id' => 'integer', diff --git a/app/Services/Pos/CustomerDisplayService.php b/app/Services/Pos/CustomerDisplayService.php index 3b98aaf..478d536 100644 --- a/app/Services/Pos/CustomerDisplayService.php +++ b/app/Services/Pos/CustomerDisplayService.php @@ -130,16 +130,24 @@ class CustomerDisplayService return $this->push($location, PosCustomerDisplay::PHASE_RECEIPT, array_merge([ 'lines' => $this->linesFromSale($sale), 'subtotal_minor' => (int) $sale->subtotal_minor, + 'discount_minor' => (int) $sale->loyalty_discount_minor, + 'tip_minor' => (int) $sale->tip_minor, 'total_minor' => (int) $sale->total_minor, 'currency' => $sale->currency, 'receipt' => [ 'reference' => $sale->reference, + 'sale_id' => $sale->id, 'total_minor' => (int) $sale->total_minor, 'digital_url' => $extras['digital_url'] ?? null, 'message' => $extras['message'] ?? 'Thank you for your purchase', 'email_prompt' => true, ], 'sale_reference' => $sale->reference, + 'sale_id' => $sale->id, + 'tip' => [ + 'selected_minor' => (int) $sale->tip_minor, + 'base_minor' => max(0, (int) $sale->subtotal_minor - (int) $sale->loyalty_discount_minor), + ], ], $extras)); } @@ -187,6 +195,7 @@ class CustomerDisplayService 'subtotal_minor' => (int) ($payload['subtotal_minor'] ?? 0), 'discount_minor' => (int) ($payload['discount_minor'] ?? 0), 'tax_minor' => (int) ($payload['tax_minor'] ?? 0), + 'tip_minor' => (int) ($payload['tip_minor'] ?? ($payload['tip']['selected_minor'] ?? 0)), 'total_minor' => (int) ($payload['total_minor'] ?? 0), 'loyalty' => $payload['loyalty'] ?? null, 'payment' => $payload['payment'] ?? null, @@ -196,6 +205,7 @@ class CustomerDisplayService 'promo' => $payload['promo'] ?? ($this->defaultPayload($location ?? new PosLocation)['promo'] ?? null), 'customer_name' => $payload['customer_name'] ?? null, 'sale_reference' => $payload['sale_reference'] ?? null, + 'sale_id' => $payload['sale_id'] ?? ($payload['receipt']['sale_id'] ?? null), 'message' => $payload['message'] ?? null, ]; } @@ -206,6 +216,9 @@ class CustomerDisplayService public function defaultPayload(?PosLocation $location): array { $name = $location?->name ?? 'Welcome'; + $promo = $location + ? $location->customerPromo() + : ['headline' => $name, 'body' => 'Thank you for shopping with us.', 'image_url' => null]; return [ 'location_name' => $name, @@ -214,6 +227,7 @@ class CustomerDisplayService 'subtotal_minor' => 0, 'discount_minor' => 0, 'tax_minor' => 0, + 'tip_minor' => 0, 'total_minor' => 0, 'loyalty' => null, 'payment' => null, @@ -231,11 +245,7 @@ class CustomerDisplayService 'prompt' => 'Please sign below', ], 'receipt' => null, - 'promo' => [ - 'headline' => $name, - 'body' => 'Thank you for shopping with us.', - 'image_url' => $location?->receiptLogoUrl(), - ], + 'promo' => $promo, 'message' => null, ]; } diff --git a/app/Services/Pos/PosLoyaltyService.php b/app/Services/Pos/PosLoyaltyService.php index 23bd644..466d089 100644 --- a/app/Services/Pos/PosLoyaltyService.php +++ b/app/Services/Pos/PosLoyaltyService.php @@ -110,7 +110,8 @@ class PosLoyaltyService } $externalRef = $sale->loyalty_external_ref ?: ('pos-sale-'.$sale->id.'-'.$sale->reference); - $amount = (int) $sale->total_minor; + // 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([ diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index e3908fe..b90d963 100644 --- a/app/Services/Pos/PosSaleService.php +++ b/app/Services/Pos/PosSaleService.php @@ -20,11 +20,12 @@ class PosSaleService private MerchantGatewayService $gateway, private PosTimelineService $timeline, private PosLoyaltyService $loyalty, + private ReceiptEmailService $receiptEmail, ) {} /** * @param list $lines - * @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 + * @param array{customer_name?: ?string, customer_email?: ?string, customer_phone?: ?string, crm_customer_id?: ?int, location_id?: ?int, currency?: string, loyalty_redeem_points?: int, tip_minor?: int} $meta */ public function createSale(User $merchant, array $lines, array $meta = []): PosSale { @@ -39,6 +40,7 @@ class PosSaleService } $currency = strtoupper((string) ($meta['currency'] ?? config('pos.default_currency', 'GHS'))); + $tipMinor = max(0, (int) ($meta['tip_minor'] ?? 0)); $sale = PosSale::create([ 'owner_ref' => $merchant->public_id, @@ -54,7 +56,8 @@ class PosSaleService 'loyalty_discount_minor' => 0, 'loyalty_points_redeemed' => 0, 'loyalty_points_earned' => 0, - 'total_minor' => $subtotal, + 'tip_minor' => $tipMinor, + 'total_minor' => $subtotal + $tipMinor, 'currency' => $currency, ]); @@ -89,7 +92,18 @@ class PosSaleService '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), + 'total_minor' => max(0, (int) $sale->subtotal_minor - $discount) + max(0, (int) $sale->tip_minor), + ])->save(); + + return $sale->fresh('lines'); + } + + /** Recompute total from subtotal, loyalty discount, and tip. */ + public function recomputeTotal(PosSale $sale): PosSale + { + $sale->forceFill([ + 'total_minor' => max(0, (int) $sale->subtotal_minor - (int) $sale->loyalty_discount_minor) + + max(0, (int) $sale->tip_minor), ])->save(); return $sale->fresh('lines'); @@ -583,6 +597,7 @@ class PosSaleService $sale = $sale->fresh('lines'); $this->loyalty->earnForSale($sale); $this->timeline->pushPaidSale($sale); + $this->receiptEmail->sendIfPresent($sale->fresh()); return $sale->fresh('lines'); } @@ -599,9 +614,11 @@ class PosSaleService throw new RuntimeException('Payment was not completed.'); } + // Keep tip/loyalty total; gateway amount should match charged total. + $paidAmount = (int) ($result['amount_minor'] ?: $sale->total_minor); $sale->forceFill([ 'status' => PosSale::STATUS_PAID, - 'total_minor' => (int) ($result['amount_minor'] ?: $sale->total_minor), + 'total_minor' => $paidAmount > 0 ? $paidAmount : (int) $sale->total_minor, 'paid_at' => now(), ])->save(); @@ -610,6 +627,7 @@ class PosSaleService $sale = $sale->fresh('lines'); $this->loyalty->earnForSale($sale); $this->timeline->pushPaidSale($sale); + $this->receiptEmail->sendIfPresent($sale->fresh()); return $sale->fresh('lines'); } diff --git a/app/Services/Pos/ReceiptEmailService.php b/app/Services/Pos/ReceiptEmailService.php new file mode 100644 index 0000000..1ec1ad0 --- /dev/null +++ b/app/Services/Pos/ReceiptEmailService.php @@ -0,0 +1,72 @@ +loadMissing(['lines', 'location']); + + if (! $sale->customer_email) { + $sale->forceFill(['customer_email' => $email])->save(); + } + + try { + Mail::to($email)->send(new DigitalReceiptMail($sale)); + } catch (\Throwable $e) { + Log::warning('pos.receipt_email_failed', [ + 'sale_id' => $sale->id, + 'email' => $email, + 'error' => $e->getMessage(), + ]); + + throw new RuntimeException('Could not send the receipt email. Please try again.'); + } + + return [ + 'ok' => true, + 'message' => 'Receipt sent to '.$email, + ]; + } + + /** + * Best-effort send after a paid sale when an email is already on the sale. + */ + public function sendIfPresent(PosSale $sale): bool + { + $email = trim((string) ($sale->customer_email ?? '')); + if ($email === '') { + return false; + } + + try { + $this->send($sale, $email); + + return true; + } catch (\Throwable $e) { + Log::info('pos.receipt_email_skipped', [ + 'sale_id' => $sale->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } +} diff --git a/database/migrations/2026_07_15_190000_add_tip_and_customer_promo_fields.php b/database/migrations/2026_07_15_190000_add_tip_and_customer_promo_fields.php new file mode 100644 index 0000000..dcb1846 --- /dev/null +++ b/database/migrations/2026_07_15_190000_add_tip_and_customer_promo_fields.php @@ -0,0 +1,36 @@ +unsignedInteger('tip_minor')->default(0)->after('loyalty_points_earned'); + }); + + Schema::table('pos_locations', function (Blueprint $table) { + $table->string('customer_promo_headline', 160)->nullable()->after('receipt_logo_path'); + $table->string('customer_promo_body', 500)->nullable()->after('customer_promo_headline'); + $table->string('customer_promo_image_path')->nullable()->after('customer_promo_body'); + }); + } + + public function down(): void + { + Schema::table('pos_sales', function (Blueprint $table) { + $table->dropColumn('tip_minor'); + }); + + Schema::table('pos_locations', function (Blueprint $table) { + $table->dropColumn([ + 'customer_promo_headline', + 'customer_promo_body', + 'customer_promo_image_path', + ]); + }); + } +}; diff --git a/resources/views/emails/digital-receipt.blade.php b/resources/views/emails/digital-receipt.blade.php new file mode 100644 index 0000000..b85cd3a --- /dev/null +++ b/resources/views/emails/digital-receipt.blade.php @@ -0,0 +1,86 @@ + + + + + + Receipt {{ $sale->reference }} + + + + + + +
+ + + + + + + + + + +
+ @if ($location?->receiptLogoUrl()) + + @endif +

+ {{ $location?->receipt_header ?: $location?->name ?: 'Receipt' }} +

+

+ {{ $sale->reference }} · {{ optional($sale->paid_at ?? $sale->created_at)->format('d M Y, H:i') }} +

+
+ + @foreach ($sale->lines as $line) + + + + + @endforeach +
+ {{ $line->quantity }}× {{ $line->name }} + + {{ pos_money($line->line_total_minor, $sale->currency) }} +
+
+ + + + + + @if ($sale->loyalty_discount_minor) + + + + + @endif + @if ($sale->tip_minor) + + + + + @endif + + + + + @if ($sale->loyalty_points_earned) + + + + @endif +
Subtotal{{ pos_money($sale->subtotal_minor, $sale->currency) }}
Loyalty−{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }}
Tip{{ pos_money($sale->tip_minor, $sale->currency) }}
Total + {{ pos_money($sale->total_minor, $sale->currency) }} +
+ You earned {{ $sale->loyalty_points_earned }} loyalty points on this visit. +
+ @if ($location?->receipt_footer) +

{{ $location->receipt_footer }}

+ @endif +

Powered by Ladill POS

+
+
+ + diff --git a/resources/views/pos/customer-display.blade.php b/resources/views/pos/customer-display.blade.php index 69c0028..7f7eb89 100644 --- a/resources/views/pos/customer-display.blade.php +++ b/resources/views/pos/customer-display.blade.php @@ -228,7 +228,8 @@ -

Request sent to the till.

+

Receipt emailed — check your inbox.

+

@@ -248,6 +249,7 @@ customTip: '', receiptEmail: '', emailSent: false, + emailError: '', sigSaved: false, drawing: false, clockTimer: null, @@ -373,8 +375,14 @@ async sendReceiptEmail() { if (!this.receiptEmail) return; - await this.postAction({ type: 'receipt_email', email: this.receiptEmail }); - this.emailSent = true; + this.emailSent = false; + this.emailError = ''; + const data = await this.postAction({ type: 'receipt_email', email: this.receiptEmail }); + if (data.email_sent) { + this.emailSent = true; + } else { + this.emailError = data.message || 'Could not send email.'; + } }, resizeCanvas() { diff --git a/resources/views/pos/receipts/print.blade.php b/resources/views/pos/receipts/print.blade.php index e90cfa9..359155e 100644 --- a/resources/views/pos/receipts/print.blade.php +++ b/resources/views/pos/receipts/print.blade.php @@ -139,6 +139,12 @@ −{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }} @endif + @if ($sale->tip_minor) + + Tip + {{ pos_money($sale->tip_minor, $sale->currency) }} + + @endif Total {{ pos_money($sale->total_minor, $sale->currency) }} diff --git a/resources/views/pos/register.blade.php b/resources/views/pos/register.blade.php index ba3f418..05d0e46 100644 --- a/resources/views/pos/register.blade.php +++ b/resources/views/pos/register.blade.php @@ -109,10 +109,15 @@ Loyalty discount +
+ Tip + +
Total
+
@@ -197,6 +202,8 @@ loyaltyMaxRedeem: 0, loyaltyDiscountMinor: 0, loyaltyQuoteTimer: null, + tipMinor: 0, + tipPercent: null, isRestaurant: !!config.isRestaurant, currency: config.currency || 'GHS', cart: [], @@ -280,7 +287,7 @@ } catch (_) {} }, payableTotal() { - return Math.max(0, this.cartTotal() - (this.loyaltyDiscountMinor || 0)); + return Math.max(0, this.cartTotal() - (this.loyaltyDiscountMinor || 0)) + (this.tipMinor || 0); }, onGlobalKeydown(e) { if (this.shouldIgnoreScan(e)) return; @@ -529,16 +536,23 @@ const action = data.action; if (!action?.type) return; if (action.type === 'tip') { - const tip = action.tip_minor != null - ? (this.currency + ' ' + (Number(action.tip_minor) / 100).toFixed(2)) + this.tipMinor = Number(action.tip_minor) || 0; + this.tipPercent = action.tip_percent ?? null; + const tip = this.tipMinor + ? (this.currency + ' ' + (this.tipMinor / 100).toFixed(2)) : ((action.tip_percent ?? 0) + '%'); - this.customerActionNote = 'Customer selected tip: ' + tip; + this.customerActionNote = this.tipMinor + ? ('Tip applied: ' + tip + ' — will be added at charge.') + : 'Customer declined tip.'; + this.scheduleDisplayPush(); } else if (action.type === 'signature') { this.customerActionNote = 'Customer signature captured.'; } else if (action.type === 'receipt_email' && action.email) { - this.customerActionNote = 'Customer requested receipt email: ' + action.email; const emailInput = document.getElementById('customer_email'); if (emailInput && !emailInput.value) emailInput.value = action.email; + this.customerActionNote = action.email_sent + ? ('Receipt emailed to ' + action.email) + : (action.email_message || ('Receipt email: ' + action.email)); } else if (action.type === 'payment_option') { this.customerActionNote = 'Customer chose payment: ' + (action.payment_option || ''); } diff --git a/resources/views/pos/sales/show.blade.php b/resources/views/pos/sales/show.blade.php index 957b61f..091ee7c 100644 --- a/resources/views/pos/sales/show.blade.php +++ b/resources/views/pos/sales/show.blade.php @@ -30,6 +30,9 @@ @if ($sale->loyalty_discount_minor)
Loyalty discount
−{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }} ({{ $sale->loyalty_points_redeemed }} pts)
@endif + @if ($sale->tip_minor) +
Tip
{{ pos_money($sale->tip_minor, $sale->currency) }}
+ @endif @if ($sale->loyalty_points_earned)
Points earned
+{{ $sale->loyalty_points_earned }} pts
@endif @@ -59,6 +62,18 @@ class="inline-flex items-center justify-center rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800"> Print receipt + @if ($sale->isPaid()) +
+ @csrf + + +
+ @endif
@if ($sale->isPaid() && !empty($invoiceUrl)) diff --git a/resources/views/pos/settings.blade.php b/resources/views/pos/settings.blade.php index 1fd34b4..153236c 100644 --- a/resources/views/pos/settings.blade.php +++ b/resources/views/pos/settings.blade.php @@ -79,6 +79,48 @@ Opens the receipt print dialog when you record a cash payment. + +
+

Customer screen promo

+

Shown on the dual-screen customer display when the cart is idle. Leave blank to use the branch name and a default thank-you message.

+
+
+ + +
+
+ + +
+
+ +

Optional. Falls back to the receipt logo when empty.

+
+ @if ($location->customer_promo_image_path || $location->receipt_logo_path) + Promo preview + @else +
No image
+ @endif +
+ + @if ($location->customer_promo_image_path) + + @endif +
+
+
+

Payment gateway

Connect Paystack, Flutterwave, or Hubtel. Online checkouts go 100% to you — 0% Ladill fee.

diff --git a/routes/web.php b/routes/web.php index aa7e922..a4e7562 100644 --- a/routes/web.php +++ b/routes/web.php @@ -89,6 +89,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/sales', [SaleController::class, 'index'])->name('pos.sales.index'); Route::get('/sales/{sale}/receipt', [SaleController::class, 'receipt'])->name('pos.sales.receipt'); Route::get('/sales/{sale}', [SaleController::class, 'show'])->name('pos.sales.show'); + Route::post('/sales/{sale}/email-receipt', [SaleController::class, 'emailReceipt'])->name('pos.sales.email-receipt'); Route::post('/sales/{sale}/cancel', [SaleController::class, 'cancel'])->name('pos.sales.cancel'); Route::delete('/sales/{sale}', [SaleController::class, 'destroy'])->name('pos.sales.destroy'); diff --git a/tests/Feature/ReceiptTipPromoTest.php b/tests/Feature/ReceiptTipPromoTest.php new file mode 100644 index 0000000..1634b5f --- /dev/null +++ b/tests/Feature/ReceiptTipPromoTest.php @@ -0,0 +1,185 @@ + 'owner-rtp', + 'name' => 'Owner', + 'email' => 'owner-rtp@example.com', + ]); + } + + private function location(User $owner): PosLocation + { + return PosLocation::create([ + 'owner_ref' => $owner->public_id, + 'name' => 'Front counter', + 'currency' => 'GHS', + 'is_default' => true, + ]); + } + + protected function setUp(): void + { + parent::setUp(); + $this->withoutMiddleware(EnsurePlatformSession::class); + $this->withoutVite(); + } + + public function test_tip_is_applied_to_sale_total_on_create(): void + { + $owner = $this->owner(); + $this->location($owner); + + $sale = app(PosSaleService::class)->createSale($owner, [ + ['name' => 'Meal', 'unit_price_minor' => 10000, 'quantity' => 1], + ], [ + 'tip_minor' => 1500, + 'currency' => 'GHS', + ]); + + $this->assertSame(10000, $sale->subtotal_minor); + $this->assertSame(1500, $sale->tip_minor); + $this->assertSame(11500, $sale->total_minor); + } + + public function test_digital_receipt_email_is_sent(): void + { + Mail::fake(); + + $owner = $this->owner(); + $location = $this->location($owner); + $sale = PosSale::create([ + 'owner_ref' => $owner->public_id, + 'location_id' => $location->id, + 'reference' => 'POS-TESTRECEIPT1', + 'status' => PosSale::STATUS_PAID, + 'payment_method' => PosSale::METHOD_CASH, + 'subtotal_minor' => 500, + 'total_minor' => 500, + 'currency' => 'GHS', + 'paid_at' => now(), + ]); + + $result = app(ReceiptEmailService::class)->send($sale, 'guest@example.com'); + + $this->assertTrue($result['ok']); + Mail::assertSent(DigitalReceiptMail::class, function (DigitalReceiptMail $mail) use ($sale) { + return $mail->sale->is($sale) + && $mail->hasTo('guest@example.com'); + }); + $this->assertSame('guest@example.com', $sale->fresh()->customer_email); + } + + public function test_customer_display_receipt_email_action_sends_mail(): void + { + Mail::fake(); + + $owner = $this->owner(); + $location = $this->location($owner); + $sale = PosSale::create([ + 'owner_ref' => $owner->public_id, + 'location_id' => $location->id, + 'reference' => 'POS-TESTRECEIPT2', + 'status' => PosSale::STATUS_PAID, + 'payment_method' => PosSale::METHOD_CASH, + 'subtotal_minor' => 800, + 'total_minor' => 800, + 'currency' => 'GHS', + 'paid_at' => now(), + ]); + + $display = app(CustomerDisplayService::class)->pushReceipt($location, $sale); + + $this->postJson(route('pos.customer-display.action', ['token' => $display->token]), [ + 'type' => 'receipt_email', + 'email' => 'buyer@example.com', + ]) + ->assertOk() + ->assertJsonPath('email_sent', true); + + Mail::assertSent(DigitalReceiptMail::class); + } + + public function test_settings_save_customer_promo_content(): void + { + Storage::fake('public'); + + $owner = $this->owner(); + $this->location($owner); + + $this->actingAs($owner) + ->put(route('pos.settings.update'), [ + 'name' => 'Front counter', + 'currency' => 'GHS', + 'service_style' => 'retail', + 'receipt_footer' => '', + 'receipt_header' => 'Demo Shop', + 'printer_paper_mm' => 80, + 'customer_promo_headline' => 'Happy hour', + 'customer_promo_body' => 'Buy one get one free until 6pm.', + 'customer_promo_image' => UploadedFile::fake()->image('promo.jpg'), + 'gateway_provider' => '', + ]) + ->assertRedirect(); + + $location = PosLocation::query()->first(); + $this->assertSame('Happy hour', $location->customer_promo_headline); + $this->assertSame('Buy one get one free until 6pm.', $location->customer_promo_body); + $this->assertNotNull($location->customer_promo_image_path); + + $promo = $location->customerPromo(); + $this->assertSame('Happy hour', $promo['headline']); + $this->assertNotNull($promo['image_url']); + + $display = app(CustomerDisplayService::class)->ensureForLocation($location); + $state = app(CustomerDisplayService::class)->publicState($display); + $this->assertSame('Happy hour', $state['promo']['headline']); + } + + public function test_operator_can_email_receipt_from_sale_page(): void + { + Mail::fake(); + + $owner = $this->owner(); + $location = $this->location($owner); + $sale = PosSale::create([ + 'owner_ref' => $owner->public_id, + 'location_id' => $location->id, + 'reference' => 'POS-TESTRECEIPT3', + 'status' => PosSale::STATUS_PAID, + 'payment_method' => PosSale::METHOD_CASH, + 'subtotal_minor' => 300, + 'total_minor' => 300, + 'currency' => 'GHS', + 'paid_at' => now(), + ]); + + $this->actingAs($owner) + ->post(route('pos.sales.email-receipt', $sale), ['email' => 'print@example.com']) + ->assertRedirect(); + + Mail::assertSent(DigitalReceiptMail::class); + } +}