Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,10 @@ VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# PAYPAL_CLIENT_ID=
# PAYPAL_SECRET=

# Configuration values for Payzum integration (crypto/stablecoin payments)
# PAYZUM_API_KEY=
# PAYZUM_WEBHOOK_SECRET=

###################################################################
# AI Vision (facial recognition & NSFW classification) #
###################################################################
Expand Down
239 changes: 238 additions & 1 deletion app/Actions/Shop/CheckoutService.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,20 @@
use App\Factories\OmnipayFactory;
use App\Models\Order;
use App\Services\MoneyService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Omnipay\Common\Exception\InvalidCreditCardException;
use Omnipay\Common\Exception\InvalidRequestException;
use Omnipay\Common\GatewayInterface;
use Omnipay\Common\Message\NotificationInterface;
use Omnipay\Common\Message\RedirectResponseInterface;
use Omnipay\Common\Message\ResponseInterface;
use Omnipay\Dummy\Message\Response as DummyResponse;
use Omnipay\Mollie\Message\Response\FetchTransactionResponse;
use Omnipay\Payzum\Gateway as PayzumGateway;
use Omnipay\Payzum\Message\Response\FetchTransactionResponse as PayzumFetchTransactionResponse;
use Omnipay\Payzum\Message\Response\PurchaseResponse as PayzumPurchaseResponse;

/**
* Service for handling checkout operations using Omnipay.
Expand Down Expand Up @@ -95,6 +101,12 @@ public function processPayment(Order $order, string $return_url, string $cancel_
Session::put('metadata.' . $order->id, $metadata);
}

if ($response instanceof PayzumPurchaseResponse) {
// Keep the gateway payment id so the return handler can
// refresh the payment status while it confirms on-chain.
Session::put('metadata.' . $order->id, ['transactionReference' => $response->getTransactionReference()]);
}

if (!$response instanceof RedirectResponseInterface) {
throw new LycheeLogicException('Expected RedirectResponseInterface for redirect response.');
}
Expand Down Expand Up @@ -163,11 +175,51 @@ public function processPayment(Order $order, string $return_url, string $cancel_
public function completePayment(Order $order, ResponseInterface $response): Order
{
$transaction_id = $response->getTransactionReference();
$order->markAsPaid($transaction_id);
$this->settle($order, $transaction_id);

return $order;
}

/**
* Mark an order as paid, at most once.
*
* The browser return and an inbound payment notification can arrive at the
* same moment, and both would otherwise observe PROCESSING and settle the
* order — dispatching OrderCompleted (and thus fulfilling) twice. The row
* is locked and re-read inside a transaction, so exactly one caller
* performs the transition; the loser sees the fresh state and reports that
* it changed nothing.
*
* @param Order $order The order to settle
* @param string $transaction_id The reference to store on the order
*
* @return bool Whether THIS call completed the order
*/
private function settle(Order $order, string $transaction_id): bool
{
return DB::transaction(function () use ($order, $transaction_id): bool {
$fresh = Order::query()->whereKey($order->getKey())->lockForUpdate()->first();

if ($fresh === null) {
return false;
}

if (in_array($fresh->status, [PaymentStatusType::COMPLETED, PaymentStatusType::CLOSED], true)) {
// Someone else settled it first; adopt their state without
// claiming the transition.
$order->refresh();

return false;
}

// Saved through the caller's instance, while this transaction holds
// the row lock, so wasChanged('status') is true for the winner only.
$order->markAsPaid($transaction_id);

return true;
});
}

/**
* Handle the return from the payment gateway.
*
Expand All @@ -183,6 +235,10 @@ public function handlePaymentReturn(Order $order, OmnipayProviderType $provider)

$gateway = $this->omnipay_factory->create_gateway($provider);

if ($provider === OmnipayProviderType::PAYZUM && $gateway instanceof PayzumGateway) {
return $this->handleAsyncPaymentReturn($order, $gateway, $metadata);
}

try {
if ($order->status !== PaymentStatusType::PROCESSING) {
throw new LycheeLogicException('Order with invalid status.');
Expand All @@ -204,6 +260,181 @@ public function handlePaymentReturn(Order $order, OmnipayProviderType $provider)
return $order;
}

/**
* Handle the buyer's return for asynchronous providers (crypto settles
* on-chain, usually after the redirect back).
*
* The order is only ever completed from a verified source: either the
* signed payment notification (handlePaymentNotification) or the
* status refresh below. A payment that is still confirming stays in
* PROCESSING — it must never be marked FAILED just because the buyer
* returned before the chain did.
*
* @param Order $order The order being processed
* @param PayzumGateway $gateway The initialized gateway
* @param array $metadata Session metadata stored at purchase time
*
* @return Order The refreshed order
*/
private function handleAsyncPaymentReturn(Order $order, PayzumGateway $gateway, array $metadata): Order
{
if ($order->status !== PaymentStatusType::PROCESSING) {
// Already settled, e.g. the notification landed before the redirect.
return $order;
}

$transaction_reference = $metadata['transactionReference'] ?? null;
if (!is_string($transaction_reference) || $transaction_reference === '') {
// Nothing to poll (e.g. session lost): the signed notification
// will complete the order server-side.
return $order;
}

try {
$response = $gateway->fetchTransaction(['transactionReference' => $transaction_reference])->send();

if ($response->isSuccessful()) {
// Same reasoning as in handlePaymentNotification(): the order's
// own transaction id has to stay the stable lookup key.
$this->settle($order, $order->transaction_id);

return $order;
}

if ($response instanceof PayzumFetchTransactionResponse && ($response->isExpired() || $response->isCancelled())) {
$order->status = PaymentStatusType::FAILED;
$order->save();
}
// Still pending or confirming: leave the order in PROCESSING.
} catch (\Exception $e) {
Log::error('Error refreshing async payment status: ' . $e->getMessage(), [
'order_id' => $order->id,
'exception' => $e,
]);
// Leave the order in PROCESSING; the notification stays authoritative.
}

return $order;
}

/**
* Complete an order from a signed Payzum payment notification.
*
* The Omnipay driver verifies the HMAC-SHA-512 signature over the raw
* request bytes (with a replay window) before any payload field is
* readable — a forged, stale, or malformed delivery aborts with 400
* without touching the order. Deliveries are retried by the gateway,
* so a redelivered notification for an already-completed order is a
* no-op.
*
* @param Order $order The order the notification URL points at
*
* @return Order The updated order
*/
public function handlePaymentNotification(Order $order): Order
{
// The current request is passed explicitly: the driver verifies the
// notification signature against its raw body and headers.
$gateway = $this->omnipay_factory->create_notification_gateway(OmnipayProviderType::PAYZUM, request());
if (!$gateway instanceof PayzumGateway) {
throw new LycheeLogicException('Expected Payzum gateway.');
}

$notification = $gateway->acceptNotification();

try {
// Accessors verify the signature on first use.
$status = $notification->getTransactionStatus();
} catch (InvalidRequestException $e) {
Log::warning('Rejected Payzum notification: ' . $e->getMessage(), ['order_id' => $order->id]);
abort(400, 'Invalid notification');
}

if ($order->status === PaymentStatusType::COMPLETED || $order->status === PaymentStatusType::CLOSED) {
// Redelivered notification: acknowledge without a second fulfilment.
// Checked before the reference comparison below, because completing
// the order replaced its transaction id with the provider reference.
return $order;
}

if ($notification->getTransactionId() !== $order->transaction_id) {
Log::warning('Payzum notification order mismatch.', ['order_id' => $order->id]);
abort(400, 'Order mismatch');
}

if ($status === NotificationInterface::STATUS_FAILED) {
// The invoice expired or failed before full payment arrived.
$order->status = PaymentStatusType::FAILED;
$order->save();

return $order;
}

if ($status !== NotificationInterface::STATUS_COMPLETED) {
// Pending or confirming: acknowledge without touching the order.
return $order;
}

$payload = $notification->getData();

if (!$this->isNotifiedAmountExpected($payload, $order)) {
Log::warning('Payzum notification amount/currency mismatch.', ['order_id' => $order->id]);
abort(400, 'Amount mismatch');
}

if ($notification->getTransactionReference() === null) {
abort(400, 'Missing payment reference');
}

// Settled with the order's own transaction id rather than the gateway
// reference: that id is the lookup key of both the return and the
// notification URLs, and replacing it would make every later request
// for this order — including the buyer's browser return after an
// early notification — fail to resolve. The Payzum invoice stays
// reachable by it, since their API reads an invoice by payment id or
// by order id.
$this->settle($order, $order->transaction_id);

return $order;
}

/**
* Whether a notification reports the exact amount and currency of the order.
*
* Compared as Money objects rather than floats, so the check is exact.
*
* @param array $payload The verified notification payload
* @param Order $order The order the notification is about
*
* @return bool
*/
private function isNotifiedAmountExpected(array $payload, Order $order): bool
{
$notified_amount = $payload['price_amount'] ?? null;
$notified_currency = $payload['price_currency'] ?? null;

if (!is_string($notified_amount) && !is_numeric($notified_amount)) {
return false;
}

if (!is_string($notified_currency)) {
return false;
}

$expected_currency = $order->amount_cents->getCurrency()->getCode();
if (strtoupper($notified_currency) !== strtoupper($expected_currency)) {
return false;
}

try {
$notified = $this->money_service->createFromDecimal((string) $notified_amount, $expected_currency);
} catch (\Exception) {
return false;
}

return $notified->equals($order->amount_cents);
}

/**
* Prepare parameters for the purchase request.
*
Expand Down Expand Up @@ -233,6 +464,12 @@ private function preparePurchaseParameters(Order $order, string $return_url, str
'description' => 'Order #' . $order->id,
];

if ($this->gateway instanceof PayzumGateway) {
// Crypto settles asynchronously: the signed notification posted to
// this URL is what completes the order, not the browser return.
$params['notifyUrl'] = route('shop.checkout.notify', ['order_id' => $order->id]);
}

// Add customer details if available
if ($order->email !== null) {
$params['email'] = $order->email;
Expand Down
2 changes: 2 additions & 0 deletions app/Enum/OmnipayProviderType.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum OmnipayProviderType: string
case DUMMY = 'Dummy';
case MOLLIE = 'Mollie';
case PAYPAL = 'PayPal';
case PAYZUM = 'Payzum';
case STRIPE = 'Stripe';

/**
Expand All @@ -48,6 +49,7 @@ public function requiredKeys(): array
return match ($this) {
OmnipayProviderType::DUMMY => ['apiKey'],
OmnipayProviderType::MOLLIE => ['apiKey', 'profileId'],
OmnipayProviderType::PAYZUM => ['apiKey', 'webhookSecret'],
OmnipayProviderType::STRIPE => ['apiKey', 'publishableKey', 'disabled'], // we set disabled to prevent it from showing up.
OmnipayProviderType::PAYPAL => ['clientId', 'secret'],
};
Expand Down
20 changes: 20 additions & 0 deletions app/Factories/OmnipayFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use App\Exceptions\Shop\ProviderConfigurationNotFoundException;
use Omnipay\Common\GatewayInterface;
use Omnipay\Omnipay;
use Symfony\Component\HttpFoundation\Request as HttpRequest;

class OmnipayFactory
{
Expand All @@ -38,6 +39,25 @@ public function create_gateway(OmnipayProviderType $provider): GatewayInterface
return $gateway;
}

/**
* Create a payment gateway instance bound to a specific HTTP request.
*
* Used to verify incoming payment notifications: the driver reads the raw
* body and headers of that request to check its signature, so it must be
* the request being handled and not Omnipay's default built from globals.
*
* @param OmnipayProviderType $provider
* @param HttpRequest $http_request
*
* @return GatewayInterface
*
* @throws \InvalidArgumentException
*/
public function create_notification_gateway(OmnipayProviderType $provider, HttpRequest $http_request): GatewayInterface
{
return $this->initialize_gateway(Omnipay::create($provider->value, null, $http_request), $provider);
}

/**
* @param GatewayInterface $gateway
* @param OmnipayProviderType $provider
Expand Down
Loading