Email digital receipts, add promo settings, and apply tips at charge.
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.
This commit is contained in:
isaacclad
2026-07-15 16:10:20 +00:00
parent 468346b183
commit 600aedb59d
20 changed files with 675 additions and 19 deletions
@@ -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') {
@@ -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);
@@ -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);
@@ -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()
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Mail;
use App\Models\PosSale;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class DigitalReceiptMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public PosSale $sale)
{
$this->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,
],
);
}
}
+22
View File
@@ -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');
+2
View File
@@ -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',
+15 -5
View File
@@ -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,
];
}
+2 -1
View File
@@ -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([
+22 -4
View File
@@ -20,11 +20,12 @@ class PosSaleService
private MerchantGatewayService $gateway,
private PosTimelineService $timeline,
private PosLoyaltyService $loyalty,
private ReceiptEmailService $receiptEmail,
) {}
/**
* @param list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int}> $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');
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Services\Pos;
use App\Mail\DigitalReceiptMail;
use App\Models\PosSale;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use RuntimeException;
class ReceiptEmailService
{
/**
* Email a digital receipt for a sale. Updates customer_email when empty.
*
* @return array{ok: bool, message: string}
*/
public function send(PosSale $sale, string $email): array
{
$email = strtolower(trim($email));
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('A valid email address is required.');
}
$sale->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;
}
}
}