Files
ladill-pos/app/Services/Pos/PosSaleService.php
T
isaacclad 7c20cf705a
Deploy Ladill POS / deploy (push) Successful in 1m16s
Frame POS checkout sheet for till operators.
Use operator-facing copy and merchant email so Card/MoMo checkout is for staff handing the screen to a customer, not self-serve buyers.
2026-07-15 13:23:29 +00:00

616 lines
22 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,
) {}
/**
* @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} $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')));
$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,
'total_minor' => $subtotal,
'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,
]);
}
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.');
}
$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->timeline->pushPaidSale($sale);
return $sale;
}
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.');
}
$sale->forceFill([
'status' => PosSale::STATUS_PAID,
'total_minor' => (int) ($result['amount_minor'] ?: $sale->total_minor),
'paid_at' => now(),
])->save();
$this->closeTicket($sale);
$sale = $sale->fresh('lines');
$this->timeline->pushPaidSale($sale);
return $sale;
}
/**
* @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;
}
}