$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 $modifierIds * @return array|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} $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} $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 $lines * @return list */ 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; } }