Compare commits
3
Commits
e6f1ca179a
...
v0.13.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9aa08a338 | ||
|
|
0676a1f5c7 | ||
|
|
8e8ec17d09 |
@@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [0.13.0] - 2026-09-03
|
||||
|
||||
### Changed
|
||||
- **Breaking:** `Payment` is now a genuinely standalone module — no direct calls into `Checkout`/`Order`, no reaching into their Eloquent models, communication only via events. The entire old `confirm()`-based flow is gone: `Modules\Core\Payment\Contracts\PaymentDriver` (and the already-stale `Modules\Core\Checkout\Contracts\PaymentDriver` duplicate), `Checkout\Events\PaymentConfirmed`, `Payment\Contracts\InitiatesPayment`, `Payment\DataTransferObjects\PaymentInitiation`, `Payment\Enums\PaymentInitiationMode`, `Payment\Events\PaymentSucceeded`/`PaymentFailed`, `Payment\Events\OrderPaymentStatusResolved`, and `Payment\Exceptions\PaymentNotConfirmedException` are all deleted. This flow was non-functional on `master` before this release — `CheckoutService::confirmPayment()` dispatched an event nothing listened for, so no order was ever placed after payment.
|
||||
- **Breaking:** Every payment operation is now its own explicit, opt-in contract, modeled on how real gateways (Stripe, Mastercard's own gateway, Nexi) actually split these operations — see `docs/payments.md`: `Modules\Core\Payment\Contracts\SupportsPay` (atomic authorize+capture), `SupportsAuthorization` (hold only), `SupportsCaptures` (settle a prior hold), `SupportsVoids` (release a prior hold without settling), `SupportsRefunds` (reverse settled funds), `HandlesPaymentCallback` (resolve an async pay()/authorize() later, from a webhook), and `Configurable` (`isConfigured()`, split out of the old single `PaymentDriver` interface). A driver implements only the operations its gateway actually supports.
|
||||
- **Breaking:** Every amount flowing through these contracts is `Lunar\DataTypes\Price` (Lunar's own bundled minor-unit-value + `Currency` type) — never a bare `int` paired separately with a `Currency`. Each driver converts at its own boundary (e.g. `StripeManager::toStripeAmount()`/`fromStripeAmount()`); `Payment` itself only ever speaks Lunar's `Price`.
|
||||
- **Breaking:** `Modules\Core\Checkout\Services\CheckoutService::placeOrder()` and `confirmPayment()` are both replaced by a single `initiatePayment(string $fingerprint, array $data = []): Modules\Core\Payment\DTOs\PaymentResult`. It creates the draft `Order` (`Cart::createOrder()`, idempotent against an existing draft) and hands off directly to the resolved driver's `pay()`/`authorize()`, per that type's new `config('lunar.payments.types.{type}.capture_mode')` key. `Checkout\Events\OrderPlaced` no longer dispatches from `CheckoutService` — it now fires from `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` once a `PaymentCaptured`/`PaymentAuthorized` event actually transitions the order's `placed_at`, since a draft order can now exist well before payment resolves (an async gateway).
|
||||
- `Modules\Core\Payment\Services\PaymentDriverResolver::resolve()` now returns `?object` instead of the deleted `PaymentDriver` interface — a driver implements several independent capability interfaces at once, so a caller does its own `instanceof SupportsPay`/`instanceof SupportsAuthorization` check, the same pattern the capability interfaces themselves are designed around.
|
||||
- `config/payment.php`'s `cash-on-delivery` entry gains `capture_mode` (`'pay'`, since `OfflinePaymentDriver` only implements `SupportsPay`) and `captured_status` (`'payment-offline'`, replacing the previously dead `'authorized' => 'awaiting-payment'` key, which nothing ever read).
|
||||
|
||||
### Added
|
||||
- `Modules\Core\Payment\DTOs\PaymentResult` — the one return shape every operation (`pay`, `authorize`, `capture`, `void`, `refund`, `handleCallback`) produces, regardless of gateway: `status` (`Modules\Core\Payment\Enums\PaymentResultStatus`: `Succeeded`/`Failed`/`Pending`), `reference`, `amount` (a `Price`), `failureReason`, `retriable` (real on Stripe/Mastercard's own soft-decline classification, always `false` on Nexi — it has no such signal), `raw` (the untouched gateway response, for audit), `meta`, and `continuation` (see below).
|
||||
- `Modules\Core\Payment\DTOs\PaymentContinuation` / `Modules\Core\Payment\Enums\PaymentContinuationType` — what a caller does next with a `Pending` `PaymentResult`, gateway-agnostically (`Redirect` or `ClientSecret`), so a storefront controller never needs gateway-specific knowledge of e.g. Stripe's own `PaymentIntent` fields to drive a 3-D Secure/redirect continuation.
|
||||
- Eight new events, one terminal pair per operation, replacing the old single `PaymentSucceeded`/`PaymentFailed`: `PaymentAuthorized`/`PaymentAuthorizationFailed`, `PaymentCaptured`/`PaymentCaptureFailed`, `PaymentVoided`/`PaymentVoidFailed`, `PaymentRefunded`/`PaymentRefundFailed`. `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. Every event carries `{type, result: PaymentResult, context}` — `context` is an opaque bag the caller hands in and gets back untouched, so `Payment` never needs to know what a `Cart` or `Order` is.
|
||||
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` (rewired, not new — previously listened to the now-deleted `OrderPaymentStatusResolved`) is the only place an `Order`'s `status` column is written in reaction to a payment outcome: it listens to `PaymentCaptured`/`PaymentAuthorized` directly, reads `$event->context['order_id']`, and resolves the new status from `config('lunar.payments.types.{type}.captured_status')`/`authorized_status`.
|
||||
- `Modules\Core\Payment\Drivers\StripePaymentDriver` rewritten onto the new contracts — implements all six capability interfaces plus `Configurable`. Solves `handleCallback()`'s async-correlation problem (a webhook is a separate HTTP request from the `pay()`/`authorize()` call that started it) the same way `lunarphp/stripe`'s own `StripePaymentType`/`ProcessStripeWebhook` do: real `cart_id`/`order_id` columns on `Lunar\Stripe\Models\StripePaymentIntent`, plus two new columns this driver needs (`context`, `payment_type`) added by a new migration — `database/migrations/2026_09_03_000002_add_context_to_stripe_payment_intents.php`.
|
||||
- `Modules\Core\Payment\Http\Controllers\StripeWebhookController` + `src/Payment/routes/webhooks.php` (`POST /payments/stripe/webhook`, loaded by `PaymentServiceProvider`) — a boboko-owned webhook endpoint, deliberately not `lunarphp/stripe`'s own route (which dispatches into Lunar's own `Payments::driver('stripe')` flow, the flow this driver replaces). Reuses `Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware` and `Stripe\Webhook::constructEvent()` directly — both are genuine Stripe SDK signature verification, safe to reuse without touching the rest of that vendor package's flow. Requires `config('services.stripe.webhooks.lunar')` set in a consuming app; no `stripe` config type entry is added to `config/payment.php` in this release — enabling Stripe for real is a follow-up.
|
||||
- `docs/payments.md` — full design notes: the operation/contract table cross-referenced against Mastercard/Stripe/Nexi's real APIs, why `PaymentResult` normalizes only what every gateway can always provide, the async-correlation pattern, and what's explicitly out of scope (a `Transaction`-writing listener, the `stripe` config entry, frontend Stripe Elements integration).
|
||||
|
||||
### Fixed
|
||||
- `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` crashed (`Call to a member function toArray() on null`) the first time it ran against a cart whose `meta` column was still a genuine SQL `NULL` (any freshly-created cart) — `Cart::$meta`'s `AsArrayObject` cast returns `null`, not an empty array-like object, for a `null` column. Fixed with a null-safe fallback.
|
||||
- `Modules\Core\Shipping\Carriers\Acs\AcsRateDriver`/`BoxNowRateDriver` referenced `Lunar\Shipping\DTOs\ShippingOptionRequest`, a namespace that doesn't exist in the installed `lunarphp/table-rate-shipping` version (the real class is `Lunar\Shipping\DataTransferObjects\ShippingOptionRequest`) — crashed `Illuminate\Support\Manager`'s interface-compatibility check the moment anything touched `ShippingManager::getSupportedDrivers()`, including simply adding a line to a cart (via `Modules\Core\Shipping\Listeners\FlushLivePricingCache`).
|
||||
|
||||
## [0.13.1] - 2026-09-03
|
||||
|
||||
### Added
|
||||
- `Modules\Core\Order\Listeners\RecordPaymentTransaction` — writes the `lunar_transactions` row for a successful `PaymentCaptured`/`PaymentAuthorized`/`PaymentVoided`/`PaymentRefunded` event, via a new `Modules\Core\Order\Services\TransactionRecorder` (moved here from `Payment\Services`, and rewritten to take a `PaymentResult` directly instead of the deleted `CaptureResult`/`RefundResult` DTOs — `Payment` never writes to `Order`'s models, `Transaction.order_id` being required is exactly why this lives in `Order`, same reasoning as `ApplyResolvedPaymentStatus`). Closes a real gap introduced in `0.13.0`: `Order::paymentStatus()` (which derives its answer entirely from `$order->transactions`) always resolved to `PaymentStatus::Offline` — its "no transactions at all" fallback — regardless of what actually happened, since nothing had ever written a row. Verified live: a captured offline payment now produces a `type: capture` transaction and `Order::paymentStatus()` correctly resolves to `captured`.
|
||||
|
||||
## [0.12.1] - 2026-09-03
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "boboko/core",
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"type": "library",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Core\\": "src/"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Services\TransactionRecorder;
|
||||
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
use Modules\Core\Payment\Events\PaymentVoided;
|
||||
|
||||
/**
|
||||
* Writes the Transaction row for a successful payment outcome — the
|
||||
* "record what happened" half of reacting to Payment's events, separate
|
||||
* from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update
|
||||
* the order's status" half. Both listen to the same events for the same
|
||||
* reason: two independent reactions to one payment outcome, neither
|
||||
* calling the other (see docs/payments.md).
|
||||
*
|
||||
* Only registered against the SUCCESS events (PaymentCaptured,
|
||||
* PaymentAuthorized, PaymentVoided, PaymentRefunded) — a Failed event
|
||||
* never reaches here, since a failed attempt moved no money and settled
|
||||
* nothing worth auditing as a Transaction row (see OrderServiceProvider's
|
||||
* registration and docs/payments.md's "Explicitly out of scope" section
|
||||
* on why no Failed-side Order reaction exists at all).
|
||||
*
|
||||
* Same defensive $context['order_id'] ?? null early-return as
|
||||
* ApplyResolvedPaymentStatus — $context is caller-supplied and optional,
|
||||
* and this listener must not crash for a future non-Checkout caller of
|
||||
* pay()/authorize() with no order_id in its context.
|
||||
*/
|
||||
class RecordPaymentTransaction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TransactionRecorder $transactions,
|
||||
) {}
|
||||
|
||||
public function handle(PaymentCaptured|PaymentAuthorized|PaymentVoided|PaymentRefunded $event): void
|
||||
{
|
||||
$orderId = $event->context['order_id'] ?? null;
|
||||
|
||||
if ($orderId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = Order::findOrFail($orderId);
|
||||
|
||||
$type = match ($event::class) {
|
||||
PaymentAuthorized::class => 'intent',
|
||||
PaymentCaptured::class => 'capture',
|
||||
PaymentRefunded::class => 'refund',
|
||||
PaymentVoided::class => 'void',
|
||||
};
|
||||
|
||||
$this->transactions->record($order, $type, $event->type, $event->result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||
|
||||
/**
|
||||
* Writes the Transaction row a Payment operation's PaymentResult becomes —
|
||||
* the one place that translates Payment's gateway-agnostic result into
|
||||
* Lunar's own transactions table, in the same shape lunarphp/stripe's own
|
||||
* StoreCharges already writes (type, success, amount, reference, driver).
|
||||
* Lives in Order, not Payment — Transaction.order_id is required, and
|
||||
* Payment never writes to another module's models (see docs/payments.md);
|
||||
* this is the "read the event, do the write" half of that boundary, same
|
||||
* shape as Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus.
|
||||
*
|
||||
* Kept as its own class (not inlined into the listener that calls it) so a
|
||||
* future admin action (a manually-triggered capture/refund from Filament)
|
||||
* can write a row the same way, without going through an event at all.
|
||||
*/
|
||||
class TransactionRecorder
|
||||
{
|
||||
/**
|
||||
* $type is Lunar's own transaction type string — 'intent' (an
|
||||
* authorize()-produced hold), 'capture' (settled funds, whether via
|
||||
* pay() directly or capture() settling a prior intent), 'refund',
|
||||
* 'void' is NOT one of Lunar's three built-in types (Order::
|
||||
* paymentStatus() only ever reads 'intent'/'capture'/'refund' — see
|
||||
* Modules\Core\Order\Support\OrderStatus::payment()) — a void never
|
||||
* moved money, so it's still recorded for audit but $success reflects
|
||||
* whether the RELEASE succeeded, not a captured amount.
|
||||
*
|
||||
* $driver is the payment type key (e.g. 'stripe', 'cash-on-delivery'),
|
||||
* not a class name — matches the $type PaymentCaptured/etc. events
|
||||
* themselves carry, and what Transaction.driver already means
|
||||
* elsewhere in this codebase (see the old, now-removed
|
||||
* TransactionRecorder this replaces).
|
||||
*/
|
||||
public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction
|
||||
{
|
||||
return $order->transactions()->create([
|
||||
'success' => $result->status === PaymentResultStatus::Succeeded,
|
||||
'type' => $type,
|
||||
'driver' => $driver,
|
||||
'amount' => $result->amount->value,
|
||||
'reference' => $result->reference,
|
||||
'status' => $result->status->name,
|
||||
'notes' => $result->failureReason,
|
||||
'meta' => $result->meta,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Payment\DTOs\CaptureResult;
|
||||
use Modules\Core\Payment\DTOs\RefundResult;
|
||||
|
||||
/**
|
||||
* Writes the Transaction row a SupportsRefunds/SupportsCaptures driver's
|
||||
* result becomes — the one place that translates a gateway-agnostic
|
||||
* RefundResult/CaptureResult into Lunar's own transactions table, in the
|
||||
* same shape lunarphp/stripe's StoreCharges already writes (type, success,
|
||||
* amount, reference, driver, notes). Kept here rather than inside each
|
||||
* driver so every driver's rows land in a consistent shape that
|
||||
* Order::paymentStatus() and TransactionObserver both already understand,
|
||||
* without any driver needing to know about either.
|
||||
*/
|
||||
class TransactionRecorder
|
||||
{
|
||||
public function recordRefund(Order $order, string $driver, RefundResult $result, ?string $notes = null): Transaction
|
||||
{
|
||||
return $order->transactions()->create([
|
||||
'success' => $result->success,
|
||||
'type' => 'refund',
|
||||
'driver' => $driver,
|
||||
'amount' => $result->amount,
|
||||
'reference' => $result->reference,
|
||||
'status' => $result->success ? 'succeeded' : 'failed',
|
||||
'notes' => $notes ?? $result->message,
|
||||
'meta' => $result->meta,
|
||||
]);
|
||||
}
|
||||
|
||||
public function recordCapture(Order $order, string $driver, CaptureResult $result, ?string $notes = null): Transaction
|
||||
{
|
||||
return $order->transactions()->create([
|
||||
'success' => $result->success,
|
||||
'type' => 'capture',
|
||||
'driver' => $driver,
|
||||
'amount' => $result->amount,
|
||||
'reference' => $result->reference,
|
||||
'status' => $result->success ? 'succeeded' : 'failed',
|
||||
'notes' => $notes ?? $result->message,
|
||||
'meta' => $result->meta,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Lunar\Models\Transaction;
|
||||
use Modules\Core\Notification\NotificationRegistry;
|
||||
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
|
||||
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
|
||||
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
|
||||
use Modules\Core\Order\Notifications\OrderCapturedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
|
||||
use Modules\Core\Order\Notifications\OrderRefundedNotification;
|
||||
@@ -18,6 +19,8 @@ use Modules\Core\Order\Observers\TransactionObserver;
|
||||
use Modules\Core\Order\Support\OrderStatus;
|
||||
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
use Modules\Core\Payment\Events\PaymentVoided;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
class OrderServiceProvider extends ServiceProvider
|
||||
@@ -33,6 +36,10 @@ class OrderServiceProvider extends ServiceProvider
|
||||
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
|
||||
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
|
||||
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
|
||||
Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class);
|
||||
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
|
||||
Event::listen(PaymentVoided::class, RecordPaymentTransaction::class);
|
||||
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
|
||||
|
||||
NotificationRegistry::get()->register([
|
||||
OrderDeliveredNotification::class,
|
||||
|
||||
Reference in New Issue
Block a user