Files
ladill-merchant/app/Services/Merchant/MerchantSaleService.php
T
isaaccladandClaude Opus 4.8 2ced61fed0
Deploy Ladill Merchant / deploy (push) Successful in 45s
Push paid menu orders to the Ladill POS kitchen
When a menu (food) storefront order is paid, best-effort POST it to the
merchant's Ladill POS kitchen ingest (config/kitchen.php → KITCHEN_API_URL/KEY)
so it lands on their Kitchen Display alongside dine-in tabs. Failures are logged,
never surfaced — order completion is unaffected; POS ignores orders for accounts
that don't run a POS kitchen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:57:30 +00:00

431 lines
16 KiB
PHP

<?php
namespace App\Services\Merchant;
use App\Models\QrBooking;
use App\Models\QrCode;
use App\Models\QrSaleOrder;
use Carbon\Carbon;
use App\Services\Billing\BillingClient;
use App\Services\Billing\PaystackService;
use App\Services\Billing\SmsService;
use App\Services\Pay\PayClient;
use App\Support\Qr\QrTypeCatalog;
use RuntimeException;
class MerchantSaleService
{
public function __construct(
private PayClient $pay,
private PaystackService $paystack,
private BillingClient $billing,
private SmsService $sms,
private KitchenPusher $kitchen,
) {}
/**
* @param array{customer_name: string, customer_email: string, customer_phone: ?string, shipping_fee?: float, items: list<array{name: string, price: float, qty: int}>} $data
* @return array{order: QrSaleOrder, checkout_url: string, callback_url: string}
*/
public function initiateOrder(QrCode $qrCode, array $data): array
{
if (! in_array($qrCode->type, \App\Support\Qr\QrTypeCatalog::storefrontTypes(), true)) {
throw new RuntimeException('This QR is not a merchant storefront.');
}
$items = $this->normalizeItems($data['items'] ?? []);
if (empty($items)) {
throw new RuntimeException('Your cart is empty.');
}
$shippingFee = round(max(0, (float) ($data['shipping_fee'] ?? 0)), 2);
if ($shippingFee > 0) {
$items[] = ['name' => 'Shipping', 'price_ghs' => $shippingFee, 'qty' => 1];
}
$totalGhs = collect($items)->sum(fn ($i) => $i['price_ghs'] * $i['qty']);
if ($totalGhs <= 0) {
throw new RuntimeException('Order total must be greater than zero to process payment.');
}
$qrCode->loadMissing('user');
$customerEmail = trim((string) ($data['customer_email'] ?? ''));
$callbackUrl = route('qr.public.order.callback', ['shortCode' => $qrCode->short_code]);
$payOrder = $this->pay->createCheckout([
'merchant' => $qrCode->user->public_id,
'fee_tier' => 'sales',
'source_service' => 'merchant',
'source_ref' => (string) $qrCode->id,
'callback_url' => $callbackUrl,
'customer_name' => trim((string) ($data['customer_name'] ?? '')),
'customer_email' => $customerEmail,
'customer_phone' => trim((string) ($data['customer_phone'] ?? '')) ?: null,
'line_items' => collect($items)->map(fn (array $i) => [
'name' => $i['name'],
'unit_price_minor' => (int) round($i['price_ghs'] * 100),
'quantity' => $i['qty'],
])->all(),
'metadata' => [
'qr_code_id' => $qrCode->id,
'short_code' => $qrCode->short_code,
],
]);
$order = QrSaleOrder::create([
'pay_order_id' => $payOrder['id'] ?? null,
'qr_code_id' => $qrCode->id,
'user_id' => $qrCode->user_id,
'customer_name' => trim((string) ($data['customer_name'] ?? '')),
'customer_email' => $customerEmail ?: null,
'customer_phone' => trim((string) ($data['customer_phone'] ?? '')) ?: null,
'items' => $items,
'amount_ghs' => round(($payOrder['amount_minor'] ?? 0) / 100, 2),
'status' => QrSaleOrder::STATUS_PENDING,
'payment_reference' => $payOrder['reference'],
]);
return [
'order' => $order,
'checkout_url' => (string) ($payOrder['checkout_url'] ?? ''),
'callback_url' => $callbackUrl,
];
}
/**
* @param array{customer_name: string, customer_email: string, customer_phone: ?string, service_index: int, starts_at: string} $data
* @return array{booking: QrBooking, checkout_url: ?string, callback_url: ?string}
*/
public function initiateBooking(QrCode $qrCode, array $data): array
{
if (! $qrCode->isBookingType()) {
throw new RuntimeException('This QR is not a booking page.');
}
$content = $qrCode->content();
$services = $content['services'] ?? [];
$serviceIndex = (int) ($data['service_index'] ?? -1);
$service = $services[$serviceIndex] ?? null;
if (! is_array($service)) {
throw new RuntimeException('Select a valid service.');
}
$serviceName = trim((string) ($service['name'] ?? ''));
$duration = max(15, (int) ($service['duration_minutes'] ?? 30));
$priceGhs = round(max(0, (float) ($service['price_ghs'] ?? 0)), 2);
$startsAt = Carbon::parse((string) ($data['starts_at'] ?? ''));
$endsAt = $startsAt->copy()->addMinutes($duration);
if ($startsAt->isPast()) {
throw new RuntimeException('That time slot has passed. Pick another.');
}
$qrCode->loadMissing('user');
$customerEmail = trim((string) ($data['customer_email'] ?? ''));
$callbackUrl = route('qr.public.booking.callback', ['shortCode' => $qrCode->short_code]);
$booking = QrBooking::create([
'qr_code_id' => $qrCode->id,
'user_id' => $qrCode->user_id,
'customer_name' => trim((string) ($data['customer_name'] ?? '')),
'customer_email' => $customerEmail ?: null,
'customer_phone' => trim((string) ($data['customer_phone'] ?? '')) ?: null,
'service_name' => $serviceName,
'duration_minutes' => $duration,
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'amount_ghs' => $priceGhs,
'status' => QrBooking::STATUS_PENDING,
'metadata' => ['service_index' => $serviceIndex],
]);
if ($priceGhs <= 0 || ! ($content['accepts_payment'] ?? true)) {
$booking->update([
'status' => QrBooking::STATUS_CONFIRMED,
'fulfillment_status' => QrBooking::FULFILLMENT_CONFIRMED,
'paid_at' => now(),
]);
$this->notifyBookingConfirmed($booking->fresh(['qrCode']));
return ['booking' => $booking, 'checkout_url' => null, 'callback_url' => null];
}
$lineLabel = sprintf('%s — %s', $serviceName, $startsAt->format('D j M, g:i A'));
$payOrder = $this->pay->createCheckout([
'merchant' => $qrCode->user->public_id,
'fee_tier' => 'sales',
'source_service' => 'merchant',
'source_ref' => (string) $qrCode->id,
'callback_url' => $callbackUrl,
'customer_name' => $booking->customer_name,
'customer_email' => $customerEmail,
'customer_phone' => $booking->customer_phone,
'line_items' => [
['name' => $lineLabel, 'unit_price_minor' => (int) round($priceGhs * 100), 'quantity' => 1],
],
'metadata' => [
'qr_code_id' => $qrCode->id,
'qr_booking_id' => $booking->id,
'type' => 'booking',
],
]);
$booking->update([
'pay_order_id' => $payOrder['id'] ?? null,
'payment_reference' => $payOrder['reference'],
]);
return [
'booking' => $booking,
'checkout_url' => (string) ($payOrder['checkout_url'] ?? ''),
'callback_url' => $callbackUrl,
];
}
public function completeBooking(string $reference): QrBooking
{
if (str_starts_with($reference, 'LP-')) {
return $this->completeLadillPayBooking($reference);
}
return $this->completeLegacyBooking($reference);
}
public function completeOrder(string $reference): QrSaleOrder
{
if (str_starts_with($reference, 'LP-')) {
return $this->completeLadillPay($reference);
}
return $this->completeLegacy($reference);
}
private function completeLadillPay(string $reference): QrSaleOrder
{
$order = QrSaleOrder::where('payment_reference', $reference)
->where('status', QrSaleOrder::STATUS_PENDING)
->firstOrFail();
$payOrder = $this->pay->verify($reference);
$order->update([
'status' => QrSaleOrder::STATUS_PAID,
'paid_at' => now(),
'platform_fee_ghs' => round(($payOrder['platform_fee_minor'] ?? 0) / 100, 2),
'merchant_amount_ghs' => round(($payOrder['merchant_amount_minor'] ?? 0) / 100, 2),
'pay_order_id' => $payOrder['id'] ?? $order->pay_order_id,
'metadata' => array_merge((array) $order->metadata, ['ladill_pay' => $payOrder]),
]);
$this->notifyOrderPlaced($order->fresh(['qrCode', 'merchant']));
$this->kitchen->push($order->fresh(['qrCode', 'merchant']));
return $order;
}
private function completeLegacy(string $reference): QrSaleOrder
{
$order = QrSaleOrder::where('payment_reference', $reference)
->where('status', QrSaleOrder::STATUS_PENDING)
->firstOrFail();
$data = $this->paystack->verifyTransaction($reference);
if (($data['status'] ?? '') !== 'success') {
$order->update(['status' => QrSaleOrder::STATUS_FAILED]);
throw new RuntimeException('Payment was not successful.');
}
$paidAmount = round(($data['amount'] ?? 0) / 100, 2);
$platformFee = round($paidAmount * QrSaleOrder::PLATFORM_FEE_RATE, 2);
$merchantAmount = round($paidAmount - $platformFee, 2);
$order->update([
'status' => QrSaleOrder::STATUS_PAID,
'paid_at' => now(),
'platform_fee_ghs' => $platformFee,
'merchant_amount_ghs' => $merchantAmount,
'metadata' => array_merge((array) $order->metadata, ['paystack' => $data, 'legacy' => true]),
]);
$storeName = $order->qrCode?->label ?? 'Storefront';
$this->billing->credit(
$order->merchant->public_id,
(int) round($merchantAmount * 100),
'merchant',
'pay',
$reference,
$order->id,
sprintf('Sale from %s — %s', $storeName, $order->customer_name ?: 'Customer'),
);
$this->notifyOrderPlaced($order->fresh(['qrCode', 'merchant']));
$this->kitchen->push($order->fresh(['qrCode', 'merchant']));
return $order;
}
private function completeLadillPayBooking(string $reference): QrBooking
{
$booking = QrBooking::where('payment_reference', $reference)
->where('status', QrBooking::STATUS_PENDING)
->firstOrFail();
$payOrder = $this->pay->verify($reference);
$booking->update([
'status' => QrBooking::STATUS_CONFIRMED,
'fulfillment_status' => QrBooking::FULFILLMENT_CONFIRMED,
'paid_at' => now(),
'platform_fee_ghs' => round(($payOrder['platform_fee_minor'] ?? 0) / 100, 2),
'merchant_amount_ghs' => round(($payOrder['merchant_amount_minor'] ?? 0) / 100, 2),
'pay_order_id' => $payOrder['id'] ?? $booking->pay_order_id,
'metadata' => array_merge((array) $booking->metadata, ['ladill_pay' => $payOrder]),
]);
$this->notifyBookingConfirmed($booking->fresh(['qrCode']));
return $booking;
}
private function completeLegacyBooking(string $reference): QrBooking
{
$booking = QrBooking::where('payment_reference', $reference)
->where('status', QrBooking::STATUS_PENDING)
->firstOrFail();
$data = $this->paystack->verifyTransaction($reference);
if (($data['status'] ?? '') !== 'success') {
$booking->update(['status' => QrBooking::STATUS_FAILED]);
throw new RuntimeException('Payment was not successful.');
}
$paidAmount = round(($data['amount'] ?? 0) / 100, 2);
$platformFee = round($paidAmount * QrSaleOrder::PLATFORM_FEE_RATE, 2);
$merchantAmount = round($paidAmount - $platformFee, 2);
$booking->update([
'status' => QrBooking::STATUS_CONFIRMED,
'fulfillment_status' => QrBooking::FULFILLMENT_CONFIRMED,
'paid_at' => now(),
'platform_fee_ghs' => $platformFee,
'merchant_amount_ghs' => $merchantAmount,
'metadata' => array_merge((array) $booking->metadata, ['paystack' => $data, 'legacy' => true]),
]);
$this->billing->credit(
$booking->merchant->public_id,
(int) round($merchantAmount * 100),
'merchant',
'pay',
$reference,
$booking->id,
sprintf('Booking — %s', $booking->service_name),
);
$this->notifyBookingConfirmed($booking->fresh(['qrCode']));
return $booking;
}
private function notifyBookingConfirmed(QrBooking $booking): void
{
if (! $booking->customer_phone) {
return;
}
$business = $booking->qrCode?->content()['booking_title'] ?? $booking->qrCode?->label ?? 'Business';
$this->sms->send(
$booking->customer_phone,
sprintf(
'Hi %s, your booking for %s at %s is confirmed for %s. Ref: %s',
explode(' ', $booking->customer_name ?? 'there')[0],
$booking->service_name,
$business,
$booking->starts_at->format('D j M, g:i A'),
$booking->payment_reference ?? ('BK-'.$booking->id)
)
);
}
public function notifyBookingStatusUpdated(QrBooking $booking): void
{
if (! $booking->customer_phone) {
return;
}
$business = $booking->qrCode?->content()['booking_title'] ?? $booking->qrCode?->label ?? 'Business';
$this->sms->send(
$booking->customer_phone,
sprintf(
'Hi %s, your booking at %s (%s) is now: %s.',
explode(' ', $booking->customer_name ?? 'there')[0],
$business,
$booking->starts_at->format('D j M, g:i A'),
$booking->fulfillmentLabel()
)
);
}
public function notifyStatusUpdated(QrSaleOrder $order): void
{
if (! $order->customer_phone) {
return;
}
$storeName = $order->qrCode?->label ?? config('app.name');
$this->sms->send(
$order->customer_phone,
sprintf(
'Hi %s, your order #%d at %s is now: %s.',
explode(' ', $order->customer_name ?? 'there')[0],
$order->id,
$storeName,
$order->fulfillmentLabel()
)
);
}
private function notifyOrderPlaced(QrSaleOrder $order): void
{
if (! $order->customer_phone) {
return;
}
$storeName = $order->qrCode?->label ?? config('app.name');
$this->sms->send(
$order->customer_phone,
sprintf(
'Hi %s, your order #%d at %s has been received! Total: GHS %s. Ref: %s',
explode(' ', $order->customer_name ?? 'there')[0],
$order->id,
$storeName,
number_format((float) $order->amount_ghs, 2),
$order->payment_reference
)
);
}
/** @return list<array{name: string, price_ghs: float, qty: int}> */
private function normalizeItems(array $items): array
{
$normalized = [];
foreach ($items as $item) {
$name = trim((string) ($item['name'] ?? ''));
$price = max(0, (float) ($item['price'] ?? 0));
$qty = max(1, (int) ($item['qty'] ?? 1));
if ($name === '' || $price <= 0) {
continue;
}
$normalized[] = [
'name' => $name,
'price_ghs' => $price,
'qty' => $qty,
];
}
return $normalized;
}
}