Feat: Payment Restructuring to be fully event-driven
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lunarphp/stripe's own stripe_payment_intents table already correlates a
|
||||||
|
* Stripe intent back to a cart/order via cart_id/order_id — exactly what
|
||||||
|
* Modules\Core\Payment\Drivers\StripePaymentDriver needs to recover
|
||||||
|
* $context in handleCallback(), a separate request (a webhook) from the
|
||||||
|
* pay()/authorize() call that originated it. Two columns this driver
|
||||||
|
* needs that the vendor table doesn't have:
|
||||||
|
* - context: the full opaque $context bag pay()/authorize() received,
|
||||||
|
* stored so handleCallback() can dispatch the SAME context the
|
||||||
|
* original call would have, without Payment inventing its own
|
||||||
|
* correlation table — see docs/payments.md "Async resolution".
|
||||||
|
* - payment_type: the payment type key (e.g. 'stripe') pay()/authorize()
|
||||||
|
* were called with — needed to dispatch Payment events with the
|
||||||
|
* correct $type in handleCallback(), which otherwise has no way to
|
||||||
|
* know it (a webhook payload doesn't carry it).
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('stripe_payment_intents', function (Blueprint $table) {
|
||||||
|
$table->json('context')->nullable()->after('status');
|
||||||
|
$table->string('payment_type')->nullable()->after('context');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('stripe_payment_intents', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['context', 'payment_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Payment — Design Notes
|
||||||
|
|
||||||
|
**Status: abstraction layer built, drivers/wiring in progress.** `Payment` is designed as a
|
||||||
|
standalone module: it never calls into `Checkout` or `Order`, never touches their Eloquent
|
||||||
|
models, and communicates only via events. This document is the design spec for that
|
||||||
|
abstraction — contracts, DTOs, events — independent of how `Checkout`/`Order` end up consuming
|
||||||
|
it (that wiring is a separate, later pass).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations, not gateways
|
||||||
|
|
||||||
|
The driver contracts model the actual operations a payment gateway can perform, not vendor
|
||||||
|
terminology. Every real gateway checked while designing this converges on the same small set
|
||||||
|
under different names:
|
||||||
|
|
||||||
|
| Operation | Mastercard | Stripe | Nexi |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Atomic charge (authorize+capture in one call) | `Pay` | `capture_method: automatic` | `ActionType::PAY()` |
|
||||||
|
| Hold only, settle/release later | `Authorize` | `capture_method: manual` | `ActionType::PREAUTH()` |
|
||||||
|
| Settle a prior hold | `Capture` | `PaymentIntent::capture()` | `CaptureRequest`/`CaptureResponse` |
|
||||||
|
| Release a prior hold without settling | `Void`/`Cancel` | `PaymentIntent::cancel()` | `CancelRequest`/`CancelResponse` |
|
||||||
|
| Reverse settled funds | `Refund` | `Refund::create()` | (refund endpoint) |
|
||||||
|
|
||||||
|
A driver implements only the interfaces its gateway actually supports:
|
||||||
|
|
||||||
|
- An offline/cash type (`cash-on-delivery`, `cash-in-hand`) only ever settles atomically —
|
||||||
|
implements `SupportsPay` alone.
|
||||||
|
- A card gateway capable of either mode per-transaction (Stripe, most card processors)
|
||||||
|
implements `SupportsPay`, `SupportsAuthorization`, `SupportsCaptures`, `SupportsVoids`, and
|
||||||
|
`SupportsRefunds` all at once — which one gets *called* for a given attempt is the caller's
|
||||||
|
policy choice (e.g. `config('lunar.stripe.policy')`), not something baked into the driver's
|
||||||
|
shape.
|
||||||
|
- A redirect/wallet gateway with no separate hold step (Viva/Klarna in typical flows)
|
||||||
|
implements `SupportsPay` and `SupportsRefunds`, never `SupportsCaptures`/`SupportsVoids`.
|
||||||
|
|
||||||
|
### `pay()` and `authorize()` stay separate methods even when a gateway implements both as "the same call with a flag"
|
||||||
|
|
||||||
|
Stripe has no separate `authorize`/`pay` API endpoints — one `PaymentIntent`, confirmed with
|
||||||
|
either `capture_method: automatic` or `manual`. Mastercard and Nexi *do* have genuinely
|
||||||
|
separate operations. The contract abstracts over both shapes uniformly: every driver capable
|
||||||
|
of both exposes two distinct methods, `pay()` and `authorize()`. A Mastercard-style driver
|
||||||
|
calls two different endpoints under the hood; a Stripe-style driver calls the same endpoint
|
||||||
|
twice with a different flag each time. Neither difference is visible to a caller.
|
||||||
|
|
||||||
|
### `capture()`/`void()` are only ever valid against a prior `authorize()`
|
||||||
|
|
||||||
|
They are not standalone operations — `capture()` settles a specific hold identified by the
|
||||||
|
`reference` `authorize()` returned; `void()` releases that same hold instead. A driver that
|
||||||
|
never implements `SupportsAuthorization` never produces a reference either of these methods
|
||||||
|
could act on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `PaymentResult` — the one return shape, every operation, every driver
|
||||||
|
|
||||||
|
```php
|
||||||
|
enum PaymentResultStatus { case Succeeded; case Failed; case Pending; }
|
||||||
|
|
||||||
|
final class PaymentResult {
|
||||||
|
public function __construct(
|
||||||
|
public readonly PaymentResultStatus $status,
|
||||||
|
public readonly string $reference,
|
||||||
|
public readonly int $amount,
|
||||||
|
public readonly ?string $failureReason = null,
|
||||||
|
public readonly bool $retriable = false,
|
||||||
|
public readonly array $raw = [],
|
||||||
|
public readonly array $meta = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Real gateway responses vary wildly in richness — confirmed by reading three SDKs directly:
|
||||||
|
|
||||||
|
- **Stripe's `PaymentIntent`** is rich: `status`, `amount`, `amount_capturable`,
|
||||||
|
`amount_received`, `last_payment_error`, a full `getLastResponse()`.
|
||||||
|
- **Nexi's `CaptureResponse`/`CancelResponse`** are minimal: just `operationId` +
|
||||||
|
`operationTime` — no echoed amount or status at all. Success is inferred from getting a
|
||||||
|
response rather than an SDK exception.
|
||||||
|
- **Mastercard's** gateway sits in between, with `gatewayCode`/`acquirerCode`/
|
||||||
|
`merchantAdviceCode`.
|
||||||
|
|
||||||
|
`PaymentResult` only requires what every driver can always know: `status`, `reference`,
|
||||||
|
`amount` (the amount **we** requested — not necessarily echoed back by a sparse gateway like
|
||||||
|
Nexi's capture). Everything else is best-effort: `failureReason`/`retriable` are normalized
|
||||||
|
only when the gateway has something to normalize from; `raw` is the unconditional escape
|
||||||
|
hatch — the untouched gateway response body, always populated, for genuine audit fidelity
|
||||||
|
regardless of how sparse the normalized fields ended up.
|
||||||
|
|
||||||
|
### `retriable` — real on some gateways, absent on others
|
||||||
|
|
||||||
|
Stripe classifies declines as soft (`do_not_honor`, `insufficient_funds` — worth retrying,
|
||||||
|
after a delay) vs. hard (`stolen_card`, `expired_card` — never retry the same method).
|
||||||
|
Mastercard has the equivalent via `authorizationResponse.merchantAdviceCode` and card-scheme
|
||||||
|
soft-decline codes. **Nexi has no such signal at all** — `OperationResult` is just
|
||||||
|
`DECLINED`/`DENIED_BY_RISK`/`FAILED`/etc. with no retriability classification. `retriable`
|
||||||
|
therefore defaults to `false` (assume not safely retriable) rather than guessing when a
|
||||||
|
driver's gateway has nothing to base it on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events — one terminal pair per operation, keyed to the business fact, not the call path
|
||||||
|
|
||||||
|
`Modules\Core\Payment\Events`:
|
||||||
|
|
||||||
|
| Event pair | Dispatched by |
|
||||||
|
|---|---|
|
||||||
|
| `PaymentAuthorized` / `PaymentAuthorizationFailed` | `SupportsAuthorization::authorize()`, or a later `HandlesPaymentCallback::handleCallback()` resolving it |
|
||||||
|
| `PaymentCaptured` / `PaymentCaptureFailed` | `SupportsPay::pay()` **or** `SupportsCaptures::capture()` |
|
||||||
|
| `PaymentVoided` / `PaymentVoidFailed` | `SupportsVoids::void()` |
|
||||||
|
| `PaymentRefunded` / `PaymentRefundFailed` | `SupportsRefunds::refund()` |
|
||||||
|
|
||||||
|
`PaymentCaptured` is deliberately the *same* event whether money was taken via `pay()` (one
|
||||||
|
gateway call) or `authorize()` → `capture()` (two calls) — "a payment has been captured" is
|
||||||
|
the same business fact either way, and a listener reacting to it never needs to know which
|
||||||
|
path produced it. There is no separate "payment succeeded" wrapper event distinct from
|
||||||
|
`PaymentCaptured`.
|
||||||
|
|
||||||
|
Every event carries `{type: string, result: PaymentResult, context: array}`. `Payment` has no
|
||||||
|
concept of a `Cart`, an `Order`, or a checkout fingerprint — `$context` is an opaque bag the
|
||||||
|
caller hands in on the way down (`pay($type, $data, $context)`) and gets back untouched on
|
||||||
|
whichever event that call (or a later `handleCallback()`) produces. Each listener interprets
|
||||||
|
`$context` on its own terms, or ignores the event if the keys it needs aren't present —
|
||||||
|
`Checkout` is only one possible consumer of these events, not the only one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Async resolution — `HandlesPaymentCallback`
|
||||||
|
|
||||||
|
Only implemented by a driver whose `pay()`/`authorize()` can return `PaymentResultStatus::Pending`
|
||||||
|
— a redirect the shopper completes elsewhere, a webhook that arrives later. A driver whose
|
||||||
|
gateway always resolves synchronously never implements this.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handleCallback(string $reference, array $data, array $context = []): PaymentResult;
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolves into the *same* event pair the original `pay()`/`authorize()` call would have
|
||||||
|
produced had it resolved synchronously.
|
||||||
|
|
||||||
|
### The correlation problem: `handleCallback()` runs in a different request
|
||||||
|
|
||||||
|
`$context` passed into the original `pay()`/`authorize()` call does not survive to
|
||||||
|
`handleCallback()` on its own — that call is typically a separate HTTP request (a webhook)
|
||||||
|
with no memory of the request that started the payment. Something has to persist enough to
|
||||||
|
answer "which order/cart does gateway reference X belong to?" between the two calls.
|
||||||
|
|
||||||
|
**Read directly from `lunarphp/stripe`'s own source** (`StripePaymentType::authorize()`,
|
||||||
|
`ProcessStripeWebhook`, `WebhookController`) to see how Lunar itself solves this — confirmed
|
||||||
|
it does **not** stash a generic opaque blob. It writes the correlating ids as real, typed
|
||||||
|
columns on `Lunar\Stripe\Models\StripePaymentIntent` (`cart_id`, `order_id`) at the moment the
|
||||||
|
intent is created/first seen, then reads them back the same way when the webhook arrives:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ProcessStripeWebhook::handle() — falls back through two real lookups,
|
||||||
|
// neither of them a generic context blob:
|
||||||
|
$cart = StripePaymentIntent::where('intent_id', $this->paymentIntentId)->first()?->cart
|
||||||
|
?: Cart::where('meta->payment_intent', '=', $this->paymentIntentId)->first();
|
||||||
|
```
|
||||||
|
|
||||||
|
**`StripePaymentDriver` follows this exact precedent**: it reads `cart_id`/`order_id` out of
|
||||||
|
`$context` at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent`
|
||||||
|
row (a table already owned by `lunarphp/stripe`, already shaped for exactly this), then reads
|
||||||
|
them back the same way in `handleCallback()`. No generic `context` json column, no new table.
|
||||||
|
|
||||||
|
### This pattern is per-driver, not a shared table
|
||||||
|
|
||||||
|
`stripe_payment_intents` is Stripe-specific — keyed on `intent_id`, typed around
|
||||||
|
`Stripe\PaymentIntent`'s own status values. It cannot be reused as-is for a future non-Stripe
|
||||||
|
async driver (Nexi, Viva): that driver's own gateway reference has a different shape entirely,
|
||||||
|
and shoehorning it into Stripe-named columns would make the table misleading. The **pattern**
|
||||||
|
generalizes — *any* driver needing async callback resolution owns a small table keyed by its
|
||||||
|
own gateway's reference, storing whatever correlation data that driver specifically needs —
|
||||||
|
but each driver gets its own table, matching what it actually needs to correlate, rather than
|
||||||
|
a shared generic one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explicitly out of scope for this pass
|
||||||
|
|
||||||
|
- **`Checkout`/`Order` wiring** — how `Checkout` calls into `Payment`, how `Order`/`Checkout`
|
||||||
|
react to `Payment`'s events, where a draft `Order` gets created relative to when `Payment` is
|
||||||
|
called. Deliberately designed and built separately, after `Payment` itself was complete —
|
||||||
|
`Payment` must stand on its own regardless of what ends up consuming it.
|
||||||
|
- **`Transaction` persistence** — Lunar's own `transactions` table (`type`: `intent`/`capture`/
|
||||||
|
`refund`, `parent_transaction_id` chaining) already models the audit trail these events
|
||||||
|
would feed, once a listener is built to write to it. `Payment` itself does not write
|
||||||
|
`Transaction` rows — see the events table above; that is a listener's job, in whichever
|
||||||
|
module ends up owning the write (likely `Order`, since `Transaction.order_id` is required).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Order\Listeners;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Payment\Events\OrderPaymentStatusResolved;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only place an Order's status column is written in reaction to a
|
||||||
|
* payment outcome — Payment dispatches OrderPaymentStatusResolved with
|
||||||
|
* what the status should become, never touching the Order model itself;
|
||||||
|
* this listener, living in Order's own module, is what applies it.
|
||||||
|
*
|
||||||
|
* Loads and saves the model (not a bulk ::whereKey()->update()) so
|
||||||
|
* Order::observe()'s updated() hook fires and OrderStatusUpdated goes out
|
||||||
|
* the same as any other status write — see that event's own docblock for
|
||||||
|
* why it's meant to fire "regardless of what wrote it."
|
||||||
|
*/
|
||||||
|
class ApplyResolvedPaymentStatus
|
||||||
|
{
|
||||||
|
public function handle(OrderPaymentStatusResolved $event): void
|
||||||
|
{
|
||||||
|
$order = Order::findOrFail($event->orderId);
|
||||||
|
$order->update(['status' => $event->status]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every driver implements this, orthogonal to which payment operations
|
||||||
|
* (SupportsPay, SupportsAuthorization, ...) it supports — whether a driver
|
||||||
|
* can actually be used right now is a separate question from what it's
|
||||||
|
* capable of when it can be. An offline driver has no external dependency
|
||||||
|
* to be missing and always returns true; a gateway driver checks its own
|
||||||
|
* credentials/API key.
|
||||||
|
*/
|
||||||
|
interface Configurable
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Independent of any admin-facing enabled/disabled toggle a caller
|
||||||
|
* might also apply on top — this is only about whether the driver
|
||||||
|
* itself is usable right now (e.g. Stripe with no API key configured
|
||||||
|
* is never usable, regardless of any such toggle).
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Contracts;
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\Price;
|
||||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,10 +28,10 @@ use Modules\Core\Payment\DTOs\PaymentResult;
|
|||||||
interface SupportsAuthorization
|
interface SupportsAuthorization
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Same $type/$data/$context reasoning as SupportsPay::pay().
|
* Same $type/$amount/$data/$context reasoning as SupportsPay::pay().
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
* @param array<string, mixed> $context
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public function authorize(string $type, array $data, array $context = []): PaymentResult;
|
public function authorize(string $type, Price $amount, array $data = [], array $context = []): PaymentResult;
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Contracts;
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\Price;
|
||||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,12 +24,16 @@ interface SupportsCaptures
|
|||||||
* $reference is the identifier SupportsAuthorization::authorize()
|
* $reference is the identifier SupportsAuthorization::authorize()
|
||||||
* returned (PaymentResult::$reference) for the hold being settled.
|
* returned (PaymentResult::$reference) for the hold being settled.
|
||||||
*
|
*
|
||||||
* $amount lets a driver capture less than the full authorized amount
|
* $amount is Lunar's own Price (never a gateway's own minor-unit
|
||||||
* (e.g. shipping less than ordered) — up to the driver/gateway
|
* scale — see PaymentResult's docblock), and lets a driver capture
|
||||||
* whether a partial capture also releases the remainder or leaves it
|
* less than the full authorized amount (e.g. shipping less than
|
||||||
* capturable again later (multicapture-style gateways).
|
* ordered) — up to the driver/gateway whether a partial capture also
|
||||||
|
* releases the remainder or leaves it capturable again later
|
||||||
|
* (multicapture-style gateways). Required explicitly, not derived by
|
||||||
|
* the driver from a live gateway lookup — the caller (whatever placed
|
||||||
|
* the original authorize() call) already knows it.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $context
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public function capture(string $reference, int $amount, array $context = []): PaymentResult;
|
public function capture(string $reference, Price $amount, array $context = []): PaymentResult;
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Contracts;
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\Price;
|
||||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,10 +28,16 @@ interface SupportsPay
|
|||||||
* one type, because a driver shared across several types needs it to
|
* one type, because a driver shared across several types needs it to
|
||||||
* look up that type's own config.
|
* look up that type's own config.
|
||||||
*
|
*
|
||||||
* $data carries whatever the gateway needs (amount, currency, customer
|
* $amount is required, not optional data a caller might omit — there
|
||||||
* details, a payment method token) — the caller's responsibility to
|
* is no way to process a payment without knowing what to charge.
|
||||||
* assemble, since a driver has no notion of a cart or order to pull
|
* Lunar's own Price (bundling its own currency) — the same money
|
||||||
* them from itself.
|
* representation every other Payment contract method takes/returns,
|
||||||
|
* see PaymentResult's own docblock.
|
||||||
|
*
|
||||||
|
* $data carries whatever ELSE the gateway needs (customer details, a
|
||||||
|
* payment method token) — the caller's responsibility to assemble,
|
||||||
|
* since a driver has no notion of a cart or order to pull them from
|
||||||
|
* itself.
|
||||||
*
|
*
|
||||||
* $context is opaque to the driver — carried through untouched into
|
* $context is opaque to the driver — carried through untouched into
|
||||||
* whichever Payment event this call (or a later handleCallback()
|
* whichever Payment event this call (or a later handleCallback()
|
||||||
@@ -41,5 +48,5 @@ interface SupportsPay
|
|||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
* @param array<string, mixed> $context
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public function pay(string $type, array $data, array $context = []): PaymentResult;
|
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult;
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Contracts;
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\Price;
|
||||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,10 +23,13 @@ interface SupportsRefunds
|
|||||||
* SupportsCaptures::capture() call returned for the settled funds
|
* SupportsCaptures::capture() call returned for the settled funds
|
||||||
* being refunded.
|
* being refunded.
|
||||||
*
|
*
|
||||||
* $amount allows a partial refund; a gateway may allow multiple
|
* $amount is Lunar's own Price (never a gateway's own minor-unit
|
||||||
* partial refunds against one settlement, up to its own total.
|
* scale — see PaymentResult's docblock), allowing a partial refund; a
|
||||||
|
* gateway may allow multiple partial refunds against one settlement,
|
||||||
|
* up to its own total. Required explicitly, same reasoning as
|
||||||
|
* SupportsCaptures::capture()'s own $amount.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $context
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public function refund(string $reference, int $amount, array $context = []): PaymentResult;
|
public function refund(string $reference, Price $amount, array $context = []): PaymentResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Contracts;
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\Price;
|
||||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +21,15 @@ interface SupportsVoids
|
|||||||
* $reference is the identifier SupportsAuthorization::authorize()
|
* $reference is the identifier SupportsAuthorization::authorize()
|
||||||
* returned for the hold being released.
|
* returned for the hold being released.
|
||||||
*
|
*
|
||||||
|
* $amount is the authorized amount being released — Lunar's own
|
||||||
|
* Price, same as every other Payment contract method (see
|
||||||
|
* PaymentResult's own docblock). Required explicitly: the caller
|
||||||
|
* (whatever placed the original authorize() call) already knows it,
|
||||||
|
* same reasoning as SupportsCaptures::capture()'s own $amount — a
|
||||||
|
* driver shouldn't need a live gateway lookup just to know what it's
|
||||||
|
* releasing.
|
||||||
|
*
|
||||||
* @param array<string, mixed> $context
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public function void(string $reference, array $context = []): PaymentResult;
|
public function void(string $reference, Price $amount, array $context = []): PaymentResult;
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\DTOs;
|
namespace Modules\Core\Payment\DTOs;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\Price;
|
||||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,13 @@ use Modules\Core\Payment\Enums\PaymentResultStatus;
|
|||||||
final class PaymentResult
|
final class PaymentResult
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
* @param $amount Lunar's own money type (Lunar\DataTypes\Price —
|
||||||
|
* integer minor units bundled with its Currency), the SAME
|
||||||
|
* representation every contract method takes/returns — never a
|
||||||
|
* gateway's own minor-unit scale. Each driver converts at its own
|
||||||
|
* boundary (e.g. StripeManager::toStripeAmount()/fromStripeAmount())
|
||||||
|
* before calling out to, or after reading back from, its gateway —
|
||||||
|
* Payment itself only ever speaks Lunar's Price.
|
||||||
* @param $failureReason a human-readable reason, only meaningful
|
* @param $failureReason a human-readable reason, only meaningful
|
||||||
* when $status is Failed — the driver's own normalization of
|
* when $status is Failed — the driver's own normalization of
|
||||||
* whatever the gateway called it (Stripe's decline_code message,
|
* whatever the gateway called it (Stripe's decline_code message,
|
||||||
@@ -43,7 +51,7 @@ final class PaymentResult
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly PaymentResultStatus $status,
|
public readonly PaymentResultStatus $status,
|
||||||
public readonly string $reference,
|
public readonly string $reference,
|
||||||
public readonly int $amount,
|
public readonly Price $amount,
|
||||||
public readonly ?string $failureReason = null,
|
public readonly ?string $failureReason = null,
|
||||||
public readonly bool $retriable = false,
|
public readonly bool $retriable = false,
|
||||||
public readonly array $raw = [],
|
public readonly array $raw = [],
|
||||||
|
|||||||
@@ -2,27 +2,27 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Drivers;
|
namespace Modules\Core\Payment\Drivers;
|
||||||
|
|
||||||
use Lunar\Models\Cart;
|
use Illuminate\Support\Str;
|
||||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
use Lunar\DataTypes\Price;
|
||||||
use Modules\Core\Checkout\Events\PaymentConfirmed;
|
use Modules\Core\Payment\Contracts\Configurable;
|
||||||
use Modules\Core\Payment\Contracts\PaymentDriver;
|
use Modules\Core\Payment\Contracts\SupportsPay;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||||
|
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared by every payment type with no real gateway to confirm against —
|
* Shared by every payment type with no real gateway to confirm against —
|
||||||
* cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on
|
* cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on
|
||||||
* delivery, not at checkout. confirm() has nothing to wait on, so it
|
* delivery, not at checkout. There is no separate hold-then-settle model
|
||||||
* dispatches PaymentConfirmed immediately, same moment Lunar's own
|
* (SupportsAuthorization/SupportsCaptures/SupportsVoids) and no async
|
||||||
* OfflinePayment would place the order — but the actual placement now
|
* resolution (HandlesPaymentCallback) — pay() decides success immediately
|
||||||
* happens in CheckoutService::onPaymentConfirmed(), not here. $data is
|
* and dispatches PaymentCaptured before returning.
|
||||||
* unused: nothing about this confirmation depends on gateway-specific
|
|
||||||
* payload.
|
|
||||||
*
|
*
|
||||||
* The status-mapping step this driver used to do inline right after
|
* $reference is generated here (not supplied by a gateway, since there is
|
||||||
* placeOrder() returned now happens in onOrderPlaced() below instead —
|
* none) purely so PaymentCaptured, and anything downstream keying on it,
|
||||||
* see PaymentDriver's docblock for why a driver can no longer rely on
|
* have something to identify this attempt by.
|
||||||
* placeOrder()'s return value.
|
|
||||||
*/
|
*/
|
||||||
class OfflinePaymentDriver implements PaymentDriver
|
class OfflinePaymentDriver implements Configurable, SupportsPay
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Always true — no external dependency to be missing.
|
* Always true — no external dependency to be missing.
|
||||||
@@ -32,30 +32,18 @@ class OfflinePaymentDriver implements PaymentDriver
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void
|
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
|
||||||
{
|
{
|
||||||
PaymentConfirmed::dispatch($cart, $type, $fingerprint, $data);
|
$reference = 'offline-'.Str::uuid();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
$result = new PaymentResult(
|
||||||
* Registered in PaymentServiceProvider. Every offline-style type
|
status: PaymentResultStatus::Succeeded,
|
||||||
* shares this one driver, so $order->meta['payment_method'] is checked
|
reference: $reference,
|
||||||
* against config('lunar.payments.types') to confirm the placed order
|
amount: $amount,
|
||||||
* actually belongs to one of them, rather than assuming every
|
);
|
||||||
* OrderPlaced is this driver's to act on — a Stripe order placed via
|
|
||||||
* StripePaymentDriver fires the same event.
|
|
||||||
*/
|
|
||||||
public function onOrderPlaced(OrderPlaced $event): void
|
|
||||||
{
|
|
||||||
$order = $event->order;
|
|
||||||
$type = $order->meta['payment_method'] ?? null;
|
|
||||||
|
|
||||||
if (! $type || config("lunar.payments.types.{$type}.payment_driver") !== self::class) {
|
PaymentCaptured::dispatch($type, $result, $context);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$order->update([
|
return $result;
|
||||||
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,44 +2,67 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Drivers;
|
namespace Modules\Core\Payment\Drivers;
|
||||||
|
|
||||||
use Lunar\Models\Cart;
|
use Lunar\DataTypes\Price;
|
||||||
use Lunar\Stripe\Actions\UpdateOrderFromIntent;
|
use Lunar\Models\Currency;
|
||||||
use Lunar\Stripe\Facades\Stripe;
|
use Lunar\Stripe\Facades\Stripe;
|
||||||
|
use Lunar\Stripe\Managers\StripeManager;
|
||||||
use Lunar\Stripe\Models\StripePaymentIntent;
|
use Lunar\Stripe\Models\StripePaymentIntent;
|
||||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
use Modules\Core\Payment\Contracts\Configurable;
|
||||||
use Modules\Core\Checkout\Events\PaymentConfirmed;
|
use Modules\Core\Payment\Contracts\HandlesPaymentCallback;
|
||||||
use Modules\Core\Payment\Contracts\PaymentDriver;
|
use Modules\Core\Payment\Contracts\SupportsAuthorization;
|
||||||
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException;
|
use Modules\Core\Payment\Contracts\SupportsCaptures;
|
||||||
|
use Modules\Core\Payment\Contracts\SupportsPay;
|
||||||
|
use Modules\Core\Payment\Contracts\SupportsRefunds;
|
||||||
|
use Modules\Core\Payment\Contracts\SupportsVoids;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||||
|
use Modules\Core\Payment\Events\PaymentAuthorizationFailed;
|
||||||
|
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||||
|
use Modules\Core\Payment\Events\PaymentCaptureFailed;
|
||||||
|
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||||
|
use Modules\Core\Payment\Events\PaymentRefundFailed;
|
||||||
|
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||||
|
use Modules\Core\Payment\Events\PaymentVoidFailed;
|
||||||
|
use Modules\Core\Payment\Events\PaymentVoided;
|
||||||
|
use Stripe\Exception\ApiErrorException;
|
||||||
use Stripe\PaymentIntent;
|
use Stripe\PaymentIntent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy
|
* Talks to Stripe's PaymentIntent API directly — deliberately NOT via
|
||||||
* Modules\Core\Payment\Contracts\PaymentDriver — dispatches
|
* Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which
|
||||||
* PaymentConfirmed at the moment Stripe confirms payment, instead of the
|
* take a Lunar\Models\Cart and derive amount/currency from it. Payment
|
||||||
* vendor's own Cart::createOrder() call.
|
* must never receive a Cart (see docs/payments.md) — pay()/authorize()
|
||||||
|
* already receive $amount explicitly as their own required Lunar Price
|
||||||
|
* parameter (see PaymentResult's own docblock), the caller's job to
|
||||||
|
* assemble, same as every other driver.
|
||||||
*
|
*
|
||||||
* This is a fork, not a decoration: StripePaymentType::authorize() is
|
* Every amount that crosses this class's own boundary is converted right
|
||||||
* `final` and calls Cart::createOrder() directly with no seam to redirect
|
* there: Lunar's Price -> Stripe's minor-unit int going INTO a gateway
|
||||||
* that one call — so this class reimplements authorize()'s logic (intent
|
* call (StripeManager::toStripeAmount()), Stripe's response amount ->
|
||||||
* retrieval, capture-on-policy) rather than wrapping the vendor method.
|
* Lunar's Price coming back OUT (StripeManager::fromStripeAmount()).
|
||||||
* Kept deliberately close to the original so a lunarphp/stripe upgrade is
|
* Nothing outside this class ever sees a Stripe-scaled integer.
|
||||||
* easy to diff against. See docs/payments.md.
|
|
||||||
*
|
*
|
||||||
* The status-mapping step (UpdateOrderFromIntent) this driver used to do
|
* Correlating a later handleCallback() (a separate request — a webhook)
|
||||||
* inline right after placeOrder() returned now happens in onOrderPlaced()
|
* back to whatever $context identified this attempt is solved the same
|
||||||
* below instead — see PaymentDriver's docblock for why a driver can no
|
* way lunarphp/stripe's own StripePaymentType/ProcessStripeWebhook solve
|
||||||
* longer rely on placeOrder()'s return value. Since that step needs the
|
* it: real cart_id/order_id columns on Lunar\Stripe\Models\
|
||||||
* live Stripe PaymentIntent, not just the Order, onOrderPlaced() re-fetches
|
* StripePaymentIntent (a table already owned by lunarphp/stripe, already
|
||||||
* it from Stripe via the StripePaymentIntent row this method already wrote
|
* shaped for exactly this), not a generic context blob. See
|
||||||
* (keyed by the order's cart_id) rather than carrying the PaymentIntent
|
* docs/payments.md "Async resolution" for the full reasoning.
|
||||||
* object across the event boundary itself.
|
|
||||||
*/
|
*/
|
||||||
class StripePaymentDriver implements PaymentDriver
|
class StripePaymentDriver implements
|
||||||
|
Configurable,
|
||||||
|
SupportsPay,
|
||||||
|
SupportsAuthorization,
|
||||||
|
SupportsCaptures,
|
||||||
|
SupportsVoids,
|
||||||
|
SupportsRefunds,
|
||||||
|
HandlesPaymentCallback
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Same key lunarphp/stripe's own StripeManager reads its API key from
|
* Same key lunarphp/stripe's own StripeManager reads its API key from
|
||||||
* (Stripe::setApiKey(config('services.stripe.key')) in
|
* (Stripe::setApiKey(config('services.stripe.key'))) — no key, no
|
||||||
* StripeManager::__construct()) — no key, no usable driver.
|
* usable driver.
|
||||||
*/
|
*/
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
{
|
{
|
||||||
@@ -47,79 +70,307 @@ class StripePaymentDriver implements PaymentDriver
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws PaymentNotConfirmedException if Stripe hasn't confirmed the
|
* Atomic charge — capture_method: automatic. Stripe still frequently
|
||||||
* payment intent (wrong intent id, already processed, or the gateway
|
* confirms into requires_action/requires_confirmation rather than
|
||||||
* call itself fails) — nothing here should be treated as "confirm
|
* succeeded in the same call (3-D Secure, most real cards) — Pending
|
||||||
* anyway."
|
* is a normal outcome here, not an edge case, resolved later via
|
||||||
|
* handleCallback().
|
||||||
*/
|
*/
|
||||||
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void
|
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
|
||||||
{
|
{
|
||||||
$paymentIntentId = $data['payment_intent'];
|
return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'automatic');
|
||||||
|
|
||||||
$paymentIntentModel = StripePaymentIntent::where('intent_id', $paymentIntentId)->first();
|
|
||||||
|
|
||||||
if ($paymentIntentModel && ! $paymentIntentModel->isActive()) {
|
|
||||||
throw new PaymentNotConfirmedException('Payment intent already processed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $paymentIntentModel) {
|
|
||||||
$paymentIntentModel = StripePaymentIntent::create([
|
|
||||||
'intent_id' => $paymentIntentId,
|
|
||||||
'cart_id' => $cart->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$paymentIntentModel->update(['processing_at' => now()]);
|
|
||||||
|
|
||||||
$stripe = Stripe::getClient();
|
|
||||||
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
|
|
||||||
|
|
||||||
if (! $paymentIntent) {
|
|
||||||
throw new PaymentNotConfirmedException('Unable to locate payment intent.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$policy = config('lunar.stripe.policy', 'automatic');
|
|
||||||
|
|
||||||
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') {
|
|
||||||
$paymentIntent = $stripe->paymentIntents->capture($paymentIntentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) {
|
|
||||||
$paymentIntentModel->update(['status' => $paymentIntent->status]);
|
|
||||||
|
|
||||||
throw new PaymentNotConfirmedException(
|
|
||||||
$paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$paymentIntentModel->status = $paymentIntent->status;
|
|
||||||
$paymentIntentModel->save();
|
|
||||||
|
|
||||||
PaymentConfirmed::dispatch($cart, $type, $fingerprint, $data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registered in PaymentServiceProvider. Matches via the order's
|
* Hold only — capture_method: manual. Resolves to Pending or an
|
||||||
* cart_id against the StripePaymentIntent row confirm() wrote, so a
|
* authorized (requires_capture) intent, never succeeded directly:
|
||||||
* non-Stripe OrderPlaced (offline types fire the same event) is
|
* Stripe never captures on its own for a manual intent.
|
||||||
* ignored rather than acted on.
|
|
||||||
*/
|
*/
|
||||||
public function onOrderPlaced(OrderPlaced $event): void
|
public function authorize(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
|
||||||
{
|
{
|
||||||
$order = $event->order;
|
return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'manual');
|
||||||
|
|
||||||
$paymentIntentModel = StripePaymentIntent::where('cart_id', $order->cart_id)->first();
|
|
||||||
|
|
||||||
if (! $paymentIntentModel) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$paymentIntentModel->order_id = $order->id;
|
private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult
|
||||||
$paymentIntentModel->processed_at = now();
|
{
|
||||||
$paymentIntentModel->save();
|
try {
|
||||||
|
$paymentIntent = Stripe::getClient()->paymentIntents->create([
|
||||||
|
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
||||||
|
'currency' => $amount->currency->code,
|
||||||
|
'capture_method' => $captureMethod,
|
||||||
|
'confirm' => true,
|
||||||
|
'payment_method' => $data['payment_method'] ?? null,
|
||||||
|
'automatic_payment_methods' => isset($data['payment_method'])
|
||||||
|
? null
|
||||||
|
: ['enabled' => true],
|
||||||
|
]);
|
||||||
|
} catch (ApiErrorException $e) {
|
||||||
|
return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
|
||||||
|
}
|
||||||
|
|
||||||
$paymentIntent = Stripe::getClient()->paymentIntents->retrieve($paymentIntentModel->intent_id);
|
$this->rememberIntent($paymentIntent, $type, $context);
|
||||||
|
|
||||||
UpdateOrderFromIntent::execute($order, $paymentIntent);
|
return $this->resultFromIntent($type, $paymentIntent, $amount, $context, authorizing: $captureMethod === 'manual');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleCallback(string $reference, array $data, array $context = []): PaymentResult
|
||||||
|
{
|
||||||
|
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? '');
|
||||||
|
|
||||||
|
$paymentIntent = Stripe::getClient()->paymentIntents->retrieve($reference);
|
||||||
|
|
||||||
|
$authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL;
|
||||||
|
|
||||||
|
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && ! $authorizing) {
|
||||||
|
// automatic capture_method, but Stripe stopped short of
|
||||||
|
// capturing (rare, but the API contract allows it) — finish
|
||||||
|
// the job pay() started.
|
||||||
|
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
$intentModel?->update(['status' => $paymentIntent->status]);
|
||||||
|
|
||||||
|
$amount = $this->priceFromIntent($paymentIntent);
|
||||||
|
|
||||||
|
return $this->resultFromIntent($type, $paymentIntent, $amount, $context, $authorizing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function capture(string $reference, Price $amount, array $context = []): PaymentResult
|
||||||
|
{
|
||||||
|
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [
|
||||||
|
'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
||||||
|
]);
|
||||||
|
} catch (ApiErrorException $e) {
|
||||||
|
$result = $this->failure($amount, $e, $reference);
|
||||||
|
PaymentCaptureFailed::dispatch($type, $result, $context);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$intentModel?->update(['status' => $paymentIntent->status]);
|
||||||
|
|
||||||
|
$result = new PaymentResult(
|
||||||
|
status: $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
|
||||||
|
? PaymentResultStatus::Succeeded
|
||||||
|
: PaymentResultStatus::Failed,
|
||||||
|
reference: $paymentIntent->id,
|
||||||
|
amount: $amount,
|
||||||
|
raw: $paymentIntent->toArray(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
|
||||||
|
? PaymentCaptured::dispatch($type, $result, $context)
|
||||||
|
: PaymentCaptureFailed::dispatch($type, $result, $context);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function void(string $reference, Price $amount, array $context = []): PaymentResult
|
||||||
|
{
|
||||||
|
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$paymentIntent = Stripe::getClient()->paymentIntents->cancel($reference);
|
||||||
|
} catch (ApiErrorException $e) {
|
||||||
|
$result = $this->failure($amount, $e, $reference);
|
||||||
|
PaymentVoidFailed::dispatch($type, $result, $context);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$intentModel?->update(['status' => $paymentIntent->status]);
|
||||||
|
|
||||||
|
$result = new PaymentResult(
|
||||||
|
status: $paymentIntent->status === PaymentIntent::STATUS_CANCELED
|
||||||
|
? PaymentResultStatus::Succeeded
|
||||||
|
: PaymentResultStatus::Failed,
|
||||||
|
reference: $paymentIntent->id,
|
||||||
|
amount: $amount,
|
||||||
|
raw: $paymentIntent->toArray(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$paymentIntent->status === PaymentIntent::STATUS_CANCELED
|
||||||
|
? PaymentVoided::dispatch($type, $result, $context)
|
||||||
|
: PaymentVoidFailed::dispatch($type, $result, $context);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refund(string $reference, Price $amount, array $context = []): PaymentResult
|
||||||
|
{
|
||||||
|
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$refund = Stripe::getClient()->refunds->create([
|
||||||
|
'payment_intent' => $reference,
|
||||||
|
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
||||||
|
]);
|
||||||
|
} catch (ApiErrorException $e) {
|
||||||
|
$result = $this->failure($amount, $e, $reference);
|
||||||
|
PaymentRefundFailed::dispatch($type, $result, $context);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = new PaymentResult(
|
||||||
|
status: $refund->status !== 'failed' ? PaymentResultStatus::Succeeded : PaymentResultStatus::Failed,
|
||||||
|
reference: $refund->id,
|
||||||
|
amount: $amount,
|
||||||
|
raw: $refund->toArray(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$refund->status !== 'failed'
|
||||||
|
? PaymentRefunded::dispatch($type, $result, $context)
|
||||||
|
: PaymentRefundFailed::dispatch($type, $result, $context);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rememberIntent(PaymentIntent $paymentIntent, string $type, array $context): ?StripePaymentIntent
|
||||||
|
{
|
||||||
|
if (! ($context['cart_id'] ?? null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StripePaymentIntent::create([
|
||||||
|
'intent_id' => $paymentIntent->id,
|
||||||
|
'cart_id' => $context['cart_id'],
|
||||||
|
'order_id' => $context['order_id'] ?? null,
|
||||||
|
'status' => $paymentIntent->status,
|
||||||
|
'payment_type' => $type,
|
||||||
|
'context' => json_encode($context),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one lookup every method past initiate() shares: find the
|
||||||
|
* StripePaymentIntent row this $reference belongs to, then recover
|
||||||
|
* $type/$context from it — the original context, if any, always
|
||||||
|
* takes precedence over whatever the caller passed in (see
|
||||||
|
* handleCallback()'s own note: a webhook caller usually has none of
|
||||||
|
* its own).
|
||||||
|
*
|
||||||
|
* $typeFallback only matters when there's no $intentModel to read
|
||||||
|
* payment_type from — handleCallback() has its own $data['type'] to
|
||||||
|
* fall back to; capture()/void()/refund() have nothing better than ''.
|
||||||
|
*
|
||||||
|
* @return array{0: ?StripePaymentIntent, 1: string, 2: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
private function resolveIntentModel(string $reference, array $context, string $typeFallback = ''): array
|
||||||
|
{
|
||||||
|
$intentModel = StripePaymentIntent::where('intent_id', $reference)->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
$intentModel,
|
||||||
|
$intentModel?->payment_type ?? $typeFallback,
|
||||||
|
$this->decodeContext($intentModel) ?? $context,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StripePaymentIntent is a vendor model (lunarphp/stripe) with no cast
|
||||||
|
* declared for our own 'context' column (added by boboko-core's own
|
||||||
|
* migration, see database/migrations/..._add_context_to_stripe_
|
||||||
|
* payment_intents.php) — we can't edit the vendor model to add one, so
|
||||||
|
* decode manually here instead of assuming Eloquent already did it.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function decodeContext(?StripePaymentIntent $intentModel): ?array
|
||||||
|
{
|
||||||
|
if (! $intentModel || ! $intentModel->context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode($intentModel->context, associative: true) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a live Stripe PaymentIntent's own amount/currency back
|
||||||
|
* into Lunar's Price — the one place this class reads a Stripe
|
||||||
|
* response's amount without already holding the Price that produced
|
||||||
|
* it (handleCallback() has no $data['amount'] to fall back on, unlike
|
||||||
|
* pay()/authorize()).
|
||||||
|
*/
|
||||||
|
private function priceFromIntent(PaymentIntent $paymentIntent): Price
|
||||||
|
{
|
||||||
|
$currency = Currency::whereRaw('lower(code) = ?', [strtolower($paymentIntent->currency)])->firstOrFail();
|
||||||
|
|
||||||
|
return new Price(
|
||||||
|
(int) StripeManager::fromStripeAmount($paymentIntent->amount, $currency),
|
||||||
|
$currency,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resultFromIntent(
|
||||||
|
string $type,
|
||||||
|
PaymentIntent $paymentIntent,
|
||||||
|
Price $amount,
|
||||||
|
array $context,
|
||||||
|
bool $authorizing,
|
||||||
|
): PaymentResult {
|
||||||
|
$status = match ($paymentIntent->status) {
|
||||||
|
PaymentIntent::STATUS_SUCCEEDED => PaymentResultStatus::Succeeded,
|
||||||
|
PaymentIntent::STATUS_REQUIRES_CAPTURE => $authorizing ? PaymentResultStatus::Succeeded : PaymentResultStatus::Pending,
|
||||||
|
PaymentIntent::STATUS_CANCELED => PaymentResultStatus::Failed,
|
||||||
|
default => PaymentResultStatus::Pending,
|
||||||
|
};
|
||||||
|
|
||||||
|
$result = new PaymentResult(
|
||||||
|
status: $status,
|
||||||
|
reference: $paymentIntent->id,
|
||||||
|
amount: $amount,
|
||||||
|
failureReason: $paymentIntent->last_payment_error->message ?? null,
|
||||||
|
raw: $paymentIntent->toArray(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status === PaymentResultStatus::Pending) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$succeeded = $status === PaymentResultStatus::Succeeded;
|
||||||
|
|
||||||
|
if ($authorizing) {
|
||||||
|
$succeeded
|
||||||
|
? PaymentAuthorized::dispatch($type, $result, $context)
|
||||||
|
: PaymentAuthorizationFailed::dispatch($type, $result, $context);
|
||||||
|
} else {
|
||||||
|
$succeeded
|
||||||
|
? PaymentCaptured::dispatch($type, $result, $context)
|
||||||
|
: PaymentCaptureFailed::dispatch($type, $result, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult
|
||||||
|
{
|
||||||
|
$result = $this->failure($amount, $e);
|
||||||
|
|
||||||
|
$authorizing
|
||||||
|
? PaymentAuthorizationFailed::dispatch($type, $result, $context)
|
||||||
|
: PaymentCaptureFailed::dispatch($type, $result, $context);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failure(Price $amount, ApiErrorException $e, string $reference = ''): PaymentResult
|
||||||
|
{
|
||||||
|
$stripeError = $e->getError();
|
||||||
|
|
||||||
|
return new PaymentResult(
|
||||||
|
status: PaymentResultStatus::Failed,
|
||||||
|
reference: $reference ?: ($stripeError->payment_intent->id ?? ''),
|
||||||
|
amount: $amount,
|
||||||
|
failureReason: $e->getMessage(),
|
||||||
|
retriable: in_array($stripeError->decline_code ?? null, [
|
||||||
|
'do_not_honor', 'insufficient_funds', 'card_velocity_exceeded',
|
||||||
|
'processing_error', 'try_again_later', 'issuer_not_available',
|
||||||
|
], true),
|
||||||
|
raw: $stripeError?->toArray() ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Payment\Listeners;
|
|
||||||
|
|
||||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
|
||||||
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The status-mapping step OfflinePaymentDriver used to do inline right
|
|
||||||
* after CheckoutService::placeOrder() returned — moved out to a listener
|
|
||||||
* since confirm() can no longer rely on that return value (see
|
|
||||||
* PaymentDriver's docblock).
|
|
||||||
*
|
|
||||||
* Every offline-style type shares OfflinePaymentDriver, so
|
|
||||||
* $order->meta['payment_method'] is checked against
|
|
||||||
* config('lunar.payments.types') to confirm the placed order actually
|
|
||||||
* belongs to one of them, rather than assuming every OrderPlaced is
|
|
||||||
* this listener's to act on — a Stripe order placed via
|
|
||||||
* StripePaymentDriver fires the same event.
|
|
||||||
*/
|
|
||||||
class ApplyOfflinePaymentStatus
|
|
||||||
{
|
|
||||||
public function handle(OrderPlaced $event): void
|
|
||||||
{
|
|
||||||
$order = $event->order;
|
|
||||||
$type = $order->meta['payment_method'] ?? null;
|
|
||||||
|
|
||||||
if (! $type || config("lunar.payments.types.{$type}.payment_driver") !== OfflinePaymentDriver::class) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$order->update([
|
|
||||||
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user