Shop, menu, and booking storefronts with OIDC SSO, Pay checkout, and merchant.ladill.com deployment workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
428 lines
16 KiB
PHP
428 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,
|
|
) {}
|
|
|
|
/**
|
|
* @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']));
|
|
|
|
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']));
|
|
|
|
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 * 0.15, 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;
|
|
}
|
|
}
|