Add Ladill POS v1 — register, Pay checkout, and commerce links.
Deploy Ladill Mini / deploy (push) Successful in 23s
Deploy Ladill Mini / deploy (push) Successful in 23s
Staff-facing counter register at pos.ladill.com with catalog cart, cash and MoMo/card checkout via Ladill Pay, CRM timeline/import, invoice prefill, and Merchant catalog import. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Pos;
|
||||
|
||||
use App\Models\PosSale;
|
||||
use App\Models\PosSaleLine;
|
||||
use App\Models\User;
|
||||
use App\Services\Pay\PayClient;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class PosSaleService
|
||||
{
|
||||
public function __construct(
|
||||
private PayClient $pay,
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $merchant->public_id,
|
||||
'fee_tier' => 'sales',
|
||||
'source_service' => 'pos',
|
||||
'source_ref' => (string) $sale->id,
|
||||
'callback_url' => $callbackUrl,
|
||||
'customer_name' => $sale->customer_name,
|
||||
'customer_email' => $sale->customer_email,
|
||||
'customer_phone' => $sale->customer_phone,
|
||||
'line_items' => $sale->lines->map(fn (PosSaleLine $line) => [
|
||||
'name' => $line->name,
|
||||
'unit_price_minor' => $line->unit_price_minor,
|
||||
'quantity' => $line->quantity,
|
||||
])->all(),
|
||||
'metadata' => [
|
||||
'pos_sale_id' => $sale->id,
|
||||
'pos_reference' => $sale->reference,
|
||||
],
|
||||
]);
|
||||
|
||||
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
|
||||
if ($checkoutUrl === '') {
|
||||
throw new RuntimeException('Could not start checkout. Please try again.');
|
||||
}
|
||||
|
||||
$sale->forceFill([
|
||||
'payment_method' => PosSale::METHOD_PAY,
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'payment_reference' => $payOrder['reference'] ?? null,
|
||||
])->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();
|
||||
|
||||
$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();
|
||||
|
||||
$payOrder = $this->pay->verify($paymentReference);
|
||||
|
||||
$sale->forceFill([
|
||||
'status' => PosSale::STATUS_PAID,
|
||||
'total_minor' => (int) ($payOrder['amount_minor'] ?? $sale->total_minor),
|
||||
'pay_order_id' => $payOrder['id'] ?? $sale->pay_order_id,
|
||||
'paid_at' => now(),
|
||||
])->save();
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user