Files
ladill-pos/app/Services/Pos/PosSaleService.php
T
isaacclad 600aedb59d
Deploy Ladill POS / deploy (push) Successful in 31s
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.
2026-07-15 16:10:20 +00:00

664 lines
24 KiB
PHP

<?php
namespace App\Services\Pos;
use App\Models\PosLocation;
use App\Models\PosPayment;
use App\Models\PosProduct;
use App\Models\PosSale;
use App\Models\PosSaleLine;
use App\Models\PosSaleLineModifier;
use App\Models\PosTable;
use App\Models\User;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Support\Str;
use RuntimeException;
class PosSaleService
{
public function __construct(
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, tip_minor?: int} $meta
*/
public function createSale(User $merchant, array $lines, array $meta = []): PosSale
{
$normalized = $this->normalizeLines($lines);
if ($normalized === []) {
throw new RuntimeException('Add at least one item to the sale.');
}
$subtotal = collect($normalized)->sum('line_total_minor');
if ($subtotal <= 0) {
throw new RuntimeException('Sale total must be greater than zero.');
}
$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,
'location_id' => $meta['location_id'] ?? null,
'reference' => 'POS-'.strtoupper(Str::random(12)),
'status' => PosSale::STATUS_PENDING,
'payment_method' => PosSale::METHOD_PAY,
'customer_name' => $meta['customer_name'] ?? null,
'customer_email' => $meta['customer_email'] ?? null,
'customer_phone' => $meta['customer_phone'] ?? null,
'crm_customer_id' => $meta['crm_customer_id'] ?? null,
'subtotal_minor' => $subtotal,
'loyalty_discount_minor' => 0,
'loyalty_points_redeemed' => 0,
'loyalty_points_earned' => 0,
'tip_minor' => $tipMinor,
'total_minor' => $subtotal + $tipMinor,
'currency' => $currency,
]);
foreach ($normalized as $index => $line) {
PosSaleLine::create([
'pos_sale_id' => $sale->id,
'product_id' => $line['product_id'] ?? null,
'name' => $line['name'],
'unit_price_minor' => $line['unit_price_minor'],
'quantity' => $line['quantity'],
'line_total_minor' => $line['line_total_minor'],
'position' => $index,
]);
}
$sale = $sale->fresh('lines');
$redeemPoints = (int) ($meta['loyalty_redeem_points'] ?? 0);
if ($redeemPoints > 0 && $sale->crm_customer_id) {
$sale = $this->applyLoyaltyRedeem($sale, $redeemPoints);
}
return $sale;
}
public function applyLoyaltyRedeem(PosSale $sale, int $redeemPoints): PosSale
{
$result = $this->loyalty->redeemForSale($sale, $redeemPoints);
$discount = (int) $result['discount_minor'];
$points = (int) $result['points'];
$sale->forceFill([
'loyalty_discount_minor' => $discount,
'loyalty_points_redeemed' => $points,
'loyalty_external_ref' => $result['external_ref'] ?: $sale->loyalty_external_ref,
'total_minor' => max(0, (int) $sale->subtotal_minor - $discount) + 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');
}
/**
* Open an empty restaurant ticket (a tab) that stays pending while items are
* added. Occupies the table for dine-in orders.
*
* @param array{order_type?: string, table_id?: ?int, location_id?: ?int, currency?: string, covers?: ?int, customer_name?: ?string} $meta
*/
public function openTicket(User $merchant, array $meta = []): PosSale
{
$orderType = in_array($meta['order_type'] ?? '', [PosSale::ORDER_DINE_IN, PosSale::ORDER_TAKEAWAY, PosSale::ORDER_COUNTER], true)
? $meta['order_type']
: PosSale::ORDER_COUNTER;
$table = null;
if ($orderType === PosSale::ORDER_DINE_IN && ! empty($meta['table_id'])) {
$table = PosTable::owned($merchant->public_id)->find($meta['table_id']);
if ($table && ! $table->isFree()) {
throw new RuntimeException('That table already has an open ticket.');
}
}
$sale = PosSale::create([
'owner_ref' => $merchant->public_id,
'location_id' => $meta['location_id'] ?? null,
'table_id' => $table?->id,
'reference' => 'POS-'.strtoupper(Str::random(12)),
'status' => PosSale::STATUS_PENDING,
'payment_method' => PosSale::METHOD_PAY,
'order_type' => $orderType,
'kitchen_status' => PosSale::KITCHEN_NONE,
'covers' => $meta['covers'] ?? null,
'customer_name' => $meta['customer_name'] ?? null,
'subtotal_minor' => 0,
'total_minor' => 0,
'currency' => strtoupper((string) ($meta['currency'] ?? config('pos.default_currency', 'GHS'))),
'opened_at' => now(),
]);
if ($table) {
$table->forceFill([
'status' => PosTable::STATUS_OCCUPIED,
'current_sale_id' => $sale->id,
])->save();
}
return $sale->fresh('lines');
}
/**
* Resolve a catalog product (owner-scoped) + chosen modifiers into an addLine
* payload. Effective price = base + modifier deltas; null if product unknown.
*
* @param list<int|string> $modifierIds
* @return array<string,mixed>|null
*/
public function buildProductLine(string $owner, int $productId, array $modifierIds = [], int $quantity = 1, ?string $notes = null, ?string $course = null, string $source = 'staff'): ?array
{
$product = PosProduct::owned($owner)->with('modifierGroups.modifiers')->find($productId);
if (! $product) {
return null;
}
$allowed = $product->modifierGroups->flatMap->modifiers->keyBy('id');
$chosen = collect($modifierIds)->map(fn ($id) => $allowed->get((int) $id))->filter()->values();
return [
'product_id' => $product->id,
'name' => $product->name,
'station_id' => $product->station_id,
'course' => $course ?: $product->course,
'quantity' => max(1, $quantity),
'notes' => $notes,
'source' => $source,
'unit_price_minor' => max(1, $product->price_minor + (int) $chosen->sum('price_delta_minor')),
'modifiers' => $chosen->map(fn ($m) => [
'modifier_id' => $m->id, 'name' => $m->name, 'price_delta_minor' => $m->price_delta_minor,
])->all(),
];
}
/**
* @param array{product_id?: ?int, station_id?: ?int, course?: ?string, source?: string, name: string, unit_price_minor: int, quantity?: int, notes?: ?string, modifiers?: list<array{modifier_id?: ?int, name: string, price_delta_minor?: int}>} $line
*/
public function addLine(PosSale $sale, array $line): PosSaleLine
{
$name = trim((string) ($line['name'] ?? ''));
$unit = max(0, (int) ($line['unit_price_minor'] ?? 0));
$qty = max(1, (int) ($line['quantity'] ?? 1));
if ($name === '' || $unit <= 0) {
throw new RuntimeException('Choose an item with a price.');
}
$position = (int) $sale->lines()->max('position') + 1;
$created = PosSaleLine::create([
'pos_sale_id' => $sale->id,
'product_id' => $line['product_id'] ?? null,
'station_id' => $line['station_id'] ?? null,
'name' => $name,
'unit_price_minor' => $unit,
'quantity' => $qty,
'line_total_minor' => $unit * $qty,
'position' => $position,
'kitchen_state' => PosSaleLine::KITCHEN_NEW,
'source' => $line['source'] ?? 'staff',
'notes' => isset($line['notes']) ? trim((string) $line['notes']) ?: null : null,
'course' => $line['course'] ?? null,
]);
foreach (($line['modifiers'] ?? []) as $mod) {
$modName = trim((string) ($mod['name'] ?? ''));
if ($modName === '') {
continue;
}
PosSaleLineModifier::create([
'pos_sale_line_id' => $created->id,
'modifier_id' => $mod['modifier_id'] ?? null,
'name' => $modName,
'price_delta_minor' => (int) ($mod['price_delta_minor'] ?? 0),
]);
}
$this->recomputeTotals($sale);
return $created;
}
public function updateLine(PosSaleLine $line, int $quantity, ?string $notes = null): void
{
if ($quantity <= 0) {
$this->removeLine($line);
return;
}
$line->forceFill([
'quantity' => $quantity,
'line_total_minor' => $line->unit_price_minor * $quantity,
'notes' => $notes !== null ? (trim($notes) ?: null) : $line->notes,
])->save();
$this->recomputeTotals($line->sale);
}
public function removeLine(PosSaleLine $line): void
{
$sale = $line->sale;
$line->delete();
$this->recomputeTotals($sale);
}
public function recomputeTotals(PosSale $sale): PosSale
{
$subtotal = (int) $sale->lines()->sum('line_total_minor');
$sale->forceFill([
'subtotal_minor' => $subtotal,
'total_minor' => $subtotal,
])->save();
return $sale;
}
/**
* Fire not-yet-sent lines to the kitchen. Pass a course to fire just that course.
*
* @return int number of lines fired
*/
public function sendToKitchen(PosSale $sale, ?string $course = null): int
{
$query = $sale->lines()->where('kitchen_state', PosSaleLine::KITCHEN_NEW);
if ($course !== null && $course !== '') {
$query->where('course', $course);
}
$fired = $query->update(['kitchen_state' => PosSaleLine::KITCHEN_QUEUED]);
if ($fired > 0) {
$sale->forceFill([
'kitchen_status' => PosSale::KITCHEN_ACTIVE,
'kitchen_sent_at' => $sale->kitchen_sent_at ?? now(),
])->save();
}
return $fired;
}
/**
* Record a (possibly partial) cash payment against a ticket. Finalises the
* sale once the balance reaches zero — supports split bills.
*/
public function takeCashPayment(PosSale $sale, int $amountMinor): PosPayment
{
$balance = $sale->balanceMinor();
if ($balance <= 0) {
throw new RuntimeException('This ticket is already fully paid.');
}
$payment = $sale->payments()->create([
'owner_ref' => $sale->owner_ref,
'amount_minor' => max(1, min($amountMinor, $balance)),
'method' => PosPayment::METHOD_CASH,
'status' => PosPayment::STATUS_PAID,
'paid_at' => now(),
]);
$this->finalizeIfSettled($sale);
return $payment;
}
/**
* Start a Card / MoMo checkout for part (or all) of a ticket's balance.
*
* @return array{payment: PosPayment, checkout_url: string}
*/
public function startPayPayment(PosSale $sale, User $merchant, int $amountMinor): array
{
$balance = $sale->balanceMinor();
if ($balance <= 0) {
throw new RuntimeException('This ticket is already fully paid.');
}
$amount = max(1, min($amountMinor, $balance));
$payment = $sale->payments()->create([
'owner_ref' => $sale->owner_ref,
'amount_minor' => $amount,
'method' => PosPayment::METHOD_PAY,
'status' => PosPayment::STATUS_PENDING,
]);
// Till-operator checkout: gateways require an email; use the merchant
// account (not a walk-up customer) so the register can complete Card/MoMo.
$operatorEmail = trim((string) ($merchant->email ?? ''));
if ($operatorEmail === '' || ! str_contains($operatorEmail, '@')) {
$operatorEmail = 'seller+'.($merchant->public_id ?? 'pos').'@checkout.ladill.local';
}
$reference = 'POSP-'.strtoupper(Str::random(16));
$checkout = $this->gateway->initializeCheckout(
$merchant,
$amount,
(string) ($sale->currency ?? 'GHS'),
$operatorEmail,
route('pos.payments.callback'),
$reference,
['title' => 'Ticket '.$sale->reference.' payment', 'pos_sale_id' => $sale->id, 'pos_payment_id' => $payment->id, 'channel' => 'pos_till'],
);
$payment->forceFill([
'payment_reference' => $checkout['reference'],
])->save();
return ['payment' => $payment, 'checkout_url' => $checkout['checkout_url']];
}
public function completePayPayment(string $reference): PosPayment
{
$payment = PosPayment::where('payment_reference', $reference)
->where('status', PosPayment::STATUS_PENDING)
->with('sale')
->firstOrFail();
$result = $this->gateway->verify($payment->owner_ref, $reference);
if (! $result['paid']) {
throw new RuntimeException('Payment was not completed.');
}
$payment->forceFill([
'status' => PosPayment::STATUS_PAID,
'amount_minor' => (int) ($result['amount_minor'] ?: $payment->amount_minor),
'paid_at' => now(),
])->save();
$this->finalizeIfSettled($payment->sale);
return $payment;
}
private function finalizeIfSettled(PosSale $sale): void
{
$sale = $sale->fresh();
if (! $sale || $sale->status === PosSale::STATUS_PAID || $sale->balanceMinor() > 0) {
return;
}
$paid = $sale->payments()->where('status', PosPayment::STATUS_PAID)->get();
$sale->forceFill([
'status' => PosSale::STATUS_PAID,
'payment_method' => $paid->count() > 1 ? 'split' : ($paid->first()->method ?? PosSale::METHOD_CASH),
'paid_at' => now(),
])->save();
$this->closeTicket($sale);
$this->timeline->pushPaidSale($sale->fresh('lines'));
}
/**
* Create a paid, already-fired kitchen ticket from an external order (e.g. a
* Merchant online/QR order). Idempotent by owner + external reference, so it's
* safe to call more than once for the same order.
*
* @param array{owner: string, reference: string, customer_name?: ?string, items: list<array{name: string, quantity?: int, unit_price_minor?: int, notes?: ?string}>} $data
*/
public function ingestExternalOrder(array $data): ?PosSale
{
$owner = (string) $data['owner'];
$reference = (string) $data['reference'];
$existing = PosSale::where('owner_ref', $owner)->where('external_ref', $reference)->first();
if ($existing) {
return $existing;
}
$location = PosLocation::owned($owner)->first();
// Only surface online orders for accounts that actually run a POS kitchen.
if (! $location || ! $location->isRestaurant()) {
return null;
}
$currency = strtoupper((string) ($location?->currency ?? config('pos.default_currency', 'GHS')));
$lines = [];
$subtotal = 0;
$position = 0;
foreach ($data['items'] as $item) {
$name = trim((string) ($item['name'] ?? ''));
if ($name === '') {
continue;
}
$qty = max(1, (int) ($item['quantity'] ?? 1));
$unit = max(0, (int) ($item['unit_price_minor'] ?? 0));
$subtotal += $unit * $qty;
$lines[] = [
'name' => $name,
'unit_price_minor' => $unit,
'quantity' => $qty,
'line_total_minor' => $unit * $qty,
'position' => $position++,
'kitchen_state' => PosSaleLine::KITCHEN_QUEUED,
'source' => 'online',
'notes' => isset($item['notes']) ? (trim((string) $item['notes']) ?: null) : null,
];
}
if ($lines === []) {
throw new RuntimeException('No valid items to ingest.');
}
$sale = PosSale::create([
'owner_ref' => $owner,
'location_id' => $location?->id,
'reference' => 'POS-'.strtoupper(Str::random(12)),
'external_ref' => $reference,
'status' => PosSale::STATUS_PAID,
'payment_method' => PosSale::METHOD_PAY,
'order_type' => 'online',
'kitchen_status' => PosSale::KITCHEN_ACTIVE,
'customer_name' => $data['customer_name'] ?? null,
'subtotal_minor' => $subtotal,
'total_minor' => $subtotal,
'currency' => $currency,
'paid_at' => now(),
'opened_at' => now(),
'kitchen_sent_at' => now(),
]);
foreach ($lines as $line) {
$sale->lines()->create($line);
}
return $sale->fresh('lines');
}
/** Cancel a pending sale/ticket — frees its table and clears it from the kitchen. */
public function cancelSale(PosSale $sale): void
{
if ($sale->status !== PosSale::STATUS_PENDING) {
throw new RuntimeException('Only pending sales can be cancelled.');
}
$this->loyalty->reverseForSale($sale);
$sale->forceFill(['status' => PosSale::STATUS_CANCELLED])->save();
$this->closeTicket($sale);
}
/** Permanently delete an unpaid sale (and its lines, modifiers, payments). */
public function deleteSale(PosSale $sale): void
{
if ($sale->status === PosSale::STATUS_PAID) {
throw new RuntimeException('Paid sales cannot be deleted.');
}
if ($sale->table_id) {
PosTable::where('id', $sale->table_id)
->where('current_sale_id', $sale->id)
->update(['status' => PosTable::STATUS_FREE, 'current_sale_id' => null]);
}
$sale->delete();
}
/**
* Close a ticket once it's settled: free its table and mark kitchen done.
*/
public function closeTicket(PosSale $sale): void
{
$sale->forceFill([
'kitchen_status' => $sale->kitchen_status === PosSale::KITCHEN_NONE ? PosSale::KITCHEN_NONE : PosSale::KITCHEN_SERVED,
'closed_at' => now(),
])->save();
$sale->lines()
->whereNotIn('kitchen_state', [PosSaleLine::KITCHEN_NEW, PosSaleLine::KITCHEN_SERVED])
->update(['kitchen_state' => PosSaleLine::KITCHEN_SERVED]);
if ($sale->table_id) {
PosTable::where('id', $sale->table_id)
->where('current_sale_id', $sale->id)
->update(['status' => PosTable::STATUS_FREE, 'current_sale_id' => null]);
}
}
/**
* @return array{sale: PosSale, checkout_url: string}
*/
public function initiatePayCheckout(PosSale $sale, User $merchant): array
{
if ($sale->isPaid()) {
throw new RuntimeException('This sale is already paid.');
}
$sale->load('lines');
$callbackUrl = route('pos.sales.callback', $sale);
$reference = 'POSS-'.strtoupper(Str::random(16));
$amount = (int) $sale->balanceMinor();
if ($amount < 1) {
$amount = (int) $sale->total_minor;
}
// Till-operator checkout: use merchant email (register staff), not a guest.
$operatorEmail = trim((string) ($merchant->email ?? ''));
if ($operatorEmail === '' || ! str_contains($operatorEmail, '@')) {
$operatorEmail = 'seller+'.($merchant->public_id ?? 'pos').'@checkout.ladill.local';
}
$checkout = $this->gateway->initializeCheckout(
$merchant,
$amount,
(string) ($sale->currency ?? 'GHS'),
$operatorEmail,
$callbackUrl,
$reference,
['title' => 'POS '.$sale->reference, 'pos_sale_id' => $sale->id, 'pos_reference' => $sale->reference, 'channel' => 'pos_till'],
);
$checkoutUrl = $checkout['checkout_url'];
$sale->forceFill([
'payment_method' => PosSale::METHOD_PAY,
'payment_reference' => $checkout['reference'],
])->save();
return [
'sale' => $sale->fresh('lines'),
'checkout_url' => $checkoutUrl,
];
}
public function recordCashPayment(PosSale $sale): PosSale
{
if ($sale->isPaid()) {
throw new RuntimeException('This sale is already paid.');
}
$sale->forceFill([
'payment_method' => PosSale::METHOD_CASH,
'status' => PosSale::STATUS_PAID,
'paid_at' => now(),
])->save();
$this->closeTicket($sale);
$sale = $sale->fresh('lines');
$this->loyalty->earnForSale($sale);
$this->timeline->pushPaidSale($sale);
$this->receiptEmail->sendIfPresent($sale->fresh());
return $sale->fresh('lines');
}
public function completePayCheckout(string $paymentReference): PosSale
{
$sale = PosSale::query()
->where('payment_reference', $paymentReference)
->where('status', PosSale::STATUS_PENDING)
->firstOrFail();
$result = $this->gateway->verify($sale->owner_ref, $paymentReference);
if (! $result['paid']) {
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' => $paidAmount > 0 ? $paidAmount : (int) $sale->total_minor,
'paid_at' => now(),
])->save();
$this->closeTicket($sale);
$sale = $sale->fresh('lines');
$this->loyalty->earnForSale($sale);
$this->timeline->pushPaidSale($sale);
$this->receiptEmail->sendIfPresent($sale->fresh());
return $sale->fresh('lines');
}
/**
* @param list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int}> $lines
* @return list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int, line_total_minor: int}>
*/
private function normalizeLines(array $lines): array
{
$out = [];
foreach ($lines as $line) {
$qty = max(1, (int) ($line['quantity'] ?? 1));
$unit = max(0, (int) ($line['unit_price_minor'] ?? 0));
$name = trim((string) ($line['name'] ?? ''));
if ($name === '' || $unit <= 0) {
continue;
}
$out[] = [
'product_id' => $line['product_id'] ?? null,
'name' => $name,
'unit_price_minor' => $unit,
'quantity' => $qty,
'line_total_minor' => $unit * $qty,
];
}
return $out;
}
}