Compare commits

...
112 Commits
Author SHA1 Message Date
arvanitakis 547f07f01e Feat: Extracting Cart and Checout from 3dealer 2026-09-25 20:19:11 +03:00
arvanitakis ccab9bbb8e Chore: Storing UserOtp in cache before he is registered 2026-09-25 20:10:29 +03:00
arvanitakis 985f53efa2 Bump Version to 0.22.0 2026-09-25 15:58:41 +03:00
arvanitakis 23643db996 Feat: Adding More Storefront Labels 2026-09-25 15:53:29 +03:00
arvanitakis 099271e0a8 Feat: Pending Email Change Updates, Moving Mailables to core 2026-09-25 15:37:18 +03:00
arvanitakis 01c49485be Feat: Recording Legal Acceptance 2026-09-25 15:19:39 +03:00
arvanitakis 935b1d02f9 Feature: Adding Customer Recovery Consent to core, assigning it to the customer 2026-09-25 14:12:55 +03:00
arvanitakis 8fdaeda0ba Fix: Correcting writable profile fields from vat_no to tax_identifier 2026-09-25 14:03:04 +03:00
arvanitakis f416e207eb Bump version to 0.21.1 2026-09-25 13:59:30 +03:00
arvanitakis c55019d04a Chore: Claiming Guest Orders moved from 3dealer to core 2026-09-25 13:59:16 +03:00
arvanitakis 910d4c5df0 Bump version to 0.21.0 2026-09-25 13:50:46 +03:00
arvanitakis f1a0322d3f Feat: Upload Controller and Prune Commands Extraction from 3dealer 2026-09-25 13:48:42 +03:00
arvanitakis 6025ea4304 Feat: Updating FIle Services, Updating Order Views to list product extra options 2026-09-25 10:08:57 +03:00
arvanitakis 2b8fe5764c Feat: Creating Migration Models And Adapters for file Service 2026-09-25 09:10:56 +03:00
arvanitakis 69fdd0b4b8 Bump version to 0.20.2 2026-09-25 08:55:10 +03:00
arvanitakis 5347e01f0e Chore: Updating ProductDocumentLocalizer to translate Custom Fields 2026-09-25 08:39:15 +03:00
arvanitakis 78b46e5594 Chore: Adding Locales to Product Custom Fields 2026-09-24 23:42:43 +03:00
arvanitakis 621381beaa Bump Version to 0.20.1 2026-09-24 22:53:03 +03:00
arvanitakis 8f4156cfe8 Feat: Restructuring MigrateImport, Dispatching a per product job for import 2026-09-24 22:50:41 +03:00
arvanitakis a9b993182b Fix: Product Localizer now checks if a value is filled, or, not to show the fallback 2026-09-24 22:00:28 +03:00
arvanitakis 5a7fcd9f51 Fix: Removing Cart Lines along Products, so that the frontend loads 2026-09-24 21:57:31 +03:00
arvanitakis b4e9b8a4a9 Fix: Updating MIgrateImportCommand to accept language for import 2026-09-24 21:41:11 +03:00
arvanitakis c7035d6782 Bump version to 0.20.0 2026-09-23 09:47:28 +03:00
arvanitakis 4c0974bf84 Feat: Updating Product Indexer, and Product Sort 2026-09-23 09:41:21 +03:00
arvanitakis 4e15d8ef8c Fix: Updating Wipe Catalog Command to force delete products instead of the soft delete 2026-09-22 21:17:35 +03:00
arvanitakis 1c7efc6e4d Feature: Adding Custom Fields to Products 2026-09-22 21:17:01 +03:00
arvanitakis 59c57b37fc Feat: Updating ShopifyExportImporter and WipeCatalogCommand to handle images 2026-09-22 15:29:03 +03:00
arvanitakis 37b49963f6 Feat: Adding Backfill Skus to the Migrate Import Job 2026-09-22 14:55:18 +03:00
arvanitakis 050204f063 Feature: Adding Wipe Catalog Command for all products 2026-09-22 14:36:57 +03:00
arvanitakis c0ae9d8996 Feat: Adding Purchasable to 'in_stock' when importing a new product 2026-09-22 13:48:08 +03:00
arvanitakis cc1cf6ea7f Feat: Displaying Draft Products, only when AppDebug = true 2026-09-22 13:46:26 +03:00
arvanitakis 0437057e5d Merge branch 'Quality-Updates' 2026-09-18 01:30:37 +03:00
arvanitakis 0c169daf55 Bump version to 0.19.0 2026-09-18 01:29:54 +03:00
arvanitakis 609a63c2f4 Feat: Tying Specific Methods with Carrier Drivers 2026-09-18 01:23:50 +03:00
arvanitakis 12aaa43f10 Fix: Updates to OrderFullfilmentServices and box now clients, order views and checkout services 2026-09-18 00:54:43 +03:00
arvanitakis dcdc998eee Feat: Updating Shipping Method with new variables, for correct box env vars 2026-09-18 00:52:38 +03:00
arvanitakis a55697ce82 Feature: Updating Listreners, Separating Logic from listeners, Queuing Policies 2026-09-16 23:24:02 +03:00
arvanitakis a411e6bbc1 Bump version to 0.18.1 2026-09-16 18:55:42 +03:00
arvanitakis 910fa94395 Feat: Updating the Privacy Providers, moving them into the appropriate Modules, Updating Privacy views 2026-09-16 18:51:20 +03:00
arvanitakis fdd1899c34 Feat: Rearranging Providers 2026-09-16 13:44:14 +03:00
arvanitakis 3ad3a1b4d6 Fix: Adding cart id and order id to stripe payload 2026-09-16 01:39:23 +03:00
arvanitakis 68233f43ef Feat: Privacy Concern redesign to match project structure 2026-09-16 01:21:22 +03:00
arvanitakis 027f7e8982 Feat: Updating Data Erasure and Data Export Views 2026-09-16 00:46:13 +03:00
arvanitakis c084eb47cb Feat: Bringin Privacy to Filament v4, the managers and resources were built with filament v3 2026-09-16 00:24:18 +03:00
arvanitakis 58d165acc3 Fix: FIxing Bug on resolving relation on Products, Orders, and Users 2026-09-16 00:23:29 +03:00
arvanitakis 44ad943eec Merge branch 'master' into Privacy 2026-09-16 00:13:41 +03:00
arvanitakis 2cc6f5e5f0 Bump Version to 0.18.0 2026-09-16 00:06:04 +03:00
arvanitakis 0babc6a96d Fix: Locking User on Login to manage otp attempts 2026-09-16 00:05:53 +03:00
arvanitakis 89a3d4bbad Merge branch 'master' into customer 2026-09-15 23:57:07 +03:00
arvanitakis ccb2666495 Bump Version to 0.17.5 2026-09-15 23:52:48 +03:00
arvanitakis 97004234f0 Feat: Adding Translations to Payment and Shipping Methods, removing unecessary shipping method fulfillment type 2026-09-15 23:51:10 +03:00
arvanitakis ea73cc3562 Feat: Translating States and Countries For Greece 2026-09-15 22:30:57 +03:00
arvanitakis 02816fb9e7 Bump Version to 0.17.4 2026-09-15 22:13:31 +03:00
arvanitakis 910ce0205d Feat: Adding command for backfilling all product skus 2026-09-15 22:13:18 +03:00
arvanitakis e532c32cab Bump version to 0.17.3 2026-09-15 21:49:03 +03:00
arvanitakis 6a51b672c8 Fix: Adding a check for hasTable 2026-09-15 21:40:35 +03:00
arvanitakis 956e9e88a6 Fix: Stripping Lunar's Stripe Driver with Boboko's Stripe Payment Driver 2026-09-15 21:38:16 +03:00
arvanitakis 4489475840 Bump version to 0.17.2 2026-09-15 21:25:25 +03:00
arvanitakis e4e008167a Fix: Correct Display of last 4 digits of credit card 2026-09-15 21:22:45 +03:00
arvanitakis a5f3008ce2 Fix: Update Order status to Processing when payment has been recieved 2026-09-15 21:17:56 +03:00
arvanitakis d9fb3bbde6 Bump version to 0.17.1 2026-09-15 16:12:03 +03:00
arvanitakis 26b4c5bfd7 Fix: Move Stripe Payment Intent to always allow redirect 2026-09-15 16:11:31 +03:00
arvanitakis 409e8204f6 Updating Changelog 2026-09-15 16:02:20 +03:00
arvanitakis 8472649905 Feature: Customer Account Services 2026-09-15 16:01:28 +03:00
arvanitakis 57fc28ca06 Bump version to 0.17.0 2026-09-14 20:18:26 +03:00
arvanitakis 9d3e54e5df Changelog 2026-09-14 00:04:20 +03:00
arvanitakis 44c6b7defd Feature: Order Updates, Events, Order Flows, Shipment And COD support 2026-09-14 00:03:06 +03:00
arvanitakis 78bbd8390a Feature: Minor Updates to Order Shipping And Order Statuses 2026-09-10 22:50:40 +03:00
arvanitakis 99e55902ac Feat: Updating OrderPlaced Listeners to Decrement Stock, Creating Notifications 2026-09-10 01:34:30 +03:00
arvanitakis 864c8b19aa Feat: Updating Cart Lifecycle Service, and Capping Abandoned Cart Days. Also Updating Cart Views 2026-09-10 01:13:15 +03:00
arvanitakis 8f4c1a22ea Bump version to 0.16.3 2026-09-10 00:14:57 +03:00
arvanitakis 13d5833d18 Fix: Fixing Stripe Payment Driver, Applying Payment Mehtod (COD) fee correctly 2026-09-10 00:14:42 +03:00
arvanitakis 3e45b84636 Bump version to 0.16.2 2026-09-09 23:46:29 +03:00
arvanitakis 437cbf2460 Fix: Updating Shipping Listeners to clear the ShippingManifest options 2026-09-09 23:45:21 +03:00
arvanitakis 5425a0396f Feat: Adding missing nav translations 2026-09-09 23:43:39 +03:00
arvanitakis 4d0e326cb9 Bump version to 0.16.1 2026-09-09 23:25:22 +03:00
arvanitakis d4f9766940 Fix: Correcting spaceing on login form 2026-09-09 23:22:13 +03:00
arvanitakis 359e1e262e Bump Version to 0.16.0 2026-09-09 01:19:31 +03:00
arvanitakis fb684dc97b Feat: Adding Concent Updates 2026-09-09 01:12:21 +03:00
arvanitakis 9c95c0bccb Bump Version to 0.15.0 2026-09-09 00:48:52 +03:00
arvanitakis 73bfc748b4 Feature: Moving Payment Methods to DB, adding fees, Transaction Updates, Refund Updates, General Updates to Payments 2026-09-09 00:48:09 +03:00
arvanitakis 4ff9bdacc3 Bump version to 0.14.0 2026-09-04 13:06:26 +03:00
arvanitakis 55832d9549 Feat: Adding search results for translation 2026-09-04 13:06:07 +03:00
arvanitakis 7b46a83e5e Feat: Product Search Service Restructure 2026-09-04 13:03:38 +03:00
arvanitakis e9aa08a338 Bump version to 0.13.1 2026-09-03 18:28:22 +03:00
arvanitakis 0676a1f5c7 Feat: Recording Payment Transactions 2026-09-03 18:26:48 +03:00
arvanitakis 8e8ec17d09 Bump version to 0.13.0 2026-09-03 17:44:22 +03:00
arvanitakis e6f1ca179a Fix: Fixing various bugs occured on payment lifecycle 2026-09-03 17:42:30 +03:00
arvanitakis 79e525d53f Fix: Stripe Intents Table is a Lunar Table, so needs a prefix. This is covered by the Lunar\Base\Migration 2026-09-03 17:31:39 +03:00
arvanitakis 456943dc74 Feature: Payment resolver, Payment Provider, Completing Stripe Webhooks, Wiring Payments to checkout service 2026-09-03 17:27:34 +03:00
arvanitakis 35c3334690 Feat: Payment Restructuring to be fully event-driven 2026-09-03 17:00:24 +03:00
arvanitakis a987a2d57c Feature: Abstraction on Payments based on their operations 2026-09-03 16:01:33 +03:00
arvanitakis 0fa2188146 Feat: Refactoring Shipping DataTransferObjects to DTOs namespace 2026-09-03 15:58:51 +03:00
arvanitakis a1301d4b46 Merge branch 'master' into Payments 2026-09-03 13:34:32 +03:00
arvanitakis 215f43f3ef Feat: Adding tags to product filters 2026-09-03 13:34:04 +03:00
arvanitakis c439299144 Bump version to 0.12.1 2026-09-03 13:23:50 +03:00
arvanitakis 6963515971 Fix: Small update to label parsing 2026-09-03 13:22:09 +03:00
arvanitakis f43f72f633 Feat: Updates to Product Indexer, Shopify Importer, Adding cascade delete on reviews 2026-09-03 12:26:00 +03:00
arvanitakis 448da869a9 Bump version to 0.12.0 2026-09-03 11:44:36 +03:00
arvanitakis 1287b513cd Feat: Updating Product Service with new Methods, DTOs for ListingResult And Slider Bounds 2026-09-03 11:44:23 +03:00
arvanitakis b2919f1f4b Feature: Product Search Service Updates 2026-09-03 11:04:19 +03:00
arvanitakis 8cb54e065e Feat: Updates to Payments, Checkout Services, Payment Events 2026-09-02 16:14:52 +03:00
arvanitakis 3497553b41 Feature: Order Service Provider, Order Events Observers 2026-09-01 14:04:12 +03:00
arvanitakis 29e77a973b Feat: Orders Feature Survey 2026-09-01 13:34:37 +03:00
arvanitakis 026ab5bc2d BUmp version to 0.11.1 2026-09-01 13:04:54 +03:00
arvanitakis 483c0ce00f Fix: Recommendations are an eloquent collection, not an Generic Collection 2026-09-01 13:03:39 +03:00
arvanitakis ce405d20e6 Bump Version to 0.11.0 2026-09-01 12:08:47 +03:00
arvanitakis 0f07751559 Feature: Recommendation Service
This commit introduces a RecommendationService that is used when indexing products.

The service is utilizing a recommendation rules interface, so many rules can be created and applied during indexing the products
2026-09-01 12:06:29 +03:00
arvanitakis e7784364fd Feature: Data Access and Data Export Admin Service
This commit introduces the data retention and data export Admin Services, accessed by the Boboko admin UI
2026-08-25 09:33:03 +03:00
arvanitakis 59303cf25f Feature: Updating Readme to reflect changes on Privacy 2026-08-24 21:44:10 +03:00
arvanitakis af380a7fa0 Feature: Handling Cases for User to Customer Relationships
This commit handles a case where customer data are "dead-data" menaing there is no way of erasure for them, which makes the app non-compliant
2026-08-24 21:41:23 +03:00
arvanitakis 9f540cbaa4 Feature: Creating Privacy Basics 2026-08-24 21:06:11 +03:00
380 changed files with 23462 additions and 1157 deletions
+1199 -4
View File
File diff suppressed because it is too large Load Diff
+117 -21
View File
@@ -1,6 +1,9 @@
# Core Module
A Laravel module providing authentication, notifications, activity logging, CLI tooling, and functional types on top of the [Lunar](https://lunarphp.io) admin panel. Designed to be consumed as a standalone Composer package.
A Laravel module providing authentication, localization, product search/catalog, privacy/GDPR
tooling, notifications, activity logging, CLI tooling, and functional types on top of the
[Lunar](https://lunarphp.io) e-commerce package. Designed to be consumed as a standalone Composer
package by any Lunar-based e-shop.
---
@@ -8,13 +11,83 @@ A Laravel module providing authentication, notifications, activity logging, CLI
### OTP Authentication
Passwordless login for both staff (Lunar panel) and customers via 6-digit codes delivered by email. Codes expire after 10 minutes. The Lunar panel login page is a two-step flow: email → OTP. Rate-limited to 5 attempts.
Passwordless login for both staff (Lunar panel) and customers via 6-digit codes delivered by
email. Codes expire after 10 minutes, rate-limited to 5 attempts. The Lunar panel login page is a
two-step flow (email → OTP) with a back button to return from the code step to the email step.
See [`docs/otp-auth.md`](docs/otp-auth.md).
### Localization
Locale-prefixed routing (`Modules\Core\Localization\LocaleMiddleware`) — a `locale` route
middleware, opt-in per shop, that resolves and redirects to the correct language segment
(`/el/...`, `/en/...`) based on Lunar's own language list, with caching and rename-safe
translation migration. Also brings in storefront UI label translations
(`spatie/laravel-translation-loader`) with an admin-editable `LanguageLine` resource.
See [`docs/localization.md`](docs/localization.md).
### Product Search & Catalog
Two complementary services on top of Meilisearch:
- **`Modules\Core\Search\ProductSearchService`** — locale-aware full-text product search.
- **`Modules\Core\Catalog\ProductService`** — listing/filtering (by collection, brand, price
range) and single-product lookup by id or slug, reading directly from the Meilisearch index
rather than the database.
Both are backed by `Modules\Core\Search\ProductIndexer`, which extends Lunar's own indexer with
collections, price, variants, media, tags, and reviews — everything needed for both a listing
page and a full product detail page from one index.
See [`docs/product-search.md`](docs/product-search.md) and
[`docs/product-listing.md`](docs/product-listing.md).
### Product Reviews
`Modules\Core\Review\ProductReview` — ratings/reviews with staff replies, a Filament sub-navigation
page on the product edit screen, and automatic re-indexing (via `ReviewServiceProvider`) whenever
a review is created, updated, or deleted, so a product's Meilisearch document never goes stale.
### Privacy / GDPR Data-Subject Requests
Right of access (export) and right of erasure, built as an extensible contract
(`Modules\Core\Privacy\Contracts\PersonalDataProvider`) rather than a fixed table list — any
module can register its own data without core knowing it exists.
- **Two independent scopes**: erasing/exporting a Lunar `Customer` (business account) is never
the same operation as erasing/exporting a `User` (individual login) — a `Customer` erasure
never touches any linked `User`'s login, and a `User` erasure never touches a `Customer`
account's own data. See `docs/privacy.md` "User-scope vs Customer-scope".
- **Cancellable grace period** (default 30 days, configurable) before anything is actually
erased — logging back in during the window automatically reverts the request, mirroring
Shopify's own account-deletion flow. Immediate erasure exists but is staff-only by type, never
reachable from a self-service flow.
- **Sole-owner cascade**: erasing the last remaining `User` on a `Customer` also opens a (grace
period) erasure request for that now-orphaned `Customer`, so its PII doesn't sit unreachable
forever — traced back to the triggering request so login-reactivation can revert exactly that
cascade.
- **Queued export**: gathering data and writing a CSV-per-provider zip (via the generic,
reusable `Modules\Core\Export\CsvWriter`) runs as a background job; a consuming app hooks its
own notification onto the completion event via the Notification Registry (below).
See [`docs/privacy.md`](docs/privacy.md).
### Shopify Migration
`Modules\Core\MigrateImport\Shopify\ShopifyExportImporter` — imports a Shopify CSV product export
(products, variants, images, collections, tags, prices) into Lunar, idempotently re-runnable via
an `import_mappings` table. Part of a source-agnostic import framework
(`boboko:migrate:import`) designed to support additional sources later.
See [`docs/shopify-import.md`](docs/shopify-import.md).
### Notification Registry
An event-driven notification system. Each notification class declares which event it listens to and who to notify — the registry wires up the listener automatically. All notifications extend `BaseNotification` which implements `ShouldQueue`, so delivery is async. Supports optional delays.
An event-driven notification system. Each notification class declares which event it listens to
and who to notify — the registry wires up the listener automatically. All notifications extend
`BaseNotification`, which implements `ShouldQueue`, so delivery is async. Supports optional
delays.
**Creating a notification:**
@@ -33,9 +106,13 @@ class MyNotification extends BaseNotification
NotificationRegistry::get()->register([MyNotification::class]);
```
See [`docs/notifications.md`](docs/notifications.md).
### Activity Logging
Thin wrapper around [Spatie Laravel Activity Log](https://github.com/spatie/laravel-activitylog). Four standardized methods: `created()`, `updated()`, `failed()`, `deleted()`. Logs to the `lunar` channel and auto-resolves the actor from the staff session.
Thin wrapper around [Spatie Laravel Activity Log](https://github.com/spatie/laravel-activitylog).
Four standardized methods: `created()`, `updated()`, `failed()`, `deleted()`. Logs to the `lunar`
channel and auto-resolves the actor from the staff session.
See [`docs/activity-log.md`](docs/activity-log.md).
@@ -43,8 +120,11 @@ See [`docs/activity-log.md`](docs/activity-log.md).
- Custom OTP login page replacing the default Lunar panel login
- `StaffResourceExtension` — removes password field from Lunar's staff resource
- `CustomerResourceExtension` — replaces default address relation manager with a custom implementation
- `CorePlugin` — configures panel path, branding, logos, navigation items, and activity log field exclusions for staff
- `CustomerResourceExtension` — replaces default address relation manager with a custom
implementation
- Table-rate shipping (`ShippingPlugin`) registered by default
- `CorePlugin` — configures panel path, branding, logos, navigation items, and activity log
field exclusions for staff
Register the plugin in your Lunar panel provider:
@@ -52,30 +132,36 @@ Register the plugin in your Lunar panel provider:
->plugin(\Modules\Core\CorePlugin::make())
```
See [`docs/lunar.md`](docs/lunar.md) for the full Lunar reference and non-obvious gotchas hit
while building against it.
### CLI Commands
| Command | Description |
|---|---|
| `core:create-admin` | Create a Lunar admin user |
| `core:anonymize` | GDPR anonymization of users and customers (local only) |
| `core:export` | Dump database + storage files to a timestamped zip |
| `core:import` | Restore from a zip export (runs anonymize automatically, local only) |
| `core:export-cleanup` | Delete old export zips, keep N most recent |
| `boboko:anonymize` | Dummy-scrub personal data in `users`/`lunar_customers` for local dev safety (local environment only — **not** the GDPR erasure tool; see Privacy above for that) |
| `boboko:export` | Dump database + storage files to a timestamped zip |
| `boboko:import` | Restore from a `boboko:export` zip archive |
| `boboko:export:cleanup` | Delete old export zips, keep N most recent |
| `boboko:migrate:import` | Import a vendor product catalog (Shopify, etc.) into Lunar |
| `boboko:privacy:process-erasure-requests` | Dispatch an erasure job for every due GDPR erasure request (wire into your own scheduler) |
| `lunar:create-admin` | Create a Lunar admin user (overrides Lunar's own command) |
| `lunar:install` | Seed default Lunar store data — countries, channel, currency, tax zone, attributes, product type (overrides Lunar's own command) |
### Functional Types
Result and Option monads for explicit error handling without exceptions.
Result and Option types for explicit error handling without exceptions.
```php
// Result<T, E>
$result = Success::of($value);
$result = Error::of('something went wrong');
$result = Success::create($value);
$result = Error::create('something went wrong');
$result->map(fn($v) => ...)->flatMap(fn($v) => ...);
// Option<T>
$option = Option::fromValue($nullableValue);
$option->getOrElse('default');
$option->map(fn($v) => ...)->filter(fn($v) => $v > 0);
$option = Some::create($value);
$option = None::create();
$option->map(fn($v) => ...);
```
---
@@ -102,17 +188,22 @@ Then run:
```bash
composer require boboko/core
php artisan vendor:publish --tag=core-config
php artisan vendor:publish --tag=core-assets
php artisan migrate
```
For local core development alongside a consuming app (path-repo symlink + Docker mount), see
[`docs/modules.md`](docs/modules.md) "Docker Compose: the local-core mount".
---
## Requirements
- PHP 8.2+
- Laravel 11+
- Lunar (lunarphp/lunar + lunarphp/admin)
- PHP 8.5+
- Laravel 12+
- Lunar 1.3 (`lunarphp/lunar`)
- Meilisearch (for product search/listing/catalog)
- Spatie Laravel Activity Log
---
@@ -120,7 +211,12 @@ php artisan migrate
## Documentation
- [`docs/otp-auth.md`](docs/otp-auth.md) — OTP authentication flow
- [`docs/localization.md`](docs/localization.md) — Locale-prefixed routing and storefront translations
- [`docs/product-search.md`](docs/product-search.md) — Full-text product search
- [`docs/product-listing.md`](docs/product-listing.md) — Product listing/filtering/detail catalog service
- [`docs/privacy.md`](docs/privacy.md) — GDPR right of access/erasure, User-scope vs Customer-scope
- [`docs/shopify-import.md`](docs/shopify-import.md) — Shopify CSV → Lunar field mapping and import design
- [`docs/activity-log.md`](docs/activity-log.md) — Activity logging
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference
- [`docs/notifications.md`](docs/notifications.md) — Notification registry
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference and gotchas
- [`docs/modules.md`](docs/modules.md) — Module architecture, Customer/User pairing, provider registration pitfalls
+8 -3
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.10.1",
"version": "0.22.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -18,7 +18,7 @@
"lunarphp/search": "*",
"lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8",
"lunarphp/stripe": "^1.5"
"stripe/stripe-php": "^16.6"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -37,12 +37,17 @@
"Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\CheckoutServiceProvider",
"Modules\\Core\\Providers\\CheckoutModuleServiceProvider",
"Modules\\Core\\Providers\\PaymentServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\CartServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider"
"Modules\\Core\\Providers\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider"
]
}
},
+25
View File
@@ -0,0 +1,25 @@
<?php
use Modules\Core\Catalog\Recommendations\RandomRule;
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
return [
/*
|--------------------------------------------------------------------------
| Product recommendation rules
|--------------------------------------------------------------------------
|
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
| the first rule that returns at least one product wins. The order here IS
| the fallback chain: SameCategoryRule first, then RandomRule as a
| last-resort so a product page is never left with zero recommendations
| (as long as the store has more than one product). A consuming app can
| reorder, add, or remove rules freely — nothing about the chain shape is
| hardcoded in the service itself.
|
*/
'recommendation_rules' => [
SameCategoryRule::class,
RandomRule::class,
],
];
+44
View File
@@ -0,0 +1,44 @@
<?php
/*
* Per-site settings for the cart + checkout module (see
* Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
* artisan vendor:publish --tag=core-config.
*/
return [
/*
* Name of the storefront's login route. The checkout's login tab and the
* confirmation page link to it with `?redirect=<checkout path>`, so the
* login page must send the shopper back there afterwards. null: no login
* offered in checkout at all.
*/
'login_route' => 'login',
/*
* Name of the storefront's product-listing route — where confirmation()
* redirects a visit with no placed order to look at (session expired,
* direct navigation, a bookmark). route($this, $locale) must resolve.
*/
'products_route' => 'products',
/*
* ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden
* field, forced server-side — no country picker shown at all). null (the
* default) gives the full country/region picker, for a multi-country
* store.
*/
'store_country_iso3' => null,
/*
* The `purpose` tag CartController expects a product custom field's
* `file` answer to already carry (see Modules\Core\File\Models\File) —
* matches whatever purpose string the host's own upload endpoint
* (extending Modules\Core\File\Http\Controllers\UploadFileController)
* tags its stored files with. This module never reaches into that
* host controller directly; this config value is the one shared
* source of truth between the two.
*/
'custom_field_upload_purpose' => 'custom-field-upload',
];
+106
View File
@@ -16,6 +16,43 @@ return [
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Privacy / GDPR data-subject requests
|--------------------------------------------------------------------------
|
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
| that should be consulted for right-of-access/right-of-erasure requests. A
| module never needs to be known to core in advance — it just adds its own
| provider class here, the same way config('lunar.search.indexers') maps a
| model to its indexer. See docs/privacy.md.
|
| 'grace_period_days' is how long an erasure request stays cancellable
| (account deactivated, not yet erased) before it's actually processed by
| the privacy:process-erasure-requests scheduled command.
|
*/
'privacy' => [
'providers' => [
// ActivityLogDataProvider MUST run before AddressDataProvider —
// it resolves which activity_log rows belong to this customer
// (including ones keyed by an Address id) before
// AddressDataProvider hard-deletes those Address rows. See that
// provider's own class docblock.
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
],
'grace_period_days' => 30,
],
/*
|--------------------------------------------------------------------------
| Cart Abandonment Threshold
@@ -30,6 +67,75 @@ return [
'cart' => [
'abandoned_after' => '1 hour',
/*
|----------------------------------------------------------------------
| Unrecoverable Cap
|----------------------------------------------------------------------
|
| Beyond this age, a stale cart stops being treated as an active
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
| CartLifecycleService) — too old to be a realistic recovery target
| (pricing/stock/tax likely stale by then). This is about the
| abandoned-cart pipeline only, not data retention — no rows are
| deleted or pruned based on this value.
|
*/
'unrecoverable_after' => '90 days',
],
/*
|--------------------------------------------------------------------------
| Order Return Window
|--------------------------------------------------------------------------
|
| How many days after a carrier order is delivered (Order::fulfillment_status
| becomes 'return_window_open') before Modules\Core\Order\Commands\
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
| Store-pickup orders have no return-window step and are unaffected by
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
*/
'order' => [
'return_window_days' => 14,
],
/*
|--------------------------------------------------------------------------
| Storefront OTP Login
|--------------------------------------------------------------------------
|
| Modules\Core\Auth\Services\UserOtpService's passwordless login.
| max_attempts caps how many wrong codes a shopper can guess against ONE
| generated code before it's invalidated outright. generation_limit/
| generation_decay_minutes cap how often a NEW code can be requested for
| the same email — independent of max_attempts, since generating a fresh
| code also resets the guess count, so an attempt cap alone doesn't stop
| an attacker from just requesting a new code every few tries. This same
| limit is also what stands between a malicious/careless caller and
| mail-bombing one inbox.
|
*/
'auth' => [
'otp' => [
'max_attempts' => 5,
'generation_limit' => 3,
'generation_decay_minutes' => 10,
],
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
// shape/reasoning as auth.otp above, independent limits since this
// is a separate flow (changing an existing account's login email,
// not logging in).
'email_change' => [
'max_attempts' => 5,
'generation_limit' => 3,
'generation_decay_minutes' => 10,
'expiry_minutes' => 10,
],
],
];
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Policy versions
|--------------------------------------------------------------------------
|
| Plain version strings, bumped by whoever edits the corresponding legal
| page — recorded alongside every consent/acceptance so a later dispute
| ("what did the shopper actually agree to?") can be answered from the
| order/cart itself rather than a live lookup against whatever the pages
| say TODAY. Not tied to any CMS/database row on purpose — this stays a
| plain config value the same way payment.php's cart_pipeline is a plain
| cross-cutting setting, not a per-instance one.
|
*/
'privacy_policy_version' => env('LEGAL_PRIVACY_POLICY_VERSION', '2026-01-01'),
'terms_version' => env('LEGAL_TERMS_VERSION', '2026-01-01'),
];
+13 -31
View File
@@ -1,46 +1,28 @@
<?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
use Modules\Core\Payment\Pipelines\Cart\ApplyPaymentMethodFee;
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.
| the selected payment method's own fee (if any) is added to the
| shipping total before the final Calculate step sums everything up.
|
| This is the one thing left in this file — everything about WHICH
| payment methods exist (driver mapping, capture_mode, statuses) moved
| onto Modules\Core\Payment\Models\PaymentMethod's own row (see
| docs/payments.md): that's a per-instance, merchant decision, not a
| store-wide-singular setting, so it never belonged in config at all.
| This pipeline registration IS genuinely cross-cutting — every store
| using this driver gets the same cart-pipeline wiring, regardless of
| how many payment methods it configures.
|
*/
'cart_pipeline' => [
ApplyCashOnDeliveryFee::class,
ApplyPaymentMethodFee::class,
],
];
+14
View File
@@ -13,12 +13,25 @@
|
| Set these via environment variables — never commit real values.
|
| Box Now has two environments (see their Partner API manual, section 2):
| Stage/Sandbox for testing, Production once live. Each has its own
| client_id/client_secret pair and its own base_url/location_api_url —
| there is no shared "switch an env var" flag, since stage credentials
| don't work against the production host or vice versa.
|
| 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_PARTNER_ID Numeric partnerId Box Now issues alongside your
| credentials. NOT used for REST API authentication
| (BoxNowClient authenticates with client_id/
| client_secret alone) — this is only consumed by
| the client-side Destination Map widget config
| (_bn_map_widget_config.partnerId), confirmed
| against Box Now's own WooCommerce plugin source.
| 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
@@ -33,6 +46,7 @@ return [
'client_id' => env('BOXNOW_CLIENT_ID'),
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
'partner_id' => env('BOXNOW_PARTNER_ID'),
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
@@ -0,0 +1,22 @@
<?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::table('users', function (Blueprint $table) {
$table->timestamp('deactivated_at')->nullable()->after('otp_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('deactivated_at');
});
}
};
@@ -0,0 +1,56 @@
<?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('data_erasure_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — a request targets either a
// Lunar Customer (business account) or a User (individual), never
// both at once. See docs/privacy.md "User-scope vs Customer-scope".
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live-looked-up value — the subject's email may
// change or the record may be gone by the time this is read.
$table->string('email')->nullable();
// Who asked for this: the subject themselves (self-service deletion)
// or a staff member acting on their behalf. Plain nullable type+id
// columns rather than morphs() — only ever one of two concrete actor
// types, not an open-ended polymorphic set.
$table->string('requested_by_type');
$table->unsignedBigInteger('requested_by_id');
$table->string('status')->default('pending');
// Set only on a Customer-scoped request that was auto-created because
// erasing a User left them as the sole remaining user on that Customer
// (see Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener).
// Null for every normal, directly-requested erasure. Lets login-
// reactivation find and revert exactly the Customer request THIS
// User's cancellation caused, without touching an unrelated,
// independently-requested Customer erasure the User happens to be
// linked to.
$table->foreignId('caused_by_request_id')->nullable()->constrained('data_erasure_requests')->nullOnDelete();
// now() + config('core.privacy.grace_period_days') at creation time —
// when privacy:process-erasure-requests will actually run this.
$table->timestamp('scheduled_for');
$table->timestamp('cancelled_at')->nullable();
$table->timestamp('completed_at')->nullable();
// Every provider's outcome, written once the request completes —
// see Modules\Core\Privacy\ErasureReport. Null until then.
$table->json('report')->nullable();
$table->timestamps();
$table->index(['status', 'scheduled_for']);
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_erasure_requests');
}
};
@@ -0,0 +1,36 @@
<?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('data_export_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — see data_erasure_requests
// for the same shape and reasoning.
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live lookup — same reasoning as
// data_erasure_requests.email (see that migration).
$table->string('email')->nullable();
$table->string('status')->default('pending');
// Storage path of the assembled export .zip, set once the queued job
// finishes. Null while pending.
$table->string('file_path')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index('status');
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_export_requests');
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* product_reviews.product_id's original foreign key (2026_07_10_000001) had
* no ON DELETE clause, so deleting a Product with reviews throws a
* constraint violation instead of the review rows going with it — unlike
* every other Product-dependent table (variants, media, etc.), which does
* cascade. A review is dependent, disposable data, not something worth
* blocking a product deletion over.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products')
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products');
});
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;
/**
* First-party copy of lunarphp/stripe's own create_stripe_payment_intents_table
* migration (package removed in favour of depending on stripe/stripe-php
* directly — see Modules\Core\Payment\Support\StripeManager and
* Modules\Core\Payment\Models\StripePaymentIntent, which replace the
* package's own classes over this same table). Timestamped to run just
* before this app's own add_context_to_stripe_payment_intents migration,
* which already alters this table.
*
* Guarded with hasTable(): on any environment that already ran
* lunarphp/stripe's own copy of this migration before the package was
* removed, the table already exists — this migration is only the one that
* actually creates it on a fresh install/database from now on.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable($this->prefix.'stripe_payment_intents')) {
return;
}
Schema::create($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained($this->prefix.'carts');
$table->foreignId('order_id')->nullable()->constrained($this->prefix.'orders');
$table->string('intent_id')->index();
$table->string('status')->nullable();
$table->string('event_id')->index()->nullable();
$table->timestamp('processing_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists($this->prefix.'stripe_payment_intents');
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;
/**
* lunarphp/stripe's own stripe_payment_intents table already correlates a
* Stripe intent back to a cart/order via cart_id/order_id — exactly what
* Modules\Core\Payment\Drivers\StripePaymentDriver needs to recover
* $context in handleCallback(), a separate request (a webhook) from the
* pay()/authorize() call that originated it. Two columns this driver
* needs that the vendor table doesn't have:
* - context: the full opaque $context bag pay()/authorize() received,
* stored so handleCallback() can dispatch the SAME context the
* original call would have, without Payment inventing its own
* correlation table — see docs/payments.md "Async resolution".
* - payment_type: the payment type key (e.g. 'stripe') pay()/authorize()
* were called with — needed to dispatch Payment events with the
* correct $type in handleCallback(), which otherwise has no way to
* know it (a webhook payload doesn't carry it).
*
* Extends Lunar\Base\Migration (not the plain base Migration) so $this->prefix
* resolves the SAME table-prefix config every Lunar-owned table uses
* (config('lunar.database.table_prefix')) — the vendor migration that
* creates this table (lunarphp/stripe's create_stripe_payment_intents_table)
* already does this, so a store running with a non-default prefix (this
* one runs with 'lunar_') would otherwise have this migration fail against
* a table name that doesn't exist.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->json('context')->nullable()->after('status');
$table->string('payment_type')->nullable()->after('context');
});
}
public function down(): void
{
Schema::table($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->dropColumn(['context', 'payment_type']);
});
}
};
@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Moves the driver mapping and per-type behavior that used to live in
* config('lunar.payments.types.{type}.*') onto the PaymentMethod row
* itself — same DB-instance-vs-config split Modules\Core\Shipping's own
* shipping_methods table already has (code/driver/name/enabled columns,
* no driver mapping in any config file). See docs/payments.md.
*
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* (NOT the same as `type` — two rows can share one driver).
* - name: admin-facing label. Nothing played this role before; `type`
* was always the machine slug.
* - capture_mode / captured_status / authorized_status: per-instance
* behavior — fails the "would a store ever want two different answers
* to this" cross-cutting-config test, so these move off config.
* - position: admin-controlled display/checkout order.
* - driver_missing_at: set by the payment:sync-drivers command when
* `driver` no longer resolves via the registry — deliberately
* separate from `enabled`, so a driver vanishing (a deploy removed
* it) is never confused with an admin's own manual toggle, and a
* driver that comes back later auto-clears this with no admin action.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('name')->nullable()->after('type');
$table->string('driver')->nullable()->after('name');
$table->string('capture_mode')->nullable()->after('driver');
$table->string('captured_status')->nullable()->after('capture_mode');
$table->string('authorized_status')->nullable()->after('captured_status');
$table->unsignedInteger('position')->default(0)->after('authorized_status');
$table->timestamp('driver_missing_at')->nullable()->after('position');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn([
'name', 'driver', 'capture_mode', 'captured_status',
'authorized_status', 'position', 'driver_missing_at',
]);
});
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status (added in 2026_09_05_000001) cover a
* payment being taken, but nothing wrote Order.status on a REFUND —
* Order::paymentStatus() (Order\Support\OrderStatus::payment(), derived
* live from transactions) already reflects a refund correctly, but the
* stored status column — the one admin filtering, customer emails, etc.
* actually key off — never moved. Same reasoning as captured_status/
* authorized_status: a store could plausibly want a different resulting
* status per payment method (e.g. a "Refunded" vs. a "Refund Pending"
* variant), so this is a PaymentMethod column, not cross-cutting config.
*
* Deliberately no separate void_status — void never moved money (it
* releases an authorization hold before any capture), so it doesn't carry
* the same "the customer needs to see this changed" weight a refund does;
* add one later if a real need for it shows up.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('refunded_status')->nullable()->after('authorized_status');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn('refunded_status');
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Splits Lunar's single flat `status` column into three independently
* tracked axes — payment, fulfillment, return — so a payment refund and a
* fulfillment dispatch stop racing to write the same field, and each axis
* can be filtered/queried directly instead of overloading one string for
* three unrelated concerns. See Modules\Core\Order\Enums\OrderPaymentStatus/
* OrderFulfillmentStatus/OrderReturnStatus for the value vocabularies, and
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus and friends for
* where these columns actually get written. `status` itself is left in
* place, unchanged — Lunar core still reads/writes it in places this
* package doesn't own — but nothing in this package's business logic keys
* off it anymore after this migration's consumers land.
*
* lunar_customers already has a direct precedent for a boboko-core
* migration altering a Lunar-owned table (see
* 2026_07_02_000002_drop_otp_from_lunar_customers_table.php) — this is not
* a new pattern for this codebase, just the first time it's applied to
* lunar_orders.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->string('payment_status')->default('awaiting_payment')->after('status')->index();
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
});
}
public function down(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Append-only audit trail for Order's three status axes (see
* 2026_09_11_000001_add_status_axes_to_orders_table.php) — the thing
* `Lunar\Models\Order::getDefaultLogExcept()` explicitly denies (`status`
* is excluded from Lunar's own Spatie activity log), so this is a
* from-scratch mechanism, not a gap in an existing one.
*
* No `updated_at` — a row is never edited after it's written, only ever
* inserted. `event_class` is the FQCN of whatever business event/action
* caused the write (e.g. Modules\Core\Order\Events\OrderDispatched, or a
* plain string like 'Modules\Core\Shipping\Extensions\OrderViewExtension::
* markDispatchedAction' for a manual Filament action that has no backing
* event class of its own) — see Modules\Core\Order\Services\
* OrderStatusTransitionRecorder.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('order_status_transitions', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('lunar_orders')->cascadeOnDelete();
$table->string('axis');
$table->string('from_status')->nullable();
$table->string('to_status');
$table->string('event_class');
$table->timestamp('created_at')->useCurrent();
$table->index(['order_id', 'axis']);
});
}
public function down(): void
{
Schema::dropIfExists('order_status_transitions');
}
};
@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Maps every existing order's flat `status` (as it stood before
* 2026_09_11_000001_add_status_axes_to_orders_table.php) onto the new
* payment_status/fulfillment_status/return_status columns. A separate
* migration from the schema change so the schema migration stays simply
* reversible via down(), and this data pass can be independently re-run.
*
* The flat status never captured refunds at all (no 'refunded' value was
* ever added to config('lunar.orders.statuses')), so the table-driven
* mapping below is corrected per-order by re-deriving
* Modules\Core\Order\Support\OrderStatus::payment() — the existing,
* unchanged derived-enum logic — and overriding payment_status to
* refunded/partially_refunded wherever it disagrees with the flat-status
* mapping. This is the one place the "keep the old derived enums" design
* decision earns its keep: refund-fraction math isn't reimplemented here,
* just reused.
*/
return new class extends Migration
{
private const MAP = [
'awaiting-payment' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
'payment-offline' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
'payment-received' => ['payment_status' => 'paid', 'fulfillment_status' => 'unfulfilled'],
'ready-for-dispatch' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
'ready-for-pickup' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
'dispatched' => ['payment_status' => 'paid', 'fulfillment_status' => 'in_transit'],
'completed' => ['payment_status' => 'paid', 'fulfillment_status' => 'completed'],
];
public function up(): void
{
Order::query()->with('transactions')->chunkById(200, function ($orders) {
foreach ($orders as $order) {
$mapped = self::MAP[$order->status] ?? null;
if ($mapped === null) {
Log::warning('Order status axis backfill: unmapped status, leaving column defaults', [
'order_id' => $order->id,
'status' => $order->status,
]);
continue;
}
$paymentStatus = $mapped['payment_status'];
$derived = OrderStatus::payment($order);
if ($derived === PaymentStatus::Refunded) {
$paymentStatus = 'refunded';
} elseif ($derived === PaymentStatus::PartialRefund) {
$paymentStatus = 'partially_refunded';
}
DB::table('lunar_orders')->where('id', $order->id)->update([
'payment_status' => $paymentStatus,
'fulfillment_status' => $mapped['fulfillment_status'],
'return_status' => 'none',
]);
}
});
}
public function down(): void
{
// Column defaults (set in the schema migration) are the correct
// "undo" — no need to reverse-map back to the flat status, since
// `status` itself was never touched by this migration.
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status/refunded_status let a merchant pick
* which per-method Order::status label a payment outcome resulted in — a
* mechanism that only made sense while Order.status was the single field
* carrying that meaning. Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus now writes a fixed 3-value payment_status
* column instead (see 2026_09_11_000001_add_status_axes_to_orders_table.php);
* there is no longer any per-method flexibility to preserve — "paid" is
* "paid" regardless of which method captured it. Dropped rather than left
* vestigial: keeping them visible in the admin would let a merchant
* configure something that silently does nothing.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn(['captured_status', 'authorized_status', 'refunded_status']);
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('captured_status')->nullable();
$table->string('authorized_status')->nullable();
$table->string('refunded_status')->nullable();
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Order::paid/paid_at — entirely independent of the `status` column (see
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
* payment timing, especially for cash-on-delivery, cannot be modeled as a
* status-sequence step). `paid` is the fast-filter boolean; `paid_at` is
* when it actually happened.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->boolean('paid')->default(false)->after('status')->index();
$table->timestamp('paid_at')->nullable()->after('paid');
});
}
public function down(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['paid', 'paid_at']);
});
}
};
@@ -0,0 +1,116 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Collapses the 3-axis (payment_status/fulfillment_status/return_status)
* model this session briefly built — abandoned before shipping — back
* onto a single `status` column plus the new independent `paid`/`paid_at`
* fields. Must run after 2026_09_12_000001 (adds paid/paid_at) and before
* 2026_09_12_000003 (drops the axis columns this migration still reads).
*
* Priority rule: axis data where it's genuinely non-default (this order
* was really moved through the axis system during this session's manual
* testing); the legacy `status` column (which may still hold pre-session
* hyphenated values) as fallback everywhere else.
*/
return new class extends Migration
{
private const LEGACY_MAP = [
'awaiting-payment' => 'awaiting_payment',
'payment-offline' => 'awaiting_payment',
'payment-received' => 'processing',
'ready-for-dispatch' => 'ready_for_dispatch',
'ready-for-pickup' => 'ready_for_pickup',
'dispatched' => 'dispatched',
'completed' => 'completed',
];
/**
* Axis fulfillment_status -> new single status, given branch. Axis
* 'delivered' folds into 'return_window_open' (same combined-value
* decision the going-forward design makes). Axis payment_status is
* used only to decide whether a fully-unfulfilled order should read
* as 'awaiting_payment' or 'processing'.
*/
private function mapFromAxes(string $payment, string $fulfillment, string $return, bool $isPickup): ?string
{
if ($return === 'returned') {
return 'returned';
}
if ($return === 'requested') {
return 'return_requested';
}
return match ($fulfillment) {
'unfulfilled' => $payment === 'paid' ? 'processing' : 'awaiting_payment',
'processing' => 'processing',
'ready' => $isPickup ? 'ready_for_pickup' : 'ready_for_dispatch',
'in_transit' => 'dispatched',
'delivered', 'return_window_open' => 'return_window_open',
'picked_up' => 'picked_up',
'completed' => 'completed',
default => null,
};
}
public function up(): void
{
Order::query()->with('transactions')->chunkById(200, function ($orders) {
foreach ($orders as $order) {
$isPickup = $order->isStorePickupOrder();
$axisIsDefault = $order->payment_status === 'awaiting_payment'
&& $order->fulfillment_status === 'unfulfilled'
&& $order->return_status === 'none';
$status = $axisIsDefault
? (self::LEGACY_MAP[$order->status] ?? null)
: $this->mapFromAxes($order->payment_status, $order->fulfillment_status, $order->return_status, $isPickup);
if ($status === null) {
Log::warning('Single-status backfill: unmapped order, defaulting to awaiting_payment', [
'order_id' => $order->id,
'status' => $order->status,
'payment_status' => $order->payment_status,
'fulfillment_status' => $order->fulfillment_status,
'return_status' => $order->return_status,
]);
$status = 'awaiting_payment';
}
$derived = OrderStatus::payment($order);
$paid = $order->payment_status === 'paid'
|| in_array($derived, [PaymentStatus::Captured, PaymentStatus::Refunded, PaymentStatus::PartialRefund], true);
// A refund implies the order concluded via a return —
// even one backfilled to an early status (e.g. an order
// refunded before fulfillment ever started) is corrected
// to refunded/partially_refunded here, not left stuck
// pre-fulfillment with no sign a refund ever happened.
if ($derived === PaymentStatus::Refunded) {
$status = 'refunded';
} elseif ($derived === PaymentStatus::PartialRefund) {
$status = 'partially_refunded';
}
DB::table('lunar_orders')->where('id', $order->id)->update([
'status' => $status,
'paid' => $paid,
'paid_at' => $paid ? ($order->placed_at ?? now()) : null,
]);
}
});
}
public function down(): void
{
// No reverse mapping — column defaults (post-rollback of the
// schema migrations) are the correct "undo".
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverses 2026_09_11_000001_add_status_axes_to_orders_table.php — the
* 3-axis model was abandoned before shipping in favor of a single
* `status` column plus independent `paid`/`paid_at` (see
* 2026_09_12_000001/000002). Must run after 2026_09_12_000002, which
* still reads these columns for the backfill.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
});
}
public function down(): void
{
// Mirrors 2026_09_11_000001's own down() — restores columns
// empty/defaulted, does not attempt to resurrect real per-order
// values.
Schema::table('lunar_orders', function (Blueprint $table) {
$table->string('payment_status')->default('awaiting_payment')->after('paid_at')->index();
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
});
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* There is only one status column left to audit (plus the synthetic
* 'paid' entry — see Modules\Core\Order\Listeners\RecordStatusTransition),
* so the `axis` column this table was created with
* (2026_09_11_000002_create_order_status_transitions_table.php) no longer
* means anything.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('order_status_transitions', function (Blueprint $table) {
$table->dropIndex(['order_id', 'axis']);
$table->dropColumn('axis');
$table->index('order_id');
});
}
public function down(): void
{
Schema::table('order_status_transitions', function (Blueprint $table) {
$table->dropIndex(['order_id']);
$table->string('axis')->default('status')->after('order_id');
$table->index(['order_id', 'axis']);
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* The seeded 'cash-on-delivery' PaymentMethod row
* (Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()) was
* wired to driver => 'offline' — the same immediate-capture driver as
* cash-in-hand. That's the bug that made COD "pay immediately" instead of
* waiting for staff to confirm cash was actually received. Repoints
* already-seeded environments to the new dedicated
* Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver; the seeder
* itself is fixed separately for fresh installs.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'cash-on-delivery']);
}
public function down(): void
{
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'offline']);
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 'return_window_open' is renamed to 'delivered' — same status value,
* same meaning (the parcel arrived AND the return window is now open,
* still one combined moment — see Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnDelivered), just a name a merchant expects to read
* on the order page rather than an internal mechanic. Also renames it in
* order_status_transitions' audit rows so the history stays consistent
* with `status` going forward.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('lunar_orders')->where('status', 'return_window_open')->update(['status' => 'delivered']);
DB::table('order_status_transitions')->where('from_status', 'return_window_open')->update(['from_status' => 'delivered']);
DB::table('order_status_transitions')->where('to_status', 'return_window_open')->update(['to_status' => 'delivered']);
}
public function down(): void
{
DB::table('lunar_orders')->where('status', 'delivered')->update(['status' => 'return_window_open']);
DB::table('order_status_transitions')->where('from_status', 'delivered')->update(['from_status' => 'return_window_open']);
DB::table('order_status_transitions')->where('to_status', 'delivered')->update(['to_status' => 'return_window_open']);
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A real record of "a manifest was issued", not just a loose
* manifest_reference string stamped onto each Shipment row — ACS's own
* ACS_Issue_Pickup_List call returns nothing beyond a PickupList_No (see
* Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService::issueManifest()),
* so this table is entirely our own bookkeeping: when the manifest was
* issued and how many shipments it included, not something re-derivable
* from the carrier later. `shipment_count` is denormalized (also
* countable via shipments()->count()) purely so the manifests list can
* render without an extra query per row.
*
* carrier-agnostic by design — see Modules\Core\Shipping\Contracts\
* SupportsManifestBatching, the same contract any future carrier
* (Speedex, etc.) implements to get manifest batching at all; this table
* has no ACS-specific columns.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('manifests', function (Blueprint $table) {
$table->id();
$table->string('carrier');
$table->string('reference');
$table->unsignedInteger('shipment_count')->default(0);
$table->timestamp('issued_at');
$table->timestamps();
$table->unique(['carrier', 'reference']);
});
}
public function down(): void
{
Schema::dropIfExists('manifests');
}
};
@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Replaces the loose manifest_reference string with a real manifests
* relation — see 2026_09_13_000002_create_manifests_table.php. Backfills
* one Manifest row per distinct (carrier, manifest_reference) pair
* already present in shipments, using the earliest label_printed_at (or
* updated_at as a fallback) among that group as a best-effort issued_at,
* since the exact original issue time was never recorded anywhere.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->foreignId('manifest_id')->nullable()->after('manifest_reference')->constrained()->nullOnDelete();
});
$groups = DB::table('shipments')
->select('carrier', 'manifest_reference')
->whereNotNull('manifest_reference')
->distinct()
->get();
foreach ($groups as $group) {
$shipments = DB::table('shipments')
->where('carrier', $group->carrier)
->where('manifest_reference', $group->manifest_reference)
->get();
$issuedAt = $shipments->pluck('label_printed_at')->filter()->min()
?? $shipments->pluck('updated_at')->min();
$manifestId = DB::table('manifests')->insertGetId([
'carrier' => $group->carrier,
'reference' => $group->manifest_reference,
'shipment_count' => $shipments->count(),
'issued_at' => $issuedAt,
'created_at' => $issuedAt,
'updated_at' => $issuedAt,
]);
DB::table('shipments')
->where('carrier', $group->carrier)
->where('manifest_reference', $group->manifest_reference)
->update(['manifest_id' => $manifestId]);
}
Schema::table('shipments', function (Blueprint $table) {
$table->dropColumn('manifest_reference');
});
}
public function down(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->string('manifest_reference')->nullable()->after('parent_reference');
});
DB::table('shipments')
->whereNotNull('manifest_id')
->orderBy('id')
->each(function ($shipment) {
$manifest = DB::table('manifests')->find($shipment->manifest_id);
if ($manifest) {
DB::table('shipments')->where('id', $shipment->id)->update([
'manifest_reference' => $manifest->reference,
]);
}
});
Schema::table('shipments', function (Blueprint $table) {
$table->dropConstrainedForeignId('manifest_id');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Caps brute-forcing a 6-digit OTP code (1M combinations, 10-minute
* window, previously uncapped) — see Modules\Core\Auth\Services\
* UserOtpService::validate(), which now invalidates the code entirely
* (forcing a fresh generateAndSend()) once otp_attempts reaches its max,
* rather than leaving a live code guessable indefinitely within its
* expiry window.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedTinyInteger('otp_attempts')->default(0)->after('otp_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('otp_attempts');
});
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A per-login session registry, independent of the actual session store
* driver (SESSION_DRIVER=redis in this app — no "sessions" table to
* purge by user_id the way the database driver would allow). Each
* successful OTP login (Modules\Core\Auth\Services\UserOtpService::
* validate()) records one row here and stamps the token into the
* Laravel session payload; Modules\Core\Auth\Http\Middleware\
* EnsureSessionNotRevoked checks it on every request. "Logout
* everywhere" (Modules\Core\Auth\Services\UserSessionService::
* revokeOtherSessions()) is then just marking every OTHER row
* revoked_at, no session-store-specific logic anywhere.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('user_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('token', 64)->unique();
$table->string('user_agent')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamp('last_used_at');
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'revoked_at']);
});
}
public function down(): void
{
Schema::dropIfExists('user_sessions');
}
};
@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Language;
/**
* PaymentMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}), rendered in Filament
* via Lunar's own Lunar\Admin\Support\Forms\Components\TranslatedText —
* the same reusable component/data-shape Product/Collection names already
* use (Lunar\Base\Traits\HasTranslations), just applied directly to a
* plain column here rather than through attribute_data, since
* PaymentMethod is a merchant-configured settings row, not a translatable
* catalog attribute.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet — this
* migration can run before lunar:install seeds one) rather than dropped,
* so an already-configured payment method's name isn't blanked out.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (which requires doctrine/dbal — not installed in this project) —
* Postgres-specific (this project runs on `pgsql`, per its own docker
* setup), with an explicit USING clause since json isn't implicitly
* castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name DROP DEFAULT');
DB::statement("ALTER TABLE payment_methods ALTER COLUMN name TYPE json USING NULL");
foreach ($existing as $id => $name) {
if ($name === null) {
continue;
}
DB::table('payment_methods')
->where('id', $id)
->update(['name' => json_encode([$defaultLocale => $name])]);
}
}
public function down(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name TYPE varchar(255) USING NULL');
foreach ($existing as $id => $name) {
$decoded = json_decode((string) $name, true);
$flat = is_array($decoded) ? ($decoded[$defaultLocale] ?? reset($decoded) ?: null) : $name;
DB::table('payment_methods')->where('id', $id)->update(['name' => $flat]);
}
}
};
@@ -0,0 +1,74 @@
<?php
use Illuminate\Support\Facades\DB;
use Lunar\Base\Migration;
use Lunar\Models\Language;
/**
* ShippingMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Standard Delivery", "el": "Κανονική Παράδοση"}), rendered in
* Filament via Lunar's own Lunar\Admin\Support\Forms\Components\
* TranslatedText (Modules\Core\Shipping\Extensions\
* ShippingMethodResourceExtension::replaceNameField()) — same shape/
* resolution as PaymentMethod.name (see its own migration,
* 2026_09_15_000001_make_payment_methods_name_translatable.php) and
* Product/Collection names (Lunar\Base\Traits\HasTranslations).
*
* ShippingMethod is a vendor (lunarphp/table-rate-shipping) table, but
* converting a vendor column's type via a migration is no different from
* any other schema change this project already makes against a vendor
* table (see database/migrations/2026_08_31_000001_create_payment_methods_table.php's
* sibling migrations for the same pattern against PaymentMethod) — there
* was no good reason to route this through `data.name` instead, unlike
* `data.fulfillment_type` which is a genuinely NEW field the vendor table
* never had at all.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet)
* rather than dropped.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (requires doctrine/dbal — not installed in this project) — Postgres-
* specific (this project runs on `pgsql`), with an explicit USING clause
* since json isn't implicitly castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// The column is NOT NULL (vendor migration never marked it
// nullable) — converting via `USING NULL` first, then
// backfilling with a second UPDATE, violates that constraint
// before the backfill ever runs. json_build_object() converts
// each existing string in place, in the same statement, so the
// column is never transiently NULL. $defaultLocale is inlined
// (not bound) — parameter binding inside an ALTER TABLE ... USING
// expression isn't reliable across drivers; it's a Language::code
// value we control, not user input, so quote_literal-safe
// interpolation here is fine.
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement("ALTER TABLE {$table} ALTER COLUMN name TYPE json USING json_build_object({$quotedLocale}, name)");
}
public function down(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// Same NOT NULL constraint applies going back — ->>'{locale}'
// extracts the default locale's text value directly in the
// USING clause, falling back to the first key present via
// COALESCE for any row missing that locale (e.g. one only ever
// filled in via a non-default language).
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement(
"ALTER TABLE {$table} ALTER COLUMN name TYPE varchar(255) ".
"USING COALESCE(name->>{$quotedLocale}, (SELECT value FROM json_each_text(name) LIMIT 1))"
);
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Per-product, customer-authored input fields — a personalized-statue
* product needing a reference photo upload and an optional engraving
* textarea, for example. Deliberately NOT modeled as a Lunar ProductOption
* (see Modules\Core\Catalog\Contracts\ProductOptionTypeInterface's own
* docblock): an option's values are a fixed, admin-authored list that
* define variants (Red/Green/Blue) — a photo upload has no such list, it's
* unique per order, and creates no variant at all. This is a genuinely
* different concept that happens to configure on the same product page.
*
* Array of {key, type: 'text'|'textarea'|'file', label, required} — `key`
* is what a submitted answer is keyed by in CartLine/OrderLine.meta (both
* already have a `meta` json column — see Modules\Core\Cart\Services\
* CartService::addLine()'s own $meta parameter), not a new table, since
* this is small, rarely-queried per-product config, the same reasoning
* ShippingMethod.data/PaymentMethod.data already follow for their own
* per-row settings.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->json('custom_fields')->nullable()->after('attribute_data');
});
}
public function down(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->dropColumn('custom_fields');
});
}
};
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A generic, storage-backend-agnostic file registry — Modules\Core\File\
* Services\FileService's own backing table. `disk`/`path` are whatever
* Laravel's Storage facade already understands (local, s3, ...); this
* table adds what Flysystem itself has no concept of: who a file
* belongs to, why it was uploaded, and whether anything still needs it.
*
* `owner_type`/`owner_id` are nullable — a file can (and, for a product
* custom-field photo, always does) exist before anything owns it yet: a
* shopper picks a photo on the product page and it's uploaded immediately
* (see 3dealer's CustomFieldUploadController), well before add-to-cart
* gives it a CartLine to belong to. FileService::attachOwner() re-points
* these columns once an owner exists, rather than creating a second row
* for the same physical file.
*
* `purpose` (e.g. 'custom-field-upload') lets one table serve unrelated
* future features without collision — FileService itself has no
* knowledge of what a purpose means, callers scope their own queries by
* it.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('files', function (Blueprint $table) {
$table->id();
$table->string('disk');
$table->string('path');
$table->string('original_name')->nullable();
$table->string('mime')->nullable();
$table->unsignedBigInteger('size')->nullable();
$table->string('purpose');
$table->nullableMorphs('owner');
$table->timestamps();
$table->index(['purpose', 'owner_type', 'owner_id']);
});
}
public function down(): void
{
Schema::dropIfExists('files');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Which terms/privacy policy version an account was created under — the
* storefront login page shows a notice ("By continuing, you accept the
* Terms of Use and have read the Privacy Policy") that a new signup
* implicitly agrees to just by requesting an OTP code, so this is
* recorded the moment Modules\Core\Auth\Services\UserOtpService::
* generateAndSend()'s firstOrCreate() actually creates the row — never
* for an existing user, whose original acceptance (whatever version was
* live at the time) must not be silently overwritten by a later config
* value. Nullable: every user created before this migration has none of
* the three, which is the honest answer ("we don't know what they saw"),
* not something to backfill with today's config values.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('terms_accepted_at')->nullable()->after('otp_attempts');
$table->string('terms_version')->nullable()->after('terms_accepted_at');
$table->string('privacy_policy_version')->nullable()->after('terms_version');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['terms_accepted_at', 'terms_version', 'privacy_policy_version']);
});
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Backs Modules\Core\Customer\Services\CustomerEmailChangeService — the
* pending new-email change lives on the user's own row, same convention
* as the existing otp_code/otp_expires_at/otp_attempts columns (Auth\
* Services\UserOtpService), rather than the session: a change requested
* on one device/session must still be confirmable from another (a code
* arrives by email, which is often opened somewhere else entirely), and
* a request-scoped session can't survive that.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('pending_email')->nullable()->after('privacy_policy_version');
$table->string('pending_email_code_hash')->nullable()->after('pending_email');
$table->timestamp('pending_email_expires_at')->nullable()->after('pending_email_code_hash');
$table->unsignedTinyInteger('pending_email_attempts')->default(0)->after('pending_email_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'pending_email',
'pending_email_code_hash',
'pending_email_expires_at',
'pending_email_attempts',
]);
});
}
};
+48 -40
View File
@@ -1,28 +1,34 @@
# 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.
every cart in the Filament admin panel, guest carts included. 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
## Scope: every cart, identified or not
`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')`
— an anonymous guest's session cart is excluded entirely.
`CartResource` lists every cart the four lifecycle states (below) cover, with no
`user_id`/`customer_id` filter — an anonymous guest's session cart is included.
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.
This was a reversal of an earlier, deliberate call to exclude guest carts entirely (on the
reasoning that 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). That reasoning holds for "can I click through to a Customer record,"
but not for the resource's other real use — seeing how many carts are ongoing/abandoned right
now regardless of who's shopping. Most real storefront traffic never reaches an identified
user/customer, so excluding it silently undercounts exactly the thing `ListCarts`'s tabs (and
`CartLifecycleService`, which they and `DetectAbandonedCarts` both build on) exist to report
on. The `Customer`/`User` columns on a guest row just render "—" (Filament's `placeholder()`)
instead of a link — nothing to click into, but the row and its contents are still visible via
`ViewCart`.
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`).
---
@@ -40,28 +46,29 @@ distinct states together: no order ever started, vs. a draft order exists
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:
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`.
`Modules\Core\Cart\Services\CartLifecycleService` is the single source of truth for these four
query shapes — both `ListCarts::getTabs()` (staff browsing) and `DetectAbandonedCarts`
(abandonment-event dispatch) build on it, rather than each reimplementing the same split
independently (which is what happened before this service existed, and is exactly the kind of
drift that lets the admin panel and the recovery-email pipeline quietly disagree about what
"abandoned" means):
```php
// Ongoing
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
- **Ongoing** (`ongoing()`) — `scopeActive()` and recent `updated_at` (within
`abandonedCutoff()`). Default active tab on page load.
- **Abandoned Cart** (`abandonedCarts()`) — `whereDoesntHave('orders')` and stale
`updated_at`.
- **Abandoned Checkout** (`abandonedCheckouts()`) — has an order with `placed_at IS NULL`,
and stale `updated_at`.
- **Completed** (`completed()`) — has an order with `placed_at IS NOT NULL`.
// 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'));
```
Each method takes a `Builder` and returns it further scoped, so callers compose it onto
whatever base query they already have (`CartResource::getEloquentQuery()` for the Filament
tabs, a bare `Cart::query()` for the command). Deliberately query-shape-only: consent
(`meta->recovery_consent`) and non-empty-lines filtering stay in `DetectAbandonedCarts`, not on
the service — those gate whether a recovery *event* should fire, not what "abandoned" means to
a staff member browsing the list.
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
@@ -111,7 +118,7 @@ runs once per admin page load, not once per cart row.
```php
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->count();
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
```
@@ -235,9 +242,10 @@ just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (v
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.
builds on the same `CartLifecycleService::abandonedCarts()`/`abandonedCheckouts()` queries
`ListCarts::getTabs()` uses (no order at all vs. draft order never placed) and dispatches
`Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` for anything currently stale
that also has `meta->recovery_consent = true`.
### Cart/Checkout have zero abandonment-related writes — by design
+10 -2
View File
@@ -49,7 +49,8 @@ boboko-test/
app/
Models/
Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer
User.php ← app-level model, dispatches Modules\Core\Auth\Events\UserCreated
User.php ← app-level model, no $dispatchesEvents needed — core dispatches
UserCreated itself (Modules\Core\Auth\Services\UserOtpService)
Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff
Lunar/
Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider)
@@ -264,7 +265,14 @@ php artisan vendor:publish --tag=core-config
'auto_create_customer_for_user' => false,
```
Both listeners guard against the other direction re-triggering: they call `User::withoutEvents(...)` around `firstOrCreate`/save, so pairing a `Customer` never spuriously fires `UserCreated` (and vice versa) even if both directions are somehow active at once.
A guard against the other direction re-triggering is only needed where a real risk exists:
`App\Listeners\CreateUserForCustomerListener` (`boboko-test`, app-level) wraps its
`firstOrCreate` in `User::withoutEvents(...)`, since finding-or-creating a `User` there could
itself fire `UserCreated` and loop back into `CreateCustomerForUser`. `Modules\Core\Customer\
Listeners\CreateCustomerForUser` (core) needs no such guard — it calls a plain
`$model::create([])` on `Customer`, which has no `$dispatchesEvents`/model hooks of its own in
core that could re-trigger anything; the guard belongs only on the side that actually creates a
`User`.
---
+228
View File
@@ -0,0 +1,228 @@
# Payment — Design Notes
**Status: abstraction layer built, drivers/wiring in progress.** `Payment` is designed as a
standalone module: it never calls into `Checkout` or `Order`, never touches their Eloquent
models, and communicates only via events. This document is the design spec for that
abstraction — contracts, DTOs, events — independent of how `Checkout`/`Order` end up consuming
it (that wiring is a separate, later pass).
---
## Operations, not gateways
The driver contracts model the actual operations a payment gateway can perform, not vendor
terminology. Every real gateway checked while designing this converges on the same small set
under different names:
| Operation | Mastercard | Stripe | Nexi |
|---|---|---|---|
| Atomic charge (authorize+capture in one call) | `Pay` | `capture_method: automatic` | `ActionType::PAY()` |
| Hold only, settle/release later | `Authorize` | `capture_method: manual` | `ActionType::PREAUTH()` |
| Settle a prior hold | `Capture` | `PaymentIntent::capture()` | `CaptureRequest`/`CaptureResponse` |
| Release a prior hold without settling | `Void`/`Cancel` | `PaymentIntent::cancel()` | `CancelRequest`/`CancelResponse` |
| Reverse settled funds | `Refund` | `Refund::create()` | (refund endpoint) |
A driver implements only the interfaces its gateway actually supports:
- An offline/cash type (`cash-on-delivery`, `cash-in-hand`) only ever settles atomically —
implements `SupportsPay` alone.
- A card gateway capable of either mode per-transaction (Stripe, most card processors)
implements `SupportsPay`, `SupportsAuthorization`, `SupportsCaptures`, `SupportsVoids`, and
`SupportsRefunds` all at once — which one gets *called* for a given attempt is the caller's
policy choice (e.g. `config('lunar.stripe.policy')`), not something baked into the driver's
shape.
- A redirect/wallet gateway with no separate hold step (Viva/Klarna in typical flows)
implements `SupportsPay` and `SupportsRefunds`, never `SupportsCaptures`/`SupportsVoids`.
### `pay()` and `authorize()` stay separate methods even when a gateway implements both as "the same call with a flag"
Stripe has no separate `authorize`/`pay` API endpoints — one `PaymentIntent`, confirmed with
either `capture_method: automatic` or `manual`. Mastercard and Nexi *do* have genuinely
separate operations. The contract abstracts over both shapes uniformly: every driver capable
of both exposes two distinct methods, `pay()` and `authorize()`. A Mastercard-style driver
calls two different endpoints under the hood; a Stripe-style driver calls the same endpoint
twice with a different flag each time. Neither difference is visible to a caller.
### `capture()`/`void()` are only ever valid against a prior `authorize()`
They are not standalone operations — `capture()` settles a specific hold identified by the
`reference` `authorize()` returned; `void()` releases that same hold instead. A driver that
never implements `SupportsAuthorization` never produces a reference either of these methods
could act on.
---
## `PaymentResult` — the one return shape, every operation, every driver
```php
enum PaymentResultStatus { case Succeeded; case Failed; case Pending; }
final class PaymentResult {
public function __construct(
public readonly PaymentResultStatus $status,
public readonly string $reference,
public readonly int $amount,
public readonly ?string $failureReason = null,
public readonly bool $retriable = false,
public readonly array $raw = [],
public readonly array $meta = [],
) {}
}
```
Real gateway responses vary wildly in richness — confirmed by reading three SDKs directly:
- **Stripe's `PaymentIntent`** is rich: `status`, `amount`, `amount_capturable`,
`amount_received`, `last_payment_error`, a full `getLastResponse()`.
- **Nexi's `CaptureResponse`/`CancelResponse`** are minimal: just `operationId` +
`operationTime` — no echoed amount or status at all. Success is inferred from getting a
response rather than an SDK exception.
- **Mastercard's** gateway sits in between, with `gatewayCode`/`acquirerCode`/
`merchantAdviceCode`.
`PaymentResult` only requires what every driver can always know: `status`, `reference`,
`amount` (the amount **we** requested — not necessarily echoed back by a sparse gateway like
Nexi's capture). Everything else is best-effort: `failureReason`/`retriable` are normalized
only when the gateway has something to normalize from; `raw` is the unconditional escape
hatch — the untouched gateway response body, always populated, for genuine audit fidelity
regardless of how sparse the normalized fields ended up.
### `retriable` — real on some gateways, absent on others
Stripe classifies declines as soft (`do_not_honor`, `insufficient_funds` — worth retrying,
after a delay) vs. hard (`stolen_card`, `expired_card` — never retry the same method).
Mastercard has the equivalent via `authorizationResponse.merchantAdviceCode` and card-scheme
soft-decline codes. **Nexi has no such signal at all** — `OperationResult` is just
`DECLINED`/`DENIED_BY_RISK`/`FAILED`/etc. with no retriability classification. `retriable`
therefore defaults to `false` (assume not safely retriable) rather than guessing when a
driver's gateway has nothing to base it on.
---
## Events — one terminal pair per operation, keyed to the business fact, not the call path
`Modules\Core\Payment\Events`:
| Event pair | Dispatched by |
|---|---|
| `PaymentAuthorized` / `PaymentAuthorizationFailed` | `SupportsAuthorization::authorize()`, or a later `HandlesPaymentCallback::handleCallback()` resolving it |
| `PaymentCaptured` / `PaymentCaptureFailed` | `SupportsPay::pay()` **or** `SupportsCaptures::capture()` |
| `PaymentVoided` / `PaymentVoidFailed` | `SupportsVoids::void()` |
| `PaymentRefunded` / `PaymentRefundFailed` | `SupportsRefunds::refund()` |
`PaymentCaptured` is deliberately the *same* event whether money was taken via `pay()` (one
gateway call) or `authorize()` → `capture()` (two calls) — "a payment has been captured" is
the same business fact either way, and a listener reacting to it never needs to know which
path produced it. There is no separate "payment succeeded" wrapper event distinct from
`PaymentCaptured`.
Every event carries `{type: string, result: PaymentResult, context: array}`. `Payment` has no
concept of a `Cart`, an `Order`, or a checkout fingerprint — `$context` is an opaque bag the
caller hands in on the way down (`pay($type, $data, $context)`) and gets back untouched on
whichever event that call (or a later `handleCallback()`) produces. Each listener interprets
`$context` on its own terms, or ignores the event if the keys it needs aren't present —
`Checkout` is only one possible consumer of these events, not the only one.
---
## Async resolution — `HandlesPaymentCallback`
Only implemented by a driver whose `pay()`/`authorize()` can return `PaymentResultStatus::Pending`
— a redirect the shopper completes elsewhere, a webhook that arrives later. A driver whose
gateway always resolves synchronously never implements this.
```php
public function handleCallback(string $reference, array $data, array $context = []): PaymentResult;
```
Resolves into the *same* event pair the original `pay()`/`authorize()` call would have
produced had it resolved synchronously.
### The correlation problem: `handleCallback()` runs in a different request
`$context` passed into the original `pay()`/`authorize()` call does not survive to
`handleCallback()` on its own — that call is typically a separate HTTP request (a webhook)
with no memory of the request that started the payment. Something has to persist enough to
answer "which order/cart does gateway reference X belong to?" between the two calls.
The precedent for this originally came from reading `lunarphp/stripe`'s own source
(`StripePaymentType::authorize()`, `ProcessStripeWebhook`, `WebhookController`) — that package
solved this the same way, writing the correlating ids as real, typed columns on its own
`StripePaymentIntent` model rather than a generic opaque blob. **`lunarphp/stripe` has since
been removed from this project** in favour of depending on `stripe/stripe-php` directly (see
CHANGELOG.md) — `Modules\Core\Payment\Models\StripePaymentIntent` is now a first-party model
over the same table shape, kept for exactly the same reason.
**`StripePaymentDriver` follows this pattern**: it reads `cart_id`/`order_id` out of `$context`
at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent` row (`src/
Payment/Models/StripePaymentIntent.php`, table `stripe_payment_intents`), then reads them back
the same way in `handleCallback()`. No generic `context` json column beyond what that table
already carries (`context`, added for a different purpose — see that migration's own
docblock), no new table.
### This pattern is per-driver, not a shared table
`stripe_payment_intents` is Stripe-specific — keyed on `intent_id`, typed around
`Stripe\PaymentIntent`'s own status values. It cannot be reused as-is for a future non-Stripe
async driver (Nexi, Viva): that driver's own gateway reference has a different shape entirely,
and shoehorning it into Stripe-named columns would make the table misleading. The **pattern**
generalizes — *any* driver needing async callback resolution owns a small table keyed by its
own gateway's reference, storing whatever correlation data that driver specifically needs —
but each driver gets its own table, matching what it actually needs to correlate, rather than
a shared generic one.
---
## Reconciliation — a charge that succeeds on Stripe but is never written locally
This app never creates or reuses a Stripe **Customer** object — every PaymentIntent is a
one-off (`StripePaymentDriver::createAndConfirm()`'s own `$params` never includes a `customer`
key), and nothing calls Stripe's Customer API anywhere in this codebase. That's a deliberate
choice, not an oversight: a Customer object only earns its keep if something actually needs it
(saved/reusable payment methods, subscriptions, Stripe-side lifetime-value grouping across
orders) — none of which exist in this checkout flow today. Creating one anyway would just be
more PII sitting on a third party's servers for no functional benefit, and it would become
another cross-reference a future Payment privacy provider has to account for (detaching/
deleting the Customer on erasure, not just the local PaymentIntent row). If a real feature
needs it later (e.g. "save my card"), add it then, scoped to that feature.
The gap this creates: with no Customer object and no other identifying field previously sent
to Stripe, a PaymentIntent that succeeds on Stripe's side but is never written to our own DB
(e.g. a database outage at exactly the wrong moment, between Stripe confirming the charge and
`rememberIntent()`'s insert) would be **untraceable** back to a cart or order — nothing to
search Stripe's dashboard by except amount, timestamp, and card last-4.
**Fix**: `createAndConfirm()` now sets `metadata: ['cart_id' => ..., 'order_id' => ...]`
(`array_filter()`-ed, since `order_id` isn't known yet at initial `pay()`/`authorize()` time —
same null-coalesce `rememberIntent()` already does) on every PaymentIntent. This is metadata
only, visible on Stripe's own dashboard/API for manual reconciliation — it does not create a
Customer object and does not change anything about how `handleCallback()`/webhook correlation
works (that still goes through `stripe_payment_intents`, per "Async resolution" above). It's
purely a recovery aid for the case where our own write never happened at all.
---
## GDPR erasure/export
`Modules\Core\Payment\Privacy\PaymentDataProvider` covers `lunar_transactions`
(`card_type`/`last_four`) and `stripe_payment_intents` — see `docs/privacy.md` for the full
right-of-erasure/right-of-access design. Pseudonymizes card metadata on erasure (same
tax/accounting retention reasoning `Order`'s own provider uses) and deletes the Stripe
correlation rows outright, since their only purpose — resolving an async webhook callback, see
"Async resolution" above — has already been served by the time an erasure request runs. No
Stripe Customer object exists anywhere in this app (see "Reconciliation" above) for this
provider to also request deletion of.
---
## Explicitly out of scope for this pass
- **`Checkout`/`Order` wiring** — how `Checkout` calls into `Payment`, how `Order`/`Checkout`
react to `Payment`'s events, where a draft `Order` gets created relative to when `Payment` is
called. Deliberately designed and built separately, after `Payment` itself was complete —
`Payment` must stand on its own regardless of what ends up consuming it.
- **`Transaction` persistence** — Lunar's own `transactions` table (`type`: `intent`/`capture`/
`refund`, `parent_transaction_id` chaining) already models the audit trail these events
would feed, once a listener is built to write to it. `Payment` itself does not write
`Transaction` rows — see the events table above; that is a listener's job, in whichever
module ends up owning the write (likely `Order`, since `Transaction.order_id` is required).
+417
View File
@@ -0,0 +1,417 @@
# Privacy / GDPR Data-Subject Requests
`Modules\Core\Privacy` implements the right of access (export) and right of erasure for
customers, as an extensible contract rather than a fixed list of tables — any module (core,
or a future ERP/banking/etc. module) can register its own data without core knowing it exists.
---
## User-scope vs Customer-scope — two genuinely different operations
A Lunar `Customer` (business account: orders, addresses, buyer record) and a `User` (individual
login identity) are linked many-to-many via the `customer_user` pivot (see `docs/modules.md`
"Customer/User Pairing") — **one User can belong to many Customer accounts, and one Customer
account can have many linked Users.** This is the real shape of B2B multi-seat access: a person
can have login access to several separate business accounts, and a business account can have
several employees each with their own login.
That means "delete my personal data" and "delete this business account" are not the same request,
and conflating them is actively wrong:
- **Erasing a Customer must never touch any linked User's login or identity.** Erasing "Acme
Corp" must not deactivate or destroy access for the employees who work there — and must not
touch any *other* Customer account, even one sharing some of the same Users.
- **Erasing a User must never touch any Customer account's own data.** John asking to delete
*his* account must clear his name/email/login wherever it appears — and correctly end his
membership on every Customer he's linked to (detach the pivot) — but must not erase Acme Corp's
orders or addresses, and must not affect any other employee still linked to Acme Corp.
Every part of this module is split along that line — a `PersonalDataProvider`, a `PrivacyService`
method, a request record — is always explicitly **for a Customer** or **for a User**, never both
at once, and never one with an implicit cascade into the other.
---
## Why an extensible contract, not a hardcoded script
A GDPR erasure/export request has to touch every module that holds personal data, but core can't
know in advance what future modules will exist or what data they'll hold — and different data
needs fundamentally different handling (freely erasable PII vs. financial records that must be
pseudonymized-not-deleted for legal retention vs. data that must be retained outright). There's
deliberately no central taxonomy for this in the contract — each module owns its own retention
judgment, since only the module that owns a table actually knows its legal requirements.
`Modules\Core\Privacy\Contracts\PersonalDataProvider` is the whole contract:
```php
interface PersonalDataProvider
{
public function name(): string;
public function exportForUser(UserSubject $subject): ProviderExportResult;
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult;
public function eraseForUser(UserSubject $subject): ProviderErasureResult;
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult;
}
```
Every provider implements all four methods. A provider with nothing relevant to one scope
implements that method as a no-op — `ErasureOutcome::Skipped` with a reason for erase, an empty
payload for export (e.g. `AddressDataProvider::eraseForUser()`, since addresses belong to a
Customer, not an individual).
A provider implementation lives inside the module that owns the data it erases/exports, under
that module's own `Privacy/` subdirectory (e.g. `Modules\Core\Order\Privacy\OrderDataProvider`,
`Modules\Core\Customer\Privacy\CustomerDataProvider`) — never inside `Modules\Core\Privacy`
itself, which only owns the shared contract (`Contracts\PersonalDataProvider`), the request
lifecycle (`Services\PrivacyManager`/`PrivacyService`), and the DTOs/enums every provider
returns. This mirrors how this codebase already handles other cross-cutting-but-domain-specific
code (e.g. a resource's own `Filament/Extensions/` subdirectory) — and matters concretely if a
module is ever extracted into its own composer package (see `docs/modules.md`): the provider
that knows how to erase that module's data must travel with it, not get stranded in `Privacy`
depending on a package that no longer ships in this repo.
A module registers by adding its provider class to `config('core.privacy.providers')` — the
same shape as Lunar's own `config('lunar.search.indexers')` model→indexer map:
```php
// config/core.php
'privacy' => [
'providers' => [
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
// A future module just adds its own provider here.
],
],
```
`PrivacyManager` resolves each class via the container and asserts every `name()` is unique —
two providers registering the same name throws, so a naming collision fails loudly at
resolution time rather than silently overwriting one provider's data in an export/report.
---
## `UserSubject` and `CustomerSubject` — identifying "the person" vs "the account"
Two separate value objects, not one — each deliberately carries only what its own scope needs, so
a provider can't accidentally reach across the boundary:
```php
class CustomerSubject
{
public readonly int $customerId;
// No userIds, no email — Customer-scope has no business knowing about logins.
}
class UserSubject
{
public readonly int $userId;
public readonly ?string $email;
// No customerId — one User can be linked to many Customers; a provider that
// needs to know which ones looks that up itself (e.g. to detach the pivot),
// rather than this value object assuming or privileging any single one.
}
```
`CustomerSubject::forCustomer(Customer $customer)` and `UserSubject::forUser($user)` build one
from the record staff (or the person themselves) look up.
---
## Providers shipped in core
| Provider | `name()` | Lives in | Covers | Customer-scope | User-scope |
|---|---|---|---|---|---|
| `ActivityLogDataProvider` | `activity_log` | `Modules\Core\Logging\Privacy` | `activity_log` (Spatie) for subject types `Customer`/`Address`/`CartAddress`/`OrderAddress`/`Transaction` | **Pseudonymized** — `properties` redacted, who/what/when metadata kept | Skipped — `causer_id` is an actor reference, not PII content; see below |
| `CustomerDataProvider` | `customer` | `Modules\Core\Customer\Privacy` | `lunar_customers`, and separately the `User`'s own name/email/OTP fields | Erases the account's own fields only | Erases that User's name/email/OTP fields only, and detaches them from every linked Customer |
| `AddressDataProvider` | `addresses` | `Modules\Core\Customer\Privacy` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual |
| `OrderDataProvider` | `orders` | `Modules\Core\Order\Privacy` | `lunar_orders`, `lunar_order_addresses`, and their `meta` (`terms_accepted*`, `payment_method`, `box_now_locker`) | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual |
| `CartDataProvider` | `carts` | `Modules\Core\Cart\Privacy` | `lunar_cart_addresses`, and `lunar_carts.meta` (`recovery_consent*`, `payment_method`, `checkout_fingerprint`) | Erased | Skipped — belongs to a Customer, not an individual |
| `ReviewDataProvider` | `reviews` | `Modules\Core\Review\Privacy` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept |
| `PaymentDataProvider` | `payments` | `Modules\Core\Payment\Privacy` | `lunar_transactions` (`card_type`/`last_four`), `stripe_payment_intents` | **Pseudonymized** — card metadata cleared, correlation rows deleted, amounts/statuses kept | Skipped — belongs to Customer-owned orders, not individual users |
| `UserSessionDataProvider` | `sessions` | `Modules\Core\Auth\Privacy` | `user_sessions` (`ip_address`, `user_agent`) | Skipped — belongs to an individual User, not a business account | Erased (deleted outright) |
`CustomerDataProvider` is the one provider that implements both scopes meaningfully, and keeps
them from touching each other — see the class docblock for the full reasoning.
### `activity_log` is redacted by subject, never by causer
`Modules\Core\Logging\ActivityLogService` (plus several Lunar models' own native `use
LogsActivity` — `Customer`, `CartAddress`, `OrderAddress`, `Transaction`) durably retains a full
snapshot of whatever it logged in `properties`, completely independent of the real row it
describes — erasing/pseudonymizing a `Customer`/`Address`/`Order`/etc. elsewhere does nothing to
this table on its own. `ActivityLogDataProvider::eraseForCustomer()` redacts `properties` on
every row whose **subject** (not causer) resolves back to that customer, across all five
PII-bearing subject types.
It deliberately never touches `causer_id` — the causer is "who performed this action," not PII
content, and erasing it would defeat the audit trail's own purpose. `eraseForUser()` is
therefore a no-op: a `User` appears in this table only as a causer, never as subject content, so
there's nothing to redact from the User side alone.
**Ordering dependency**: `ActivityLogDataProvider` must run *before* `AddressDataProvider` in
`config('core.privacy.providers')` — it resolves which `activity_log` rows are keyed by an
`Address` id while those Address rows still exist; `AddressDataProvider` then hard-deletes them.
Reversing the order would make matching those rows impossible once the addresses are gone.
**`ReviewDataProvider` needs review.** It moved from Customer-scope to User-scope on the
reasoning that authorship is a personal attribute, not a business-account attribute — but this
hasn't been fully validated against how reviews are actually attributed in this codebase. The
class carries a `NEEDS REVIEW` note; revisit before relying on it for a real request.
### Orders are pseudonymized, not deleted
GDPR Art. 17(3)(b) explicitly allows retaining data an erasure request would otherwise cover,
when a legal obligation requires it — tax/accounting law generally requires invoices be kept for
several years. `OrderDataProvider::eraseForCustomer()` clears the free-text PII fields on `Order`/
`OrderAddress` (`customer_reference`, `notes`, name/address/contact fields) but leaves the order
row, totals, line items, and tax data fully intact. Its `ProviderErasureResult` reports
`ErasureOutcome::Pseudonymized`, not `Erased` — a compliance report or admin UI can see exactly
why an order wasn't deleted without reading `OrderDataProvider`'s source.
### Reviews are matched by email — a real, documented limitation
`ProductReview` has no FK to Customer/User at all (see `docs/product-listing.md` "Reviews") —
it's deliberately anonymous, just free-text `reviewer_name`/`reviewer_email`. `ReviewDataProvider`
matches by `reviewer_email` against `UserSubject::$email`; a review submitted under a different
email than the one on file simply won't be found. There's no stronger signal available without
changing `ProductReview`'s schema.
### Staff/employee data is out of scope
`Staff` (admin/panel employees) is never a `UserSubject`/`CustomerSubject` at all — this feature
is scoped to customer-initiated and staff-initiated-on-a-customer's-behalf requests. An employee's
own data (a different HR/access-management concern) isn't reachable through this flow.
---
## Erasure isn't immediate — a cancellable grace period
`PrivacyService` has parallel methods for each scope: `requestErasureForCustomer()` /
`requestErasureForUser()`. Neither erases anything immediately. Each opens a `DataErasureRequest`
(`pending`, `scheduled_for` = now + `config('core.privacy.grace_period_days')`, default 30). This
mirrors Shopify's own account-deletion flow: a window where the subject can change their mind
before anything is actually erased.
**Only the User-scoped request deactivates a login.** `requestErasureForCustomer()` deactivates
no one — a business-account erasure must never block anyone's access.
`requestErasureForUser()` deactivates that one User's login (blocks it — see
`Modules\Core\Auth\Services\UserOtpService` — nothing else changes).
```php
use Modules\Core\Privacy\Services\PrivacyService;
$service = app(PrivacyService::class);
// Customer-scoped: either the Customer itself (self-service) or a Staff member.
$request = $service->requestErasureForCustomer($customer, $requestedBy);
// User-scoped: either the User itself (self-service) or a Staff member.
$request = $service->requestErasureForUser($user, $requestedBy);
// Cancel before scheduled_for — for a User-scoped request, reactivates the
// account. A Customer-scoped request never deactivated anything, so there's
// nothing to reactivate for it.
$service->cancelErasure($request);
```
### Logging back in during the grace period cancels the request automatically
Authentication is never blocked by deactivation — `UserOtpService::validate()` still requires
the correct OTP code. Once validated, it dispatches `Modules\Core\Auth\Events\UserAuthenticated`;
`Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener` (registered in
`PrivacyServiceProvider`, **queued** — see below) looks for a pending request keyed on *that
User's own id* — never a Customer-scoped one, since Customer-scope never deactivates a login in
the first place — and calls `cancelErasure()` on it, then reverts every Customer erasure request
it caused (see "The sole-owner cascade" below). Logging back in **is** the "I changed my mind"
action — no separate UI/flow needed for reactivation.
This listener is queued rather than synchronous, so login returns to the browser without waiting
on the bookkeeping. Nothing else in this codebase currently reads `deactivated_at` besides this
listener and `PrivacyService` itself — `UserOtpService::validate()` never gates the login on it —
so the brief window between the login response and the job actually running has no other consumer
to observe it as stale.
### The sole-owner cascade — erasing the last User on a Customer also erases the Customer
If a User is erased and they were the **only** User linked to a given Customer, that Customer's
data (orders, addresses, buyer record) becomes permanently unreachable through any login the
moment the User's identity is gone — nobody could ever again log in to exercise a data-subject
right over it. GDPR's data minimization principle (Art. 5(1)(c)) means it shouldn't just sit
there indefinitely with no legitimate purpose.
`requestErasureForUser()` and `requestImmediateErasureForUser()` both fire
`Modules\Core\Privacy\Events\UserErasureRequested` right after the request is created (and, for
the immediate path, before `completeErasure()` runs — see below).
`Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener` (**queued**, registered in
`PrivacyServiceProvider`) handles it: for every Customer the User is linked to, if that User is
currently the *sole* linked User (count is 1, and that one User is this one — not just count ===
1, to be explicit rather than relying on an assumption), it opens a second, independent
grace-period request via `requestErasureForCustomer($customer, $user, causedByRequestId: ...)`.
Both requests then run through their own separate 30-day windows.
```
User erasure requested
│
▼
UserErasureRequested event ──▶ CascadeCustomerErasureListener (queued)
│
▼
for each linked Customer: sole owner?
│ yes
▼
requestErasureForCustomer(..., causedByRequestId: <user request id>)
```
**Tracing the cascade — `caused_by_request_id`.** A cascade-created Customer request's
`caused_by_request_id` points back at the User request that triggered it. This is what lets
`CancelErasureOnLoginListener` revert *exactly* the cascade a User's own cancellation should
undo (via `DataErasureRequest::caused()`) without ever touching an unrelated, independently
staff-requested Customer erasure the User happens to still be linked to.
**Why this is queued, not synchronous.** `CascadeCustomerErasureListener` runs as an independent,
separately-retryable job rather than inline inside `requestErasureForUser()` — a failure in the
cascade check never rolls back or blocks the User's own request, and there's no
`DB::transaction()` wrapping needed, since the two writes (the User's request, and any cascaded
Customer request) aren't required to be atomic with each other.
**A known, accepted race on the immediate-erasure path only.** Because the listener is queued,
Eloquent re-fetches its models fresh when the job actually runs (see
`Illuminate\Queue\SerializesModels`) — so `$event->request->subject->customers` reflects the
*real* state at execution time, not a stale snapshot from dispatch time. For
`requestImmediateErasureForUser()`, that job may run before or after `completeErasure()` detaches
the User's memberships in the same call. If the detach happens first, the User is simply no
longer linked to anything by the time the cascade job runs, and nothing cascades — an accepted
race for that rare, staff-only path (see "Immediate erasure" below), not a concern for the
everyday `requestErasureForUser()` grace-period path, where nothing detaches until its own later,
separate `completeErasure()` run — well after the cascade job has had time to fire.
### Processing due requests — one job per request
`php artisan boboko:privacy:process-erasure-requests` finds every `pending` request whose
`scheduled_for` has passed and dispatches one `Modules\Core\Privacy\Jobs\EraseDataSubjectJob` per
request — it does not run `completeErasure()` inline itself. Each job independently calls
`PrivacyService::completeErasure()`, which checks the request's polymorphic `subject` and calls
either every registered provider's `eraseForCustomer()` or `eraseForUser()`, writing the full
per-provider outcome onto the request's `report` column and marking it `completed`. One job per
request means one request's failure (a provider throwing, a DB error) doesn't block or crash
processing of the others, and Laravel's normal per-job retry/failure handling applies to each
request independently. This package doesn't register a schedule itself; each consuming app wires
the command into its own scheduler (daily is reasonable), the same way it owns any other
scheduled task.
### Immediate erasure — staff-only, not self-service
`requestImmediateErasureForCustomer(Customer $customer, Staff $requestedBy): ErasureReport` and
`requestImmediateErasureForUser($user, Staff $requestedBy): ErasureReport` bypass the grace
period entirely and erase right away. Both are `Staff`-only **by type**, not just by convention —
their signatures take `Staff $requestedBy` specifically (not the union type the grace-period
methods accept), so a self-service/customer-facing code path can't reach either one even by
accident; calling with a `Customer`/`User` actor is a compile-time type error, not a runtime
check to remember.
This exists for a formal legal request or regulator inquiry that genuinely requires immediate
action, not as a convenience for an impatient customer. GDPR Art. 17 requires erasure "without
undue delay," but doesn't set a maximum number of days for a grace period, and a short, disclosed,
cancellable hold before executing a self-service request is a widely-used, generally accepted
pattern (the same one Shopify and most major platforms use) — it is **not** offered as a
same-click alternative on the self-service deletion flow, since doing so would mostly defeat the
grace period's purpose (protecting an impulsive requester from themselves). If a subject
explicitly insists on immediate deletion, that's a staff/support decision to make on the record
via one of these methods, not a checkbox exposed to every customer.
```php
$report = $service->requestImmediateErasureForCustomer($customer, $staffMember);
$report = $service->requestImmediateErasureForUser($user, $staffMember);
// Both run synchronously — no queueing, no grace period. $report is the same
// ErasureReport completeErasure() would produce.
```
---
## Export — queued, not synchronous
Export gathers real data across every registered provider — potentially slow, and there's no
reason to block whatever request triggered it (a customer clicking "export my data," an API
call). `requestExportForCustomer()`/`requestExportForUser()` are fast synchronous calls that only
create a `DataExportRequest` row and dispatch the actual work:
```php
$request = $service->requestExportForCustomer($customer);
$request = $service->requestExportForUser($user);
// $request->status is 'pending'; nothing has been gathered yet.
```
### The event chain
1. **`ExportDataSubjectJob`** (queued) checks the request's polymorphic `subject` and calls every
registered provider's `exportForCustomer()` or `exportForUser()` — all sequentially, in this
one job, not fanned out into one job per provider. Per-subject export work is small (a handful
of indexed queries per provider), so there's no real parallelism win, and one job means
"finished" is just "`handle()` returned," with no `Bus::batch()`/completion-counting needed. If
a future provider ever does something genuinely slow (an external API call, a generated PDF),
that's the point to reconsider a per-provider batch — not before.
2. Once every provider's data is gathered, the job fires **`PersonalDataGathered`**
(carries the request and the assembled `ExportReport`) — no file exists yet.
3. **`Modules\Core\Privacy\Listeners\WriteExportToCsvListener`** (registered in
`PrivacyServiceProvider`) handles that event: turns each provider's data into its own CSV (via
the generic `Modules\Core\Export\CsvWriter` — see below), zips them together, writes the zip to
`storage/app/exports/privacy/`, and updates the request (`status: completed`, `file_path`).
This is its own listener — not inline in the job — so the export *format* is swappable (an app
could unregister this and register a JSON-only listener instead) without touching how data is
gathered.
4. Once the file exists, that listener fires **`PersonalDataExportFileWritten`**.
5. Core has no opinion on how the subject is told. A consuming app registers its own notification
against `PersonalDataExportFileWritten` via `Modules\Core\Notification\NotificationRegistry` —
the same pattern as `App\Notifications\QuestionnaireResultsSentNotification` listening on
`App\Events\QuestionnaireResultsSent` (see `boboko-test` for a working example). Core
deliberately does not send an email itself.
### CSV shape
Every provider's `data` is either a list of associative arrays (addresses, orders, reviews — each
item becomes a row) or a single associative array (customer — becomes one row). Any nested array
value within a row (e.g. an order's `addresses` sub-array) is JSON-encoded into that one cell
rather than exploded into further columns — a generic, provider-agnostic rule in
`WriteExportToCsvListener`, not something each provider has to think about.
### `Modules\Core\Export\CsvWriter` — a generic, reusable piece
`CsvWriter::write(array $columns, iterable $rows, string $path)` has no knowledge of GDPR,
customers, or Lunar at all — a caller supplies a schema (`CsvColumn[]`, each just a header plus a
closure that pulls that column's value out of one record) and any iterable data source. It's used
here by `WriteExportToCsvListener`, but is equally usable for an unrelated future need — an admin
bulk catalog export, an accounting handoff — by supplying a different schema and row source;
nothing about it is GDPR-specific.
---
## Audit trail
`DataErasureRequest` (`data_erasure_requests`) and `DataExportRequest` (`data_export_requests`)
are the audit records for erasure and export respectively. Both have a polymorphic `subject`
(`subject_type`/`subject_id`, pointing at either a Lunar `Customer` or a `User` — never both) —
`subject_type`/`subject_id`/`email` are stored as a **snapshot**, not looked up live, since the
whole point is for these tables to remain readable after the record they're about has been
erased. `DataErasureRequest::isForCustomer()` tells you which scope a given request is.
`DataErasureRequest.requested_by_type`/`requested_by_id` capture who asked for it (the subject
themselves, self-service; `Staff` acting on their behalf; or, for a cascade-created Customer
request, the User whose erasure caused it — see "The sole-owner cascade") at request time.
`DataErasureRequest.caused_by_request_id` is set only on a cascade-created Customer request,
pointing back at the User request that triggered it; null on every normal, directly-requested
erasure — see `DataErasureRequest::causedBy()`/`::caused()`.
`DataErasureRequest.report` holds the full per-provider outcome once `completeErasure()` runs;
`DataExportRequest.file_path` points at the generated zip once `WriteExportToCsvListener`
finishes.
**Not yet built**: a standalone "leave/remove from a Customer account" action — unlinking a User
from a Customer without any erasure involved (e.g. a teammate leaving a project, or an account
admin removing someone) — is a related but separate, smaller feature, deliberately out of scope
for this module so far. It shares the same pivot-detach primitive `CustomerDataProvider::
eraseForUser()` already uses as part of a full erasure, but as a standalone action it doesn't
exist yet.
+46 -16
View File
@@ -1,8 +1,9 @@
# Product Listing
`Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product
lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the
Meilisearch index rather than the database. One data source for everything this service does.
lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` —
all reading directly from the Meilisearch index rather than the database. One data source for
everything this service does.
This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which
handles free-text query search. `ProductService` is for browsing/lookup without a search term.
@@ -28,13 +29,14 @@ use Modules\Core\Catalog\Enums\ProductSort;
$service = app(ProductService::class);
// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator,
// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
$products = $service->list(perPage: 24, page: 1);
// One call for everything a listing page needs — products AND the price slider's
// bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller
// used to have to call list() and priceSliderBounds() (or the older priceRange())
// separately and glue the results together itself; that's now list()'s own job.
$listing = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, price range, and/or stock
$products = $service->list(
$listing = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true),
perPage: 24,
page: 1,
@@ -42,8 +44,13 @@ $products = $service->list(
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
// relevance ordering (irrelevant here since the query is always empty).
$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$listing = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$products = $listing->products; // a real Illuminate\Pagination\LengthAwarePaginator,
// built from the localized Meilisearch hits (not Scout's
// own paginateRaw() result — see "Meilisearch driver
// quirk" below), so it behaves like any other Laravel
// paginator.
$products->items(); // array of Meilisearch documents (plain arrays, not models)
$products->total();
$products->perPage();
@@ -51,12 +58,32 @@ $products->currentPage();
$products->lastPage();
$products->links(); // in a Blade view — renders pagination links as usual
$bounds = $listing->priceBounds; // Modules\Core\Catalog\DTOs\PriceSliderBounds
$bounds->floor; // ?int — floor() of the matching range's minimum, in whole currency units
$bounds->ceil; // ?int — ceil() of the matching range's maximum
$bounds->filtered; // bool — whether the applied filters' minPrice/maxPrice actually
// narrow the slider below/above these bounds (drives whether a
// "clear filter" control should show)
// Single product, by primary key
$product = $service->getById(367); // array, or null if not found
// Single product, by URL slug (any locale — slugs are indexed across all languages)
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
// $limit random products — still scoped to the index's own default channel/status
// visibility, unlike Eloquent's Product::inRandomOrder() (which has no notion of
// that filtering at all). Meilisearch has no ORDER BY RANDOM() equivalent, so this
// pulls every matching id only, shuffles in PHP, then fetches the full localized
// documents for just the ids picked — see random()'s own docblock.
$randomProducts = $service->random(13); // array of documents, same shape as list()'s items
// The id/price/image of every variant on a product document — the base price and
// thumbnail a variant picker/swatch list needs, without reaching into
// $product['variants'][n]['prices'][0]/['media'][0] yourself.
$variants = $service->variantSummaries($product);
// [['id' => 1204, 'price' => 19.99, 'image' => 'https://.../thumb.jpg'], ...]
// Facet counts for a sidebar — value => matching product count, scoped to whatever
// $filters is passed. Does NOT exclude the faceted field itself from $filters — see
// facets()'s docblock for why, and how to build a standard "every option's count,
@@ -64,11 +91,13 @@ $product = $service->getBySlug('erotika-mprelok'); // array, or null if not fo
$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17));
// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135]
// Min/max price across matching products, for sizing a price-range slider.
// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike
// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink
// to whatever range is currently selected on it. Other filters (collectionId,
// brand, inStockOnly) still apply normally.
// Min/max price across matching products — the raw, unrounded values list() itself
// uses to build priceBounds above. minPrice/maxPrice are ALWAYS excluded from the
// filter driving this (unlike facets(), which doesn't auto-exclude) — the slider's
// own bounds shouldn't shrink to whatever range is currently selected on it. Other
// filters (collectionId, brand, inStockOnly) still apply normally. Pass $query too
// to scope the range to a text search's own matches (see product-search.md) rather
// than the whole catalog.
$range = $service->priceRange(new ProductFilters(collectionId: 17));
// ['min' => 0.0, 'max' => 120.0]
```
@@ -77,8 +106,8 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
`facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric
field like `price` would return one "facet" per exact price, not a usable range bucket. Use
`priceRange()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different
feature from `facetDistribution`.
`priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's
`facetStats` (min/max), a different feature from `facetDistribution`.
---
@@ -106,11 +135,12 @@ needs, listing and detail alike:
| `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. |
| `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. |
| `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. |
| `skus` | `$product->variants->pluck('sku')` | Filterable. Every variant's `sku`, deduplicated, empty ones dropped. Same "resolve from the index alone" reasoning as `slugs`, for a future SKU-based lookup/filter. |
| `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. |
| `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. |
| `tags` | `$product->tags->pluck('value')` | Display only. |
| `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. |
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). |
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `gtin`, `mpn`, `ean`, `stock`, `backorder`, `unit_quantity`, `purchasable`, `shippable`, `tax_ref`, `dimensions` (`length`/`width`/`height`/`weight`/`volume`, each `{value, unit}`), `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (the variant's own images — `ProductVariant::images()`, a separate pivot from the product's own gallery above, populated by `ShopifyExportImporter` from Shopify's `Variant Image` CSV column). |
| `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. |
| `in_stock` | `$model->variants` | Filterable boolean. `true` if ANY variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` — Lunar's own purchasability rule (`purchasable === 'always'` ignores stock entirely; `in_stock` checks `stock` alone; anything else checks `stock + backorder`). Only as fresh as the last reindex — see "Stock goes stale" below. |
+155
View File
@@ -0,0 +1,155 @@
# Product Recommendations
`Modules\Core\Catalog\Services\RecommendationService` computes "related products" for a given
product — a same-category pick today, with a random fallback, but built as a configurable chain of
strategies rather than one hardcoded rule. `Modules\Core\Catalog\Services\ProductIndexer` embeds
the result directly into each product's own Meilisearch document, so a product detail page renders
its recommendations with zero extra queries — same reasoning as `collections` (see
`docs/product-listing.md`).
---
## The rule chain
```php
use Modules\Core\Catalog\Services\RecommendationService;
$recommendations = app(RecommendationService::class)->recommend($product, limit: 4);
// Illuminate\Support\Collection<int, Lunar\Models\Product>
```
`recommend()` walks `config('catalog.recommendation_rules')` in order, **topping up** from each
successive rule until `$limit` distinct products are collected or every rule is exhausted — it does
not stop at the first rule that returns *something*. If a product's category only has 3 other
products, `SameCategoryRule` contributes those 3 and `RandomRule` fills the last slot. A rule is
handed the ids already collected (`$exclude`, always including the source product's own id) so it
never wastes its own `$limit` budget re-suggesting something already picked, and the same product
is never returned twice even if two rules would both suggest it.
Default chain (`config/catalog.php`):
```php
'recommendation_rules' => [
SameCategoryRule::class, // other products sharing $product's first collection
RandomRule::class, // universal fallback — always returns something as
// long as the store has more than one product
],
```
A consuming app publishes and edits this config to reorder, add, or remove rules — nothing about
the chain shape is hardcoded in `RecommendationService` itself. A new rule (same tag, best sellers,
"frequently bought together", ...) is a class implementing `Modules\Core\Catalog\Contracts\
RecommendationRule`, added to the array:
```php
interface RecommendationRule
{
/**
* @param array<int> $exclude ids to never return — the source product's own
* id, plus every id an earlier rule in the chain already picked
* @return Collection<int, Product> at most $limit products
*/
public function recommend(Product $product, int $limit, array $exclude): Collection;
}
```
Rules query Eloquent directly (`$product->collections->first()->products()`, `Product::query()`),
not `Modules\Core\Catalog\Services\ProductService` — see "Why not `ProductService`" below.
---
## Why not `ProductService`
Every other read path in `Modules\Core\Catalog` goes through `ProductService`, which reads
Meilisearch and resolves translated fields to whatever locale the *current request* is in (see
`docs/product-listing.md`, "Locale resolution"). Recommendation rules deliberately don't use it:
they run inside `ProductIndexer::toSearchableArray()`, at **index time** — there is no request, no
meaningful "current locale" to resolve against, and Meilisearch itself may be mid-write for the very
product being indexed. Rules return raw `Lunar\Models\Product` models instead; `ProductIndexer`
resolves what it embeds (`name` via `translateAttribute()`, `price` via the indexer's own
`cheapestPrice()`, `image` via its own `mapMedia()`) the same way it already does for the embedded
`collections` field — including that field's same accepted index-time-locale tradeoff (a
recommendation's embedded `name` reflects whatever locale was active when *that* product was last
indexed, not the viewer's current locale).
---
## What's embedded, and why not just an id
`ProductIndexer` embeds full card data per recommendation, not just an id:
```php
$data['recommendations'] = [
['id' => 42, 'name' => 'Espresso Cup', 'price' => 12.5, 'image' => 'https://.../thumb.jpg'],
// ...
];
```
This shape is deliberately exactly what `x-ui.product-card`/`x-product-grid` (3dealer's storefront
components) need — `name`, `price`, `image`, and an `id` the view resolves to a URL itself via
`route('product.show', ['id' => $rec['id']])`. A resolved `href` is **not** embedded: `product.show`
is locale-prefixed (`{locale}/products/{id}`), so a URL baked in at index time would be correct only
for whichever locale happened to be active during that index run — wrong for every other locale.
Building the URL is left to the view, which knows the current request's locale.
`recommendations.id` is marked **filterable** — not for the storefront, but for the reverse-lookup
reindexing below.
---
## Keeping it fresh: `ProductSaved` / `ProductDeleted`
A recommendation is computed once, at index time, and embedded — it does not update itself when the
recommended product later changes name, price, or image, or is deleted. Unlike `Modules\Core\Catalog\
Observers\ProductOptionReindexObserver`'s equivalent problem (which product option value is used by),
there is no Postgres relation for "which products currently recommend product X" — a recommendation
only exists inside Meilisearch. The fix is a reverse Meilisearch filter query, not a database join,
wired through a real event → listener pair (`Modules\Core\Providers\CatalogServiceProvider`):
- `Product::saved()` dispatches `Modules\Core\Catalog\Events\ProductSaved`.
- `Product::deleted()` dispatches `Modules\Core\Catalog\Events\ProductDeleted` — fires for both a
soft delete and a force delete (`Lunar\Models\Product` uses `SoftDeletes`), the same model event
Laravel Scout's own `ModelObserver` hooks to make a deleted product `unsearchable()`.
- `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct` handles both: it searches the
product index for `recommendations.id = "{id}"`, finds every referencing product, and calls
`->searchable()` on each — which recomputes their `recommendations` field fresh, picking up the
changed name/price/image, or (for a delete) dropping the now-gone product and topping back up to
the configured limit via the rule chain, same as any other reindex.
`->searchable()` dispatches Scout's own reindex job, queued if `SCOUT_QUEUE` is configured — this
listener does no synchronous Meilisearch writing itself.
**Product creation is deliberately not hooked into this.** A brand-new product has no
`recommendations` of its own until Scout's existing create-triggered indexing runs (already correct
— nothing to add). What's *not* immediate is other products picking the new one up as a fresh
recommendation candidate — that happens on their own next natural reindex (a save, or the nightly
full reindex below), the same accepted staleness window `docs/product-listing.md` already documents
for `in_stock`/`price`. A full proactive "who could now recommend this new product" pass was
considered and rejected as unnecessary cost for a cosmetic delay.
---
## Nightly full reindex
`Modules\Core\Providers\CatalogServiceProvider` schedules `lunar:search:index "Lunar\Models\Product"
--refresh` daily at 03:00 — a safety net on top of the event-driven reindexing above, not a
replacement for it. Catches what event-driven reindexing deliberately doesn't cover: a newly-created
product not yet appearing as a recommendation elsewhere, and any other drift already accepted
between reindexes (see `docs/product-listing.md`, "Stock goes stale between orders"). `--refresh`
also re-syncs filterable/sortable index *settings*, not just documents, so a deploy that changed
`ProductIndexer`'s field list self-heals overnight even if `lunar:meilisearch:setup` wasn't run
manually right after that deploy.
---
## Re-syncing after this change
Same as any other `ProductIndexer` field change (see `docs/product-listing.md`):
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
Restart the queue worker if `SCOUT_QUEUE=true` — see `docs/product-listing.md`'s "Re-syncing after
this change" for why a running worker won't otherwise pick up the new indexer code.
+39 -13
View File
@@ -24,36 +24,62 @@ merges `$builder->options` directly into the search request).
## Usage
```php
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
$results = app(ProductSearchService::class)->search('running shoes', 'el');
// Filters/sort apply the exact same semantics ProductService::list() uses for
// collection browsing (same ProductFilterBuilder, same ProductSort) — a shopper
// narrowing a text search by price/brand/stock gets identical filter behavior
// to narrowing a category listing.
$results = app(ProductSearchService::class)->search(
'running shoes',
filters: new ProductFilters(brand: 'Acme', minPrice: 20.0, inStockOnly: true),
sort: ProductSort::PriceAsc,
);
```
Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's
`->get()` hydrates real models from the database after the Meilisearch query, so relations
(`variants`, `brand`, `media`, etc.) are available on the results as normal.
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers
don't need to pass it explicitly.
There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why
every configured store language is always searched, regardless of the current request locale.
---
## Missing-translation fallback
## Missing-translation fallback, in both directions
If a product was only ever given an English name, `name_el` doesn't exist on that document at
all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the
attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly
against `name_el` would make that product invisible to Greek-locale search, even though it's a
real catalog item.
against the current request's locale field would make that product invisible whenever a shopper's
locale doesn't match the language it happens to be translated into.
To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both**
the resolved locale's fields **and** the default language's fields
(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`,
`name_en`, `description_el`, `description_en` together (assuming `en` is the default language).
A product missing an `el` translation still matches via its `en` fields.
`ProductSearchService` avoids this by targeting **every configured store language's fields**
(`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the
store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`,
`description_el`, `description_en` together, regardless of which locale the shopper is browsing
in. This is deliberately not scoped to "current locale + default locale": if the current locale
already equals the default (a single-language store, or a shopper browsing in the default
language), that pairing collapses to one locale and stops catching anything else — always
searching every configured language avoids that gap in both directions, at the cost of a larger
`attributesToSearchOn` list as the store's language count grows.
---
## Variant option values are searched too
Alongside the locale-suffixed attribute fields, every search also targets
`variants.options.value` directly — e.g. a variant named "Κάπτεν Γαμέρικα" on a "Name" option
matches a search for that text, even though it never appears in the product's own name or
description. This isn't one of Lunar's own attributes (`AttributeManifest` has no entry for it),
so it can't be discovered the way `name`/`description` are — it's a structural field of
`Modules\Core\Catalog\Services\ProductIndexer`'s own document shape (see `ProductIndexer::mapVariant()`),
added here directly. Not locale-suffixed — each option value is stored as one already-resolved
string per variant.
---
+450
View File
@@ -0,0 +1,450 @@
<title>Order 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 / order · competitive survey</div>
<h1>What order management elsewhere can do that boboko can't yet</h1>
<p class="dek">
Where the Checkout survey stopped — the instant <code>Order</code> exists — this
one starts. A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and
(briefly) Magento's post-placement order layer — sourced, not recalled from memory —
checked against what <strong>Lunar's <code>Order</code> model and the
already-shipped Filament <code>ManageOrder</code> page</strong> actually support
today. For deciding what the new <code>Order</code> module needs to own, 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>Status model</h2>
</div>
<p class="cat-note">One field, or several axes — and who's allowed to move it.</p>
<div class="feature">
<div class="f-name">Payment status independent of a single overall status</div>
<span class="f-status partial">partial</span>
<div class="f-note">The data exists — <code>ManageOrder::paymentStatus()</code> derives a real value from <code>transactions()</code>/<code>captureTotal()</code>/<code>refundTotal()</code>/<code>intentTotal()</code> — but it's a computed display value on the admin page, not a stored column or something the rest of the system (mailers, automations) can key off. Shopify and Magento both make payment status a first-class, independently-queryable dimension; here it's derived on the fly, once, in one Filament page.</div>
</div>
<div class="feature">
<div class="f-name">Fulfillment status independent of overall status</div>
<span class="f-status missing">missing</span>
<div class="f-note">No equivalent of <code>paymentStatus()</code> exists for shipment/fulfillment state — <code>Order</code> has no <code>shipments()</code> relation of its own at all; it's added dynamically by <code>Modules\Core\Shipping\Providers\ShippingServiceProvider::resolveRelationUsing()</code>, outside Order's own boundary (see docs/checkout.md, "Where Order would likely absorb work"). Every platform researched (Shopify, Woo, PrestaShop, Magento) treats "has this shipped" as derivable from child records, not a manually-set field — Lunar has the child records (<code>Shipment</code>) but no derived status method reading them.</div>
</div>
<div class="feature">
<div class="f-name">Staff-editable order status with a picker/action</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code> ships a working <code>UpdateStatusAction</code> out of the box, backed by <code>config('lunar.orders.statuses')</code> — a flat, merchant-configured list, each entry carrying a <code>label</code>/<code>color</code>/<code>favourite</code> flag. Closer to WooCommerce's single linear field than Shopify's multi-axis split.</div>
</div>
<div class="feature">
<div class="f-name">Status rows carry behavior (auto-send email, generate invoice, restock)</div>
<span class="f-status partial">partial</span>
<div class="f-note">Each status entry in <code>config('lunar.orders.statuses')</code> already declares <code>mailers</code> and <code>notifications</code> arrays — the PrestaShop-style shape is there in config — but per the Checkout survey's finding, nothing in core actually reads and dispatches from those keys on a transition. The data model for "status carries behavior" exists; the behavior doesn't.</div>
</div>
<div class="feature">
<div class="f-name">Order-status-changed event other code can react to</div>
<span class="f-status missing">missing</span>
<div class="f-note">Same gap the Checkout survey flagged for order creation: no <code>OrderStatusUpdated</code>/equivalent exists anywhere in core. <code>UpdateStatusAction</code> just writes the column. Anything wanting to react to a status change — a confirmation email, a webhook, re-deriving payment/fulfillment status — has to hook the raw Eloquent <code>Order::updated()</code> event and diff <code>status</code> itself.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Fulfillment &amp; shipment tracking</h2>
</div>
<p class="cat-note">Turning a placed order into a package that moves.</p>
<div class="feature">
<div class="f-name">Shipment as its own record, separate from the order</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Modules\Core\Shipping\Models\Shipment</code> (carrier, tracking reference, label-printed timestamp, manifest reference) already exists and belongs to <code>Order</code>. Built this session, ahead of most gaps in this survey — the record shape is closer to Magento's per-shipment entity than Woo's "no shipment entity at all."</div>
</div>
<div class="feature">
<div class="f-name">Multiple shipments per order (partial/split fulfillment)</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>Shipment</code> has no <code>quantity</code>-per-line or <code>order_line_id</code> concept — it's one shipment record per carrier voucher, with a <code>parent_reference</code> for ACS's own multipart-voucher case (one physical order split into multiple packages by the carrier), not a per-line-item fulfillment split decided by staff. Closer to "multiple packages for one shipment" than Magento's true per-line partial-shipment model.</div>
</div>
<div class="feature">
<div class="f-name">Create-shipment action from the order admin screen</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Modules\Core\Shipping\Extensions\OrderViewExtension</code> adds a working "Create Shipment" header action to <code>ManageOrder</code>, resolving a <code>CarrierFulfillmentInterface</code> by the order's chosen shipping method and calling <code>createShipment()</code> — genuinely wired, not a stub. Currently lives under <code>Shipping</code>, flagged in docs/checkout.md as conceptually an <code>Order</code> concern.</div>
</div>
<div class="feature">
<div class="f-name">Tracking number + carrier surfaced on the order itself</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Shipment.tracking_reference</code>/<code>carrier</code> exist and are populated by <code>createShipment()</code>; <code>PollShipmentTrackingJob</code> (scheduled every 30 minutes) keeps <code>ShipmentInfo</code> checkpoints current via <code>CarrierFulfillmentInterface::trackShipment()</code>. Genuinely ahead of PrestaShop's thin <code>order_carrier.tracking_number</code> field — this has a real checkpoint history, not just one string.</div>
</div>
<div class="feature">
<div class="f-name">"Shipped"/"delivered" status auto-derived from tracking</div>
<span class="f-status missing">missing</span>
<div class="f-note">The tracking checkpoints exist (<code>ShipmentInfo</code>, <code>TrackingStatus</code> enum including <code>Delivered</code>) but nothing writes them back onto <code>Order.status</code> — a delivered shipment doesn't move the order out of whatever status it was already in. Every platform researched treats "delivered" as a status a customer/staff can see on the order, not something buried one relation away.</div>
</div>
<div class="feature">
<div class="f-name">Shipping/delivery notification emails (shipped, out-for-delivery, delivered)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Research: Shopify fires four separate templated notifications across this window alone (shipping confirmation, out-for-delivery, delivered, plus edited-order). None of the pieces exist here — no order-status-changed event (01) to trigger from, and no mailer wired to <code>PollShipmentTrackingJob</code>'s own status updates either.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Payments: capture, refund, cancellation</h2>
</div>
<p class="cat-note">Money moving back out, and orders that never should have been placed.</p>
<div class="feature">
<div class="f-name">Refund action from the order screen, amount-scoped</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>refund</code> action already exists — picks a transaction, an amount (validated against <code>availableToRefund()</code>), and notes, then calls the driver's own <code>Transaction::refund()</code>. This is genuinely native, matching Woo/Magento's line-item-adjacent (if not line-item-exact) refund UX.</div>
</div>
<div class="feature">
<div class="f-name">Capture action for auth-then-capture payment flows</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>capture</code> action + <code>requiresCapture()</code>/<code>canBeRefunded()</code> guard methods already exist, delegating to <code>Transaction::capture()</code> — this is the Stripe "authorize now, capture later" flow's admin-side half, already built ahead of most gaps here.</div>
</div>
<div class="feature">
<div class="f-name">Refund tied to specific line items (not just a dollar amount)</div>
<span class="f-status missing">missing</span>
<div class="f-note">The refund action takes a transaction + amount, with no line-item selection or restock decision — WooCommerce and Magento both make "which items, how many, restock or not" the primary refund UI; here it's one number against one transaction, closer to a manual adjustment than a structured partial return.</div>
</div>
<div class="feature">
<div class="f-name">Order cancellation as a distinct action (vs. just changing status)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No dedicated "cancel" action exists on <code>ManageOrder</code> — a cancellation today would just be picking a "cancelled"-labeled entry from the generic status dropdown (01), with no automatic refund trigger, no stock-release logic, and no distinction from any other manual status edit.</div>
</div>
<div class="feature">
<div class="f-name">Refund/capture reflected back into an order-level payment status</div>
<span class="f-status partial">partial</span>
<div class="f-note">Same gap as 01's payment-status finding — <code>paymentStatus()</code> recomputes correctly from transactions when the admin page loads, but a refund doesn't push the order into a <code>refunded</code>/<code>partially-refunded</code> overall status the way Shopify's <code>displayFinancialStatus</code> does automatically.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Returns (RMA)</h2>
</div>
<p class="cat-note">The one area every researched platform treats as optional, not core.</p>
<div class="feature">
<div class="f-name">Return-merchandise-authorization flow (customer requests, staff approves)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No <code>Return</code>/RMA model, status set, or request flow exists anywhere in this codebase. Consistent with the research: Shopify is the only platform of the four with this genuinely native; PrestaShop ships it off-by-default; Magento gates it behind the paid Adobe Commerce tier; WooCommerce lacks it entirely. Safe to treat as a real gap, not an urgent one.</div>
</div>
<div class="feature">
<div class="f-name">Return shipping label generation</div>
<span class="f-status missing">missing</span>
<div class="f-note">Depends entirely on the RMA flow above existing first — <code>CarrierFulfillmentInterface</code> already has the label-printing primitive (<code>printLabel()</code>) a return label would reuse, so the carrier-side plumbing isn't the blocker, the RMA request/approval model is.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2>Order editing</h2>
</div>
<p class="cat-note">Changing a placed order — and where every platform draws the line.</p>
<div class="feature">
<div class="f-name">Editing guardrails keyed to fulfillment state</div>
<span class="f-status missing">missing</span>
<div class="f-note">No line-item add/remove exists on a placed order at all today (unlike Shopify/Woo/PrestaShop, which all allow it up to some fulfillment-keyed cutoff, then force a return instead) — so there's no guardrail to speak of yet because there's no editing to guard. Whatever gets built here should key the cutoff to <code>Shipment</code> existing, per the pattern all four researched platforms converge on.</div>
</div>
<div class="feature">
<div class="f-name">Editable shipping/billing address after placement</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>OrderAddress</code> rows are snapshotted at creation (see Checkout survey, 02) and nothing in <code>ManageOrder</code> exposes editing them afterward — every platform researched treats address edits as lower-risk than line-item edits and allows them more freely; this codebase currently allows neither.</div>
</div>
<div class="feature">
<div class="f-name">Tag editing on a placed order</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>edit_tags</code> action already works — the one piece of native post-placement editing that exists today, via <code>HasTags</code> on the <code>Order</code> model.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">06</span>
<h2>Notes &amp; audit trail</h2>
</div>
<p class="cat-note">The one thing every researched platform treats as non-negotiable.</p>
<div class="feature">
<div class="f-name">Append-only change history (who changed what, when)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Order</code> already uses Spatie's <code>LogsActivity</code> trait — every save is recorded with a diff, same underlying mechanism already relied on elsewhere in this codebase (staff activity log, translation history). Structurally equivalent to PrestaShop's <code>order_history</code> table, just via a different package.</div>
</div>
<div class="feature">
<div class="f-name">Internal staff notes, separate from system-generated log entries</div>
<span class="f-status missing">missing</span>
<div class="f-note">The activity log above captures field changes automatically, but there's no free-text "leave a note for the next person" field — every platform researched has this as a distinct feed from the automatic history (Woo's Order Notes, Shopify's Timeline comments, Magento's Comments History), usually with a private-vs-customer-visible toggle. Nothing here yet.</div>
</div>
<div class="feature">
<div class="f-name">Customer-visible note-to-customer, sent as a message</div>
<span class="f-status missing">missing</span>
<div class="f-note">Depends on both the internal-notes feature above and a working mailer (01/02) — genuinely blocked on more foundational gaps, not just unbuilt on its own.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-09-01 — sources cited inline; <code>vendor/lunarphp/lunar</code> and this codebase's own <code>src/</code> reads are marked by file/class name, Shopify/WooCommerce/PrestaShop/Magento claims are marked "Research."</span>
<span>boboko-core / docs</span>
</footer>
</div>
+3
View File
@@ -2,6 +2,9 @@
Findings from comparing a real Shopify product export CSV against Lunar's schema (`vendor/lunarphp/core`), plus the resulting implementation plan for `MigrateImport\Shopify\ShopifyExportImporter`.
Need to discard everything and re-import from scratch (e.g. after a schema/indexer change that
only applies to newly-created rows)? See `docs/shopify-reimport.md`.
## Idempotency problem
Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run.
+149
View File
@@ -0,0 +1,149 @@
# Wiping products before a clean Shopify re-import
A runbook for discarding every imported product (and everything that hangs off one —
variants, prices, media, reviews, options/values, the Meilisearch documents) and re-running
`ShopifyExportImporter` from scratch. Useful after a schema/indexer change that only applies to
newly-created rows (see "Why a wipe, not an update" below), or when the export CSV itself changed
enough that stale products need to go, not just be updated in place.
Every command below is a `tinker --execute=` one-liner run inside the app container — adjust the
exec prefix (`./bin/dc-core.sh exec app ...`, `docker compose exec app ...`, etc.) for your setup.
---
## Why a wipe, not an update
`ShopifyExportImporter`'s resolvers are mostly `firstOrCreate` — re-running the importer against
an *existing* database updates matched rows but leaves already-created ones exactly as they were.
That's the right behavior for routine re-imports (an updated price, a new variant), but it means a
change to what gets set **at creation time only** — e.g. `ProductOptionResolver` now also setting
`label`, not just `name`, on a `ProductOption` — never reaches a `ProductOption` row that already
exists. A wipe forces every row to go through creation again, picking up such fixes.
---
## 1. Delete every product
Cascades to `ProductVariant`, prices, and Spatie media rows — verified live (see
`shopify-import.md`'s own history/commit log for context). Also removes each product's Meilisearch
document automatically, via Scout's own delete hook fired on `forceDelete()` — no separate
`scout:flush` needed.
```php
\Lunar\Models\Product::withTrashed()->get()->each->forceDelete();
```
**Let this run to completion.** Interrupting it mid-loop (e.g. Ctrl+C on the tinker session) stops
after whichever product it was on, leaving the rest undeleted — safe to just re-run the same
command again afterward, since already-deleted products are simply skipped.
Verify:
```php
\Lunar\Models\Product::withTrashed()->count(); // 0
```
### Requires: `product_reviews.product_id` cascades on delete
`product_reviews` (boboko-core's own table, not Lunar's) originally had no `ON DELETE` clause on
its `product_id` foreign key — deleting a reviewed product threw a constraint violation instead of
the review going with it. Fixed by
`database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php`. Make
sure this migration has actually run (`php artisan migrate`) before step 1, or a product with
reviews will fail to delete.
---
## 2. Delete product options and values
Not touched by step 1 (`ProductOption`/`ProductOptionValue` aren't scoped to one product — they're
shared across the catalog, per `ProductOptionResolver::resolveOption()`'s `shared: true`). Safe to
delete in full once every product (and therefore every variant referencing an option value via the
`product_option_value_product_variant` pivot) is gone — deleting values while variants still
reference them throws the same kind of FK violation step 1 guards against.
```php
\Lunar\Models\ProductOptionValue::query()->delete();
\Lunar\Models\ProductOption::query()->delete();
```
Verify:
```php
\Lunar\Models\ProductOption::count(); // 0
\Lunar\Models\ProductOptionValue::count(); // 0
```
---
## 3. Clear the import mappings
Without this, the importer's `ImportMapping::resolve(...)` calls still find the (now-deleted)
mappings' rows absent, so this step is really about not leaving stale mapping rows pointing at
nothing — `ImportMapping` rows aren't foreign-keyed to the models they map (`morphTo`, no
constraint), so leaving them wouldn't break the re-import, but a stale mapping for a product that
no longer exists is dead weight.
```php
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->delete();
```
Verify:
```php
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->count(); // 0
```
---
## 4. Re-run the importer
`boboko:migrate:import` dispatches `RunMigrateImportJob` onto the queue — **not synchronous** —
so a queue worker must actually be running (`php artisan queue:work`, or your dev queue container)
or the job just sits queued.
```bash
php artisan boboko:migrate:import --source=shopify --type=export --file=<absolute path to the CSV>
```
The `--file` value must be an **absolute path** inside the container (e.g.
`/var/www/html/storage/app/private/imports/shopify/products_export.csv`) when running
non-interactively — a path relative to `storage/app/private/imports` only resolves correctly when
the command can fall back to its interactive prompt, which isn't available in a scripted/non-TTY
run.
Watch the queue worker's own log output for `FAIL` entries (see `docs/lunar.md` or your compose
setup for how logs are routed to `docker compose logs`) — a clean run shows every
`Laravel\Scout\Jobs\MakeSearchable` / `Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob`
line ending `DONE`, never `FAIL`.
---
## 5. Re-sync Meilisearch and reindex
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:meilisearch:tune-product-search
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
`--refresh` re-syncs filterable/sortable index settings *and* reindexes every document — it does
not reset `typoTolerance`/`prefixSearch` (confirmed live: both survived a `--refresh` run
unchanged), so `tune-product-search` only needs re-running here for completeness/if it hadn't
already been applied, not because `--refresh` would have clobbered it.
---
## Verifying the result
```php
// Product count should match the CSV's actual unique `Handle` count, not
// whatever the database held before the wipe — those aren't the same number
// if stale/manually-added products existed alongside the CSV-sourced ones.
\Lunar\Models\Product::count();
// Spot-check that at least one variant picked up its own image (see
// shopify-import.md's "Images" section) — 0 is only correct if the CSV
// genuinely has no `Variant Image` values populated.
\Lunar\Models\ProductVariant::has('images')->count();
```
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* Greek translations for Lunar\Models\Country::name, keyed by the exact
* English spelling Lunar's own installer seeds (`lunar:import:address-data`
* fetches http://data.lunarphp.io/countries+states.json — see
* vendor/lunarphp/core/src/Console/Commands/Import/AddressData.php).
* `Country`/`State` have no i18n support of their own (plain string
* columns, no translatable trait) — this is a plain Laravel lang file, not
* Modules\Core\Localization's DB-backed TranslationService, since these
* names are fixed reference data seeded once, not editable UI copy (see
* docs/localization.md). A consuming app's storefront looks this up
* itself, e.g. __('core::countries.'.$country->name) — core has no
* storefront UI of its own to wire this into (see docs/lunar.md).
*
* Only Greece is covered — this store operates within Greece; add further
* countries here as needed.
*/
return [
'Greece' => 'Ελλάδα',
];
+52
View File
@@ -0,0 +1,52 @@
<?php
/**
* Greek translations for Lunar\Models\State::name, keyed by the exact
* English spelling Lunar's own installer seeds for Greece
* (`lunar:import:address-data` — see lang/el/countries.php's own docblock
* for the full explanation of why this is a plain lang file, not
* Modules\Core\Localization's TranslationService).
*
* Covers every Greek state/regional-unit row in Lunar's seed dataset —
* scoped to Greece only, matching this store's operating country.
*/
return [
'Achaea Regional Unit' => 'Περιφερειακή Ενότητα Αχαΐας',
'Aetolia-Acarnania Regional Unit' => 'Περιφερειακή Ενότητα Αιτωλοακαρνανίας',
'Arcadia Prefecture' => 'Νομός Αρκαδίας',
'Argolis Regional Unit' => 'Περιφερειακή Ενότητα Αργολίδας',
'Attica Region' => 'Περιφέρεια Αττικής',
'Boeotia Regional Unit' => 'Περιφερειακή Ενότητα Βοιωτίας',
'Central Greece Region' => 'Περιφέρεια Στερεάς Ελλάδας',
'Central Macedonia' => 'Κεντρική Μακεδονία',
'Chania Regional Unit' => 'Περιφερειακή Ενότητα Χανίων',
'Corfu Prefecture' => 'Νομός Κέρκυρας',
'Corinthia Regional Unit' => 'Περιφερειακή Ενότητα Κορινθίας',
'Crete Region' => 'Περιφέρεια Κρήτης',
'Drama Regional Unit' => 'Περιφερειακή Ενότητα Δράμας',
'East Attica Regional Unit' => 'Περιφερειακή Ενότητα Ανατολικής Αττικής',
'East Macedonia and Thrace' => 'Ανατολική Μακεδονία και Θράκη',
'Epirus Region' => 'Περιφέρεια Ηπείρου',
'Euboea' => 'Εύβοια',
'Grevena Prefecture' => 'Νομός Γρεβενών',
'Imathia Regional Unit' => 'Περιφερειακή Ενότητα Ημαθίας',
'Ioannina Regional Unit' => 'Περιφερειακή Ενότητα Ιωαννίνων',
'Ionian Islands Region' => 'Περιφέρεια Ιονίων Νήσων',
'Karditsa Regional Unit' => 'Περιφερειακή Ενότητα Καρδίτσας',
'Kastoria Regional Unit' => 'Περιφερειακή Ενότητα Καστοριάς',
'Kefalonia Prefecture' => 'Νομός Κεφαλληνίας',
'Kilkis Regional Unit' => 'Περιφερειακή Ενότητα Κιλκίς',
'Kozani Prefecture' => 'Νομός Κοζάνης',
'Laconia' => 'Λακωνία',
'Larissa Prefecture' => 'Νομός Λάρισας',
'Lefkada Regional Unit' => 'Περιφερειακή Ενότητα Λευκάδας',
'Pella Regional Unit' => 'Περιφερειακή Ενότητα Πέλλας',
'Peloponnese Region' => 'Περιφέρεια Πελοποννήσου',
'Phthiotis Prefecture' => 'Νομός Φθιώτιδας',
'Preveza Prefecture' => 'Νομός Πρέβεζας',
'Serres Prefecture' => 'Νομός Σερρών',
'South Aegean' => 'Νότιο Αιγαίο',
'Thessaloniki Regional Unit' => 'Περιφερειακή Ενότητα Θεσσαλονίκης',
'West Greece Region' => 'Περιφέρεια Δυτικής Ελλάδας',
'West Macedonia Region' => 'Περιφέρεια Δυτικής Μακεδονίας',
];
+856
View File
@@ -0,0 +1,856 @@
/*
* Cart + checkout module — generic default styling.
*
* Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version
* put these rules in `@layer bbk-checkout`, positioned (via a cross-file
* @layer ordering statement) to sit between Tailwind's `base` and
* `components` — in theory enough to beat Preflight's element resets while
* still losing to a host override. In practice a build tool processing each
* CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the
* cross-file ordering information that trick depends on, so it silently
* didn't work: Preflight's `button { background-color: transparent }`,
* `* { border-width: 0 }` etc. (layered, in `base`) were beating every
* `.bbk-*` rule below regardless of specificity — buttons with no
* background, no border, wrong font-size.
*
* Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always
* beats ANY layered rule (Preflight included), full stop, no ordering tricks,
* nothing a bundler can silently invalidate. This file is loaded BEFORE the
* host's own stylesheet (see the @vite call in the layout <head>), so:
*
* - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins —
* same specificity, later in source order
* - a later host rule with a MORE specific selector wins regardless
* - a host rule inside `@layer components`/`@layer utilities` does NOT
* win — unlayered always beats layered. Theme this module from plain
* rules in app.css, not from inside a Tailwind layer.
*
* Two ways to theme this, cheapest first:
*
* 1. Redefine the --bbk-* custom properties below (from :root, or scoped to
* .bbk-cart for a cart-only override) — covers colour, radius, shadow,
* font without touching a single selector below.
*
* :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; }
*
* 2. Override individual `.bbk-*` rules directly (as plain rules, per
* above) for anything structural (spacing, layout) the variables don't
* cover.
*
* This file's own look is a deliberately neutral placeholder — inoffensive,
* not "designed" — so a project always has something reasonable before it
* themes; it is not meant to be edited per project.
*/
:root {
--bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--bbk-color-text: #18181b;
--bbk-color-muted: #71717a;
--bbk-color-bg: #ffffff;
--bbk-color-bg-muted: #f4f4f5;
--bbk-color-border: #e4e4e7;
--bbk-color-accent: #18181b;
--bbk-color-accent-text: #ffffff;
--bbk-color-danger: #dc2626;
--bbk-radius: 8px;
--bbk-radius-sm: 4px;
--bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
}
.bbk-cart[hidden] { display: none; }
.bbk-cart {
position: fixed;
inset: 0;
z-index: 1000;
font-family: var(--bbk-font);
font-size: 0.9375rem;
line-height: 1.4;
color: var(--bbk-color-text);
}
.bbk-cart-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.25s ease;
}
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; }
.bbk-cart-panel {
position: absolute;
top: 0;
right: 0;
display: flex;
flex-direction: column;
width: min(420px, 100vw);
height: 100%;
background: var(--bbk-color-bg);
box-shadow: var(--bbk-shadow);
transform: translateX(100%);
transition: transform 0.25s ease;
}
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); }
.bbk-cart-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.5rem 1.25rem;
border-bottom: 1px solid var(--bbk-color-border);
}
.bbk-cart-heading {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
}
.bbk-cart-dismiss,
.bbk-cart-item-remove,
.bbk-cart-qty-btn {
cursor: pointer;
background: none;
border: 0;
padding: 0;
font: inherit;
line-height: 1;
color: var(--bbk-color-muted);
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
.bbk-cart-dismiss {
font-size: 1.75rem;
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--bbk-radius-sm);
flex-shrink: 0;
}
.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); }
.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); }
.bbk-cart-dismiss:focus-visible,
.bbk-cart-item-remove:focus-visible,
.bbk-cart-qty-btn:focus-visible,
.bbk-cart-qty-input:focus-visible,
.bbk-cart-checkout:focus-visible,
.bbk-cart-coupon-input:focus-visible,
.bbk-cart-coupon-submit:focus-visible,
.bbk-cart-coupon-remove:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.bbk-cart-panel-body {
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1.5rem;
}
.bbk-cart-items {
list-style: none;
margin: 0 0 2rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bbk-cart-item {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 0.875rem;
align-items: start;
}
.bbk-cart-item-media img {
display: block;
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
}
.bbk-cart-item-detail { min-width: 0; }
.bbk-cart-item-title {
display: block;
margin: 0 0 0.25rem;
font-weight: 600;
color: inherit;
text-decoration: none;
}
a.bbk-cart-item-title:hover { text-decoration: underline; }
.bbk-cart-item-variant {
margin: 0 0 0.25rem;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
.bbk-line-fields {
display: grid;
gap: 0.25rem;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.bbk-line-field dt {
color: var(--bbk-color-muted);
}
.bbk-line-field dd {
margin: 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.bbk-line-field-file {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: inherit;
}
.bbk-line-field-file img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 0;
}
.bbk-cart-item-unit {
margin: 0 0 0.625rem;
color: var(--bbk-color-muted);
}
.bbk-cart-item-aside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.bbk-cart-item-total { margin: 0; font-weight: 600; }
.bbk-cart-item-remove {
font-size: 1.125rem;
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bbk-cart-qty {
display: inline-flex;
align-items: center;
gap: 0;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
overflow: hidden;
}
.bbk-cart-qty-btn {
width: 1.75rem;
height: 1.75rem;
background: var(--bbk-color-bg-muted);
}
.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); }
.bbk-cart-qty-input {
width: 2.25rem;
height: 1.75rem;
border: 0;
border-left: 1px solid var(--bbk-color-border);
border-right: 1px solid var(--bbk-color-border);
text-align: center;
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
appearance: textfield;
-moz-appearance: textfield;
}
.bbk-cart-qty-input::-webkit-outer-spin-button,
.bbk-cart-qty-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.bbk-cart-summary {
padding-top: 1.25rem;
border-top: 1px solid var(--bbk-color-border);
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.bbk-cart-summary-row {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
.bbk-cart-summary-pending {
color: var(--bbk-color-muted);
font-size: 0.8125rem;
}
.bbk-cart-summary-row--total {
margin-top: 0.375rem;
padding-top: 0.875rem;
border-top: 1px solid var(--bbk-color-border);
font-size: 1.0625rem;
font-weight: 700;
}
.bbk-cart-coupon-form {
display: flex;
gap: 0.5rem;
}
.bbk-cart-coupon-input {
flex: 1 1 auto;
min-width: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
}
.bbk-cart-coupon-submit {
flex: 0 0 auto;
padding: 0.5rem 0.875rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); }
.bbk-cart-coupon-applied {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
}
.bbk-cart-coupon-code {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.bbk-cart-coupon-remove {
flex: 0 0 auto;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
text-decoration: underline;
cursor: pointer;
transition: color 0.15s ease;
}
.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); }
.bbk-cart-coupon-error {
margin: 0.5rem 0 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-cart-error {
margin: 0;
padding: 0.75rem 1.5rem 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-add-to-cart-error {
margin: 0.375rem 0 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-cart-checkout {
display: block;
width: 100%;
padding: 0.875rem 1.25rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius);
background: var(--bbk-color-accent);
color: var(--bbk-color-accent-text);
font: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease;
}
.bbk-cart-checkout:hover { opacity: 0.85; }
.bbk-cart-checkout:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.bbk-cart-empty {
text-align: center;
color: var(--bbk-color-muted);
padding: 2.5rem 0;
}
/* ───────────────────────────────────────────────────────────────────
Checkout page — two columns: fields on the left, order summary (the
same cart-body partial the drawer uses) on the right.
─────────────────────────────────────────────────────────────────── */
.bbk-checkout-page {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 5rem;
font-family: var(--bbk-font);
font-size: 0.9375rem;
line-height: 1.4;
color: var(--bbk-color-text);
}
.bbk-checkout-heading {
margin: 0 0 2rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-checkout {
display: grid;
grid-template-columns: 1fr 380px;
gap: 3rem;
align-items: start;
}
@media (max-width: 860px) {
.bbk-checkout { grid-template-columns: 1fr; }
}
.bbk-checkout-main {
display: flex;
flex-direction: column;
gap: 2rem;
}
.bbk-checkout-section {
padding-bottom: 2rem;
border-bottom: 1px solid var(--bbk-color-border);
display: flex;
flex-direction: column;
gap: 1rem;
}
.bbk-checkout-section-heading {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.bbk-checkout-note {
margin: 0;
color: var(--bbk-color-muted);
font-size: 0.875rem;
}
/* Contact: "logged in as" line, or the guest login prompt */
.bbk-checkout-logged-in,
.bbk-checkout-login-prompt { margin: 0; }
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
/* Fields */
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
.bbk-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 480px) {
.bbk-field-row { grid-template-columns: 1fr; }
}
.bbk-field-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--bbk-color-muted);
}
.bbk-field-input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
}
.bbk-field-input:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-field-input:disabled {
background: var(--bbk-color-bg-muted);
color: var(--bbk-color-muted);
}
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
.bbk-field-error {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
/* A fixed, non-editable field value (e.g. the store's single country). */
.bbk-field-static {
margin: 0;
padding: 0.625rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
color: var(--bbk-color-muted);
}
textarea.bbk-field-input { resize: vertical; }
.bbk-checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
/* For a full-sentence label that can wrap — align the box to the first line. */
/* "I want an invoice": company/ΑΦΜ only while ticked */
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
.bbk-checkbox--stacked {
display: flex;
align-items: flex-start;
margin-top: 0.75rem;
color: var(--bbk-color-muted);
}
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
.bbk-checkout-shipping-fields {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Shipping method */
.bbk-checkout-shipping-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-shipping-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-shipping-option-detail {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.bbk-checkout-shipping-option-name { font-weight: 600; }
.bbk-checkout-shipping-option-description {
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-shipping-option-price { font-weight: 600; }
/* The single auto-selected option — a fixed line, not a choosable radio. */
.bbk-checkout-shipping-confirmed {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius-sm);
}
/* Autosave status line under the address form. */
.bbk-checkout-status {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
/* Continue / submit buttons — same look as the drawer's checkout CTA */
.bbk-checkout-continue {
display: block;
width: 100%;
padding: 0.875rem 1.25rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius);
background: var(--bbk-color-accent);
color: var(--bbk-color-accent-text);
font: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
box-sizing: border-box;
cursor: pointer;
transition: opacity 0.15s ease;
}
.bbk-checkout-continue:hover { opacity: 0.85; }
.bbk-checkout-continue:disabled {
cursor: not-allowed;
opacity: 0.4;
}
/* Order summary column */
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
.bbk-checkout-summary {
padding: 1.5rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius);
background: var(--bbk-color-bg);
}
.bbk-checkout-summary-heading {
margin: 0 0 1.25rem;
font-size: 1.125rem;
font-weight: 700;
}
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
nowhere further to send you from here. */
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
/* ── Payment ───────────────────────────────────────────────────────── */
.bbk-checkout-payment-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-payment-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-payment-option-name { font-weight: 600; }
.bbk-payment-element { margin: 0.25rem 0; }
.bbk-checkout-withdrawal {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-withdrawal a { color: inherit; }
.bbk-checkout-error {
margin: 0;
font-size: 0.875rem;
color: var(--bbk-color-danger);
}
/* Processing overlay — fixed, covers the page while a payment confirms. */
.bbk-checkout-processing {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
text-align: center;
padding: 1.5rem;
}
.bbk-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--bbk-color-border);
border-top-color: var(--bbk-color-accent);
border-radius: 50%;
animation: bbk-spin 0.8s linear infinite;
}
@keyframes bbk-spin {
to { transform: rotate(360deg); }
}
/* ── Confirmation page ─────────────────────────────────────────────── */
.bbk-confirmation {
max-width: 720px;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
font-family: var(--bbk-font);
color: var(--bbk-color-text);
}
.bbk-confirmation-heading {
margin: 0 0 1rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta {
margin: 0 0 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bbk-confirmation-meta-row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
}
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body {
margin: 2rem 0;
display: grid;
gap: 2.5rem;
}
@media (min-width: 640px) {
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
}
.bbk-confirmation-lines {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-confirmation-line {
display: grid;
grid-template-columns: 72px 1fr auto;
align-items: start;
gap: 0.875rem;
}
.bbk-confirmation-line-detail { min-width: 0; }
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
.bbk-confirmation-addresses {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bbk-confirmation-address-heading {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
}
.bbk-address-lines {
font-style: normal;
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.875rem;
color: var(--bbk-color-muted);
}
@@ -0,0 +1,57 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
// via fetch and hands the server-rendered cart body to the drawer through the
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
static targets = ['error']
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
this.clearError()
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (!response.ok) {
const data = await response.json().catch(() => null)
this.showError(data?.error)
return
}
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
}))
} finally {
form.removeAttribute('data-bbk-add-to-cart-state')
if (submit) submit.disabled = false
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
}
@@ -0,0 +1,161 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the slide-in cart drawer. One instance, on the drawer root in
// checkout/drawer.blade.php.
//
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
// this drawer's own line forms) and swaps in the server-rendered cart body
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react
//
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists.
export default class extends Controller {
static targets = ['panel', 'body', 'error']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
this.updateTimers = new Map() // line id -> pending debounce timer
window.addEventListener('bbk-cart:changed', this.onChanged)
window.addEventListener('bbk-cart:open', this.open.bind(this))
document.addEventListener('keydown', this.onKeydown)
// Prime the host with the count rendered server-side on page load.
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
}
disconnect() {
window.removeEventListener('bbk-cart:changed', this.onChanged)
document.removeEventListener('keydown', this.onKeydown)
this.updateTimers.forEach((timer) => clearTimeout(timer))
}
onChanged(event) {
if (event.detail?.html) this.replaceBody(event.detail.html)
this.open()
}
onKeydown(event) {
if (event.key === 'Escape' && !this.element.hidden) this.close()
}
open() {
if (!this.element.hidden) return
this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start.
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
}
close() {
this.element.removeAttribute('data-bbk-cart-state')
const panel = this.panelTarget
const done = () => {
this.element.hidden = true
panel.removeEventListener('transitionend', done)
}
panel.addEventListener('transitionend', done)
}
// change on a line quantity input, or submit of a line's remove form
submit(event) {
event.preventDefault()
const form = event.target.closest('form')
if (!form) return
// A remove is a deliberate, one-shot action — only the quantity form
// (typing, or the +/- stepper below) benefits from debouncing.
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
}
// +/- stepper buttons inside a line
step(event) {
event.preventDefault()
const form = event.target.closest('form')
const input = form.querySelector('input[type="number"]')
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
input.value = String(next)
this.scheduleSend(form)
}
// Repeated clicks (or spinner nudges) update the input instantly but only
// send once they settle for 300ms — sending on every single click was
// firing overlapping requests that raced each other and made the drawer
// visibly flicker/lag under quick clicking.
scheduleSend(form) {
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
if (!lineId) return this.send(form)
clearTimeout(this.updateTimers.get(lineId))
this.updateTimers.set(lineId, setTimeout(() => {
this.updateTimers.delete(lineId)
this.send(form)
}, 300))
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
this.clearError()
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (response.ok) {
this.replaceBody(await response.text())
return
}
const data = await response.json().catch(() => null)
this.showError(data?.error)
// The rejected quantity (typed, or from a +/- click) is left
// sitting in the input with nothing to correct it — the update
// never reached the cart, so the input must be put back to what
// the cart actually still holds, not just left showing whatever
// was rejected.
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
replaceBody(html) {
this.bodyTarget.innerHTML = html
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
}
emitUpdated(node) {
if (!node) return
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
detail: {
count: parseInt(node.dataset.bbkCartCount || '0', 10),
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
},
}))
}
}
@@ -0,0 +1,204 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the checkout page's left column: contact tabs, the same-as-billing
// toggle, and — the bulk of it — autosaving the address form and the shipping
// method with no submit buttons.
//
// Flow: any `change` in the address form is debounced ~400ms, then the whole
// form is POSTed to saveUrl. The server persists leniently and returns
// { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options
// block in place and hand the summary fragment to the drawer's bbk-cart
// controller via the `bbk-cart:changed` window event (same mechanism the drawer
// already uses). Shipping-method radios post to selectShippingUrl the same way.
export default class extends Controller {
static targets = [
'sameAsBilling', 'shippingFields',
'form', 'shippingOptions', 'status',
]
static values = {
saveUrl: String,
selectShippingUrl: String,
statusSaving: String,
statusSaved: String,
statusError: String,
}
connect() {
this.saveTimer = null
this.saveController = null
this.statusTimer = null
this.shippingPromise = null
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
}
disconnect() {
clearTimeout(this.saveTimer)
clearTimeout(this.statusTimer)
this.saveController?.abort()
}
// ── Same as billing ────────────────────────────────────────────────
toggleSameAsBilling() {
this.applySameAsBilling()
}
applySameAsBilling() {
const on = this.sameAsBillingTarget.checked
// Checked: shipping *is* billing — copy every value across, then hide +
// disable so the browser doesn't submit them; the server reuses billing.
// Unchecked: reveal them pre-filled from billing wherever still empty.
this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => {
const shippingField = this.element.querySelector(
`[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`,
)
if (shippingField && (on || !shippingField.value)) {
shippingField.value = billingField.value
}
})
this.shippingFieldsTarget.hidden = on
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
field.disabled = on
})
}
// ── Autosave ───────────────────────────────────────────────────────
scheduleSave(event) {
// The shipping-method and payment radios live inside this controller's
// element too, and this action is bound on .bbk-checkout-main to also
// catch the contact email/consent that sit outside the <form>. Only
// react to fields that actually belong to the address form.
const el = event.target
const belongsToForm = el.form?.id === 'bbk-address-form'
if (!belongsToForm) return
// No status during the wait — it only shows once the request is in flight,
// so the indicator isn't flickering "saving" on every keystroke.
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(() => this.save(), 700)
}
// Called by bbk-payment right before place-order — a debounced save (and
// the shipping-option auto-select that happens as part of it) might still
// be pending when the shopper clicks "place order"; this guarantees the
// server has processed the current form state first.
async flush() {
clearTimeout(this.saveTimer)
await this.save()
// A shipping-method radio click fires its own (undebounced) request —
// still async, still racy against an immediate "place order" click.
if (this.shippingPromise) await this.shippingPromise
}
async save() {
this.saveController?.abort()
this.saveController = new AbortController()
this.setStatus('saving')
try {
const response = await fetch(this.saveUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(this.formTarget),
signal: this.saveController.signal,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch (error) {
if (error.name !== 'AbortError') this.setStatus('error')
}
}
async selectShipping(event) {
// Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request.
this.shippingPromise = this.doSelectShipping(event.target.value)
await this.shippingPromise
}
async doSelectShipping(value) {
this.saveController?.abort()
this.setStatus('saving')
const body = new FormData()
body.append('shipping_option', value)
try {
const response = await fetch(this.selectShippingUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch {
this.setStatus('error')
} finally {
this.shippingPromise = null
}
}
applyResult(data) {
this.applyErrors(data.errors || {})
if (data.shippingOptionsHtml != null) {
this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml
}
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: data.summaryHtml },
}))
}
}
applyErrors(errors) {
this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => {
const message = errors[el.dataset.bbkFieldError]
el.textContent = message || ''
el.hidden = !message
const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`)
field?.classList.toggle('bbk-field-input--error', Boolean(message))
})
}
setStatus(state) {
if (!this.hasStatusTarget) return
const text = {
saving: this.statusSavingValue,
saved: this.statusSavedValue,
error: this.statusErrorValue,
}[state]
this.statusTarget.textContent = text
this.statusTarget.hidden = false
this.statusTarget.dataset.state = state
clearTimeout(this.statusTimer)
if (state === 'saved') {
this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000)
}
}
}
@@ -0,0 +1,289 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
const STRIPE_JS = 'https://js.stripe.com/v3/'
const POLL_INTERVAL = 1500
const POLL_TIMEOUT = 30000
// The payment step of the checkout page. Sits alongside bbk-checkout-form on
// .bbk-checkout-main.
//
// - selectMethod: radio change -> persist via /payment-method, refresh the
// summary (COD fee), mount/unmount the Stripe Payment Element
// - placeOrder: the real submit. For Stripe, builds a PaymentMethod client-side
// and POSTs it to /place-order, then routes on the JSON result:
// { redirect } -> order placed, go to confirmation
// { status:'pending', clientSecret } -> 3-D Secure: handleNextAction, then
// poll /order-status until the webhook places it
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
export default class extends Controller {
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
static values = {
selectUrl: String,
placeOrderUrl: String,
orderStatusUrl: String,
stripeKey: String,
amount: Number,
currency: String,
termsRequired: String,
chooseMethod: String,
genericError: String,
processingSlow: String,
}
connect() {
this.stripe = null
this.elements = null
this.paymentElement = null
this.onSummaryUpdate = (event) => {
const total = event.detail?.total
if (typeof total === 'number' && this.elements) {
this.amountValue = total
this.elements.update({ amount: Math.max(total, 1) })
}
// Removing the last line while sitting on the checkout page (via
// the order summary's own remove form) must not leave "place
// order" clickable with nothing left to charge for — this fires
// from both the drawer and the checkout page's own summary
// instance, whichever the shopper actually used.
const count = event.detail?.count
if (typeof count === 'number' && this.hasSubmitTarget) {
this.submitTarget.disabled = count === 0
}
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
if (this.selectedIsStripe()) this.mountStripe()
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
this.unmountStripe()
}
// ── Method selection ──────────────────────────────────────────────
async selectMethod(event) {
const isStripe = event.target.dataset.paymentDriver === 'stripe'
try {
const response = await fetch(this.selectUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({ payment_type: event.target.value }),
})
if (response.ok) {
const data = await response.json()
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } }))
}
}
} catch {
// summary just won't refresh — non-fatal
}
isStripe ? this.mountStripe() : this.unmountStripe()
}
selectedRadio() {
return this.element.querySelector('input[name="payment_type"]:checked')
}
selectedIsStripe() {
return this.selectedRadio()?.dataset.paymentDriver === 'stripe'
}
// ── Stripe Payment Element ────────────────────────────────────────
async loadStripe() {
if (window.Stripe) return window.Stripe
await new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${STRIPE_JS}"]`)
if (existing) {
existing.addEventListener('load', resolve)
existing.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = STRIPE_JS
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
return window.Stripe
}
async mountStripe() {
if (this.paymentElement || !this.stripeKeyValue) return
const Stripe = await this.loadStripe()
this.stripe = this.stripe || Stripe(this.stripeKeyValue)
this.elements = this.stripe.elements({
mode: 'payment',
amount: Math.max(this.amountValue, 1),
currency: this.currencyValue,
paymentMethodCreation: 'manual',
// Card only — matches the server confirming with
// automatic_payment_methods.allow_redirects = 'never' (no
// return_url in our flow: 3-D Secure resolves in-page via
// handleNextAction, never a full-page redirect).
paymentMethodTypes: ['card'],
})
this.paymentElement = this.elements.create('payment')
this.paymentElement.mount(this.elementTarget)
this.elementTarget.hidden = false
}
unmountStripe() {
this.paymentElement?.unmount()
this.paymentElement = null
this.elements = null
if (this.hasElementTarget) {
this.elementTarget.innerHTML = ''
this.elementTarget.hidden = true
}
}
// ── Place order ──────────────────────────────────────────────────
// Sibling controller on the same element (.bbk-checkout-main) — used to
// flush a pending debounced address autosave before placing the order.
get checkoutForm() {
return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form')
}
async placeOrder() {
this.clearError()
this.submitTarget.disabled = true
// A debounced address save (and the shipping-option auto-select that
// happens as part of it) might still be pending — make sure the
// server has the latest state before we ask it to place the order.
await this.checkoutForm?.flush()
if (!this.termsTarget.checked) {
this.submitTarget.disabled = false
this.showError(this.termsRequiredValue)
return
}
const radio = this.selectedRadio()
if (!radio) {
this.submitTarget.disabled = false
this.showError(this.chooseMethodValue)
return
}
let paymentMethodId = null
if (radio.dataset.paymentDriver === 'stripe') {
const { error: submitError } = await this.elements.submit()
if (submitError) return this.fail(submitError.message)
const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
if (pmError) return this.fail(pmError.message)
paymentMethodId = paymentMethod.id
}
let data
try {
const response = await fetch(this.placeOrderUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({
payment_type: radio.value,
payment_method: paymentMethodId ?? '',
terms_accepted: '1',
}),
})
data = await response.json()
} catch {
return this.fail(this.genericErrorValue)
}
if (data.redirect) {
window.location.assign(data.redirect)
return
}
if (data.status === 'pending' && data.clientSecret) {
await this.resolvePending(data.clientSecret)
return
}
// Points at the section that actually needs attention, rather than
// leaving a generic error and making the shopper hunt for it — e.g. a
// region with 2+ shipping methods needs an explicit pick, easy to miss.
if (data.field === 'shipping_option') {
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
return
}
this.fail(data.message || data.error || this.genericErrorValue)
}
async resolvePending(clientSecret) {
this.processingTarget.hidden = false
const { error } = await this.stripe.handleNextAction({ clientSecret })
if (error) {
this.processingTarget.hidden = true
return this.fail(error.message)
}
// 3-D Secure cleared client-side — the webhook places the order. Poll.
const startedAt = Date.now()
const tick = async () => {
try {
const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } })
const data = await response.json()
if (data.placed && data.redirect) {
window.location.assign(data.redirect)
return
}
} catch {
// keep polling
}
if (Date.now() - startedAt > POLL_TIMEOUT) {
this.processingTextTarget.textContent = this.processingSlowValue
return
}
setTimeout(tick, POLL_INTERVAL)
}
tick()
}
// ── helpers ──────────────────────────────────────────────────────
fail(message, { scroll = true } = {}) {
this.showError(message, { scroll })
this.submitTarget.disabled = false
}
showError(message, { scroll = true } = {}) {
this.errorTarget.textContent = message
this.errorTarget.hidden = false
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
clearError() {
this.errorTarget.textContent = ''
this.errorTarget.hidden = true
}
}
+6
View File
@@ -0,0 +1,6 @@
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
// boboko host renders in its layout <head>. Kept as its own module so both
// checkout controllers share one source.
export function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
+19
View File
@@ -0,0 +1,19 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkCartController from './bbk-cart-controller'
import BbkCheckoutFormController from './bbk-checkout-form-controller'
import BbkPaymentController from './bbk-payment-controller'
// Registers the checkout module's Stimulus controllers onto the host app's
// Stimulus application. Call once from the host's JS entry point:
//
// import { registerCheckout } from './checkout'
// registerCheckout(application)
//
// When this module moves to boboko-core this file ships with it unchanged;
// only that one import line in the host entry point differs per project.
export function registerCheckout(application) {
application.register('bbk-add-to-cart', BbkAddToCartController)
application.register('bbk-cart', BbkCartController)
application.register('bbk-checkout-form', BbkCheckoutFormController)
application.register('bbk-payment', BbkPaymentController)
}
@@ -2,7 +2,7 @@
@if (! $otpSent)
<form wire:submit="requestOtp">
<div class="grid gap-y-4">
<div style="display: flex; flex-direction: column; row-gap: 1rem;">
<x-filament::input.wrapper>
<x-filament::input
type="email"
@@ -24,7 +24,7 @@
</form>
@else
<form wire:submit="authenticate">
<div class="grid gap-y-4">
<div style="display: flex; flex-direction: column; row-gap: 1rem;">
<p class="text-sm text-gray-500">
A login code was sent to <strong>{{ $email }}</strong>.
</p>
@@ -0,0 +1,17 @@
@extends('emails.layout')
@section('content')
<p style="margin: 0 0 24px 0;">Use the code below to confirm this address as your account's new email.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0; background-color: #f7f6f5; border-radius: 8px;">
<tr>
<td style="padding: 16px 20px; text-align: center; font-size: 28px; font-weight: bold; letter-spacing: 0.25rem;">
{{ $code }}
</td>
</tr>
</table>
<p style="margin: 0 0 16px 0;">This code expires in 10 minutes.</p>
<p style="margin: 0;">If you didn't request this change, you can ignore this email — nothing will change.</p>
@endsection
@@ -0,0 +1,9 @@
@extends('emails.layout')
@section('content')
<p style="margin: 0 0 16px 0;">Your account's login email was changed to <strong>{{ $maskedEmail }}</strong>.</p>
<p style="margin: 0 0 24px 0;">From now on, login codes will be sent to the new address.</p>
<p style="margin: 0;">If you didn't make this change, please contact us right away.</p>
@endsection
+1 -1
View File
@@ -1,5 +1,5 @@
<p>Hi {{ $name }},</p>
<p>Your login code is:</p>
<p>{{ $intro }}</p>
<p style="font-size: 2rem; font-weight: bold; letter-spacing: 0.25rem;">{{ $code }}</p>
@@ -0,0 +1,44 @@
{{--
<x-checkout::add-to-cart :purchasable="$variantId" />
A self-contained add-to-cart form. Posts the line via bbk-add-to-cart-controller
(fetch) and hands the rendered cart body to the drawer over the
`bbk-cart:changed` window event.
Props:
purchasable ProductVariant id. Omit to render no hidden id field — the host
must then supply [data-bbk-purchasable-input] itself (e.g. a
variant picker writing the selected id into it).
quantity Integer for the hidden quantity field, or false to omit it
(the host then puts its own name="quantity" control in the slot).
The button and any quantity control come from the slot, so the host owns all
appearance. Extra attributes (class, etc.) land on the <form>.
--}}
@props([
'purchasable' => null,
'quantity' => 1,
'action' => null,
])
<form
method="POST"
action="{{ $action ?? route('checkout.cart.add', app()->getLocale()) }}"
data-controller="bbk-add-to-cart"
data-action="bbk-add-to-cart#add"
{{ $attributes->class('bbk-add-to-cart') }}
>
@csrf
@if (! is_null($purchasable))
<input type="hidden" name="purchasable_id" value="{{ $purchasable }}" data-bbk-purchasable-input>
@endif
@if ($quantity !== false)
<input type="hidden" name="quantity" value="{{ $quantity }}">
@endif
{{ $slot }}
<p class="bbk-add-to-cart-error" data-bbk-add-to-cart-target="error" hidden role="alert"></p>
</form>
@@ -0,0 +1,15 @@
{{--
Read-only formatted address. $address is any Lunar address model
(OrderAddress / CartAddress) — same column names on both.
--}}
@props(['address'])
<address class="bbk-address-lines">
<span>{{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }}</span>
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
<span>{{ $address->line_one }}</span>
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
<span>{{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }}</span>
@if ($address->state)<span>{{ $address->state }}</span>@endif
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
</address>
@@ -0,0 +1,29 @@
{{--
<x-checkout::field name="billing_first_name" label="First name" required />
Generic labelled text input with old-input repopulation and validation
error display — the module's own equivalent of a host x-ui.field, used
instead of it per the module's independence rule. All styling is .bbk-field*
(resources/css/checkout.css); no host classes.
--}}
@props([
'name',
'label',
'type' => 'text',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<input
type="{{ $type }}"
name="{{ $name }}"
id="bbk-{{ $name }}"
value="{{ old($name, $value) }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
{{-- Always present so bbk-checkout-form can fill it live on an autosave. --}}
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,52 @@
{{--
The state/region + country pair for one address (billing or shipping).
Single-country store ($storeCountry set): region is a <select> of that
country's Lunar states, submitting `->name` (table-rate-shipping resolves
zones with State::whereName()), and country is a fixed hidden field + label.
Otherwise: free-text region + full country <select>, as before.
--}}
@props([
'prefix',
'storeCountry' => null,
'regions' => [],
'countries' => [],
'address' => null,
])
<div class="bbk-field-row">
@if ($storeCountry)
<x-checkout::select
:name="$prefix . '_state'"
label="{{ __('checkout.page.state') }}"
:options="$regions"
value-field="name"
translation-group="states"
:value="$address?->state"
placeholder="{{ __('checkout.page.state_placeholder') }}"
required
/>
<div class="bbk-field">
<span class="bbk-field-label">{{ __('checkout.page.country') }}</span>
<p class="bbk-field-static">
{{ \Illuminate\Support\Facades\Lang::has("core::countries.{$storeCountry->name}")
? __("core::countries.{$storeCountry->name}")
: $storeCountry->name }}
</p>
<input type="hidden" name="{{ $prefix }}_country_id" value="{{ $storeCountry->id }}">
</div>
@else
<x-checkout::field :name="$prefix . '_state'" label="{{ __('checkout.page.state') }}" :value="$address?->state" />
<x-checkout::select
:name="$prefix . '_country_id'"
label="{{ __('checkout.page.country') }}"
:options="$countries"
translation-group="countries"
:value="$address?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
@endif
</div>
@@ -0,0 +1,62 @@
{{--
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
<x-checkout::select name="shipping_state" label="Region" :options="$regions" value-field="name" translation-group="states" required />
`options` is an iterable of models/objects; `label` is always read from
`->name`, the submitted value from `->{$valueField}` (default `id`, but e.g.
`name` for Lunar states — table-rate-shipping resolves those with
State::whereName(), so the address must carry the exact name string).
`translationGroup` (optional, e.g. "countries"/"states") looks the raw
`->name` up in boboko-core's `core::{group}.{name}` lang file (see
boboko-core's lang/el/countries.php, lang/el/states.php) for the
DISPLAYED label only — the submitted `value` is always the untranslated
`->{$valueField}`, since table-rate-shipping/Lunar's Country lookups key
off the original English name. Falls back to the raw name when no
translation exists for the current locale (e.g. English, or a country
outside the covered set).
--}}
@props([
'name',
'label',
'options' => [],
'value' => null,
'placeholder' => null,
'required' => false,
'valueField' => 'id',
'translationGroup' => null,
])
@php
$optionLabel = function ($option) use ($translationGroup) {
if (! $translationGroup) {
return $option->name;
}
$key = "core::{$translationGroup}.{$option->name}";
return \Illuminate\Support\Facades\Lang::has($key) ? __($key) : $option->name;
};
@endphp
@php($selected = old($name, $value))
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<select
name="{{ $name }}"
id="bbk-{{ $name }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
@if ($placeholder)
<option value="" @selected(! $selected)>{{ $placeholder }}</option>
@endif
@foreach ($options as $option)
<option value="{{ $option->{$valueField} }}" @selected((string) $selected === (string) $option->{$valueField})>
{{ $optionLabel($option) }}
</option>
@endforeach
</select>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,18 @@
@props([
'name',
'label',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<textarea
name="{{ $name }}"
id="bbk-{{ $name }}"
rows="3"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>{{ old($name, $value) }}</textarea>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,132 @@
{{--
Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
--}}
@extends('layouts.app')
@section('title', __('checkout.page.confirmation_title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ $order->reference }}</dd>
</div>
@if ($order->billingAddress?->contact_email)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.email_label') }}</dt>
<dd>{{ $order->billingAddress->contact_email }}</dd>
</div>
@endif
@if ($paymentMethodName)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.payment_heading') }}</dt>
<dd>{{ $paymentMethodName }}</dd>
</div>
@endif
@if ($shippingLine = $order->lines->firstWhere('type', 'shipping'))
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.shipping_method_heading') }}</dt>
<dd>{{ $shippingLine->description }}</dd>
</div>
@endif
</dl>
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
{{-- Guests: logging in with the order's email attaches it to an account
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
so it shows in their history. --}}
@guest
@if ($loginRoute = config('checkout.login_route'))
<p class="bbk-checkout-note">
{{ __('checkout.page.confirmation_login_hint') }}
<a href="{{ route($loginRoute) }}">{{ __('checkout.page.login_link') }}</a>
</p>
@endif
@endguest
<div class="bbk-confirmation-body">
<div class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line">
<div class="bbk-cart-item-media">
@if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
@endif
</div>
<div class="bbk-confirmation-line-detail">
<span class="bbk-confirmation-line-name">
{{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span>
</span>
@if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
</div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
</div>
@endforeach
<div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span>
</div>
@if ($order->discount_total?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->tax_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $order->total?->formatted() }}</span>
</div>
</div>
</div>
<div class="bbk-confirmation-addresses">
@if ($order->shippingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" />
</div>
@endif
@if ($order->billingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" />
</div>
@endif
</div>
</div>
</div>
@endsection
+33
View File
@@ -0,0 +1,33 @@
{{--
Slide-in cart drawer. Rendered once, globally, from the app layout
(@include('checkout::drawer')). Structure only — all styling lives in
resources/css/checkout.css under @layer bbk-checkout; the host restyles the
.bbk-* classes from its own stylesheet. No host components, no Tailwind.
--}}
<div class="bbk-cart" data-controller="bbk-cart" hidden>
<div class="bbk-cart-backdrop" data-action="click->bbk-cart#close"></div>
<aside
class="bbk-cart-panel"
role="dialog"
aria-modal="true"
aria-labelledby="bbk-cart-heading"
data-bbk-cart-target="panel"
>
<header class="bbk-cart-panel-header">
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
<button
type="button"
class="bbk-cart-dismiss"
data-action="bbk-cart#close"
aria-label="{{ __('checkout.cart.close') }}"
>&times;</button>
</header>
@include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</aside>
</div>
+279
View File
@@ -0,0 +1,279 @@
{{--
The checkout page. Two columns: left is contact + billing + shipping +
shipping method, right is the order summary (the same cart-body partial the
drawer uses, minus its own "Checkout" CTA — see .bbk-checkout-summary in
checkout.css). Stops short of payment for this slice — see
CheckoutController's class docblock.
$cart, $lines, $billingAddress, $shippingAddress, $shippingOptions,
$countries come from CheckoutController::show().
--}}
@extends('layouts.app')
@section('title', __('checkout.page.title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-checkout-page">
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
<div class="bbk-checkout">
<div
class="bbk-checkout-main"
data-controller="bbk-checkout-form bbk-payment"
data-action="input->bbk-checkout-form#scheduleSave"
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
data-bbk-payment-select-url-value="{{ route('checkout.payment-method.select', app()->getLocale()) }}"
data-bbk-payment-place-order-url-value="{{ route('checkout.place-order', app()->getLocale()) }}"
data-bbk-payment-order-status-url-value="{{ route('checkout.order-status', app()->getLocale()) }}"
data-bbk-payment-stripe-key-value="{{ config('services.stripe.public_key') }}"
data-bbk-payment-amount-value="{{ $cart?->total?->value ?? 0 }}"
data-bbk-payment-currency-value="{{ strtolower($cart?->total?->currency?->code ?? 'eur') }}"
data-bbk-payment-terms-required-value="{{ __('checkout.page.terms_required') }}"
data-bbk-payment-choose-method-value="{{ __('checkout.page.choose_payment_method') }}"
data-bbk-payment-generic-error-value="{{ __('checkout.page.payment_failed') }}"
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
>
{{-- Contact. Logged in: the order email is the account's (forced
server-side in saveAddress()), so there's no field. Guests type
their email, plus a login link when config('checkout.login_route')
is set; the storefront's login page sends them back here and Lunar
merges the guest cart into the account. --}}
@php($loginRoute = config('checkout.login_route'))
<section class="bbk-checkout-section">
@auth
<p class="bbk-checkout-logged-in">
{{ __('checkout.page.logged_in_as') }} <strong>{{ auth()->user()->email }}</strong>
</p>
@else
@if ($loginRoute)
<p class="bbk-checkout-login-prompt">
{{ __('checkout.page.login_prompt') }}
<a href="{{ route($loginRoute, ['redirect' => route('checkout.show', app()->getLocale(), false)]) }}">{{ __('checkout.page.login_link') }}</a>
</p>
@endif
<x-checkout::field
name="contact_email"
label="{{ __('checkout.page.email_label') }}"
type="email"
:value="$shippingAddress?->contact_email ?? $billingAddress?->contact_email"
required
form="bbk-address-form"
/>
@endauth
{{-- Abandoned-cart-recovery opt-in. Optional, unticked, never
required — direct marketing under ePrivacy (GR L. 3471/2006
art. 11), so it needs an explicit opt-in and checkout can't be
gated on it. Narrow scope by design (boboko-core's
setRecoveryConsent) — a general newsletter opt-in, if wanted,
is a separate checkbox. --}}
<label class="bbk-checkbox bbk-checkbox--stacked">
<input
type="checkbox"
name="recovery_consent"
value="1"
form="bbk-address-form"
{{ old('recovery_consent', data_get($cart, 'meta.recovery_consent')) ? 'checked' : '' }}
>
{{ __('checkout.page.recovery_consent') }}
</label>
</section>
{{-- Autosaves — no submit button. Any `change` inside .bbk-checkout-main
(this form, plus the contact email/consent which sit outside it but
link via form="bbk-address-form") is debounced and POSTed as the whole
form; the shipping-method radios are excluded in scheduleSave(). --}}
<form
id="bbk-address-form"
method="POST"
action="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-target="form"
>
@csrf
{{-- Billing --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.billing_heading') }}</h2>
<div class="bbk-field-row">
<x-checkout::field name="billing_first_name" label="{{ __('checkout.page.first_name') }}" :value="$billingAddress?->first_name" required />
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
</div>
{{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS
(:has on the checkbox), saved/cleared by saveAddress(), and
required at place-order. --}}
<div class="bbk-invoice">
<label class="bbk-checkbox">
<input
type="checkbox"
name="wants_invoice"
value="1"
aria-controls="bbk-invoice-fields"
@checked($wantsInvoice)
>
{{ __('checkout.page.wants_invoice') }}
</label>
<div class="bbk-field-row bbk-invoice-fields" id="bbk-invoice-fields">
<x-checkout::field name="billing_company_name" label="{{ __('checkout.page.company_name') }}" :value="$billingAddress?->company_name" />
<x-checkout::field name="billing_tax_identifier" label="{{ __('checkout.page.tax_identifier') }}" :value="$billingAddress?->tax_identifier" />
</div>
</div>
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
<div class="bbk-field-row">
<x-checkout::field name="billing_city" label="{{ __('checkout.page.city') }}" :value="$billingAddress?->city" required />
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="billing"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$billingAddress"
/>
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
</section>
{{-- Shipping --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_heading') }}</h2>
<label class="bbk-checkbox">
<input
type="checkbox"
name="same_as_billing"
value="1"
data-bbk-checkout-form-target="sameAsBilling"
data-action="bbk-checkout-form#toggleSameAsBilling"
@checked($shipToBilling)
>
{{ __('checkout.page.same_as_billing') }}
</label>
<div class="bbk-checkout-shipping-fields" data-bbk-checkout-form-target="shippingFields">
<div class="bbk-field-row">
<x-checkout::field name="shipping_first_name" label="{{ __('checkout.page.first_name') }}" :value="$shippingAddress?->first_name" required />
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
</div>
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
<div class="bbk-field-row">
<x-checkout::field name="shipping_city" label="{{ __('checkout.page.city') }}" :value="$shippingAddress?->city" required />
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="shipping"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$shippingAddress"
/>
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
</div>
<x-checkout::textarea
name="shipping_delivery_instructions"
label="{{ __('checkout.page.delivery_instructions') }}"
:value="$shippingAddress?->delivery_instructions"
/>
</section>
</form>
<p
class="bbk-checkout-status"
data-bbk-checkout-form-target="status"
role="status"
aria-live="polite"
hidden
></p>
{{-- Shipping method — resolves from the saved shipping address;
re-rendered as a fragment by bbk-checkout-form after each
autosave / option change. --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
@include('checkout::partials.shipping-options', [
'shippingAddress' => $shippingAddress,
'shippingOptions' => $shippingOptions,
])
</div>
</section>
{{-- Payment --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
<div id="bbk-payment-methods">
@include('checkout::partials.payment-methods', [
'paymentMethods' => $paymentMethods,
'cart' => $cart,
])
</div>
{{-- Stripe Payment Element mounts here when a Stripe method is picked. --}}
<div class="bbk-payment-element" data-bbk-payment-target="element" hidden></div>
<label class="bbk-checkbox bbk-checkbox--stacked">
<input type="checkbox" data-bbk-payment-target="terms">
{!! __('checkout.page.terms_accept', [
'terms' => route('legal.terms', app()->getLocale()),
'privacy' => route('legal.privacy', app()->getLocale()),
]) !!}
</label>
<p class="bbk-checkout-withdrawal">
{!! __('checkout.page.withdrawal_notice', [
'link' => route('legal.shipping-returns', app()->getLocale()),
]) !!}
</p>
<p class="bbk-checkout-error" data-bbk-payment-target="error" role="alert" hidden></p>
<button
type="button"
class="bbk-checkout-continue"
data-bbk-payment-target="submit"
data-action="bbk-payment#placeOrder"
@disabled($lines->isEmpty())
>
{{ __('checkout.page.place_order') }}
</button>
</section>
{{-- Fixed overlay while a payment is confirming (3-D Secure / webhook
poll). Inside .bbk-checkout-main so bbk-payment can target it. --}}
<div class="bbk-checkout-processing" data-bbk-payment-target="processing" hidden>
<span class="bbk-spinner" aria-hidden="true"></span>
<p data-bbk-payment-target="processingText">{{ __('checkout.page.payment_processing') }}</p>
</div>
</div>
<aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error')
<div data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</div>
</aside>
</div>
</div>
@endsection
@@ -0,0 +1,121 @@
{{--
Server-rendered cart contents. Rendered inline on first page load inside
checkout/drawer.blade.php, and re-fetched + swapped into the drawer by
bbk-cart-controller after every mutation. $cart / $lines come from the view
composer in CheckoutModuleServiceProvider.
The data-bbk-cart-* attributes on the root are the module's read API for the
host (e.g. the header bag-icon count) — bbk-cart-controller reads them after
each swap and re-emits them on the `bbk-cart:updated` window event.
--}}
@php($count = $lines->sum('quantity'))
{{-- @dump($lines) --}}
<div
class="bbk-cart-content"
data-bbk-cart-count="{{ $count }}"
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
>
@if ($lines->isEmpty())
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
@else
<ul class="bbk-cart-items">
@each('checkout::partials.cart-line', $lines, 'line')
</ul>
<div class="bbk-cart-summary">
<div class="bbk-cart-coupon">
@if ($cart?->coupon_code)
<div class="bbk-cart-coupon-applied">
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
<form
method="POST"
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button type="submit" class="bbk-cart-coupon-remove">
{{ __('checkout.cart.coupon_remove') }}
</button>
</form>
</div>
@else
<form
class="bbk-cart-coupon-form"
method="POST"
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
<label class="bbk-visually-hidden" for="bbk-coupon-code">
{{ __('checkout.cart.coupon_label') }}
</label>
<input
type="text"
name="code"
id="bbk-coupon-code"
class="bbk-cart-coupon-input"
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
autocomplete="off"
required
>
<button type="submit" class="bbk-cart-coupon-submit">
{{ __('checkout.cart.coupon_apply') }}
</button>
</form>
@if ($couponError ?? false)
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
@endif
@endif
</div>
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $cart?->subTotal?->formatted() }}</span>
</div>
@if ($cart?->discountTotal?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $cart->discountTotal->formatted() }}</span>
</div>
@endif
{{-- Shipping + tax appear once the shopper has a shipping address
(i.e. they're on the checkout page). In the drawer, where no
address is set yet, only subtotal + total show. --}}
@if ($cart?->shippingAddress)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
@if ($cart->shippingAddress->shipping_option)
<span>{{ $cart->shippingTotal?->formatted() }}</span>
@else
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
@endif
</div>
@endif
@if ($cart?->taxTotal?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $cart->taxTotal->formatted() }}</span>
</div>
@endif
{{-- Always shown — equals subtotal with nothing else applied,
diverges as discount / shipping / tax come in. --}}
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $cart?->total?->formatted() }}</span>
</div>
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
{{ __('checkout.cart.checkout') }}
</a>
</div>
@endif
</div>
@@ -0,0 +1,9 @@
{{--
Shared error slot for any host wrapping cart-body in a bbk-cart controller
instance (the drawer, and the checkout page's own order summary) —
bbk-cart-controller.js#showError() writes into whichever one is present.
Without this element in a given host, a rejected quantity update (e.g.
over stock) still gets rejected server-side, but the shopper never sees
why.
--}}
<p class="bbk-cart-error" data-bbk-cart-target="error" hidden role="alert"></p>
@@ -0,0 +1,103 @@
{{--
One cart line. $line is a Lunar\Models\CartLine (iteration var set by
@each in cart-body). The two forms post through bbk-cart-controller
(fetch + method spoofing) and the response re-renders cart-body.
--}}
@php
$variant = $line->purchasable;
$product = $variant?->product;
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
// The variant's own image (falls back to the product's thumbnail
// internally — see ProductVariant::getThumbnail()) — the specific option
// the shopper picked, not just the product in general.
$thumb = $variant?->getThumbnailImage() ?: null;
$variantLabel = $variant?->getOption();
// Not routed through checkout::'s own locale-explicit convention — this
// is a storefront route, so it follows the storefront's own (implicit
// locale) call shape, same as App\Catalog\ProductCard. Carries the
// variant id along so the product page can restore the same option the
// shopper actually has in their cart, not just default to the first one
// (see product-form-controller.js reading ?variant= on connect()).
$productUrl = $product ? route('product.show', ['id' => $product->id, 'variant' => $variant?->id]) : null;
@endphp
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media">
@if ($thumb)
@if ($productUrl)
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
</a>
@else
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
@endif
@endif
</div>
<div class="bbk-cart-item-detail">
@if ($productUrl)
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
@else
<p class="bbk-cart-item-title">{{ $name }}</p>
@endif
@if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<form
class="bbk-cart-qty"
method="POST"
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
>
@csrf
@method('PATCH')
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button>
<input
type="number"
name="quantity"
value="{{ $line->quantity }}"
min="0"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
>
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
>+</button>
</form>
</div>
<div class="bbk-cart-item-aside">
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
<form
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div>
</li>
@@ -0,0 +1,50 @@
{{--
@include('checkout::partials.line-custom-fields', ['line' => $line])
A cart or order line's custom-field answers (meta.custom_fields, written by
Cart\Http\Controllers\CartController::customFieldsMeta()). A file answer
only carries a File id (Modules\Core\File\Models\File is the source of
truth for name/mime/disk/path — never duplicated into meta), resolved
here and linked through the signed download route (files.download),
minted fresh on every render, with a thumbnail when the browser can
display the format (HEIC can't be shown outside Safari, so it gets the
name only).
--}}
@php
$fields = $line->meta['custom_fields'] ?? [];
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
@endphp
@if (! empty($fields))
<dl class="bbk-line-fields">
@foreach ($fields as $field)
<div class="bbk-line-field">
<dt>{{ $field['label'] }}</dt>
<dd>
@if ($field['type'] === 'file')
@php
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
@endphp
@if ($file)
@php
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
'files.download',
now()->addHours(2),
['file' => $file->id],
);
@endphp
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
@if (in_array($file->mime, $previewable, true))
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
@endif
<span>{{ $file->original_name }}</span>
</a>
@endif
@else
{{ $field['value'] }}
@endif
</dd>
</div>
@endforeach
</dl>
@endif
@@ -0,0 +1,30 @@
{{--
Payment method radios. $paymentMethods is Collection<Modules\Core\Payment\
Models\PaymentMethod> from CheckoutService::getPaymentMethods() (already
filtered to enabled + driver-resolves + isConfigured()). Selecting one
autosaves via bbk-payment#selectMethod; `data-payment-driver` tells the
controller whether to mount the Stripe Element.
$paymentMethods, $cart come from the page / controller.
--}}
@php($selected = $cart?->meta['payment_method'] ?? null)
@if ($paymentMethods->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.payment_method_none') }}</p>
@else
<div class="bbk-checkout-payment-options">
@foreach ($paymentMethods as $method)
<label class="bbk-checkout-payment-option">
<input
type="radio"
name="payment_type"
value="{{ $method->type }}"
data-payment-driver="{{ $method->driver }}"
@checked($selected === $method->type)
data-action="change->bbk-payment#selectMethod"
>
<span class="bbk-checkout-payment-option-name">{{ $method->translate('name') }}</span>
</label>
@endforeach
</div>
@endif
@@ -0,0 +1,50 @@
{{--
Shipping methods for the checkout page. Rendered inline by page.blade.php on
load, and re-rendered as a fragment by CheckoutController after every
address save / option change (bbk-checkout-form swaps it in). Radios
autosave via bbk-checkout-form#selectShipping — no submit button. A single
resolved option is auto-selected server-side and shown as a fixed line.
$shippingAddress, $shippingOptions come from the controller / page scope.
--}}
@php($selected = $shippingAddress?->shipping_option)
{{-- Rate resolution needs country (always Greece here) + postcode; until a
postcode is saved there's nothing to quote against yet. --}}
@if (! $shippingAddress?->postcode)
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
@elseif ($shippingOptions->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
@elseif ($shippingOptions->count() === 1)
@php($only = $shippingOptions->first())
<div class="bbk-checkout-shipping-confirmed">
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
@if ($only->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
</div>
@else
<div class="bbk-checkout-shipping-options">
@foreach ($shippingOptions as $option)
<label class="bbk-checkout-shipping-option">
<input
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
@checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping"
>
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
@if ($option->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
</label>
@endforeach
</div>
@endif
@@ -0,0 +1,127 @@
@php
$transaction = $getRecord();
$notes = $transaction->notes ?: ($transaction->meta['notes'] ?? null);
@endphp
@once
@php
$renderPaymentIcons();
@endphp
@endonce
<div
@class([
'text-sm rounded-lg shadow-md border dark:bg-gray-900',
'text-gray-950 dark:text-white',
match($transaction->type){
'refund' => 'border-orange-300',
'intent' => 'border-sky-300',
'capture' => 'border-green-300',
default => 'border-gray-300',
},
'!border-red-500 bg-red-50' => !$transaction->success,
'bg-gray-50' => $transaction->success,
])
>
<div class="p-2 space-y-2">
<div class="px-4 py-2 rounded text-xs bg-white dark:bg-gray-800 shadow text-gray-600 dark:text-gray-400 ring-1 ring-gray-100 dark:ring-gray-700">
<span>{{ $transaction->driver }}</span> //
<span>{{ $transaction->reference }}</span>
</div>
<div class="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded shadow ring-1 ring-gray-100 dark:ring-gray-700">
<div class="flex items-center gap-6">
<div>
<strong class="text-xs">
{{ $transaction->status }}
</strong>
</div>
<div>
<svg viewBox="0 0 50 50" class="w-10">
<use xlink:href="#{{ strtolower($transaction->card_type) }}"></use>
</svg>
</div>
@if($transaction->last_four)
<p class="text-sm">
<span class="inline-block -translate-y-px">
&lowast;&lowast;&lowast;&lowast; &lowast;&lowast;&lowast;&lowast; &lowast;&lowast;&lowast;&lowast;
</span>
<span class="font-medium">
{{ (string) $transaction->last_four }}
</span>
</p>
@endif
</div>
<strong
@class([
"text-sm",
'text-red-500' => !$transaction->success,
match($transaction->type){
'refund' => "text-orange-500",
default => "text-gray-900 dark:text-gray-100",
},
])
>
@if($transaction->type == 'refund')-@endif{{ $transaction->amount->formatted }}
</strong>
</div>
<div class="px-4 py-2 bg-white dark:bg-gray-800 shadow rounded flex items-center justify-between text-gray-600 dark:text-gray-400 ring-1 ring-gray-100 dark:ring-gray-700">
<div class="text-xs flex items-center gap-2">
<div>
<x-filament::icon
icon="heroicon-o-clock"
class="w-4"
/>
</div>
<span>{{ $transaction->created_at->format('jS F Y h:ia') }}</span>
</div>
<div class="flex space-x-2">
@foreach($transaction->paymentChecks() as $check)
<x-filament::badge
:icon="$check->successful ? 'heroicon-m-check' : 'heroicon-m-x-mark'"
:color="$check->successful ? \Filament\Support\Colors\Color::Sky : 'gray'"
>
{{ $check->label }}: {{ $check->message }}
</x-filament::badge>
@endforeach
</div>
</div>
@if($notes)
<div class="px-4 py-2 bg-white dark:bg-gray-800 shadow flex items-center rounded gap-2 ring-1 ring-gray-100 dark:ring-gray-700">
<div>
<x-filament::icon
icon="heroicon-o-chat-bubble-oval-left-ellipsis"
class="w-4"
/>
</div>
<p class="text-sm">{{ $notes }}</p>
</div>
@endif
</div>
<div
@class([
"bottom-0 left-0 block w-full text-center rounded-b-lg border-t text-xs py-1",
"!bg-red-50 !dark:bg-red-400/10 !border-red-300 !text-red-600 !dark:text-red-400" => !$transaction->success,
match($transaction->type){
'refund' => "bg-orange-50 dark:bg-orange-400/10 border-orange-300 text-orange-600 dark:text-orange-400",
'intent' => "bg-sky-50 dark:bg-sky-400/10 border-sky-300 text-sky-600 dark:text-sky-400",
'capture' => "bg-green-50 dark:bg-green-400/10 border-green-300 text-green-600 dark:text-green-400",
default => "bg-gray-50 dark:bg-gray-400/10 border-gray-300 text-gray-600 dark:text-gray-400",
},
])
>
@if(!$transaction->success)
{{ __('lunarpanel::order.transactions.failed') }}
@else
{{ __('lunarpanel::order.transactions.'.$transaction->type) }}
@endif
</div>
</div>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is complete. Thanks for shopping with us!</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is ready for pickup in store.</p>
@@ -0,0 +1,11 @@
<p>Hi,</p>
<p>Thanks for your order! Your order <strong>{{ $reference }}</strong> is confirmed.</p>
<ul>
@foreach ($lines as $line)
<li>{{ $line->quantity }} &times; {{ $line->description }} — {{ $line->total?->formatted }}</li>
@endforeach
</ul>
<p>Total: <strong>{{ $total }}</strong></p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
@@ -1,3 +0,0 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
/**
* Dispatched by UserOtpService::validate() on every successful OTP login, not just
* a first-time one. Modules\Core\Privacy listens on this to auto-cancel a pending
* DataErasureRequest — logging back in during the grace period is the "I changed
* my mind" action (see Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener),
* which needs $user->customers to resolve any pending request. Typed as
* Authenticatable&LunarUser rather than plain Authenticatable (unlike the sibling
* UserCreated event) specifically because that listener depends on it — every real
* User in this codebase implements LunarUser (see docs/lunar.md "LunarUser trait"),
* and User is the only Authenticatable entity in this project (Customer is not —
* see docs/modules.md "Customer/User Pairing").
*/
class UserAuthenticated
{
public function __construct(
public readonly Authenticatable&LunarUser $user,
) {}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
/**
* Dispatched by Customer\Services\CustomerEmailChangeService::confirm()
* once a login-email change actually takes effect — $oldEmail is what the
* account's login used to be, already overwritten on $user by the time
* this fires.
*/
class UserEmailChanged
{
public function __construct(
public readonly Authenticatable $user,
public readonly string $oldEmail,
) {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Auth\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Auth\Services\UserOtpService::generateAndSend()
* when an email has requested too many codes too quickly — caps both
* mail-bombing one inbox and the "just request a fresh code to reset my
* guess count" loophole a per-code attempt cap alone doesn't close.
*/
class OtpThrottledException extends RuntimeException
{
public function __construct(
public readonly int $availableInSeconds,
) {
parent::__construct("Too many code requests. Try again in {$availableInSeconds} second(s).");
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Auth\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Core\Auth\Services\UserSessionService;
use Symfony\Component\HttpFoundation\Response;
/**
* The enforcement half of the session registry — see
* Modules\Core\Auth\Services\UserSessionService's own docblock. Not
* auto-registered anywhere (no routes/kernel wiring exist in this
* package — see Modules\Core\Customer\Services\CustomerAccountService's
* own docblock for why this branch stops at services); a consuming app
* adds this to its `web` middleware group (after `auth`) to actually get
* "logout everywhere" enforcement.
*
* A request with no recorded UserSession at all (see
* UserSessionService::currentSession()'s own docblock) is let through —
* only an EXPLICITLY revoked session is rejected.
*/
class EnsureSessionNotRevoked
{
public function __construct(
private readonly UserSessionService $sessions,
) {}
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$session = $this->sessions->currentSession();
if ($session && $session->isRevoked()) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
abort(401, 'Your session has been revoked. Please log in again.');
}
$session?->update(['last_used_at' => now()]);
return $next($request);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Auth\Listeners;
use Modules\Core\Auth\Events\UserCreated;
/**
* The storefront login page shows a terms/privacy notice ("By continuing,
* you accept the Terms of Use and have read the Privacy Policy") that
* requesting an OTP code implicitly accepts — recorded once, right here,
* for a genuinely new signup only (UserCreated fires exactly once per
* user, from Auth\Services\UserOtpService::generateAndSend()'s own
* wasRecentlyCreated check). An existing user's original acceptance
* (whatever version was live when THEY signed up) must never be
* overwritten by whatever config('legal.*') says today, which is exactly
* why this only ever runs from UserCreated and nowhere else.
*/
class RecordLegalAcceptanceForNewUser
{
public function handle(UserCreated $event): void
{
$event->user->forceFill([
'terms_accepted_at' => now(),
'terms_version' => config('legal.terms_version'),
'privacy_policy_version' => config('legal.privacy_policy_version'),
])->save();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Auth\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the NEW address a shopper is trying to switch their login email
* to (Customer\Services\CustomerEmailChangeService::request()) — proves
* they can actually receive mail there before the switch takes effect.
* View overridable per-app the same way UserOtpMail's is (resources/
* views/vendor/core/auth/mail/email-change-code.blade.php).
*/
class EmailChangeCodeMail extends Mailable
{
public function __construct(
public readonly string $code,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Confirm your new email address');
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.email-change-code');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Auth\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the OLD address once a login-email change actually takes
* effect (Customer\Services\CustomerEmailChangeService::confirm()) — lets
* the previous owner notice if someone else changed it from a hijacked
* session. Shows the new address masked (first character + domain only),
* never the full new address — this notice's whole point is alerting the
* OLD owner, not handing them the new address outright. View overridable
* per-app the same way UserOtpMail's is (resources/views/vendor/core/
* auth/mail/email-changed-notice.blade.php).
*/
class EmailChangedNoticeMail extends Mailable
{
public readonly string $maskedEmail;
public function __construct(string $newEmail)
{
[$local, $domain] = explode('@', $newEmail, 2);
$this->maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain;
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your account email was changed');
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.email-changed-notice');
}
}
+29 -2
View File
@@ -6,20 +6,47 @@ use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* The one OTP email template for every use of Auth\Services\OtpService —
* not just admin login. A code confirming a destructive Artisan command
* (e.g. Command\WipeCatalogCommand) reuses the exact same generation/
* validation mechanism as login, but "Your login code" as the subject
* would be actively misleading for that — the recipient never initiated a
* login. $purpose is a small, fixed set of known keys (see
* COPY_BY_PURPOSE), not free text — a typo'd/unknown purpose falls back
* to 'login' rather than rendering a blank subject/intro.
*/
class OtpMail extends Mailable
{
private const COPY_BY_PURPOSE = [
'login' => [
'subject' => 'Your login code',
'intro' => 'Your login code is:',
],
'wipe-catalog' => [
'subject' => 'Confirm: Wipe Catalog',
'intro' => 'Someone requested to permanently delete every product in the catalog. If this was you, enter this code to confirm:',
],
];
public function __construct(
public readonly string $name,
public readonly string $code,
public readonly string $purpose = 'login',
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your login code');
return new Envelope(subject: $this->copy()['subject']);
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.otp');
return new Content(view: 'core::auth.mail.otp', with: ['intro' => $this->copy()['intro']]);
}
private function copy(): array
{
return self::COPY_BY_PURPOSE[$this->purpose] ?? self::COPY_BY_PURPOSE['login'];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Auth\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One row per login (see Modules\Core\Auth\Services\UserOtpService::
* validate()) — see that table's own migration docblock for why this
* exists independent of the actual session-store driver.
*/
class UserSession extends Model
{
protected $guarded = [];
protected $casts = [
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function user(): BelongsTo
{
$model = config('auth.providers.users.model');
return $this->belongsTo($model);
}
public function isRevoked(): bool
{
return $this->revoked_at !== null;
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\Core\Auth\Privacy;
use Modules\Core\Auth\Models\UserSession;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* Login-session device/location metadata (user_sessions) — ip_address and
* user_agent are device/location fingerprinting data tied 1:1 to a User via
* user_id, never to a Customer (business account), so this is User-scope
* only. No legal retention requirement applies to session metadata the way
* it does to Order (there's no tax/accounting reason to keep old login IPs
* around), so rows are deleted outright rather than pseudonymized.
*
* A hard delete here is safe regardless of whether the User row itself has
* already been erased — CustomerDataProvider::eraseForUser() nulls the
* User's own name/email but never touches user_sessions, and the table's
* own user_id FK is cascadeOnDelete() only if the User row itself were
* hard-deleted, which it never is (erasure here means "identity nulled,"
* not "row removed" — see docs/modules.md "Customer/User Pairing").
*/
class UserSessionDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'sessions';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
return new ProviderExportResult('sessions', []);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
$sessions = UserSession::where('user_id', $subject->userId)->get();
return new ProviderExportResult('sessions', $sessions->map(fn (UserSession $session) => [
'id' => $session->id,
'ip_address' => $session->ip_address,
'user_agent' => $session->user_agent,
'last_used_at' => $session->last_used_at?->toIso8601String(),
'revoked_at' => $session->revoked_at?->toIso8601String(),
])->all());
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('sessions', ErasureOutcome::Skipped, 'Login sessions belong to individual Users, not Customer accounts.');
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
$deleted = UserSession::where('user_id', $subject->userId)->delete();
if ($deleted === 0) {
return new ProviderErasureResult('sessions', ErasureOutcome::Skipped, 'No login sessions for this user.');
}
return new ProviderErasureResult('sessions', ErasureOutcome::Erased);
}
}
+8 -2
View File
@@ -11,7 +11,13 @@ class OtpService
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function generateAndSend(string $email): bool
/**
* $purpose is forwarded as-is to OtpMail, which only recognizes a
* fixed set of keys (see its own COPY_BY_PURPOSE) — an unrecognized
* value there just falls back to 'login' rather than failing here, so
* this method has nothing of its own to validate.
*/
public function generateAndSend(string $email, string $purpose = 'login'): bool
{
$staff = Staff::where('email', $email)->first();
@@ -25,7 +31,7 @@ class OtpService
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save();
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code));
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code, $purpose));
return true;
}
+210 -16
View File
@@ -2,47 +2,241 @@
namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Auth\Events\UserCreated;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Mail\UserOtpMail;
/**
* The storefront's passwordless login — a shopper supplies only an email
* (Shopify-style), gets a 6-digit code, and validate() authenticates the
* `web` guard via Auth::login().
*
* That alone is enough to merge/associate any active guest cart into the
* now-known customer — Auth::login() fires Illuminate\Auth\Events\Login,
* which Lunar's own Lunar\Listeners\CartSessionAuthListener (registered
* unconditionally in LunarServiceProvider::boot(), no opt-in needed)
* already listens to, calling CartSession::associate() with
* config('lunar.cart.auth_policy') — 'merge' by default, 'override' if a
* consumer changes that config. Deliberately no cart-association call
* here: doing our own on top would run a SECOND merge attempt with a
* hardcoded policy that ignores whatever the consumer configured.
*
* generateAndSend() does NOT create a User row for an email it hasn't
* seen before — it used to (firstOrCreate() ran unconditionally), which
* meant this login FORM was effectively a registration form: anyone could
* create a real User (and, via UserCreated's own cascade, a paired
* Customer) for any email address they liked, whether or not a single
* correct code was ever entered. A genuinely new email's pending code now
* lives in the cache (see pendingKey()), keyed by email, with no DB row
* at all — firstOrCreate() and UserCreated only fire from validate(), and
* only once the code has actually been proven correct. An email that
* already has a User row is unaffected: its OTP state still lives on that
* row's own otp_code/otp_expires_at/otp_attempts columns exactly as
* before, so a returning shopper's login is unchanged.
*
* Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts
* caps wrong guesses against ONE code; generation_limit caps how often a
* NEW code can be requested for the same email at all (closes both the
* "regenerate to reset my guess count" loophole and mail-bombing one
* inbox). Both apply identically whether or not a User row exists yet.
*
* validate() also records a UserSessionService entry for the new login —
* see that class's own docblock for the "logout everywhere" registry
* this feeds (Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked
* is the enforcement half; a consuming app must add it to its own
* middleware stack). $request is optional purely so this service stays
* callable from a context with no HTTP request at all (a console
* command, a test) — user-agent/ip are simply not recorded when omitted.
*/
class UserOtpService
{
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function __construct(
private readonly UserSessionService $sessions,
) {}
/**
* @throws OtpThrottledException if this email has requested too many
* codes within core.auth.otp.generation_decay_minutes
*/
public function generateAndSend(string $email): bool
{
$model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]);
$limiterKey = $this->generationLimiterKey($email);
$maxGenerations = (int) config('core.auth.otp.generation_limit', 3);
if (RateLimiter::tooManyAttempts($limiterKey, $maxGenerations)) {
throw new OtpThrottledException(RateLimiter::availableIn($limiterKey));
}
RateLimiter::hit($limiterKey, (int) config('core.auth.otp.generation_decay_minutes', 10) * 60);
$model = config('auth.providers.users.model');
$user = $model::where('email', $email)->first();
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$user->otp_code = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->save();
if ($user) {
$user->otp_code = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0;
$user->save();
} else {
// No row yet — deliberately not created here. See this
// class's own docblock for why: creating one on every
// generateAndSend() call let anyone mint real User/Customer
// rows for an email nobody proved they owned.
Cache::put($this->pendingKey($email), [
'code' => $code,
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0,
], now()->addMinutes(self::EXPIRY_MINUTES));
}
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code));
Mail::to($email)->send(new UserOtpMail($user->name ?? $email, $code));
return true;
}
public function validate(string $email, string $code)
/**
* A wrong code counts against core.auth.otp.max_attempts and, once
* reached, invalidates the code entirely — the shopper must request
* a fresh one via generateAndSend() (itself throttled independently
* — see this class's own docblock) rather than being able to keep
* guessing against a still-live code for the rest of its 10-minute
* expiry window. Applies identically to the cache-backed (no User row
* yet) and DB-backed (existing User row) paths.
*/
public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable
{
$model = config('auth.providers.users.model');
$user = $model::where('email', $email)->first();
$existing = $model::where('email', $email)->exists();
if (! $user) {
$result = $existing
? $this->validateExisting($model, $email, $code)
: $this->validatePending($model, $email, $code);
if (! $result) {
return null;
}
if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) {
return null;
}
RateLimiter::clear($this->generationLimiterKey($email));
$user->otp_code = null;
$user->otp_expires_at = null;
$user->save();
Auth::login($result);
return $user;
$this->sessions->record($result, $request);
Event::dispatch(new UserAuthenticated($result));
return $result;
}
/**
* lockForUpdate() + a transaction make the read-check-increment-save
* atomic across concurrent requests for the same user — without it,
* two guesses fired in parallel can each read the same pre-increment
* otp_attempts value and both save past max_attempts, letting an
* attacker exceed the lockout by parallelizing requests instead of
* sending them serially.
*/
private function validateExisting(string $model, string $email, string $code): ?Authenticatable
{
return DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first();
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
return null;
}
if (! hash_equals((string) $user->otp_code, $code)) {
$user->otp_attempts++;
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
$user->otp_code = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
}
$user->save();
return null;
}
$user->otp_code = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
$user->save();
return $user;
});
}
/**
* No User row exists yet, so there's nothing to lockForUpdate() —
* Cache::lock() is the equivalent guard against two parallel guesses
* against the same pending signup both reading the same pre-increment
* attempts count. The User (and, via UserCreated, its paired Customer)
* is only ever created here, once the code has actually been proven
* correct — never from generateAndSend().
*/
private function validatePending(string $model, string $email, string $code): ?Authenticatable
{
$key = $this->pendingKey($email);
return Cache::lock("{$key}:lock", 10)->block(5, function () use ($model, $email, $code, $key) {
$pending = Cache::get($key);
if (! $pending || now()->timestamp > $pending['expires_at']) {
return null;
}
if (! hash_equals((string) $pending['code'], $code)) {
$pending['attempts']++;
if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
Cache::forget($key);
} else {
Cache::put($key, $pending, now()->addMinutes(self::EXPIRY_MINUTES));
}
return null;
}
Cache::forget($key);
$user = $model::firstOrCreate(['email' => $email]);
// wasRecentlyCreated is Eloquent's own "did firstOrCreate()
// just INSERT, or did it find an existing row" flag. Always
// true here in practice (validatePending() only runs when no
// row existed moments ago), but checked anyway rather than
// assumed, in case of an extremely unlikely race with a
// signup completed through some other path in between.
if ($user->wasRecentlyCreated) {
Event::dispatch(new UserCreated($user));
}
return $user;
});
}
private function generationLimiterKey(string $email): string
{
return 'otp-generate:'.strtolower($email);
}
private function pendingKey(string $email): string
{
return 'otp-pending:'.strtolower($email);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Modules\Core\Auth\Models\UserSession;
/**
* The record/revoke half of the session registry — see
* database/migrations/2026_09_15_000001_create_user_sessions_table.php's
* own docblock for why this exists (SESSION_DRIVER=redis in this app has
* no "sessions" table to purge by user_id). The enforcement half is
* Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked, which reads
* the token this class stamps into the session payload.
*/
class UserSessionService
{
private const SESSION_TOKEN_KEY = 'user_session_token';
/**
* Called once, right after Auth::login() succeeds (see
* UserOtpService::validate()) — generates a fresh token, records it,
* and stamps it into the CURRENT session payload so
* EnsureSessionNotRevoked can look it up on later requests.
*/
public function record(Authenticatable $user, ?Request $request = null): UserSession
{
$token = Str::random(64);
$session = UserSession::create([
'user_id' => $user->getAuthIdentifier(),
'token' => $token,
'user_agent' => $request?->userAgent(),
'ip_address' => $request?->ip(),
'last_used_at' => now(),
]);
session([self::SESSION_TOKEN_KEY => $token]);
return $session;
}
/**
* Revokes every OTHER active session for $user — the current one
* (matched by the token in the CURRENT session payload) is left
* alone, matching Laravel's own logoutOtherDevices() semantics
* (there just isn't a password to re-verify against here — this is a
* passwordless account, so revocation is simply "every row that
* isn't the one making this request").
*
* Known, deliberately accepted gap: this requires only a currently
* valid session, not a freshly-completed login — so anyone holding
* an already-authenticated session (e.g. someone who sits down at an
* account left logged in on a shared/public PC) can use this to
* evict the real owner's OTHER sessions just as easily as the real
* owner could use it to evict an intruder's. A stricter version would
* require a fresh OTP re-verification (e.g. within the last few
* minutes) before allowing this call. Left as-is for now — revisit if
* this turns out to matter in practice, rather than building
* abuse-resistance against a threat model nobody's confirmed is real
* for this storefront.
*/
public function revokeOtherSessions(Authenticatable $user): int
{
$currentToken = session(self::SESSION_TOKEN_KEY);
return UserSession::query()
->where('user_id', $user->getAuthIdentifier())
->whereNull('revoked_at')
->when($currentToken, fn ($query) => $query->where('token', '!=', $currentToken))
->update(['revoked_at' => now()]);
}
/**
* Revokes EVERY session for $user, current one included — for a
* "this account may be compromised" response, not a routine logout.
*/
public function revokeAllSessions(Authenticatable $user): int
{
return UserSession::query()
->where('user_id', $user->getAuthIdentifier())
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
}
/**
* @return UserSession|null null if the CURRENT session has no
* recorded token at all (e.g. a session predating this feature, or
* one Auth::login() established outside UserOtpService) — treated
* as valid by EnsureSessionNotRevoked rather than rejected, since
* there's nothing to have been revoked.
*/
public function currentSession(): ?UserSession
{
$token = session(self::SESSION_TOKEN_KEY);
if (! $token) {
return null;
}
return UserSession::where('token', $token)->first();
}
}
+20 -15
View File
@@ -5,7 +5,7 @@ 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\Cart\Services\CartLifecycleService;
use Modules\Core\Recovery\Events\CartAbandoned;
use Modules\Core\Recovery\Events\CheckoutAbandoned;
@@ -31,6 +31,20 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
* state at all; every cart still matching the query below refires its event
* on every run until Recovery (not yet built — see
* docs/recovery-strategies.md) owns its own dedup/tracking table.
*
* Both queries require meta->recovery_consent = true — CartAbandoned/
* CheckoutAbandoned exist specifically to drive future recovery-email
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
* that consent is actually recorded), and a non-consenting cart's
* abandonment must never be dispatched at all, not merely filtered later
* at send time — see docs referenced above for the legal reasoning. This
* consent filter stays here rather than on Modules\Core\Cart\Services\
* CartLifecycleService, whose two "abandoned" queries this command builds
* on — dispatch eligibility is this command's own concern, not part of
* what "abandoned" means to a staff member browsing the admin panel. (The
* non-empty-lines requirement, by contrast, IS part of what "abandoned"
* means either way, so it lives on CartLifecycleService::abandonedCarts()
* itself, not here.)
*/
class DetectAbandonedCarts extends Command
{
@@ -38,32 +52,23 @@ class DetectAbandonedCarts extends Command
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
public function handle(): void
public function handle(CartLifecycleService $lifecycle): void
{
$cutoff = CartResource::abandonedCutoff();
$cartsAbandoned = 0;
$checkoutsAbandoned = 0;
Cart::query()
->whereDoesntHave('orders')
->where('updated_at', '<=', $cutoff)
->with('lines')
$lifecycle->abandonedCarts(Cart::query())
->where('meta->recovery_consent', true)
->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)
$lifecycle->abandonedCheckouts(Cart::query())
->where('meta->recovery_consent', true)
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
foreach ($carts as $cart) {
+15 -14
View File
@@ -9,20 +9,22 @@ 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;
use Modules\Core\Cart\Services\CartLifecycleService;
/**
* 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).
* line, checkout), never hand-edited by staff. Lists every cart, guest carts
* included — see docs/cart.md ("Scope: every cart, identified or not"). An
* anonymous cart's Customer/User columns just render "—" (see table() below)
* rather than the row being hidden outright: most real traffic never reaches
* an identified user/customer, and "how many carts are ongoing/abandoned
* right now" is a real reporting need regardless of identity — excluding
* anonymous carts would silently undercount it. Lunar itself ships no cart
* admin view at all to follow a precedent from.
*/
class CartResource extends Resource
{
@@ -36,12 +38,6 @@ class CartResource extends Resource
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,
@@ -56,6 +52,11 @@ class CartResource extends Resource
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
public static function lifecycle(): CartLifecycleService
{
return app(CartLifecycleService::class);
}
/**
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
* different things together: a cart someone is actively shopping in right now,
@@ -67,7 +68,7 @@ class CartResource extends Resource
*/
public static function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
return static::lifecycle()->abandonedCutoff();
}
public static function table(Table $table): Table

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