Feat: Adding Concent Updates

This commit is contained in:
2026-09-09 01:12:21 +03:00
parent 9c95c0bccb
commit fb684dc97b
7 changed files with 156 additions and 1 deletions
+1
View File
@@ -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",
+21
View File
@@ -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
* 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.');
}
}
+73 -1
View File
@@ -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];
+13
View File
@@ -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');
}
}