diff --git a/docs/scratch/analytics-feature-survey.html b/docs/scratch/analytics-feature-survey.html new file mode 100644 index 0000000..18ee9a7 --- /dev/null +++ b/docs/scratch/analytics-feature-survey.html @@ -0,0 +1,449 @@ +Analytics Feature Survey + + + +
+ +
+
boboko / analytics · competitive survey
+

What analytics elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce Analytics, and PrestaShop's + stats modules — sourced, not recalled from memory — checked against what + Lunar's admin Dashboard actually ships today and what + raw data already sits in lunar_orders/lunar_carts 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. +

+
+ ● have + ◐ partial + ○ missing +
+
+ +
+
+ 01 +

Sales & revenue dashboard

+
+

What loads the moment staff open the admin panel — this is the one area where Lunar ships more than expected.

+ +
+
Revenue / order-count stat cards with period-over-period trend
+ have +
Verified from source: OrderStatsOverview 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 Dashboard page, and boboko's panel (3dealer/app/Providers/PanelServiceProvider.php) registers the stock panel with no pages()/Dashboard override — this ships as-is.
+
+ +
+
Sales-over-time chart (revenue + order count, 12-month trend)
+ have +
OrdersSalesChart — 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.
+
+ +
+
Average order value (AOV) trend, segmented by customer group
+ have +
AverageOrderValueChart — one series per CustomerGroup plus a synthetic guest series, monthly average of sub_total over the trailing year.
+
+ +
+
New vs. returning customer split
+ have +
NewVsReturningCustomersChart reads Order::new_customer, a real boolean column set by Lunar\Jobs\Orders\MarkAsNewCustomer (true when no prior order existed for that customer at placement time) — not a cosmetic flag.
+
+ +
+
Live/latest-orders feed on the dashboard
+ have +
LatestOrdersTable — last 10 placed orders, 60s polling, reuses OrderResource's own table columns.
+
+ +
+
Real-time dashboard vs. scheduled email reports
+ partial +
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.
+
+
+ +
+
+ 02 +

Product & catalog performance

+
+

Which products are actually selling, and what's about to run out.

+ +
+
Best-sellers / top-products report
+ have +
PopularProductsTable — groups lunar_order_lines by product identifier over the trailing 12 months, ranked by quantity sold, with revenue (sub_total) alongside. Physical products only (whereType('physical')).
+
+ +
+
Per-product detail stats (views, conversion, revenue for one SKU)
+ missing +
PrestaShop's statsproduct 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.
+
+ +
+
Catalog-wide statistics (active/inactive counts, category breakdown)
+ missing +
PrestaShop's statscatalog module researched as the reference. No equivalent surface in Lunar or boboko-core — would be a straightforward aggregate over lunar_products/lunar_collections, just not built.
+
+ +
+
Inventory / stock-turnover report
+ missing +
ProductVariant::$stock is a plain point-in-time integer column — no stock-movement ledger or history table exists in lunarphp/core (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.
+
+ +
+
Low-stock / reorder alerting surfaced in a report
+ missing +
The Cart survey already noted ProductIndexer's in_stock field exists for search/listing purposes — nothing aggregates it into a "low stock" admin view or report.
+
+
+ +
+
+ 03 +

Customer analytics

+
+

Value and behavior at the level of one shopper, or a group of them.

+ +
+
Per-customer order count / average spend / lifetime spend
+ have +
Verified from source: CustomerStatsOverviewWidget on the customer view page — total orders, average spend, and total spend, computed live from orders()->sum()/average(). This is per-customer lookup, not an aggregate report across all customers.
+
+ +
+
Customer Lifetime Value (CLV) as a store-wide metric/segment
+ partial +
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.
+
+ +
+
Cohort retention analysis
+ missing +
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.
+
+
+ +
+
+ 04 +

Behavioral & funnel tracking

+
+

What happens before an order exists — the storefront side neither repo instruments at all.

+ +
+
Page-view / product-view event capture
+ missing +
Grepped both repos for gtag/dataLayer/GA4/any client-side event tracker — zero hits. No storefront event of any kind is dispatched, captured, or stored anywhere.
+
+ +
+
Conversion funnel (view → add to cart → checkout → purchase)
+ missing +
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 Order) — 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.
+
+ +
+
Abandoned-cart aggregate value/rate reporting
+ partial +
Distinct from the Cart survey's per-cart admin lookup (CartResource, 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 lunar_carts/lunar_cart_lines (same query CartResource's Abandoned tab already runs), but nothing aggregates them into a rate or a trend — it's list-only today.
+
+ +
+
Traffic-source / campaign attribution (UTM-based)
+ missing +
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 utm_source/medium/campaign at first touch, tied forward to the eventual order.
+
+
+ +
+
+ 05 +

Tax, accounting & export

+
+

Getting numbers out of boboko and into someone else's books.

+ +
+
Tax / VAT breakdown captured per order
+ have +
Verified from source: lunar_orders migration stores both tax_breakdown (JSON, per-rate detail) and tax_total as real columns on every placed order — this is genuine underlying data, not inferred.
+
+ +
+
Tax / VAT report for accounting (e.g. by tax zone, by period)
+ partial +
The per-order data above is complete enough to build this from, but nothing aggregates tax_breakdown/tax_total across orders into a filing-ready report by TaxZone or period — no such widget, page, or query exists in Lunar or boboko-core.
+
+ +
+
CSV / accounting-software export of orders or sales data
+ missing +
Grepped for Exporter/ExportAction/Excel:: across lunarphp/lunar and boboko-core's src — no hits. Filament ships export actions as a first-party feature elsewhere in the ecosystem; nothing here wires one up for orders.
+
+ +
+
Sales by channel
+ partial +
Order::channel_id is a real, always-populated foreign key (verified in the lunar_orders migration) — every order already knows its channel. No report groups by it; the dashboard's charts are all channel-blind.
+
+
+ +
+
+ 06 +

Audit trail vs. analytics

+
+

A distinction worth being explicit about, since it's easy to mistake one for the other.

+ +
+
Activity log (Spatie activitylog) on core models
+ have +
Verified from source and docs/lunar.md's Activity Logging section: Lunar\Base\Traits\LogsActivity covers Order, Cart, Product, Customer, and 15 other models, recording only dirty attributes per change under the lunar log name.
+
+ +
+
This counts as analytics
+ missing +
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.
+
+
+ + + +
diff --git a/docs/scratch/checkout-feature-survey.html b/docs/scratch/checkout-feature-survey.html new file mode 100644 index 0000000..f543775 --- /dev/null +++ b/docs/scratch/checkout-feature-survey.html @@ -0,0 +1,429 @@ +Checkout Feature Survey + + + +
+ +
+
boboko / checkout · competitive survey
+

What checkout elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's checkout + layer — sourced, not recalled from memory — checked against what + Lunar's Cart::createOrder() / order-creation pipeline + 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. +

+
+ ● have + ◐ partial + ○ missing +
+
+ +
+
+ 01 +

Getting to checkout

+
+

Who's allowed to check out, and in how many steps.

+ +
+
Guest checkout (no account required)
+ have +
Structural, not bolted-on: Order.user_id and customer_id are both nullable, and ValidateCartForOrderCreation never checks for either — it only requires a billing address and, if shippable, a shipping address + option. A cart with no user_id creates an order fine.
+
+ +
+
One-page vs. multi-step checkout
+ missing +
Pure storefront-UI concern — Lunar has no opinion here, it just exposes setShippingAddress()/setBillingAddress()/setShippingOption() 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.
+
+ +
+
Address autocomplete (type-ahead, from Google Places / Loqate)
+ missing +
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 setShippingAddress() call.
+
+ +
+
Express/accelerated checkout (Shop Pay, Apple Pay, Google Pay equivalents)
+ missing +
Research: Shopify reports Shop Pay can lift conversion up to 50% over guest checkout, mobile especially. Lunar's Payments facade is driver-based (Payments::driver('card')) 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.
+
+ +
+
Terms & conditions acceptance at checkout
+ partial +
Order.meta and Cart.meta are both free-form JSON columns carried straight through FillOrderFromCart ('meta' => $cart->meta) — technically able to record a timestamp/version of accepted terms today, but no dedicated field, checkbox validation, or admin display exists.
+
+
+ +
+
+ 02 +

Order creation mechanics

+
+

What actually happens inside createOrder(), verified from source.

+ +
+
Duplicate-order prevention on repeat submits
+ have +
Two layers, both real: Cart::draftOrder() matches on fingerprint() + total, so re-running createOrder() on an unchanged cart reuses the same draft order instead of duplicating it (CreateOrder::execute()); once an order is placed, hasCompletedOrders() throws DisallowMultipleCartOrdersException unless allowMultipleOrders is explicitly passed.
+
+ +
+
Draft order created before payment, finalized after
+ have +
Order::isDraft()/isPlaced() gate on placed_at; orders.draft_status config (default awaiting-payment) 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.
+
+ +
+
Order address, line, and shipping-line snapshotting from cart
+ have +
The whole orders.pipelines.creation chain does this explicitly — FillOrderFromCart, CreateOrderLines, CreateOrderAddresses, CreateShippingLine, CleanUpOrderLines, MapDiscountBreakdown — each copying cart state into immutable order rows rather than referencing the cart live.
+
+ +
+
Address validation before order creation
+ have +
ValidateCartForOrderCreation requires country_id, first_name, line_one, city, postcode on billing always, and on shipping too unless the chosen ShippingOption->collect is true (in-store pickup skips a shipping address).
+
+ +
+
Exchange rate and currency locked at order time
+ have +
FillOrderFromCart copies currency_code and exchange_rate from the cart's currency onto the order at creation — later currency-config changes don't retroactively alter placed orders.
+
+
+ +
+
+ 03 +

Confirmation & communication

+
+

What tells the customer (and staff) an order happened.

+ +
+
Order confirmation email on placement
+ missing +
Surprising given how close it looks to shipping: every status in config/lunar/orders.php carries a mailers and notifications array, but grep across core turns up exactly one reader of that config (Order::getStatusLabelAttribute(), and it only reads label). Nothing in core ever dispatches a mailer or notification from a status change — those keys are unwired placeholders, not a working feature.
+
+ +
+
Order-status-changed events
+ missing +
Same gap as Cart's event survey found — src/Events/ in core contains only PaymentAttemptEvent. No OrderCreated, no OrderStatusUpdated. Confirmation email, staff Slack ping, or customer SMS on status change all have to be built from scratch on plain Eloquent model events (Order::updated()), same pattern as the cart-event gap.
+
+ +
+
Order tracking / status lookup for guests
+ missing +
Research: PrestaShop's order-tracking extensions explicitly cover "non-logged-in customers track their orders." Lunar has the data (Order.reference, status, OrderAddress.contact_email) 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.
+
+ +
+
New-customer detection on first order
+ have +
CreateOrder::execute() dispatches MarkAsNewCustomer::dispatch($order->id) as a queued job after every order creation — genuinely wired, unlike the mail/notification config above.
+
+
+ +
+
+ 04 +

Abandoned checkout recovery

+
+

Distinct from abandoned cart recovery (covered in the Cart survey) — this is someone who reached address/email capture and still left.

+ +
+
Draft orders are queryable and staff-visible
+ partial +
The data exists — Order::isDraft() plus the address already captured on it — but per the Cart survey's finding, there's no Filament resource for Cart 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.
+
+ +
+
Automated recovery email (post-address-capture)
+ missing +
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 OrderAddress.contact_email, where an abandoned guest cart usually has none — but nothing sends on it.
+
+ +
+
Abandoned-checkout stage tracking (email captured vs. shipping selected vs. payment started)
+ missing +
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.
+
+
+ +
+
+ 05 +

Pricing, tax & locale at checkout

+
+

What the customer sees the moment money is on screen.

+ +
+
Tax-inclusive vs. tax-exclusive price display
+ have +
TaxZone.price_display is a first-class enum (tax_inclusive/tax_exclusive), and Price::priceExTax()/priceIncTax() both exist on the model — more complete than PrestaShop, where dual-price display is a separately-sold addon module, not core.
+
+ +
+
Full tax breakdown shown at checkout (per-line, per-rate)
+ have +
Cart.taxBreakdown and OrderLine.tax_breakdown are both populated structured objects (iterate .amounts), not just a lump-sum total — the data supports a itemized tax display, a storefront just has to render it.
+
+ +
+
Multi-currency checkout (pay in shopper's own currency)
+ have +
Currency.exchange_rate plus sync_prices per non-default currency, and the rate is snapshotted onto the order at creation (see 02) — the same mechanics PrestaShop needs an addon for.
+
+ +
+
Multi-language checkout copy
+ partial +
Product/collection/attribute copy is fully translatable via attribute_data + Language, but checkout itself — form labels, validation errors, status labels — is storefront-owned Laravel localization, not something Lunar's order pipeline touches either way.
+
+ +
+
Click-and-collect / in-store pickup as a checkout option
+ have +
ShippingOption.collect is a real boolean the validator checks directly — when true, ValidateCartForOrderCreation skips the shipping-address requirement entirely. Modeled at the same level as the collection driver in the Table Rate Shipping add-on.
+
+
+ + + +
diff --git a/docs/scratch/customer-accounts-feature-survey.html b/docs/scratch/customer-accounts-feature-survey.html new file mode 100644 index 0000000..32a9097 --- /dev/null +++ b/docs/scratch/customer-accounts-feature-survey.html @@ -0,0 +1,476 @@ +Customer Accounts Feature Survey + + + +
+ +
+
boboko / customer accounts · competitive survey
+

What customer accounts elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's + account layer — sourced, not recalled from memory — checked against what + Lunar's Customer/Address/CustomerGroup + 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. +

+
+ ● have + ◐ partial + ○ missing +
+
+ +
+
+ 01 +

Whether an account exists at all

+
+

The storefront-facing account experience, as distinct from staff/admin auth in Modules\Core\Auth.

+ +
+
Customer↔User linking (data model)
+ have +
Fully modeled by Lunar core — Customer::users() / User::customers() via customer_user pivot (LunarUser trait), plus User::latestCustomer().
+
+ +
+
Customer record auto-created on signup
+ have +
Modules\Core\Customer\Listeners\CreateCustomerForUser attaches a new Customer to every User on UserCreated, gated by config('core.auto_create_customer_for_user').
+
+ +
+
Storefront login / registration UI
+ missing +
3dealer has no auth scaffolding at all — no Breeze/Fortify/Sanctum in composer.json, no login/register views, nothing in routes/web.php. Only Modules\Core\Auth's Filament staff panel login exists.
+
+ +
+
Account/profile page (name, addresses, orders)
+ missing +
No AccountController, no account/profile route, no matching Blade views anywhere in 3dealer's app/ or resources/views — confirmed by exhaustive grep.
+
+ +
+
Account nav link in header
+ missing +
resources/views/components/header.blade.php 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 /cart, which also has no matching route, matching this codebase's known stubbed-UI pattern.
+
+
+ +
+
+ 02 +

Order history & tracking

+
+

Letting a customer see and follow their own orders without contacting support.

+ +
+
Order history data (per customer)
+ have +
Fully modeled — Customer::orders() and User::orders() both exist (Lunar\Models\Order), with status, line items, addresses, and transactions already relational.
+
+ +
+
Self-service order history / status page
+ missing +
No storefront route or controller reads Order 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.
+
+ +
+
Shipment tracking numbers surfaced to customer
+ missing +
No tracking-number field found on Order/OrderLine/shipping models in vendor/lunarphp/core; PrestaShop's tracking module patches this same gap with a third-party add-on, so it isn't a "native everywhere" bar either.
+
+ +
+
Reorder / buy-again from order history
+ missing +
Needs an order-history UI to exist first (see above) plus a "re-add these lines to cart" action — Lunar's Cart::add() already supports the mechanics, nothing wires an Order line back into a new cart.
+
+
+ +
+
+ 03 +

Saved addresses

+
+

What a returning customer doesn't have to retype.

+ +
+
Multiple saved addresses per customer
+ have +
Customer::addresses() (HasMany) — Lunar\Models\Address has no cap on count.
+
+ +
+
Separate default shipping / billing address
+ have +
Address::shipping_default and billing_default booleans; AddressObserver auto-unsets the previous default when a new one is flagged, so only one of each can be true at a time.
+
+ +
+
Self-service address book (add/edit/delete UI)
+ missing +
Only Filament's staff-facing AddressRelationManager (src/Customer/RelationManagers/AddressRelationManager.php) touches addresses today — that's an admin back-office view, not a storefront one. No customer-facing CRUD exists.
+
+ +
+
Address autocomplete / validation at entry
+ missing +
Nothing in lunarphp/core or boboko-core wires a geocoding/validation service — this is a storefront-only concern layered on top of the plain line_one…postcode fields.
+
+
+ +
+
+ 04 +

Login & identity

+
+

How a customer gets in, and how forgiving that path is.

+ +
+
Email + password login
+ missing +
No storefront auth guard/routes configured — see 01. Modules\Core\Auth\Services\OtpService/UserOtpService exist but are wired to staff/Filament login, not a customer-facing flow.
+
+ +
+
Passwordless / magic-link / OTP login
+ partial +
UserOtpService and UserOtpMail 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.
+
+ +
+
Social login (Google / Apple / Facebook)
+ missing +
No laravel/socialite in either composer.json. Shopify offers Google/Facebook sign-in and "Sign in with Shop" natively; this would be a from-scratch integration here.
+
+ +
+
Guest checkout → account conversion
+ missing +
No storefront checkout flow exists yet in 3dealer to convert from — this depends on checkout being built before it's meaningful. Lunar's Cart::user_id/customer_id nullable-until-claimed design would support it once a checkout and account UI exist.
+
+
+ +
+
+ 05 +

Payments & saved methods

+
+

Whether a returning customer can skip re-entering card details.

+ +
+
Saved payment methods on account
+ missing +
No tokenized-card storage model found in lunarphp/core 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.
+
+
+ +
+
+ 06 +

Wishlist & saved items

+
+

Keeping track of products outside the cart.

+ +
+
Wishlist / saved-for-later products
+ missing +
No wishlist model, table, or reference anywhere in src/ or vendor/lunarphp — 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.
+
+
+ +
+
+ 07 +

Groups, pricing & B2B

+
+

Where boboko is already ahead of a typical single-tenant storefront — Lunar's CustomerGroup does real work here.

+ +
+
Customer groups for differentiated pricing/visibility
+ have +
CustomerGroup model plus HasCustomerGroups trait — Product::customerGroup() scope and Price's polymorphic customer-group awareness are both real, shipped behavior, not scaffolding.
+
+ +
+
Scheduled group availability (time-boxed access)
+ have +
HasCustomerGroups::scheduleCustomerGroup() / unscheduleCustomerGroup(), backed by CanScheduleAvailability — supports a starts_at/ends_at window per group, e.g. early access for wholesale.
+
+ +
+
Multi-user company / B2B accounts
+ partial +
Customer::users()->sync([...]) already supports attaching several Users to one Customer 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.
+
+ +
+
Self-service customer-group selection at registration
+ missing +
Groups exist and are assignable (HasCustomerGroups::bootHasCustomerGroups() 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 CustomerResourceExtension.
+
+
+ +
+
+ 08 +

Loyalty, retention & data rights

+
+

Longer-tail account features — noted for completeness, not depth (data rights specifically overlaps a separate Privacy survey).

+ +
+
Loyalty / rewards points program
+ missing +
No points/loyalty model anywhere in lunarphp/core or boboko-core — Discount's BuyXGetY 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).
+
+ +
+
Self-service data export / account deletion
+ missing +
The only related tool is boboko:anonymize — a local-environment-only dev command that scrubs users/lunar_customers 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.
+
+ +
+
Subscription / recurring-order management
+ missing +
No subscription model, billing-cycle field, or recurring-cart concept found in lunarphp/core. This is WooCommerce Subscriptions/Shopify-app territory on the platforms researched too — not a core-package feature anywhere.
+
+
+ + + +
diff --git a/docs/scratch/discounts-feature-survey.html b/docs/scratch/discounts-feature-survey.html new file mode 100644 index 0000000..9147f01 --- /dev/null +++ b/docs/scratch/discounts-feature-survey.html @@ -0,0 +1,527 @@ +Discounts Feature Survey + + +
+ +
+

boboko-core · competitive spec sheet

+

What discounts & promotions elsewhere can do that boboko can’t yet

+

A feature-by-feature audit of Lunar's Discount engine against promotion tooling in Shopify, WooCommerce, and PrestaShop. Each row is graded against the underlying Lunar source, not the docs.

+
+ have + partial + missing +
+
+ +
+
+ 01 +

Core discount mechanics

+
+

The two shipped discount types and the machinery that decides whether they fire.

+ +
+
+
+ Percentage / fixed-amount off cart or line items + have +
+

Built in as Lunar\DiscountTypes\AmountOff. applyPercentage() and applyFixedValue() distribute the discount across eligible lines, tracking per-currency fixed values (data.fixed_values.{code}) so the amount is currency-aware, not a single converted number.

+
+
+
+ Buy X get Y (free or discounted) + have +
+

Built in as Lunar\DiscountTypes\BuyXGetY. Condition lines and reward lines are configured separately via discountableConditions/discountableRewards; getRewardQuantity() computes how many reward units a given condition quantity earns, with an optional max_reward_qty cap.

+
+
+
+ Coupon-code discounts + have +
+

checkDiscountConditions() compares strtoupper($cart->coupon_code) against $discount->coupon; Discounts::validateCoupon() exposes a standalone check. Coupon is cast via CouponString on the model.

+
+
+
+ Automatic (no-code) discounts + have +
+

A blank coupon column makes a discount apply to every eligible cart with no code entered — DiscountManager::getDiscounts() queries whereNull('coupon')->orWhere('coupon', '') when the cart carries no coupon code.

+
+
+
+ Minimum cart spend condition + have +
+

checkDiscountConditions() reads data.min_prices.{currency} and compares it against $lines->sum('subTotal.value'). Configurable per-currency in the admin form's "Minimum cart amount" fieldset — but only enforced by AmountOff, see row below.

+
+
+
+ Scoping to products, variants, collections, brands (incl. exclusions) + have +
+

AmountOff::getEligibleLines() filters/rejects cart lines against discountableLimitations/discountableExclusions plus collections()/brands() pivot rows typed limitation or exclusion. Configured through five separate Filament relation managers on the discount record.

+
+
+
+ +
+
+ 02 +

Timing, status, and usage limits

+
+

Whether a discount is currently live, and how hard its usage caps are enforced.

+ +
+
+
+ Scheduled / expiring discount windows + have +
+

Discount::getStatusAttribute() derives active/pending/expired/scheduled from starts_at/ends_at; the Filament table badges this status column directly (green/gray/red/blue via DiscountResource::getTableColumns()).

+
+
+
+ Global max-uses cap + have +
+

Discount::scopeUsable() filters query-side (uses < max_uses OR max_uses IS NULL) before a discount is even fetched; checkDiscountConditions() re-checks it in AmountOff. markAsUsed() increments uses and attaches the user via discount_user.

+
+
+
+ Per-user max-uses cap + partial +
+

checkDiscountConditions() calls usesByUser() only when $cart->user exists — a guest checkout cannot be capped per-customer since there's no user_id to key against, only customer_id. Wholesale/B2B carts often complete without a Laravel User attached, so the cap silently no-ops for them.

+
+
+
+ Usage/eligibility checks on Buy X Get Y + missing +
+

BuyXGetY::apply() never calls checkDiscountConditions() — 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. AmountOff::apply() calls it correctly by contrast.

+
+
+
+ +
+
+ 03 +

Multiple discounts, priority, and stacking

+
+

What happens when more than one discount could legally apply to the same cart.

+ +
+ The stop field is dead code. It's a real column, cast as boolean on the model, and it's a live toggle in the Filament admin form (DiscountResource::getStopFormComponent()) — but a repo-wide grep of both lunarphp/core and lunarphp/lunar for reads of $discount->stop outside the model and the form turns up nothing. DiscountManager::apply() is a plain unconditional foreach over every fetched discount; nothing ever breaks the loop. Staff can toggle a setting that has zero runtime effect. +
+ +
+
+
+ Priority ordering between discounts + have +
+

DiscountManager::getDiscounts() ends with orderBy('priority', 'desc')->orderBy('id'), and the admin form exposes low/medium/high (1/5/10) presets. This genuinely controls apply order.

+
+
+
+ Stopping further discounts once one applies ("exclusive" discount) + missing +
+

See callout above — stop 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 AbstractDiscountType that inspects $cart->discounts itself.

+
+
+
+ Per-class combination rules (product vs. order vs. shipping discounts) + missing +
+

Shopify models discounts as Product/Order/Shipping classes with an explicit "Combines with" toggle per pair. Lunar has no discount class concept at all — AmountOff and BuyXGetY are the only two types and neither declares a class or combination policy.

+
+
+
+ Customer-facing stacking transparency (which discounts combined, and why) + partial +
+

$cart->discountBreakdown (a collection of DiscountBreakdown 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 — it's a data structure a storefront app must build its own component against.

+
+
+
+ "Best deal wins" line-level conflict resolution + have +
+

Both AmountOff::applyFixedValue() and applyPercentage() explicitly skip a line when $line->discountTotal->value > $amount — "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.

+
+
+
+ +
+
+ 04 +

Volume, tiers, and bundles

+
+

"Buy more, save more" mechanics — and the separate pricing layer that actually implements some of them in Lunar.

+ +
+ Tiered/volume pricing exists — but it's not a Discount. PricingManager::get() filters a purchasable's Price rows for min_quantity > 1 AND $this->qty >= $price->min_quantity and picks the cheapest matching price break. This is quantity-break pricing baked into the price table itself, resolved at Pricing::for($variant)->qty($n)->get() time — it never touches the Discount model, coupon system, or discount breakdown at all. A storefront gets the discounted unit price with no visible "discount applied" line. +
+ +
+
+
+ Per-SKU quantity price breaks + have +
+

Via the Price model's min_quantity/pricing pipeline described above, not Discount. Configured directly on product variant pricing in the admin, no separate promotion object needed.

+
+
+
+ Cart-wide tiered discount ("spend $100, save 10%; spend $200, save 20%") + missing +
+

AmountOff takes one flat percentage or fixed value per discount record; there is no multi-tier threshold structure in data. Reaching this today means creating several separate Discount rows, each with its own min_prices floor, and hoping only the intended one wins (compounded by the stop gap in section 03).

+
+
+
+ Bundle / kit discount (buy this set, get a fixed bundle price) + missing +
+

No bundle or kit concept anywhere in lunarphp/core's catalog or discount models. Shopify/WooCommerce/PrestaShop all support this via dedicated bundle apps or plugins layered on the same primitive Lunar lacks — a discount keyed to a co-purchased product set rather than any single line.

+
+
+
+ Free-gift-with-purchase (a distinct SKU added free, not a percentage off an existing line) + have +
+

BuyXGetY's automatically_add_rewards flag drives processAutomaticRewards(), which inserts a brand-new CartLine for a randomly selected reward product and zeroes its price via discountTotal. $cart->freeItems tracks which purchasables were added this way.

+
+
+
+ +
+
+ 05 +

Customer targeting

+
+

Lunar has two genuinely different mechanisms here that solve overlapping-looking problems — conflating them is the easiest mistake to make.

+ +
+ CustomerGroup pricing and Discount customer-group scoping are not the same feature. Pricing::for($variant)->customerGroups($groups)->get() resolves a different base price per customer group directly from the Price table (wholesale sees $8, retail sees $10 — two rows, no discount object, no coupon, nothing to "apply"). Discount::customerGroups() is a separate pivot (customer_group_discount, via the HasCustomerGroups trait) that scopes whether a promotion is visible/enabled to a group at all, with its own starts_at/ends_at/enabled/visible 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. +
+ +
+
+
+ Differential pricing per customer group (wholesale/VIP base price) + have +
+

PricingManager::get(): $potentialGroupPrice filters Price rows with a matching customer_group_id and picks the cheapest; falls back to $basePrice when no group price exists.

+
+
+
+ Restricting a discount/coupon to specific customer groups + have +
+

DiscountManager::getDiscounts() applies ->customerGroup($this->customerGroups) via the shared HasCustomerGroups trait's scopeCustomerGroup(), configured on the discount's own "Availability" sub-page (ManageDiscountAvailability) alongside channel restriction.

+
+
+
+ Restricting a discount to specific named customers + have +
+

Discount::customers() pivot (customer_discount), checked in checkDiscountConditions(): if the discount has any tied customers, a cart without a matching customer_id fails eligibility outright. Managed via CustomerLimitationRelationManager in the admin.

+
+
+
+ First-purchase / welcome discount + missing +
+

Lunar does compute an order-level new_customer boolean (Jobs\Orders\MarkAsNewCustomer, ! $previousOrder) — but it's a post-order reporting flag surfaced only in the Filament order table/dashboard chart. Nothing reads it during ApplyDiscounts; there's no "is this customer's first order" condition available to a Discount at checkout time.

+
+
+
+ Referral discounts (reward both referrer and referee) + missing +
+

No referral concept anywhere in lunarphp/core or lunarphp/lunar — 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 Discount::customers() at best.

+
+
+
+ Loyalty points redeemable as a discount + missing +
+

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 — same gap here, but Lunar offers no AbstractDiscountType hook obviously suited to "redeem N points" either, since discount eligibility has no notion of a spendable balance.

+
+
+
+ +
+
+ 06 +

Extensibility

+
+

What it takes to reach a feature Lunar doesn't ship, without forking the package.

+ +
+
+
+ Registering a custom discount type + have +
+

Discounts::addType(MyType::class) appends to DiscountManager::$types (seeded with just AmountOff::class, BuyXGetY::class). A new type extends AbstractDiscountType and implements apply(CartContract $cart) — the same contract the two built-ins use, so it participates in the same unconditional-foreach loop from section 03.

+
+
+
+ Admin UI for a custom discount type + partial +
+

Requires additionally implementing Lunar\Admin\Base\LunarPanelDiscountInterface (lunarPanelSchema()/lunarPanelOnFill()/lunarPanelOnSave()) for DiscountResource::getDefaultForm() 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 AmountOff/BuyXGetY, which are hard-coded into the form rather than using the interface themselves.

+
+
+
+ + + +
diff --git a/docs/scratch/payments-feature-survey.html b/docs/scratch/payments-feature-survey.html new file mode 100644 index 0000000..cb29969 --- /dev/null +++ b/docs/scratch/payments-feature-survey.html @@ -0,0 +1,448 @@ +Payments Feature Survey + + + + + + + +
+ +
+

boboko-core · competitive gap survey · 03

+

What payments elsewhere can do that boboko can't yet

+

+ Lunar's payment layer (Lunar\Facades\Payments, Transaction, 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. +

+
+ 4 have + 9 partial + 14 missing +
+
+ +
+
+ 01 +

Payment method breadth

+
+

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.

+
+ +
+
Offline / pay-on-accounthave
+
The only configured type in config/lunar/payments.php (3dealer's published copy): 'cash-in-hand' => ['driver' => 'offline', 'authorized' => 'payment-offline'], backed by Lunar\PaymentTypes\OfflinePayment.
+
+ +
+
Card payments (Stripe/other gateway)missing
+
lunarphp/stripe is not present in either boboko-core/vendor/lunarphp or 3dealer/vendor/lunarphp, and not listed in either composer.json. docs/lunar.md's Stripe section documents Lunar's general capability, not something wired into this project.
+
+ +
+
Digital wallets (Apple Pay, Google Pay, Shop Pay)missing
+
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.
+
+ +
+
Buy-now-pay-later (Klarna, Afterpay, Affirm)missing
+
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.
+
+ +
+
Bank transfer / open banking (SEPA, Pay by Bank)missing
+
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.
+
+ +
+
Crypto / stablecoin checkoutmissing
+
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.
+
+ +
+
Pluggable driver architecture for adding methodshave
+
Lunar\Managers\PaymentManager extends Laravel's Manager; Payments::extend('custom', fn ($app) => ...) registers a new driver, and any class extending Lunar\PaymentTypes\AbstractPayment implementing authorize()/capture()/refund() plugs in. The scaffolding is solid — nothing beyond offline is plugged into it yet.
+
+ +
+
+ +
+
+ 02 +

Capture, refund & transaction lifecycle

+
+

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.

+
+ +
+
Authorize / capture / refund contracthave
+
Lunar\Base\PaymentTypeInterface defines authorize(), capture(Transaction $t, $amount), refund(Transaction $t, int $amount, $notes); Transaction::capture()/refund() forward to the transaction's own driver() via Payments::driver($this->driver).
+
+ +
+
Manual vs. automatic capture policypartial
+
The interface supports separate authorize/capture steps (intent vs. capture transaction types), but OfflinePayment::capture() just returns new PaymentCapture(true) unconditionally — there's no real deferred-capture gateway wired up to exercise the distinction.
+
+ +
+
Partial capturepartial
+
Admin Filament action passes an arbitrary $data['amount'] to $transaction->capture(bcmul($data['amount'], $record->currency->factor)) in ManageOrder.php — the plumbing supports partial amounts, but only staff can trigger it, and only against a real (non-offline) driver would it mean anything.
+
+ +
+
Partial / staged refundshave
+
Same file: the "refund" Filament action computes $response = $transaction->refund(bcmul($data['amount'], ...), $data['notes']), and isPartiallyRefunded() / order status logic (partial-refund, refunded) compares refundTotal against captureTotal/intentTotal. This genuinely works today through the offline driver's no-op refund().
+
+ +
+
Multiple payment attempts per orderpartial
+
Transaction.parent_transaction_id 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.
+
+ +
+
Transaction audit trailhave
+
Lunar\Observers\TransactionObserver::created() 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.
+
+ +
+
Webhook handling for async payment eventsmissing
+
Lunar's Stripe package registers a stripe/webhook route, but that package isn't installed here, so there is no webhook endpoint of any kind in this project today.
+
+ +
+
Payment attempt events for downstream hookshave
+
Lunar\Events\PaymentAttemptEvent is dispatched from OfflinePayment::authorize() with the resulting PaymentAuthorize DTO — a real, listenable event, though only one driver currently fires it.
+
+ +
+
+ +
+
+ 03 +

Customer-facing payment experience

+
+

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.

+
+ +
+
Saved payment methods on customer accountmissing
+
No vault/tokenization model exists anywhere in Lunar\Models — no PaymentMethod/Card model, no field on Customer. 2026 trend research (Nuvei, Checkout.com) treats network-tokenized saved cards as baseline for one-click checkout.
+
+ +
+
One-click repeat purchasemissing
+
Depends on saved payment methods, which don't exist. No "reorder" or "buy again" affordance found in boboko-core or 3dealer.
+
+ +
+
Customer self-service refund requestsmissing
+
The only refund entry point is the Filament staff action in ManageOrder.php (Actions\Action::make('refund')), gated behind admin auth. WooCommerce/PrestaShop ecosystems commonly expose a customer-initiated return/refund request flow; nothing equivalent exists here.
+
+ +
+
Split / partial payment plans (pay-in-installments at checkout)missing
+
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.
+
+ +
+
3D Secure / SCA authenticationmissing
+
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.
+
+ +
+
Fraud detection / risk scoringmissing
+
No fraud-scoring hook in PaymentTypeInterface or the offline driver. getPaymentChecks() exists as an extension point (Lunar\Base\DataTransferObjects\PaymentChecks, an iterable of pass/fail PaymentCheck DTOs) but AbstractPayment::getPaymentChecks() just returns an empty collection — real fraud tooling (Stripe Radar-style) isn't behind it.
+
+ +
+
Payment check / validation extension pointpartial
+
Transaction::paymentChecks() → driver's getPaymentChecks($transaction) 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.
+
+ +
+
+ +
+
+ 04 +

Currency, subscriptions & recurring billing

+
+

Lunar's multi-currency model covers pricing display, not multi-currency payment settlement; recurring billing/dunning has no representation at all.

+
+ +
+
Multi-currency pricing displayhave
+
Lunar\Models\Currency (code, exchange_rate, decimal_places, default) with sync_prices-gated conversion, documented in docs/lunar.md "Channels and Currencies" — this is genuinely wired, cart/pricing layer already uses it.
+
+ +
+
Multi-currency payment processing (charge in customer's currency)partial
+
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.
+
+ +
+
Recurring billing / subscriptionsmissing
+
No subscription model, no recurring-charge scheduler anywhere in Lunar\Models or boboko-core. This is a one-time-purchase order/cart model end to end.
+
+ +
+
Failed-payment retry / dunningmissing
+
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.
+
+ +
+
PCI compliance / tokenized card storagemissing
+
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.
+
+ +
+
+ + + +
diff --git a/docs/scratch/privacy-feature-survey.html b/docs/scratch/privacy-feature-survey.html new file mode 100644 index 0000000..a5dfe97 --- /dev/null +++ b/docs/scratch/privacy-feature-survey.html @@ -0,0 +1,492 @@ +Privacy Feature Survey + + + +
+ +
+
boboko / privacy & compliance · competitive survey
+

What privacy & compliance elsewhere can do that boboko can't yet

+

+ 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 master and the substantial, + unmerged Privacy branch ("Feature: Creating Privacy + Basics") already built in this repo. For deciding what to finish and merge + next, not a build order. +

+
+ ● have + ◐ partial + ○ missing +
+
+ Most "partial" rows below are fully coded on the unmerged Privacy + branch (53 files, +3127/‑24 across two commits: 9f540cb, + 59303cf) but not on master — treated as partial, not + have, until it merges. boboko:anonymize is the one privacy-adjacent + command that already lives on master today. +
+
+ +
+
+ 01 +

Right of access & erasure

+
+

GDPR Art. 15 (access) and Art. 17 (erasure) — the two rights every DSAR tool is built around.

+ +
+
Data export request (right of access)
+ partial +
On Privacy branch only: PrivacyService::requestExportForCustomer()/requestExportForUser() queue ExportDataSubjectJob, which gathers every registered provider's data and writes a CSV-per-provider zip via WriteExportToCsvListener. Not on master.
+
+ +
+
Data erasure request (right to be forgotten)
+ partial +
On Privacy branch only: PrivacyService::requestErasureForCustomer()/requestErasureForUser(), extensible via config('core.privacy.providers') — the same config-array-registration pattern as NotificationRegistry, keyed off Modules\Core\Privacy\Contracts\PersonalDataProvider.
+
+ +
+
Cancellable grace period before erasure
+ partial +
On Privacy branch only: 30-day default (core.privacy.grace_period_days), reverted automatically on login via CancelErasureOnLoginListener — 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.
+
+ +
+
Immediate erasure for regulator/legal requests
+ partial +
On Privacy branch only: requestImmediateErasureForCustomer()/ForUser(), typed to accept only Staff $requestedBy so a self-service path cannot reach it even by accident.
+
+ +
+
Multi-tenant erasure scoping (business account vs. individual login)
+ partial +
On Privacy branch only, and a genuinely uncommon feature: PrivacyService splits every operation into Customer-scope vs. User-scope, plus a sole-owner cascade (CascadeCustomerErasureListener) when erasing the last linked User orphans a Customer. No researched competitor product handles B2B multi-seat erasure this explicitly.
+
+ +
+
Right to rectification (self-service data correction)
+ missing +
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.
+
+ +
+
Dummy data anonymization for local dev
+ have +
On master: src/Command/AnonymizeCommand.php (boboko:anonymize) — scrubs users/lunar_customers, environment-guarded to local only. Distinct from GDPR erasure; the Privacy branch README diff explicitly flags this is not the compliance tool.
+
+
+ +
+
+ 02 +

Anonymization, pseudonymization & retention

+
+

Deletion isn't the only lawful outcome — these are three different operations, often confused with each other.

+ +
+
Legal-retention pseudonymization (orders/invoices)
+ partial +
On Privacy branch only: OrderDataProvider::eraseForCustomer() clears PII fields but keeps order rows/totals/tax data intact, citing GDPR Art. 17(3)(b)'s legal-obligation exception — reports ErasureOutcome::Pseudonymized, not Erased, distinctly.
+
+ +
+
Per-provider retention policy, owned by the data's own module
+ partial +
On Privacy branch only: PersonalDataProvider deliberately has no central taxonomy — each provider (CustomerDataProvider, AddressDataProvider, OrderDataProvider, CartDataProvider, ReviewDataProvider) decides erase vs. pseudonymize vs. skip for its own table. docs/privacy.md flags ReviewDataProvider's scope choice as needing review before relying on it.
+
+ +
+
Automatic data retention / auto-deletion after N days
+ missing +
Neither branch has a scheduled sweep that erases stale data on its own — every erasure on the Privacy 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").
+
+ +
+
Audit trail of what was erased/exported and why
+ partial +
On Privacy branch only: DataErasureRequest.report 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.
+
+
+ +
+
+ 03 +

Consent & cookies

+
+

What a visitor is asked before tracking starts, and whether that choice is recorded anywhere.

+ +
+
Cookie consent banner (categorized: essential/analytics/marketing)
+ missing +
No code on either branch. Shopify ships a first-party Customer Privacy API recognizing four consent signals (analytics, marketing, preferences, sale-of-data); WooCommerce relies entirely on third-party plugins for this.
+
+ +
+
Granular marketing-consent tracking (email/SMS opt-in, per channel)
+ missing +
Not modeled anywhere in Modules\Core — no consent flag found on the Customer/User models on either branch.
+
+ +
+
Timestamped, versioned consent log (audit trail per visitor)
+ missing +
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 Privacy branch's audit trail covers erasure/export requests only, not consent events.
+
+ +
+
Google Consent Mode v2 / IAB TCF v2.3 integration
+ missing +
Storefront/analytics-layer concern, not present in boboko-core at all — would live in the 3dealer storefront, not this package.
+
+
+ +
+
+ 04 +

Policy & agreement management

+
+

Terms of service and privacy policy as tracked, versioned documents — not just static pages.

+ +
+
Terms-of-service / privacy-policy versioning
+ missing +
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.
+
+ +
+
Per-user acceptance tracking (clickwrap audit trail)
+ missing +
No record of "which policy version did this customer accept, and when" anywhere in Modules\Core. Researched as a standard requirement for surviving a legal dispute or regulatory inquiry.
+
+ +
+
Re-acceptance prompt on material policy change
+ missing +
Depends on the versioning row above existing first — nothing to gate a re-prompt on today.
+
+
+ +
+
+ 05 +

Payment data & PCI-DSS scope

+
+

Whether cardholder data ever actually reaches boboko's own infrastructure.

+ +
+
Card data never touches application servers (tokenization)
+ have +
Verified from source: docs/lunar.md "Stripe integration" — payment flows through Lunar's Stripe driver (Lunar\Stripe\Facades\Stripe, fetchOrCreateIntent()/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.
+
+ +
+
Self-attested SAQ-A eligibility documentation
+ missing +
The technical precondition (no card data touching the server) is met, but nothing in docs/ documents or asserts SAQ-A eligibility for a consuming app's own compliance paperwork.
+
+
+ +
+
+ 06 +

Regional & regulatory coverage

+
+

Beyond GDPR — the other regimes a storefront selling outside the EU may need.

+ +
+
CCPA "Do Not Sell/Share My Info" opt-out
+ missing +
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.
+
+ +
+
Geo-targeted regulatory detection (GDPR vs. CCPA vs. LGPD banner)
+ missing +
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.
+
+ +
+
Age verification / minor-data restrictions (COPPA-adjacent)
+ missing +
No age gate or minor-specific data handling found on either branch.
+
+
+ +
+
+ 07 +

Incident & vendor accountability

+
+

What happens when something goes wrong, or when a third party is handling data on the shop's behalf.

+ +
+
Data breach notification workflow
+ missing +
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.
+
+ +
+
Subprocessor / third-party vendor disclosure list
+ missing +
No subprocessor registry in code — Stripe is the one third-party data processor identifiable from docs/lunar.md, but nothing formally tracks or discloses it as a subprocessor.
+
+ +
+
Data processing agreement (DPA) tracking per vendor
+ missing +
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.
+
+
+ + + +
diff --git a/docs/scratch/products-collections-feature-survey.html b/docs/scratch/products-collections-feature-survey.html new file mode 100644 index 0000000..239aa6d --- /dev/null +++ b/docs/scratch/products-collections-feature-survey.html @@ -0,0 +1,508 @@ +Products & Collections Feature Survey + + + +
+ +
+
boboko / products & collections · competitive survey
+

What products & collections elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and general + 2026 storefront UX trends — checked against what + Modules\Core\Catalog 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. +

+
+ Read this first: 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 (ProductService::facets(), + priceRange(), list()'s sort param) already exist and + work; nothing in CategoryController passes them through yet. +
+
+ ● have + ◐ partial + ○ missing +
+
31 features surveyed — 8 have · 10 partial · 13 missing
+
+ +
+
+ 01 +

Core listing & filtering plumbing

+
+

The Meilisearch-backed layer everything else in this survey sits on top of — this is where most of this session's real build lives.

+ +
+
Paginated product listing, index-backed (not DB reads)
+ have +
ProductService::list() reads Product::search('') via Meilisearch and returns a real LengthAwarePaginator — used end-to-end by CategoryController::show() and rendered by x-product-grid.
+
+ +
+
Filter by collection (including descendant collections)
+ have +
ProductFilters::collectionId matches ProductIndexer's collection_ids 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 CategoryController.
+
+ +
+
Filter by brand, price range, stock status
+ partial +
ProductFilters supports brand, minPrice/maxPrice, inStockOnly, fully implemented in ProductService::buildFilter() — but category/show.blade.php's price slider and in-stock checkbox are hardcoded markup with no form submission; CategoryController never constructs a ProductFilters with any of these three.
+
+ +
+
Faceted counts for a filter sidebar (brand, stock, etc.)
+ partial +
ProductService::facets() returns value→count via Meilisearch facetDistribution, correctly scoped to co-applied filters — but nothing storefront-side calls it. No brand/attribute facet list renders anywhere in category/show.blade.php.
+
+ +
+
Price-range slider backed by real min/max
+ partial +
ProductService::priceRange() reads Meilisearch facetStats for a correct, filter-scoped min/max — the sidebar instead shows a static "€10 - €50" label with a non-functional apply button.
+
+ +
+
Sort (price asc/desc, newest)
+ partial +
ProductSort enum + ProductIndexer::getSortableFields() (price, created_at) work end-to-end in ProductService::list(sort: ...) — the storefront's sort <select> is explicitly commented {{-- Dummy — not wired to real sorting yet --}} and includes a "popularity" option with no backing signal at all.
+
+ +
+
Free-text product search
+ partial +
ProductSearchService::search() is a complete, locale-aware, fallback-safe implementation (attributesToSearchOn targeting current + default locale) — but no search route exists in 3dealer (routes/web.php only has product.show/category.show), and both the header search icon and the sidebar search box are inert buttons/inputs.
+
+ +
+
Single-product lookup by slug or id, index-only
+ have +
ProductService::getById()/getBySlug(), both zero-database-read lookups against the slugs/id filterable fields. ProductController::show() uses getById() directly.
+
+
+ +
+
+ 02 +

Collections & navigation

+
+

Category tree browsing, breadcrumbs, and merchandising — what turns a flat product list into a navigable store.

+ +
+
Category tree browsing (root / children / by group)
+ have +
CollectionService::list() with CollectionFilters(rootOnly/parentId/groupId), backed by CollectionIndexer's nested-set parent_id/_lft fields — no database read needed to build a nav tree.
+
+ +
+
Top-nav category dropdown
+ have +
components/header.blade.php renders a CSS-only hover dropdown from a $categories list passed into the layout, linking to category.show.
+
+ +
+
Breadcrumb navigation
+ partial +
CollectionIndexer indexes a full root-first ancestors array ({id, name}) specifically so a breadcrumb needs zero extra queries — but category/show.blade.php and product/show.blade.php both build a flat two-level x-breadcrumb (Home → this category/product) by hand, never reading ancestors. A product under a three-deep category shows no intermediate levels.
+
+ +
+
Category landing page merchandising (banner, pinned/featured products)
+ missing +
category/show.blade.php renders only the collection name/description above a plain product grid — no banner image field, no "featured in this category" pinning above organic results. CollectionIndexer's thumbnail field exists but isn't read on the category page at all (only used, if anywhere, for nav-level imagery).
+
+ +
+
Sub-category faceting (filter by attribute within a category)
+ missing +
No attribute-value facet (size, material, etc.) is indexed as filterable on ProductIndexer beyond brand and in_stock — 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.
+
+ +
+
Product count shown per category
+ partial +
CollectionIndexer computes product_count (including descendant collections) at index time by querying the product index directly — correct and cheap, but nothing in category/show.blade.php or the nav dropdown displays it.
+
+
+ +
+
+ 03 +

Product detail page

+
+

What a shopper sees once they land on a single product — media, variants, reviews, cross-sell.

+ +
+
Multi-image gallery with lightbox
+ have +
product/show.blade.php's product-gallery Stimulus controller — thumbnail rail, main image, full popover lightbox with prev/next/counter — fed from ProductIndexer's full media array (not just a single thumbnail).
+
+ +
+
Variant selection via color swatches
+ have +
End-to-end: ColorOptionType lets an admin attach a hex code to an option value → ProductIndexer::mapVariant() embeds meta.hex per variant → x-ui.color-swatch renders real swatch buttons wired to a product-form Stimulus controller that swaps price/image on selection.
+
+ +
+
Swatches for non-color attributes (pattern, texture, material)
+ partial +
The ProductOptionTypeInterface system is explicitly built to be extensible — a PatternOptionType or MaterialOptionType is a new class plus a Filament form, no core change needed — but only ColorOptionType is registered, and x-ui.color-swatch itself hardcodes a background-color swatch, not a generic swatch renderer.
+
+ +
+
Customer reviews with ratings, photos, staff replies
+ have +
ProductReview model, fully indexed (items/count/average_rating, PII-safe), live-reindexed on review create/update/delete via ReviewServiceProvider, and rendered in product/show.blade.php's Reviews tab with x-review-card/x-review-form.
+
+ +
+
Structured data / schema.org Product markup
+ missing +
No application/ld+json or itemscope 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.
+
+ +
+
Related products / "customers also bought" / cross-sell
+ missing +
Raw Lunar already models this (Lunar\Base\Enums\ProductAssociation::CROSS_SELL/UP_SELL/ALTERNATE, $product->associate()/associations() — see docs/lunar.md "Products and Variants") but nothing in Modules\Core\Catalog surfaces it, and the "Σχετικά προϊόντα" block at the bottom of product/show.blade.php is four fully hardcoded fake products with href => '#'.
+
+ +
+
Recently-viewed products
+ missing +
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.
+
+ +
+
Product badges (new / sale / bestseller)
+ missing +
No badge concept on ProductIndexer's document and no badge markup on x-ui.product-card — would need either a computed signal (e.g. "new" from created_at, "sale" from compare_price already indexed per-variant) or an admin-set tag, neither wired to a visual badge today.
+
+ +
+
Size chart / fit guide
+ missing +
No size-chart content field on Product/ProductType 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.
+
+ +
+
Stock notification ("notify me when back in stock")
+ missing +
in_stock is indexed and known per-product (ProductIndexer::toSearchableArray()), 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.
+
+ +
+
Product Q&A section
+ missing +
No question/answer model anywhere in core — only the separate review system (ProductReview) exists, which is a distinct concept (post-purchase rating, not pre-purchase Q&A).
+
+
+ +
+
+ 04 +

Emerging discovery & merchandising UX

+
+

2026 trend-adjacent features, mostly backed on other platforms by paid apps/plugins rather than core — useful for calibrating how unusual these gaps are.

+ +
+
Quick-view modal (preview from listing grid, no page load)
+ missing +
x-ui.product-card links straight to product.show 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.
+
+ +
+
Infinite scroll as an alternative to pagination
+ missing +
category/show.blade.php uses classic x-ui.pagination against the real paginator from ProductService::list() — 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.
+
+ +
+
Product comparison tool (side-by-side spec table)
+ missing +
Not in boboko-core, and notably not native on Shopify or WooCommerce either — both rely on third-party apps (Bear Specs & Compare, Equate, WooCommerce's own paid "Advanced Product Comparison" extension). A real gap, but not one competitors solve in-platform for free.
+
+ +
+
Product bundles / kits
+ missing +
No bundle/kit concept (a purchasable grouping of several variants as one line item) anywhere in Lunar\Models\Product/ProductVariant or Modules\Core\Catalog.
+
+ +
+
360°/video product media, AR try-on
+ partial +
ProductIndexer's media array is just Spatie media-library images (url/thumb) — no video or 360° asset type modeled, and no AR integration. The gallery component (product-gallery Stimulus controller) is generic enough to extend to a video slide without a rewrite, but nothing does today.
+
+ +
+
Variant-specific SEO URLs (distinct slug per color/size)
+ partial +
Lunar's HasUrls/Url model supports per-locale slugs per product (indexed in ProductIndexer's slugs field), but there's no per-variant URL — selecting a color swatch changes displayed price/image via product-form client-side state, not the URL, so a specific variant can't be linked or indexed separately.
+
+
+ + + +
diff --git a/docs/scratch/shipping-feature-survey.html b/docs/scratch/shipping-feature-survey.html new file mode 100644 index 0000000..ccccbc7 --- /dev/null +++ b/docs/scratch/shipping-feature-survey.html @@ -0,0 +1,465 @@ +Shipping Feature Survey + + + +
+ +
+
boboko / shipping · competitive survey
+

What shipping elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's shipping + layer — sourced, not recalled from memory — checked against what + Lunar core's ShippingManifest and the + lunarphp/table-rate-shipping 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. +

+
+ ● have + ◐ partial + ○ missing +
+
+ +
+
+ 01 +

Core plumbing

+
+

The mechanism Lunar core provides for offering and applying a shipping charge — everything else in this survey is built on top of it.

+ +
+
Pluggable shipping option providers
+ have +
Lunar\Base\ShippingModifier abstract class + ShippingManifest::addOption() — any package can register options onto the manifest via a pipeline of modifiers (ShippingModifiers::getModifiers()).
+
+ +
+
Shipping applied to cart totals during calculate()
+ have +
Lunar\Pipelines\Cart\ApplyShipping — reads ShippingManifest::getShippingOption($cart) or a manual shippingOptionOverride, writes a ShippingBreakdown and shippingSubTotal onto the cart before CalculateTax runs.
+
+ +
+
Cart-level shippable check
+ have +
Cart::isShippable() — true if any line's purchasable (e.g. ProductVariant::isShippable()) is shippable; a digital-only cart skips the shipping-address requirement entirely.
+
+ +
+
Selecting a shipping option on the cart
+ have +
Cart::setShippingOption() → SetShippingOption action, validated by ShippingOptionValidator, triggers a recalculate. Nothing in 3dealer's storefront calls it yet — no shipping step exists in the UI.
+
+ +
+
Order-time shipping line snapshot
+ have +
Lunar\Pipelines\Order\Creation\CreateShippingLine writes an immutable shipping-type order line from the cart's shipping breakdown at checkout — survives later rate changes.
+
+
+ +
+
+ 02 +

Rate configuration (table-rate-shipping add-on)

+
+

lunarphp/table-rate-shipping is installed (composer.json, pinned ^1.3) and its ShippingPlugin is registered in CorePlugin::boot() — so 3dealer inherits it automatically, it doesn't need its own registration.

+ +
+
Geographic shipping zones (country / state / postcode)
+ have +
ShippingZone model, type unrestricted|countries|states|postcodes; ShippingZoneResolver::get() matches a cart's address against zone scope, falling back to any unrestricted zone.
+
+ +
+
Flat-rate shipping
+ have +
Drivers\ShippingMethods\FlatRate::resolve() — one price per cart subtotal via Pricing::for($shippingRate).
+
+ +
+
Weight- or total-tiered rates ("ship by")
+ have +
Drivers\ShippingMethods\ShipBy::resolve() — data['charge_by'] is cart_total or weight, tiered via priceBreaks, with customer-group price overrides taking priority.
+
+ +
+
Free-shipping threshold
+ have +
Drivers\ShippingMethods\FreeShipping::resolve() — data['minimum_spend'] (per-currency array supported), optional use_discount_amount to check against post-discount subtotal.
+
+ +
+
In-store pickup / collection
+ have +
Drivers\ShippingMethods\Collection::resolve() — zero-price option, flagged collect: true on the ShippingOption. Single implicit "store" — no concept of which location, no per-location stock or hours.
+
+ +
+
Per-product shipping exclusions by zone
+ have +
ShippingExclusionList + ShippingZone::shippingExclusions() — every driver checks it before resolving and returns null if any cart line's product is excluded from that zone.
+
+ +
+
Per-customer-group rate visibility
+ have +
ShippingMethod::customerGroups() pivot carries visible, enabled, starts_at, ends_at — scheduling and audience-gating a rate is already modeled.
+
+ +
+
Filament admin UI for zones/methods/rates
+ have +
ShippingZoneResource, ShippingMethodResource, ShippingExclusionListResource ship with the add-on — usable as soon as the Filament plugin is registered, which it is via CorePlugin.
+
+ +
+
Storefront checkout step to pick a rate
+ missing +
No shipping views exist in 3dealer's resources/views beyond a passing mention in components/footer.blade.php — the whole backend above is unwired to any customer-facing UI.
+
+
+ +
+
+ 03 +

Carrier integration

+
+

Real carriers quoting and printing on Lunar's behalf, rather than merchant-defined flat/tiered rates.

+ +
+
Real-time carrier rate shopping (USPS/UPS/FedEx/DHL)
+ missing +
No driver in table-rate-shipping calls an external carrier API — all four shipped drivers (FlatRate, ShipBy, FreeShipping, Collection) compute from local data. Shopify's CarrierService API is the model for this: shop sends weight/dims/destination, carrier returns live rates at checkout.
+
+ +
+
Product/variant weight & dimensions for rating
+ partial +
ProductVariant has weight_value/weight_unit (referenced in ShipBy's weight tier and docs/lunar.md) but no length/width/height fields exist in core migrations — enough for weight-tier rating, not enough for carrier-grade dimensional/volumetric quotes.
+
+ +
+
Shipping label generation & printing (staff-facing)
+ missing +
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.
+
+ +
+
Return / exchange label generation
+ missing +
No returns concept exists in Lunar core at all — this sits behind both "labels" and "returns," neither of which exists yet.
+
+ +
+
Shipment tracking numbers on orders
+ missing +
OrderShippingZone pivot table records which zone an order matched, but no field anywhere stores a carrier tracking number or shipment status.
+
+
+ +
+
+ 04 +

Fulfillment logistics

+
+

Where an order physically ships from, and whether it can ship from more than one place.

+ +
+
Multi-warehouse / multi-location inventory
+ missing +
No warehouse, location, or fulfillment-center model anywhere in vendor/lunarphp/core or lunar — 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.
+
+ +
+
Split shipment (one order, multiple packages/warehouses)
+ missing +
Downstream of multi-warehouse — with a single implicit stock pool, there's nothing to split by. CreateShippingLine writes exactly one shipping line per order.
+
+ +
+
Multiple pickup locations (choose a specific store)
+ missing +
The Collection driver models pickup as a single yes/no rate per zone — no location entity to pick from, no per-location hours/capacity.
+
+ +
+
Local delivery (distinct from carrier shipping or pickup)
+ missing +
No radius/zone-based "we deliver it ourselves" driver — only ShipBy/FlatRate (carrier-agnostic priced shipping) and Collection (pickup) exist as concepts.
+
+ +
+
Delivery date / time-slot selection at checkout
+ missing +
No date/time field on ShippingOption, CartAddress, 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.
+
+
+ +
+
+ 05 +

International & risk

+
+

What happens when a shipment crosses a border, or something goes wrong in transit.

+ +
+
Customs documentation / HS codes per product
+ missing +
No HS-code or customs-description field found on Product/ProductVariant migrations. Every international shipment needs one per line item to clear customs — researched requirement, not yet modeled anywhere in Lunar.
+
+ +
+
Duties/taxes collected at checkout (DDP)
+ missing +
Lunar's CalculateTax 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.
+
+ +
+
Country/zone-restricted shipping
+ have +
ShippingZone type countries/states/postcodes already scopes which rates apply where — the building block international shipping would sit on top of.
+
+ +
+
Shipping insurance / package protection at checkout
+ missing +
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.
+
+
+ + + +