Email digital receipts, add promo settings, and apply tips at charge.
Deploy Ladill POS / deploy (push) Successful in 31s
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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('pos_sales', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Receipt {{ $sale->reference }}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f8fafc;font-family:Figtree,ui-sans-serif,system-ui,-apple-system,sans-serif;color:#0f172a;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;padding:24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:480px;background:#ffffff;border:1px solid #e2e8f0;border-radius:16px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="padding:24px 24px 8px;text-align:center;">
|
||||
@if ($location?->receiptLogoUrl())
|
||||
<img src="{{ $location->receiptLogoUrl() }}" alt="" width="72" height="72" style="display:block;margin:0 auto 12px;border-radius:12px;object-fit:contain;background:#f8fafc;">
|
||||
@endif
|
||||
<p style="margin:0;font-size:18px;font-weight:700;">
|
||||
{{ $location?->receipt_header ?: $location?->name ?: 'Receipt' }}
|
||||
</p>
|
||||
<p style="margin:8px 0 0;font-size:13px;color:#64748b;">
|
||||
{{ $sale->reference }} · {{ optional($sale->paid_at ?? $sale->created_at)->format('d M Y, H:i') }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px 0;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="font-size:14px;">
|
||||
@foreach ($sale->lines as $line)
|
||||
<tr>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f1f5f9;color:#334155;">
|
||||
{{ $line->quantity }}× {{ $line->name }}
|
||||
</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #f1f5f9;text-align:right;font-weight:600;white-space:nowrap;">
|
||||
{{ pos_money($line->line_total_minor, $sale->currency) }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 24px 24px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="font-size:14px;">
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#64748b;">Subtotal</td>
|
||||
<td style="padding:4px 0;text-align:right;">{{ pos_money($sale->subtotal_minor, $sale->currency) }}</td>
|
||||
</tr>
|
||||
@if ($sale->loyalty_discount_minor)
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#059669;">Loyalty</td>
|
||||
<td style="padding:4px 0;text-align:right;color:#059669;">−{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if ($sale->tip_minor)
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#64748b;">Tip</td>
|
||||
<td style="padding:4px 0;text-align:right;">{{ pos_money($sale->tip_minor, $sale->currency) }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<td style="padding:12px 0 0;font-size:18px;font-weight:700;">Total</td>
|
||||
<td style="padding:12px 0 0;text-align:right;font-size:18px;font-weight:700;color:#4f46e5;">
|
||||
{{ pos_money($sale->total_minor, $sale->currency) }}
|
||||
</td>
|
||||
</tr>
|
||||
@if ($sale->loyalty_points_earned)
|
||||
<tr>
|
||||
<td colspan="2" style="padding:12px 0 0;font-size:12px;color:#b45309;">
|
||||
You earned {{ $sale->loyalty_points_earned }} loyalty points on this visit.
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
@if ($location?->receipt_footer)
|
||||
<p style="margin:20px 0 0;font-size:12px;color:#94a3b8;text-align:center;">{{ $location->receipt_footer }}</p>
|
||||
@endif
|
||||
<p style="margin:16px 0 0;font-size:11px;color:#cbd5e1;text-align:center;">Powered by Ladill POS</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -228,7 +228,8 @@
|
||||
<button type="button" @click="sendReceiptEmail()"
|
||||
class="rounded-xl bg-indigo-500 px-4 py-2.5 text-sm font-semibold hover:bg-indigo-400">Send</button>
|
||||
</div>
|
||||
<p x-show="emailSent" class="mt-2 text-sm text-emerald-300">Request sent to the till.</p>
|
||||
<p x-show="emailSent" class="mt-2 text-sm text-emerald-300">Receipt emailed — check your inbox.</p>
|
||||
<p x-show="emailError" class="mt-2 text-sm text-rose-300" x-text="emailError"></p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-500" x-show="state.brand?.footer" x-text="state.brand.footer"></p>
|
||||
@@ -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 = 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() {
|
||||
|
||||
@@ -139,6 +139,12 @@
|
||||
<td class="amt">−{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if ($sale->tip_minor)
|
||||
<tr>
|
||||
<td colspan="2">Tip</td>
|
||||
<td class="amt">{{ pos_money($sale->tip_minor, $sale->currency) }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total</td>
|
||||
<td class="amt">{{ pos_money($sale->total_minor, $sale->currency) }}</td>
|
||||
|
||||
@@ -109,10 +109,15 @@
|
||||
<span class="text-emerald-600">Loyalty discount</span>
|
||||
<span class="font-medium text-emerald-700" x-text="'−' + formatMoney(loyaltyDiscountMinor)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm" x-show="tipMinor > 0">
|
||||
<span class="text-slate-500">Tip</span>
|
||||
<span class="font-medium text-slate-800" x-text="formatMoney(tipMinor)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-slate-500">Total</span>
|
||||
<span class="font-semibold text-slate-900" x-text="formatMoney(payableTotal())"></span>
|
||||
</div>
|
||||
<input type="hidden" name="tip_minor" :value="tipMinor || 0">
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
@@ -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 || '');
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
@if ($sale->loyalty_discount_minor)
|
||||
<div><dt class="text-slate-400">Loyalty discount</dt><dd class="text-emerald-700">−{{ pos_money($sale->loyalty_discount_minor, $sale->currency) }} ({{ $sale->loyalty_points_redeemed }} pts)</dd></div>
|
||||
@endif
|
||||
@if ($sale->tip_minor)
|
||||
<div><dt class="text-slate-400">Tip</dt><dd class="text-slate-800">{{ pos_money($sale->tip_minor, $sale->currency) }}</dd></div>
|
||||
@endif
|
||||
@if ($sale->loyalty_points_earned)
|
||||
<div><dt class="text-slate-400">Points earned</dt><dd class="text-amber-800">+{{ $sale->loyalty_points_earned }} pts</dd></div>
|
||||
@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
|
||||
</a>
|
||||
@if ($sale->isPaid())
|
||||
<form method="post" action="{{ route('pos.sales.email-receipt', $sale) }}" class="flex flex-wrap items-center gap-2">
|
||||
@csrf
|
||||
<input type="email" name="email" required
|
||||
value="{{ old('email', $sale->customer_email) }}"
|
||||
placeholder="customer@email.com"
|
||||
class="rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<button type="submit" class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">
|
||||
Email receipt
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($sale->isPaid() && !empty($invoiceUrl))
|
||||
|
||||
@@ -79,6 +79,48 @@
|
||||
<span class="mt-0.5 block text-xs text-slate-500">Opens the receipt print dialog when you record a cash payment.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="text-sm font-semibold text-slate-900">Customer screen promo</p>
|
||||
<p class="mt-1 text-xs text-slate-400">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.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="customer_promo_headline" class="text-sm font-medium text-slate-700">Promo headline</label>
|
||||
<input type="text" id="customer_promo_headline" name="customer_promo_headline" maxlength="160"
|
||||
value="{{ old('customer_promo_headline', $location->customer_promo_headline) }}"
|
||||
placeholder="{{ $location->name }}"
|
||||
class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="customer_promo_body" class="text-sm font-medium text-slate-700">Promo message</label>
|
||||
<textarea id="customer_promo_body" name="customer_promo_body" rows="2" maxlength="500"
|
||||
placeholder="Thank you for shopping with us."
|
||||
class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">{{ old('customer_promo_body', $location->customer_promo_body) }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Promo image</label>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Optional. Falls back to the receipt logo when empty.</p>
|
||||
<div class="mt-3 flex items-center gap-4">
|
||||
@if ($location->customer_promo_image_path || $location->receipt_logo_path)
|
||||
<img src="{{ $location->customerPromoImageUrl() }}?v={{ $location->updated_at?->timestamp }}"
|
||||
alt="Promo preview"
|
||||
class="h-16 w-16 rounded-xl border border-slate-200 bg-white object-contain p-1">
|
||||
@else
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-xl border border-dashed border-slate-300 text-[10px] text-slate-400">No image</div>
|
||||
@endif
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<input type="file" name="customer_promo_image" accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-indigo-700 hover:file:bg-indigo-100">
|
||||
@if ($location->customer_promo_image_path)
|
||||
<label class="inline-flex items-center gap-2 text-xs text-slate-500">
|
||||
<input type="checkbox" name="remove_customer_promo_image" value="1" class="rounded border-slate-300 text-indigo-600">
|
||||
Remove promo image
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="text-sm font-semibold text-slate-900">Payment gateway</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Connect Paystack, Flutterwave, or Hubtel. Online checkouts go 100% to you — 0% Ladill fee.</p>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Mail\DigitalReceiptMail;
|
||||
use App\Models\PosCustomerDisplay;
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosSale;
|
||||
use App\Models\User;
|
||||
use App\Services\Pos\CustomerDisplayService;
|
||||
use App\Services\Pos\PosSaleService;
|
||||
use App\Services\Pos\ReceiptEmailService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ReceiptTipPromoTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function owner(): User
|
||||
{
|
||||
return User::create([
|
||||
'public_id' => '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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user