From 600aedb59de13d7c68e8851bc792b73e397f7ad8 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 15 Jul 2026 16:10:20 +0000 Subject: [PATCH] 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. --- .../Controllers/Pos/RegisterController.php | 2 + app/Http/Controllers/Pos/SaleController.php | 23 +++ .../Controllers/Pos/SettingsController.php | 22 +++ .../Public/CustomerDisplayController.php | 45 ++++- app/Mail/DigitalReceiptMail.php | 48 +++++ app/Models/PosLocation.php | 22 +++ app/Models/PosSale.php | 2 + app/Services/Pos/CustomerDisplayService.php | 20 +- app/Services/Pos/PosLoyaltyService.php | 3 +- app/Services/Pos/PosSaleService.php | 26 ++- app/Services/Pos/ReceiptEmailService.php | 72 +++++++ ...0000_add_tip_and_customer_promo_fields.php | 36 ++++ .../views/emails/digital-receipt.blade.php | 86 ++++++++ .../views/pos/customer-display.blade.php | 14 +- resources/views/pos/receipts/print.blade.php | 6 + resources/views/pos/register.blade.php | 24 ++- resources/views/pos/sales/show.blade.php | 15 ++ resources/views/pos/settings.blade.php | 42 ++++ routes/web.php | 1 + tests/Feature/ReceiptTipPromoTest.php | 185 ++++++++++++++++++ 20 files changed, 675 insertions(+), 19 deletions(-) create mode 100644 app/Mail/DigitalReceiptMail.php create mode 100644 app/Services/Pos/ReceiptEmailService.php create mode 100644 database/migrations/2026_07_15_190000_add_tip_and_customer_promo_fields.php create mode 100644 resources/views/emails/digital-receipt.blade.php create mode 100644 tests/Feature/ReceiptTipPromoTest.php 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); + } +}