} $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 = LadillLink::path($qrCode->short_code, 'order/callback'); $usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user); $payOrder = null; if ($usesOwnerGateway) { try { $payOrder = $this->gateway->initialize( $qrCode->user, (int) round($totalGhs * 100), (string) ($qrCode->content()['currency'] ?? 'GHS'), (string) config('pay.mini_checkout_email', 'pay@ladill.com'), $callbackUrl, 'MGP-'.strtoupper(\Illuminate\Support\Str::random(16)), ['qr_code_id' => $qrCode->id, 'short_code' => $qrCode->short_code, 'type' => 'order'], ); } catch (\Throwable) { $usesOwnerGateway = false; } } if (! $usesOwnerGateway) { $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' => $usesOwnerGateway ? null : ($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' => $usesOwnerGateway ? $totalGhs : round(($payOrder['amount_minor'] ?? 0) / 100, 2), 'status' => QrSaleOrder::STATUS_PENDING, 'payment_reference' => $payOrder['reference'], ]); return [ 'order' => $order, 'checkout_url' => (string) ($payOrder['checkout_url'] ?? ''), 'access_code' => isset($payOrder['access_code']) ? (string) $payOrder['access_code'] : null, 'public_key' => isset($payOrder['public_key']) ? (string) $payOrder['public_key'] : null, '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 = LadillLink::path($qrCode->short_code, 'booking/callback'); $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')); $usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user); $payOrder = null; if ($usesOwnerGateway) { try { $payOrder = $this->gateway->initialize( $qrCode->user, (int) round($priceGhs * 100), (string) ($content['currency'] ?? 'GHS'), (string) config('pay.mini_checkout_email', 'pay@ladill.com'), $callbackUrl, 'MGP-'.strtoupper(\Illuminate\Support\Str::random(16)), ['qr_code_id' => $qrCode->id, 'qr_booking_id' => $booking->id, 'type' => 'booking'], ); } catch (\Throwable) { $usesOwnerGateway = false; } } if (! $usesOwnerGateway) { $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' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null), 'payment_reference' => $payOrder['reference'], ]); return [ 'booking' => $booking, 'checkout_url' => (string) ($payOrder['checkout_url'] ?? ''), 'access_code' => isset($payOrder['access_code']) ? (string) $payOrder['access_code'] : null, 'public_key' => isset($payOrder['public_key']) ? (string) $payOrder['public_key'] : null, 'callback_url' => $callbackUrl, ]; } public function completeBooking(string $reference): QrBooking { if (str_starts_with($reference, 'LP-')) { return $this->completeLadillPayBooking($reference); } if (str_starts_with($reference, 'MGP-')) { return $this->completeOwnerGatewayBooking($reference); } return $this->completeLegacyBooking($reference); } public function completeOrder(string $reference): QrSaleOrder { if (str_starts_with($reference, 'LP-')) { return $this->completeLadillPay($reference); } if (str_starts_with($reference, 'MGP-')) { return $this->completeOwnerGateway($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 completeOwnerGateway(string $reference): QrSaleOrder { $order = QrSaleOrder::where('payment_reference', $reference) ->where('status', QrSaleOrder::STATUS_PENDING) ->with('merchant') ->firstOrFail(); $result = $this->gateway->verify($order->merchant, $reference); if (! $result['paid']) { $order->update(['status' => QrSaleOrder::STATUS_FAILED]); throw new RuntimeException('Payment was not successful.'); } $paidMinor = (int) ($result['amount_minor'] ?: round($order->amount_ghs * 100)); $order->update([ 'status' => QrSaleOrder::STATUS_PAID, 'paid_at' => now(), 'amount_ghs' => round($paidMinor / 100, 2), 'platform_fee_ghs' => 0, 'merchant_amount_ghs' => round($paidMinor / 100, 2), 'metadata' => array_merge((array) $order->metadata, ['merchant_gateway' => $result]), ]); $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 completeOwnerGatewayBooking(string $reference): QrBooking { $booking = QrBooking::where('payment_reference', $reference) ->where('status', QrBooking::STATUS_PENDING) ->with('merchant') ->firstOrFail(); $result = $this->gateway->verify($booking->merchant, $reference); if (! $result['paid']) { $booking->update(['status' => QrBooking::STATUS_FAILED]); throw new RuntimeException('Payment was not successful.'); } $paidMinor = (int) ($result['amount_minor'] ?: round($booking->amount_ghs * 100)); $booking->update([ 'status' => QrBooking::STATUS_CONFIRMED, 'fulfillment_status' => QrBooking::FULFILLMENT_CONFIRMED, 'paid_at' => now(), 'amount_ghs' => round($paidMinor / 100, 2), 'platform_fee_ghs' => 0, 'merchant_amount_ghs' => round($paidMinor / 100, 2), 'metadata' => array_merge((array) $booking->metadata, ['merchant_gateway' => $result]), ]); $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 */ 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; } }