Deploy Ladill POS / deploy (push) Successful in 1m16s
Use operator-facing copy and merchant email so Card/MoMo checkout is for staff handing the screen to a customer, not self-serve buyers.
616 lines
22 KiB
PHP
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;
|
|
}
|
|
}
|