Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
359e1e262e | ||
|
|
fb684dc97b |
@@ -4,6 +4,44 @@ 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.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
|
## [0.15.0] - 2026-09-07
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+2
-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.15.0",
|
"version": "0.16.0",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"Modules\\Core\\Providers\\CoreServiceProvider",
|
"Modules\\Core\\Providers\\CoreServiceProvider",
|
||||||
"Modules\\Core\\Providers\\AuthServiceProvider",
|
"Modules\\Core\\Providers\\AuthServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\CheckoutServiceProvider",
|
||||||
"Modules\\Core\\Providers\\PaymentServiceProvider",
|
"Modules\\Core\\Providers\\PaymentServiceProvider",
|
||||||
"Modules\\Core\\Providers\\LocalizationServiceProvider",
|
"Modules\\Core\\Providers\\LocalizationServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
"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'),
|
||||||
|
];
|
||||||
@@ -31,6 +31,13 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
|
|||||||
* state at all; every cart still matching the query below refires its event
|
* state at all; every cart still matching the query below refires its event
|
||||||
* on every run until Recovery (not yet built — see
|
* on every run until Recovery (not yet built — see
|
||||||
* docs/recovery-strategies.md) owns its own dedup/tracking table.
|
* 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
|
class DetectAbandonedCarts extends Command
|
||||||
{
|
{
|
||||||
@@ -48,6 +55,7 @@ class DetectAbandonedCarts extends Command
|
|||||||
Cart::query()
|
Cart::query()
|
||||||
->whereDoesntHave('orders')
|
->whereDoesntHave('orders')
|
||||||
->where('updated_at', '<=', $cutoff)
|
->where('updated_at', '<=', $cutoff)
|
||||||
|
->where('meta->recovery_consent', true)
|
||||||
->with('lines')
|
->with('lines')
|
||||||
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
||||||
foreach ($carts as $cart) {
|
foreach ($carts as $cart) {
|
||||||
@@ -64,6 +72,7 @@ class DetectAbandonedCarts extends Command
|
|||||||
Cart::query()
|
Cart::query()
|
||||||
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
|
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
|
||||||
->where('updated_at', '<=', $cutoff)
|
->where('updated_at', '<=', $cutoff)
|
||||||
|
->where('meta->recovery_consent', true)
|
||||||
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
|
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
|
||||||
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
|
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
|
||||||
foreach ($carts as $cart) {
|
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\Cart\Services\CartService;
|
||||||
use Modules\Core\Checkout\Events\BillingAddressSet;
|
use Modules\Core\Checkout\Events\BillingAddressSet;
|
||||||
use Modules\Core\Checkout\Events\PaymentMethodSelected;
|
use Modules\Core\Checkout\Events\PaymentMethodSelected;
|
||||||
|
use Modules\Core\Checkout\Events\RecoveryConsentSet;
|
||||||
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
||||||
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
||||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||||
|
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
||||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
use Modules\Core\Payment\Models\PaymentMethod;
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
@@ -65,6 +67,49 @@ class CheckoutService
|
|||||||
return $cart;
|
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
|
* Every shipping option currently available for the cart — already
|
||||||
* fully backed by the merged Shipping-Carriers work: this runs every
|
* 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,
|
* Same fingerprint precondition the old placeOrder() had: mandatory,
|
||||||
* not optional, checked before the draft is created.
|
* 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
|
* @param array<string, mixed> $data passed through untouched to
|
||||||
* the driver's pay()/authorize() — e.g. Stripe's payment_method
|
* the driver's pay()/authorize() — e.g. Stripe's payment_method
|
||||||
* token.
|
* token.
|
||||||
@@ -206,11 +265,16 @@ class CheckoutService
|
|||||||
* payment_method (from selectPaymentMethod()) is no longer offered
|
* payment_method (from selectPaymentMethod()) is no longer offered
|
||||||
* — re-checked here, not just at selection time, since a method
|
* — re-checked here, not just at selection time, since a method
|
||||||
* could be disabled (or its driver removed) in between
|
* could be disabled (or its driver removed) in between
|
||||||
|
* @throws TermsNotAcceptedException if $termsAccepted is false
|
||||||
* @throws FingerprintMismatchException
|
* @throws FingerprintMismatchException
|
||||||
* @throws CartException
|
* @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 = $this->cart->currentOrCreate();
|
||||||
$cart->checkFingerprint($fingerprint);
|
$cart->checkFingerprint($fingerprint);
|
||||||
|
|
||||||
@@ -223,6 +287,14 @@ class CheckoutService
|
|||||||
|
|
||||||
$order = $cart->createOrder();
|
$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);
|
$driver = $this->paymentDrivers->resolve($method->driver);
|
||||||
$context = ['cart_id' => $cart->id, 'order_id' => $order->id];
|
$context = ['cart_id' => $cart->id, 'order_id' => $order->id];
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user