Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e45b84636 | ||
|
|
437cbf2460 | ||
|
|
5425a0396f | ||
|
|
4d0e326cb9 | ||
|
|
d4f9766940 | ||
|
|
359e1e262e | ||
|
|
fb684dc97b | ||
|
|
9c95c0bccb |
+223
@@ -4,6 +4,229 @@ 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.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
|
||||
- **Breaking:** `Modules\Core\Payment\Models\PaymentMethod` is now the full DB-instance layer for
|
||||
Payment, same three-layer split (registry / DB instance / cross-cutting config) `Shipping`
|
||||
already has via `ShippingMethod` — see `docs/payments.md`. Every value that used to live in
|
||||
`config('lunar.payments.types.{type}.*')` (`payment_driver`, `capture_mode`, `captured_status`)
|
||||
moves onto the `PaymentMethod` row itself as real columns: `driver` (the new
|
||||
`PaymentDriverRegistry` key — NOT the same as `type`; two rows can share one driver), `name`
|
||||
(admin-facing label, nothing played this role before), `capture_mode`, `captured_status`,
|
||||
`authorized_status`, `position` (admin-controlled ordering, new — reorderable in the Filament
|
||||
table), `driver_missing_at`. `config('lunar.payments.types')` is gone entirely; `config/
|
||||
payment.php` now holds only `cart_pipeline` (genuinely cross-cutting — every store gets the
|
||||
same pipeline wiring regardless of how many payment methods it configures).
|
||||
- **Breaking:** `Modules\Core\Payment\Services\PaymentDriverResolver` is deleted, replaced by
|
||||
`Modules\Core\Payment\Services\PaymentDriverRegistry` — `register(string $key, string
|
||||
$driverClass)`/`resolve(string $key): ?object`/`all(): array<string, string>`. Deliberately
|
||||
knows nothing about `PaymentMethod` or the database (mirrors `Lunar\Shipping\Managers\
|
||||
ShippingManager`'s built-in-methods + `Manager::extend()` split, purpose-built rather than
|
||||
extending `Illuminate\Support\Manager` — Payment's drivers implement several independent
|
||||
capability interfaces at once, not one uniform contract). Built-ins (`OfflinePaymentDriver`
|
||||
as `'offline'`, `StripePaymentDriver` as `'stripe'`) registered in
|
||||
`PaymentServiceProvider::boot()`, exactly how `Shipping::extend('acs', ...)` already works.
|
||||
- **Breaking:** `Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()` now returns
|
||||
`Illuminate\Support\Collection<int, PaymentMethod>` (ordered by `position`), not
|
||||
`array<string>`. A method is offered only once three independent checks all pass — `enabled`
|
||||
(admin turned it on), `driver_missing_at` is null (the driver class still exists), and the
|
||||
resolved driver's own `Configurable::isConfigured()` (its runtime requirements are met) — each
|
||||
failure meaning something different to an admin diagnosing why a method isn't showing up.
|
||||
`initiatePayment()` resolves the driver via the selected row's own `driver` column, not `type`.
|
||||
- `Modules\Core\Payment\Filament\Resources\PaymentMethodResource` — `canCreate()`/`canDelete()`
|
||||
now both `true` (previously hardcoded `false`, since a row could only ever be a config-defined
|
||||
type before this release). New create/edit form (`name`, `type`, `driver` — a `Select`
|
||||
populated live from `PaymentDriverRegistry::all()`, `capture_mode`, `captured_status`,
|
||||
`authorized_status`); reorderable table (`->reorderable('position')`); a distinct "Driver
|
||||
status" icon column (separate from the `enabled` toggle) showing whether `driver_missing_at`
|
||||
is set.
|
||||
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` reads `captured_status`/
|
||||
`authorized_status` off the `PaymentMethod` row (`where('type', $event->type)`) instead of
|
||||
`config(...)`.
|
||||
- `Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()` no longer iterates
|
||||
`config('lunar.payments.types')` — it seeds exactly one opinionated `cash-on-delivery` starter
|
||||
row, every value a plain literal in the command itself (not sourced from config or the
|
||||
registry — a driver has no business carrying opinions about what its captured order status
|
||||
should be called; that's a merchant decision). Skip-if-exists, same as before.
|
||||
|
||||
### Added
|
||||
- `php artisan boboko:payment:sync-drivers` — reconciles every `PaymentMethod` row's `driver`
|
||||
against `PaymentDriverRegistry`, setting `driver_missing_at` when a driver no longer resolves
|
||||
(a package removed, a custom `register()` call deleted) and clearing it automatically if that
|
||||
driver is registered again in a later deploy. Deliberately its own standalone command, meant to
|
||||
run unconditionally on every container start/deploy (Dockerfile entrypoint, alongside
|
||||
`migrate`) — "did the set of registered drivers change" is a deploy-time event, cheap enough to
|
||||
check every time regardless of whether anything actually changed. Verified live: flags a row
|
||||
whose `driver` was manually corrupted, and auto-clears the flag once the driver resolves again.
|
||||
- `docs/payments.md` — new "Registry, DB instance, and cross-cutting config" section: the
|
||||
three-layer split researched against `Shipping`'s own already-existing pattern and three real
|
||||
e-commerce platforms (Shopify, WooCommerce, Medusa.js), the "would a store ever plausibly want
|
||||
two different answers to this" test for deciding config vs. DB-column placement, and the
|
||||
three-check availability chain.
|
||||
|
||||
- `Modules\Core\Payment\Services\PaymentMethodCache` (`Cache::rememberForever`, same pattern as
|
||||
`Localization\Services\LanguageCache`) + `Modules\Core\Payment\Services\PaymentMethodService`
|
||||
(`create`/`update`/`delete`/`list`) — the single read/write gateway for `PaymentMethod` now used
|
||||
by every Filament resource action (create, edit, edit-fee, delete, the inline `enabled` toggle)
|
||||
instead of the Eloquent model directly, so the cache is invalidated and
|
||||
`PaymentMethodCreated`/`PaymentMethodUpdated`/`PaymentMethodDeleted`/`PaymentMethodsReordered`
|
||||
dispatch on every write, with no exceptions other than Filament's own drag-to-reorder (which
|
||||
does a raw bulk SQL `UPDATE` on the position column directly via
|
||||
`CanReorderRecords`/`reorderTable()`, before `afterReordering()` fires — a confirmed, unavoidable
|
||||
Filament limitation; the reorder hook only clears the cache and dispatches
|
||||
`PaymentMethodsReordered` afterward). `CheckoutService::getPaymentMethods()` and
|
||||
`ApplyResolvedPaymentStatus` both now read through the cache instead of querying `PaymentMethod`
|
||||
directly.
|
||||
- `Modules\Core\Payment\Models\CoreTransaction` (a `Lunar\Models\Transaction` subclass) +
|
||||
`Modules\Core\Payment\Support\TransactionDriverAdapter`, registered via
|
||||
`Lunar\Facades\ModelManifest::replace(Lunar\Models\Contracts\Transaction::class,
|
||||
CoreTransaction::class)` — the same contract-swap mechanism already used elsewhere for
|
||||
`Customer`/`Staff`. Fixes a real crash (`InvalidArgumentException: Driver [cash-on-delivery] not
|
||||
supported`) the first time anything called `$transaction->refund()`/`->capture()`:
|
||||
`Lunar\Models\Transaction::driver()` calls Lunar's own, entirely separate
|
||||
`Lunar\Facades\Payments::driver()` manager, which had never heard of any of this codebase's
|
||||
driver keys. `CoreTransaction::driver()` returns `TransactionDriverAdapter` instead, which
|
||||
resolves the transaction's real `PaymentMethod`/`PaymentDriverRegistry` driver and calls it —
|
||||
Lunar's own admin panel "Refund"/"Capture" buttons now transparently reach the real payment
|
||||
system underneath, including correctly reporting failure (not a silently-faked success) when
|
||||
the resolved driver doesn't implement `SupportsRefunds`/`SupportsCaptures`.
|
||||
- `TransactionDriverAdapter::refundVia(Transaction $transaction, ?string $driverKey, int $amount,
|
||||
?string $notes = null)` — refund through an explicitly chosen driver, independent of the one
|
||||
the original payment went through (e.g. a cash-on-delivery order refunded via Bank Transfer,
|
||||
which has no notion of the original offline payment at all). The order page's refund action
|
||||
gained a "Refund via" `Select` (every `PaymentDriverRegistry` driver implementing
|
||||
`SupportsRefunds`, defaulting to the transaction's own driver) that routes through this method
|
||||
instead of `Lunar\Models\Transaction::refund()`, whose fixed signature has no room for a driver
|
||||
override.
|
||||
- `Modules\Core\Payment\Drivers\BankTransferPaymentDriver` (registered as `'bank-transfer'`) —
|
||||
manual/attested, same trust model as `OfflinePaymentDriver`: no gateway call, `pay()`/`refund()`
|
||||
decide success immediately on a staff member's say-so. Implements both `SupportsPay` and
|
||||
`SupportsRefunds`; exists specifically so a payment taken through a different method can still
|
||||
be refunded via bank transfer. The admin UI for receiving a payment this way (bank reference,
|
||||
notes, proof-of-transfer upload) is a follow-up — the driver itself is complete and usable via
|
||||
the registry today.
|
||||
- `Modules\Core\Order\Filament\Infolists\TransactionEntry` (swapped in for Lunar's own
|
||||
`Lunar\Admin\Support\Infolists\Components\Transaction` via a new
|
||||
`OrderTransactionsExtension::extendTransactionsRepeatableEntry()` hook) — the order page's
|
||||
transaction cards now also show a note recorded in `Transaction.meta['notes']` when the `notes`
|
||||
column itself is empty. `Order\Services\TransactionRecorder` only ever wrote `notes` from
|
||||
`PaymentResult::$failureReason`, which is never set on a successful result — a manual driver's
|
||||
staff-entered note (e.g. `BankTransferPaymentDriver`'s) was being recorded but had nowhere to
|
||||
render.
|
||||
- `Modules\Core\Payment\Listeners\LogPaymentMethodActivity` — `PaymentMethod` now has an admin
|
||||
activity trail, unlike `Order`/`Transaction`/`Staff` it previously had none. Routes
|
||||
`PaymentMethodCreated`/`Updated`/`Deleted` through the existing `Logging\ActivityLogService`
|
||||
(the same one `Localization\Listeners\LogTranslationActivity` already uses) rather than adding
|
||||
`PaymentMethod` to `Lunar\Base\Traits\LogsActivity`'s generic model-observer logging —
|
||||
`PaymentMethodUpdated::$old`/`PaymentMethodDeleted::$method`'s snapshot already carry more
|
||||
deliberate before/after context than Eloquent's own dirty-attribute diffing would reconstruct.
|
||||
`PaymentMethodsReordered` is deliberately NOT logged — a multi-row position change doesn't fit
|
||||
`ActivityLogService`'s one-`Model`-subject shape, and isn't worth a new method for a low-stakes,
|
||||
purely-cosmetic setting.
|
||||
- New `payment_methods.refunded_status` column + form field (same `Select` pattern as
|
||||
`captured_status`/`authorized_status`) — `ApplyResolvedPaymentStatus` now also reacts to
|
||||
`PaymentRefunded`, so `Order.status` actually changes on a refund; before this, only the
|
||||
*derived* `Order::paymentStatus()` reflected a refund (reading `transactions` live), while the
|
||||
stored `status` column — what admin filtering, customer emails, etc. actually key off — never
|
||||
moved. Resolves the ORIGINAL payment method for this lookup, not the refund event's own
|
||||
`$type`: a refund routed through a different driver via `refundVia()` (e.g. cash-on-delivery
|
||||
refunded through Bank Transfer) carries the REFUND driver's registry key as `$event->type`,
|
||||
which usually isn't even a real `PaymentMethod.type` — the listener now finds the order's
|
||||
earliest successful `capture`/`intent` transaction instead and reads `refunded_status` off
|
||||
*that* transaction's own `PaymentMethod` row, since that's the payment the refund is actually
|
||||
reversing. Deliberately no `void_status` yet — a void never moved money, so it doesn't carry
|
||||
the same "the customer needs to see this changed" weight a refund does.
|
||||
|
||||
### Fixed
|
||||
- Existing `PaymentMethod` rows seeded before this release (`cash-on-delivery`, `cash-in-hand`)
|
||||
had `driver`/`capture_mode`/`captured_status` all `NULL` after the migration ran — a data
|
||||
backfill was required (not automated by the migration itself) to restore them to a resolvable
|
||||
state; flagged here since a consuming app upgrading past this release needs the same backfill
|
||||
for its own pre-existing rows before `getPaymentMethods()` will offer them again.
|
||||
- `Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::getRefundAction()`/
|
||||
`getCaptureAction()` and `OrderItemsTable::getBulkRefundAction()` report a failed refund/capture
|
||||
by calling `$action->failureNotification(...)`, `$action->failure()`, then `$action->halt()` —
|
||||
but `Filament\Actions\Concerns\InteractsWithActions::callMountedAction()` only ever sends that
|
||||
notification from a code path that runs after the action's closure returns normally; `halt()`
|
||||
throws `Filament\Support\Exceptions\Halt`, caught by an earlier `catch` block that rolls back and
|
||||
returns, so the notification was built but never sent — clicking "Refund" on a payment method
|
||||
that genuinely can't be refunded looked like nothing happened at all, no error, no toast. Real,
|
||||
pre-existing Filament/Lunar bug, invisible until this release's `TransactionDriverAdapter` made
|
||||
an honest failure (rather than a hard crash or a silently-faked success) actually reachable.
|
||||
Fixed via new `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension`/
|
||||
`OrderItemsTableExtension`, which wrap the affected actions' closures to send the queued failure
|
||||
notification themselves before re-throwing `Halt`.
|
||||
- `TransactionDriverAdapter::refund()`/`capture()` never included `order_id` in the `$context`
|
||||
passed to the driver, so `Order\Listeners\RecordPaymentTransaction`/`ApplyResolvedPaymentStatus`
|
||||
(both requiring `$context['order_id']`) silently no-op'd for every admin-initiated refund/capture
|
||||
through any driver — no audit `Transaction` row was ever created, regardless of whether the
|
||||
refund/capture itself succeeded. Fixed by passing `$transaction->order_id` through.
|
||||
|
||||
## [0.14.0] - 2026-09-03
|
||||
|
||||
### Changed
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
"name": "boboko/core",
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"type": "library",
|
||||
"version": "0.14.0",
|
||||
"version": "0.16.2",
|
||||
"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'),
|
||||
];
|
||||
@@ -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
|
||||
@@ -198,6 +243,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 +265,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 +287,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' => 'Δημοφιλή'],
|
||||
|
||||
@@ -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