Compare commits

...
28 Commits
Author SHA1 Message Date
arvanitakis 53d8a5aefe Bump Version to 0.10.1 2026-09-01 12:04:06 +03:00
arvanitakis 380e860386 Feat: Adding Translations to the Seeder 2026-09-01 10:52:56 +03:00
arvanitakis d680200ab1 Bump Version to 0.10.0 2026-08-31 14:18:51 +03:00
arvanitakis 9529036585 Feat: Updating PaymentDrivers and CheckoutService to handle payment methods 2026-08-31 14:12:21 +03:00
arvanitakis 2db1e1331f Feat: Creating PaymentMethods, Setting Fees, Availabilities 2026-08-31 13:54:20 +03:00
arvanitakis d873cb4931 Feat: Upgrading Lunar to 1.5 2026-08-31 13:16:13 +03:00
arvanitakis 661e8b9a96 Feat: Adding Payment Drivers, configuring stripe 2026-08-31 12:50:29 +03:00
arvanitakis 637da37b9b Merge branch 'master' into Payment-Methods 2026-08-29 14:17:48 +03:00
arvanitakis be0c037c62 Bump version to 0.9.0 2026-08-29 13:41:17 +03:00
arvanitakis f85fb51ecd Feature: Adding Caches and Fallbacks to Live Pricing Requests 2026-08-29 10:31:58 +03:00
arvanitakis 6671c5e9d1 Feature: Adding Checkout Services and Events 2026-08-29 01:14:58 +03:00
arvanitakis ca36c31cab Hotfix: Correcting Wrong Position on Checkout, Correcting Cart Display Pages 2026-08-29 00:27:20 +03:00
arvanitakis c6c44db742 Feature: Sweeping features of most known e-commerce shops, creating artifacts for the research 2026-08-28 13:19:29 +03:00
arvanitakis 7b63f43695 Merge branch 'Shipping-Carriers' into Checkout 2026-08-28 13:17:58 +03:00
arvanitakis 66068ef68a Bump version to 0.8.0 2026-08-28 13:15:40 +03:00
arvanitakis f6ef0761d6 Feature: Creating Cart Service, Cart Events, Save For Later functionality, Adding Filament Views for viewing abandoned carts 2026-08-28 13:14:47 +03:00
arvanitakis a8ddbb8056 Feature: Creating Cart Views, rules for abandonment 2026-08-28 00:27:46 +03:00
arvanitakis 9f30a7324e Feature: Shipment Updates to handle COD 2026-07-19 18:27:24 +03:00
arvanitakis 435a4dd290 Feat: Adding Shippment Tracking 2026-07-19 17:41:09 +03:00
arvanitakis 808c769595 Add cash-on-delivery payment type with cart fee pipeline 2026-07-19 03:35:16 +03:00
arvanitakis d34e450526 Fix: Move carrier live-pricing choice onto ShippingMethod.charge_by
Reverts the earlier per-rate pricing_mode column in favor of extending Lunar's existing charge_by field (cart_total/weight) with a third "live" option, gated by a SupportsLivePricing capability check on the driver. Adds a shared ResolvesFixedPricing trait so any carrier driver can fall back to Lunar's normal price-break resolution, matching the vendor ShipBy driver's own charge_by handling instead of introducing a separate mechanism. Also fixes an incorrect Get() path in the admin form that silently hid the new "live" option.
2026-07-19 02:24:10 +03:00
arvanitakis 4acabe4185 Feature: Wire up carrier-agnostic shipping admin UI
Registers ShippingServiceProvider (carrier config, driver/fulfillment bindings, Rates page override) and wires the shipping admin surface into CorePlugin: dynamic carrier dropdown on Shipping Method create/edit, a "Create Shipment" order action resolved generically by carrier, and a Pickup Manifests page for carriers that support manifest batching.
2026-07-19 00:52:01 +03:00
arvanitakis 37c2e1194c Feature: Add Box Now locker delivery integration
Box Now rate driver (fixed pricing only — no live pricing API) and fulfillment service (delivery request creation, label printing, cancellation). Unlike ACS, Box Now books courier pickup automatically on delivery request creation, so no manifest/pickup-list step is implemented. Storefront locker selection is not yet built; createShipment() expects the chosen locker's locationId to be supplied by the caller.
2026-07-19 00:51:23 +03:00
arvanitakis 89255687a1 Feature: Add ACS courier integration
ACS rate driver (live price quotes via ACS_Price_Calculation, cached postcode-to-station lookups) and fulfillment service (voucher creation, label printing, end-of-day pickup manifest). Adds a per-rate pricing_mode column so admins can choose live API pricing vs. a fixed price on ACS-driven shipping rates, surfaced via a custom Rates page.
2026-07-19 00:50:51 +03:00
arvanitakis 3599329b57 Feature: Add ManifestResult DTO for carrier manifest batching
Completes the carrier-agnostic shipping abstraction (CarrierFulfillmentInterface, SupportsManifestBatching, Shipment model) with the result type SupportsManifestBatching::issueManifest() returns.
2026-07-19 00:50:15 +03:00
arvanitakis 9618d19cfa Feature: Creating Migration For Adding Pricing Mode to Shipping Rates 2026-07-19 00:42:13 +03:00
arvanitakis 08135c4c8f Feature: Creating Shipping Carrier Contracts 2026-07-16 17:14:41 +03:00
arvanitakis e18b2fa44c Feature: Creating Shipment migration and model 2026-07-16 17:14:23 +03:00
119 changed files with 8705 additions and 114 deletions
+42
View File
@@ -4,6 +4,48 @@ 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.10.1] - 2026-09-01
### Added
- `Modules\Core\Localization\Services\StorefrontLabels::all()` gains three keys found missing from `3dealer`'s actual `storefront.*` translation usage: `shop.price_min`, `shop.price_max`, `shop.reset` (the price-filter sidebar's min/max labels and its reset link). Picked up by `InstallLunarCommand`'s existing per-key upsert — re-running `lunar:install` on an already-installed store adds only these three rows, leaving everything already seeded or admin-edited untouched.
## [0.10.0] - 2026-08-31
### Changed
- **Breaking:** Upgraded `lunarphp/lunar`, `lunarphp/core`, `lunarphp/stripe`, `lunarphp/table-rate-shipping`, and `lunarphp/search` to `1.5.0`, and `filament/filament` to `v4.12.6` — the first Filament v4 admin panel on this codebase. `lunarphp/filament3-2fa` and `kalnoy/nestedset` are gone, replaced by Filament v4's native two-factor auth and `lunarphp/nestedset`. Ran Filament's automated `filament-v4` migration tool across `src/`, then hand-fixed three bugs it introduced or left behind: a stale `$infolist` variable reference in `CartResource`'s `ViewCart` page (the parameter had been renamed to `$schema` but the body wasn't updated), `ShippingMethodResourceExtension` rewritten to call `getDefaultChildComponents()` (returns `array|Schema`) instead of the type-safe `getChildComponents()` (always `array<Component>`), and — unrelated to the tool, but surfaced by the same PHP version bump — `InvalidCouponException`'s `readonly $code` property illegally shadowing the built-in `Exception::$code`, renamed to `$couponCode`. `LunarStaff::addActivitylogExcept()` updated for the renamed `two_factor_secret`/`two_factor_recovery_codes` staff columns (now `app_authentication_secret`/`app_authentication_recovery_codes`; `two_factor_confirmed_at` removed). Consuming apps must run `composer update boboko/core --with-all-dependencies` and `php artisan migrate`.
### Added
- `Modules\Core\Checkout\Contracts\PaymentDriver` — the abstraction every payment provider implements: `confirm(Cart $cart, string $type, string $fingerprint, array $data): Order` and `isConfigured(): bool`. A driver only ever calls `CheckoutService::placeOrder()` once it has, by whatever mechanism is native to that gateway, independently confirmed payment — never Lunar's raw `Cart::createOrder()`. This is what lets the storefront checkout sequence stay uniform regardless of which provider is active: set addresses, select shipping, hand off to whichever driver is configured, and the driver decides when (or whether) the order gets created.
- `Modules\Core\Payment\Drivers\OfflinePaymentDriver` — shared by every payment type with no real gateway to confirm against (`cash-in-hand`, `cash-on-delivery`): places the order immediately via `CheckoutService::placeOrder()`, then sets the order status from `config("lunar.payments.types.{$type}.authorized")` using the type actually confirmed, not a hardcoded key, since one driver instance serves multiple types.
- `Modules\Core\Payment\Drivers\StripePaymentDriver` — a fork, not a decoration, of `lunarphp/stripe`'s `StripePaymentType::authorize()`: that method is `final` and calls `Cart::createOrder()` directly with no seam to redirect into our fingerprint-checked `placeOrder()`, so this class reimplements its logic (intent retrieval, capture-on-policy, status mapping via `UpdateOrderFromIntent`) with that one substitution. Throws the new `Modules\Core\Payment\Exceptions\PaymentNotConfirmedException` on anything short of a genuinely confirmed payment intent — never falls through to placing an order on ambiguity.
- `CheckoutService::getPaymentMethods(): array` — every payment type currently offered to the storefront: every key in `config('lunar.payments.types')` that is both administratively enabled (`Modules\Core\Payment\Models\PaymentMethod::enabled`) and whose driver reports `isConfigured()` (e.g. Stripe with no API key set is never offered, regardless of the enabled toggle). `selectPaymentMethod(string $type)` and `confirmPayment(string $type, array $data)` both validate against this list, throwing the new `UnknownPaymentTypeException` for a type that isn't currently offered — re-checked in `confirmPayment()` too, since a type could be disabled between selection and confirmation.
- `CheckoutService::selectPaymentMethod()` snapshots `Cart::fingerprint()` into `cart->meta['checkout_fingerprint']` *after* saving the chosen type and recalculating — 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. `confirmPayment()` reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to know `Cart::fingerprint()` exists or capture it at exactly the right moment itself.
- `Modules\Core\Payment\Models\PaymentMethod` — one DB row per payment type key (matching `config('lunar.payments.types')`), `enabled` boolean plus a `data` jsonb column (starting with `fee`, the flat cash-on-delivery surcharge) — mirrors Lunar's own `Discount` model (a single jsonb column of keyed settings, not a fixed column per setting or a separate conditions table). Seeded idempotently by `InstallLunarCommand` (skip-if-exists per type, safe to re-run after installing a new payment-provider package), always `enabled: false` — a newly-seeded type shouldn't go live for shoppers before staff have configured and reviewed it. Admin-editable via the new `PaymentMethodResource` (inline enabled toggle, modal fee editor) under Settings.
- `ApplyCashOnDeliveryFee` now reads its surcharge from `PaymentMethod` instead of static config, so it's admin-editable without a deploy.
### Fixed
- `CashOnDeliveryPaymentDriver` renamed to `OfflinePaymentDriver` and generalized to work for any offline-style type — it previously hardcoded `'cash-on-delivery'` when reading the post-placement order status from config, which would have silently read the wrong type's status the moment a second offline type (`cash-in-hand`) used it.
## [0.9.0] - 2026-08-29
### Added
- `Modules\Core\Cart\Services\CartService` — the boboko-owned API for all cart mutation, wrapping Lunar's `CartSession`/`Cart` primitives: `addLine()`, `updateLine()`, `removeLine()`, `clear()`, `applyCoupon()`/`removeCoupon()` (throws `InvalidCouponException` on an invalid code), and save-for-later (`saveForLater()`/`moveToCart()`/`activeLines()`/`savedLines()`, backed by a `meta.saved_for_later` flag and a new `Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice` cart-line pipeline step that zeroes a saved line's price so it's excluded from cart totals without being removed). Dispatches 8 real domain events (`CartLineAdded`/`Updated`/`Removed`/`Saved`/`MovedToCart`, `CartCleared`, `CartCouponApplied`/`Removed`) — none have a listener yet, built so a future concern (analytics, recovery) has something to attach to. Documented in `docs/cart.md`.
- `Modules\Core\Checkout\Services\CheckoutService` — the boboko-owned API for the checkout stage (address → shipping selection → order placement), sitting between `CartService` and `Order`: `setShippingAddress()`/`setBillingAddress()`, `getShippingOptions()`/`selectShippingOption()` (throws the new `InvalidShippingOptionException` on an identifier that doesn't resolve — previously a silent no-op), and `placeOrder(string $fingerprint)` (the fingerprint is mandatory, not optional — forces re-confirmation via Lunar's own `FingerprintMismatchException` if the cart changed since the shopper last saw its total). Dispatches `ShippingAddressSet`/`BillingAddressSet`/`ShippingOptionSelected`/`OrderPlaced`, each carrying richer, already-resolved payload (e.g. the resolved `ShippingOption`, not just its identifier) than `CartService`'s events. No exception wrapping otherwise — Lunar's own `CartException`/`FingerprintMismatchException` are already the right shape for a storefront to render as form errors. Documented in `docs/checkout.md`.
- `Modules\Core\Cart\Filament\Resources\CartResource`'s list view now classifies every cart into one of four states — **Ongoing**, **Abandoned Cart**, **Abandoned Checkout**, **Completed** — instead of the previous two-tab Abandoned/Completed split, distinguishing a cart that never reached checkout from one that has a started-but-unplaced order (mirrors the real distinction in Lunar's own `Cart::scopeActive()`). Abandonment threshold is a fixed, configurable cutoff (`config('core.cart.abandoned_after')`, default 1 hour). Added a customer hyperlink (list column + a "View Customer" header action on the view page, both pointing straight at `customers/{id}` via the plain `customer_id` column, no extra query via the `customer` relation).
- `Modules\Core\Cart\Commands\DetectAbandonedCarts` (`boboko:cart:detect-abandoned`, scheduled hourly) dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` for carts/checkouts past the abandonment cutoff — detection only, no persistence; a real tracking table is left for when `Recovery` is built as its own concern. Fixed a self-defeating bug from an earlier draft: marking a cart as notified by writing to it bumped `updated_at`, which immediately un-staled it for the next run's own cutoff check.
- Merged the `Shipping-Carriers` branch: live carrier rate quoting and fulfillment for **ACS Courier** and **Box Now** (`Modules\Core\Shipping\Carriers\{Acs,BoxNow}`) on top of `lunarphp/table-rate-shipping` — `AcsRateDriver`/`BoxNowRateDriver` (live + static price-break resolution), `AcsFulfillmentService`/`BoxNowFulfillmentService` (shipment creation, label printing, cancellation via the new `Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface`, resolved per-carrier via contextual container binding), `Modules\Core\Shipping\Models\Shipment`/`ShipmentInfo`, `PollShipmentTrackingJob` (scheduled every 30 minutes), `ManagePickupManifests` (Filament page for carrier manifest batching), and an `OrderViewExtension` adding a "Create Shipment" header action to Lunar's order view. Carrier credentials are published config (`config/shippingCarriers/{acs,boxnow}.php`), never committed.
- `Modules\Core\Shipping\Concerns\CachesLivePricing` caches a live-priced carrier quote per `(rate, cart)` for 30 minutes — a real, billed API call that's otherwise re-run on every `getShippingOptions()`/`selectShippingOption()` call within the same checkout attempt. `Modules\Core\Shipping\Listeners\FlushLivePricingCache` invalidates it on the only two things that can change a quote: a cart line changing or the shipping address changing (deliberately **not** on order placement — the price the shopper was quoted must still be readable afterwards). Scoped generically to any `SupportsLivePricing` driver, not hardcoded to ACS.
- `AcsRateDriver::resolveLivePrice()` now falls back to the rate's own configured static price if the live ACS API call fails (previously: the shipping option silently disappeared from the list on any API error, including a brief outage). `ManageShippingRates` (our Filament subclass of the vendor rates page) now allows a static price to be configured and saved on a "live" rate specifically for this fallback — previously those fields were hidden and discarded on save for any live-priced rate.
### Fixed
- Fixed a crash (`Attempt to read property "price" on null`) opening/editing a live-priced shipping rate with no fallback price configured yet — the vendor `ManageShippingRates` page's `afterStateHydrated` callback for the price field had no null-guard for a rate with zero `basePrices`, which is now the routine case for an unconfigured live rate.
- Fixed the Filament admin panel's home URL (`/boboko/home`) incorrectly resolving to the Shipping module's `ManagePickupManifests` page instead of the Dashboard — Filament falls back to the first item of the first registered navigation group when no explicit `homeUrl()` is set, and `ManagePickupManifests` had no `navigationGroup`/`navigationSort` of its own. Fixed via explicit `navigationGroup = 'Sales'` / `navigationSort = 100`, placing it after Sales in the nav instead of first overall.
## [0.8.0] - 2026-08-27
### Added
- `Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a known `user_id`/`customer_id` (an anonymous guest cart carries no identity staff could act on); list table shows customer/user, line/item counts (via Filament's built-in `->counts()`/`->sum()`, no per-row queries), currency, and last activity. List page has only two tabs, **Abandoned** (default active) and **Completed** — no "All" tab, so the list never runs an unfiltered fetch over the whole table. They key off whether the cart has a **placed** order (`orders.placed_at IS NOT NULL`), not `Cart::completed_at` — that column is declared/cast on the model but never actually written anywhere in Lunar core, so it's not a real signal; "Abandoned" mirrors Lunar's own `Cart::scopeActive()`. `getNavigationBadge()` shows the abandoned-cart count in the sidebar via a single `COUNT(*)` query, no rows loaded. View page runs `$cart->calculate()` once so line/cart totals (plain public properties Lunar never persists) are populated, without paying that cost per row in the list. Documented in `docs/cart.md`.
## [0.7.0] - 2026-08-27
### Added
+11 -6
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.7.0",
"version": "0.10.1",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -10,14 +10,15 @@
},
"require": {
"php": "^8.5",
"lunarphp/lunar": "1.3.0",
"lunarphp/lunar": "1.5.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^3.0",
"symfony/yaml": "^7.0",
"lunarphp/table-rate-shipping": "^1.3",
"lunarphp/table-rate-shipping": "1.5.0",
"lunarphp/search": "*",
"lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8"
"spatie/laravel-translation-loader": "^2.8",
"lunarphp/stripe": "^1.5"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -27,7 +28,8 @@
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.6",
"pestphp/pest-plugin-laravel": "^4.1"
"pestphp/pest-plugin-laravel": "^4.1",
"filament/upgrade": "^4.0"
},
"extra": {
"laravel": {
@@ -35,9 +37,12 @@
"Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\PaymentServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider"
"Modules\\Core\\Providers\\CartServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider"
]
}
},
+16
View File
@@ -16,4 +16,20 @@ return [
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Cart Abandonment Threshold
|--------------------------------------------------------------------------
|
| How long a cart (that hasn't converted to a placed order) can go without
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
*/
'cart' => [
'abandoned_after' => '1 hour',
],
];
+46
View File
@@ -0,0 +1,46 @@
<?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
return [
/*
|--------------------------------------------------------------------------
| Lunar payment types merged in by Boboko Core
|--------------------------------------------------------------------------
|
| These are merged into config('lunar.payments.types') so every app using
| boboko-core gets cash-on-delivery out of the box, without publishing
| Lunar's own config.
|
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
| it's the Modules\Core\Checkout\Contracts\PaymentDriver class
| CheckoutService::confirmPayment() resolves via the container and calls
| confirm() on. Kept on the same row as 'driver' rather than a second,
| separately-keyed map, so a type's full definition — Lunar's driver,
| its config, and its PaymentDriver — lives in one place.
|
*/
'types' => [
'cash-on-delivery' => [
'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class,
'authorized' => 'awaiting-payment',
'fee' => 0,
],
],
/*
|--------------------------------------------------------------------------
| Lunar cart pipeline additions
|--------------------------------------------------------------------------
|
| 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.
|
*/
'cart_pipeline' => [
ApplyCashOnDeliveryFee::class,
],
];
+50
View File
@@ -0,0 +1,50 @@
<?php
/*
|--------------------------------------------------------------------------
| ACS Courier credentials
|--------------------------------------------------------------------------
|
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
| HTTP header (gates the REST gateway itself) and four account fields
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
| request body. Both are supplied by ACS when your account is set up.
|
| Set these via environment variables — never commit real values.
|
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
| every ACSAlias call).
| ACS_API_KEY The AcsApiKey header value.
| ACS_COMPANY_ID Company_ID body field.
| ACS_COMPANY_PASSWORD Company_Password body field.
| ACS_USER_ID User_ID body field.
| ACS_USER_PASSWORD User_Password body field.
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
| calculation and voucher creation.
| ACS_SENDER_* Static sender details reused on every voucher.
|
*/
return [
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
'api_key' => env('ACS_API_KEY'),
'company_id' => env('ACS_COMPANY_ID'),
'company_password' => env('ACS_COMPANY_PASSWORD'),
'user_id' => env('ACS_USER_ID'),
'user_password' => env('ACS_USER_PASSWORD'),
'billing_code' => env('ACS_BILLING_CODE'),
'sender' => [
'name' => env('ACS_SENDER_NAME'),
'address' => env('ACS_SENDER_ADDRESS'),
'zip_code' => env('ACS_SENDER_ZIP'),
'phone' => env('ACS_SENDER_PHONE'),
],
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
];
+47
View File
@@ -0,0 +1,47 @@
<?php
/*
|--------------------------------------------------------------------------
| Box Now credentials
|--------------------------------------------------------------------------
|
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
| ~1hr expiry), then attach it as an Authorization header on every call.
| Unlike ACS, there is no separate per-request credential body — the
| token alone authorizes all calls once obtained.
|
| Set these via environment variables — never commit real values.
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
| lookups (Box Now recommends this over the main
| base URL for those two calls specifically).
| BOXNOW_CLIENT_ID OAuth2 client id.
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
| the pickup origin on every delivery request.
| BOXNOW_SENDER_* Static sender contact details reused on every
| delivery request.
|
*/
return [
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
'client_id' => env('BOXNOW_CLIENT_ID'),
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
'sender' => [
'name' => env('BOXNOW_SENDER_NAME'),
'email' => env('BOXNOW_SENDER_EMAIL'),
'phone' => env('BOXNOW_SENDER_PHONE'),
],
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
];
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained(config('lunar.database.table_prefix').'orders');
$table->string('carrier');
$table->string('tracking_reference')->unique();
$table->string('parent_reference')->nullable();
$table->timestamp('label_printed_at')->nullable();
$table->string('manifest_reference')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('shipments');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipment_info', function (Blueprint $table) {
$table->id();
$table->foreignId('shipment_id')->constrained('shipments')->cascadeOnDelete();
$table->string('status');
$table->string('carrier_status')->nullable();
$table->text('message')->nullable();
$table->string('location')->nullable();
$table->timestamp('occurred_at');
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('shipment_info');
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_methods', function (Blueprint $table) {
$table->id();
$table->string('type')->unique();
$table->boolean('enabled')->default(true);
$table->json('data')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_methods');
}
};
+272
View File
@@ -0,0 +1,272 @@
# Cart Admin Visibility
`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into
customer/user carts in the Filament admin panel. Lunar itself ships no cart admin view at
all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or
`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar
half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart
mechanics this resource reads from.
---
## Scope: only carts with a known customer or user
`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')`
— an anonymous guest's session cart is excluded entirely.
This was a deliberate call, not an oversight: an anonymous cart carries no identity a staff
member could act on — no name, no email, nothing to follow up with — so listing every guest
session cart would be noise, not a real admin capability. This does **not** mirror Shopify's
admin (Shopify has no "all carts" view at all — only "Abandoned checkouts," gated on a
shopper reaching checkout and entering contact info, a later/narrower stage than Lunar's
`Cart`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a
shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no
checkout step required — so scoping to "identifiable" here is broader than Shopify's
equivalent, not a copy of it.
---
## Four states, not two — and not `Cart::completed_at`
`Lunar\Models\Cart::completed_at` is declared and cast (`'completed_at' => 'datetime'`) but
**never actually written anywhere in Lunar core** — grep `vendor/lunarphp/core/src` for it;
the only hits are the property declaration and the cast. It is not a real signal. `Cart` has
no `status` column at all — every state below is derived from relations/timestamps, not a
single field.
`Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope) actually mixes two
distinct states together: no order ever started, vs. a draft order exists
(`placed_at IS NULL`) but was never placed — checkout was started, not finished. Those are
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
different reachability (checkout usually captures an email even for a guest), so
`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state
split:
- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default
active tab on page load.
- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`.
- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`.
- **Completed** — has an order with `placed_at IS NOT NULL`.
```php
// Ongoing
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
// Abandoned Cart
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff());
// Abandoned Checkout
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff());
// Completed
$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at'));
```
There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
(potentially large) table.
### Abandoned Cart vs Abandoned Checkout — why they're not one bucket
Different purchase intent, different reachability, and different recovery strategy — see
`docs/recovery-strategies.md` for the full marketing-strategy discussion. In short:
- **Abandoned Cart** (no order started) is a weak intent signal — often window-shopping, not
a near-purchase. Frequently unreachable (no email/identity at all for a true guest).
Recovery leans on on-site retargeting and ad remarketing rather than email.
- **Abandoned Checkout** (draft order, never placed) is a strong intent signal — the shopper
committed to buying and something blocked completion. Checkout typically captures contact
info even for a guest, so this state is usually reachable. This is the state the
researched 1h/24h/72h recovery-email cadence targets specifically.
`Modules\Core\Cart\Events\CartAbandoned` and `Modules\Core\Checkout\Events\CheckoutAbandoned`
mirror this same split (see "Events" below) rather than one combined event.
---
## Why this scales fine at a large cart count
Two things keep this cheap regardless of how many carts exist (10,000+):
- **The list is always paginated.** Filament applies `LIMIT`/`OFFSET` to whichever tab's
query is active — a page only ever fetches one page's worth of rows, never the whole
table, "All" tab or not (and there is no "All" tab — see above).
- **No per-row queries.** `lines_count`/`lines_sum_quantity` use Filament's built-in
`->counts('lines')`/`->sum('lines', 'quantity')`, which fold into the same query as the
rest of the list (one `LEFT JOIN`-based aggregate, not N separate lookups). There's no
per-record `getStateUsing()` closure anywhere in this table doing its own query — that's
the pattern to avoid if a future column needs derived data (see `Modules\Core\Catalog\
Services\ProductIndexer` for the general "compute once at index time / one aggregate
query, never per-row" principle this project follows elsewhere).
The one thing that **does** scan more rows as the cart count grows is
`CartResource::getNavigationBadge()` (see below) — but it's a `COUNT(*)`, not a fetch, and
runs once per admin page load, not once per cart row.
---
## Navigation badge — abandoned cart count
```php
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->count();
}
```
Shows the number of abandoned carts (not all carts — a converted cart isn't something a
staff member needs to keep noticing) next to "Carts" in the sidebar. `->count()` compiles to
a single `SELECT COUNT(*) ...` — confirmed via query log — no rows are ever loaded just to
render the badge.
---
## The view page runs the cart's full calculate pipeline — once
`ViewCart::resolveRecord()` calls `$cart->calculate()` before rendering, since `CartLine`'s
computed properties (`unitPrice`, `total`, etc.) and `Cart`'s own totals (`subTotal`, `total`,
...) are plain public properties populated as a side effect of that pipeline — never
persisted, so a plain Eloquent-fetched `Cart` has them all `null`/unset (see `docs/lunar.md`
Gotchas). This only runs on the single-record view page, not per row in the list table —
running the full 5-step pipeline for every row of a paginated list would be needless cost for
data the list doesn't display.
---
## Not built: staff editing a cart
The resource is deliberately read-only (`canCreate()` returns `false`, no edit page
registered). A cart is owned by the storefront's own add/update/remove flow
(`CartSession`/`Cart::add()`/etc.) — hand-editing cart contents from the admin panel isn't a
supported use case here.
---
## `CartService` — the storefront-facing API
`Modules\Core\Cart\Services\CartService` mirrors `Modules\Core\Catalog\Services\
ProductService`/`CollectionService`'s shape — one boboko-owned API a storefront calls, so
Lunar's own `CartSession`/`Cart` stay an implementation detail rather than something a
consuming app depends on directly.
- `current()` / `currentOrCreate()` — the latter force-creates a cart (`CartSession::manager()`),
the former doesn't (`CartSession::current()`, returns `null` for a fresh visitor — see
`docs/lunar.md`'s Cart gotchas).
- `addLine()` / `updateLine()` / `removeLine()` / `clear()` — thin wrappers over
`Cart::add()`/`updateLine()`/`remove()`/`clear()`. No boboko-owned exception types wrap
Lunar's own cart exceptions (`InvalidCartLineQuantityException`, `CartLineIdMismatchException`,
etc.) — they propagate as-is; a wrapper would add indirection with identical semantics.
- `applyCoupon()` / `removeCoupon()` — sets/clears `Cart::coupon_code` (there's no dedicated
Lunar action for this, unlike add/update/remove). `applyCoupon()` validates via
`Discounts::validateCoupon()` first and throws `Modules\Core\Cart\Exceptions\
InvalidCouponException` on a bad code — `CouponString`'s cast only normalizes casing, it
doesn't validate anything, so setting `coupon_code` directly would silently accept a bogus
code and just not discount anything once calculated.
- `saveForLater()` / `moveToCart()` / `activeLines()` / `savedLines()` — see "Save for later"
below.
Every mutating method returns the recalculated `Cart` (matching Lunar's own `Cart::add()`
etc., which already return `$this` after `refresh()->recalculate()`) and dispatches a
matching domain event.
### Events — Lunar dispatches none of its own
`Lunar` dispatches zero cart events — no "item added," no "cart created" (see
`docs/lunar.md`'s Cart gotchas). `CartService` fills that gap with its own, dispatched after
the underlying Lunar operation completes:
`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`, `CartCouponApplied`,
`CartCouponRemoved`, `CartLineSaved`, `CartLineMovedToCart` — all under
`Modules\Core\Cart\Events`. `CartAbandoned`/`CheckoutAbandoned` live under
`Modules\Core\Recovery\Events` instead, not `Cart`/`Checkout` — see "Abandonment detection"
below for why.
**None of these currently have a listener.** They're dispatched-but-unconsumed by design —
built so something downstream (reindexing, notifications, a future read-side reporting
service) has a hook to attach to, not because a concrete consumer exists today. This was a
deliberate decision, not an oversight — see the "don't build speculative infrastructure"
calls made elsewhere in this project (e.g. not wrapping Lunar's cart exceptions).
**Why not wired to Spatie's Activity Log:** `Cart`/`CartLine` already use Lunar's own
`LogsActivity` trait (Spatie's package, Lunar's defaults) — confirmed from source, this logs
model saves/deletes automatically, independent of actor. `Modules\Core\Logging\
ActivityLogService` (this project's own wrapper, used by e.g. `LogTranslationActivity`) is
hardcoded to the `staff` guard — correctly scoped for staff-driven writes (Filament admin
actions), but wrong for customer-driven cart activity, which would resolve `causedBy()` to
`null` every time. Both `ActivityLogService` and `Cart`/`CartLine`'s native `LogsActivity`
write to the **same** `log_name = 'lunar'` / `activity_log` table, with no built-in
separation beyond reading `causer_type` per row — a real limitation worth knowing about, but
not one this project is fixing by giving Cart a distinct `log_name`, since every other Lunar
model logs to `'lunar'` too and a Cart-only carve-out would just be inconsistent. The
intended fix, if this becomes a real need, is a read-side service that queries `activity_log`
and classifies by `causer_type`/`log_name` — not touching every write site.
### Save for later
A `CartLine` can be moved out of the purchasable cart without being deleted — flagged via
`meta.saved_for_later`, not a new column (matches the free-form-JSON pattern already used
elsewhere, e.g. `ProductOptionValue::meta`). `Modules\Core\Cart\Pipelines\
ZeroSavedForLaterPrice` (registered in `config('lunar.cart.pipelines.cart_lines')`, after the
stock `GetUnitPrice`) zeroes `unitPrice`/`unitPriceInclTax` for flagged lines **before**
Lunar's own `CalculateLines` pipeline step sums the cart — `CalculateLines` sums every
`CartLine` unconditionally with no meta-based exclusion of its own, so zeroing the price
upstream is what makes `Cart::subTotal`/`total` naturally correct without a second pass or
callers needing a different totals accessor.
`Lunar\Actions\Carts\UpdateCartLine` **replaces** the whole `meta` column on write (plain
`update(['meta' => $meta])`, not a merge) — `saveForLater()`/`moveToCart()` read the line's
existing meta and merge in the flag change before calling `Cart::updateLine()`, or an
unrelated meta key set by something else would be silently wiped.
### Coupons
See `CartService::applyCoupon()`/`removeCoupon()` above. `Lunar\Base\Casts\CouponString`
just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (via the
`Discounts` facade) is the actual check — does a matching `Discount` (type `AmountOff` or
`BuyXGetY`) exist, `active()`, with `max_uses` not exhausted.
---
## Abandonment detection
"Abandoned" is a **derived** state (`Cart::updated_at` older than
`config('core.cart.abandoned_after')`, default `1 hour`) — nothing transitions a cart into it
via a normal Eloquent write, so there's no model-event hook to dispatch from directly.
`Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by
`Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it
queries the same two branches `ListCarts::getTabs()` uses (no order at all vs. draft order
never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned`
for anything currently stale.
### Cart/Checkout have zero abandonment-related writes — by design
`DetectAbandonedCarts` **only dispatches** — it never writes to `Cart`/`Order` at all. An
earlier version recorded an "already notified" marker on `Cart::meta`/`Order::meta` to avoid
refiring the same event every run, but that `->save()` call bumped `Cart::updated_at` as an
Eloquent side effect — since `updated_at` is also the field abandonment staleness is computed
from, the write **un-staled the very cart it had just marked abandoned**: confirmed live, a
cart that correctly fired `CartAbandoned` showed back up as "Ongoing," not "Abandoned Cart,"
on the very next tab-count check.
The fix wasn't to write the marker more carefully — it was to stop `Cart`/`Checkout` from
having any way to write abandonment state at all. Deduplication ("has this cart already been
notified") is deliberately **not** this command's job; it belongs to `Recovery` (not yet
built — see `docs/recovery-strategies.md`), which will own its own tracking table, keeping
`Cart`/`Order` permanently free of abandonment-related columns or `meta` keys.
**Current tradeoff, accepted deliberately**: until `Recovery` exists, every cart still
matching the "abandoned" query refires its event on every hourly run — there is no dedup at
all right now. That's fine today only because nothing consumes these events yet (see
"Events" above); it would need addressing before anything real listens for them.
---
## Recovery Sequences — design only, not built
See `docs/recovery-strategies.md` — a full marketing-strategy discussion and a first-pass
feature design for an admin-configurable sequence of "touches" (delay + optional discount +
label) per abandonment type. Explicitly parked as an open design question, not scoped for
implementation yet — whether this belongs under `Cart`, a new `Recovery`/`Marketing` concern,
and how far the touch model needs to flex (channel choice, value-based branching, segment
targeting) are all still undecided.
+155
View File
@@ -0,0 +1,155 @@
# Checkout — Design Notes
**Status: design finalized, not yet built.** This is the design spec for
`Modules\Core\Checkout\Services\CheckoutService`, plus the three-stage lifecycle model it's
part of. Nothing in this document is implemented yet.
---
## Three-stage lifecycle: Cart → Checkout → Order
Each stage is its own concern, not a phase inside a shared one — matching the pattern already
established this session (`Recovery` was split out from `Cart` specifically because
abandonment detection is a different lifecycle stage than line-item mutation, even though it
reads `Cart` state).
- **`Cart`** — line items, coupons, save-for-later (`docs/cart.md`). Ends the moment
`Cart::createOrder()` is called.
- **`Checkout`** — the placement moment itself: setting addresses, selecting a shipping
option, placing the order. Starts where Cart ends, ends the instant an `Order` exists.
This document.
- **`Order`** — everything after an order exists: status transitions (`Order::status`,
changed via the Filament admin `EditOrder` page — always staff-driven, never part of
checkout itself), fulfillment/shipment tracking. **Named and scoped here, not yet built** —
same status as `Recovery` before it existed as real code.
`Modules\Core\Checkout\Events\OrderPlaced` (see below) is the handoff point: `Checkout`
dispatches it the moment an order exists; `Order`'s own listeners (not built yet) would be
what reacts to it — e.g. sending a confirmation email, initializing whatever `Order` needs to
initialize. `Checkout` itself has no opinion about what happens after `OrderPlaced` fires.
### Where `Order` would likely absorb work that currently lives under `Shipping`
`Modules\Core\Shipping`'s `Shipment`/`ShipmentInfo` models are already order-scoped
(`Shipment::order(): BelongsTo`), and `PollShipmentTrackingJob`/
`ShipmentStatusUpdatedByCarrier` are fulfillment/tracking concerns that happen entirely after
an order exists — conceptually closer to `Order` than to `Shipping`'s actual job (carrier
rate quoting, `ShippingRateInterface` drivers, `ShippingManifest`). Not decided whether/when
this gets moved; noted here so the boundary is visible when `Order` is actually scoped.
---
## `CheckoutService`
Mirrors `Modules\Core\Cart\Services\CartService`'s shape (see `docs/cart.md`) — one
boboko-owned API a storefront calls, keeping Lunar's own `Cart`/`ShippingManifest` primitives
an implementation detail.
| Method | Wraps | Dispatches |
|---|---|---|
| `setShippingAddress(array\|Addressable $address)` | `Cart::setShippingAddress()` | `ShippingAddressSet($cart, $address)` |
| `setBillingAddress(array\|Addressable $address)` | `Cart::setBillingAddress()` | `BillingAddressSet($cart, $address)` |
| `getShippingOptions()` | `ShippingManifest::getOptions($cart)` | — (read-only) |
| `selectShippingOption(string $identifier)` | `Cart::setShippingOption()` | `ShippingOptionSelected($cart, $option)` — throws `InvalidShippingOptionException` if `$identifier` doesn't resolve |
| `placeOrder(string $fingerprint)` | `Cart::checkFingerprint()` then `Cart::createOrder()` | `OrderPlaced($order)` |
### `getShippingOptions()` — already fully backed by the merged Shipping-Carriers work
`ShippingManifest::getOptions($cart)` runs every registered `ShippingRateInterface` driver
through a pipeline — this already includes ACS/Box Now live-rate quoting
(`Modules\Core\Shipping\Carriers\Acs\AcsRateDriver`/`BoxNowRateDriver`, merged from the
`Shipping-Carriers` branch) alongside `table-rate-shipping`'s own flat-rate/free-shipping/
collection drivers. `CheckoutService` doesn't need to build any rate-resolution logic — it's
a thin pass-through to what already exists and works.
### `placeOrder()` — fingerprint check is mandatory, not optional
`placeOrder(string $fingerprint): Order` requires the fingerprint the shopper's last-seen
cart total was built from (`Cart::fingerprint()`) as a parameter — not an optional
after-the-fact check a caller might forget. `Cart::checkFingerprint()` throws Lunar's own
`FingerprintMismatchException` if the cart's contents/total changed since that fingerprint
was generated (a line's price changed, stock adjusted the total, another tab modified the
cart), forcing re-confirmation instead of silently placing an order at a different total than
what the shopper approved.
### No exception wrapping — same reasoning as `CartService`
Confirmed from source: `Lunar\Validation\Cart\ValidateCartForOrderCreation` (the validator
`Cart::createOrder()` runs via `config('lunar.cart.validators.order_create')`) already throws
`Lunar\Exceptions\Carts\CartException` with a field-keyed `MessageBag`
(`$exception->errors()`) — billing/shipping address completeness, missing shipping option,
duplicate-order guard. This is already the right shape for a storefront to catch and render
as form errors directly; wrapping it in a boboko-owned exception type would add indirection
with identical semantics, the same call made for `CartService`'s cart-line exceptions.
`FingerprintMismatchException` (from the mandatory fingerprint check above) propagates
as-is for the same reason.
**One genuine exception to this rule**: `selectShippingOption()` throws
`Modules\Core\Checkout\Exceptions\InvalidShippingOptionException` when `$identifier` doesn't
resolve to a real option (`ShippingManifest::getOption()` just returns `null` — Lunar has no
matching exception type here to propagate, unlike `CartException`/`FingerprintMismatchException`
above). Same reasoning as `Modules\Core\Cart\Exceptions\InvalidCouponException` for
`Discounts::validateCoupon()`, which also just returns a bool with nothing to reuse. Confirmed
live: an invalid identifier previously returned the cart unchanged with no signal at all —
fixed to throw instead, verified via a real container test.
### Validated from source: the real precondition chain
`ValidateCartForOrderCreation::validate()`, read directly from `vendor/lunarphp/core`:
1. No completed order already exists on this cart (duplicate-order guard).
2. A billing address is set and passes `country_id`/`first_name`/`line_one`/`city`/`postcode`
required-field validation.
3. If the cart `isShippable()` (has at least one non-digital line):
- A shipping option must already be selected (`Cart::getShippingOption()` — which only
resolves anything once `shippingAddress->shipping_option` has been persisted via
`selectShippingOption()`, confirmed from `Lunar\Base\ShippingManifest::getShippingOption()`).
- Unless that option is collect/pickup (`$shippingOption->collect`), a shipping address is
also required and validated the same way as billing.
This is why `CheckoutService`'s methods exist in the order they're listed above — a
storefront checkout flow has to drive them roughly in that sequence for `placeOrder()` to
ever succeed.
---
## Events — richer payload than `CartService`'s, deliberately
`Modules\Core\Checkout\Events`: `ShippingAddressSet`, `BillingAddressSet`,
`ShippingOptionSelected`, `OrderPlaced`.
Unlike `CartService`'s events (which carry a plain `Cart`/`CartLine` model reference — see
`docs/cart.md`), these carry richer, already-resolved payload — e.g. `ShippingOptionSelected`
includes the resolved `ShippingOption` (name, price, carrier identifier), not just the
string identifier a listener would have to re-resolve. Deliberate divergence from
`CartService`'s convention: a live-priced shipping quote or a submitted address is
meaningfully more expensive/awkward for a listener to re-derive later than a `CartLine`
model reference is.
**Why this matters beyond `Checkout` itself:** the Analytics survey (`docs/scratch/
analytics-feature-survey.html`) found conversion-funnel tracking (product view → add to cart
→ checkout → purchase) entirely missing, with zero underlying data captured anywhere. The
Checkout survey separately flagged "abandoned-checkout stage tracking (email captured vs.
shipping selected vs. payment started)" as missing. One event per real state transition here
— not just a single `OrderPlaced` at the end — is what gives a future analytics/reporting
listener (not built) the funnel-stage data neither gap currently has anything to build on.
**None of these have a listener yet.** Same status as `CartService`'s events — dispatched,
unconsumed, built so something downstream has a hook to attach to.
---
## Explicitly out of scope for `CheckoutService`
- **Order-status-changed events** — post-placement, staff-driven (`Order::status` changes via
the Filament admin `EditOrder` page, never through checkout). Belongs to `Order` (see
above), not `Checkout`.
- **Order confirmation email** — needs `OrderPlaced` as a trigger, but actual sending is
separate infrastructure, same "detection/signal only, sending is a later concern" deferral
already applied to `Recovery` (`docs/recovery-strategies.md`).
- **Guest order tracking/lookup** — a separate storefront feature, not part of the placement
flow itself.
- **Payment** — authorizing/capturing a transaction against the placed order. Genuinely
separate from `Checkout` as scoped here; `CheckoutService::placeOrder()` produces an
`Order`, what happens to pay for it is out of this document's scope.
+58 -1
View File
@@ -554,7 +554,11 @@ Customer resolution order: session → `$user->latestCustomer()`.
```php
use Lunar\Facades\CartSession;
$cart = CartSession::current(); // calculates totals; returns null if no cart
$cart = CartSession::current(); // returns null unless a cart already exists in
// session — does NOT auto-create one (see Gotchas)
$cart = CartSession::manager(); // force-creates a cart if none exists yet — use
// this (or __call forwarding, see Gotchas) for
// "give me a cart to add to" flows
$cart->recalculate(); // force recalculation
CartSession::createOrder(); // creates order, removes cart from session
@@ -563,6 +567,39 @@ CartSession::forget(); // clear session (soft deletes cart by def
CartSession::forget(delete: false); // clear session, keep cart in DB
```
Session/identity: the active cart's id is stored under session key `lunar.cart_session.session_key`
(default `lunar_cart`). `CartSession`'s underlying manager (`Lunar\Managers\CartSessionManager`) —
not `Lunar\Base\CartSessionInterface`, which is stale/incomplete, see Gotchas — resolves the current
cart from that session key, falling back to the authenticated user's active cart
(`$user->carts()->active()->first()`) if the session has none.
### `config/lunar/cart_session.php`
| Key | Default | Meaning |
|---|---|---|
| `session_key` | `'lunar_cart'` | Laravel session key storing the active cart id. |
| `auto_create` | `false` | Whether `CartSession::current()` auto-creates a cart when none exists — it does **not**, by default (see Gotchas). |
| `allow_multiple_orders_per_cart` | `false` | If false, a cart with a completed order is abandoned in favor of a fresh cart on next fetch. |
| `delete_on_forget` | `true` | Whether `forget()` (called on logout) soft-deletes the cart — see the auth-policy note above. |
### `config/lunar/cart.php` (cart-line-relevant keys)
| Key | Default | Meaning |
|---|---|---|
| `auth_policy` | `'merge'` | Guest→user cart reconciliation on login: `merge` or `override`. |
| `pipelines.cart` | `CalculateLines, ApplyShipping, ApplyDiscounts, CalculateTax, Calculate` | Steps run on `$cart->calculate()`. |
| `pipelines.cart_lines` | `[GetUnitPrice::class]` | Steps run per-line before cart-level calc. |
| `actions.add_to_cart` | `AddOrUpdatePurchasable::class` | Swappable action behind `Cart::add()`. |
| `actions.get_existing_cart_line` | `GetExistingCartLine::class` | Line-matching logic for add-or-merge (see "Adding items" above). |
| `actions.update_cart_line` | `UpdateCartLine::class` | Behind `Cart::updateLine()`. |
| `actions.remove_from_cart` | `RemovePurchasable::class` | Behind `Cart::remove()`. |
| `validators.add_to_cart` | `[CartLineQuantity, CartLineStock]` | Run before add. |
| `validators.update_cart_line` | `[CartLineQuantity, CartLineStock]` | Run before update. |
| `validators.remove_from_cart` | `[]` | None by default. |
| `eager_load` | 7 relation paths (currency, `lines.purchasable.*`, `lines.cart.currency`) | Auto-eager-loaded whenever the session manager fetches a cart by id. Does **not** include `addresses`/`shippingAddress`/`billingAddress`, `discounts`, or `customer` — add these yourself if needed, to avoid N+1s. |
| `prune_tables.enabled` | `false` | Whether scheduled cart pruning runs. |
| `prune_tables.prune_interval` | `90` (days) | Age threshold for pruning. |
### Adding items
```php
@@ -573,6 +610,11 @@ $cart->addLines([
]);
```
`add()` matches an existing line by purchasable **and exact `meta` equality** (config
`lunar.cart.actions.get_existing_cart_line`, default `GetExistingCartLine`) — if it matches, the
existing line's quantity is incremented instead of a new line being created; any difference in
`meta` (e.g. a different chosen option) makes it a separate line for the same purchasable.
### Updating and removing
```php
@@ -664,6 +706,14 @@ class MyPipeline
`merge` — guest cart items combine with user's existing cart on login.
`override` — guest cart replaces user's cart.
This is wired via `Lunar\Listeners\CartSessionAuthListener`, listening on Laravel's own
`Illuminate\Auth\Events\Login`/`Logout`. On login, if the session already has a cart with no
`user_id` yet, it associates that cart to the user (running the policy above); if the session has
no cart at all, it looks up and resumes the user's own active cart instead. **On logout, it calls
`CartSession::forget()`** — which, per `cart_session.delete_on_forget` (default `true`), **soft-
deletes the cart**. A logged-in customer's cart is gone on logout unless that config is set to
`false`.
### Shipping options
```php
@@ -1209,3 +1259,10 @@ Real bugs/traps hit while building against Lunar in this package — not obvious
- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\Services\ProductService` / `docs/product-listing.md`.
- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Catalog\Services\ProductIndexer::translatedName()`.
- **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed.
- **`CartSession::current()` returns `null` for a fresh visitor by default.** `cart_session.auto_create` defaults to `false`, so nothing auto-creates a cart just from checking `current()`. Use `CartSession::manager()` (force-creates) for an "add to cart" flow, or rely on the fact that `add()`/`remove()`/etc. auto-create via `__call` forwarding (next entry) — don't gate an add-to-cart button on `current() !== null`, it will be null for every guest who hasn't added anything yet.
- **`CartSession`'s facade/interface don't declare `add()`, `remove()`, `updateLine()`, `clear()`, etc. at all — they work anyway, via `__call` magic.** `CartSessionManager::__call()` forwards any undeclared method call straight to the underlying `Cart` model (auto-creating one first if needed). So `CartSession::add($variant, 2)` genuinely works, but neither the facade's `@method` docblock nor `Lunar\Base\CartSessionInterface` mention it — reading either in isolation makes it look unsupported. Trust the manager's source (`Lunar\Managers\CartSessionManager`), not the interface, which is also missing several real methods (`manager()`, `createOrder()`, the shipping-estimate methods) and has a stale signature for `current()`.
- **`Cart::calculate()` is a no-op if totals already look populated — even right after you mutated lines with raw Eloquent.** It's memoized via `isCalculated()` (true when `total` and every line's `total` are non-blank). Every built-in mutator (`add`, `remove`, `updateLine`, `clear`, `associate`, …) already calls `$this->refresh()->recalculate()` to force past this memo — but custom code that touches `CartLine` rows directly (raw `update()`, a queued job, a migration) must call `$cart->recalculate()` itself, or `total`/`subTotal`/etc. silently stay stale.
- **`CartLine`'s computed properties (`unitPrice`, `subTotal`, `total`, `taxAmount`, …) are plain public properties, not DB columns or Eloquent attributes.** A raw `CartLine::find($id)` (no `calculate()` having run on its owning cart) has all of these as `null`/unset — they only populate as a side effect of the owning `Cart`'s pipeline running. Don't read them off a line fetched outside of `CartSession`/`Cart::add()` etc. without calling `$cart->calculate()` first.
- **Logging out deletes the cart by default.** `CartSessionAuthListener::logout()` calls `CartSession::forget()`, and `cart_session.delete_on_forget` defaults to `true` — so a logged-in customer's cart is soft-deleted the moment they log out, guest or not. Set `delete_on_forget` to `false` in `config/lunar/cart_session.php` if carts should survive a logout.
- **Lunar dispatches no cart events at all** — no "item added," "cart created," "line removed," nothing under `Lunar\Events\Cart*`/`CartLine*` exists (unlike products/collections, which have their own Scout indexing hooks). The only reactive surface is `CartLineObserver` (`creating`/`updating`, and it only validates the purchasable type — doesn't dispatch anything). If a feature needs to react to cart changes (reindexing, abandoned-cart notifications, analytics), it has to be built from scratch on plain Eloquent model events (`CartLine::created`, etc.) — there's no Lunar-native pattern to hook into.
- **No Filament admin resource exists for `Cart`/`CartLine`.** Carts aren't visible anywhere in the admin panel except indirectly through an order's `cart` relationship once that cart has become an order. Don't assume there's an admin cart-viewer to check against when debugging — there isn't one.
+128
View File
@@ -0,0 +1,128 @@
# Cart/Checkout Recovery Strategies — Design Notes
**Status: open design discussion, not scoped or built.** This is a record of the
reasoning behind an eventual "Recovery Sequences" feature, kept so the discussion doesn't
have to be re-derived from scratch later. Nothing in this document is implemented.
See `docs/cart.md` for what's actually built today (the four-state cart classification,
`CartAbandoned`/`CheckoutAbandoned` events, `DetectAbandonedCarts`).
---
## Why Abandoned Cart and Abandoned Checkout need different strategies
Established in `docs/cart.md`: Abandoned Cart (no order ever started) is a weak purchase-intent
signal and often unreachable (no identity for a true guest). Abandoned Checkout (a draft order
exists, `placed_at IS NULL`) is a strong intent signal and usually reachable, since checkout
typically captures an email/address even for a guest.
That difference in intent and reachability drives genuinely different marketing strategy, not
just a different admin filter:
### Abandoned Cart strategy — re-engagement, not completion
- **On-site retargeting first** (exit-intent popups, "still thinking it over?" banners on
return visits) — often the only viable channel, since email may not exist yet.
- **Ad platform retargeting** (Meta/Google dynamic remarketing) is the dominant channel here
specifically because it works off a browser/device signal, not an email address — the one
thing reliably available for an anonymous cart.
- **Soft messaging** ("did you forget something?") rather than urgency-driven — intent is
weak, so aggressive discounting is often poor ROI: it trains browsers who were never close
to buying to expect a coupon.
- **Longer, gentler cadence** — a single reminder around 24h, maybe a second a few days out,
sometimes trigger-based (a price drop, back-in-stock) rather than a fixed schedule.
### Abandoned Checkout strategy — completion, not re-engagement
- **Speed matters most.** This is where the classic 1h/24h/72h recovery-email cadence lives —
conversion drops sharply with delay, since the shopper is often still in a "was about to
buy" mental state within the first hour.
- **Direct, urgency-framed messaging** ("complete your order"), sometimes showing cart
contents/total, occasionally a countdown or limited-time incentive on later touches.
- **Discount escalation pays off here** — a small incentive (free shipping, 10% off) on the
2nd/3rd touch is standard, because it's nudging someone who already decided to buy past
whatever blocked them (price shock, a broken payment step, indecision on shipping cost) —
not manufacturing demand from nothing.
- **SMS is more viable** — checkout often captures a phone number, and the higher intent
justifies a more direct channel than for cart-stage.
---
## The broader strategy space (beyond cadence + discount)
Raised as context for how far a "Recovery Sequence" feature might eventually need to flex,
without committing to building any of it yet:
**Message-content strategies**
- Social proof ("X people have this in their cart," reviews shown in the reminder)
- Scarcity/urgency framing (low-stock count, countdown timer on an offer)
- Personalized alternatives — a cheaper or complementary item instead of just re-showing the
abandoned one, useful when the likely blocker was price
**Channel strategies**
- Email (the baseline; nothing built yet — see `docs/cart.md`'s "Recovery Sequences" section)
- SMS — checkout-stage specifically, opt-in required
- Push notifications — not relevant yet given this project's storefront maturity, noted for
completeness
- On-site remarketing (banner/modal on the shopper's next visit) — doesn't require email at
all, arguably the highest-value channel for Abandoned Cart specifically
- Ad platform sync (pushing abandoned-cart product data to a custom audience for paid retargeting)
**Escalation/segmentation strategies**
- Value-based branching — a high-value abandoned checkout might skip straight to a bigger
incentive rather than waiting through a full ladder
- Repeat-abandoner suppression — a customer who's abandoned 3+ times without ever completing
either stops receiving emails (fatigue/spam risk) or gets a different tactic (e.g. a "what
stopped you?" survey) instead of another discount
- New vs. returning customer branching — a first-time visitor's abandoned cart might warrant
"welcome discount" framing instead of a generic recovery email, since the blocker was
likely trust/unfamiliarity rather than price
**Timing refinement**
- Time-of-day/timezone-aware sending (don't fire a touch at 3am local time even if the delay
technically elapsed)
- Cart-content-triggered timing — a fast-moving/low-stock item might warrant an earlier, more
urgent first touch than a cart of always-in-stock staples
---
## First-pass feature shape (discussed, not finalized)
An admin defines, independently per abandonment type (Abandoned Cart, Abandoned Checkout), an
ordered sequence of **touches**. Each touch is three ideas:
1. **How long to wait** since the abandonment began
2. **What offer to attach**, optional — reusing whatever `Discount` already exists in the
system rather than inventing a new pricing concept
3. **A label**, so staff can see what a touch represents in the admin UI
The system continuously re-evaluates every abandoned cart/checkout against its sequence, and
when a cart becomes due for the next touch it hasn't had yet, that becomes a signal — this
feature's responsibility ends there. Actually sending anything (email, SMS, on-site banner) is
explicitly out of scope for this feature; something else, not yet designed, would consume that
signal.
### What this requires that isn't built yet
- **A fixed "abandonment began at" timestamp**, captured once and never re-derived — a
sequence needs to schedule touches from a stable starting point, not from `Cart::updated_at`,
which keeps moving every time the cart (or its own bookkeeping) is written to. This is the
same underlying issue as the known bug in `docs/cart.md`'s "Abandonment detection" section —
fixing that bug properly (freezing the abandonment moment) is very likely a prerequisite for
this feature, not a separate concern.
- **Re-evaluation, not one-shot detection** — `DetectAbandonedCarts` today marks a cart
abandoned once and stops; a sequence needs a cart to be revisited on every scheduler run to
check "which touch, if any, is now due," for as long as it stays unrecovered.
### Still undecided
- **Which concern this belongs under.** Not `Cart` (it's not a cart-mechanics concern) —
candidates raised: a new `Recovery` concern, or `Marketing`. Not decided.
- **How far the touch model needs to flex.** The three-idea shape above (delay, discount,
label) covers cadence + discount escalation cleanly, but doesn't yet accommodate channel
choice, value-based branching, or segment targeting from the broader strategy list above.
Whether those get folded into the touch model, layered on top some other way, or deliberately
left out of v1 is unresolved.
- **Whether "recovery" is cart/checkout-specific at all**, or a more general "scheduled
customer touch based on a triggering condition" mechanism that cart/checkout abandonment
happens to be the first use case for.
+449
View File
@@ -0,0 +1,449 @@
<title>Analytics Feature Survey</title>
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
:root:not([data-theme="light"]) {
@media (prefers-color-scheme: dark) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 15.5px;
line-height: 1.55;
margin: 0;
padding: 4.5rem 1.5rem 6rem;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
header.page {
margin-bottom: 3.25rem;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.9rem;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 560;
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
line-height: 1.08;
letter-spacing: -0.01em;
margin: 0 0 0.9rem;
text-wrap: balance;
}
.dek {
color: var(--muted);
max-width: 60ch;
font-size: 1.02rem;
}
.dek strong {
color: var(--ink);
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.6rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border-radius: 3px;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-top: 3rem;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 0.85rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.7rem;
margin-bottom: 1.1rem;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 1.6rem;
}
.cat-head h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.28rem;
margin: 0;
letter-spacing: -0.005em;
}
.cat-note {
color: var(--muted);
font-size: 0.86rem;
margin: 0 0 1.2rem;
max-width: 62ch;
}
.feature {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid var(--hairline);
align-items: start;
}
.feature:last-child { border-bottom: none; }
.f-name {
font-weight: 600;
font-size: 0.98rem;
}
.f-status {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
height: fit-content;
}
.f-status.have { background: var(--good-soft); color: var(--good); }
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
.f-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.87rem;
margin-top: 0.15rem;
max-width: 66ch;
}
.f-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.82em;
background: var(--accent-soft);
color: var(--accent);
padding: 0.08em 0.35em;
border-radius: 3px;
}
footer.page {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 0.82rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer.page a { color: var(--accent); }
@media (max-width: 560px) {
body { padding: 3rem 1.1rem 4rem; }
.feature { grid-template-columns: 1fr; }
.f-status { justify-self: start; }
}
</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<div class="wrap">
<header class="page">
<div class="eyebrow">boboko / analytics · competitive survey</div>
<h1>What analytics elsewhere can do that boboko can't yet</h1>
<p class="dek">
A feature-by-feature pass across Shopify, WooCommerce Analytics, and PrestaShop's
stats modules — sourced, not recalled from memory — checked against what
<strong>Lunar's admin <code>Dashboard</code></strong> actually ships today and what
raw data already sits in <code>lunar_orders</code>/<code>lunar_carts</code> unused.
This is genuinely new territory for boboko — most rows below land on partial or
missing, and that's an honest read, not an undersell.
</p>
<div class="legend">
<span class="chip have">● have</span>
<span class="chip partial">◐ partial</span>
<span class="chip missing">○ missing</span>
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2>Sales &amp; revenue dashboard</h2>
</div>
<p class="cat-note">What loads the moment staff open the admin panel — this is the one area where Lunar ships more than expected.</p>
<div class="feature">
<div class="f-name">Revenue / order-count stat cards with period-over-period trend</div>
<span class="f-status have">have</span>
<div class="f-note">Verified from source: <code>OrderStatsOverview</code> widget — today vs. yesterday, last 7 vs. prior 7, last 30 vs. prior 30 days, both order count and sub-total, with up/down trend icons. Registered by default on Lunar's <code>Dashboard</code> page, and boboko's panel (<code>3dealer/app/Providers/PanelServiceProvider.php</code>) registers the stock panel with no <code>pages()</code>/<code>Dashboard</code> override — this ships as-is.</div>
</div>
<div class="feature">
<div class="f-name">Sales-over-time chart (revenue + order count, 12-month trend)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>OrdersSalesChart</code> — ApexCharts area chart, monthly buckets over the trailing year, dual y-axis (order count / sub-total). Same "no override" reasoning as above applies to every widget on this page.</div>
</div>
<div class="feature">
<div class="f-name">Average order value (AOV) trend, segmented by customer group</div>
<span class="f-status have">have</span>
<div class="f-note"><code>AverageOrderValueChart</code> — one series per <code>CustomerGroup</code> plus a synthetic guest series, monthly average of <code>sub_total</code> over the trailing year.</div>
</div>
<div class="feature">
<div class="f-name">New vs. returning customer split</div>
<span class="f-status have">have</span>
<div class="f-note"><code>NewVsReturningCustomersChart</code> reads <code>Order::new_customer</code>, a real boolean column set by <code>Lunar\Jobs\Orders\MarkAsNewCustomer</code> (true when no prior order existed for that customer at placement time) — not a cosmetic flag.</div>
</div>
<div class="feature">
<div class="f-name">Live/latest-orders feed on the dashboard</div>
<span class="f-status have">have</span>
<div class="f-note"><code>LatestOrdersTable</code> — last 10 placed orders, 60s polling, reuses <code>OrderResource</code>'s own table columns.</div>
</div>
<div class="feature">
<div class="f-name">Real-time dashboard vs. scheduled email reports</div>
<span class="f-status partial">partial</span>
<div class="f-note">The dashboard widgets above poll every 60s (near-real-time, pull-based) — there is no scheduled/emailed report anywhere in Lunar or boboko-core. Industry pattern researched: real-time suits operational checks, scheduled digest suits weekly/monthly strategic review — boboko only has the first half.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Product &amp; catalog performance</h2>
</div>
<p class="cat-note">Which products are actually selling, and what's about to run out.</p>
<div class="feature">
<div class="f-name">Best-sellers / top-products report</div>
<span class="f-status have">have</span>
<div class="f-note"><code>PopularProductsTable</code> — groups <code>lunar_order_lines</code> by product identifier over the trailing 12 months, ranked by quantity sold, with revenue (<code>sub_total</code>) alongside. Physical products only (<code>whereType('physical')</code>).</div>
</div>
<div class="feature">
<div class="f-name">Per-product detail stats (views, conversion, revenue for one SKU)</div>
<span class="f-status missing">missing</span>
<div class="f-note">PrestaShop's <code>statsproduct</code> module was researched as the comparison point (per-product page-view + sales detail) — boboko has no page-view capture at all (see 04), so even the sales half of this can't be built without the traffic half.</div>
</div>
<div class="feature">
<div class="f-name">Catalog-wide statistics (active/inactive counts, category breakdown)</div>
<span class="f-status missing">missing</span>
<div class="f-note">PrestaShop's <code>statscatalog</code> module researched as the reference. No equivalent surface in Lunar or boboko-core — would be a straightforward aggregate over <code>lunar_products</code>/<code>lunar_collections</code>, just not built.</div>
</div>
<div class="feature">
<div class="f-name">Inventory / stock-turnover report</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>ProductVariant::$stock</code> is a plain point-in-time integer column — no stock-movement ledger or history table exists in <code>lunarphp/core</code> (grepped the models and migrations directories). Turnover reporting needs a time series of stock levels or receipts/sales deltas; today's schema only has "current stock," so there's nothing to compute turnover from yet, not just a missing report.</div>
</div>
<div class="feature">
<div class="f-name">Low-stock / reorder alerting surfaced in a report</div>
<span class="f-status missing">missing</span>
<div class="f-note">The Cart survey already noted <code>ProductIndexer</code>'s <code>in_stock</code> field exists for search/listing purposes — nothing aggregates it into a "low stock" admin view or report.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Customer analytics</h2>
</div>
<p class="cat-note">Value and behavior at the level of one shopper, or a group of them.</p>
<div class="feature">
<div class="f-name">Per-customer order count / average spend / lifetime spend</div>
<span class="f-status have">have</span>
<div class="f-note">Verified from source: <code>CustomerStatsOverviewWidget</code> on the customer view page — total orders, average spend, and total spend, computed live from <code>orders()->sum()/average()</code>. This is per-customer lookup, not an aggregate report across all customers.</div>
</div>
<div class="feature">
<div class="f-name">Customer Lifetime Value (CLV) as a store-wide metric/segment</div>
<span class="f-status partial">partial</span>
<div class="f-note">The per-customer total-spend figure above is the raw ingredient, but there's no store-wide CLV report, no ranking of customers by CLV, and no predictive/forward-looking CLV — WooCommerce Analytics' Customer Analytics extension (researched) computes this plus churn and RFM segments, none of which exist here.</div>
</div>
<div class="feature">
<div class="f-name">Cohort retention analysis</div>
<span class="f-status missing">missing</span>
<div class="f-note">Researched as a WooCommerce/Metorik feature (retention rate by signup-month cohort). No cohort concept, table, or query exists anywhere in Lunar or boboko-core.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Behavioral &amp; funnel tracking</h2>
</div>
<p class="cat-note">What happens before an order exists — the storefront side neither repo instruments at all.</p>
<div class="feature">
<div class="f-name">Page-view / product-view event capture</div>
<span class="f-status missing">missing</span>
<div class="f-note">Grepped both repos for <code>gtag</code>/<code>dataLayer</code>/GA4/any client-side event tracker — zero hits. No storefront event of any kind is dispatched, captured, or stored anywhere.</div>
</div>
<div class="feature">
<div class="f-name">Conversion funnel (view → add to cart → checkout → purchase)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Shopify's funnel report (researched) needs a session-scoped event stream across all four stages. boboko has only the last stage as durable data (a placed <code>Order</code>) — no view or add-to-cart events exist to build the earlier steps from, consistent with the Cart survey's finding that Lunar dispatches zero cart events.</div>
</div>
<div class="feature">
<div class="f-name">Abandoned-cart aggregate value/rate reporting</div>
<span class="f-status partial">partial</span>
<div class="f-note">Distinct from the Cart survey's per-cart admin lookup (<code>CartResource</code>, already shipped) — this is a rolled-up metric: total abandoned value this week, abandonment rate as a percentage of carts started. The underlying rows exist in <code>lunar_carts</code>/<code>lunar_cart_lines</code> (same query <code>CartResource</code>'s Abandoned tab already runs), but nothing aggregates them into a rate or a trend — it's list-only today.</div>
</div>
<div class="feature">
<div class="f-name">Traffic-source / campaign attribution (UTM-based)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No UTM capture, no marketing/session table anywhere in either repo. Researched as the backbone of Shopify's/GA4's acquisition reporting — would need a session table capturing <code>utm_source</code>/<code>medium</code>/<code>campaign</code> at first touch, tied forward to the eventual order.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2>Tax, accounting &amp; export</h2>
</div>
<p class="cat-note">Getting numbers out of boboko and into someone else's books.</p>
<div class="feature">
<div class="f-name">Tax / VAT breakdown captured per order</div>
<span class="f-status have">have</span>
<div class="f-note">Verified from source: <code>lunar_orders</code> migration stores both <code>tax_breakdown</code> (JSON, per-rate detail) and <code>tax_total</code> as real columns on every placed order — this is genuine underlying data, not inferred.</div>
</div>
<div class="feature">
<div class="f-name">Tax / VAT report for accounting (e.g. by tax zone, by period)</div>
<span class="f-status partial">partial</span>
<div class="f-note">The per-order data above is complete enough to build this from, but nothing aggregates <code>tax_breakdown</code>/<code>tax_total</code> across orders into a filing-ready report by <code>TaxZone</code> or period — no such widget, page, or query exists in Lunar or boboko-core.</div>
</div>
<div class="feature">
<div class="f-name">CSV / accounting-software export of orders or sales data</div>
<span class="f-status missing">missing</span>
<div class="f-note">Grepped for <code>Exporter</code>/<code>ExportAction</code>/<code>Excel::</code> across <code>lunarphp/lunar</code> and boboko-core's <code>src</code> — no hits. Filament ships export actions as a first-party feature elsewhere in the ecosystem; nothing here wires one up for orders.</div>
</div>
<div class="feature">
<div class="f-name">Sales by channel</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>Order::channel_id</code> is a real, always-populated foreign key (verified in the <code>lunar_orders</code> migration) — every order already knows its channel. No report groups by it; the dashboard's charts are all channel-blind.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">06</span>
<h2>Audit trail vs. analytics</h2>
</div>
<p class="cat-note">A distinction worth being explicit about, since it's easy to mistake one for the other.</p>
<div class="feature">
<div class="f-name">Activity log (Spatie activitylog) on core models</div>
<span class="f-status have">have</span>
<div class="f-note">Verified from source and <code>docs/lunar.md</code>'s Activity Logging section: <code>Lunar\Base\Traits\LogsActivity</code> covers Order, Cart, Product, Customer, and 15 other models, recording only dirty attributes per change under the <code>lunar</code> log name.</div>
</div>
<div class="feature">
<div class="f-name">This counts as analytics</div>
<span class="f-status missing">missing</span>
<div class="f-note">It doesn't, and isn't listed as "have" anywhere above for that reason — activity log is a per-record change history for compliance/support ("who edited this order's shipping address"), not aggregate reporting ("how much revenue this month"). No row in this survey is satisfied by activity-log data.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-08-28 — sources cited inline: <code>docs/lunar.md</code> §Filament Panel Integration and §Activity Logging plus direct reads of <code>vendor/lunarphp/lunar/src/Filament/Widgets/Dashboard</code>, <code>vendor/lunarphp/core</code> models/migrations, and <code>3dealer/app/Providers/PanelServiceProvider.php</code> are repo-verified; Shopify/WooCommerce/PrestaShop feature claims are from web research, not repo reads.</span>
<span>boboko-core / docs</span>
</footer>
</div>
+429
View File
@@ -0,0 +1,429 @@
<title>Checkout Feature Survey</title>
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
:root:not([data-theme="light"]) {
@media (prefers-color-scheme: dark) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 15.5px;
line-height: 1.55;
margin: 0;
padding: 4.5rem 1.5rem 6rem;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
header.page {
margin-bottom: 3.25rem;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.9rem;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 560;
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
line-height: 1.08;
letter-spacing: -0.01em;
margin: 0 0 0.9rem;
text-wrap: balance;
}
.dek {
color: var(--muted);
max-width: 60ch;
font-size: 1.02rem;
}
.dek strong {
color: var(--ink);
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.6rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border-radius: 3px;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-top: 3rem;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 0.85rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.7rem;
margin-bottom: 1.1rem;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 1.6rem;
}
.cat-head h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.28rem;
margin: 0;
letter-spacing: -0.005em;
}
.cat-note {
color: var(--muted);
font-size: 0.86rem;
margin: 0 0 1.2rem;
max-width: 62ch;
}
.feature {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid var(--hairline);
align-items: start;
}
.feature:last-child { border-bottom: none; }
.f-name {
font-weight: 600;
font-size: 0.98rem;
}
.f-status {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
height: fit-content;
}
.f-status.have { background: var(--good-soft); color: var(--good); }
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
.f-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.87rem;
margin-top: 0.15rem;
max-width: 66ch;
}
.f-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.82em;
background: var(--accent-soft);
color: var(--accent);
padding: 0.08em 0.35em;
border-radius: 3px;
}
footer.page {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 0.82rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer.page a { color: var(--accent); }
@media (max-width: 560px) {
body { padding: 3rem 1.1rem 4rem; }
.feature { grid-template-columns: 1fr; }
.f-status { justify-self: start; }
}
</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<div class="wrap">
<header class="page">
<div class="eyebrow">boboko / checkout · competitive survey</div>
<h1>What checkout elsewhere can do that boboko can't yet</h1>
<p class="dek">
A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's checkout
layer — sourced, not recalled from memory — checked against what
<strong>Lunar's <code>Cart::createOrder()</code> / order-creation pipeline</strong>
actually supports today. Companion to the Cart survey: this starts where that one
left off — address and shipping-option capture through to a placed order. For
deciding what to design next, not a build order.
</p>
<div class="legend">
<span class="chip have">● have</span>
<span class="chip partial">◐ partial</span>
<span class="chip missing">○ missing</span>
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2>Getting to checkout</h2>
</div>
<p class="cat-note">Who's allowed to check out, and in how many steps.</p>
<div class="feature">
<div class="f-name">Guest checkout (no account required)</div>
<span class="f-status have">have</span>
<div class="f-note">Structural, not bolted-on: <code>Order.user_id</code> and <code>customer_id</code> are both nullable, and <code>ValidateCartForOrderCreation</code> never checks for either — it only requires a billing address and, if shippable, a shipping address + option. A cart with no <code>user_id</code> creates an order fine.</div>
</div>
<div class="feature">
<div class="f-name">One-page vs. multi-step checkout</div>
<span class="f-status missing">missing</span>
<div class="f-note">Pure storefront-UI concern — Lunar has no opinion here, it just exposes <code>setShippingAddress()</code>/<code>setBillingAddress()</code>/<code>setShippingOption()</code> as independent calls that a UI can sequence however it likes. WooCommerce and PrestaShop both ship one-page as a plugin/theme layer, not core, so this isn't a Lunar gap so much as storefront work still to do.</div>
</div>
<div class="feature">
<div class="f-name">Address autocomplete (type-ahead, from Google Places / Loqate)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Research: cuts address-entry keystrokes by 70%+ and is a proven abandonment-reduction tactic (Google Maps Platform, Loqate). No Lunar hook for it either way — it's a storefront form concern layered on top of the same <code>setShippingAddress()</code> call.</div>
</div>
<div class="feature">
<div class="f-name">Express/accelerated checkout (Shop Pay, Apple Pay, Google Pay equivalents)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Research: Shopify reports Shop Pay can lift conversion up to 50% over guest checkout, mobile especially. Lunar's <code>Payments</code> facade is driver-based (<code>Payments::driver('card')</code>) so a wallet driver is architecturally pluggable, but none ships, and there's no one-tap "skip the address form" path since address capture still runs through the standard cart-address flow first.</div>
</div>
<div class="feature">
<div class="f-name">Terms &amp; conditions acceptance at checkout</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>Order.meta</code> and <code>Cart.meta</code> are both free-form JSON columns carried straight through <code>FillOrderFromCart</code> (<code>'meta' => $cart->meta</code>) — technically able to record a timestamp/version of accepted terms today, but no dedicated field, checkbox validation, or admin display exists.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Order creation mechanics</h2>
</div>
<p class="cat-note">What actually happens inside <code>createOrder()</code>, verified from source.</p>
<div class="feature">
<div class="f-name">Duplicate-order prevention on repeat submits</div>
<span class="f-status have">have</span>
<div class="f-note">Two layers, both real: <code>Cart::draftOrder()</code> matches on <code>fingerprint()</code> + <code>total</code>, so re-running <code>createOrder()</code> on an unchanged cart reuses the same draft order instead of duplicating it (<code>CreateOrder::execute()</code>); once an order is placed, <code>hasCompletedOrders()</code> throws <code>DisallowMultipleCartOrdersException</code> unless <code>allowMultipleOrders</code> is explicitly passed.</div>
</div>
<div class="feature">
<div class="f-name">Draft order created before payment, finalized after</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Order::isDraft()</code>/<code>isPlaced()</code> gate on <code>placed_at</code>; <code>orders.draft_status</code> config (default <code>awaiting-payment</code>) sets the initial status. The order exists — and can be re-run through the pipeline idempotently via the fingerprint match above — before a payment driver ever authorizes anything.</div>
</div>
<div class="feature">
<div class="f-name">Order address, line, and shipping-line snapshotting from cart</div>
<span class="f-status have">have</span>
<div class="f-note">The whole <code>orders.pipelines.creation</code> chain does this explicitly — <code>FillOrderFromCart</code>, <code>CreateOrderLines</code>, <code>CreateOrderAddresses</code>, <code>CreateShippingLine</code>, <code>CleanUpOrderLines</code>, <code>MapDiscountBreakdown</code> — each copying cart state into immutable order rows rather than referencing the cart live.</div>
</div>
<div class="feature">
<div class="f-name">Address validation before order creation</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ValidateCartForOrderCreation</code> requires <code>country_id</code>, <code>first_name</code>, <code>line_one</code>, <code>city</code>, <code>postcode</code> on billing always, and on shipping too unless the chosen <code>ShippingOption-&gt;collect</code> is true (in-store pickup skips a shipping address).</div>
</div>
<div class="feature">
<div class="f-name">Exchange rate and currency locked at order time</div>
<span class="f-status have">have</span>
<div class="f-note"><code>FillOrderFromCart</code> copies <code>currency_code</code> and <code>exchange_rate</code> from the cart's currency onto the order at creation — later currency-config changes don't retroactively alter placed orders.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Confirmation &amp; communication</h2>
</div>
<p class="cat-note">What tells the customer (and staff) an order happened.</p>
<div class="feature">
<div class="f-name">Order confirmation email on placement</div>
<span class="f-status missing">missing</span>
<div class="f-note">Surprising given how close it looks to shipping: every status in <code>config/lunar/orders.php</code> carries a <code>mailers</code> and <code>notifications</code> array, but grep across core turns up exactly one reader of that config (<code>Order::getStatusLabelAttribute()</code>, and it only reads <code>label</code>). Nothing in core ever dispatches a mailer or notification from a status change — those keys are unwired placeholders, not a working feature.</div>
</div>
<div class="feature">
<div class="f-name">Order-status-changed events</div>
<span class="f-status missing">missing</span>
<div class="f-note">Same gap as Cart's event survey found — <code>src/Events/</code> in core contains only <code>PaymentAttemptEvent</code>. No <code>OrderCreated</code>, no <code>OrderStatusUpdated</code>. Confirmation email, staff Slack ping, or customer SMS on status change all have to be built from scratch on plain Eloquent model events (<code>Order::updated()</code>), same pattern as the cart-event gap.</div>
</div>
<div class="feature">
<div class="f-name">Order tracking / status lookup for guests</div>
<span class="f-status missing">missing</span>
<div class="f-note">Research: PrestaShop's order-tracking extensions explicitly cover "non-logged-in customers track their orders." Lunar has the data (<code>Order.reference</code>, <code>status</code>, <code>OrderAddress.contact_email</code>) but no lookup mechanism — a guest with no account has no route back to their order without the confirmation email that also doesn't exist yet.</div>
</div>
<div class="feature">
<div class="f-name">New-customer detection on first order</div>
<span class="f-status have">have</span>
<div class="f-note"><code>CreateOrder::execute()</code> dispatches <code>MarkAsNewCustomer::dispatch($order->id)</code> as a queued job after every order creation — genuinely wired, unlike the mail/notification config above.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Abandoned checkout recovery</h2>
</div>
<p class="cat-note">Distinct from abandoned <em>cart</em> recovery (covered in the Cart survey) — this is someone who reached address/email capture and still left.</p>
<div class="feature">
<div class="f-name">Draft orders are queryable and staff-visible</div>
<span class="f-status partial">partial</span>
<div class="f-note">The data exists — <code>Order::isDraft()</code> plus the address already captured on it — but per the Cart survey's finding, there's no Filament resource for <code>Cart</code> and (unverified here, likely the same gap) no dedicated "abandoned checkout" view distinguishing a draft order with a captured address from one that never got that far.</div>
</div>
<div class="feature">
<div class="f-name">Automated recovery email (post-address-capture)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Research: Shopify's built-in template fires after a shopper enters details and leaves, with editable wait time and an optional discount. boboko has strictly better raw material for this than the cart-abandonment case — a draft order after address capture always has <code>OrderAddress.contact_email</code>, where an abandoned guest cart usually has none — but nothing sends on it.</div>
</div>
<div class="feature">
<div class="f-name">Abandoned-checkout stage tracking (email captured vs. shipping selected vs. payment started)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No event dispatch anywhere in the checkout pipeline (see 03) means no timestamped record of which step a checkout got to — only the current state of the draft order, not its history.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2>Pricing, tax &amp; locale at checkout</h2>
</div>
<p class="cat-note">What the customer sees the moment money is on screen.</p>
<div class="feature">
<div class="f-name">Tax-inclusive vs. tax-exclusive price display</div>
<span class="f-status have">have</span>
<div class="f-note"><code>TaxZone.price_display</code> is a first-class enum (<code>tax_inclusive</code>/<code>tax_exclusive</code>), and <code>Price::priceExTax()</code>/<code>priceIncTax()</code> both exist on the model — more complete than PrestaShop, where dual-price display is a separately-sold addon module, not core.</div>
</div>
<div class="feature">
<div class="f-name">Full tax breakdown shown at checkout (per-line, per-rate)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Cart.taxBreakdown</code> and <code>OrderLine.tax_breakdown</code> are both populated structured objects (iterate <code>.amounts</code>), not just a lump-sum total — the data supports a itemized tax display, a storefront just has to render it.</div>
</div>
<div class="feature">
<div class="f-name">Multi-currency checkout (pay in shopper's own currency)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Currency.exchange_rate</code> plus <code>sync_prices</code> per non-default currency, and the rate is snapshotted onto the order at creation (see 02) — the same mechanics PrestaShop needs an addon for.</div>
</div>
<div class="feature">
<div class="f-name">Multi-language checkout copy</div>
<span class="f-status partial">partial</span>
<div class="f-note">Product/collection/attribute copy is fully translatable via <code>attribute_data</code> + <code>Language</code>, but checkout itself — form labels, validation errors, status labels — is storefront-owned Laravel localization, not something Lunar's order pipeline touches either way.</div>
</div>
<div class="feature">
<div class="f-name">Click-and-collect / in-store pickup as a checkout option</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ShippingOption.collect</code> is a real boolean the validator checks directly — when true, <code>ValidateCartForOrderCreation</code> skips the shipping-address requirement entirely. Modeled at the same level as the <code>collection</code> driver in the Table Rate Shipping add-on.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-08-28 — sources cited inline; <code>vendor/lunarphp/core/src</code> reads are marked by file/class name, Shopify/WooCommerce/PrestaShop claims are marked "Research."</span>
<span>boboko-core / docs</span>
</footer>
</div>
@@ -0,0 +1,476 @@
<title>Customer Accounts Feature Survey</title>
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
:root:not([data-theme="light"]) {
@media (prefers-color-scheme: dark) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 15.5px;
line-height: 1.55;
margin: 0;
padding: 4.5rem 1.5rem 6rem;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
header.page {
margin-bottom: 3.25rem;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.9rem;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 560;
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
line-height: 1.08;
letter-spacing: -0.01em;
margin: 0 0 0.9rem;
text-wrap: balance;
}
.dek {
color: var(--muted);
max-width: 60ch;
font-size: 1.02rem;
}
.dek strong {
color: var(--ink);
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.6rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border-radius: 3px;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-top: 3rem;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 0.85rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.7rem;
margin-bottom: 1.1rem;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 1.6rem;
}
.cat-head h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.28rem;
margin: 0;
letter-spacing: -0.005em;
}
.cat-note {
color: var(--muted);
font-size: 0.86rem;
margin: 0 0 1.2rem;
max-width: 62ch;
}
.feature {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid var(--hairline);
align-items: start;
}
.feature:last-child { border-bottom: none; }
.f-name {
font-weight: 600;
font-size: 0.98rem;
}
.f-status {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
height: fit-content;
}
.f-status.have { background: var(--good-soft); color: var(--good); }
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
.f-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.87rem;
margin-top: 0.15rem;
max-width: 66ch;
}
.f-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.82em;
background: var(--accent-soft);
color: var(--accent);
padding: 0.08em 0.35em;
border-radius: 3px;
}
footer.page {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 0.82rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer.page a { color: var(--accent); }
@media (max-width: 560px) {
body { padding: 3rem 1.1rem 4rem; }
.feature { grid-template-columns: 1fr; }
.f-status { justify-self: start; }
}
</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<div class="wrap">
<header class="page">
<div class="eyebrow">boboko / customer accounts · competitive survey</div>
<h1>What customer accounts elsewhere can do that boboko can't yet</h1>
<p class="dek">
A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's
account layer — sourced, not recalled from memory — checked against what
<strong>Lunar's <code>Customer</code>/<code>Address</code>/<code>CustomerGroup</code></strong>
models actually support today and what exists (or doesn't) in boboko-core
and 3dealer right now. For deciding what to design next, not a build order.
</p>
<div class="legend">
<span class="chip have">● have</span>
<span class="chip partial">◐ partial</span>
<span class="chip missing">○ missing</span>
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2>Whether an account exists at all</h2>
</div>
<p class="cat-note">The storefront-facing account experience, as distinct from staff/admin auth in <code>Modules\Core\Auth</code>.</p>
<div class="feature">
<div class="f-name">Customer↔User linking (data model)</div>
<span class="f-status have">have</span>
<div class="f-note">Fully modeled by Lunar core — <code>Customer::users()</code> / <code>User::customers()</code> via <code>customer_user</code> pivot (<code>LunarUser</code> trait), plus <code>User::latestCustomer()</code>.</div>
</div>
<div class="feature">
<div class="f-name">Customer record auto-created on signup</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Modules\Core\Customer\Listeners\CreateCustomerForUser</code> attaches a new <code>Customer</code> to every <code>User</code> on <code>UserCreated</code>, gated by <code>config('core.auto_create_customer_for_user')</code>.</div>
</div>
<div class="feature">
<div class="f-name">Storefront login / registration UI</div>
<span class="f-status missing">missing</span>
<div class="f-note">3dealer has no auth scaffolding at all — no Breeze/Fortify/Sanctum in <code>composer.json</code>, no <code>login</code>/<code>register</code> views, nothing in <code>routes/web.php</code>. Only <code>Modules\Core\Auth</code>'s Filament staff panel login exists.</div>
</div>
<div class="feature">
<div class="f-name">Account/profile page (name, addresses, orders)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No <code>AccountController</code>, no <code>account</code>/<code>profile</code> route, no matching Blade views anywhere in 3dealer's <code>app/</code> or <code>resources/views</code> — confirmed by exhaustive grep.</div>
</div>
<div class="feature">
<div class="f-name">Account nav link in header</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>resources/views/components/header.blade.php</code> has a cart icon and a search button but no account/login link at all — not even a dead one. The cart icon itself links to <code>/cart</code>, which also has no matching route, matching this codebase's known stubbed-UI pattern.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Order history &amp; tracking</h2>
</div>
<p class="cat-note">Letting a customer see and follow their own orders without contacting support.</p>
<div class="feature">
<div class="f-name">Order history data (per customer)</div>
<span class="f-status have">have</span>
<div class="f-note">Fully modeled — <code>Customer::orders()</code> and <code>User::orders()</code> both exist (<code>Lunar\Models\Order</code>), with <code>status</code>, line items, addresses, and transactions already relational.</div>
</div>
<div class="feature">
<div class="f-name">Self-service order history / status page</div>
<span class="f-status missing">missing</span>
<div class="f-note">No storefront route or controller reads <code>Order</code> for a logged-in customer — the data exists, nothing surfaces it. Shopify's rebuilt (2026) customer-accounts UI and PrestaShop's order-detail tracking page are both native; WooCommerce ships this in My Account by default.</div>
</div>
<div class="feature">
<div class="f-name">Shipment tracking numbers surfaced to customer</div>
<span class="f-status missing">missing</span>
<div class="f-note">No tracking-number field found on <code>Order</code>/<code>OrderLine</code>/shipping models in <code>vendor/lunarphp/core</code>; PrestaShop's tracking module patches this same gap with a third-party add-on, so it isn't a "native everywhere" bar either.</div>
</div>
<div class="feature">
<div class="f-name">Reorder / buy-again from order history</div>
<span class="f-status missing">missing</span>
<div class="f-note">Needs an order-history UI to exist first (see above) plus a "re-add these lines to cart" action — Lunar's <code>Cart::add()</code> already supports the mechanics, nothing wires an <code>Order</code> line back into a new cart.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Saved addresses</h2>
</div>
<p class="cat-note">What a returning customer doesn't have to retype.</p>
<div class="feature">
<div class="f-name">Multiple saved addresses per customer</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Customer::addresses()</code> (<code>HasMany</code>) — <code>Lunar\Models\Address</code> has no cap on count.</div>
</div>
<div class="feature">
<div class="f-name">Separate default shipping / billing address</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Address::shipping_default</code> and <code>billing_default</code> booleans; <code>AddressObserver</code> auto-unsets the previous default when a new one is flagged, so only one of each can be true at a time.</div>
</div>
<div class="feature">
<div class="f-name">Self-service address book (add/edit/delete UI)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Only Filament's staff-facing <code>AddressRelationManager</code> (<code>src/Customer/RelationManagers/AddressRelationManager.php</code>) touches addresses today — that's an admin back-office view, not a storefront one. No customer-facing CRUD exists.</div>
</div>
<div class="feature">
<div class="f-name">Address autocomplete / validation at entry</div>
<span class="f-status missing">missing</span>
<div class="f-note">Nothing in <code>lunarphp/core</code> or boboko-core wires a geocoding/validation service — this is a storefront-only concern layered on top of the plain <code>line_one</code>…<code>postcode</code> fields.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Login &amp; identity</h2>
</div>
<p class="cat-note">How a customer gets in, and how forgiving that path is.</p>
<div class="feature">
<div class="f-name">Email + password login</div>
<span class="f-status missing">missing</span>
<div class="f-note">No storefront auth guard/routes configured — see 01. <code>Modules\Core\Auth\Services\OtpService</code>/<code>UserOtpService</code> exist but are wired to staff/Filament login, not a customer-facing flow.</div>
</div>
<div class="feature">
<div class="f-name">Passwordless / magic-link / OTP login</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>UserOtpService</code> and <code>UserOtpMail</code> already implement an OTP-by-email mechanism for the staff panel — the building block for a customer-facing passwordless flow exists, just not exposed to a storefront route. Shopify ships this as sign-in links (6-digit email code) by default in its 2026 customer accounts.</div>
</div>
<div class="feature">
<div class="f-name">Social login (Google / Apple / Facebook)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No <code>laravel/socialite</code> in either <code>composer.json</code>. Shopify offers Google/Facebook sign-in and "Sign in with Shop" natively; this would be a from-scratch integration here.</div>
</div>
<div class="feature">
<div class="f-name">Guest checkout → account conversion</div>
<span class="f-status missing">missing</span>
<div class="f-note">No storefront checkout flow exists yet in 3dealer to convert from — this depends on checkout being built before it's meaningful. Lunar's <code>Cart::user_id</code>/<code>customer_id</code> nullable-until-claimed design would support it once a checkout and account UI exist.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2>Payments &amp; saved methods</h2>
</div>
<p class="cat-note">Whether a returning customer can skip re-entering card details.</p>
<div class="feature">
<div class="f-name">Saved payment methods on account</div>
<span class="f-status missing">missing</span>
<div class="f-note">No tokenized-card storage model found in <code>lunarphp/core</code> or boboko-core's payment integration. Even Shopify gates this behind Enterprise; WooCommerce's version depends entirely on gateway-level tokenization (e.g. Stripe), not a core feature.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">06</span>
<h2>Wishlist &amp; saved items</h2>
</div>
<p class="cat-note">Keeping track of products outside the cart.</p>
<div class="feature">
<div class="f-name">Wishlist / saved-for-later products</div>
<span class="f-status missing">missing</span>
<div class="f-note">No <code>wishlist</code> model, table, or reference anywhere in <code>src/</code> or <code>vendor/lunarphp</code> — grep confirms zero hits. Shopify also has no native wishlist (third-party apps like Flits fill the gap); WooCommerce/PrestaShop are the same story via plugins, so this is a genuinely common gap, not a boboko-specific one.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">07</span>
<h2>Groups, pricing &amp; B2B</h2>
</div>
<p class="cat-note">Where boboko is already ahead of a typical single-tenant storefront — Lunar's <code>CustomerGroup</code> does real work here.</p>
<div class="feature">
<div class="f-name">Customer groups for differentiated pricing/visibility</div>
<span class="f-status have">have</span>
<div class="f-note"><code>CustomerGroup</code> model plus <code>HasCustomerGroups</code> trait — <code>Product::customerGroup()</code> scope and <code>Price</code>'s polymorphic customer-group awareness are both real, shipped behavior, not scaffolding.</div>
</div>
<div class="feature">
<div class="f-name">Scheduled group availability (time-boxed access)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>HasCustomerGroups::scheduleCustomerGroup()</code> / <code>unscheduleCustomerGroup()</code>, backed by <code>CanScheduleAvailability</code> — supports a <code>starts_at</code>/<code>ends_at</code> window per group, e.g. early access for wholesale.</div>
</div>
<div class="feature">
<div class="f-name">Multi-user company / B2B accounts</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>Customer::users()->sync([...])</code> already supports attaching several <code>User</code>s to one <code>Customer</code> record — the data model allows a shared company account today, but nothing (invite flow, role/permission split between company users, storefront switch-account UI) is built on top of it. PrestaShop's "Multi-User Customer Account" add-on is the closest native comparison, and it's a paid third-party module there too.</div>
</div>
<div class="feature">
<div class="f-name">Self-service customer-group selection at registration</div>
<span class="f-status missing">missing</span>
<div class="f-note">Groups exist and are assignable (<code>HasCustomerGroups::bootHasCustomerGroups()</code> auto-syncs default groups on creation), but nothing lets a customer request/select a group like "wholesale" at signup — that's currently a staff-only Filament action via <code>CustomerResourceExtension</code>.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">08</span>
<h2>Loyalty, retention &amp; data rights</h2>
</div>
<p class="cat-note">Longer-tail account features — noted for completeness, not depth (data rights specifically overlaps a separate Privacy survey).</p>
<div class="feature">
<div class="f-name">Loyalty / rewards points program</div>
<span class="f-status missing">missing</span>
<div class="f-note">No points/loyalty model anywhere in <code>lunarphp/core</code> or boboko-core — <code>Discount</code>'s <code>BuyXGetY</code> type is the closest primitive, but it's a promo mechanic, not an accruing balance. PrestaShop and WooCommerce both rely on third-party modules for this too (Knowband, Webkul, Yith).</div>
</div>
<div class="feature">
<div class="f-name">Self-service data export / account deletion</div>
<span class="f-status missing">missing</span>
<div class="f-note">The only related tool is <code>boboko:anonymize</code> — a local-environment-only dev command that scrubs <code>users</code>/<code>lunar_customers</code> for testing, not a customer-facing GDPR flow. WooCommerce's closest native equivalent is also a paid add-on (Data Privacy Manager); flagged briefly here, full treatment belongs to the separate Privacy survey.</div>
</div>
<div class="feature">
<div class="f-name">Subscription / recurring-order management</div>
<span class="f-status missing">missing</span>
<div class="f-note">No subscription model, billing-cycle field, or recurring-cart concept found in <code>lunarphp/core</code>. This is WooCommerce Subscriptions/Shopify-app territory on the platforms researched too — not a core-package feature anywhere.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-08-28 — chips backed by web research (Shopify/WooCommerce/PrestaShop feature claims) are noted inline by platform name; all other claims are direct reads of <code>vendor/lunarphp/core/src</code>, boboko-core's <code>src/</code>, and 3dealer's <code>app/</code>/<code>resources/views</code>/<code>routes</code>.</span>
<span>boboko-core / docs</span>
</footer>
</div>
+527
View File
@@ -0,0 +1,527 @@
<title>Discounts Feature Survey</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9C9A90;
--accent: #6FAE97;
--accent-soft: #1E2D28;
--good: #6FAE97;
--good-soft: #1C2B22;
--warn: #D9AD6B;
--warn-soft: #2E2618;
--miss: #DE8A76;
--miss-soft: #2E1F1B;
--hairline: #302F2B;
--card: #1D1E20;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9C9A90;
--accent: #6FAE97;
--accent-soft: #1E2D28;
--good: #6FAE97;
--good-soft: #1C2B22;
--warn: #D9AD6B;
--warn-soft: #2E2618;
--miss: #DE8A76;
--miss-soft: #2E1F1B;
--hairline: #302F2B;
--card: #1D1E20;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
line-height: 1.6;
margin: 0;
padding: 0;
}
.sheet {
max-width: 780px;
margin: 0 auto;
padding: 72px 24px 56px;
}
header.title-block {
margin-bottom: 56px;
padding-bottom: 32px;
border-bottom: 1px solid var(--hairline);
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 16px;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 2.15rem;
line-height: 1.18;
letter-spacing: -0.01em;
text-wrap: balance;
margin: 0 0 18px;
color: var(--ink);
}
.lede {
font-size: 1rem;
color: var(--muted);
max-width: 62ch;
margin: 0 0 20px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 11.5px;
letter-spacing: 0.03em;
padding: 3px 9px;
border-radius: 3px;
text-transform: uppercase;
white-space: nowrap;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-bottom: 48px;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 14px;
margin-bottom: 6px;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1rem;
color: var(--accent);
min-width: 26px;
}
.cat-title {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.3rem;
letter-spacing: -0.005em;
margin: 0;
}
.cat-note {
font-size: 0.92rem;
color: var(--muted);
margin: 0 0 22px 40px;
max-width: 58ch;
}
.rows {
display: flex;
flex-direction: column;
border-top: 1px solid var(--hairline);
margin-left: 40px;
}
.row {
padding: 15px 0;
border-bottom: 1px solid var(--hairline);
}
.row-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin-bottom: 6px;
}
.feat-name {
font-weight: 600;
font-size: 0.98rem;
color: var(--ink);
}
.ground {
font-size: 0.87rem;
color: var(--muted);
max-width: 66ch;
}
.ground code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.83em;
background: var(--accent-soft);
color: var(--accent);
padding: 1px 5px;
border-radius: 3px;
}
.callout {
background: var(--card);
border: 1px solid var(--hairline);
border-left: 3px solid var(--accent);
border-radius: 4px;
padding: 16px 18px;
margin: 0 0 22px 40px;
font-size: 0.9rem;
color: var(--ink);
}
.callout strong {
color: var(--accent);
}
footer {
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--hairline);
font-size: 0.82rem;
color: var(--muted);
font-family: "IBM Plex Mono", ui-monospace, monospace;
}
footer p {
margin: 0 0 8px;
line-height: 1.6;
}
footer p:last-child { margin-bottom: 0; }
@media (max-width: 560px) {
.cat-note, .rows, .callout { margin-left: 0; }
.row-head { flex-direction: column; gap: 4px; }
}
</style>
<div class="sheet">
<header class="title-block">
<p class="eyebrow">boboko-core &middot; competitive spec sheet</p>
<h1>What discounts &amp; promotions elsewhere can do that boboko can&rsquo;t yet</h1>
<p class="lede">A feature-by-feature audit of Lunar's <code style="font-family:'IBM Plex Mono',monospace;background:var(--accent-soft);color:var(--accent);padding:1px 5px;border-radius:3px;font-size:0.85em;">Discount</code> engine against promotion tooling in Shopify, WooCommerce, and PrestaShop. Each row is graded against the underlying Lunar source, not the docs.</p>
<div class="legend">
<span class="chip have">have</span>
<span class="chip partial">partial</span>
<span class="chip missing">missing</span>
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2 class="cat-title">Core discount mechanics</h2>
</div>
<p class="cat-note">The two shipped discount types and the machinery that decides whether they fire.</p>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Percentage / fixed-amount off cart or line items</span>
<span class="chip have">have</span>
</div>
<p class="ground">Built in as <code>Lunar\DiscountTypes\AmountOff</code>. <code>applyPercentage()</code> and <code>applyFixedValue()</code> distribute the discount across eligible lines, tracking per-currency fixed values (<code>data.fixed_values.{code}</code>) so the amount is currency-aware, not a single converted number.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Buy X get Y (free or discounted)</span>
<span class="chip have">have</span>
</div>
<p class="ground">Built in as <code>Lunar\DiscountTypes\BuyXGetY</code>. Condition lines and reward lines are configured separately via <code>discountableConditions</code>/<code>discountableRewards</code>; <code>getRewardQuantity()</code> computes how many reward units a given condition quantity earns, with an optional <code>max_reward_qty</code> cap.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Coupon-code discounts</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>checkDiscountConditions()</code> compares <code>strtoupper($cart-&gt;coupon_code)</code> against <code>$discount-&gt;coupon</code>; <code>Discounts::validateCoupon()</code> exposes a standalone check. Coupon is cast via <code>CouponString</code> on the model.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Automatic (no-code) discounts</span>
<span class="chip have">have</span>
</div>
<p class="ground">A blank <code>coupon</code> column makes a discount apply to every eligible cart with no code entered &mdash; <code>DiscountManager::getDiscounts()</code> queries <code>whereNull('coupon')-&gt;orWhere('coupon', '')</code> when the cart carries no coupon code.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Minimum cart spend condition</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>checkDiscountConditions()</code> reads <code>data.min_prices.{currency}</code> and compares it against <code>$lines-&gt;sum('subTotal.value')</code>. Configurable per-currency in the admin form's "Minimum cart amount" fieldset &mdash; but only enforced by <code>AmountOff</code>, see row below.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Scoping to products, variants, collections, brands (incl. exclusions)</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>AmountOff::getEligibleLines()</code> filters/rejects cart lines against <code>discountableLimitations</code>/<code>discountableExclusions</code> plus <code>collections()</code>/<code>brands()</code> pivot rows typed <code>limitation</code> or <code>exclusion</code>. Configured through five separate Filament relation managers on the discount record.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2 class="cat-title">Timing, status, and usage limits</h2>
</div>
<p class="cat-note">Whether a discount is currently live, and how hard its usage caps are enforced.</p>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Scheduled / expiring discount windows</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>Discount::getStatusAttribute()</code> derives <code>active</code>/<code>pending</code>/<code>expired</code>/<code>scheduled</code> from <code>starts_at</code>/<code>ends_at</code>; the Filament table badges this status column directly (green/gray/red/blue via <code>DiscountResource::getTableColumns()</code>).</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Global max-uses cap</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>Discount::scopeUsable()</code> filters query-side (<code>uses &lt; max_uses OR max_uses IS NULL</code>) before a discount is even fetched; <code>checkDiscountConditions()</code> re-checks it in <code>AmountOff</code>. <code>markAsUsed()</code> increments <code>uses</code> and attaches the user via <code>discount_user</code>.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Per-user max-uses cap</span>
<span class="chip partial">partial</span>
</div>
<p class="ground"><code>checkDiscountConditions()</code> calls <code>usesByUser()</code> only when <code>$cart-&gt;user</code> exists &mdash; a guest checkout cannot be capped per-customer since there's no <code>user_id</code> to key against, only <code>customer_id</code>. Wholesale/B2B carts often complete without a Laravel <code>User</code> attached, so the cap silently no-ops for them.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Usage/eligibility checks on Buy X Get Y</span>
<span class="chip missing">missing</span>
</div>
<p class="ground"><code>BuyXGetY::apply()</code> never calls <code>checkDiscountConditions()</code> &mdash; grep the method body, it's absent. A coupon-gated, min-spend-gated, or max-uses-capped BOGO discount ignores all three conditions; only the min-quantity/reward math runs. <code>AmountOff::apply()</code> calls it correctly by contrast.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2 class="cat-title">Multiple discounts, priority, and stacking</h2>
</div>
<p class="cat-note">What happens when more than one discount could legally apply to the same cart.</p>
<div class="callout">
<strong>The <code>stop</code> field is dead code.</strong> It's a real column, cast as boolean on the model, and it's a live toggle in the Filament admin form (<code>DiscountResource::getStopFormComponent()</code>) &mdash; but a repo-wide grep of both <code>lunarphp/core</code> and <code>lunarphp/lunar</code> for reads of <code>$discount-&gt;stop</code> outside the model and the form turns up nothing. <code>DiscountManager::apply()</code> is a plain unconditional <code>foreach</code> over every fetched discount; nothing ever breaks the loop. Staff can toggle a setting that has zero runtime effect.
</div>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Priority ordering between discounts</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>DiscountManager::getDiscounts()</code> ends with <code>orderBy('priority', 'desc')-&gt;orderBy('id')</code>, and the admin form exposes low/medium/high (1/5/10) presets. This genuinely controls apply order.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Stopping further discounts once one applies ("exclusive" discount)</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">See callout above &mdash; <code>stop</code> is unread at runtime. Every active, eligible discount is applied every time; there is no way to make one discount exclusive of the rest short of writing a custom <code>AbstractDiscountType</code> that inspects <code>$cart-&gt;discounts</code> itself.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Per-class combination rules (product vs. order vs. shipping discounts)</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">Shopify models discounts as Product/Order/Shipping classes with an explicit "Combines with" toggle per pair. Lunar has no discount class concept at all &mdash; <code>AmountOff</code> and <code>BuyXGetY</code> are the only two types and neither declares a class or combination policy.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Customer-facing stacking transparency (which discounts combined, and why)</span>
<span class="chip partial">partial</span>
</div>
<p class="ground"><code>$cart-&gt;discountBreakdown</code> (a collection of <code>DiscountBreakdown</code> value objects, one per applied discount with its affected lines) gives a storefront the raw data to render "2 promotions applied," but no UI ships to render it &mdash; it's a data structure a storefront app must build its own component against.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">"Best deal wins" line-level conflict resolution</span>
<span class="chip have">have</span>
</div>
<p class="ground">Both <code>AmountOff::applyFixedValue()</code> and <code>applyPercentage()</code> explicitly skip a line when <code>$line-&gt;discountTotal-&gt;value &gt; $amount</code> &mdash; "if this line already has a greater discount value, don't add this one as they already have a better deal." This is a real per-line max-discount guard, just not a whole-cart exclusivity rule.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2 class="cat-title">Volume, tiers, and bundles</h2>
</div>
<p class="cat-note">"Buy more, save more" mechanics &mdash; and the separate pricing layer that actually implements some of them in Lunar.</p>
<div class="callout">
<strong>Tiered/volume pricing exists &mdash; but it's not a <code>Discount</code>.</strong> <code>PricingManager::get()</code> filters a purchasable's <code>Price</code> rows for <code>min_quantity &gt; 1 AND $this-&gt;qty &gt;= $price-&gt;min_quantity</code> and picks the cheapest matching price break. This is quantity-break pricing baked into the price table itself, resolved at <code>Pricing::for($variant)-&gt;qty($n)-&gt;get()</code> time &mdash; it never touches the <code>Discount</code> model, coupon system, or discount breakdown at all. A storefront gets the discounted unit price with no visible "discount applied" line.
</div>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Per-SKU quantity price breaks</span>
<span class="chip have">have</span>
</div>
<p class="ground">Via the <code>Price</code> model's <code>min_quantity</code>/pricing pipeline described above, not <code>Discount</code>. Configured directly on product variant pricing in the admin, no separate promotion object needed.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Cart-wide tiered discount ("spend $100, save 10%; spend $200, save 20%")</span>
<span class="chip missing">missing</span>
</div>
<p class="ground"><code>AmountOff</code> takes one flat percentage or fixed value per discount record; there is no multi-tier threshold structure in <code>data</code>. Reaching this today means creating several separate <code>Discount</code> rows, each with its own <code>min_prices</code> floor, and hoping only the intended one wins (compounded by the <code>stop</code> gap in section 03).</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Bundle / kit discount (buy this set, get a fixed bundle price)</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">No bundle or kit concept anywhere in <code>lunarphp/core</code>'s catalog or discount models. Shopify/WooCommerce/PrestaShop all support this via dedicated bundle apps or plugins layered on the same primitive Lunar lacks &mdash; a discount keyed to a co-purchased product set rather than any single line.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Free-gift-with-purchase (a distinct SKU added free, not a percentage off an existing line)</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>BuyXGetY</code>'s <code>automatically_add_rewards</code> flag drives <code>processAutomaticRewards()</code>, which inserts a brand-new <code>CartLine</code> for a randomly selected reward product and zeroes its price via <code>discountTotal</code>. <code>$cart-&gt;freeItems</code> tracks which purchasables were added this way.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2 class="cat-title">Customer targeting</h2>
</div>
<p class="cat-note">Lunar has two genuinely different mechanisms here that solve overlapping-looking problems &mdash; conflating them is the easiest mistake to make.</p>
<div class="callout">
<strong><code>CustomerGroup</code> pricing and <code>Discount</code> customer-group scoping are not the same feature.</strong> <code>Pricing::for($variant)-&gt;customerGroups($groups)-&gt;get()</code> resolves a <em>different base price</em> per customer group directly from the <code>Price</code> table (wholesale sees $8, retail sees $10 &mdash; two rows, no discount object, no coupon, nothing to "apply"). <code>Discount::customerGroups()</code> is a separate pivot (<code>customer_group_discount</code>, via the <code>HasCustomerGroups</code> trait) that scopes whether a <em>promotion</em> is visible/enabled to a group at all, with its own <code>starts_at</code>/<code>ends_at</code>/<code>enabled</code>/<code>visible</code> per-pivot-row scheduling. One is differential pricing; the other is promotion eligibility. Both exist and both work, but they're wired into completely separate code paths.
</div>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Differential pricing per customer group (wholesale/VIP base price)</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>PricingManager::get()</code>: <code>$potentialGroupPrice</code> filters <code>Price</code> rows with a matching <code>customer_group_id</code> and picks the cheapest; falls back to <code>$basePrice</code> when no group price exists.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Restricting a discount/coupon to specific customer groups</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>DiscountManager::getDiscounts()</code> applies <code>-&gt;customerGroup($this-&gt;customerGroups)</code> via the shared <code>HasCustomerGroups</code> trait's <code>scopeCustomerGroup()</code>, configured on the discount's own "Availability" sub-page (<code>ManageDiscountAvailability</code>) alongside channel restriction.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Restricting a discount to specific named customers</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>Discount::customers()</code> pivot (<code>customer_discount</code>), checked in <code>checkDiscountConditions()</code>: if the discount has any tied customers, a cart without a matching <code>customer_id</code> fails eligibility outright. Managed via <code>CustomerLimitationRelationManager</code> in the admin.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">First-purchase / welcome discount</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">Lunar does compute an order-level <code>new_customer</code> boolean (<code>Jobs\Orders\MarkAsNewCustomer</code>, <code>! $previousOrder</code>) &mdash; but it's a post-order reporting flag surfaced only in the Filament order table/dashboard chart. Nothing reads it during <code>ApplyDiscounts</code>; there's no "is this customer's first order" condition available to a <code>Discount</code> at checkout time.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Referral discounts (reward both referrer and referee)</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">No referral concept anywhere in <code>lunarphp/core</code> or <code>lunarphp/lunar</code> &mdash; not a model, job, or config key. Common as a bolt-on in WooCommerce/Shopify via loyalty apps (e.g. WPLoyalty's referral-points module); would need to be built from scratch on top of <code>Discount::customers()</code> at best.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Loyalty points redeemable as a discount</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">No points ledger, balance, or redemption model exists in Lunar core. A loyalty program (points-to-discount conversion, VIP-tier multipliers) is a third-party plugin layer in every researched competitor, not core commerce logic &mdash; same gap here, but Lunar offers no <code>AbstractDiscountType</code> hook obviously suited to "redeem N points" either, since discount eligibility has no notion of a spendable balance.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">06</span>
<h2 class="cat-title">Extensibility</h2>
</div>
<p class="cat-note">What it takes to reach a feature Lunar doesn't ship, without forking the package.</p>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Registering a custom discount type</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>Discounts::addType(MyType::class)</code> appends to <code>DiscountManager::$types</code> (seeded with just <code>AmountOff::class, BuyXGetY::class</code>). A new type extends <code>AbstractDiscountType</code> and implements <code>apply(CartContract $cart)</code> &mdash; the same contract the two built-ins use, so it participates in the same unconditional-foreach loop from section 03.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Admin UI for a custom discount type</span>
<span class="chip partial">partial</span>
</div>
<p class="ground">Requires additionally implementing <code>Lunar\Admin\Base\LunarPanelDiscountInterface</code> (<code>lunarPanelSchema()</code>/<code>lunarPanelOnFill()</code>/<code>lunarPanelOnSave()</code>) for <code>DiscountResource::getDefaultForm()</code> to render a config section for it. The interface exists and is wired in, but there is no shipped example implementation to copy from beyond <code>AmountOff</code>/<code>BuyXGetY</code>, which are hard-coded into the form rather than using the interface themselves.</p>
</div>
</div>
</section>
<footer>
<p>Compiled 2026-08-28 &middot; boboko-core / docs</p>
<p>Section 01&ndash;03 and 05&ndash;06 rows are grounded directly in <code>vendor/lunarphp/core/src</code> and <code>vendor/lunarphp/lunar/src</code> source reads (file/method citations inline). Section 02's per-user cap and section 04's pricing-vs-discount distinction are likewise direct source reads. Comparative claims about Shopify, WooCommerce, and PrestaShop feature sets and terminology (discount classes, cart-rule compatibility, loyalty/referral plugins) are sourced from current public documentation and app-store listings via web research, not from reading those platforms' source.</p>
</footer>
</div>
+448
View File
@@ -0,0 +1,448 @@
<title>Payments Feature Survey</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A91;
--accent: #6FAE97;
--accent-soft: #1E2C27;
--good: #6FAE97;
--good-soft: #1C2B22;
--warn: #D8A85C;
--warn-soft: #2E2718;
--miss: #D97F68;
--miss-soft: #2E1F1A;
--hairline: #2C2D2E;
--card: #1D1F20;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A91;
--accent: #6FAE97;
--accent-soft: #1E2C27;
--good: #6FAE97;
--good-soft: #1C2B22;
--warn: #D8A85C;
--warn-soft: #2E2718;
--miss: #D97F68;
--miss-soft: #2E1F1A;
--hairline: #2C2D2E;
--card: #1D1F20;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 780px;
margin: 0 auto;
padding: 72px 24px 96px;
}
header.masthead {
margin-bottom: 56px;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 18px;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: clamp(30px, 5vw, 40px);
line-height: 1.18;
letter-spacing: -0.01em;
margin: 0 0 18px;
text-wrap: balance;
max-width: 22ch;
}
.dek {
color: var(--muted);
font-size: 16.5px;
max-width: 62ch;
margin: 0 0 28px;
}
.summary-strip {
display: flex;
gap: 10px;
flex-wrap: wrap;
padding-top: 22px;
border-top: 1px solid var(--hairline);
}
.summary-pill {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12.5px;
padding: 6px 12px;
border-radius: 999px;
display: flex;
align-items: baseline;
gap: 6px;
}
.summary-pill b { font-size: 13.5px; }
.summary-pill.have { background: var(--good-soft); color: var(--good); }
.summary-pill.partial { background: var(--warn-soft); color: var(--warn); }
.summary-pill.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-bottom: 52px;
}
.category-head {
display: flex;
gap: 16px;
align-items: baseline;
margin-bottom: 6px;
}
.numeral {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 15px;
color: var(--accent);
font-variant-numeric: tabular-nums;
flex: none;
width: 2ch;
}
h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: 22px;
margin: 0;
letter-spacing: -0.01em;
}
.category-note {
color: var(--muted);
font-size: 14.5px;
margin: 0 0 22px 34px;
max-width: 58ch;
}
.rows {
margin-left: 34px;
border-top: 1px solid var(--hairline);
}
.row {
padding: 16px 0;
border-bottom: 1px solid var(--hairline);
}
.row-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.feature-name {
font-weight: 500;
font-size: 15.5px;
}
.chip {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 11.5px;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 3px 10px;
border-radius: 999px;
flex: none;
white-space: nowrap;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
.grounding {
color: var(--muted);
font-size: 13.5px;
margin-top: 6px;
line-height: 1.5;
max-width: 64ch;
}
code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12.5px;
background: var(--accent-soft);
color: var(--accent);
padding: 1px 5px;
border-radius: 4px;
}
footer {
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 13px;
}
footer .compiled {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12px;
margin-bottom: 10px;
}
footer p {
margin: 0 0 8px;
max-width: 62ch;
}
footer p:last-child { margin-bottom: 0; }
@media (max-width: 560px) {
.category-note, .rows { margin-left: 0; }
.category-head { gap: 10px; }
}
</style>
<div class="page">
<header class="masthead">
<p class="eyebrow">boboko-core &middot; competitive gap survey &middot; 03</p>
<h1>What payments elsewhere can do that boboko can't yet</h1>
<p class="dek">
Lunar's payment layer (<code>Lunar\Facades\Payments</code>, <code>Transaction</code>, the offline
driver) is wired for a single "pay on delivery / bank transfer" flow. Everything downstream of
that — cards, wallets, saved methods, self-service refunds, retries — is either scaffolded in
Lunar core and unused here, or absent from the stack entirely. This is a research survey, not a
build plan.
</p>
<div class="summary-strip">
<span class="summary-pill have"><b>4</b> have</span>
<span class="summary-pill partial"><b>9</b> partial</span>
<span class="summary-pill missing"><b>14</b> missing</span>
</div>
</header>
<section class="category">
<div class="category-head">
<span class="numeral">01</span>
<h2>Payment method breadth</h2>
</div>
<p class="category-note">boboko currently ships one payment type: cash-in-hand via the offline driver. Every card/wallet/BNPL path below is theoretically pluggable but has zero live implementation.</p>
<div class="rows">
<div class="row">
<div class="row-head"><span class="feature-name">Offline / pay-on-account</span><span class="chip have">have</span></div>
<div class="grounding">The only configured type in <code>config/lunar/payments.php</code> (3dealer's published copy): <code>'cash-in-hand' => ['driver' => 'offline', 'authorized' => 'payment-offline']</code>, backed by <code>Lunar\PaymentTypes\OfflinePayment</code>.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Card payments (Stripe/other gateway)</span><span class="chip missing">missing</span></div>
<div class="grounding"><code>lunarphp/stripe</code> is not present in either <code>boboko-core/vendor/lunarphp</code> or <code>3dealer/vendor/lunarphp</code>, and not listed in either <code>composer.json</code>. <code>docs/lunar.md</code>'s Stripe section documents Lunar's general capability, not something wired into this project.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Digital wallets (Apple Pay, Google Pay, Shop Pay)</span><span class="chip missing">missing</span></div>
<div class="grounding">Depends entirely on a card gateway (Stripe Payment Request Button or similar) that isn't installed. Shopify bundles Apple Pay, Google Pay, and Shop Pay as one-tap checkout by default.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Buy-now-pay-later (Klarna, Afterpay, Affirm)</span><span class="chip missing">missing</span></div>
<div class="grounding">No BNPL driver or config entry anywhere in the repo. Shopify bundles Klarna natively in eligible regions with Pay-in-4, Pay-Later, and financing tiers; WooCommerce and PrestaShop both offer it as installable gateway plugins.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Bank transfer / open banking (SEPA, Pay by Bank)</span><span class="chip missing">missing</span></div>
<div class="grounding">Not represented as a distinct payment type; only the generic cash-in-hand offline flow exists, which is manual reconciliation rather than an automated bank-transfer rail.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Crypto / stablecoin checkout</span><span class="chip missing">missing</span></div>
<div class="grounding">No driver, no research finding of it being used in this stack. Industry-wide it's still marginal — stablecoin payment volume is roughly 0.02% of global payments in 2026 per Nuvei's trend report — so this is low-priority even elsewhere.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Pluggable driver architecture for adding methods</span><span class="chip have">have</span></div>
<div class="grounding"><code>Lunar\Managers\PaymentManager</code> extends Laravel's <code>Manager</code>; <code>Payments::extend('custom', fn ($app) => ...)</code> registers a new driver, and any class extending <code>Lunar\PaymentTypes\AbstractPayment</code> implementing <code>authorize()</code>/<code>capture()</code>/<code>refund()</code> plugs in. The scaffolding is solid — nothing beyond offline is plugged into it yet.</div>
</div>
</div>
</section>
<section class="category">
<div class="category-head">
<span class="numeral">02</span>
<h2>Capture, refund &amp; transaction lifecycle</h2>
</div>
<p class="category-note">The core primitives (intent/capture/refund, partial amounts, transaction chaining) exist in Lunar and are exposed in the Filament admin — but nothing calls them outside cash-in-hand, and none of it is customer-facing.</p>
<div class="rows">
<div class="row">
<div class="row-head"><span class="feature-name">Authorize / capture / refund contract</span><span class="chip have">have</span></div>
<div class="grounding"><code>Lunar\Base\PaymentTypeInterface</code> defines <code>authorize()</code>, <code>capture(Transaction $t, $amount)</code>, <code>refund(Transaction $t, int $amount, $notes)</code>; <code>Transaction::capture()</code>/<code>refund()</code> forward to the transaction's own <code>driver()</code> via <code>Payments::driver($this->driver)</code>.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Manual vs. automatic capture policy</span><span class="chip partial">partial</span></div>
<div class="grounding">The interface supports separate authorize/capture steps (intent vs. capture transaction types), but <code>OfflinePayment::capture()</code> just returns <code>new PaymentCapture(true)</code> unconditionally — there's no real deferred-capture gateway wired up to exercise the distinction.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Partial capture</span><span class="chip partial">partial</span></div>
<div class="grounding">Admin Filament action passes an arbitrary <code>$data['amount']</code> to <code>$transaction->capture(bcmul($data['amount'], $record->currency->factor))</code> in <code>ManageOrder.php</code> — the plumbing supports partial amounts, but only staff can trigger it, and only against a real (non-offline) driver would it mean anything.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Partial / staged refunds</span><span class="chip have">have</span></div>
<div class="grounding">Same file: the "refund" Filament action computes <code>$response = $transaction->refund(bcmul($data['amount'], ...), $data['notes'])</code>, and <code>isPartiallyRefunded()</code> / order status logic (<code>partial-refund</code>, <code>refunded</code>) compares <code>refundTotal</code> against <code>captureTotal</code>/<code>intentTotal</code>. This genuinely works today through the offline driver's no-op <code>refund()</code>.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Multiple payment attempts per order</span><span class="chip partial">partial</span></div>
<div class="grounding"><code>Transaction.parent_transaction_id</code> chains captures to intents and refunds to captures, and nothing in the model stops multiple transaction rows per order — but no code path in this repo actually retries a failed attempt with a second transaction; it's schema support, not a driven flow.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Transaction audit trail</span><span class="chip have">have</span></div>
<div class="grounding"><code>Lunar\Observers\TransactionObserver::created()</code> logs every transaction (amount, type, status, card_type, last_four, reference, notes) via Spatie activity log automatically — this is real and unconditional, independent of driver.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Webhook handling for async payment events</span><span class="chip missing">missing</span></div>
<div class="grounding">Lunar's Stripe package registers a <code>stripe/webhook</code> route, but that package isn't installed here, so there is no webhook endpoint of any kind in this project today.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Payment attempt events for downstream hooks</span><span class="chip have">have</span></div>
<div class="grounding"><code>Lunar\Events\PaymentAttemptEvent</code> is dispatched from <code>OfflinePayment::authorize()</code> with the resulting <code>PaymentAuthorize</code> DTO — a real, listenable event, though only one driver currently fires it.</div>
</div>
</div>
</section>
<section class="category">
<div class="category-head">
<span class="numeral">03</span>
<h2>Customer-facing payment experience</h2>
</div>
<p class="category-note">Everything a shopper would touch directly — saved cards, one-click repeat purchase, self-service refunds — is absent. Lunar's payment layer is staff/checkout-oriented, not account-oriented.</p>
<div class="rows">
<div class="row">
<div class="row-head"><span class="feature-name">Saved payment methods on customer account</span><span class="chip missing">missing</span></div>
<div class="grounding">No vault/tokenization model exists anywhere in <code>Lunar\Models</code> — no <code>PaymentMethod</code>/<code>Card</code> model, no field on <code>Customer</code>. 2026 trend research (Nuvei, Checkout.com) treats network-tokenized saved cards as baseline for one-click checkout.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">One-click repeat purchase</span><span class="chip missing">missing</span></div>
<div class="grounding">Depends on saved payment methods, which don't exist. No "reorder" or "buy again" affordance found in boboko-core or 3dealer.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Customer self-service refund requests</span><span class="chip missing">missing</span></div>
<div class="grounding">The only refund entry point is the Filament staff action in <code>ManageOrder.php</code> (<code>Actions\Action::make('refund')</code>), gated behind admin auth. WooCommerce/PrestaShop ecosystems commonly expose a customer-initiated return/refund request flow; nothing equivalent exists here.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Split / partial payment plans (pay-in-installments at checkout)</span><span class="chip missing">missing</span></div>
<div class="grounding">Distinct from BNPL-as-a-gateway: this is a native "split into N charges" checkout option, seen as marketplace split-payment modules in the PrestaShop ecosystem. No equivalent concept in Lunar's cart/order/payment pipeline.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">3D Secure / SCA authentication</span><span class="chip missing">missing</span></div>
<div class="grounding">3DS is a property of the card gateway integration (e.g. Stripe PaymentIntents), which isn't installed. WooPayments explicitly advertises 3DS/SCA compatibility with visible card-brand + last-four confirmation as a baseline expectation in 2026.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Fraud detection / risk scoring</span><span class="chip missing">missing</span></div>
<div class="grounding">No fraud-scoring hook in <code>PaymentTypeInterface</code> or the offline driver. <code>getPaymentChecks()</code> exists as an extension point (<code>Lunar\Base\DataTransferObjects\PaymentChecks</code>, an iterable of pass/fail <code>PaymentCheck</code> DTOs) but <code>AbstractPayment::getPaymentChecks()</code> just returns an empty collection — real fraud tooling (Stripe Radar-style) isn't behind it.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Payment check / validation extension point</span><span class="chip partial">partial</span></div>
<div class="grounding"><code>Transaction::paymentChecks()</code> → driver's <code>getPaymentChecks($transaction)</code> is real, typed infrastructure for surfacing checks (e.g. "AVS matched") in the admin UI — but the default implementation is a no-op, so nothing populates it today.</div>
</div>
</div>
</section>
<section class="category">
<div class="category-head">
<span class="numeral">04</span>
<h2>Currency, subscriptions &amp; recurring billing</h2>
</div>
<p class="category-note">Lunar's multi-currency model covers pricing display, not multi-currency payment settlement; recurring billing/dunning has no representation at all.</p>
<div class="rows">
<div class="row">
<div class="row-head"><span class="feature-name">Multi-currency pricing display</span><span class="chip have">have</span></div>
<div class="grounding"><code>Lunar\Models\Currency</code> (code, exchange_rate, decimal_places, default) with <code>sync_prices</code>-gated conversion, documented in <code>docs/lunar.md</code> "Channels and Currencies" — this is genuinely wired, cart/pricing layer already uses it.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Multi-currency payment processing (charge in customer's currency)</span><span class="chip partial">partial</span></div>
<div class="grounding">Pricing can display and calculate in any configured currency, but no payment driver in this project actually settles a charge — so whether a real gateway would charge in-currency is untested; the pricing half is there, the processing half isn't proven.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Recurring billing / subscriptions</span><span class="chip missing">missing</span></div>
<div class="grounding">No subscription model, no recurring-charge scheduler anywhere in <code>Lunar\Models</code> or boboko-core. This is a one-time-purchase order/cart model end to end.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">Failed-payment retry / dunning</span><span class="chip missing">missing</span></div>
<div class="grounding">No retry scheduling, no dunning email sequence, no soft-decline handling anywhere in the payment layer — there's nothing to retry against since there's no recurring billing and no live gateway. WooPayments' dunning (1-3 day delayed retry on soft declines) is the comparison point.</div>
</div>
<div class="row">
<div class="row-head"><span class="feature-name">PCI compliance / tokenized card storage</span><span class="chip missing">missing</span></div>
<div class="grounding">No card data is collected or stored anywhere in this codebase (offline driver never touches card fields), so there's no PCI-scope exposure today — but also no tokenized-vault capability to build saved cards or 3DS on top of when a real gateway is added.</div>
</div>
</div>
</section>
<footer>
<p class="compiled">Compiled 2026-08-28 &middot; boboko-core / docs</p>
<p>Section 01 (driver architecture) and section 02 (transaction lifecycle, refund/capture, observer, events) are grounded in direct reads of <code>vendor/lunarphp/core/src/{Managers,PaymentTypes,Models,Observers,Events,Base}</code> and <code>vendor/lunarphp/lunar/src/Filament/Resources/OrderResource/Pages/ManageOrder.php</code>, plus the published <code>config/lunar/payments.php</code> in 3dealer — not from <code>docs/lunar.md</code> alone, which was cross-checked and found to describe Lunar's general Stripe capability rather than anything installed in this project.</p>
<p>Sections 03 and 04, and the competitive framing throughout, draw on 2026 web research covering Shopify, WooCommerce/WooPayments, and PrestaShop payment modules, plus general industry trend reporting (Nuvei, Checkout.com, Mastercard). Those claims are marked by comparison language ("Shopify bundles...", "WooPayments advertises...") rather than citation to this repo.</p>
</footer>
</div>
+492
View File
@@ -0,0 +1,492 @@
<title>Privacy Feature Survey</title>
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
:root:not([data-theme="light"]) {
@media (prefers-color-scheme: dark) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 15.5px;
line-height: 1.55;
margin: 0;
padding: 4.5rem 1.5rem 6rem;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
header.page {
margin-bottom: 3.25rem;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.9rem;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 560;
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
line-height: 1.08;
letter-spacing: -0.01em;
margin: 0 0 0.9rem;
text-wrap: balance;
}
.dek {
color: var(--muted);
max-width: 60ch;
font-size: 1.02rem;
}
.dek strong {
color: var(--ink);
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.6rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border-radius: 3px;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
.branch-note {
margin-top: 1.4rem;
padding: 0.85rem 1rem;
background: var(--accent-soft);
border-radius: 4px;
font-size: 0.86rem;
color: var(--ink);
max-width: 66ch;
}
.branch-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.85em;
color: var(--accent);
}
section.category {
margin-top: 3rem;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 0.85rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.7rem;
margin-bottom: 1.1rem;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 1.6rem;
}
.cat-head h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.28rem;
margin: 0;
letter-spacing: -0.005em;
}
.cat-note {
color: var(--muted);
font-size: 0.86rem;
margin: 0 0 1.2rem;
max-width: 62ch;
}
.feature {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid var(--hairline);
align-items: start;
}
.feature:last-child { border-bottom: none; }
.f-name {
font-weight: 600;
font-size: 0.98rem;
}
.f-status {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
height: fit-content;
}
.f-status.have { background: var(--good-soft); color: var(--good); }
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
.f-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.87rem;
margin-top: 0.15rem;
max-width: 66ch;
}
.f-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.82em;
background: var(--accent-soft);
color: var(--accent);
padding: 0.08em 0.35em;
border-radius: 3px;
}
footer.page {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 0.82rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer.page a { color: var(--accent); }
@media (max-width: 560px) {
body { padding: 3rem 1.1rem 4rem; }
.feature { grid-template-columns: 1fr; }
.f-status { justify-self: start; }
}
</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<div class="wrap">
<header class="page">
<div class="eyebrow">boboko / privacy &amp; compliance · competitive survey</div>
<h1>What privacy &amp; compliance elsewhere can do that boboko can't yet</h1>
<p class="dek">
A feature-by-feature pass across GDPR/CCPA compliance tooling used by Shopify,
WooCommerce, and dedicated consent-management platforms — sourced, not recalled
from memory — checked against <strong>master</strong> and the substantial,
unmerged <strong><code>Privacy</code> branch</strong> ("Feature: Creating Privacy
Basics") already built in this repo. For deciding what to finish and merge
next, not a build order.
</p>
<div class="legend">
<span class="chip have">● have</span>
<span class="chip partial">◐ partial</span>
<span class="chip missing">○ missing</span>
</div>
<div class="branch-note">
Most "partial" rows below are fully coded on the unmerged <code>Privacy</code>
branch (53 files, +3127/&#8209;24 across two commits: <code>9f540cb</code>,
<code>59303cf</code>) but not on <code>master</code> — treated as partial, not
have, until it merges. <code>boboko:anonymize</code> is the one privacy-adjacent
command that already lives on <code>master</code> today.
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2>Right of access &amp; erasure</h2>
</div>
<p class="cat-note">GDPR Art. 15 (access) and Art. 17 (erasure) — the two rights every DSAR tool is built around.</p>
<div class="feature">
<div class="f-name">Data export request (right of access)</div>
<span class="f-status partial">partial</span>
<div class="f-note">On <code>Privacy</code> branch only: <code>PrivacyService::requestExportForCustomer()/requestExportForUser()</code> queue <code>ExportDataSubjectJob</code>, which gathers every registered provider's data and writes a CSV-per-provider zip via <code>WriteExportToCsvListener</code>. Not on <code>master</code>.</div>
</div>
<div class="feature">
<div class="f-name">Data erasure request (right to be forgotten)</div>
<span class="f-status partial">partial</span>
<div class="f-note">On <code>Privacy</code> branch only: <code>PrivacyService::requestErasureForCustomer()/requestErasureForUser()</code>, extensible via <code>config('core.privacy.providers')</code> — the same config-array-registration pattern as <code>NotificationRegistry</code>, keyed off <code>Modules\Core\Privacy\Contracts\PersonalDataProvider</code>.</div>
</div>
<div class="feature">
<div class="f-name">Cancellable grace period before erasure</div>
<span class="f-status partial">partial</span>
<div class="f-note">On <code>Privacy</code> branch only: 30-day default (<code>core.privacy.grace_period_days</code>), reverted automatically on login via <code>CancelErasureOnLoginListener</code> — same pattern Shopify's own account-deletion flow uses. No native platform documents this as a first-party primitive; it's usually left to a third-party app.</div>
</div>
<div class="feature">
<div class="f-name">Immediate erasure for regulator/legal requests</div>
<span class="f-status partial">partial</span>
<div class="f-note">On <code>Privacy</code> branch only: <code>requestImmediateErasureForCustomer()/ForUser()</code>, typed to accept only <code>Staff $requestedBy</code> so a self-service path cannot reach it even by accident.</div>
</div>
<div class="feature">
<div class="f-name">Multi-tenant erasure scoping (business account vs. individual login)</div>
<span class="f-status partial">partial</span>
<div class="f-note">On <code>Privacy</code> branch only, and a genuinely uncommon feature: <code>PrivacyService</code> splits every operation into Customer-scope vs. User-scope, plus a sole-owner cascade (<code>CascadeCustomerErasureListener</code>) when erasing the last linked User orphans a Customer. No researched competitor product handles B2B multi-seat erasure this explicitly.</div>
</div>
<div class="feature">
<div class="f-name">Right to rectification (self-service data correction)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No dedicated flow found on either branch — Art. 16 is generally satisfied today only incidentally, by a customer editing their own profile/address through existing account forms, not a tracked rectification request.</div>
</div>
<div class="feature">
<div class="f-name">Dummy data anonymization for local dev</div>
<span class="f-status have">have</span>
<div class="f-note">On <code>master</code>: <code>src/Command/AnonymizeCommand.php</code> (<code>boboko:anonymize</code>) — scrubs <code>users</code>/<code>lunar_customers</code>, environment-guarded to <code>local</code> only. Distinct from GDPR erasure; the <code>Privacy</code> branch README diff explicitly flags this is <strong>not</strong> the compliance tool.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Anonymization, pseudonymization &amp; retention</h2>
</div>
<p class="cat-note">Deletion isn't the only lawful outcome — these are three different operations, often confused with each other.</p>
<div class="feature">
<div class="f-name">Legal-retention pseudonymization (orders/invoices)</div>
<span class="f-status partial">partial</span>
<div class="f-note">On <code>Privacy</code> branch only: <code>OrderDataProvider::eraseForCustomer()</code> clears PII fields but keeps order rows/totals/tax data intact, citing GDPR Art. 17(3)(b)'s legal-obligation exception — reports <code>ErasureOutcome::Pseudonymized</code>, not <code>Erased</code>, distinctly.</div>
</div>
<div class="feature">
<div class="f-name">Per-provider retention policy, owned by the data's own module</div>
<span class="f-status partial">partial</span>
<div class="f-note">On <code>Privacy</code> branch only: <code>PersonalDataProvider</code> deliberately has no central taxonomy — each provider (<code>CustomerDataProvider</code>, <code>AddressDataProvider</code>, <code>OrderDataProvider</code>, <code>CartDataProvider</code>, <code>ReviewDataProvider</code>) decides erase vs. pseudonymize vs. skip for its own table. <code>docs/privacy.md</code> flags <code>ReviewDataProvider</code>'s scope choice as needing review before relying on it.</div>
</div>
<div class="feature">
<div class="f-name">Automatic data retention / auto-deletion after N days</div>
<span class="f-status missing">missing</span>
<div class="f-note">Neither branch has a scheduled sweep that erases stale data on its own — every erasure on the <code>Privacy</code> branch is triggered by an explicit request, not a retention-policy timer (e.g. "delete guest carts after 2 years," "purge OTP logs after 90 days").</div>
</div>
<div class="feature">
<div class="f-name">Audit trail of what was erased/exported and why</div>
<span class="f-status partial">partial</span>
<div class="f-note">On <code>Privacy</code> branch only: <code>DataErasureRequest.report</code> stores the full per-provider outcome as a snapshot (not a live lookup), specifically so the audit record stays readable after the underlying data is gone.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Consent &amp; cookies</h2>
</div>
<p class="cat-note">What a visitor is asked before tracking starts, and whether that choice is recorded anywhere.</p>
<div class="feature">
<div class="f-name">Cookie consent banner (categorized: essential/analytics/marketing)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No code on either branch. Shopify ships a first-party <code>Customer Privacy API</code> recognizing four consent signals (analytics, marketing, preferences, sale-of-data); WooCommerce relies entirely on third-party plugins for this.</div>
</div>
<div class="feature">
<div class="f-name">Granular marketing-consent tracking (email/SMS opt-in, per channel)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Not modeled anywhere in <code>Modules\Core</code> — no consent flag found on the <code>Customer</code>/<code>User</code> models on either branch.</div>
</div>
<div class="feature">
<div class="f-name">Timestamped, versioned consent log (audit trail per visitor)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Standard feature of dedicated CMPs (OneTrust, Enzuzo, Consentmo) — a logged record of which policy version a visitor consented to and when. Nothing comparable exists in this codebase; the <code>Privacy</code> branch's audit trail covers erasure/export requests only, not consent events.</div>
</div>
<div class="feature">
<div class="f-name">Google Consent Mode v2 / IAB TCF v2.3 integration</div>
<span class="f-status missing">missing</span>
<div class="f-note">Storefront/analytics-layer concern, not present in boboko-core at all — would live in the 3dealer storefront, not this package.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Policy &amp; agreement management</h2>
</div>
<p class="cat-note">Terms of service and privacy policy as tracked, versioned documents — not just static pages.</p>
<div class="feature">
<div class="f-name">Terms-of-service / privacy-policy versioning</div>
<span class="f-status missing">missing</span>
<div class="f-note">No version-tracked policy document model on either branch — best practice researched: store version hashes or dated text alongside each acceptance record, review at least annually.</div>
</div>
<div class="feature">
<div class="f-name">Per-user acceptance tracking (clickwrap audit trail)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No record of "which policy version did this customer accept, and when" anywhere in <code>Modules\Core</code>. Researched as a standard requirement for surviving a legal dispute or regulatory inquiry.</div>
</div>
<div class="feature">
<div class="f-name">Re-acceptance prompt on material policy change</div>
<span class="f-status missing">missing</span>
<div class="f-note">Depends on the versioning row above existing first — nothing to gate a re-prompt on today.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2>Payment data &amp; PCI-DSS scope</h2>
</div>
<p class="cat-note">Whether cardholder data ever actually reaches boboko's own infrastructure.</p>
<div class="feature">
<div class="f-name">Card data never touches application servers (tokenization)</div>
<span class="f-status have">have</span>
<div class="f-note">Verified from source: <code>docs/lunar.md</code> "Stripe integration" — payment flows through Lunar's Stripe driver (<code>Lunar\Stripe\Facades\Stripe</code>, <code>fetchOrCreateIntent()</code>/PaymentIntents), so PAN never lands in a boboko/Lunar database. Researched: this pattern alone can cut PCI-DSS scope by roughly 90% per industry sources.</div>
</div>
<div class="feature">
<div class="f-name">Self-attested SAQ-A eligibility documentation</div>
<span class="f-status missing">missing</span>
<div class="f-note">The technical precondition (no card data touching the server) is met, but nothing in <code>docs/</code> documents or asserts SAQ-A eligibility for a consuming app's own compliance paperwork.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">06</span>
<h2>Regional &amp; regulatory coverage</h2>
</div>
<p class="cat-note">Beyond GDPR — the other regimes a storefront selling outside the EU may need.</p>
<div class="feature">
<div class="f-name">CCPA "Do Not Sell/Share My Info" opt-out</div>
<span class="f-status missing">missing</span>
<div class="f-note">No opt-out flag or page found on either branch. Shopify's Customer Privacy API models this as a distinct fourth consent signal ("sale of data") alongside analytics/marketing/preferences — boboko has no equivalent signal at all yet.</div>
</div>
<div class="feature">
<div class="f-name">Geo-targeted regulatory detection (GDPR vs. CCPA vs. LGPD banner)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Third-party CMPs (Consentmo, UniConsent) auto-detect visitor region to show the applicable banner/rights. No geo-based privacy-regime logic anywhere in this codebase.</div>
</div>
<div class="feature">
<div class="f-name">Age verification / minor-data restrictions (COPPA-adjacent)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No age gate or minor-specific data handling found on either branch.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">07</span>
<h2>Incident &amp; vendor accountability</h2>
</div>
<p class="cat-note">What happens when something goes wrong, or when a third party is handling data on the shop's behalf.</p>
<div class="feature">
<div class="f-name">Data breach notification workflow</div>
<span class="f-status missing">missing</span>
<div class="f-note">No incident-tracking model or notification path found on either branch — GDPR Art. 33/34's 72-hour authority-notification and affected-subject-notification duties have no tooling here today.</div>
</div>
<div class="feature">
<div class="f-name">Subprocessor / third-party vendor disclosure list</div>
<span class="f-status missing">missing</span>
<div class="f-note">No subprocessor registry in code — Stripe is the one third-party data processor identifiable from <code>docs/lunar.md</code>, but nothing formally tracks or discloses it as a subprocessor.</div>
</div>
<div class="feature">
<div class="f-name">Data processing agreement (DPA) tracking per vendor</div>
<span class="f-status missing">missing</span>
<div class="f-note">Not applicable to application code directly, but no config or doc references a DPA registry either — purely a legal/ops artifact today, not represented in boboko-core at all.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-08-28 — <code>have</code>/<code>partial</code> statuses sourced from direct reads of <code>master</code> and the unmerged <code>Privacy</code> branch (commits <code>9f540cb</code>, <code>59303cf</code>) via <code>git show</code>; competitor/regulatory claims sourced from web research, cited inline.</span>
<span>boboko-core / docs</span>
</footer>
</div>
@@ -0,0 +1,508 @@
<title>Products &amp; Collections Feature Survey</title>
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
:root:not([data-theme="light"]) {
@media (prefers-color-scheme: dark) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 15.5px;
line-height: 1.55;
margin: 0;
padding: 4.5rem 1.5rem 6rem;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
header.page {
margin-bottom: 3.25rem;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.9rem;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 560;
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
line-height: 1.08;
letter-spacing: -0.01em;
margin: 0 0 0.9rem;
text-wrap: balance;
}
.dek {
color: var(--muted);
max-width: 60ch;
font-size: 1.02rem;
}
.dek strong {
color: var(--ink);
font-weight: 600;
}
.callout {
margin-top: 1.4rem;
padding: 0.9rem 1.1rem;
background: var(--accent-soft);
border-radius: 4px;
color: var(--ink);
font-size: 0.88rem;
max-width: 62ch;
}
.callout strong { color: var(--accent); }
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.6rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border-radius: 3px;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
.tally {
margin-top: 1rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.78rem;
color: var(--muted);
letter-spacing: 0.02em;
}
.tally b { color: var(--ink); }
section.category {
margin-top: 3rem;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 0.85rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.7rem;
margin-bottom: 1.1rem;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 1.6rem;
}
.cat-head h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.28rem;
margin: 0;
letter-spacing: -0.005em;
}
.cat-note {
color: var(--muted);
font-size: 0.86rem;
margin: 0 0 1.2rem;
max-width: 62ch;
}
.feature {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid var(--hairline);
align-items: start;
}
.feature:last-child { border-bottom: none; }
.f-name {
font-weight: 600;
font-size: 0.98rem;
}
.f-status {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
height: fit-content;
}
.f-status.have { background: var(--good-soft); color: var(--good); }
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
.f-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.87rem;
margin-top: 0.15rem;
max-width: 66ch;
}
.f-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.82em;
background: var(--accent-soft);
color: var(--accent);
padding: 0.08em 0.35em;
border-radius: 3px;
}
footer.page {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 0.82rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer.page a { color: var(--accent); }
@media (max-width: 560px) {
body { padding: 3rem 1.1rem 4rem; }
.feature { grid-template-columns: 1fr; }
.f-status { justify-self: start; }
}
</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<div class="wrap">
<header class="page">
<div class="eyebrow">boboko / products &amp; collections · competitive survey</div>
<h1>What products &amp; collections elsewhere can do that boboko can't yet</h1>
<p class="dek">
A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and general
2026 storefront UX trends — checked against what
<strong><code>Modules\Core\Catalog</code></strong> actually ships in boboko-core
and what 3dealer's storefront actually calls. Unlike the rest of this survey
series, this concern is not a blank slate: a real Meilisearch-backed catalog
layer (listing, filtering, facets, search, collections, a product-option-type
system) was built this session. The gaps here are mostly about storefront wiring
and discovery/merchandising UX, not backend plumbing.
</p>
<div class="callout">
<strong>Read this first:</strong> the category page's sort dropdown, price
slider, in-stock checkbox, and sidebar search box are all visually present but
functionally dead — none of them submit a request or call a filter. The backend
methods they'd need (<code>ProductService::facets()</code>,
<code>priceRange()</code>, <code>list()</code>'s sort param) already exist and
work; nothing in <code>CategoryController</code> passes them through yet.
</div>
<div class="legend">
<span class="chip have">● have</span>
<span class="chip partial">◐ partial</span>
<span class="chip missing">○ missing</span>
</div>
<div class="tally">31 features surveyed — <b>8 have</b> · <b>10 partial</b> · <b>13 missing</b></div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2>Core listing &amp; filtering plumbing</h2>
</div>
<p class="cat-note">The Meilisearch-backed layer everything else in this survey sits on top of — this is where most of this session's real build lives.</p>
<div class="feature">
<div class="f-name">Paginated product listing, index-backed (not DB reads)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ProductService::list()</code> reads <code>Product::search('')</code> via Meilisearch and returns a real <code>LengthAwarePaginator</code> — used end-to-end by <code>CategoryController::show()</code> and rendered by <code>x-product-grid</code>.</div>
</div>
<div class="feature">
<div class="f-name">Filter by collection (including descendant collections)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ProductFilters::collectionId</code> matches <code>ProductIndexer</code>'s <code>collection_ids</code> field, which unions a product's direct collections with all ancestors — so a parent-category page picks up products attached only to a leaf subcategory. Wired in <code>CategoryController</code>.</div>
</div>
<div class="feature">
<div class="f-name">Filter by brand, price range, stock status</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>ProductFilters</code> supports <code>brand</code>, <code>minPrice</code>/<code>maxPrice</code>, <code>inStockOnly</code>, fully implemented in <code>ProductService::buildFilter()</code> — but <code>category/show.blade.php</code>'s price slider and in-stock checkbox are hardcoded markup with no form submission; <code>CategoryController</code> never constructs a <code>ProductFilters</code> with any of these three.</div>
</div>
<div class="feature">
<div class="f-name">Faceted counts for a filter sidebar (brand, stock, etc.)</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>ProductService::facets()</code> returns value→count via Meilisearch <code>facetDistribution</code>, correctly scoped to co-applied filters — but nothing storefront-side calls it. No brand/attribute facet list renders anywhere in <code>category/show.blade.php</code>.</div>
</div>
<div class="feature">
<div class="f-name">Price-range slider backed by real min/max</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>ProductService::priceRange()</code> reads Meilisearch <code>facetStats</code> for a correct, filter-scoped min/max — the sidebar instead shows a static "€10 - €50" label with a non-functional apply button.</div>
</div>
<div class="feature">
<div class="f-name">Sort (price asc/desc, newest)</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>ProductSort</code> enum + <code>ProductIndexer::getSortableFields()</code> (price, created_at) work end-to-end in <code>ProductService::list(sort: ...)</code> — the storefront's sort <code>&lt;select&gt;</code> is explicitly commented <code>{{-- Dummy — not wired to real sorting yet --}}</code> and includes a "popularity" option with no backing signal at all.</div>
</div>
<div class="feature">
<div class="f-name">Free-text product search</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>ProductSearchService::search()</code> is a complete, locale-aware, fallback-safe implementation (<code>attributesToSearchOn</code> targeting current + default locale) — but no search route exists in 3dealer (<code>routes/web.php</code> only has <code>product.show</code>/<code>category.show</code>), and both the header search icon and the sidebar search box are inert buttons/inputs.</div>
</div>
<div class="feature">
<div class="f-name">Single-product lookup by slug or id, index-only</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ProductService::getById()</code>/<code>getBySlug()</code>, both zero-database-read lookups against the <code>slugs</code>/<code>id</code> filterable fields. <code>ProductController::show()</code> uses <code>getById()</code> directly.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Collections &amp; navigation</h2>
</div>
<p class="cat-note">Category tree browsing, breadcrumbs, and merchandising — what turns a flat product list into a navigable store.</p>
<div class="feature">
<div class="f-name">Category tree browsing (root / children / by group)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>CollectionService::list()</code> with <code>CollectionFilters(rootOnly</code>/<code>parentId</code>/<code>groupId)</code>, backed by <code>CollectionIndexer</code>'s nested-set <code>parent_id</code>/<code>_lft</code> fields — no database read needed to build a nav tree.</div>
</div>
<div class="feature">
<div class="f-name">Top-nav category dropdown</div>
<span class="f-status have">have</span>
<div class="f-note"><code>components/header.blade.php</code> renders a CSS-only hover dropdown from a <code>$categories</code> list passed into the layout, linking to <code>category.show</code>.</div>
</div>
<div class="feature">
<div class="f-name">Breadcrumb navigation</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>CollectionIndexer</code> indexes a full root-first <code>ancestors</code> array ({id, name}) specifically so a breadcrumb needs zero extra queries — but <code>category/show.blade.php</code> and <code>product/show.blade.php</code> both build a flat two-level <code>x-breadcrumb</code> (Home → this category/product) by hand, never reading <code>ancestors</code>. A product under a three-deep category shows no intermediate levels.</div>
</div>
<div class="feature">
<div class="f-name">Category landing page merchandising (banner, pinned/featured products)</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>category/show.blade.php</code> renders only the collection name/description above a plain product grid — no banner image field, no "featured in this category" pinning above organic results. <code>CollectionIndexer</code>'s <code>thumbnail</code> field exists but isn't read on the category page at all (only used, if anywhere, for nav-level imagery).</div>
</div>
<div class="feature">
<div class="f-name">Sub-category faceting (filter by attribute within a category)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No attribute-value facet (size, material, etc.) is indexed as filterable on <code>ProductIndexer</code> beyond <code>brand</code> and <code>in_stock</code> — a category page can't offer "filter dresses by size" the way Shopify/WooCommerce faceted nav does; would need new filterable fields on custom product attributes plus sidebar UI.</div>
</div>
<div class="feature">
<div class="f-name">Product count shown per category</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>CollectionIndexer</code> computes <code>product_count</code> (including descendant collections) at index time by querying the product index directly — correct and cheap, but nothing in <code>category/show.blade.php</code> or the nav dropdown displays it.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Product detail page</h2>
</div>
<p class="cat-note">What a shopper sees once they land on a single product — media, variants, reviews, cross-sell.</p>
<div class="feature">
<div class="f-name">Multi-image gallery with lightbox</div>
<span class="f-status have">have</span>
<div class="f-note"><code>product/show.blade.php</code>'s <code>product-gallery</code> Stimulus controller — thumbnail rail, main image, full popover lightbox with prev/next/counter — fed from <code>ProductIndexer</code>'s full <code>media</code> array (not just a single thumbnail).</div>
</div>
<div class="feature">
<div class="f-name">Variant selection via color swatches</div>
<span class="f-status have">have</span>
<div class="f-note">End-to-end: <code>ColorOptionType</code> lets an admin attach a hex code to an option value → <code>ProductIndexer::mapVariant()</code> embeds <code>meta.hex</code> per variant → <code>x-ui.color-swatch</code> renders real swatch buttons wired to a <code>product-form</code> Stimulus controller that swaps price/image on selection.</div>
</div>
<div class="feature">
<div class="f-name">Swatches for non-color attributes (pattern, texture, material)</div>
<span class="f-status partial">partial</span>
<div class="f-note">The <code>ProductOptionTypeInterface</code> system is explicitly built to be extensible — a <code>PatternOptionType</code> or <code>MaterialOptionType</code> is a new class plus a Filament form, no core change needed — but only <code>ColorOptionType</code> is registered, and <code>x-ui.color-swatch</code> itself hardcodes a background-color swatch, not a generic swatch renderer.</div>
</div>
<div class="feature">
<div class="f-name">Customer reviews with ratings, photos, staff replies</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ProductReview</code> model, fully indexed (<code>items</code>/<code>count</code>/<code>average_rating</code>, PII-safe), live-reindexed on review create/update/delete via <code>ReviewServiceProvider</code>, and rendered in <code>product/show.blade.php</code>'s Reviews tab with <code>x-review-card</code>/<code>x-review-form</code>.</div>
</div>
<div class="feature">
<div class="f-name">Structured data / schema.org Product markup</div>
<span class="f-status missing">missing</span>
<div class="f-note">No <code>application/ld+json</code> or <code>itemscope</code> markup anywhere in 3dealer's views. Rich results (price/rating/availability in Google Shopping) are a significant organic-CTR lever per 2026 SEO guidance — the product page already has every field (price, rating, stock) a Product schema block would need, just not emitted.</div>
</div>
<div class="feature">
<div class="f-name">Related products / "customers also bought" / cross-sell</div>
<span class="f-status missing">missing</span>
<div class="f-note">Raw Lunar already models this (<code>Lunar\Base\Enums\ProductAssociation::CROSS_SELL</code>/<code>UP_SELL</code>/<code>ALTERNATE</code>, <code>$product-&gt;associate()</code>/<code>associations()</code> — see <code>docs/lunar.md</code> "Products and Variants") but nothing in <code>Modules\Core\Catalog</code> surfaces it, and the "Σχετικά προϊόντα" block at the bottom of <code>product/show.blade.php</code> is four fully hardcoded fake products with <code>href =&gt; '#'</code>.</div>
</div>
<div class="feature">
<div class="f-name">Recently-viewed products</div>
<span class="f-status missing">missing</span>
<div class="f-note">No session/cookie tracking of viewed products anywhere in 3dealer or core — a standard discovery module on both Shopify and WooCommerce storefronts per current UX research.</div>
</div>
<div class="feature">
<div class="f-name">Product badges (new / sale / bestseller)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No badge concept on <code>ProductIndexer</code>'s document and no badge markup on <code>x-ui.product-card</code> — would need either a computed signal (e.g. "new" from <code>created_at</code>, "sale" from <code>compare_price</code> already indexed per-variant) or an admin-set tag, neither wired to a visual badge today.</div>
</div>
<div class="feature">
<div class="f-name">Size chart / fit guide</div>
<span class="f-status missing">missing</span>
<div class="f-note">No size-chart content field on <code>Product</code>/<code>ProductType</code> and no UI for it on the product page. Not especially relevant to 3dealer's current catalog (3D-printed goods), but a real gap for any apparel-leaning store built on this core.</div>
</div>
<div class="feature">
<div class="f-name">Stock notification ("notify me when back in stock")</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>in_stock</code> is indexed and known per-product (<code>ProductIndexer::toSearchableArray()</code>), but there's no subscription model, email trigger, or UI for a shopper to ask to be notified — the signal exists, nothing acts on it.</div>
</div>
<div class="feature">
<div class="f-name">Product Q&amp;A section</div>
<span class="f-status missing">missing</span>
<div class="f-note">No question/answer model anywhere in core — only the separate review system (<code>ProductReview</code>) exists, which is a distinct concept (post-purchase rating, not pre-purchase Q&amp;A).</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Emerging discovery &amp; merchandising UX</h2>
</div>
<p class="cat-note">2026 trend-adjacent features, mostly backed on other platforms by paid apps/plugins rather than core — useful for calibrating how unusual these gaps are.</p>
<div class="feature">
<div class="f-name">Quick-view modal (preview from listing grid, no page load)</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>x-ui.product-card</code> links straight to <code>product.show</code> with a hover-revealed "add to cart" button only — no modal/preview interaction. Current UX research flags quick-view modals as a common INP (responsiveness) failure point, so the absence isn't purely a gap to close blindly.</div>
</div>
<div class="feature">
<div class="f-name">Infinite scroll as an alternative to pagination</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>category/show.blade.php</code> uses classic <code>x-ui.pagination</code> against the real paginator from <code>ProductService::list()</code> — works correctly, just page-based rather than scroll-based. Research is genuinely mixed on whether infinite scroll is even preferable for conversion/SEO, so this is a parity note, not a clear gap.</div>
</div>
<div class="feature">
<div class="f-name">Product comparison tool (side-by-side spec table)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Not in boboko-core, and notably not native on Shopify or WooCommerce either — both rely on third-party apps (Bear Specs &amp; Compare, Equate, WooCommerce's own paid "Advanced Product Comparison" extension). A real gap, but not one competitors solve in-platform for free.</div>
</div>
<div class="feature">
<div class="f-name">Product bundles / kits</div>
<span class="f-status missing">missing</span>
<div class="f-note">No bundle/kit concept (a purchasable grouping of several variants as one line item) anywhere in <code>Lunar\Models\Product</code>/<code>ProductVariant</code> or <code>Modules\Core\Catalog</code>.</div>
</div>
<div class="feature">
<div class="f-name">360°/video product media, AR try-on</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>ProductIndexer</code>'s <code>media</code> array is just Spatie media-library images (<code>url</code>/<code>thumb</code>) — no video or 360° asset type modeled, and no AR integration. The gallery component (<code>product-gallery</code> Stimulus controller) is generic enough to extend to a video slide without a rewrite, but nothing does today.</div>
</div>
<div class="feature">
<div class="f-name">Variant-specific SEO URLs (distinct slug per color/size)</div>
<span class="f-status partial">partial</span>
<div class="f-note">Lunar's <code>HasUrls</code>/<code>Url</code> model supports per-locale slugs per <em>product</em> (indexed in <code>ProductIndexer</code>'s <code>slugs</code> field), but there's no per-<em>variant</em> URL — selecting a color swatch changes displayed price/image via <code>product-form</code> client-side state, not the URL, so a specific variant can't be linked or indexed separately.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-08-28 — <code>Modules\Core\Catalog</code> source, docs, and 3dealer storefront claims are direct reads; 2026 UX-trend, quick-view/infinite-scroll, and product-comparison-tooling claims are sourced from web research and marked accordingly in context.</span>
<span>boboko-core / docs</span>
</footer>
</div>
+465
View File
@@ -0,0 +1,465 @@
<title>Shipping Feature Survey</title>
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
:root:not([data-theme="light"]) {
@media (prefers-color-scheme: dark) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 15.5px;
line-height: 1.55;
margin: 0;
padding: 4.5rem 1.5rem 6rem;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
header.page {
margin-bottom: 3.25rem;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.9rem;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 560;
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
line-height: 1.08;
letter-spacing: -0.01em;
margin: 0 0 0.9rem;
text-wrap: balance;
}
.dek {
color: var(--muted);
max-width: 60ch;
font-size: 1.02rem;
}
.dek strong {
color: var(--ink);
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.6rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border-radius: 3px;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-top: 3rem;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 0.85rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.7rem;
margin-bottom: 1.1rem;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 1.6rem;
}
.cat-head h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.28rem;
margin: 0;
letter-spacing: -0.005em;
}
.cat-note {
color: var(--muted);
font-size: 0.86rem;
margin: 0 0 1.2rem;
max-width: 62ch;
}
.feature {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid var(--hairline);
align-items: start;
}
.feature:last-child { border-bottom: none; }
.f-name {
font-weight: 600;
font-size: 0.98rem;
}
.f-status {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
height: fit-content;
}
.f-status.have { background: var(--good-soft); color: var(--good); }
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
.f-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.87rem;
margin-top: 0.15rem;
max-width: 66ch;
}
.f-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.82em;
background: var(--accent-soft);
color: var(--accent);
padding: 0.08em 0.35em;
border-radius: 3px;
}
footer.page {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 0.82rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer.page a { color: var(--accent); }
@media (max-width: 560px) {
body { padding: 3rem 1.1rem 4rem; }
.feature { grid-template-columns: 1fr; }
.f-status { justify-self: start; }
}
</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<div class="wrap">
<header class="page">
<div class="eyebrow">boboko / shipping · competitive survey</div>
<h1>What shipping elsewhere can do that boboko can't yet</h1>
<p class="dek">
A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's shipping
layer — sourced, not recalled from memory — checked against what
<strong>Lunar core's <code>ShippingManifest</code></strong> and the
<strong><code>lunarphp/table-rate-shipping</code></strong> add-on actually support
today, and what's actually wired up in boboko-core and 3dealer right now.
For deciding what to design next, not a build order.
</p>
<div class="legend">
<span class="chip have">● have</span>
<span class="chip partial">◐ partial</span>
<span class="chip missing">○ missing</span>
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2>Core plumbing</h2>
</div>
<p class="cat-note">The mechanism Lunar core provides for offering and applying a shipping charge — everything else in this survey is built on top of it.</p>
<div class="feature">
<div class="f-name">Pluggable shipping option providers</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Lunar\Base\ShippingModifier</code> abstract class + <code>ShippingManifest::addOption()</code> — any package can register options onto the manifest via a pipeline of modifiers (<code>ShippingModifiers::getModifiers()</code>).</div>
</div>
<div class="feature">
<div class="f-name">Shipping applied to cart totals during calculate()</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Lunar\Pipelines\Cart\ApplyShipping</code> — reads <code>ShippingManifest::getShippingOption($cart)</code> or a manual <code>shippingOptionOverride</code>, writes a <code>ShippingBreakdown</code> and <code>shippingSubTotal</code> onto the cart before <code>CalculateTax</code> runs.</div>
</div>
<div class="feature">
<div class="f-name">Cart-level shippable check</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Cart::isShippable()</code> — true if any line's <code>purchasable</code> (e.g. <code>ProductVariant::isShippable()</code>) is shippable; a digital-only cart skips the shipping-address requirement entirely.</div>
</div>
<div class="feature">
<div class="f-name">Selecting a shipping option on the cart</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Cart::setShippingOption()</code> → <code>SetShippingOption</code> action, validated by <code>ShippingOptionValidator</code>, triggers a recalculate. Nothing in 3dealer's storefront calls it yet — no shipping step exists in the UI.</div>
</div>
<div class="feature">
<div class="f-name">Order-time shipping line snapshot</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Lunar\Pipelines\Order\Creation\CreateShippingLine</code> writes an immutable <code>shipping</code>-type order line from the cart's shipping breakdown at checkout — survives later rate changes.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Rate configuration (table-rate-shipping add-on)</h2>
</div>
<p class="cat-note"><code>lunarphp/table-rate-shipping</code> is installed (<code>composer.json</code>, pinned <code>^1.3</code>) and its <code>ShippingPlugin</code> is registered in <code>CorePlugin::boot()</code> — so 3dealer inherits it automatically, it doesn't need its own registration.</p>
<div class="feature">
<div class="f-name">Geographic shipping zones (country / state / postcode)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ShippingZone</code> model, type <code>unrestricted|countries|states|postcodes</code>; <code>ShippingZoneResolver::get()</code> matches a cart's address against zone scope, falling back to any <code>unrestricted</code> zone.</div>
</div>
<div class="feature">
<div class="f-name">Flat-rate shipping</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Drivers\ShippingMethods\FlatRate::resolve()</code> — one price per cart subtotal via <code>Pricing::for($shippingRate)</code>.</div>
</div>
<div class="feature">
<div class="f-name">Weight- or total-tiered rates ("ship by")</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Drivers\ShippingMethods\ShipBy::resolve()</code> — <code>data['charge_by']</code> is <code>cart_total</code> or <code>weight</code>, tiered via <code>priceBreaks</code>, with customer-group price overrides taking priority.</div>
</div>
<div class="feature">
<div class="f-name">Free-shipping threshold</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Drivers\ShippingMethods\FreeShipping::resolve()</code> — <code>data['minimum_spend']</code> (per-currency array supported), optional <code>use_discount_amount</code> to check against post-discount subtotal.</div>
</div>
<div class="feature">
<div class="f-name">In-store pickup / collection</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Drivers\ShippingMethods\Collection::resolve()</code> — zero-price option, flagged <code>collect: true</code> on the <code>ShippingOption</code>. Single implicit "store" — no concept of which location, no per-location stock or hours.</div>
</div>
<div class="feature">
<div class="f-name">Per-product shipping exclusions by zone</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ShippingExclusionList</code> + <code>ShippingZone::shippingExclusions()</code> — every driver checks it before resolving and returns <code>null</code> if any cart line's product is excluded from that zone.</div>
</div>
<div class="feature">
<div class="f-name">Per-customer-group rate visibility</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ShippingMethod::customerGroups()</code> pivot carries <code>visible</code>, <code>enabled</code>, <code>starts_at</code>, <code>ends_at</code> — scheduling and audience-gating a rate is already modeled.</div>
</div>
<div class="feature">
<div class="f-name">Filament admin UI for zones/methods/rates</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ShippingZoneResource</code>, <code>ShippingMethodResource</code>, <code>ShippingExclusionListResource</code> ship with the add-on — usable as soon as the Filament plugin is registered, which it is via <code>CorePlugin</code>.</div>
</div>
<div class="feature">
<div class="f-name">Storefront checkout step to pick a rate</div>
<span class="f-status missing">missing</span>
<div class="f-note">No shipping views exist in 3dealer's <code>resources/views</code> beyond a passing mention in <code>components/footer.blade.php</code> — the whole backend above is unwired to any customer-facing UI.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Carrier integration</h2>
</div>
<p class="cat-note">Real carriers quoting and printing on Lunar's behalf, rather than merchant-defined flat/tiered rates.</p>
<div class="feature">
<div class="f-name">Real-time carrier rate shopping (USPS/UPS/FedEx/DHL)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No driver in <code>table-rate-shipping</code> calls an external carrier API — all four shipped drivers (<code>FlatRate</code>, <code>ShipBy</code>, <code>FreeShipping</code>, <code>Collection</code>) compute from local data. Shopify's <code>CarrierService</code> API is the model for this: shop sends weight/dims/destination, carrier returns live rates at checkout.</div>
</div>
<div class="feature">
<div class="f-name">Product/variant weight &amp; dimensions for rating</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>ProductVariant</code> has <code>weight_value</code>/<code>weight_unit</code> (referenced in <code>ShipBy</code>'s weight tier and <code>docs/lunar.md</code>) but no length/width/height fields exist in core migrations — enough for weight-tier rating, not enough for carrier-grade dimensional/volumetric quotes.</div>
</div>
<div class="feature">
<div class="f-name">Shipping label generation &amp; printing (staff-facing)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No label concept anywhere in core or the add-on. Shopify has this built in for US merchants (USPS/UPS labels from admin or mobile); WooCommerce/PrestaShop lean on Shippo/EasyPost-style apps.</div>
</div>
<div class="feature">
<div class="f-name">Return / exchange label generation</div>
<span class="f-status missing">missing</span>
<div class="f-note">No returns concept exists in Lunar core at all — this sits behind both "labels" and "returns," neither of which exists yet.</div>
</div>
<div class="feature">
<div class="f-name">Shipment tracking numbers on orders</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>OrderShippingZone</code> pivot table records which zone an order matched, but no field anywhere stores a carrier tracking number or shipment status.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Fulfillment logistics</h2>
</div>
<p class="cat-note">Where an order physically ships from, and whether it can ship from more than one place.</p>
<div class="feature">
<div class="f-name">Multi-warehouse / multi-location inventory</div>
<span class="f-status missing">missing</span>
<div class="f-note">No warehouse, location, or fulfillment-center model anywhere in <code>vendor/lunarphp/core</code> or <code>lunar</code> — stock is a flat quantity on the variant. WooCommerce needs Calcurates or WooCommerce Warehouses add-ons for this; it's genuinely not a Lunar concept at all.</div>
</div>
<div class="feature">
<div class="f-name">Split shipment (one order, multiple packages/warehouses)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Downstream of multi-warehouse — with a single implicit stock pool, there's nothing to split by. <code>CreateShippingLine</code> writes exactly one shipping line per order.</div>
</div>
<div class="feature">
<div class="f-name">Multiple pickup locations (choose a specific store)</div>
<span class="f-status missing">missing</span>
<div class="f-note">The <code>Collection</code> driver models pickup as a single yes/no rate per zone — no location entity to pick from, no per-location hours/capacity.</div>
</div>
<div class="feature">
<div class="f-name">Local delivery (distinct from carrier shipping or pickup)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No radius/zone-based "we deliver it ourselves" driver — only <code>ShipBy</code>/<code>FlatRate</code> (carrier-agnostic priced shipping) and <code>Collection</code> (pickup) exist as concepts.</div>
</div>
<div class="feature">
<div class="f-name">Delivery date / time-slot selection at checkout</div>
<span class="f-status missing">missing</span>
<div class="f-note">No date/time field on <code>ShippingOption</code>, <code>CartAddress</code>, or the order shipping line. WooCommerce needs a dedicated delivery-date-picker plugin for this too — not a gap unique to Lunar, but still open here.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2>International &amp; risk</h2>
</div>
<p class="cat-note">What happens when a shipment crosses a border, or something goes wrong in transit.</p>
<div class="feature">
<div class="f-name">Customs documentation / HS codes per product</div>
<span class="f-status missing">missing</span>
<div class="f-note">No HS-code or customs-description field found on <code>Product</code>/<code>ProductVariant</code> migrations. Every international shipment needs one per line item to clear customs — researched requirement, not yet modeled anywhere in Lunar.</div>
</div>
<div class="feature">
<div class="f-name">Duties/taxes collected at checkout (DDP)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Lunar's <code>CalculateTax</code> pipeline step handles sales tax/VAT on the cart itself, not import duty estimation for cross-border orders. DDP vs. DDU is the standard framing (seller-collects-upfront vs. customer-pays-on-delivery) — neither is modeled.</div>
</div>
<div class="feature">
<div class="f-name">Country/zone-restricted shipping</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ShippingZone</code> type <code>countries</code>/<code>states</code>/<code>postcodes</code> already scopes which rates apply where — the building block international shipping would sit on top of.</div>
</div>
<div class="feature">
<div class="f-name">Shipping insurance / package protection at checkout</div>
<span class="f-status missing">missing</span>
<div class="f-note">No insurance line-item concept in core. On Shopify this is exclusively third-party (ShipInsure, Route, Simply Shipping Protection) — not a platform-native feature there either, so the gap is normal, not distinctive.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-08-28 — inline citations from <code>vendor/lunarphp/core</code> and <code>vendor/lunarphp/table-rate-shipping</code> source are direct reads; DDP/DDU, carrier-API, label, and warehouse claims are sourced from web research on Shopify/WooCommerce/PrestaShop, marked accordingly by context.</span>
<span>boboko-core / docs</span>
</footer>
</div>
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@@ -2,18 +2,18 @@
namespace Modules\Core\Auth\Extensions;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\ResourceExtension;
class StaffResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
public function extendForm(Schema $form): Schema
{
$schema = collect($form->getComponents())
->reject(fn ($component) => method_exists($component, 'getName') && $component->getName() == 'password')
->values()
->all();
return $form->schema($schema);
return $form->components($schema);
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ class Login extends SimplePage
{
use WithRateLimiting;
protected static string $view = 'core::auth.filament.pages.login';
protected string $view = 'core::auth.filament.pages.login';
public ?string $email = '';
public ?string $otp = '';
@@ -78,7 +78,7 @@ class Login extends SimplePage
]);
}
if ($staff instanceof FilamentUser && !$staff->canAccessPanel(Filament::getCurrentPanel())) {
if ($staff instanceof FilamentUser && !$staff->canAccessPanel(Filament::getCurrentOrDefaultPanel())) {
throw ValidationException::withMessages([
'email' => 'You do not have access to this panel.',
]);
@@ -0,0 +1,84 @@
<?php
namespace Modules\Core\Cart\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Recovery\Events\CartAbandoned;
use Modules\Core\Recovery\Events\CheckoutAbandoned;
/**
* "Abandoned" is a derived state (Cart::updated_at older than
* config('core.cart.abandoned_after')) — nothing transitions a cart into it
* via a normal Eloquent write, so there's no model-event hook to dispatch
* CartAbandoned/CheckoutAbandoned from directly. This command is the only
* place that moment gets detected; run it on a schedule (see docs/cart.md).
*
* Splits Cart::scopeActive()'s two branches into their own events —
* see CartAbandoned/CheckoutAbandoned's docblocks for why they're distinct,
* not one combined "abandoned" state: a cart with no order at all is a much
* weaker purchase-intent signal than one with a draft order that was never
* placed.
*
* Deliberately does NOT write anything to Cart/Order — dispatch only. An
* earlier version recorded an "already notified" marker on Cart::meta/
* Order::meta, but that write bumped updated_at as an Eloquent side effect,
* which un-staled the very cart being marked abandoned (the same field
* abandonment staleness is computed from) — see docs/cart.md's former
* "Known bug" note. Cart/Checkout must have no way of writing abandonment
* 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.
*/
class DetectAbandonedCarts extends Command
{
protected $signature = 'boboko:cart:detect-abandoned';
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
public function handle(): void
{
$cutoff = CartResource::abandonedCutoff();
$cartsAbandoned = 0;
$checkoutsAbandoned = 0;
Cart::query()
->whereDoesntHave('orders')
->where('updated_at', '<=', $cutoff)
->with('lines')
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
foreach ($carts as $cart) {
if ($cart->lines->isEmpty()) {
continue;
}
Event::dispatch(new CartAbandoned($cart));
$cartsAbandoned++;
}
});
Cart::query()
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $cutoff)
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
foreach ($carts as $cart) {
$order = $cart->orders->first();
if ($order === null) {
continue;
}
Event::dispatch(new CheckoutAbandoned($cart, $order));
$checkoutsAbandoned++;
}
});
$this->components->info("Dispatched CartAbandoned for {$cartsAbandoned} cart(s), CheckoutAbandoned for {$checkoutsAbandoned} checkout(s).");
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCleared
{
/**
* @param array<int, array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}> $lines
* Snapshot of every line that was in the cart before clearing — Cart::clear()
* deletes all rows directly, so nothing here can be fresh CartLine instances.
*/
public function __construct(
public readonly Cart $cart,
public readonly array $lines,
) {}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCouponApplied
{
public function __construct(
public readonly Cart $cart,
public readonly string $code,
) {}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCouponRemoved
{
public function __construct(
public readonly Cart $cart,
public readonly string $code,
) {}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
class CartLineAdded
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
/**
* The reverse of CartLineSaved — a previously saved-for-later line moved back
* into the purchasable cart (now counted in totals again).
*/
class CartLineMovedToCart
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartLineRemoved
{
/**
* @param array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array} $line
* Snapshot of the removed line — the row is already deleted by the time this
* event dispatches, so nothing here can be a fresh CartLine model instance.
*/
public function __construct(
public readonly Cart $cart,
public readonly array $line,
) {}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
/**
* A line was moved OUT of the purchasable cart and into "saved for later" —
* not a removal (the row still exists), but distinct from CartLineUpdated
* since it's a state transition worth its own hook (e.g. abandoned-cart
* recovery treating a saved line very differently from a deleted one).
*/
class CartLineSaved
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
class CartLineUpdated
{
/**
* @param array{quantity: int, meta: array} $old Snapshot before the update.
*/
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
public readonly array $old,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Cart\Exceptions;
use RuntimeException;
/**
* Thrown by CartService::applyCoupon() when the given code doesn't match any
* currently-active, non-exhausted Discount — Lunar's own
* Discounts::validateCoupon() only returns a bool, it has no matching
* exception type of its own to reuse here.
*/
class InvalidCouponException extends RuntimeException
{
public function __construct(public readonly string $couponCode)
{
parent::__construct("The coupon code \"{$couponCode}\" is not valid.");
}
}
@@ -0,0 +1,124 @@
<?php
namespace Modules\Core\Cart\Filament\Resources;
use Filament\Tables\Columns\TextColumn;
use Filament\Actions\ViewAction;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ListCarts;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
/**
* Read-only — a cart is managed entirely through the storefront (add/update/remove
* line, checkout), never hand-edited by staff. Scoped to carts with a known
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no
* identity a staff member could act on (no name, no email, nothing to follow up
* with), so listing every such row would be noise, not a real admin capability —
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all
* to follow a precedent from).
*/
class CartResource extends Resource
{
protected static ?string $model = Cart::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-cart';
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?string $modelLabel = 'Cart';
protected static ?string $pluralModelLabel = 'Carts';
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id'));
}
/**
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
* states (`active()` already covers "no order at all" and "draft order,
* never placed" together — see ListCarts::getTabs()'s "Abandoned Cart" /
* "Abandoned Checkout" tabs for where they're split apart), not "Ongoing"
* — the badge is meant to answer "how many carts might need following up
* on," not the total including ones someone is actively shopping in right
* now.
*/
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
/**
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
* different things together: a cart someone is actively shopping in right now,
* and one that's genuinely been left behind. Lunar tracks no time-based
* staleness signal of its own — `Cart::updated_at` plus a configurable
* threshold (`config('core.cart.abandoned_after')`, default 1 hour) is what
* this resource uses to tell them apart. A cart with no recent activity is
* "Abandoned"; anything more recent is "Ongoing".
*/
public static function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->label('Cart')
->sortable(),
TextColumn::make('customer.full_name')
->label('Customer')
->placeholder('—')
->searchable()
->url(fn (Cart $record) => $record->customer_id !== null
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
: null),
TextColumn::make('user.email')
->label('User')
->placeholder('—')
->searchable(),
TextColumn::make('lines_count')
->label('Lines')
->counts('lines')
->sortable(),
TextColumn::make('lines_sum_quantity')
->label('Items')
->sum('lines', 'quantity')
->sortable(),
TextColumn::make('currency.code')
->label('Currency'),
TextColumn::make('updated_at')
->label('Last activity')
->dateTime()
->sortable(),
])
->recordActions([
ViewAction::make(),
])
->defaultSort('updated_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => ListCarts::route('/'),
'view' => ViewCart::route('/{record}'),
];
}
public static function canCreate(): bool
{
return false;
}
}
@@ -0,0 +1,56 @@
<?php
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Cart\Filament\Resources\CartResource;
class ListCarts extends ListRecords
{
protected static string $resource = CartResource::class;
/**
* `Cart::completed_at` is declared/cast on the model but never actually written
* anywhere in Lunar core — it's dead, not a real "did this convert" signal.
* "Completed" instead means the cart has an order with `placed_at` set (a
* placed, not just drafted, order).
*
* `Cart::scopeActive()` (not yet converted to an order) actually mixes two
* distinct states: no order started at all, vs. a draft order exists
* (`placed_at IS NULL`) but was never placed — checkout was started, not
* finished. That's a real difference in purchase intent (a cart with a
* draft order is a much stronger signal than one with no order at all) and
* in reachability (checkout usually captures an email even for a guest),
* so they get separate tabs rather than one combined "no order yet"
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
*
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
* signal of its own, so recent activity is the only thing distinguishing a
* cart someone is shopping in right now from one genuinely left behind.
*/
public function getTabs(): array
{
return [
'abandoned_cart' => Tab::make('Abandoned Cart')
->modifyQueryUsing(fn(Builder $query) => $query
->whereDoesntHave('orders')
->where('updated_at', '<=', CartResource::abandonedCutoff())),
'abandoned_checkout' => Tab::make('Abandoned Checkout')
->modifyQueryUsing(fn(Builder $query) => $query
->whereHas('orders', fn(Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff())),
'ongoing' => Tab::make('Ongoing')
->modifyQueryUsing(fn(Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())),
'completed' => Tab::make('Completed')
->modifyQueryUsing(fn(Builder $query) => $query->whereHas(
'orders',
fn(Builder $query) => $query->whereNotNull('placed_at'),
)),
];
}
}
@@ -0,0 +1,112 @@
<?php
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Section;
use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Pages\ViewRecord;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Modules\Core\Cart\Filament\Resources\CartResource;
class ViewCart extends ViewRecord
{
protected static string $resource = CartResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('viewCustomer')
->label('View Customer')
->icon('heroicon-o-user')
->url(fn (Cart $record) => CustomerResource::getUrl('view', ['record' => $record->customer_id]))
->visible(fn (Cart $record) => $record->customer_id !== null),
];
}
/**
* Cart's computed properties (subTotal/total/etc.) are plain public properties
* populated as a side effect of the pipeline calculate() runs — never persisted,
* so they don't exist on a plain Eloquent-fetched record. Calculated once here
* (a single view page load), not per-row in the list table, since running the
* full pipeline for every row of a paginated table would be expensive for no
* real benefit — see docs/lunar.md's Cart gotchas.
*/
protected function resolveRecord(int|string $key): Cart
{
/** @var Cart $cart */
$cart = parent::resolveRecord($key);
return $cart->calculate();
}
public function infolist(Schema $schema): Schema
{
return $schema
->components([
Section::make('Cart')
->columns(3)
->schema([
TextEntry::make('id'),
TextEntry::make('customer.full_name')
->label('Customer')
->placeholder('—')
->url(fn (Cart $record) => $record->customer_id !== null
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
: null),
TextEntry::make('user.email')
->label('User')
->placeholder('—'),
TextEntry::make('currency.code')
->label('Currency'),
TextEntry::make('completedOrderPlacedAt')
->label('Ordered at')
->state(fn (Cart $record) => $record->orders()->whereNotNull('placed_at')->value('placed_at'))
->dateTime()
->placeholder('Not ordered'),
TextEntry::make('updated_at')
->label('Last activity')
->dateTime(),
]),
Section::make('Lines')
->schema([
RepeatableEntry::make('lines')
->hiddenLabel()
->schema([
TextEntry::make('purchasable.sku')
->label('SKU')
->placeholder('—'),
TextEntry::make('quantity'),
TextEntry::make('unitPrice')
->label('Unit price')
->formatStateUsing(fn (CartLine $record) => $record->unitPrice?->formatted() ?? '—'),
TextEntry::make('total')
->label('Line total')
->formatStateUsing(fn (CartLine $record) => $record->total?->formatted() ?? '—'),
])
->columns(4),
]),
Section::make('Totals')
->columns(3)
->schema([
TextEntry::make('subTotal')
->label('Subtotal')
->formatStateUsing(fn (Cart $record) => $record->subTotal?->formatted() ?? '—'),
TextEntry::make('discountTotal')
->label('Discount')
->formatStateUsing(fn (Cart $record) => $record->discountTotal?->formatted() ?? '—'),
TextEntry::make('taxTotal')
->label('Tax')
->formatStateUsing(fn (Cart $record) => $record->taxTotal?->formatted() ?? '—'),
TextEntry::make('total')
->label('Total')
->formatStateUsing(fn (Cart $record) => $record->total?->formatted() ?? '—')
->weight('bold'),
]),
]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Cart\Pipelines;
use Closure;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\CartLine as CartLineContract;
/**
* Runs in config('lunar.cart.pipelines.cart_lines'), after GetUnitPrice —
* zeroes out unitPrice/unitPriceInclTax for any line flagged
* meta.saved_for_later, BEFORE Lunar's own CalculateLines pipeline step reads
* unitPrice to compute subTotal/total. A saved-for-later item is deliberately
* parked, not pending purchase, so it shouldn't inflate Cart::total — and
* since CalculateLines sums every CartLine unconditionally with no meta-based
* exclusion of its own, zeroing the price here (rather than patching subTotal
* after the fact) is what makes every downstream total naturally correct
* without a second pass.
*/
class ZeroSavedForLaterPrice
{
public function handle(CartLineContract $cartLine, Closure $next): mixed
{
if ($cartLine->meta['saved_for_later'] ?? false) {
$currency = $cartLine->cart->currency;
$cartLine->unitPrice = new Price(0, $currency, 1);
$cartLine->unitPriceInclTax = new Price(0, $currency, 1);
}
return $next($cartLine);
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace Modules\Core\Cart\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Lunar\Actions\Carts\GetExistingCartLine;
use Lunar\Base\Purchasable;
use Lunar\Facades\CartSession;
use Lunar\Facades\Discounts;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Modules\Core\Cart\Events\CartCleared;
use Modules\Core\Cart\Events\CartCouponApplied;
use Modules\Core\Cart\Events\CartCouponRemoved;
use Modules\Core\Cart\Events\CartLineAdded;
use Modules\Core\Cart\Events\CartLineMovedToCart;
use Modules\Core\Cart\Events\CartLineRemoved;
use Modules\Core\Cart\Events\CartLineSaved;
use Modules\Core\Cart\Events\CartLineUpdated;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
/**
* Storefront-facing cart operations, mirroring Modules\Core\Catalog\Services\
* ProductService/CollectionService's shape — one boboko-owned API a storefront
* calls, so Lunar's own CartSession/Cart stay an implementation detail rather
* than something a consuming app depends on directly.
*
* Every mutating method dispatches a matching domain event
* (Modules\Core\Cart\Events\*) after the underlying Lunar operation completes —
* Lunar itself dispatches zero cart events (see docs/lunar.md's Cart gotchas),
* so without this, nothing in a consuming app has anything to react to when a
* cart actually changes (reindexing, notifications, analytics, etc.).
*
* All mutating methods return the recalculated Cart — matching Lunar's own
* Cart::add()/updateLine()/etc., which already return $this after
* refresh()->recalculate() — so a caller gets fresh totals in the same call,
* no second fetch needed.
*/
class CartService
{
/**
* The current session's cart, or null if none exists yet. Does NOT
* auto-create one — see currentOrCreate() for that.
*/
public function current(): ?Cart
{
return CartSession::current();
}
/**
* The current session's cart, creating one if none exists yet — the right
* call for "add to cart" style flows where a cart must exist by the time
* the method returns.
*/
public function currentOrCreate(): Cart
{
return CartSession::manager();
}
public function addLine(Purchasable $purchasable, int $quantity = 1, array $meta = []): Cart
{
$cart = $this->currentOrCreate()->add($purchasable, $quantity, $meta);
$line = app(config('lunar.cart.actions.get_existing_cart_line', GetExistingCartLine::class))
->execute($cart, $purchasable, $meta);
if ($line !== null) {
Event::dispatch(new CartLineAdded($cart, $line));
}
return $cart;
}
public function updateLine(int $cartLineId, int $quantity, ?array $meta = null): Cart
{
$before = CartLine::findOrFail($cartLineId);
$old = ['quantity' => $before->quantity, 'meta' => $before->meta->toArray()];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineUpdated($cart, $line, $old));
}
return $cart;
}
public function removeLine(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$snapshot = $this->snapshotLine($line);
$cart = $this->currentOrCreate()->remove($cartLineId);
Event::dispatch(new CartLineRemoved($cart, $snapshot));
return $cart;
}
public function clear(): Cart
{
$cart = $this->currentOrCreate();
$snapshots = $cart->lines->map($this->snapshotLine(...))->all();
$cart = $cart->clear();
Event::dispatch(new CartCleared($cart, $snapshots));
return $cart;
}
/**
* Sets the cart's coupon code, which the ApplyDiscounts pipeline step picks
* up on the next calculate() — there's no dedicated Lunar action for this
* (unlike add/update/remove, coupon_code is a plain cast attribute), so
* this is the closest thing to one for a consuming app to call.
*
* Validated via Discounts::validateCoupon() (does a matching, currently
* active, non-exhausted Discount exist?) before it's set — CouponString's
* cast only normalizes casing, it doesn't validate anything, so setting
* coupon_code directly would silently accept a bogus code and just not
* discount anything once calculated.
*
* @throws InvalidCouponException if the code doesn't match a valid, active,
* non-exhausted Discount
*/
public function applyCoupon(string $code): Cart
{
if (! Discounts::validateCoupon($code)) {
throw new InvalidCouponException($code);
}
$cart = $this->currentOrCreate();
$cart->coupon_code = $code;
$cart->save();
$cart = $cart->recalculate();
Event::dispatch(new CartCouponApplied($cart, $cart->coupon_code));
return $cart;
}
public function removeCoupon(): Cart
{
$cart = $this->currentOrCreate();
$code = $cart->coupon_code;
if ($code === null) {
return $cart;
}
$cart->coupon_code = null;
$cart->save();
$cart = $cart->recalculate();
Event::dispatch(new CartCouponRemoved($cart, $code));
return $cart;
}
/**
* Lines currently counted toward the cart's totals — everything except
* ones flagged meta.saved_for_later (see savedLines()). This is the set a
* cart page's main list / checkout would iterate, since a saved line
* isn't pending purchase.
*
* @return Collection<int, CartLine>
*/
public function activeLines(?Cart $cart = null): Collection
{
$cart ??= $this->currentOrCreate();
return $cart->lines->reject(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
}
/**
* Lines a shopper has deliberately parked rather than deleted — excluded
* from Cart totals (see Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice)
* and from activeLines(). A cart page's "Saved for later" section iterates
* this set.
*
* @return Collection<int, CartLine>
*/
public function savedLines(?Cart $cart = null): Collection
{
$cart ??= $this->currentOrCreate();
return $cart->lines->filter(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
}
/**
* Moves a line OUT of the purchasable cart without deleting it — it stays
* on the cart (still visible, still re-addable) but is excluded from
* totals via meta.saved_for_later, zeroed by ZeroSavedForLaterPrice before
* Lunar's own CalculateLines sums the cart (which has no meta-based
* exclusion of its own).
*/
public function saveForLater(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$meta = [...$line->meta->toArray(), 'saved_for_later' => true];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineSaved($cart, $line));
}
return $cart;
}
/**
* The reverse of saveForLater() — moves a line back into the purchasable
* cart, counted in totals again.
*/
public function moveToCart(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$meta = [...$line->meta->toArray(), 'saved_for_later' => false];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineMovedToCart($cart, $line));
}
return $cart;
}
/**
* @return array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}
*/
private function snapshotLine(CartLine $line): array
{
return [
'id' => $line->id,
'purchasable_type' => $line->purchasable_type,
'purchasable_id' => $line->purchasable_id,
'quantity' => $line->quantity,
'meta' => $line->meta->toArray(),
];
}
}
@@ -2,7 +2,7 @@
namespace Modules\Core\Catalog\Contracts;
use Filament\Forms\Components\Component;
use Filament\Schemas\Components\Component;
/**
* A Product Option Type describes how a category of Lunar `ProductOption` (e.g.
@@ -2,8 +2,8 @@
namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Schemas\Schema;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Illuminate\Support\Str;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
@@ -16,7 +16,7 @@ use Modules\Core\Catalog\Services\ProductOptionTypeManager;
*/
class ProductOptionResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
public function extendForm(Schema $schema): Schema
{
$options = collect(ProductOptionTypeManager::get()->all())
->keys()
@@ -24,11 +24,11 @@ class ProductOptionResourceExtension extends ResourceExtension
->all();
if ($options === []) {
return $form;
return $schema;
}
return $form->schema([
...$form->getComponents(),
return $schema->components([
...$schema->getComponents(),
Select::make('meta.option_type')
->label('Option Type')
->options($options)
@@ -2,7 +2,7 @@
namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\RelationManagerExtension;
use Lunar\Models\ProductOption;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
@@ -15,7 +15,7 @@ use Modules\Core\Catalog\Services\ProductOptionTypeManager;
*/
class ValuesRelationManagerExtension extends RelationManagerExtension
{
public function extendForm(Form $form): Form
public function extendForm(Schema $schema): Schema
{
/** @var ProductOption $option */
$option = $this->caller->getOwnerRecord();
@@ -23,11 +23,11 @@ class ValuesRelationManagerExtension extends RelationManagerExtension
$type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null);
if ($type === null) {
return $form;
return $schema;
}
return $form->schema([
...$form->getComponents(),
return $schema->components([
...$schema->getComponents(),
...$type->getMetaForm(),
]);
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\Checkout\Contracts;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* A boboko-owned payment driver — wraps a payment gateway's own confirmation
* mechanics (Stripe's synchronous authorize() call, a redirect-based
* provider's async callback/webhook, anything else) behind one uniform
* moment: "payment is confirmed, place the order."
*
* confirm() is the only thing a driver is required to do: once it has,
* by whatever mechanism is native to that gateway, independently decided
* the payment succeeded, it calls Modules\Core\Checkout\Services\
* CheckoutService::placeOrder($fingerprint) itself — no driver ever calls
* Lunar\Models\Cart::createOrder() directly. This is what lets the
* storefront checkout sequence stay uniform regardless of which provider is
* active: set addresses, select shipping, hand off to whichever driver is
* configured, and the driver decides when (or whether) the order actually
* gets created. See docs/checkout.md / docs/payments.md.
*/
interface PaymentDriver
{
/**
* Whether this driver can actually be used right now — e.g. Stripe
* checking its own API key is present, an offline-style driver always
* returning true since it has no external dependency. Independent of
* Modules\Core\Payment\Models\PaymentMethod::enabled (the admin
* on/off toggle) — CheckoutService::getPaymentMethods() combines both:
* a type is only offered to the storefront if it's administratively
* enabled AND its driver reports itself configured.
*/
public function isConfigured(): bool;
/**
* $type is the payment type key being confirmed (e.g. 'cash-in-hand',
* 'cash-on-delivery', 'stripe') — passed through even though most
* drivers only ever serve one type, because a driver shared across
* several types (e.g. one "no real confirmation" offline driver behind
* both cash-in-hand and cash-on-delivery) needs it to look up that
* type's own config (e.g. its 'authorized' status) rather than another
* type's.
*
* $data carries whatever the gateway needs to confirm this specific
* payment (Stripe: ['payment_intent' => $id], a redirect-based
* provider: its callback payload) — passed explicitly by the caller
* (a controller, a webhook job) rather than a driver reaching into the
* global request(), so confirm() works the same whether it's called
* from a synchronous HTTP request or an async webhook/job with no
* active request at all.
*
* @param array<string, mixed> $data
*
* @throws FingerprintMismatchException
* @throws CartException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Base\Addressable;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::setBillingAddress() — see
* ShippingAddressSet's docblock for the full reasoning (Lunar dispatches no
* checkout-lifecycle events; this feeds funnel-stage tracking, not built
* yet).
*/
class BillingAddressSet
{
public function __construct(
public readonly Cart $cart,
public readonly array|Addressable $address,
) {}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Order;
/**
* Dispatched by CheckoutService::placeOrder() the moment an Order exists —
* the handoff point between Checkout and Order (see docs/checkout.md's
* "Three-stage lifecycle"). Checkout has no opinion about what happens
* after this fires; Order's own listeners (not built yet — Order is a
* named-but-unscoped concern, same status Recovery had before it existed)
* would be what reacts to it — e.g. a confirmation email, initializing
* order status tracking.
*/
class OrderPlaced
{
public function __construct(
public readonly Order $order,
) {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::selectPaymentMethod() — carries the plain
* type key (e.g. 'cash-on-delivery', 'stripe'), same convention as
* CartService's events (a plain reference the listener resolves further
* itself, rather than an already-resolved object) since a payment type key
* has nothing further to eagerly resolve the way a ShippingOption does.
*/
class PaymentMethodSelected
{
public function __construct(
public readonly Cart $cart,
public readonly string $type,
) {}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Base\Addressable;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::setShippingAddress() — Lunar itself
* dispatches no checkout-lifecycle events at all (same gap CartService's
* events fill for cart mutations; see docs/cart.md). Feeds
* abandoned-checkout stage tracking / conversion-funnel analytics (neither
* built yet — see docs/checkout.md), which is why $address is carried
* directly rather than requiring a listener to re-read it off the cart.
*/
class ShippingAddressSet
{
public function __construct(
public readonly Cart $cart,
public readonly array|Addressable $address,
) {}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\DataTypes\ShippingOption;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::selectShippingOption() — carries the fully
* resolved ShippingOption (name, price, carrier identifier), not just the
* string identifier the caller passed in. Deliberate divergence from
* CartService's events, which carry a plain Cart/CartLine model reference —
* a live-priced carrier quote (see docs/checkout.md's note on
* ShippingManifest::getOptions() already being backed by the merged
* Shipping-Carriers ACS/Box Now live-rate drivers) is meaningfully more
* expensive for a listener to re-derive later than a CartLine reference is.
*/
class ShippingOptionSelected
{
public function __construct(
public readonly Cart $cart,
public readonly ShippingOption $option,
) {}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::selectShippingOption() when the given
* identifier doesn't resolve to a real, currently-available ShippingOption
* for the cart — Lunar's own ShippingManifest::getOption() just returns
* null, it has no matching exception type of its own to reuse here (same
* reasoning as Modules\Core\Cart\Exceptions\InvalidCouponException for
* Discounts::validateCoupon()).
*/
class InvalidShippingOptionException extends RuntimeException
{
public function __construct(public readonly string $identifier)
{
parent::__construct("The shipping option \"{$identifier}\" is not available for this cart.");
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::selectPaymentMethod()/confirmPayment() when
* $type doesn't resolve to a registered Modules\Core\Checkout\Contracts\
* PaymentDriver (config('payment.drivers')) — same reasoning as
* InvalidShippingOptionException: nothing here has a matching Lunar
* exception type to reuse, so this is the boboko-owned signal instead of a
* silent no-op or an opaque container-resolution error.
*/
class UnknownPaymentTypeException extends RuntimeException
{
public function __construct(public readonly string $type)
{
parent::__construct("The payment type \"{$type}\" is not registered.");
}
}
+248
View File
@@ -0,0 +1,248 @@
<?php
namespace Modules\Core\Checkout\Services;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Lunar\Base\Addressable;
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Events\PaymentMethodSelected;
use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Storefront-facing checkout operations, mirroring
* Modules\Core\Cart\Services\CartService's shape — one boboko-owned API a
* storefront calls, keeping Lunar's own Cart/ShippingManifest primitives an
* implementation detail. See docs/checkout.md for the full design —
* Checkout is the middle of a three-stage lifecycle (Cart → Checkout →
* Order): it owns the placement moment itself (address, shipping selection,
* placeOrder()) and ends the instant an Order exists. What happens to that
* Order afterward (status transitions, fulfillment) is deliberately out of
* scope here — see OrderPlaced's docblock.
*
* Depends on CartService for cart access rather than reaching into
* Lunar\Facades\CartSession directly a second time, so Checkout stays
* layered on top of Cart's own service boundary instead of duplicating it.
*/
class CheckoutService
{
public function __construct(
private readonly CartService $cart,
) {}
public function setShippingAddress(array|Addressable $address): Cart
{
$cart = $this->cart->currentOrCreate()->setShippingAddress($address);
Event::dispatch(new ShippingAddressSet($cart, $address));
return $cart;
}
public function setBillingAddress(array|Addressable $address): Cart
{
$cart = $this->cart->currentOrCreate()->setBillingAddress($address);
Event::dispatch(new BillingAddressSet($cart, $address));
return $cart;
}
/**
* Every shipping option currently available for the cart — already
* fully backed by the merged Shipping-Carriers work: this runs every
* registered Lunar\Shipping\Interfaces\ShippingRateInterface driver
* (ACS/Box Now live-rate quoting alongside table-rate-shipping's own
* flat-rate/free-shipping/collection drivers) through
* ShippingManifest's pipeline. No rate-resolution logic lives here —
* this is a thin pass-through.
*
* @return Collection<int, ShippingOption>
*/
public function getShippingOptions(): Collection
{
return ShippingManifest::getOptions($this->cart->currentOrCreate());
}
/**
* @throws InvalidShippingOptionException if $identifier doesn't resolve
* to a real, currently-available option for the cart
*/
public function selectShippingOption(string $identifier): Cart
{
$cartBefore = $this->cart->currentOrCreate();
$option = ShippingManifest::getOption($cartBefore, $identifier);
if ($option === null) {
throw new InvalidShippingOptionException($identifier);
}
$cart = $cartBefore->setShippingOption($option);
Event::dispatch(new ShippingOptionSelected($cart, $option));
return $cart;
}
/**
* $fingerprint is mandatory, not optional — the caller must prove the
* cart total the shopper last saw (Cart::fingerprint()) still matches
* before an order is placed. Cart::checkFingerprint() throws Lunar's own
* FingerprintMismatchException on a mismatch (a line's price changed,
* stock adjusted the total, another tab modified the cart) rather than
* silently placing an order at a different total than what was shown.
*
* Not called directly by a storefront — see confirmPayment(), which is
* the only caller and supplies the fingerprint captured in
* selectPaymentMethod(), not one the storefront has to obtain itself.
*
* No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation
* (run inside Cart::createOrder()) already throws
* Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag
* ($exception->errors()) for address/shipping-option validation and the
* duplicate-order guard — already the right shape for a storefront to
* render as form errors directly. FingerprintMismatchException
* propagates the same way, for the same reason.
*
* @throws FingerprintMismatchException
* @throws CartException
*/
public function placeOrder(string $fingerprint): Order
{
$cart = $this->cart->currentOrCreate();
$cart->checkFingerprint($fingerprint);
$order = $cart->createOrder();
Event::dispatch(new OrderPlaced($order));
return $order;
}
/**
* Every payment type currently offered to the storefront — every key
* in config('lunar.payments.types') that is BOTH administratively
* enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND
* whose registered PaymentDriver reports itself usable right now
* (PaymentDriver::isConfigured() — e.g. Stripe with no API key set is
* never offered, regardless of the enabled toggle). A type with no
* PaymentMethod row at all (never seeded) is treated as not offered,
* same as disabled — nothing here creates one; see
* InstallLunarCommand::seedPaymentMethods().
*
* @return array<string>
*/
public function getPaymentMethods(): array
{
return PaymentMethod::where('enabled', true)
->pluck('type')
->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false)
->values()
->all();
}
/**
* 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.
*
* 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 confirmPayment() that
* the shopper's reviewed total is known, and confirmPayment() 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 PaymentDriver — selecting a method and
* confirming payment against it are deliberately separate steps, same
* as selecting a shipping option happens before placing the order.
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* — see getPaymentMethods() for what that means (registered,
* administratively enabled, and its driver reports itself usable)
*/
public function selectPaymentMethod(string $type): Cart
{
if (! in_array($type, $this->getPaymentMethods(), true)) {
throw new UnknownPaymentTypeException($type);
}
$cart = $this->cart->currentOrCreate();
$cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type];
$cart->save();
$cart = $cart->calculate();
$cart->meta = [...$cart->meta->toArray(), 'checkout_fingerprint' => $cart->fingerprint()];
$cart->save();
Event::dispatch(new PaymentMethodSelected($cart, $type));
return $cart;
}
/**
* Resolves $type's registered PaymentDriver and calls confirm() —
* the driver decides whether/when the order actually gets placed (see
* Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data
* carries whatever that driver needs (Stripe's payment_intent id, a
* future redirect-based provider's callback payload).
*
* The fingerprint passed to the driver is the one captured by
* selectPaymentMethod(), not supplied by the caller — see that
* method's docblock. Throws the same FingerprintMismatchException a
* caller-supplied one would if the cart's total has since changed;
* missing entirely (selectPaymentMethod() was never called for this
* cart) is treated the same as a mismatch, not a different error.
*
* @param array<string, mixed> $data
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* (see getPaymentMethods()) — re-checked here, not just in
* selectPaymentMethod(), since a type could be disabled between
* selection and confirmation
* @throws \Lunar\Exceptions\FingerprintMismatchException
* @throws \Lunar\Exceptions\Carts\CartException
*/
public function confirmPayment(string $type, array $data = []): Order
{
if (! in_array($type, $this->getPaymentMethods(), true)) {
throw new UnknownPaymentTypeException($type);
}
$cart = $this->cart->currentOrCreate();
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data);
}
/**
* Resolves $type's registered PaymentDriver, or null if $type has no
* 'payment_driver' registered in config('lunar.payments.types.<type>')
* at all — deliberately non-throwing so getPaymentMethods() can filter
* unresolvable types silently rather than treating "not registered"
* as an error condition when just checking availability.
*/
private function resolvePaymentDriver(string $type): ?PaymentDriver
{
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
return $driverClass ? app($driverClass) : null;
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Command;
use Lunar\Admin\Models\Staff;
use Lunar\Admin\Console\Commands\MakeLunarAdminCommand;
use function Laravel\Prompts\text;
@@ -31,7 +32,7 @@ class CreateAdminCommand extends MakeLunarAdminCommand
required: true,
validate: fn (string $email): ?string => match (true) {
! filter_var($email, FILTER_VALIDATE_EMAIL) => 'The email address must be valid.',
\Lunar\Admin\Models\Staff::where('email', $email)->exists() => 'A user with this email address already exists',
Staff::where('email', $email)->exists() => 'A user with this email address already exists',
default => null,
},
),
+6 -3
View File
@@ -2,6 +2,9 @@
namespace Modules\Core\Command;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use FilesystemIterator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Modules\Core\ResultType\Error;
@@ -88,10 +91,10 @@ class ExportCommand extends Command
$zip->addFile($sqlFile, basename($sqlFile));
if (is_dir($filesDir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$filesDir,
\FilesystemIterator::SKIP_DOTS,
FilesystemIterator::SKIP_DOTS,
),
);
foreach ($iterator as $file) {
+37
View File
@@ -21,6 +21,7 @@ use Lunar\Models\TaxZone;
use Modules\Core\Localization\Models\LanguageLine;
use Modules\Core\Localization\Services\StorefrontLabels;
use Modules\Core\Localization\Services\TranslationService;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
@@ -247,6 +248,9 @@ class InstallLunarCommand extends Command
$this->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels($translations);
$this->components->info('Seeding payment method settings');
$this->seedPaymentMethods();
$this->components->info('Publishing Filament assets');
$this->call('filament:assets');
@@ -278,4 +282,37 @@ class InstallLunarCommand extends Command
$translations->create('storefront', $key, $text);
}
}
/**
* Per-type skip-if-exists, same idempotent convention as
* seedStorefrontLabels() — a type already present (including one an
* admin has since edited via the Filament Payment Methods resource) is
* left untouched. Safe to re-run after a new payment type is added to
* config('lunar.payments.types') (e.g. installing a Stripe/Nexi
* package), which is the whole reason this isn't a one-time-only seed.
*
* Seeded disabled — a newly-seeded row (whether from this store's
* initial install, or a payment provider package installed later)
* shouldn't go live for shoppers before staff have actually reviewed
* it (real credentials configured, a fee set, etc.) and turned it on
* via the Payment Methods resource. See CheckoutService::
* getPaymentMethods(), which only offers a type once both 'enabled'
* here and its driver's own isConfigured() check pass.
*/
private function seedPaymentMethods(): void
{
$existingTypes = PaymentMethod::pluck('type');
foreach (array_keys(config('lunar.payments.types', [])) as $type) {
if ($existingTypes->contains($type)) {
continue;
}
PaymentMethod::create([
'type' => $type,
'enabled' => false,
'data' => [],
]);
}
}
}
+19 -4
View File
@@ -2,26 +2,36 @@
namespace Modules\Core;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder;
use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\ProductOptionResource;
use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager;
use Lunar\Admin\Filament\Resources\OrderResource;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Models\Product;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod;
use Lunar\Shipping\ShippingPlugin;
use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
class CorePlugin implements Plugin
{
@@ -39,14 +49,20 @@ class CorePlugin implements Plugin
->login(Login::class)
->resources([
LanguageLineResource::class,
CartResource::class,
PaymentMethodResource::class,
])
->plugin(ShippingPlugin::make());
->plugin(ShippingPlugin::make())
->pages([ManagePickupManifests::class]);
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
ProductResource::class => ProductResourceExtension::class,
ProductOptionResource::class => ProductOptionResourceExtension::class,
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class,
ManageOrder::class => OrderViewExtension::class,
]);
Product::macro('reviews', function (): HasMany {
@@ -60,9 +76,8 @@ class CorePlugin implements Plugin
'password',
'remember_token',
'email_verified_at',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
'app_authentication_secret',
'app_authentication_recovery_codes',
]);
LunarStaff::created(function (LunarStaff $staff) {
@@ -2,12 +2,12 @@
namespace Modules\Core\Customer\RelationManagers;
use Filament\Forms\Components\Group;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Schemas\Components\Group;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
@@ -38,9 +38,9 @@ class AddressRelationManager extends BaseAddressRelationManager
),
])
->headerActions([
CreateAction::make()->form($this->addressForm()),
CreateAction::make()->schema($this->addressForm()),
])
->actions([
->recordActions([
EditAction::make('editAddress')
->fillForm(fn (AddressContract $record): array => [
'line_one' => $record->line_one,
@@ -51,7 +51,7 @@ class AddressRelationManager extends BaseAddressRelationManager
'contact_email' => $record->contact_email,
'contact_phone' => $record->contact_phone,
])
->form($this->addressForm()),
->schema($this->addressForm()),
DeleteAction::make('deleteAddress'),
]);
}
@@ -2,6 +2,8 @@
namespace Modules\Core\Customer\RelationManagers;
use Filament\Tables\Columns\TextColumn;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput;
use Filament\Tables;
use Filament\Tables\Table;
@@ -14,16 +16,16 @@ class UserRelationManager extends BaseUserRelationManager
public function getDefaultTable(Table $table): Table
{
return $table->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->label(__('lunarpanel::user.table.name.label')),
Tables\Columns\TextColumn::make('email')
TextColumn::make('email')
->label(__('lunarpanel::user.table.email.label')),
])->actions([
Tables\Actions\EditAction::make('edit')
])->recordActions([
EditAction::make('edit')
->after(
fn (Model $record) => CustomerUserEdited::dispatch($record)
)
->form([
->schema([
TextInput::make('email')
->label(__('lunarpanel::user.form.email.label'))
->required()
@@ -2,8 +2,17 @@
namespace Modules\Core\Localization\Filament\Resources;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Fieldset;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\ListLanguageLines;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\CreateLanguageLine;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\EditLanguageLine;
use Filament\Forms\Components\Textarea;
use Illuminate\Support\Collection;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
@@ -15,29 +24,29 @@ class LanguageLineResource extends Resource
{
protected static ?string $model = LanguageLine::class;
protected static ?string $navigationIcon = 'heroicon-o-language';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-language';
protected static ?string $navigationGroup = 'Settings';
protected static string | \UnitEnum | null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Translation';
protected static ?string $pluralModelLabel = 'Translations';
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form->schema([
Forms\Components\TextInput::make('group')
return $schema->components([
TextInput::make('group')
->required()
->maxLength(255)
->default('storefront')
->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'),
Forms\Components\TextInput::make('key')
TextInput::make('key')
->required()
->maxLength(255)
->helperText('Dot-notation key, e.g. "nav.cart".'),
Forms\Components\Fieldset::make('Translations')
Fieldset::make('Translations')
->schema(static::localeInputs()),
]);
}
@@ -46,16 +55,16 @@ class LanguageLineResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('group')
TextColumn::make('group')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('key')
TextColumn::make('key')
->searchable()
->sortable(),
...static::localeColumns(),
])
->filters([
Tables\Filters\SelectFilter::make('group')
SelectFilter::make('group')
->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')),
])
->defaultSort('key');
@@ -69,38 +78,38 @@ class LanguageLineResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListLanguageLines::route('/'),
'create' => Pages\CreateLanguageLine::route('/create'),
'edit' => Pages\EditLanguageLine::route('/{record}/edit'),
'index' => ListLanguageLines::route('/'),
'create' => CreateLanguageLine::route('/create'),
'edit' => EditLanguageLine::route('/{record}/edit'),
];
}
/**
* @return array<Forms\Components\Textarea>
* @return array<Textarea>
*/
private static function localeInputs(): array
{
return static::localeCodes()
->map(fn (string $code) => Forms\Components\Textarea::make("text.{$code}")
->map(fn (string $code) => Textarea::make("text.{$code}")
->label(strtoupper($code))
->rows(2))
->all();
}
/**
* @return array<Tables\Columns\TextColumn>
* @return array<TextColumn>
*/
private static function localeColumns(): array
{
return static::localeCodes()
->map(fn (string $code) => Tables\Columns\TextColumn::make("text.{$code}")
->map(fn (string $code) => TextColumn::make("text.{$code}")
->label(strtoupper($code))
->limit(40)
->toggleable())
->all();
}
private static function localeCodes(): \Illuminate\Support\Collection
private static function localeCodes(): Collection
{
return Language::query()->pluck('code');
}
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Actions;
use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord;
@@ -17,7 +18,7 @@ class EditLanguageLine extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->action(function (LanguageLine $record) {
app(TranslationService::class)->delete($record);
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
@@ -13,7 +14,7 @@ class ListLanguageLines extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
CreateAction::make(),
];
}
}
@@ -76,6 +76,9 @@ class StorefrontLabels
'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'],
'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'],
'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'],
'shop.price_min' => ['en' => 'Min price', 'el' => 'Ελάχιστη τιμή'],
'shop.price_max' => ['en' => 'Max price', 'el' => 'Μέγιστη τιμή'],
'shop.reset' => ['en' => 'Reset', 'el' => 'Επαναφορά'],
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\MigrateImport;
use InvalidArgumentException;
use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter;
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
@@ -15,7 +16,7 @@ class ImporterFactory
['judgeme', 'export'] => new JudgeMeExportImporter,
// ["woocommerce", "export"] => new WooCommerceExportImporter(),
// ["woocommerce", "api"] => new WooCommerceApiImporter(),
default => throw new \InvalidArgumentException(
default => throw new InvalidArgumentException(
"No importer available for source \"{$spec->source}\" with type \"{$spec->type}\".",
),
};
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\JudgeMe;
use RuntimeException;
class JudgeMeCsvReader
{
/**
@@ -12,7 +14,7 @@ class JudgeMeCsvReader
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
throw new RuntimeException("Could not open CSV file: {$csvPath}");
}
$headers = fgetcsv($handle);
@@ -2,6 +2,7 @@
namespace Modules\Core\MigrateImport\JudgeMe;
use Throwable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\Importer;
@@ -75,7 +76,7 @@ class JudgeMeExportImporter implements Importer
foreach ($urls as $url) {
try {
$review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION);
} catch (\Throwable $e) {
} catch (Throwable $e) {
Log::warning('JudgeMe import: failed to download review image', [
'review_id' => $review->id,
'url' => $url,
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\Shopify;
use RuntimeException;
class ShopifyCsvReader
{
/**
@@ -12,7 +14,7 @@ class ShopifyCsvReader
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
throw new RuntimeException("Could not open CSV file: {$csvPath}");
}
$headers = fgetcsv($handle);
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\Shopify;
use Lunar\Models\TaxClass;
use Lunar\Models\ProductOption;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
@@ -120,7 +122,7 @@ class ShopifyExportImporter implements Importer
}
/**
* @return array<int, \Lunar\Models\ProductOption>
* @return array<int, ProductOption>
*/
private function attachOptions(Product $product, array $row): array
{
@@ -146,7 +148,7 @@ class ShopifyExportImporter implements Importer
string $handle,
int $index,
array $row,
\Lunar\Models\TaxClass $taxClass,
TaxClass $taxClass,
Currency $currency,
array $options,
): void {
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Notification;
use Throwable;
use Illuminate\Support\Facades\Event;
class NotificationRegistry
@@ -44,7 +45,7 @@ class NotificationRegistry
$notification->delay($event->delaySeconds);
}
$notification->notifiable()->notify($notification);
} catch (\Throwable $e) {
} catch (Throwable $e) {
report($e);
}
});
+6 -3
View File
@@ -2,6 +2,9 @@
namespace Modules\Core\Option;
use InvalidArgumentException;
use Exception;
use RuntimeException;
use Traversable;
/**
@@ -39,7 +42,7 @@ final class LazyOption extends Option
public function __construct($callback, array $arguments = [])
{
if (!is_callable($callback)) {
throw new \InvalidArgumentException("Invalid callback given");
throw new InvalidArgumentException("Invalid callback given");
}
$this->callback = $callback;
@@ -71,7 +74,7 @@ final class LazyOption extends Option
return $this->option()->getOrCall($callable);
}
public function getOrThrow(\Exception $ex)
public function getOrThrow(Exception $ex)
{
return $this->option()->getOrThrow($ex);
}
@@ -146,7 +149,7 @@ final class LazyOption extends Option
if ($option instanceof Option) {
$this->option = $option;
} else {
throw new \RuntimeException(
throw new RuntimeException(
sprintf("Expected instance of %s", Option::class),
);
}
+4 -2
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Option;
use RuntimeException;
use Exception;
use EmptyIterator;
/**
@@ -24,7 +26,7 @@ final class None extends Option
public function get()
{
throw new \RuntimeException("None has no value.");
throw new RuntimeException("None has no value.");
}
public function getOrCall($callable)
@@ -37,7 +39,7 @@ final class None extends Option
return $default;
}
public function getOrThrow(\Exception $ex)
public function getOrThrow(Exception $ex)
{
throw $ex;
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Option;
use Exception;
use ArrayAccess;
use IteratorAggregate;
@@ -164,7 +165,7 @@ abstract class Option implements IteratorAggregate
abstract public function getOrCall($callable);
/** @return T */
abstract public function getOrThrow(\Exception $ex);
abstract public function getOrThrow(Exception $ex);
abstract public function isEmpty(): bool;
+3 -2
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Option;
use RuntimeException;
use ArrayIterator;
use Exception;
@@ -56,7 +57,7 @@ final class Some extends Option
return $this->value;
}
public function getOrThrow(\Exception $ex)
public function getOrThrow(Exception $ex)
{
return $this->value;
}
@@ -88,7 +89,7 @@ final class Some extends Option
/** @var mixed */
$rs = $callable($this->value);
if (!$rs instanceof Option) {
throw new \RuntimeException(
throw new RuntimeException(
"Callables passed to flatMap() must return an Option. Maybe you should use map() instead?",
);
}
@@ -0,0 +1,57 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Services\CheckoutService;
/**
* Shared by every payment type with no real gateway to confirm against —
* cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on
* delivery, not at checkout. confirm() has nothing to wait on, so it places
* the order immediately, same as Lunar's own OfflinePayment would, but
* through CheckoutService::placeOrder() so it goes through the same
* fingerprint check every other driver does. $data is unused: nothing about
* this confirmation depends on gateway-specific payload.
*
* Sets the order status to config("lunar.payments.types.{$type}.authorized")
* afterward, using the type actually confirmed — not a hardcoded key —
* since this one driver is shared across multiple types.
* placeOrder() itself leaves the order at Lunar's configured draft_status,
* same as every driver is responsible for moving it on from.
*/
class OfflinePaymentDriver implements PaymentDriver
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
/**
* Always true — no external dependency to be missing.
*/
public function isConfigured(): bool
{
return true;
}
/**
* @throws FingerprintMismatchException
* @throws CartException
* @throws DisallowMultipleCartOrdersException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
{
$order = $this->checkout->placeOrder($fingerprint);
$order->update([
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
]);
return $order->refresh();
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Lunar\Stripe\Actions\UpdateOrderFromIntent;
use Lunar\Stripe\Facades\Stripe;
use Lunar\Stripe\Models\StripePaymentIntent;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException;
use Stripe\PaymentIntent;
/**
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy
* Modules\Core\Checkout\Contracts\PaymentDriver — calls
* CheckoutService::placeOrder($fingerprint) at the moment Stripe confirms
* payment, instead of the vendor's own Cart::createOrder() call.
*
* This is a fork, not a decoration: StripePaymentType::authorize() is
* `final` and calls Cart::createOrder() directly with no seam to redirect
* that one call — so this class reimplements authorize()'s logic (intent
* retrieval, capture-on-policy, status mapping via UpdateOrderFromIntent)
* rather than wrapping the vendor method. Kept deliberately close to the
* original so a lunarphp/stripe upgrade is easy to diff against. See
* docs/payments.md.
*/
class StripePaymentDriver implements PaymentDriver
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
/**
* Same key lunarphp/stripe's own StripeManager reads its API key from
* (Stripe::setApiKey(config('services.stripe.key')) in
* StripeManager::__construct()) — no key, no usable driver.
*/
public function isConfigured(): bool
{
return filled(config('services.stripe.key'));
}
/**
* @throws PaymentNotConfirmedException if Stripe hasn't confirmed the
* payment intent (wrong intent id, already processed, order already
* placed, or the gateway call itself fails) — nothing here should be
* treated as "place the order anyway."
* @throws FingerprintMismatchException
* @throws CartException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
{
$paymentIntentId = $data['payment_intent'];
$paymentIntentModel = StripePaymentIntent::where('intent_id', $paymentIntentId)->first();
if ($paymentIntentModel && ! $paymentIntentModel->isActive()) {
throw new PaymentNotConfirmedException('Payment intent already processed.');
}
if (! $paymentIntentModel) {
$paymentIntentModel = StripePaymentIntent::create([
'intent_id' => $paymentIntentId,
'cart_id' => $cart->id,
]);
}
$paymentIntentModel->update(['processing_at' => now()]);
$stripe = Stripe::getClient();
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (! $paymentIntent) {
throw new PaymentNotConfirmedException('Unable to locate payment intent.');
}
$policy = config('lunar.stripe.policy', 'automatic');
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') {
$paymentIntent = $stripe->paymentIntents->capture($paymentIntentId);
}
if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) {
$paymentIntentModel->update(['status' => $paymentIntent->status]);
throw new PaymentNotConfirmedException(
$paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}."
);
}
try {
$order = $this->checkout->placeOrder($fingerprint);
} catch (DisallowMultipleCartOrdersException|CartException $e) {
throw new PaymentNotConfirmedException($e->getMessage(), previous: $e);
}
$paymentIntentModel->order_id = $order->id;
$paymentIntentModel->status = $paymentIntent->status;
$paymentIntentModel->processed_at = now();
$paymentIntentModel->save();
UpdateOrderFromIntent::execute($order, $paymentIntent);
return $order->refresh();
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Payment\Exceptions;
use RuntimeException;
use Throwable;
/**
* Thrown by a Modules\Core\Checkout\Contracts\PaymentDriver when the
* gateway has not confirmed payment — wrong/expired intent, already
* processed, or the gateway itself rejects the confirmation. A driver
* throws this instead of silently placing the order: CheckoutService::
* placeOrder() must only ever be called once a driver has positively
* confirmed payment, never as a fallback.
*/
class PaymentNotConfirmedException extends RuntimeException
{
public function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* One row per payment type key (config('lunar.payments.types')), seeded by
* InstallLunarCommand — never created/deleted here, only edited. `enabled`
* toggles inline; `data.fee` (currently the only type-specific setting, for
* cash-on-delivery's flat surcharge — see ApplyCashOnDeliveryFee) is edited
* via a modal action rather than a dedicated form field, since not every
* type has the same data keys.
*/
class PaymentMethodResource extends Resource
{
protected static ?string $model = PaymentMethod::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Payment Method';
protected static ?string $pluralModelLabel = 'Payment Methods';
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('type')
->label('Type'),
ToggleColumn::make('enabled')
->label('Enabled'),
TextColumn::make('data.fee')
->label('Fee')
->formatStateUsing(fn (?int $state) => $state
? number_format($state / 100, 2)
: '—'),
TextColumn::make('updated_at')
->label('Last updated')
->dateTime(),
])
->recordActions([
static::editFeeAction(),
])
->defaultSort('type');
}
/**
* $data['fee'] is stored as an integer minor unit (cents), matching
* Lunar's own Price convention everywhere else in this codebase — the
* form collects/displays a decimal and converts at the boundary.
*/
private static function editFeeAction(): Action
{
return Action::make('edit_fee')
->label('Edit fee')
->icon('heroicon-o-pencil')
->schema([
TextInput::make('fee')
->label('Fee')
->numeric()
->minValue(0)
->step(0.01)
->helperText('Flat surcharge added when this payment method is selected.'),
])
->fillForm(fn (PaymentMethod $record) => [
'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null,
])
->action(function (PaymentMethod $record, array $data) {
$record->update([
'data' => [
...$record->data->toArray(),
'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null,
],
]);
});
}
public static function getPages(): array
{
return [
'index' => ListPaymentMethods::route('/'),
];
}
public static function canCreate(): bool
{
return false;
}
public static function canDelete($record = null): bool
{
return false;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
class ListPaymentMethods extends ListRecords
{
protected static string $resource = PaymentMethodResource::class;
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Payment\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
/**
* Admin-editable settings for one payment type key (matching a key in
* config('lunar.payments.types')) — enabled/disabled, and whatever type-
* specific data it needs (starts with 'fee' for cash-on-delivery's flat
* surcharge). Mirrors Lunar's own Discount model: a single jsonb 'data'
* column holding keyed settings, rather than a fixed column per setting or
* a separate conditions table — new settings are a code change (a new key
* read from data), not a migration.
*
* Seeded once per type by InstallLunarCommand (skip-if-exists, same
* idempotent convention as seedStorefrontLabels()) — never auto-created on
* read, so a read path stays a pure read.
*/
class PaymentMethod extends Model
{
protected $guarded = [];
protected $casts = [
'enabled' => 'boolean',
'data' => AsArrayObject::class,
];
}
@@ -0,0 +1,31 @@
<?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);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Cart\Commands\DetectAbandonedCarts;
class CartServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([DetectAbandonedCarts::class]);
}
$this->app->booted(function () {
$this->app->make(Schedule::class)
->command(DetectAbandonedCarts::class)
->hourly();
});
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Lunar\Pipelines\Cart\ApplyShipping;
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/payment.php', 'payment');
}
public function boot(): void
{
config([
'lunar.payments.types' => array_merge(
config('lunar.payments.types', []),
config('payment.types', [])
),
]);
$cartPipeline = config('lunar.cart.pipelines.cart', []);
$insertAfter = array_search(ApplyShipping::class, $cartPipeline, true);
foreach (config('payment.cart_pipeline', []) as $pipe) {
if (in_array($pipe, $cartPipeline, true)) {
continue;
}
if ($insertAfter === false) {
$cartPipeline[] = $pipe;
} else {
array_splice($cartPipeline, $insertAfter + 1, 0, [$pipe]);
$insertAfter++;
}
}
config(['lunar.cart.pipelines.cart' => $cartPipeline]);
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Cart\Events\CartCleared;
use Modules\Core\Cart\Events\CartLineAdded;
use Modules\Core\Cart\Events\CartLineRemoved;
use Modules\Core\Cart\Events\CartLineUpdated;
use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Shipping\Carriers\Acs\AcsClient;
use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService;
use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver;
use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
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\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
return match ($params['carrier'] ?? null) {
'acs' => $app->make(AcsFulfillmentService::class),
'box-now' => $app->make(BoxNowFulfillmentService::class),
default => null,
};
});
// The vendor Rates page has no extension hook, so we swap it for
// our subclass everywhere. Route::get($path, VendorClass::class)
// instantiates the vendor class directly via the container for the
// initial full-page load (bypassing Livewire's component registry
// entirely), so this container bind is required in addition to the
// Livewire::component() re-registration below — the bind covers
// first load, the Livewire registration covers every AJAX
// round-trip (form submits, table interactions) afterwards.
$this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class);
}
public function boot(): void
{
$this->publishes([
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
], 'core-config');
Order::resolveRelationUsing('shipments', function ($order) {
return $order->hasMany(Shipment::class);
});
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
Event::listen($event, [FlushLivePricingCache::class, 'handle']);
}
// Deferred: the Shipping facade resolves a binding registered in
// lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(),
// and provider boot order between packages isn't guaranteed.
$this->app->booted(function () {
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
$this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob)
->dailyAt('06:00')
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
$this->app->make(ConsoleSchedule::class)
->job(new PollShipmentTrackingJob)
->everyThirtyMinutes();
$this->overrideRatesPageLivewireComponent();
});
}
/**
* The vendor Rates page has no extension hook, so we swap it for our
* subclass (see Shipping/Filament/Pages/ManageShippingRates). Filament
* already registered the vendor class as a Livewire component under a
* name derived from its class string (see
* Panel::registerLivewireComponents()); Livewire's own registry is a
* simple last-write-wins name => class map, so re-registering the same
* derived name against our subclass here overrides it — keeping the
* route, sub-navigation, and every Livewire round-trip (including form
* submissions) pointed at one consistent component identity.
*/
private function overrideRatesPageLivewireComponent(): void
{
$name = $this->app->make(ComponentRegistry::class)->getName(VendorManageShippingRates::class);
Livewire::component($name, ManageShippingRates::class);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Recovery\Events;
use Lunar\Models\Cart;
/**
* A cart has gone stale (no activity for config('core.cart.abandoned_after'))
* with NO order ever started — the shopper added items and never began
* checkout. Weak purchase-intent signal: usually a browsing/price-check
* action, not a near-purchase. Distinct from CheckoutAbandoned, which fires
* for a cart that DID reach checkout (a draft Order exists) but never placed
* it — a much stronger intent signal, and reachable via the email/address
* checkout itself usually captures even for a guest.
*
* Lives under Recovery, not Cart — abandonment detection/tracking is
* deliberately kept out of the Cart module entirely, including its event
* definitions, so Cart has no abandonment-related code at all. See
* docs/cart.md and docs/recovery-strategies.md.
*
* "Abandoned" is a derived state (stale updated_at), not something that
* transitions via a normal Eloquent write, so there's no natural model-event
* hook to dispatch this from directly — detection is Recovery's own concern
* (not yet built; design notes in docs/recovery-strategies.md).
*/
class CartAbandoned
{
public function __construct(
public readonly Cart $cart,
) {}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Recovery\Events;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* A cart's checkout has gone stale (no activity for
* config('core.cart.abandoned_after')) with a draft Order already created
* (Order::isDraft() — placed_at IS NULL) but never placed. Strong
* purchase-intent signal — the shopper committed to checking out, something
* blocked completion. Distinct from CartAbandoned, which fires for a cart
* with no order at all (weak intent, usually unreachable). Checkout
* typically captures an email/address even for a guest, so this state is
* normally reachable regardless of login status.
*
* Lives under Recovery, not Checkout/Cart — abandonment detection/tracking
* is deliberately kept out of both modules entirely, including its event
* definitions. See docs/cart.md and docs/recovery-strategies.md.
*
* "Abandoned" is a derived state (stale updated_at, no placed_at), not
* something that transitions via a normal Eloquent write — detection is
* Recovery's own concern (not yet built; design notes in
* docs/recovery-strategies.md).
*/
class CheckoutAbandoned
{
public function __construct(
public readonly Cart $cart,
public readonly Order $order,
) {}
}
+10 -9
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Core\ResultType;
use Modules\Core\Option\Option;
use Modules\Core\Option\None;
use Modules\Core\Option\Some;
@@ -11,7 +12,7 @@ use Modules\Core\Option\Some;
* @template T
* @template E
*
* @extends \Modules\Core\ResultType\Result<T,E>
* @extends Result<T, E>
*/
final class Error extends Result
{
@@ -39,7 +40,7 @@ final class Error extends Result
*
* @param F $value
*
* @return \Modules\Core\ResultType\Result<T,F>
* @return Result<T, F>
*/
public static function create($value): Error
{
@@ -49,7 +50,7 @@ final class Error extends Result
/**
* Get the success option value.
*
* @return \Modules\Core\Option\Option<T>
* @return Option<T>
*/
public function success()
{
@@ -63,7 +64,7 @@ final class Error extends Result
*
* @param callable(T):S $f
*
* @return \Modules\Core\ResultType\Result<S,E>
* @return Result<S, E>
*/
public function map(callable $f): Result
{
@@ -76,20 +77,20 @@ final class Error extends Result
* @template S
* @template F
*
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f
* @param callable(T):Result<S, F> $f
*
* @return \Modules\Core\ResultType\Result<S,F>
* @return Result<S, F>
*/
public function flatMap(callable $f): Result
{
/** @var \Modules\Core\ResultType\Result<S,F> */
/** @var Result<S, F> */
return self::create($this->value);
}
/**
* Get the error option value.
*
* @return \Modules\Core\Option\Option<E>
* @return Option<E>
*/
public function error(): Some
{
@@ -103,7 +104,7 @@ final class Error extends Result
*
* @param callable(E):F $f
*
* @return \Modules\Core\ResultType\Result<T,F>
* @return Result<T, F>
*/
public function mapError(callable $f): Result
{
+8 -6
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Modules\Core\ResultType;
use Modules\Core\Option\Option;
/**
* @template T
* @template E
@@ -13,7 +15,7 @@ abstract class Result
/**
* Get the success option value.
*
* @return \Modules\Core\Option\Option<T>
* @return Option<T>
*/
abstract public function success();
@@ -24,7 +26,7 @@ abstract class Result
*
* @param callable(T):S $f
*
* @return \Modules\Core\ResultType\Result<S,E>
* @return Result<S, E>
*/
abstract public function map(callable $f);
@@ -34,16 +36,16 @@ abstract class Result
* @template S
* @template F
*
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f
* @param callable(T):Result<S, F> $f
*
* @return \Modules\Core\ResultType\Result<S,F>
* @return Result<S, F>
*/
abstract public function flatMap(callable $f);
/**
* Get the error option value.
*
* @return \Modules\Core\Option\Option<E>
* @return Option<E>
*/
abstract public function error();
@@ -54,7 +56,7 @@ abstract class Result
*
* @param callable(E):F $f
*
* @return \Modules\Core\ResultType\Result<T,F>
* @return Result<T, F>
*/
abstract public function mapError(callable $f);
}
+9 -8
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Core\ResultType;
use Modules\Core\Option\Option;
use Modules\Core\Option\None;
use Modules\Core\Option\Some;
@@ -11,7 +12,7 @@ use Modules\Core\Option\Some;
* @template T
* @template E
*
* @extends \Modules\Core\ResultType\Result<T,E>
* @extends Result<T, E>
*/
final class Success extends Result
{
@@ -39,7 +40,7 @@ final class Success extends Result
*
* @param S $value
*
* @return \Modules\Core\ResultType\Result<S,E>
* @return Result<S, E>
*/
public static function create($value): Success
{
@@ -49,7 +50,7 @@ final class Success extends Result
/**
* Get the success option value.
*
* @return \Modules\Core\Option\Option<T>
* @return Option<T>
*/
public function success(): Some
{
@@ -63,7 +64,7 @@ final class Success extends Result
*
* @param callable(T):S $f
*
* @return \Modules\Core\ResultType\Result<S,E>
* @return Result<S, E>
*/
public function map(callable $f): Result
{
@@ -76,9 +77,9 @@ final class Success extends Result
* @template S
* @template F
*
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f
* @param callable(T):Result<S, F> $f
*
* @return \Modules\Core\ResultType\Result<S,F>
* @return Result<S, F>
*/
public function flatMap(callable $f)
{
@@ -88,7 +89,7 @@ final class Success extends Result
/**
* Get the error option value.
*
* @return \Modules\Core\Option\Option<E>
* @return Option<E>
*/
public function error()
{
@@ -102,7 +103,7 @@ final class Success extends Result
*
* @param callable(E):F $f
*
* @return \Modules\Core\ResultType\Result<T,F>
* @return Result<T, F>
*/
public function mapError(callable $f): Result
{
@@ -2,15 +2,15 @@
namespace Modules\Core\Review\Filament\Pages;
use Filament\Forms\Components\Group;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Group;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Carbon;
@@ -40,10 +40,10 @@ class ManageProductReviews extends BaseManageRelatedRecords
return 'Reviews';
}
public function form(Form $form): Form
public function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
TextInput::make('rating')
->label('Rating')
->disabled(),
@@ -127,12 +127,12 @@ class ManageProductReviews extends BaseManageRelatedRecords
->filters([
//
])
->actions([
->recordActions([
ViewAction::make(),
Action::make('reply')
->label(fn (ProductReview $record) => $record->reply ? 'Edit reply' : 'Reply')
->icon('heroicon-o-chat-bubble-left-right')
->form([
->schema([
Textarea::make('reply')
->label('Reply')
->required(),
@@ -146,7 +146,7 @@ class ManageProductReviews extends BaseManageRelatedRecords
}),
DeleteAction::make(),
])
->bulkActions([
->toolbarActions([
DeleteBulkAction::make(),
]);
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
class AcsArea
{
public function __construct(
public readonly string $stationId,
public readonly int $branchId,
) {}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Facades\Http;
class AcsClient
{
public function __construct(private readonly array $config) {}
public function call(string $alias, array $parameters = []): AcsResponse
{
$response = Http::withHeaders([
'AcsApiKey' => $this->config['api_key'],
])
->timeout($this->config['timeout'])
->post($this->config['base_url'], [
'ACSAlias' => $alias,
'ACSInputParameters' => array_merge($this->credentialParams(), $parameters),
]);
return AcsResponse::fromHttpResponse($response);
}
private function credentialParams(): array
{
return [
'Company_ID' => $this->config['company_id'],
'Company_Password' => $this->config['company_password'],
'User_ID' => $this->config['user_id'],
'User_Password' => $this->config['user_password'],
];
}
}
@@ -0,0 +1,203 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use RuntimeException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
{
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$destination = $this->areaResolver->resolve($address->postcode);
$weight = $request->weight ?? 0.5;
$params = [
'Pickup_Date' => now()->toDateString(),
'Sender' => config('acs.sender.name'),
'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"),
'Recipient_Address' => $address->line_one,
'Recipient_Zipcode' => $address->postcode,
'Recipient_Region' => $address->city,
'Recipient_Phone' => $address->contact_phone,
'Recipient_Country' => 'GR',
'Acs_Station_Branch_Destination' => $destination->branchId,
'Billing_Code' => config('acs.billing_code'),
'Charge_Type' => 2,
'Item_Quantity' => $request->packageCount,
'Weight' => $weight,
];
if ($request->paymentMode === 'cod') {
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal;
$params['Cod_Payment_Way'] = 0; // cash
$params['Acs_Delivery_Products'] = 'COD';
}
$response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
$voucherNo = (string) $response->valueOutput['Voucher_No'];
$shipment = Shipment::create([
'order_id' => $order->id,
'carrier' => 'acs',
'tracking_reference' => $voucherNo,
'meta' => [
'station_destination' => $destination->stationId,
'weight' => $weight,
'pickup_date' => now()->toDateString(),
],
]);
if ($request->packageCount > 1) {
$this->persistMultipartVouchers($shipment);
}
return $shipment;
}
public function printLabel(Shipment $shipment): string
{
$response = $this->client->call('ACS_Print_Voucher', [
'Voucher_No' => $shipment->tracking_reference,
'Print_Type' => 2,
'Start_Position' => 1,
])->throwIfError();
$shipment->update(['label_printed_at' => now()]);
return $response->valueOutput[$shipment->tracking_reference] ?? '';
}
public function cancelShipment(Shipment $shipment): void
{
if ($shipment->manifest_reference) {
throw new RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
}
$this->client->call('ACS_Delete_Voucher', [
'Voucher_No' => $shipment->tracking_reference,
])->throwIfError();
$shipment->update(['cancelled_at' => now()]);
}
public function pendingForManifest(): Collection
{
return Shipment::query()
->where('carrier', 'acs')
->whereNull('manifest_reference')
->whereNull('cancelled_at')
->get();
}
public function issueManifest(Collection $shipments): ManifestResult
{
$unprinted = $shipments->whereNull('label_printed_at');
if ($unprinted->isNotEmpty()) {
return ManifestResult::blocked($unprinted, 'unprinted');
}
$response = $this->client->call('ACS_Issue_Pickup_List', [
'Pickup_Date' => now()->toDateString(),
'MyData' => null,
])->throwIfError();
$pickupListNo = (string) $response->valueOutput['PickupList_No'];
$shipments->each(fn (Shipment $shipment) => $shipment->update([
'manifest_reference' => $pickupListNo,
]));
return ManifestResult::success($pickupListNo, $shipments);
}
public function trackShipment(Shipment $shipment): Collection
{
$response = $this->client->call('ACS_TrackingDetails', [
'Voucher_No' => $shipment->tracking_reference,
])->throwIfError();
$rows = $response->tableOutput['Table_Data'] ?? [];
// ACS's per-checkpoint data (checkpoint_action) is free text with no
// status code, so the final checkpoint's status is corroborated
// against the structured summary call rather than guessed from text.
$isDelivered = $this->isDelivered($shipment);
return collect($rows)->values()->map(function (array $row, int $index) use ($rows, $isDelivered) {
$isLast = $index === count($rows) - 1;
return new TrackingCheckpoint(
status: $isLast && $isDelivered
? TrackingStatus::Delivered
: $this->guessStatusFromAction($row['checkpoint_action'] ?? ''),
carrierStatus: $row['checkpoint_action'] ?? null,
message: $row['checkpoint_action'] ?? null,
location: $row['checkpoint_location'] ?? null,
occurredAt: Carbon::parse($row['checkpoint_date_time']),
meta: $row,
);
});
}
private function isDelivered(Shipment $shipment): bool
{
try {
$response = $this->client->call('ACS_Trackingsummary', [
'Voucher_No' => $shipment->tracking_reference,
])->throwIfError();
} catch (AcsApiException) {
return false;
}
return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4;
}
private function guessStatusFromAction(string $action): TrackingStatus
{
$action = strtolower($action);
return match (true) {
str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
str_contains($action, 'arrival') || str_contains($action, 'departure') => TrackingStatus::InTransit,
default => TrackingStatus::Pending,
};
}
private function persistMultipartVouchers(Shipment $mainShipment): void
{
$response = $this->client->call('ACS_Get_Multipart_Vouchers', [
'Main_Voucher_No' => $mainShipment->tracking_reference,
])->throwIfError();
foreach ($response->tableOutput['Table_Data'] ?? [] as $row) {
Shipment::create([
'order_id' => $mainShipment->order_id,
'carrier' => 'acs',
'tracking_reference' => $row['MultiPart_Voucher_No'],
'parent_reference' => $mainShipment->tracking_reference,
'meta' => $mainShipment->meta?->toArray() ?? [],
]);
}
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
{
use ResolvesFixedPricing;
use CachesLivePricing;
public ShippingRate $shippingRate;
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
) {}
public function name(): string
{
return 'ACS Courier';
}
public function description(): string
{
return 'Live rate quote from ACS Courier.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
$shippingRate = $shippingOptionRequest->shippingRate;
$shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart;
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
}
$postcode = $cart->shippingAddress?->postcode;
if (! $postcode) {
return null;
}
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
}
/**
* Wrapped in CachesLivePricing's cache so a live-pricing outage within
* the cache window still serves the last successful quote instead of
* immediately falling back. A cold cache during an outage falls back
* to the rate's own configured static price (resolveFixedPrice()) —
* see ManageShippingRates, which now allows a static price to be
* configured on a "live" rate specifically for this fallback.
*/
private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
{
return $this->cached($shippingRate, $cart, function () use ($shippingRate, $shippingMethod, $cart, $postcode) {
try {
$destination = $this->areaResolver->resolve($postcode);
$response = $this->client->call('ACS_Price_Calculation', [
'Billing_Code' => config('acs.billing_code'),
'Acs_Station_Destination' => $destination->stationId,
'Weight' => $this->totalWeightInKg($cart),
'Pickup_Date' => now()->toDateString(),
'Charge_Type' => 2,
])->throwIfError();
} catch (AcsApiException $e) {
report($e);
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
}
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: new Price($amount, $cart->currency, 1),
taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(),
meta: ['acs_station_destination' => $destination->stationId],
);
});
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
private function totalWeightInKg($cart): float
{
$weight = 0.0;
foreach ($cart->lines->load('purchasable') as $line) {
$variant = $line->purchasable;
if (! $variant || ! $variant->weight_value) {
continue;
}
$unit = $variant->weight_unit ?? 'kg';
$value = (float) $variant->weight_value;
$weight += match ($unit) {
'g' => $value / 1000,
'lb' => $value * 0.45359237,
'oz' => $value * 0.0283495231,
default => $value, // kg
} * $line->quantity;
}
return max($weight, 0.5); // ACS minimum billable weight
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Http\Client\Response;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
class AcsResponse
{
private function __construct(
public readonly bool $hasError,
public readonly ?string $errorMessage,
public readonly array $valueOutput,
public readonly array $tableOutput,
) {}
public static function fromHttpResponse(Response $response): self
{
$body = $response->json() ?? [];
// ACS's own JSON key is misspelled ("Responce") — preserved here verbatim.
$output = $body['ACSOutputResponce'] ?? [];
return new self(
hasError: (bool) ($body['ACSExecution_HasError'] ?? ! $response->successful()),
errorMessage: $body['ACSExecutionErrorMessage'] ?? null,
valueOutput: $output['ACSValueOutput'][0] ?? [],
tableOutput: $output['ACSTableOutput'] ?? [],
);
}
public function throwIfError(): self
{
if ($this->hasError) {
throw new AcsApiException(
$this->errorMessage ?? ($this->valueOutput['Error_Message'] ?? 'Unknown ACS API error'),
$this->tableOutput,
);
}
return $this;
}
}
@@ -0,0 +1,81 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
class AreaResolver
{
public const CACHE_KEY = 'acs.areas';
public function __construct(private readonly AcsClient $client) {}
/**
* Resolve a Greek postcode to its ACS station/branch codes.
*
* Reads from the table warmed daily by WarmAcsAreaCacheJob. Falls
* back to a live lookup for that single postcode if the warmed cache is
* missing (e.g. the daily job never ran or failed) or doesn't contain it.
*/
public function resolve(string $postcode): AcsArea
{
$areas = Cache::get(self::CACHE_KEY);
if ($areas !== null && isset($areas[$postcode])) {
return $this->toArea($areas[$postcode]);
}
return $this->toArea($this->fetch($postcode));
}
/**
* Fetch and cache the full country's postcode-to-station map in one call.
*/
public function warmAll(): void
{
$areas = [];
foreach ($this->fetchAll() as $row) {
$areas[$row['Zip_Code']] = $row;
}
Cache::forever(self::CACHE_KEY, $areas);
}
private function fetch(string $postcode): array
{
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
'Zip_Code' => $postcode,
'Show_Only_Inaccessible_Areas' => 0,
'Country' => 'GR',
])->throwIfError();
$area = $response->tableOutput['Table_Data'][0] ?? null;
if (! $area) {
throw new AcsApiException("No ACS area found for postcode {$postcode}");
}
return $area;
}
private function fetchAll(): array
{
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
'Zip_Code' => null,
'Show_Only_Inaccessible_Areas' => 0,
'Country' => 'GR',
])->throwIfError();
return $response->tableOutput['Table_Data'] ?? [];
}
private function toArea(array $row): AcsArea
{
return new AcsArea(
stationId: $row['Station_ID'],
branchId: (int) $row['Branch_ID'],
);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs\Exceptions;
use RuntimeException;
class AcsApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $tableOutput = [])
{
parent::__construct($message);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Shipping\Carriers\Acs\AreaResolver;
class WarmAcsAreaCacheJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function handle(AreaResolver $areaResolver): void
{
$areaResolver->warmAll();
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
class BoxNowClient
{
private const TOKEN_CACHE_KEY = 'boxnow.access_token';
public function __construct(private readonly array $config) {}
/**
* GET/POST against the main delivery/parcel API, authenticated with a
* cached Bearer token.
*/
public function request(string $method, string $path, array $payload = []): array
{
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->{$method}("{$this->config['base_url']}{$path}", $payload);
if ($response->status() === 401) {
// Token expired early / was revoked — refresh once and retry.
Cache::forget(self::TOKEN_CACHE_KEY);
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->{$method}("{$this->config['base_url']}{$path}", $payload);
}
if ($response->failed()) {
throw new BoxNowApiException(
"Box Now API error ({$response->status()}) on {$method} {$path}",
$response->json() ?? [],
);
}
return $response->json() ?? [];
}
/**
* The origins/destinations lookups are served faster from a separate
* location API host, per Box Now's own documentation.
*/
public function locationRequest(string $path, array $query = []): array
{
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->get("{$this->config['location_api_url']}{$path}", $query);
if ($response->failed()) {
throw new BoxNowApiException(
"Box Now location API error ({$response->status()}) on GET {$path}",
$response->json() ?? [],
);
}
return $response->json() ?? [];
}
/**
* Fetch raw bytes (e.g. a PDF label) rather than JSON.
*/
public function requestRaw(string $path): string
{
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->get("{$this->config['base_url']}{$path}");
if ($response->failed()) {
throw new BoxNowApiException("Box Now API error ({$response->status()}) on GET {$path}");
}
return $response->body();
}
private function token(): string
{
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(55), function () {
$response = Http::timeout($this->config['timeout'])
->post("{$this->config['base_url']}/auth-sessions", [
'grant_type' => 'client_credentials',
'client_id' => $this->config['client_id'],
'client_secret' => $this->config['client_secret'],
]);
if ($response->failed()) {
throw new BoxNowApiException('Box Now authentication failed', $response->json() ?? []);
}
return $response->json('access_token');
});
}
}
@@ -0,0 +1,150 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
/**
* Unlike ACS, Box Now has no manifest/pickup-list step — creating a
* delivery request also books the courier pickup, so this only implements
* CarrierFulfillmentInterface (not SupportsManifestBatching).
*
* Box Now delivers to lockers, not addresses. The storefront locker-picker
* is out of scope for this pass — createShipment() requires the chosen
* locker's Box Now locationId via ShipmentRequest::$destinationLocationId
* (e.g. set manually by admin staff until checkout UI exists).
*/
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
{
public function __construct(private readonly BoxNowClient $client) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$destinationLocationId = $request->destinationLocationId;
if (! $destinationLocationId) {
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
}
$isCod = $request->paymentMode === 'cod';
$response = $this->client->request('post', '/delivery-requests', [
'orderNumber' => $order->reference.'-'.$order->id,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
'paymentMode' => $isCod ? 'cod' : 'prepaid',
'amountToBeCollected' => $isCod
? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '')
: '0.00',
'origin' => [
'contactNumber' => config('boxnow.sender.phone'),
'contactEmail' => config('boxnow.sender.email'),
'contactName' => config('boxnow.sender.name'),
'locationId' => config('boxnow.origin_location_id'),
],
'destination' => [
'contactNumber' => $address->contact_phone,
'contactEmail' => $address->contact_email,
'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId,
],
'items' => [
[
'id' => (string) $order->id,
'name' => 'Order '.$order->reference,
'value' => '0.00',
'compartmentSize' => 1,
'weight' => $request->weight ?? 0,
],
],
]);
$parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
'Box Now delivery request succeeded but returned no parcel id.',
$response,
));
return Shipment::create([
'order_id' => $order->id,
'carrier' => 'box-now',
'tracking_reference' => $parcelId,
'meta' => [
'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId,
],
]);
}
public function printLabel(Shipment $shipment): string
{
$bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf");
$shipment->update(['label_printed_at' => now()]);
return $bytes;
}
public function cancelShipment(Shipment $shipment): void
{
$this->client->request('post', "/parcels/{$shipment->tracking_reference}:cancel");
$shipment->update(['cancelled_at' => now()]);
}
public function trackShipment(Shipment $shipment): Collection
{
$response = $this->client->request('get', '/parcels', [
'parcelId' => $shipment->tracking_reference,
]);
$parcel = $response['data'][0] ?? null;
if (! $parcel) {
return collect();
}
$events = $parcel['events'] ?? [];
// Fall back to a single checkpoint from the parcel's current state
// if Box Now didn't return a detailed events history.
if (empty($events)) {
$events = [[
'type' => $parcel['state'] ?? 'new',
'locationDisplayName' => null,
'createTime' => $parcel['updateTime'] ?? $parcel['createTime'] ?? now()->toIso8601String(),
]];
}
return collect($events)->map(fn (array $event) => new TrackingCheckpoint(
status: $this->mapState($event['type'] ?? $parcel['state'] ?? 'new'),
carrierStatus: $event['type'] ?? $parcel['state'] ?? null,
message: null,
location: $event['locationDisplayName'] ?? null,
occurredAt: Carbon::parse($event['createTime']),
meta: $event,
));
}
private function mapState(string $state): TrackingStatus
{
return match ($state) {
'new' => TrackingStatus::Pending,
'in-transit', 'in-depot' => TrackingStatus::InTransit,
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
'delivered' => TrackingStatus::Delivered,
'returned', 'accepted-for-return' => TrackingStatus::Returned,
'cancelled' => TrackingStatus::Cancelled,
'expired-return', 'missing' => TrackingStatus::Failed,
default => TrackingStatus::Unknown,
};
}
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
/**
* Box Now has no pricing API, so this always resolves the method's normal
* charge_by + price-break configuration — the same mechanism the built-in
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
* there is no live option to offer.
*/
class BoxNowRateDriver implements ShippingRateInterface
{
use ResolvesFixedPricing;
public ShippingRate $shippingRate;
public function name(): string
{
return 'Box Now Locker Delivery';
}
public function description(): string
{
return 'Deliver to a Box Now parcel locker.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow\Exceptions;
use RuntimeException;
class BoxNowApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $body = [])
{
parent::__construct($message);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Shipping\Concerns;
use Closure;
use Illuminate\Support\Facades\Cache;
use Lunar\DataTypes\ShippingOption;
use Lunar\Models\Cart;
use Lunar\Shipping\Models\ShippingRate;
/**
* Shared by any Modules\Core\Shipping\Contracts\SupportsLivePricing driver —
* a live carrier price quote is a real, billed API call, but stable from
* one fetch to the next within a single checkout attempt. Keyed by rate id
* + cart id, so two different live-priced rates (e.g. ACS and a future
* carrier) never collide — each is its own ShippingRate row. Not shared
* across carts: the quote depends on cart-specific weight/quantity/
* destination (see docs/checkout.md).
*
* Invalidated by Modules\Core\Shipping\Listeners\FlushLivePricingCache 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
* the shopper was quoted must still be there if anything re-reads it after
* the order exists; Cart::createOrder() persists the resolved price onto
* the order anyway, so nothing should be re-querying the live driver for
* that cart again regardless of cache state.
*/
trait CachesLivePricing
{
private function cached(ShippingRate $shippingRate, Cart $cart, Closure $resolve): ?ShippingOption
{
return Cache::remember(
"shipping.live_price.{$shippingRate->id}.{$cart->id}",
now()->addMinutes(30),
$resolve,
);
}
}

Some files were not shown because too many files have changed in this diff Show More