Compare commits
2
Commits
3e45b84636
...
v0.16.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f4c1a22ea | ||
|
|
13d5833d18 |
@@ -4,6 +4,53 @@ 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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [0.16.3] - 2026-09-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Stripe `createAndConfirm()` built its `PaymentIntent` params with
|
||||||
|
`'automatic_payment_methods' => isset($data['payment_method']) ? null : ['enabled' => true]`. The
|
||||||
|
Stripe PHP SDK does not omit `null`-valued params from `create()` — it serializes them to an empty
|
||||||
|
string (`ApiRequestor::_encodeObjects()` → `Util::utf8(null)`), and Stripe's API rejects an empty
|
||||||
|
`automatic_payment_methods`. Every Stripe charge failed before it started whenever a
|
||||||
|
`payment_method` was supplied (i.e. every real charge in this flow). Fixed by building `$params`
|
||||||
|
conditionally so the key is either omitted entirely or set to `['enabled' => true]`, never `null`.
|
||||||
|
- `Modules\Core\Payment\Filament\Resources\PaymentMethodResource`'s "Driver status" column only
|
||||||
|
flagged a payment method whose driver *class* no longer resolves (`driver_missing_at`) — it gave
|
||||||
|
no indication when a driver resolves fine but fails `Configurable::isConfigured()` (e.g. Stripe
|
||||||
|
enabled in the DB with no `services.stripe.key` set), which `CheckoutService::getPaymentMethods()`
|
||||||
|
filters out identically. An admin had no way to tell "this method is silently absent at checkout
|
||||||
|
because of missing config" from "everything's fine" at a glance. The same icon column now also
|
||||||
|
reflects `isConfigured()`, with a tooltip distinguishing "driver not found" from "missing required
|
||||||
|
configuration" from "fully configured."
|
||||||
|
- `Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee` (now `ApplyPaymentMethodFee`) had two
|
||||||
|
stacked bugs that together meant a configured payment-method fee (e.g. €5 on Cash on Delivery)
|
||||||
|
never actually reached the cart total:
|
||||||
|
- `PaymentMethod::where(...)->value('data->fee')` silently returned `null` on Postgres — Laravel's
|
||||||
|
query builder does not translate the `->` JSON-path column-selector syntax in `value()`/`pluck()`
|
||||||
|
the way it does inside `where()` clauses, so this resolved to a discarded
|
||||||
|
`stdClass::$data->fee` property access instead of the actual fee. Fixed by loading the model and
|
||||||
|
reading the cast `->data['fee']` attribute instead.
|
||||||
|
- Even with the fee correctly read, adding it directly to `$cart->shippingTotal` didn't survive:
|
||||||
|
`Lunar\Pipelines\Cart\CalculateTax`, which runs later in the same cart-calculation pipeline,
|
||||||
|
unconditionally recomputes `shippingTotal` (and shipping tax) from `$cart->shippingBreakdown`'s
|
||||||
|
item sum — silently discarding anything set only on the plain property. Fixed by adding the fee
|
||||||
|
as its own `Lunar\Base\ValueObjects\Cart\ShippingBreakdownItem` on `shippingBreakdown` instead,
|
||||||
|
so it survives `CalculateTax`'s recompute and is correctly included in shipping tax too.
|
||||||
|
- Also generalized while fixing: the pipeline was hardcoded to the literal type string
|
||||||
|
`cash-on-delivery`. Renamed to `ApplyPaymentMethodFee` and changed it to look up whichever
|
||||||
|
`PaymentMethod` row matches `Cart::meta['payment_method']` and apply its own `data.fee` if
|
||||||
|
present — works for any payment method configured with a fee, not just one specific slug.
|
||||||
|
- `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` called `$cart->calculate()`
|
||||||
|
after saving the new payment method, but `Lunar\Models\Cart::calculate()` no-ops if the cart
|
||||||
|
instance was already calculated earlier in the same request (`Cart::isCalculated()`) —
|
||||||
|
`Lunar\Managers\CartSessionManager` memoizes one `Cart` instance per request, so this was true on
|
||||||
|
every request where the checkout page's initial render had already calculated the cart. The
|
||||||
|
result: after switching payment methods, the just-saved `meta['payment_method']` change was
|
||||||
|
persisted, but the cart's totals silently kept reflecting whichever method was calculated *first*
|
||||||
|
in the request — a shopper switching from Cash in Hand to Cash on Delivery would keep seeing Cash
|
||||||
|
in Hand's total, with no COD fee applied, until something else forced a fresh calculation. Fixed
|
||||||
|
by calling `$cart->recalculate()` instead, which forces the pipeline to re-run.
|
||||||
|
|
||||||
## [0.16.2] - 2026-09-09
|
## [0.16.2] - 2026-09-09
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
|
use Modules\Core\Payment\Pipelines\Cart\ApplyPaymentMethodFee;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
/*
|
/*
|
||||||
@@ -9,8 +9,8 @@ return [
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so
|
| Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so
|
||||||
| the cash-on-delivery fee is added to the shipping total before the
|
| the selected payment method's own fee (if any) is added to the
|
||||||
| final Calculate step sums everything up.
|
| shipping total before the final Calculate step sums everything up.
|
||||||
|
|
|
|
||||||
| This is the one thing left in this file — everything about WHICH
|
| This is the one thing left in this file — everything about WHICH
|
||||||
| payment methods exist (driver mapping, capture_mode, statuses) moved
|
| payment methods exist (driver mapping, capture_mode, statuses) moved
|
||||||
@@ -23,6 +23,6 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'cart_pipeline' => [
|
'cart_pipeline' => [
|
||||||
ApplyCashOnDeliveryFee::class,
|
ApplyPaymentMethodFee::class,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -173,19 +173,19 @@ class CheckoutService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Records which payment type the shopper picked (Cart::meta
|
* Records which payment type the shopper picked (Cart::meta
|
||||||
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
|
* ['payment_method']) — read by Modules\Core\Payment\Pipelines\
|
||||||
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total
|
* Cart\ApplyPaymentMethodFee to add that method's own `data.fee` (if
|
||||||
* adjustments before recalculation.
|
* any) before recalculation.
|
||||||
*
|
*
|
||||||
* Also snapshots Cart::fingerprint() into meta, *after* saving the
|
* Also snapshots Cart::fingerprint() into meta, *after* saving the
|
||||||
* chosen type — the fingerprint has to reflect the final total
|
* chosen type — the fingerprint has to reflect the final total
|
||||||
* including any payment-type-specific adjustment (e.g. a COD
|
* including any payment-method-specific fee, which only exists once
|
||||||
* surcharge), which only exists once payment_method is set and the
|
* payment_method is set and the cart recalculates. Captured here,
|
||||||
* cart recalculates. Captured here, server-side, rather than asked of
|
* server-side, rather than asked of the storefront: this is the last
|
||||||
* the storefront: this is the last moment before initiatePayment() that
|
* moment before initiatePayment() that the shopper's reviewed total is
|
||||||
* the shopper's reviewed total is known, and initiatePayment() reads it
|
* known, and initiatePayment() reads it back internally instead of
|
||||||
* back internally instead of taking a fingerprint parameter — a
|
* taking a fingerprint parameter — a storefront should never need to
|
||||||
* storefront should never need to know Cart::fingerprint() exists.
|
* know Cart::fingerprint() exists.
|
||||||
*
|
*
|
||||||
* Does not itself call a payment driver — selecting a method and
|
* Does not itself call a payment driver — selecting a method and
|
||||||
* initiating payment against it are deliberately separate steps, same
|
* initiating payment against it are deliberately separate steps, same
|
||||||
@@ -204,7 +204,15 @@ class CheckoutService
|
|||||||
$cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
|
$cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
|
||||||
$cart->save();
|
$cart->save();
|
||||||
|
|
||||||
$cart = $cart->calculate();
|
// Cart::calculate() no-ops if this cart instance was already
|
||||||
|
// calculated earlier in the request (Cart::isCalculated()) — which
|
||||||
|
// it will have been if the shopper switches payment method after
|
||||||
|
// the checkout page's first render already calculated it. Without
|
||||||
|
// recalculate() forcing a fresh run, the just-saved payment_method
|
||||||
|
// (and any fee tied to it, see ApplyPaymentMethodFee) would never
|
||||||
|
// be reflected — the summary would keep showing whichever method
|
||||||
|
// was calculated first.
|
||||||
|
$cart = $cart->recalculate();
|
||||||
$cart->meta = [...($cart->meta?->toArray() ?? []), 'checkout_fingerprint' => $cart->fingerprint()];
|
$cart->meta = [...($cart->meta?->toArray() ?? []), 'checkout_fingerprint' => $cart->fingerprint()];
|
||||||
$cart->save();
|
$cart->save();
|
||||||
|
|
||||||
|
|||||||
@@ -95,17 +95,21 @@ class StripePaymentDriver implements
|
|||||||
|
|
||||||
private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult
|
private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult
|
||||||
{
|
{
|
||||||
|
$params = [
|
||||||
|
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
||||||
|
'currency' => $amount->currency->code,
|
||||||
|
'capture_method' => $captureMethod,
|
||||||
|
'confirm' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($data['payment_method'])) {
|
||||||
|
$params['payment_method'] = $data['payment_method'];
|
||||||
|
} else {
|
||||||
|
$params['automatic_payment_methods'] = ['enabled' => true];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$paymentIntent = Stripe::getClient()->paymentIntents->create([
|
$paymentIntent = Stripe::getClient()->paymentIntents->create($params);
|
||||||
'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) {
|
} catch (ApiErrorException $e) {
|
||||||
return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
|
return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Filament\Tables\Columns\TextColumn;
|
|||||||
use Filament\Tables\Columns\ToggleColumn;
|
use Filament\Tables\Columns\ToggleColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Modules\Core\Payment\Contracts\Configurable;
|
||||||
use Modules\Core\Payment\Events\PaymentMethodsReordered;
|
use Modules\Core\Payment\Events\PaymentMethodsReordered;
|
||||||
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
|
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
|
||||||
use Modules\Core\Payment\Models\PaymentMethod;
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
@@ -44,10 +45,15 @@ use Modules\Core\Payment\Services\PaymentMethodService;
|
|||||||
* already correct in the database by the time it fires.
|
* already correct in the database by the time it fires.
|
||||||
*
|
*
|
||||||
* `driver_missing_at` (set by the `boboko:payment:sync-drivers` command
|
* `driver_missing_at` (set by the `boboko:payment:sync-drivers` command
|
||||||
* when a row's driver no longer resolves) is surfaced as its own table
|
* when a row's driver no longer resolves) drives the "Driver status"
|
||||||
* column, deliberately distinct from `enabled` — an admin needs to tell
|
* column, deliberately distinct from `enabled` — an admin needs to tell
|
||||||
* "I turned this off" apart from "the driver code was removed" at a
|
* "I turned this off" apart from "this driver isn't usable right now" at
|
||||||
* glance, not have both look like the same disabled state.
|
* a glance, not have both look like the same disabled state. That column
|
||||||
|
* also folds in Configurable::isConfigured() (e.g. Stripe with no API key
|
||||||
|
* set) — a class-resolves-but-isn't-usable state that CheckoutService::
|
||||||
|
* getPaymentMethods() filters out identically to a missing driver, so an
|
||||||
|
* admin needs the same at-a-glance warning for it, not just a silently
|
||||||
|
* absent checkout option.
|
||||||
*
|
*
|
||||||
* `authorized_status` only appears in the form when `capture_mode` is
|
* `authorized_status` only appears in the form when `capture_mode` is
|
||||||
* "Hold now, charge later" — it's simply unreachable for a "Charge
|
* "Hold now, charge later" — it's simply unreachable for a "Charge
|
||||||
@@ -85,13 +91,12 @@ class PaymentMethodResource extends Resource
|
|||||||
IconColumn::make('driver_missing_at')
|
IconColumn::make('driver_missing_at')
|
||||||
->label('Driver status')
|
->label('Driver status')
|
||||||
->boolean()
|
->boolean()
|
||||||
->trueIcon('heroicon-o-exclamation-triangle')
|
->state(fn (PaymentMethod $record) => ! $record->driver_missing_at && static::driverIsConfigured($record->driver))
|
||||||
->falseIcon('heroicon-o-check-circle')
|
->trueIcon('heroicon-o-check-circle')
|
||||||
->trueColor('danger')
|
->falseIcon('heroicon-o-exclamation-triangle')
|
||||||
->falseColor('success')
|
->trueColor('success')
|
||||||
->tooltip(fn (PaymentMethod $record) => $record->driver_missing_at
|
->falseColor('danger')
|
||||||
? 'Driver not found as of '.$record->driver_missing_at->diffForHumans()
|
->tooltip(fn (PaymentMethod $record) => static::driverStatusTooltip($record)),
|
||||||
: 'Driver resolves correctly'),
|
|
||||||
ToggleColumn::make('enabled')
|
ToggleColumn::make('enabled')
|
||||||
->label('Enabled')
|
->label('Enabled')
|
||||||
->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class)
|
->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class)
|
||||||
@@ -260,4 +265,33 @@ class PaymentMethodResource extends Resource
|
|||||||
|
|
||||||
return app(PaymentDriverRegistry::class)->label($key) ?? $key;
|
return app(PaymentDriverRegistry::class)->label($key) ?? $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* False for a missing driver too, since Configurable::isConfigured()
|
||||||
|
* has nothing to ask in that case — driverStatusTooltip() below is
|
||||||
|
* what tells the two reasons apart for the admin.
|
||||||
|
*/
|
||||||
|
private static function driverIsConfigured(?string $key): bool
|
||||||
|
{
|
||||||
|
$driver = $key ? app(PaymentDriverRegistry::class)->resolve($key) : null;
|
||||||
|
|
||||||
|
if (! $driver instanceof Configurable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $driver->isConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function driverStatusTooltip(PaymentMethod $record): string
|
||||||
|
{
|
||||||
|
if ($record->driver_missing_at) {
|
||||||
|
return 'Driver not found as of '.$record->driver_missing_at->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! static::driverIsConfigured($record->driver)) {
|
||||||
|
return 'Driver resolves, but is missing required configuration (e.g. an API key) — it will not be offered at checkout.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Driver resolves correctly and is fully configured.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* creatable/deletable, same split Modules\Core\Shipping's own
|
* creatable/deletable, same split Modules\Core\Shipping's own
|
||||||
* shipping_methods table already has (see docs/payments.md):
|
* shipping_methods table already has (see docs/payments.md):
|
||||||
* - type: unique, machine-facing slug (Cart::meta['payment_method'],
|
* - type: unique, machine-facing slug (Cart::meta['payment_method'],
|
||||||
* ApplyCashOnDeliveryFee's lookup key, every Payment event's $type).
|
* ApplyPaymentMethodFee's lookup key, every Payment event's $type).
|
||||||
* - name: admin-facing label.
|
* - name: admin-facing label.
|
||||||
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
|
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
|
||||||
* — NOT the same as `type`, and not unique (two rows can share one
|
* — NOT the same as `type`, and not unique (two rows can share one
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Payment\Pipelines\Cart;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Lunar\DataTypes\Price;
|
|
||||||
use Lunar\Models\Contracts\Cart as CartContract;
|
|
||||||
use Modules\Core\Payment\Models\PaymentMethod;
|
|
||||||
|
|
||||||
final class ApplyCashOnDeliveryFee
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Called just before cart totals are calculated.
|
|
||||||
*
|
|
||||||
* @param Closure(CartContract): mixed $next
|
|
||||||
*/
|
|
||||||
public function handle(CartContract $cart, Closure $next): mixed
|
|
||||||
{
|
|
||||||
if (($cart->meta['payment_method'] ?? null) === 'cash-on-delivery') {
|
|
||||||
$fee = (int) (PaymentMethod::where('type', 'cash-on-delivery')->value('data->fee') ?? 0);
|
|
||||||
|
|
||||||
$cart->shippingTotal = new Price(
|
|
||||||
($cart->shippingTotal?->value ?? 0) + $fee,
|
|
||||||
$cart->currency,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($cart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Pipelines\Cart;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Lunar\Base\ValueObjects\Cart\ShippingBreakdownItem;
|
||||||
|
use Lunar\DataTypes\Price;
|
||||||
|
use Lunar\Models\Contracts\Cart as CartContract;
|
||||||
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
|
|
||||||
|
final class ApplyPaymentMethodFee
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Called just before cart totals are calculated, right after
|
||||||
|
* Lunar\Pipelines\Cart\ApplyShipping. Generic across every
|
||||||
|
* Modules\Core\Payment\Models\PaymentMethod row, not just cash on
|
||||||
|
* delivery — whichever type the shopper picked (Cart::meta
|
||||||
|
* ['payment_method']), its own `data.fee` (set via the Filament "Edit
|
||||||
|
* fee" action) is applied if present, no matter its slug/name/driver.
|
||||||
|
*
|
||||||
|
* Must add the fee as its own Lunar\Base\ValueObjects\Cart\
|
||||||
|
* ShippingBreakdownItem on $cart->shippingBreakdown rather than
|
||||||
|
* bumping $cart->shippingTotal directly — the later Lunar\Pipelines\
|
||||||
|
* Cart\CalculateTax step unconditionally recomputes shippingTotal
|
||||||
|
* (and shipping tax) from shippingBreakdown's item sum, so a value
|
||||||
|
* set only on the plain property is silently discarded before the
|
||||||
|
* cart finishes calculating.
|
||||||
|
*
|
||||||
|
* @param Closure(CartContract): mixed $next
|
||||||
|
*/
|
||||||
|
public function handle(CartContract $cart, Closure $next): mixed
|
||||||
|
{
|
||||||
|
$type = $cart->meta['payment_method'] ?? null;
|
||||||
|
|
||||||
|
if ($type) {
|
||||||
|
$fee = (int) (PaymentMethod::where('type', $type)->first()?->data['fee'] ?? 0);
|
||||||
|
|
||||||
|
if ($fee > 0) {
|
||||||
|
$cart->shippingBreakdown->items->put('payment-method-fee', new ShippingBreakdownItem(
|
||||||
|
name: 'Payment method fee',
|
||||||
|
identifier: 'payment-method-fee',
|
||||||
|
price: new Price($fee, $cart->currency, 1),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($cart);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user