diff --git a/composer.json b/composer.json index 53cac19..2458e06 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/config/legal.php b/config/legal.php new file mode 100644 index 0000000..fd37e39 --- /dev/null +++ b/config/legal.php @@ -0,0 +1,21 @@ + env('LEGAL_PRIVACY_POLICY_VERSION', '2026-01-01'), + + 'terms_version' => env('LEGAL_TERMS_VERSION', '2026-01-01'), +]; diff --git a/src/Cart/Commands/DetectAbandonedCarts.php b/src/Cart/Commands/DetectAbandonedCarts.php index 80f51aa..4663607 100644 --- a/src/Cart/Commands/DetectAbandonedCarts.php +++ b/src/Cart/Commands/DetectAbandonedCarts.php @@ -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) { diff --git a/src/Checkout/Events/RecoveryConsentSet.php b/src/Checkout/Events/RecoveryConsentSet.php new file mode 100644 index 0000000..127734d --- /dev/null +++ b/src/Checkout/Events/RecoveryConsentSet.php @@ -0,0 +1,20 @@ +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 $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]; diff --git a/src/Providers/CheckoutServiceProvider.php b/src/Providers/CheckoutServiceProvider.php new file mode 100644 index 0000000..3b35b84 --- /dev/null +++ b/src/Providers/CheckoutServiceProvider.php @@ -0,0 +1,13 @@ +mergeConfigFrom(__DIR__ . '/../../config/legal.php', 'legal'); + } +}