Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f4c1a22ea | ||
|
|
13d5833d18 | ||
|
|
3e45b84636 | ||
|
|
437cbf2460 | ||
|
|
5425a0396f | ||
|
|
4d0e326cb9 | ||
|
|
d4f9766940 | ||
|
|
359e1e262e | ||
|
|
fb684dc97b |
+111
@@ -4,6 +4,117 @@ 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.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
|
||||
|
||||
### Fixed
|
||||
- `Lunar\Base\ShippingManifest` is a request-lifetime singleton whose `getOptions()` re-runs the
|
||||
shipping modifier pipeline without ever clearing its `$options` collection first, and whose
|
||||
`addOption()` keeps the first entry per `getIdentifier()` and silently drops any later one. In
|
||||
practice, an option resolved for an earlier shipping address (or cart state) shadowed the
|
||||
correct one after the address/region changed within the same request — e.g. a carrier priced
|
||||
differently across two zones that both match an address would keep quoting the stale zone's
|
||||
price, and `ApplyShipping` would price the cart total off that same stale option. Renamed
|
||||
`Modules\Core\Shipping\Listeners\FlushLivePricingCache` to
|
||||
`Modules\Core\Shipping\Listeners\InvalidateShippingOptions` and had it additionally call
|
||||
`ShippingManifest::clearOptions()`, merged in because both invalidations fire on the exact same
|
||||
event set (`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`,
|
||||
`ShippingAddressSet`) — the only inputs the shipping modifier pipeline depends on.
|
||||
|
||||
## [0.16.1] - 2026-09-09
|
||||
|
||||
### Fixed
|
||||
- OTP login page (`resources/views/auth/filament/pages/login.blade.php`) had no visible spacing
|
||||
between the email/OTP input, error text, and buttons following the Filament v3 → v4 upgrade.
|
||||
The view relied on a bare `grid gap-y-4` Tailwind utility class, but since this view ships from
|
||||
the `boboko-core` package rather than a consuming app, that class was never present in any
|
||||
host app's compiled Tailwind output. Replaced with an inline `style` (flex column, `row-gap:
|
||||
1rem`) so the layout no longer depends on the consuming app's Tailwind content scanning.
|
||||
|
||||
## [0.16.0] - 2026-09-08
|
||||
|
||||
### Added
|
||||
- `Modules\Core\Checkout\Services\CheckoutService::setRecoveryConsent(bool $consent): Cart` — the
|
||||
shopper's promotional/abandoned-cart-recovery opt-in, given once during guest checkout and
|
||||
deliberately independent of `setShippingAddress()`/`setBillingAddress()`: consent is a
|
||||
cart-level decision, not tied to any one `CartAddress` — changing which address is on the cart
|
||||
later never resets or re-asks for it. Only an explicit call to this method (the checkbox itself
|
||||
being submitted) ever changes it; calling it again with `false` is how a later opt-out is
|
||||
recorded, per the legal requirement that consent be provable and withdrawable. Stored on
|
||||
`Cart::meta` (interim, per the design this implements — a real column/consent record is the
|
||||
eventual target, tracked as follow-up) as `recovery_consent` (bool), `recovery_consent_at`
|
||||
(ISO 8601, `null` when `false`), and `recovery_consent_policy_version`
|
||||
(`config('legal.privacy_policy_version')` at the moment of consent, so a later dispute is
|
||||
answered from what was actually agreed to). Dispatches new
|
||||
`Modules\Core\Checkout\Events\RecoveryConsentSet`. Newsletter opt-in is explicitly a separate
|
||||
scope — never merged into this flag.
|
||||
- `Modules\Core\Checkout\Services\CheckoutService::initiatePayment()` now requires `bool
|
||||
$termsAccepted` and `string $policyVersion` as mandatory parameters (not optional data a caller
|
||||
might omit) — throws the new `Modules\Core\Checkout\Exceptions\TermsNotAcceptedException`
|
||||
*before* `Cart::createOrder()` is ever called if `$termsAccepted` is `false`, so an order can
|
||||
never exist without a recorded acceptance (refused, not created-then-flagged). On success,
|
||||
writes `terms_accepted` (`true`), `terms_accepted_at` (ISO 8601), and
|
||||
`terms_accepted_policy_version` onto the created `Order`'s own `meta` — the durable,
|
||||
order-level audit trail for a consumer-contract acceptance dispute, written directly (not via
|
||||
an event/listener) since the `Order` row doesn't exist until `createOrder()` returns.
|
||||
- `Modules\Core\Cart\Commands\DetectAbandonedCarts` — both its `CartAbandoned` and
|
||||
`CheckoutAbandoned` detection queries now require `meta->recovery_consent = true`. A
|
||||
non-consenting cart's abandonment is never dispatched at all (not merely filtered later at
|
||||
whatever future recovery-email send step reads it) — the correct enforcement point per the
|
||||
legal requirement that recovery/marketing sends only ever reach carts that opted in.
|
||||
- `config/legal.php` (merged by a new `Modules\Core\Providers\CheckoutServiceProvider`) —
|
||||
`privacy_policy_version`/`terms_version`, plain `env()`-backed strings bumped by whoever edits
|
||||
the corresponding legal page. Recorded alongside every consent/acceptance rather than read live
|
||||
at dispute time, so what a shopper actually agreed to is answered from the cart/order itself.
|
||||
`CheckoutServiceProvider` itself is new — `Checkout` previously had no dedicated service
|
||||
provider at all (its service/events were resolved/dispatched without one).
|
||||
|
||||
## [0.15.0] - 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
"name": "boboko/core",
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"type": "library",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.3",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Core\\": "src/"
|
||||
@@ -37,6 +37,7 @@
|
||||
"Modules\\Core\\Providers\\CoreServiceProvider",
|
||||
"Modules\\Core\\Providers\\AuthServiceProvider",
|
||||
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
||||
"Modules\\Core\\Providers\\CheckoutServiceProvider",
|
||||
"Modules\\Core\\Providers\\PaymentServiceProvider",
|
||||
"Modules\\Core\\Providers\\LocalizationServiceProvider",
|
||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Policy versions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Plain version strings, bumped by whoever edits the corresponding legal
|
||||
| page — recorded alongside every consent/acceptance so a later dispute
|
||||
| ("what did the shopper actually agree to?") can be answered from the
|
||||
| order/cart itself rather than a live lookup against whatever the pages
|
||||
| say TODAY. Not tied to any CMS/database row on purpose — this stays a
|
||||
| plain config value the same way payment.php's cart_pipeline is a plain
|
||||
| cross-cutting setting, not a per-instance one.
|
||||
|
|
||||
*/
|
||||
'privacy_policy_version' => env('LEGAL_PRIVACY_POLICY_VERSION', '2026-01-01'),
|
||||
|
||||
'terms_version' => env('LEGAL_TERMS_VERSION', '2026-01-01'),
|
||||
];
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
|
||||
use Modules\Core\Payment\Pipelines\Cart\ApplyPaymentMethodFee;
|
||||
|
||||
return [
|
||||
/*
|
||||
@@ -9,8 +9,8 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so
|
||||
| the cash-on-delivery fee is added to the shipping total before the
|
||||
| final Calculate step sums everything up.
|
||||
| the selected payment method's own fee (if any) is added to the
|
||||
| shipping total before the final Calculate step sums everything up.
|
||||
|
|
||||
| This is the one thing left in this file — everything about WHICH
|
||||
| payment methods exist (driver mapping, capture_mode, statuses) moved
|
||||
@@ -23,6 +23,6 @@ return [
|
||||
|
|
||||
*/
|
||||
'cart_pipeline' => [
|
||||
ApplyCashOnDeliveryFee::class,
|
||||
ApplyPaymentMethodFee::class,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@if (! $otpSent)
|
||||
<form wire:submit="requestOtp">
|
||||
<div class="grid gap-y-4">
|
||||
<div style="display: flex; flex-direction: column; row-gap: 1rem;">
|
||||
<x-filament::input.wrapper>
|
||||
<x-filament::input
|
||||
type="email"
|
||||
@@ -24,7 +24,7 @@
|
||||
</form>
|
||||
@else
|
||||
<form wire:submit="authenticate">
|
||||
<div class="grid gap-y-4">
|
||||
<div style="display: flex; flex-direction: column; row-gap: 1rem;">
|
||||
<p class="text-sm text-gray-500">
|
||||
A login code was sent to <strong>{{ $email }}</strong>.
|
||||
</p>
|
||||
|
||||
@@ -31,6 +31,13 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
|
||||
* state at all; every cart still matching the query below refires its event
|
||||
* on every run until Recovery (not yet built — see
|
||||
* docs/recovery-strategies.md) owns its own dedup/tracking table.
|
||||
*
|
||||
* Both queries require meta->recovery_consent = true — CartAbandoned/
|
||||
* CheckoutAbandoned exist specifically to drive future recovery-email
|
||||
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
|
||||
* that consent is actually recorded), and a non-consenting cart's
|
||||
* abandonment must never be dispatched at all, not merely filtered later
|
||||
* at send time — see docs referenced above for the legal reasoning.
|
||||
*/
|
||||
class DetectAbandonedCarts extends Command
|
||||
{
|
||||
@@ -48,6 +55,7 @@ class DetectAbandonedCarts extends Command
|
||||
Cart::query()
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
->where('meta->recovery_consent', true)
|
||||
->with('lines')
|
||||
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
||||
foreach ($carts as $cart) {
|
||||
@@ -64,6 +72,7 @@ class DetectAbandonedCarts extends Command
|
||||
Cart::query()
|
||||
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
->where('meta->recovery_consent', true)
|
||||
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
|
||||
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
|
||||
foreach ($carts as $cart) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Checkout\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
/**
|
||||
* Dispatched by CheckoutService::setRecoveryConsent() every time the
|
||||
* shopper's promotional/abandoned-cart-recovery opt-in changes — including
|
||||
* an explicit opt-OUT (a later submit with the checkbox unticked), not
|
||||
* just an opt-in. $consent is the new value, already written to
|
||||
* Cart::meta by the time this fires.
|
||||
*/
|
||||
class RecoveryConsentSet
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly bool $consent,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Checkout\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown by CheckoutService::initiatePayment() when $termsAccepted is
|
||||
* false — an Order is a consumer contract, and its acceptance must be
|
||||
* refused rather than created-then-flagged. No Lunar exception type
|
||||
* covers this, same reasoning as UnknownPaymentTypeException.
|
||||
*/
|
||||
class TermsNotAcceptedException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('The order cannot be placed until the terms have been accepted.');
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,11 @@ use Lunar\Models\Cart;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\Checkout\Events\BillingAddressSet;
|
||||
use Modules\Core\Checkout\Events\PaymentMethodSelected;
|
||||
use Modules\Core\Checkout\Events\RecoveryConsentSet;
|
||||
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
||||
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
@@ -65,6 +67,49 @@ class CheckoutService
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shopper's promotional/abandoned-cart-recovery opt-in — a
|
||||
* cart-level decision, deliberately independent of setShippingAddress()/
|
||||
* setBillingAddress(): consent is given once, and must NOT be reset or
|
||||
* re-asked just because the shopper later changes which address is on
|
||||
* the cart (a different Addressable being set is not a withdrawal of
|
||||
* consent). Only an explicit call to THIS method — the checkbox itself
|
||||
* being submitted, checked or unchecked — ever changes it; calling it
|
||||
* again with false is exactly how a later opt-out is recorded.
|
||||
*
|
||||
* Stored on Cart::meta (interim, per the legal design this implements —
|
||||
* a real column/consent record is the eventual target) as
|
||||
* recovery_consent (bool), recovery_consent_at (ISO 8601 timestamp,
|
||||
* null when $consent is false), and recovery_consent_policy_version
|
||||
* (config('legal.privacy_policy_version') at the moment of consent —
|
||||
* so a later dispute is answered from what was actually agreed to,
|
||||
* not whatever the policy says today). Separate from any future
|
||||
* newsletter opt-in — recovery consent is its own scope, never merged
|
||||
* with marketing-newsletter consent.
|
||||
*
|
||||
* Deliberately does not merge with the meta-writing pattern
|
||||
* selectPaymentMethod() uses (read-merge-save in two separate
|
||||
* statements) — this writes both meta keys in one save, since there's
|
||||
* no dependency between recovery_consent and anything else needing to
|
||||
* be persisted first.
|
||||
*/
|
||||
public function setRecoveryConsent(bool $consent): Cart
|
||||
{
|
||||
$cart = $this->cart->currentOrCreate();
|
||||
|
||||
$cart->meta = [
|
||||
...($cart->meta?->toArray() ?? []),
|
||||
'recovery_consent' => $consent,
|
||||
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
|
||||
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
|
||||
];
|
||||
$cart->save();
|
||||
|
||||
Event::dispatch(new RecoveryConsentSet($cart, $consent));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every shipping option currently available for the cart — already
|
||||
* fully backed by the merged Shipping-Carriers work: this runs every
|
||||
@@ -128,19 +173,19 @@ class CheckoutService
|
||||
|
||||
/**
|
||||
* Records which payment type the shopper picked (Cart::meta
|
||||
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
|
||||
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total
|
||||
* adjustments before recalculation.
|
||||
* ['payment_method']) — read by Modules\Core\Payment\Pipelines\
|
||||
* Cart\ApplyPaymentMethodFee to add that method's own `data.fee` (if
|
||||
* any) before recalculation.
|
||||
*
|
||||
* Also snapshots Cart::fingerprint() into meta, *after* saving the
|
||||
* chosen type — the fingerprint has to reflect the final total
|
||||
* including any payment-type-specific adjustment (e.g. a COD
|
||||
* surcharge), which only exists once payment_method is set and the
|
||||
* cart recalculates. Captured here, server-side, rather than asked of
|
||||
* the storefront: this is the last moment before initiatePayment() that
|
||||
* the shopper's reviewed total is known, and initiatePayment() reads it
|
||||
* back internally instead of taking a fingerprint parameter — a
|
||||
* storefront should never need to know Cart::fingerprint() exists.
|
||||
* including any payment-method-specific fee, which only exists once
|
||||
* payment_method is set and the cart recalculates. Captured here,
|
||||
* server-side, rather than asked of the storefront: this is the last
|
||||
* moment before initiatePayment() that the shopper's reviewed total is
|
||||
* known, and initiatePayment() reads it back internally instead of
|
||||
* taking a fingerprint parameter — a storefront should never need to
|
||||
* know Cart::fingerprint() exists.
|
||||
*
|
||||
* Does not itself call a payment driver — selecting a method and
|
||||
* initiating payment against it are deliberately separate steps, same
|
||||
@@ -159,7 +204,15 @@ class CheckoutService
|
||||
$cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
|
||||
$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->save();
|
||||
|
||||
@@ -198,6 +251,20 @@ class CheckoutService
|
||||
* Same fingerprint precondition the old placeOrder() had: mandatory,
|
||||
* not optional, checked before the draft is created.
|
||||
*
|
||||
* $termsAccepted is likewise mandatory, not optional data a caller
|
||||
* might omit — an Order is a consumer contract, and its acceptance
|
||||
* must be refused (TermsNotAcceptedException, before createOrder() is
|
||||
* ever called — the order is never created-then-flagged) rather than
|
||||
* assumed. $policyVersion is recorded alongside it on the created
|
||||
* Order's own meta (terms_accepted, terms_accepted_at,
|
||||
* terms_accepted_policy_version) — the order-level equivalent of
|
||||
* setRecoveryConsent()'s cart-level record, and the durable audit
|
||||
* trail for a later "what did the shopper actually agree to"
|
||||
* dispute. Written directly here (not via a separate event/listener)
|
||||
* since the Order row this attaches to doesn't exist before
|
||||
* createOrder() runs, and nothing else needs to react to this
|
||||
* specific write independently of the order simply existing.
|
||||
*
|
||||
* @param array<string, mixed> $data passed through untouched to
|
||||
* the driver's pay()/authorize() — e.g. Stripe's payment_method
|
||||
* token.
|
||||
@@ -206,11 +273,16 @@ class CheckoutService
|
||||
* payment_method (from selectPaymentMethod()) is no longer offered
|
||||
* — re-checked here, not just at selection time, since a method
|
||||
* could be disabled (or its driver removed) in between
|
||||
* @throws TermsNotAcceptedException if $termsAccepted is false
|
||||
* @throws FingerprintMismatchException
|
||||
* @throws CartException
|
||||
*/
|
||||
public function initiatePayment(string $fingerprint, array $data = []): PaymentResult
|
||||
public function initiatePayment(string $fingerprint, bool $termsAccepted, string $policyVersion, array $data = []): PaymentResult
|
||||
{
|
||||
if (! $termsAccepted) {
|
||||
throw new TermsNotAcceptedException;
|
||||
}
|
||||
|
||||
$cart = $this->cart->currentOrCreate();
|
||||
$cart->checkFingerprint($fingerprint);
|
||||
|
||||
@@ -223,6 +295,14 @@ class CheckoutService
|
||||
|
||||
$order = $cart->createOrder();
|
||||
|
||||
$order->meta = [
|
||||
...($order->meta?->toArray() ?? []),
|
||||
'terms_accepted' => true,
|
||||
'terms_accepted_at' => now()->toIso8601String(),
|
||||
'terms_accepted_policy_version' => $policyVersion,
|
||||
];
|
||||
$order->save();
|
||||
|
||||
$driver = $this->paymentDrivers->resolve($method->driver);
|
||||
$context = ['cart_id' => $cart->id, 'order_id' => $order->id];
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class StorefrontLabels
|
||||
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
|
||||
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
|
||||
'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'],
|
||||
'nav.close' => ['en' => 'Close', 'el' => 'Κλείσιμο'],
|
||||
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
|
||||
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
|
||||
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
|
||||
@@ -67,6 +68,7 @@ class StorefrontLabels
|
||||
'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
|
||||
'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
|
||||
],
|
||||
'shop.all_products' => ['en' => 'All Products', 'el' => 'Όλα τα Προϊόντα'],
|
||||
'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'],
|
||||
'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'],
|
||||
'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'],
|
||||
|
||||
@@ -95,17 +95,21 @@ class StripePaymentDriver implements
|
||||
|
||||
private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult
|
||||
{
|
||||
try {
|
||||
$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],
|
||||
]);
|
||||
];
|
||||
|
||||
if (isset($data['payment_method'])) {
|
||||
$params['payment_method'] = $data['payment_method'];
|
||||
} else {
|
||||
$params['automatic_payment_methods'] = ['enabled' => true];
|
||||
}
|
||||
|
||||
try {
|
||||
$paymentIntent = Stripe::getClient()->paymentIntents->create($params);
|
||||
} catch (ApiErrorException $e) {
|
||||
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\Table;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Core\Payment\Contracts\Configurable;
|
||||
use Modules\Core\Payment\Events\PaymentMethodsReordered;
|
||||
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
|
||||
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.
|
||||
*
|
||||
* `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
|
||||
* "I turned this off" apart from "the driver code was removed" at a
|
||||
* glance, not have both look like the same disabled state.
|
||||
* "I turned this off" apart from "this driver isn't usable right now" at
|
||||
* 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
|
||||
* "Hold now, charge later" — it's simply unreachable for a "Charge
|
||||
@@ -85,13 +91,12 @@ class PaymentMethodResource extends Resource
|
||||
IconColumn::make('driver_missing_at')
|
||||
->label('Driver status')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-exclamation-triangle')
|
||||
->falseIcon('heroicon-o-check-circle')
|
||||
->trueColor('danger')
|
||||
->falseColor('success')
|
||||
->tooltip(fn (PaymentMethod $record) => $record->driver_missing_at
|
||||
? 'Driver not found as of '.$record->driver_missing_at->diffForHumans()
|
||||
: 'Driver resolves correctly'),
|
||||
->state(fn (PaymentMethod $record) => ! $record->driver_missing_at && static::driverIsConfigured($record->driver))
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-exclamation-triangle')
|
||||
->trueColor('success')
|
||||
->falseColor('danger')
|
||||
->tooltip(fn (PaymentMethod $record) => static::driverStatusTooltip($record)),
|
||||
ToggleColumn::make('enabled')
|
||||
->label('Enabled')
|
||||
->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class)
|
||||
@@ -260,4 +265,33 @@ class PaymentMethodResource extends Resource
|
||||
|
||||
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
|
||||
* shipping_methods table already has (see docs/payments.md):
|
||||
* - 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.
|
||||
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
|
||||
* — 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class CheckoutServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../../config/legal.php', 'legal');
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
||||
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
|
||||
use Modules\Core\Shipping\Listeners\FlushLivePricingCache;
|
||||
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class ShippingServiceProvider extends ServiceProvider
|
||||
@@ -70,7 +70,7 @@ class ShippingServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
|
||||
Event::listen($event, [FlushLivePricingCache::class, 'handle']);
|
||||
Event::listen($event, [InvalidateShippingOptions::class, 'handle']);
|
||||
}
|
||||
|
||||
// Deferred: the Shipping facade resolves a binding registered in
|
||||
|
||||
@@ -17,7 +17,7 @@ use Lunar\Shipping\Models\ShippingRate;
|
||||
* across carts: the quote depends on cart-specific weight/quantity/
|
||||
* destination (see docs/checkout.md).
|
||||
*
|
||||
* Invalidated by Modules\Core\Shipping\Listeners\FlushLivePricingCache on
|
||||
* Invalidated by Modules\Core\Shipping\Listeners\InvalidateShippingOptions on
|
||||
* the only two things that can change what this cart's quote should be: a
|
||||
* cart line changing (add/update/remove/clear) or the shipping address
|
||||
* changing. Deliberately NOT invalidated on order placement — the price
|
||||
|
||||
+23
-8
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Core\Shipping\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lunar\Facades\ShippingManifest;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
use Modules\Core\Cart\Events\CartCleared;
|
||||
@@ -13,17 +14,29 @@ use Modules\Core\Checkout\Events\ShippingAddressSet;
|
||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||
|
||||
/**
|
||||
* Flushes Modules\Core\Shipping\Concerns\CachesLivePricing's cached quotes
|
||||
* for a cart on the only two things that can change what they should be: a
|
||||
* Invalidates everything that caches or memoises resolved shipping options
|
||||
* for a cart, on the only two things that can change what they should be: a
|
||||
* cart line changing (weight/quantity) or the shipping address changing
|
||||
* (destination). See that trait's docblock for why order placement is
|
||||
* deliberately not a trigger here.
|
||||
* (destination).
|
||||
*
|
||||
* Only rates whose method's driver implements SupportsLivePricing are ever
|
||||
* cached by CachesLivePricing, so only their ids need a forget() call —
|
||||
* no need to touch every ShippingRate row on every cart change.
|
||||
* Two things need clearing here, both stale for the same reason:
|
||||
*
|
||||
* - Modules\Core\Shipping\Concerns\CachesLivePricing's per-rate cache (see
|
||||
* that trait's docblock for why order placement is deliberately not a
|
||||
* trigger). Only rates whose method's driver implements
|
||||
* SupportsLivePricing are ever cached by it, so only their ids need a
|
||||
* forget() call — no need to touch every ShippingRate row on every cart
|
||||
* change.
|
||||
* - Lunar\Base\ShippingManifest's $options collection, which is a
|
||||
* request-lifetime singleton: ShippingManifest::getOptions() re-runs the
|
||||
* modifier pipeline on every call but never clears $options first, and
|
||||
* addOption() keeps the first entry per identifier and silently drops any
|
||||
* later one — so an option resolved for an earlier address/cart state
|
||||
* shadows the correct one after that state changes, within the same
|
||||
* request. clearOptions() forces the next getOptions() call to resolve
|
||||
* fresh.
|
||||
*/
|
||||
class FlushLivePricingCache
|
||||
class InvalidateShippingOptions
|
||||
{
|
||||
public function handle(CartLineAdded|CartLineUpdated|CartLineRemoved|CartCleared|ShippingAddressSet $event): void
|
||||
{
|
||||
@@ -32,6 +45,8 @@ class FlushLivePricingCache
|
||||
foreach ($this->livePricingRateIds() as $rateId) {
|
||||
Cache::forget("shipping.live_price.{$rateId}.{$cart->id}");
|
||||
}
|
||||
|
||||
ShippingManifest::clearOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
Reference in New Issue
Block a user