Compare commits

...
3 Commits
Author SHA1 Message Date
arvanitakis e9aa08a338 Bump version to 0.13.1 2026-09-03 18:28:22 +03:00
arvanitakis 0676a1f5c7 Feat: Recording Payment Transactions 2026-09-03 18:26:48 +03:00
arvanitakis 8e8ec17d09 Bump version to 0.13.0 2026-09-03 17:44:22 +03:00
6 changed files with 148 additions and 50 deletions
+28
View File
@@ -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
View File
@@ -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,
]);
}
}
+7
View File
@@ -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,