Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb2666495 | ||
|
|
97004234f0 | ||
|
|
ea73cc3562 | ||
|
|
02816fb9e7 | ||
|
|
910ce0205d | ||
|
|
e532c32cab | ||
|
|
6a51b672c8 | ||
|
|
956e9e88a6 | ||
|
|
4489475840 | ||
|
|
e4e008167a | ||
|
|
a5f3008ce2 |
+131
@@ -4,6 +4,137 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [0.17.5] - 2026-09-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Greek translations for `Lunar\Models\Country`/`State` reference data (`lang/el/countries.php`,
|
||||||
|
`lang/el/states.php`), keyed by the exact English spellings Lunar's own installer seeds for
|
||||||
|
Greece (fetched from `data.lunarphp.io/countries+states.json`). Loaded via
|
||||||
|
`loadTranslationsFrom()` under the `core::` namespace — a plain lang file, not
|
||||||
|
`Modules\Core\Localization`'s DB-backed `TranslationService`, since this is fixed reference
|
||||||
|
data, not admin-editable UI copy. A consuming app's storefront looks these up itself (e.g.
|
||||||
|
`__('core::countries.'.$country->name)`) — core has no storefront UI of its own to wire this
|
||||||
|
into.
|
||||||
|
- `Modules\Core\Order\Filament\Extensions\OrderActionsExtension::fixCaptureAction()` — reroutes
|
||||||
|
the backoffice "Capture" header action through `Modules\Core\Payment\Support\
|
||||||
|
TransactionDriverAdapter::capture()`, the same app-level payment pipeline checkout-time captures
|
||||||
|
use, instead of vendor Lunar's `Lunar\Models\Transaction::capture()` (which resolved
|
||||||
|
`Lunar\Facades\Payments`, an entirely separate, unused driver registry, and never dispatched
|
||||||
|
`Modules\Core\Payment\Events\PaymentCaptured`).
|
||||||
|
- `Modules\Core\Payment\Drivers\StripePaymentDriver::cardMetaFromIntent()` — extracts card
|
||||||
|
brand/last-four digits from the Stripe PaymentIntent's `latest_charge`, populated into
|
||||||
|
`PaymentResult::$meta` and mapped onto `Transaction.card_type`/`last_four` by
|
||||||
|
`Modules\Core\Order\Services\TransactionRecorder`. Fixes the admin activity log's "Payment of
|
||||||
|
:amount on card ending :last_four" line rendering with no digits, on both checkout-time and
|
||||||
|
manual captures. Only applies to transactions recorded after this change.
|
||||||
|
- `PaymentMethod.name` and `Lunar\Shipping\Models\ShippingMethod.name` are now locale-keyed JSON
|
||||||
|
columns, rendered in Filament via Lunar's own `Lunar\Admin\Support\Forms\Components\
|
||||||
|
TranslatedText` — one input per configured `Language` row, same shape/resolution as
|
||||||
|
Product/Collection names. Existing plain-string rows are preserved under the store's default
|
||||||
|
language on migration. `ShippingMethod` has no model cast/`ModelManifest` extension point
|
||||||
|
available (vendor table, `Contracts\ShippingMethod` exists but is never bound by the package),
|
||||||
|
so its translation is decoded/encoded at the Filament field boundary and via the new
|
||||||
|
`Modules\Core\Shipping\Support\ShippingMethodName::resolve()` helper, rather than a model cast.
|
||||||
|
- `Modules\Core\Shipping\Contracts\DeclaresFulfillmentType` — lets a shipping rate driver declare
|
||||||
|
whether it fulfils via carrier delivery or in-store pickup as a hardcoded fact about the driver
|
||||||
|
(`AcsRateDriver`, `BoxNowRateDriver` both declare `'carrier'`), instead of asking a merchant to
|
||||||
|
also pick "Carrier delivery" on every row regardless of driver. The merchant-facing "Fulfillment
|
||||||
|
type" Select (`ShippingMethod.data['fulfillment_type']`) now only appears for
|
||||||
|
table-rate-shipping's generic drivers (flat-rate, ship-by, free-shipping), which are genuinely
|
||||||
|
ambiguous, and moved next to `charge_by` instead of trailing at the end of the form,
|
||||||
|
disconnected from the decisions it relates to. `Modules\Core\Shipping\Support\
|
||||||
|
FulfillmentType::resolve()`/`isStorePickup()` is the new single source of truth, replacing a
|
||||||
|
direct `data['fulfillment_type']` read in `Order::isStorePickupOrder()`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past
|
||||||
|
`awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order
|
||||||
|
could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update
|
||||||
|
Status". Now, on `PaymentCaptured` (not `PaymentAuthorized`), `status` advances to the next step
|
||||||
|
in the order's flow, but only when it's still exactly `awaiting_payment`, so a duplicate/delayed
|
||||||
|
capture event never regresses an order staff already moved further.
|
||||||
|
- `Lunar\DataTypes\ShippingOption::$collect` (the flag `docs/checkout.md` documents as the
|
||||||
|
mechanism for detecting a pickup option at checkout) was never actually set by any shipping rate
|
||||||
|
driver — `Modules\Core\Shipping\Concerns\ResolvesFixedPricing` now populates it from the same
|
||||||
|
`FulfillmentType` resolution `Order::isStorePickupOrder()` uses, closing a real gap between
|
||||||
|
documented and actual behavior.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to
|
||||||
|
`OrderActionsExtension` — the class now fixes both the refund and capture header actions on the
|
||||||
|
order page, not just refund.
|
||||||
|
- Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly.
|
||||||
|
`Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own
|
||||||
|
Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing
|
||||||
|
from the package was raw API-client access, amount conversion, and a correlation table, none of
|
||||||
|
which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\
|
||||||
|
StripeManager`, `Modules\Core\Payment\Models\StripePaymentIntent`, `Modules\Core\Payment\Http\
|
||||||
|
Middleware\StripeWebhookMiddleware`, and a first-party copy of the vendor's
|
||||||
|
`create_stripe_payment_intents_table` migration (guarded with `Schema::hasTable()`). No behavior
|
||||||
|
change for consuming apps.
|
||||||
|
|
||||||
|
## [0.17.4] - 2026-09-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `boboko:catalog:backfill-skus` — one-off Artisan command to generate a SKU
|
||||||
|
(`SKU-P{product_id}-V{variant_id}`) for every `Lunar\Models\ProductVariant` left with a `null`
|
||||||
|
SKU by the earlier Shopify import (the source export's `Variant SKU` column was genuinely blank
|
||||||
|
for these rows, not an importer mapping bug — see `Modules\MigrateImport\Shopify\
|
||||||
|
ShopifyExportImporter`). Only touches variants missing a SKU; `--dry-run` lists what would
|
||||||
|
change without writing.
|
||||||
|
|
||||||
|
## [0.17.3] - 2026-09-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly.
|
||||||
|
`Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own
|
||||||
|
Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing
|
||||||
|
from the package was raw API-client access, amount conversion, and a correlation table, none of
|
||||||
|
which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\
|
||||||
|
StripeManager` (API client + `toStripeAmount()`/`fromStripeAmount()`), `Modules\Core\Payment\
|
||||||
|
Models\StripePaymentIntent` (now with a proper `context` array cast, replacing manual
|
||||||
|
`json_encode`/`json_decode`), and `Modules\Core\Payment\Http\Middleware\
|
||||||
|
StripeWebhookMiddleware`. Added `database/migrations/..._create_stripe_payment_intents_table.php`,
|
||||||
|
a first-party copy of the vendor migration (guarded with `Schema::hasTable()` so it's a no-op on
|
||||||
|
any environment that already has the table from the vendor package's own earlier migration run,
|
||||||
|
and only actually creates it on a genuinely fresh install). No behavior change for consuming
|
||||||
|
apps — same table, same driver contract, same webhook endpoint.
|
||||||
|
|
||||||
|
## [0.17.2] - 2026-09-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past
|
||||||
|
`awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order
|
||||||
|
could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update
|
||||||
|
Status". Now, on `PaymentCaptured` (not `PaymentAuthorized` — an authorization isn't yet
|
||||||
|
captured funds), `status` advances to the next step in the order's flow
|
||||||
|
(`Modules\Core\Order\Services\OrderStatusFlow::nextOptions()`) — but only when it's still
|
||||||
|
exactly `awaiting_payment`, so a duplicate/delayed capture event never regresses an order staff
|
||||||
|
already moved further.
|
||||||
|
- The backoffice "Capture" action on the order page (Filament) called vendor Lunar's
|
||||||
|
`Lunar\Models\Transaction::capture()` directly, which resolves `Lunar\Facades\Payments` — an
|
||||||
|
entirely separate, unused driver registry — and never dispatched `Modules\Core\Payment\Events\
|
||||||
|
PaymentCaptured`. This meant a manual capture from the admin panel never ran this app's own
|
||||||
|
payment pipeline at all (including the status-advance fix above). `Modules\Core\Order\Filament\
|
||||||
|
Extensions\OrderActionsExtension` (renamed from `OrderRefundActionsExtension`, since it now
|
||||||
|
fixes both the refund and capture header actions — see below) now routes capture through
|
||||||
|
`Modules\Core\Payment\Support\TransactionDriverAdapter::capture()`, the same app-level path
|
||||||
|
checkout-time captures use.
|
||||||
|
- `Modules\Core\Payment\Drivers\StripePaymentDriver` never extracted a card's brand/last four
|
||||||
|
digits from Stripe's response, so `Lunar\Models\Transaction::card_type`/`last_four` were always
|
||||||
|
empty and the admin's "Payment of :amount on card ending :last_four" activity-log line rendered
|
||||||
|
with no digits — reproduced on both checkout-time and manual captures. Added
|
||||||
|
`cardMetaFromIntent()`, reading `payment_method_details` off the PaymentIntent's `latest_charge`
|
||||||
|
(same source `lunarphp/stripe`'s own `StoreCharges` uses), populated into `PaymentResult::$meta`
|
||||||
|
from `resultFromIntent()` and `capture()`. `Modules\Core\Order\Services\TransactionRecorder`
|
||||||
|
now maps `meta['card_type']`/`meta['last_four']` onto the `Transaction` row. Only applies to
|
||||||
|
transactions recorded after this change — existing rows are not backfilled.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to
|
||||||
|
`OrderActionsExtension` — the class now fixes both the refund and capture header actions on the
|
||||||
|
order page, not just refund, so the old name undersold its scope.
|
||||||
|
|
||||||
## [0.17.1] - 2026-09-15
|
## [0.17.1] - 2026-09-15
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"version": "0.17.1",
|
"version": "0.17.5",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"lunarphp/search": "*",
|
"lunarphp/search": "*",
|
||||||
"lunarphp/meilisearch": "*",
|
"lunarphp/meilisearch": "*",
|
||||||
"spatie/laravel-translation-loader": "^2.8",
|
"spatie/laravel-translation-loader": "^2.8",
|
||||||
"lunarphp/stripe": "^1.5"
|
"stripe/stripe-php": "^16.6"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
@@ -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,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,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' => 'Ελλάδα',
|
||||||
|
];
|
||||||
@@ -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' => 'Περιφέρεια Δυτικής Μακεδονίας',
|
||||||
|
];
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Command;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Lunar\Models\ProductVariant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off backfill for variants the Shopify import left with a blank SKU —
|
||||||
|
* not an importer bug, the source CSV rows genuinely had no `Variant SKU`
|
||||||
|
* value (see Modules\MigrateImport\Shopify\ShopifyExportImporter) — so
|
||||||
|
* this synthesizes one instead of re-running the import. Format is
|
||||||
|
* "SKU-P{product_id}-V{variant_id}": deterministic and guaranteed unique
|
||||||
|
* without a uniqueness check, since product_id/variant_id already are.
|
||||||
|
* Only variants with a null `sku` are touched.
|
||||||
|
*/
|
||||||
|
class BackfillMissingSkusCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'boboko:catalog:backfill-skus {--dry-run : List what would change without writing}';
|
||||||
|
|
||||||
|
protected $description = 'Generate a SKU for every product variant that is missing one';
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$query = ProductVariant::query()->whereNull('sku');
|
||||||
|
$total = $query->count();
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
$this->info('No variants are missing a SKU.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(($dryRun ? '[dry-run] ' : '') . "Backfilling SKUs for {$total} variant(s)...");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
$query->chunkById(500, function ($variants) use ($dryRun, $bar) {
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Variant {$variant->id}: sku => {$sku}");
|
||||||
|
} else {
|
||||||
|
$variant->update(['sku' => $sku]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info($dryRun ? 'Dry run complete — no changes were written.' : 'Done.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,16 @@ class InstallLunarCommand extends Command
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! Language::where('code', 'el')->exists()) {
|
||||||
|
$this->components->info('Adding Greek language');
|
||||||
|
|
||||||
|
Language::create([
|
||||||
|
'code' => 'el',
|
||||||
|
'name' => 'Greek',
|
||||||
|
'default' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if (! Currency::whereDefault(true)->exists()) {
|
if (! Currency::whereDefault(true)->exists()) {
|
||||||
$this->components->info('Adding a default currency (USD)');
|
$this->components->info('Adding a default currency (USD)');
|
||||||
|
|
||||||
@@ -310,7 +320,10 @@ class InstallLunarCommand extends Command
|
|||||||
|
|
||||||
PaymentMethod::create([
|
PaymentMethod::create([
|
||||||
'type' => 'cash-on-delivery',
|
'type' => 'cash-on-delivery',
|
||||||
'name' => 'Cash on Delivery',
|
'name' => [
|
||||||
|
'en' => 'Cash on Delivery',
|
||||||
|
'el' => 'Αντικαταβολή',
|
||||||
|
],
|
||||||
'driver' => 'cash-on-delivery',
|
'driver' => 'cash-on-delivery',
|
||||||
'capture_mode' => 'pay',
|
'capture_mode' => 'pay',
|
||||||
'position' => 0,
|
'position' => 0,
|
||||||
|
|||||||
+2
-2
@@ -28,7 +28,7 @@ use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
|
|||||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||||
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension;
|
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension;
|
||||||
use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension;
|
use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension;
|
||||||
use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension;
|
use Modules\Core\Order\Filament\Extensions\OrderActionsExtension;
|
||||||
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
|
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
|
||||||
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
|
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
|
||||||
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
|
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
|
||||||
@@ -70,7 +70,7 @@ class CorePlugin implements Plugin
|
|||||||
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
|
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
|
||||||
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
|
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
|
||||||
ListShippingMethod::class => ShippingMethodListExtension::class,
|
ListShippingMethod::class => ShippingMethodListExtension::class,
|
||||||
ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
|
ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
|
||||||
OrderItemsTable::class => OrderItemsTableExtension::class,
|
OrderItemsTable::class => OrderItemsTableExtension::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+50
-39
@@ -32,10 +32,6 @@ use ReflectionProperty;
|
|||||||
* could return a real, honest failure — see Payment\Support\
|
* could return a real, honest failure — see Payment\Support\
|
||||||
* TransactionDriverAdapter's own docblock for that history.
|
* TransactionDriverAdapter's own docblock for that history.
|
||||||
*
|
*
|
||||||
* Fix, for capture: wrap the action's own action() closure so that, on
|
|
||||||
* Halt, we call $action->sendFailureNotification() ourselves before letting
|
|
||||||
* the Halt continue propagating — everything else is untouched.
|
|
||||||
*
|
|
||||||
* Fix, for refund: same notification fix, but the action() closure is
|
* Fix, for refund: same notification fix, but the action() closure is
|
||||||
* replaced outright (not wrapped) rather than reused, because refund also
|
* replaced outright (not wrapped) rather than reused, because refund also
|
||||||
* needs a "Refund via" driver Select added to the modal (see
|
* needs a "Refund via" driver Select added to the modal (see
|
||||||
@@ -43,15 +39,28 @@ use ReflectionProperty;
|
|||||||
* Payment\Support\TransactionDriverAdapter::refundVia() instead of
|
* Payment\Support\TransactionDriverAdapter::refundVia() instead of
|
||||||
* Lunar\Models\Transaction::refund() — see fixRefundAction()'s own
|
* Lunar\Models\Transaction::refund() — see fixRefundAction()'s own
|
||||||
* docblock.
|
* docblock.
|
||||||
|
*
|
||||||
|
* Fix, for capture: same notification fix, but the action() closure is
|
||||||
|
* also replaced outright — the actual call is routed through
|
||||||
|
* Payment\Support\TransactionDriverAdapter::capture() instead of
|
||||||
|
* Lunar\Models\Transaction::capture() (see fixCaptureAction()), so a
|
||||||
|
* manual backoffice capture goes through the app's own payment driver
|
||||||
|
* registry and dispatches Payment\Events\PaymentCaptured exactly like a
|
||||||
|
* checkout-time capture does — the vendor path resolved
|
||||||
|
* Lunar\Facades\Payments (an entirely separate, unused driver registry)
|
||||||
|
* and never dispatched that event, which is why Order::status used to
|
||||||
|
* stay stuck on 'awaiting_payment' after a manual capture even though
|
||||||
|
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus now advances it
|
||||||
|
* on PaymentCaptured.
|
||||||
*/
|
*/
|
||||||
class OrderRefundActionsExtension extends ViewPageExtension
|
class OrderActionsExtension extends ViewPageExtension
|
||||||
{
|
{
|
||||||
public function headerActions(array $actions): array
|
public function headerActions(array $actions): array
|
||||||
{
|
{
|
||||||
return array_map(
|
return array_map(
|
||||||
fn (Action $action) => match ($action->getName()) {
|
fn (Action $action) => match ($action->getName()) {
|
||||||
'refund' => $this->fixRefundAction($action),
|
'refund' => $this->fixRefundAction($action),
|
||||||
'capture' => $this->fixFailureNotification($action),
|
'capture' => $this->fixCaptureAction($action),
|
||||||
default => $action,
|
default => $action,
|
||||||
},
|
},
|
||||||
$actions,
|
$actions,
|
||||||
@@ -123,6 +132,41 @@ class OrderRefundActionsExtension extends ViewPageExtension
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors fixRefundAction()'s notification fix, but for the "amount"
|
||||||
|
* field already on the vendor schema — no extra field needed, since
|
||||||
|
* capture always goes back through the transaction's own original
|
||||||
|
* driver (there's no equivalent to refunding via a different driver).
|
||||||
|
*/
|
||||||
|
private function fixCaptureAction(Action $action): Action
|
||||||
|
{
|
||||||
|
return $action->action(function (array $data, Action $action) {
|
||||||
|
$transaction = Transaction::find($data['transaction']);
|
||||||
|
|
||||||
|
if (! $transaction instanceof CoreTransaction) {
|
||||||
|
$action->failureNotification(fn () => Notification::make('capture_failure')->danger()->title('Transaction not found.'))
|
||||||
|
->sendFailureNotification();
|
||||||
|
|
||||||
|
throw new Halt;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = app(TransactionDriverAdapter::class)->capture(
|
||||||
|
$transaction,
|
||||||
|
(int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $response->success) {
|
||||||
|
$action->failureNotification(
|
||||||
|
fn () => Notification::make('capture_failure')->color('danger')->title($response->message)
|
||||||
|
)->sendFailureNotification();
|
||||||
|
|
||||||
|
throw new Halt;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action->success();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -163,37 +207,4 @@ class OrderRefundActionsExtension extends ViewPageExtension
|
|||||||
|
|
||||||
return $reflected->getValue($object);
|
return $reflected->getValue($object);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps the action's own configured action() closure so that, if it
|
|
||||||
* halts (Lunar's closures throw via $action->halt() to signal failure —
|
|
||||||
* see this class's own docblock for why that alone never sends the
|
|
||||||
* notification queued via failureNotification()), we send that
|
|
||||||
* notification ourselves before letting the Halt continue propagating
|
|
||||||
* (still needed — it's what stops callMountedAction() from treating
|
|
||||||
* this as a success and closing the modal/committing the DB transaction).
|
|
||||||
*
|
|
||||||
* $this->evaluate() (not a plain call) matches exactly how Action::call()
|
|
||||||
* itself invokes the closure — Lunar's closures type-hint $data/$record/
|
|
||||||
* $action and rely on Filament's own container-style parameter
|
|
||||||
* resolution, not positional arguments.
|
|
||||||
*/
|
|
||||||
private function fixFailureNotification(Action $action): Action
|
|
||||||
{
|
|
||||||
$originalAction = $action->getActionFunction();
|
|
||||||
|
|
||||||
if ($originalAction === null) {
|
|
||||||
return $action;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $action->action(function (array $arguments) use ($action, $originalAction) {
|
|
||||||
try {
|
|
||||||
return $action->evaluate($originalAction, $arguments);
|
|
||||||
} catch (Halt $exception) {
|
|
||||||
$action->sendFailureNotification();
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ use Filament\Tables\Table;
|
|||||||
use Lunar\Admin\Support\Extending\BaseExtension;
|
use Lunar\Admin\Support\Extending\BaseExtension;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same fix as OrderRefundActionsExtension, applied to the order lines
|
* Same fix as OrderActionsExtension, applied to the order lines
|
||||||
* table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable::
|
* table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable::
|
||||||
* getBulkRefundAction()) — see that class's docblock for the underlying
|
* getBulkRefundAction()) — see that class's docblock for the underlying
|
||||||
* Filament bug (failureNotification()+failure()+halt() never actually
|
* Filament bug (failureNotification()+failure()+halt() never actually
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class OrderPaymentMethodSummaryExtension extends ViewPageExtension
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return PaymentMethod::where('type', $type)->value('name') ?? $type;
|
$method = PaymentMethod::where('type', $type)->first();
|
||||||
|
|
||||||
|
return $method?->translate('name') ?? $type;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event;
|
|||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
use Modules\Core\Order\Enums\PaymentStatus;
|
use Modules\Core\Order\Enums\PaymentStatus;
|
||||||
|
use Modules\Core\Order\Services\OrderStatusFlow;
|
||||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||||
use Modules\Core\Order\Support\OrderStatus;
|
use Modules\Core\Order\Support\OrderStatus;
|
||||||
use Modules\Core\Payment\Events\PaymentAuthorized;
|
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||||
@@ -16,13 +17,16 @@ use Modules\Core\Payment\Events\PaymentRefunded;
|
|||||||
* Registered against PaymentCaptured, PaymentAuthorized, AND
|
* Registered against PaymentCaptured, PaymentAuthorized, AND
|
||||||
* PaymentRefunded (see OrderServiceProvider).
|
* PaymentRefunded (see OrderServiceProvider).
|
||||||
*
|
*
|
||||||
* A capture/authorization only ever writes Order::paid/paid_at (via
|
* PaymentCaptured writes both Order::paid/paid_at (via
|
||||||
* OrderStatusWriter::markPaid()) — never `status`. Confirmed with the
|
* OrderStatusWriter::markPaid()) AND advances `status` out of
|
||||||
* user: status leaving 'awaiting_payment' is always a staff-driven
|
* 'awaiting_payment' to the next step in the order's flow (see
|
||||||
* "Update Status" click, regardless of payment method — no special-casing
|
* OrderStatusFlow::nextOptions()) — re-confirmed with the user: a
|
||||||
* prepaid vs. cash-on-delivery. A prepaid order briefly sitting at
|
* captured payment, manual or via Stripe's webhook, should never leave an
|
||||||
* 'awaiting_payment' with paid = true (until staff notice and advance it)
|
* order sitting at 'awaiting_payment'. Only fires when status is still
|
||||||
* is expected, not a bug.
|
* exactly 'awaiting_payment', so a duplicate/delayed capture event never
|
||||||
|
* regresses an order staff already advanced further. PaymentAuthorized
|
||||||
|
* only marks paid — an authorization is not yet captured funds, so
|
||||||
|
* status stays put until the actual capture.
|
||||||
*
|
*
|
||||||
* A refund still moves `status` (returned -> refunded/partially_refunded)
|
* A refund still moves `status` (returned -> refunded/partially_refunded)
|
||||||
* — refunds are a normal step in Modules\Core\Order\Services\
|
* — refunds are a normal step in Modules\Core\Order\Services\
|
||||||
@@ -44,6 +48,7 @@ class ApplyResolvedPaymentStatus
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OrderStatusWriter $writer,
|
private readonly OrderStatusWriter $writer,
|
||||||
|
private readonly OrderStatusFlow $flow,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
|
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
|
||||||
@@ -66,12 +71,30 @@ class ApplyResolvedPaymentStatus
|
|||||||
|
|
||||||
$this->writer->markPaid($order, $event::class);
|
$this->writer->markPaid($order, $event::class);
|
||||||
|
|
||||||
|
if ($event instanceof PaymentCaptured) {
|
||||||
|
$this->advancePastAwaitingPayment($order, $event);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $wasPlaced) {
|
if (! $wasPlaced) {
|
||||||
$order->update(['placed_at' => $order->placed_at ?? now()]);
|
$order->update(['placed_at' => $order->placed_at ?? now()]);
|
||||||
Event::dispatch(new OrderPlaced($order));
|
Event::dispatch(new OrderPlaced($order));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function advancePastAwaitingPayment(Order $order, PaymentCaptured $event): void
|
||||||
|
{
|
||||||
|
if ($order->status !== 'awaiting_payment') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$next = $this->flow->nextOptions($order);
|
||||||
|
$target = array_key_first($next);
|
||||||
|
|
||||||
|
if ($target !== null) {
|
||||||
|
$this->writer->write($order, $target, $event::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requires the refund Transaction row to already exist (Modules\Core\
|
* Requires the refund Transaction row to already exist (Modules\Core\
|
||||||
* Order\Listeners\RecordPaymentTransaction must run first — see
|
* Order\Listeners\RecordPaymentTransaction must run first — see
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class TransactionRecorder
|
|||||||
'reference' => $result->reference,
|
'reference' => $result->reference,
|
||||||
'status' => $result->status->name,
|
'status' => $result->status->name,
|
||||||
'notes' => $result->failureReason,
|
'notes' => $result->failureReason,
|
||||||
|
'card_type' => $result->meta['card_type'] ?? null,
|
||||||
|
'last_four' => $result->meta['last_four'] ?? null,
|
||||||
'meta' => $result->meta,
|
'meta' => $result->meta,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use Modules\Core\Payment\Events\PaymentRefunded;
|
|||||||
* chooses this driver explicitly in the refund action, independent of
|
* chooses this driver explicitly in the refund action, independent of
|
||||||
* which driver the original payment went through (see
|
* which driver the original payment went through (see
|
||||||
* Payment\Support\TransactionDriverAdapter::refundVia() and
|
* Payment\Support\TransactionDriverAdapter::refundVia() and
|
||||||
* Order\Filament\Extensions\OrderRefundActionsExtension). pay() exists so
|
* Order\Filament\Extensions\OrderActionsExtension). pay() exists so
|
||||||
* the same driver also covers receiving a payment by bank transfer, but
|
* the same driver also covers receiving a payment by bank transfer, but
|
||||||
* the admin UI for that (bank reference, notes, proof-of-transfer upload)
|
* the admin UI for that (bank reference, notes, proof-of-transfer upload)
|
||||||
* is deliberately not built yet — see the follow-up work tracked from this
|
* is deliberately not built yet — see the follow-up work tracked from this
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ namespace Modules\Core\Payment\Drivers;
|
|||||||
|
|
||||||
use Lunar\DataTypes\Price;
|
use Lunar\DataTypes\Price;
|
||||||
use Lunar\Models\Currency;
|
use Lunar\Models\Currency;
|
||||||
use Lunar\Stripe\Facades\Stripe;
|
|
||||||
use Lunar\Stripe\Managers\StripeManager;
|
|
||||||
use Lunar\Stripe\Models\StripePaymentIntent;
|
|
||||||
use Modules\Core\Payment\Contracts\Configurable;
|
use Modules\Core\Payment\Contracts\Configurable;
|
||||||
use Modules\Core\Payment\Contracts\HandlesPaymentCallback;
|
use Modules\Core\Payment\Contracts\HandlesPaymentCallback;
|
||||||
use Modules\Core\Payment\Contracts\SupportsAuthorization;
|
use Modules\Core\Payment\Contracts\SupportsAuthorization;
|
||||||
@@ -26,17 +23,20 @@ use Modules\Core\Payment\Events\PaymentRefundFailed;
|
|||||||
use Modules\Core\Payment\Events\PaymentRefunded;
|
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||||
use Modules\Core\Payment\Events\PaymentVoidFailed;
|
use Modules\Core\Payment\Events\PaymentVoidFailed;
|
||||||
use Modules\Core\Payment\Events\PaymentVoided;
|
use Modules\Core\Payment\Events\PaymentVoided;
|
||||||
|
use Modules\Core\Payment\Models\StripePaymentIntent;
|
||||||
|
use Modules\Core\Payment\Support\StripeManager;
|
||||||
use Stripe\Exception\ApiErrorException;
|
use Stripe\Exception\ApiErrorException;
|
||||||
use Stripe\PaymentIntent;
|
use Stripe\PaymentIntent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Talks to Stripe's PaymentIntent API directly — deliberately NOT via
|
* Talks to Stripe's PaymentIntent API directly — deliberately NOT via
|
||||||
* Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which
|
* Lunar's own checkout flow (lunarphp/stripe, since removed — see
|
||||||
* take a Lunar\Models\Cart and derive amount/currency from it. Payment
|
* Modules\Core\Payment\Support\StripeManager's own docblock), which took a
|
||||||
* must never receive a Cart (see docs/payments.md) — pay()/authorize()
|
* Lunar\Models\Cart and derived amount/currency from it. Payment must
|
||||||
* already receive $amount explicitly as their own required Lunar Price
|
* never receive a Cart (see docs/payments.md) — pay()/authorize() already
|
||||||
* parameter (see PaymentResult's own docblock), the caller's job to
|
* receive $amount explicitly as their own required Lunar Price parameter
|
||||||
* assemble, same as every other driver.
|
* (see PaymentResult's own docblock), the caller's job to assemble, same
|
||||||
|
* as every other driver.
|
||||||
*
|
*
|
||||||
* Every amount that crosses this class's own boundary is converted right
|
* Every amount that crosses this class's own boundary is converted right
|
||||||
* there: Lunar's Price -> Stripe's minor-unit int going INTO a gateway
|
* there: Lunar's Price -> Stripe's minor-unit int going INTO a gateway
|
||||||
@@ -45,12 +45,11 @@ use Stripe\PaymentIntent;
|
|||||||
* Nothing outside this class ever sees a Stripe-scaled integer.
|
* Nothing outside this class ever sees a Stripe-scaled integer.
|
||||||
*
|
*
|
||||||
* Correlating a later handleCallback() (a separate request — a webhook)
|
* Correlating a later handleCallback() (a separate request — a webhook)
|
||||||
* back to whatever $context identified this attempt is solved the same
|
* back to whatever $context identified this attempt is solved via real
|
||||||
* way lunarphp/stripe's own StripePaymentType/ProcessStripeWebhook solve
|
* cart_id/order_id columns on Modules\Core\Payment\Models\
|
||||||
* it: real cart_id/order_id columns on Lunar\Stripe\Models\
|
* StripePaymentIntent (a table this app now owns outright, already shaped
|
||||||
* StripePaymentIntent (a table already owned by lunarphp/stripe, already
|
* for exactly this), not a generic context blob. See docs/payments.md
|
||||||
* shaped for exactly this), not a generic context blob. See
|
* "Async resolution" for the full reasoning.
|
||||||
* docs/payments.md "Async resolution" for the full reasoning.
|
|
||||||
*/
|
*/
|
||||||
class StripePaymentDriver implements
|
class StripePaymentDriver implements
|
||||||
Configurable,
|
Configurable,
|
||||||
@@ -61,16 +60,18 @@ class StripePaymentDriver implements
|
|||||||
SupportsRefunds,
|
SupportsRefunds,
|
||||||
HandlesPaymentCallback
|
HandlesPaymentCallback
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StripeManager $stripe,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same key lunarphp/stripe's own StripeManager reads its API key from
|
* Same key StripeManager reads its API key from — no key, no usable
|
||||||
* (Stripe::setApiKey(config('services.stripe.key'))) — no key, no
|
* driver.
|
||||||
* usable driver.
|
|
||||||
*/
|
*/
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
{
|
{
|
||||||
return filled(config('services.stripe.key'));
|
return filled(config('services.stripe.key'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomic charge — capture_method: automatic. Stripe still frequently
|
* Atomic charge — capture_method: automatic. Stripe still frequently
|
||||||
* confirms into requires_action/requires_confirmation rather than
|
* confirms into requires_action/requires_confirmation rather than
|
||||||
@@ -115,7 +116,7 @@ class StripePaymentDriver implements
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$paymentIntent = Stripe::getClient()->paymentIntents->create($params);
|
$paymentIntent = $this->stripe->getClient()->paymentIntents->create($params);
|
||||||
} catch (ApiErrorException $e) {
|
} catch (ApiErrorException $e) {
|
||||||
return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
|
return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
|
||||||
}
|
}
|
||||||
@@ -129,7 +130,7 @@ class StripePaymentDriver implements
|
|||||||
{
|
{
|
||||||
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? '');
|
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? '');
|
||||||
|
|
||||||
$paymentIntent = Stripe::getClient()->paymentIntents->retrieve($reference);
|
$paymentIntent = $this->stripe->getClient()->paymentIntents->retrieve($reference);
|
||||||
|
|
||||||
$authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL;
|
$authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL;
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ class StripePaymentDriver implements
|
|||||||
// automatic capture_method, but Stripe stopped short of
|
// automatic capture_method, but Stripe stopped short of
|
||||||
// capturing (rare, but the API contract allows it) — finish
|
// capturing (rare, but the API contract allows it) — finish
|
||||||
// the job pay() started.
|
// the job pay() started.
|
||||||
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference);
|
$paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
$intentModel?->update(['status' => $paymentIntent->status]);
|
$intentModel?->update(['status' => $paymentIntent->status]);
|
||||||
@@ -152,7 +153,7 @@ class StripePaymentDriver implements
|
|||||||
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [
|
$paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference, [
|
||||||
'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
||||||
]);
|
]);
|
||||||
} catch (ApiErrorException $e) {
|
} catch (ApiErrorException $e) {
|
||||||
@@ -171,6 +172,7 @@ class StripePaymentDriver implements
|
|||||||
reference: $paymentIntent->id,
|
reference: $paymentIntent->id,
|
||||||
amount: $amount,
|
amount: $amount,
|
||||||
raw: $paymentIntent->toArray(),
|
raw: $paymentIntent->toArray(),
|
||||||
|
meta: $this->cardMetaFromIntent($paymentIntent),
|
||||||
);
|
);
|
||||||
|
|
||||||
$paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
|
$paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
|
||||||
@@ -185,7 +187,7 @@ class StripePaymentDriver implements
|
|||||||
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$paymentIntent = Stripe::getClient()->paymentIntents->cancel($reference);
|
$paymentIntent = $this->stripe->getClient()->paymentIntents->cancel($reference);
|
||||||
} catch (ApiErrorException $e) {
|
} catch (ApiErrorException $e) {
|
||||||
$result = $this->failure($amount, $e, $reference);
|
$result = $this->failure($amount, $e, $reference);
|
||||||
PaymentVoidFailed::dispatch($type, $result, $context);
|
PaymentVoidFailed::dispatch($type, $result, $context);
|
||||||
@@ -216,7 +218,7 @@ class StripePaymentDriver implements
|
|||||||
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$refund = Stripe::getClient()->refunds->create([
|
$refund = $this->stripe->getClient()->refunds->create([
|
||||||
'payment_intent' => $reference,
|
'payment_intent' => $reference,
|
||||||
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
||||||
]);
|
]);
|
||||||
@@ -253,7 +255,7 @@ class StripePaymentDriver implements
|
|||||||
'order_id' => $context['order_id'] ?? null,
|
'order_id' => $context['order_id'] ?? null,
|
||||||
'status' => $paymentIntent->status,
|
'status' => $paymentIntent->status,
|
||||||
'payment_type' => $type,
|
'payment_type' => $type,
|
||||||
'context' => json_encode($context),
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,28 +280,10 @@ class StripePaymentDriver implements
|
|||||||
return [
|
return [
|
||||||
$intentModel,
|
$intentModel,
|
||||||
$intentModel?->payment_type ?? $typeFallback,
|
$intentModel?->payment_type ?? $typeFallback,
|
||||||
$this->decodeContext($intentModel) ?? $context,
|
$intentModel?->context ?? $context,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* StripePaymentIntent is a vendor model (lunarphp/stripe) with no cast
|
|
||||||
* declared for our own 'context' column (added by boboko-core's own
|
|
||||||
* migration, see database/migrations/..._add_context_to_stripe_
|
|
||||||
* payment_intents.php) — we can't edit the vendor model to add one, so
|
|
||||||
* decode manually here instead of assuming Eloquent already did it.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
private function decodeContext(?StripePaymentIntent $intentModel): ?array
|
|
||||||
{
|
|
||||||
if (! $intentModel || ! $intentModel->context) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return json_decode($intentModel->context, associative: true) ?: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a live Stripe PaymentIntent's own amount/currency back
|
* Converts a live Stripe PaymentIntent's own amount/currency back
|
||||||
* into Lunar's Price — the one place this class reads a Stripe
|
* into Lunar's Price — the one place this class reads a Stripe
|
||||||
@@ -341,6 +325,7 @@ class StripePaymentDriver implements
|
|||||||
amount: $amount,
|
amount: $amount,
|
||||||
failureReason: $paymentIntent->last_payment_error->message ?? null,
|
failureReason: $paymentIntent->last_payment_error->message ?? null,
|
||||||
raw: $paymentIntent->toArray(),
|
raw: $paymentIntent->toArray(),
|
||||||
|
meta: $status === PaymentResultStatus::Pending ? [] : $this->cardMetaFromIntent($paymentIntent),
|
||||||
continuation: $continuation,
|
continuation: $continuation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -363,6 +348,40 @@ class StripePaymentDriver implements
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* card_type/last_four for Modules\Core\Order\Services\
|
||||||
|
* TransactionRecorder to map onto Transaction (see PaymentResult::
|
||||||
|
* $meta's own docblock) — same fields, same source
|
||||||
|
* (payment_method_details on the underlying Charge) as lunarphp/
|
||||||
|
* stripe's own StoreCharges, just reached via latest_charge instead of
|
||||||
|
* an order-level charge list, since this driver has no Order/Cart to
|
||||||
|
* enumerate charges from.
|
||||||
|
*
|
||||||
|
* @return array{card_type?: string, last_four?: string}
|
||||||
|
*/
|
||||||
|
private function cardMetaFromIntent(PaymentIntent $paymentIntent): array
|
||||||
|
{
|
||||||
|
$chargeId = $paymentIntent->latest_charge;
|
||||||
|
|
||||||
|
if (blank($chargeId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$charge = $this->stripe->getCharge(is_string($chargeId) ? $chargeId : $chargeId->id);
|
||||||
|
|
||||||
|
$paymentType = collect($charge->payment_method_details)->keys()->first();
|
||||||
|
$details = collect($charge->payment_method_details)->first();
|
||||||
|
|
||||||
|
if (blank($details)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'card_type' => $details['brand'] ?? $paymentType,
|
||||||
|
'last_four' => $details['last4'] ?? null,
|
||||||
|
], fn ($value) => filled($value));
|
||||||
|
}
|
||||||
|
|
||||||
private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult
|
private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult
|
||||||
{
|
{
|
||||||
$result = $this->failure($amount, $e);
|
$result = $this->failure($amount, $e);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use Filament\Tables\Columns\TextColumn;
|
|||||||
use Filament\Tables\Columns\ToggleColumn;
|
use Filament\Tables\Columns\ToggleColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Lunar\Admin\Support\Forms\Components\TranslatedText;
|
||||||
use Modules\Core\Payment\Contracts\Configurable;
|
use Modules\Core\Payment\Contracts\Configurable;
|
||||||
use Modules\Core\Payment\Events\PaymentMethodsReordered;
|
use Modules\Core\Payment\Events\PaymentMethodsReordered;
|
||||||
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
|
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
|
||||||
@@ -76,7 +77,7 @@ class PaymentMethodResource extends Resource
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->label('Name')
|
->label('Name')
|
||||||
->searchable(),
|
->state(fn (PaymentMethod $record) => $record->translate('name')),
|
||||||
TextColumn::make('type')
|
TextColumn::make('type')
|
||||||
->label('Type'),
|
->label('Type'),
|
||||||
TextColumn::make('driver')
|
TextColumn::make('driver')
|
||||||
@@ -124,10 +125,9 @@ class PaymentMethodResource extends Resource
|
|||||||
public static function getFormComponents(): array
|
public static function getFormComponents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TextInput::make('name')
|
TranslatedText::make('name')
|
||||||
->label('Name')
|
->label('Name')
|
||||||
->required()
|
->required(),
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('type')
|
TextInput::make('type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
|
->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
|
||||||
|
|||||||
@@ -9,18 +9,17 @@ use Modules\Core\Payment\Drivers\StripePaymentDriver;
|
|||||||
use Stripe\Webhook;
|
use Stripe\Webhook;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A boboko-owned webhook endpoint for Stripe — deliberately NOT
|
* A boboko-owned webhook endpoint for Stripe — never went through Lunar's
|
||||||
* lunarphp/stripe's own route (vendor/lunarphp/stripe/routes/webhooks.php),
|
* own Payments::driver('stripe') flow (the flow StripePaymentDriver was
|
||||||
* which dispatches into Lunar's own Payments::driver('stripe') flow (the
|
* built to replace, see that class's own docblock), and lunarphp/stripe
|
||||||
* flow StripePaymentDriver was built to replace, see that class's own
|
* has since been removed entirely (see Modules\Core\Payment\Support\
|
||||||
* docblock). Signature verification is handled by
|
* StripeManager's own docblock). Signature verification is handled by
|
||||||
* Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware, registered on this
|
* Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware, registered
|
||||||
* route (see src/Payment/routes/webhooks.php) — pure Stripe SDK
|
* on this route (see src/Payment/routes/webhooks.php) — pure Stripe SDK
|
||||||
* verification + event-type filtering, safe to reuse even though this
|
* verification + event-type filtering. This controller verifies the
|
||||||
* controller never touches the rest of that vendor package's flow. This
|
* signature again itself (Webhook::constructEvent()) to get the
|
||||||
* controller verifies the signature again itself (Webhook::constructEvent())
|
* constructed Event object — the middleware doesn't stash one anywhere
|
||||||
* to get the constructed Event object — the middleware doesn't stash one
|
* reusable, it only gates the request through.
|
||||||
* anywhere reusable, it only gates the request through.
|
|
||||||
*
|
*
|
||||||
* Resolves the driver directly by class, not via
|
* Resolves the driver directly by class, not via
|
||||||
* Modules\Core\Payment\Services\PaymentDriverRegistry — this endpoint is
|
* Modules\Core\Payment\Services\PaymentDriverRegistry — this endpoint is
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Stripe\Exception\SignatureVerificationException;
|
||||||
|
use Stripe\Exception\UnexpectedValueException;
|
||||||
|
use Stripe\Webhook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-party replacement for Lunar\Stripe\Http\Middleware\
|
||||||
|
* StripeWebhookMiddleware (lunarphp/stripe removed — see
|
||||||
|
* Modules\Core\Payment\Support\StripeManager's own docblock). Registered
|
||||||
|
* on the same route as before (src/Payment/routes/webhooks.php) purely to
|
||||||
|
* gate malformed/irrelevant requests before they reach
|
||||||
|
* Modules\Core\Payment\Http\Controllers\StripeWebhookController, which
|
||||||
|
* re-verifies the signature itself (see that controller's own docblock)
|
||||||
|
* to get the constructed Event object — this duplication predates the
|
||||||
|
* package removal and is left unchanged here.
|
||||||
|
*/
|
||||||
|
class StripeWebhookMiddleware
|
||||||
|
{
|
||||||
|
public function handle(Request $request, ?Closure $next = null)
|
||||||
|
{
|
||||||
|
$secret = config('services.stripe.webhooks.lunar');
|
||||||
|
$stripeSig = $request->header('Stripe-Signature');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$event = Webhook::constructEvent(
|
||||||
|
$request->getContent(),
|
||||||
|
$stripeSig,
|
||||||
|
$secret
|
||||||
|
);
|
||||||
|
} catch (UnexpectedValueException|SignatureVerificationException $e) {
|
||||||
|
abort(400, $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array(
|
||||||
|
$event->type,
|
||||||
|
[
|
||||||
|
'payment_intent.payment_failed',
|
||||||
|
'payment_intent.succeeded',
|
||||||
|
]
|
||||||
|
)) {
|
||||||
|
return response('', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace Modules\Core\Payment\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
|
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Lunar\Base\Traits\HasTranslations;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A merchant-configured payment method — the DB-instance layer, admin
|
* A merchant-configured payment method — the DB-instance layer, admin
|
||||||
@@ -11,7 +12,16 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* shipping_methods table already has (see docs/payments.md):
|
* shipping_methods table already has (see docs/payments.md):
|
||||||
* - type: unique, machine-facing slug (Cart::meta['payment_method'],
|
* - type: unique, machine-facing slug (Cart::meta['payment_method'],
|
||||||
* ApplyPaymentMethodFee's lookup key, every Payment event's $type).
|
* ApplyPaymentMethodFee's lookup key, every Payment event's $type).
|
||||||
* - name: admin-facing label.
|
* - name: admin-facing label, locale-keyed JSON (e.g.
|
||||||
|
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}) — same shape/
|
||||||
|
* resolution as Product/Collection names (Lunar\Base\Traits\
|
||||||
|
* HasTranslations), just applied directly to this column rather than
|
||||||
|
* through attribute_data, since this is a merchant settings row, not
|
||||||
|
* a catalog attribute. Rendered in Filament via Lunar's own
|
||||||
|
* Lunar\Admin\Support\Forms\Components\TranslatedText — one input per
|
||||||
|
* configured Language row, no bespoke translation UI. Resolve a
|
||||||
|
* display string with $method->translate('name') (locale defaults to
|
||||||
|
* app()->getLocale(), falling back to the store's default language).
|
||||||
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
|
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
|
||||||
* — NOT the same as `type`, and not unique (two rows can share one
|
* — NOT the same as `type`, and not unique (two rows can share one
|
||||||
* driver, e.g. two differently-named offline-style methods).
|
* driver, e.g. two differently-named offline-style methods).
|
||||||
@@ -28,12 +38,15 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
*/
|
*/
|
||||||
class PaymentMethod extends Model
|
class PaymentMethod extends Model
|
||||||
{
|
{
|
||||||
|
use HasTranslations;
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'enabled' => 'boolean',
|
'enabled' => 'boolean',
|
||||||
'position' => 'integer',
|
'position' => 'integer',
|
||||||
'driver_missing_at' => 'datetime',
|
'driver_missing_at' => 'datetime',
|
||||||
|
'name' => 'array',
|
||||||
'data' => AsArrayObject::class,
|
'data' => AsArrayObject::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Models;
|
||||||
|
|
||||||
|
use Lunar\Base\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-party replacement for Lunar\Stripe\Models\StripePaymentIntent (the
|
||||||
|
* lunarphp/stripe package was removed — see Modules\Core\Payment\Support\
|
||||||
|
* StripeManager's own docblock). Same table (lunar_stripe_payment_intents,
|
||||||
|
* created by database/migrations/..._create_stripe_payment_intents_table,
|
||||||
|
* a first-party copy of the vendor migration), including the app-owned
|
||||||
|
* `context`/`payment_type` columns Modules\Core\Payment\Drivers\
|
||||||
|
* StripePaymentDriver::handleCallback() needs to recover $context/$type
|
||||||
|
* across the separate request a webhook arrives on — see that class's own
|
||||||
|
* docblock for "Async resolution".
|
||||||
|
*
|
||||||
|
* Extends Lunar\Base\BaseModel (from lunarphp/core, unaffected by removing
|
||||||
|
* lunarphp/stripe) purely so table-prefix resolution
|
||||||
|
* (config('lunar.database.table_prefix')) stays identical to how the
|
||||||
|
* vendor model resolved it — this table was created under that prefix.
|
||||||
|
*/
|
||||||
|
class StripePaymentIntent extends BaseModel
|
||||||
|
{
|
||||||
|
protected $table = 'stripe_payment_intents';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'context' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Support;
|
||||||
|
|
||||||
|
use Lunar\Models\Contracts\Currency as CurrencyContract;
|
||||||
|
use Stripe\Charge;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-party replacement for Lunar\Stripe\Facades\Stripe +
|
||||||
|
* Lunar\Stripe\Managers\StripeManager — lunarphp/stripe was removed once
|
||||||
|
* Modules\Core\Payment\Drivers\StripePaymentDriver already replaced every
|
||||||
|
* bit of Lunar's own Stripe payment flow (see that class's own docblock);
|
||||||
|
* all that remained load-bearing from the package was raw API-client
|
||||||
|
* access and amount conversion, neither of which is Lunar-specific. Only
|
||||||
|
* the methods StripePaymentDriver actually called are kept — no
|
||||||
|
* fetchOrCreateIntent()/cart-bound helpers, which belonged to Lunar's own
|
||||||
|
* (unused) checkout flow.
|
||||||
|
*
|
||||||
|
* getClient()/getCharge() call the Stripe SDK directly rather than going
|
||||||
|
* through a facade — StripePaymentDriver resolves this class via the
|
||||||
|
* container instead, same as every other dependency it takes.
|
||||||
|
*/
|
||||||
|
class StripeManager
|
||||||
|
{
|
||||||
|
public function getClient(): StripeClient
|
||||||
|
{
|
||||||
|
return new StripeClient([
|
||||||
|
'api_key' => config('services.stripe.key'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCharge(string $chargeId): Charge
|
||||||
|
{
|
||||||
|
return $this->getClient()->charges->retrieve($chargeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zero-decimal currencies, per Stripe. The amount sent to Stripe is the
|
||||||
|
* major unit amount as-is.
|
||||||
|
*
|
||||||
|
* @see https://docs.stripe.com/currencies#zero-decimal
|
||||||
|
*/
|
||||||
|
protected const ZERO_DECIMAL_CURRENCIES = [
|
||||||
|
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg',
|
||||||
|
'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-decimal currencies, per Stripe. The amount sent to Stripe is the
|
||||||
|
* major unit amount multiplied by 1000.
|
||||||
|
*
|
||||||
|
* @see https://docs.stripe.com/currencies#three-decimal
|
||||||
|
*/
|
||||||
|
protected const THREE_DECIMAL_CURRENCIES = ['bhd', 'jod', 'kwd', 'omr', 'tnd'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HUF, TWD and UGX are ISO zero-decimal currencies, but Stripe still
|
||||||
|
* requires amounts to be sent as if they had two decimal places.
|
||||||
|
*
|
||||||
|
* @see https://docs.stripe.com/currencies#special-cases
|
||||||
|
*/
|
||||||
|
protected const SPECIAL_ZERO_DECIMAL_CURRENCIES = ['huf', 'twd', 'ugx'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Lunar price value to the amount expected by Stripe.
|
||||||
|
*
|
||||||
|
* Lunar stores prices as integers scaled by `Currency::decimal_places`,
|
||||||
|
* which merchants can set independently of what Stripe expects for a
|
||||||
|
* given currency. This converts back to the major unit amount first,
|
||||||
|
* then re-scales it to whatever sub-unit Stripe requires for the
|
||||||
|
* currency, so the result is correct regardless of how the merchant has
|
||||||
|
* configured `Currency::decimal_places`.
|
||||||
|
*
|
||||||
|
* @see https://docs.stripe.com/currencies
|
||||||
|
*/
|
||||||
|
public static function toStripeAmount(int $value, CurrencyContract $currency): int
|
||||||
|
{
|
||||||
|
return self::rescale($value, max($currency->decimal_places, 0), self::stripeDecimalPlaces($currency));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an amount received from Stripe back to a Lunar price value,
|
||||||
|
* scaled by `Currency::decimal_places`. Inverse of `toStripeAmount()`.
|
||||||
|
*/
|
||||||
|
public static function fromStripeAmount(int $amount, CurrencyContract $currency): int
|
||||||
|
{
|
||||||
|
return self::rescale($amount, self::stripeDecimalPlaces($currency), max($currency->decimal_places, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of decimal places Stripe expects amounts in for a currency.
|
||||||
|
*/
|
||||||
|
protected static function stripeDecimalPlaces(CurrencyContract $currency): int
|
||||||
|
{
|
||||||
|
$code = strtolower($currency->code);
|
||||||
|
|
||||||
|
// UGX is also in the zero-decimal list; the special case takes precedence.
|
||||||
|
if (in_array($code, self::SPECIAL_ZERO_DECIMAL_CURRENCIES, true)) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($code, self::ZERO_DECIMAL_CURRENCIES, true)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($code, self::THREE_DECIMAL_CURRENCIES, true)) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function rescale(int $value, int $fromDecimalPlaces, int $toDecimalPlaces): int
|
||||||
|
{
|
||||||
|
$exponent = $toDecimalPlaces - $fromDecimalPlaces;
|
||||||
|
|
||||||
|
if ($exponent >= 0) {
|
||||||
|
return $value * (10 ** $exponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$divisor = 10 ** (-$exponent);
|
||||||
|
|
||||||
|
return intdiv(abs($value) + intdiv($divisor, 2), $divisor) * ($value < 0 ? -1 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ class TransactionDriverAdapter
|
|||||||
/**
|
/**
|
||||||
* The PaymentDriverRegistry key $transaction was originally taken
|
* The PaymentDriverRegistry key $transaction was originally taken
|
||||||
* through — what refund()/capture() resolve against by default, and
|
* through — what refund()/capture() resolve against by default, and
|
||||||
* what Order\Filament\Extensions\OrderRefundActionsExtension defaults
|
* what Order\Filament\Extensions\OrderActionsExtension defaults
|
||||||
* its "Refund via" driver Select to, before an admin overrides it.
|
* its "Refund via" driver Select to, before an admin overrides it.
|
||||||
*/
|
*/
|
||||||
public function driverKeyFor(Transaction $transaction): ?string
|
public function driverKeyFor(Transaction $transaction): ?string
|
||||||
@@ -72,7 +72,7 @@ class TransactionDriverAdapter
|
|||||||
* when refunding through the transaction's own original driver.
|
* when refunding through the transaction's own original driver.
|
||||||
*
|
*
|
||||||
* Called directly by Order\Filament\Extensions\
|
* Called directly by Order\Filament\Extensions\
|
||||||
* OrderRefundActionsExtension when the admin picks a different driver
|
* OrderActionsExtension when the admin picks a different driver
|
||||||
* in the refund modal, bypassing Lunar\Models\Transaction::refund()
|
* in the refund modal, bypassing Lunar\Models\Transaction::refund()
|
||||||
* (whose fixed refund(int $amount, $notes = null) signature has no
|
* (whose fixed refund(int $amount, $notes = null) signature has no
|
||||||
* room for a driver override) — see that extension's own docblock.
|
* room for a driver override) — see that extension's own docblock.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware;
|
|
||||||
use Modules\Core\Payment\Http\Controllers\StripeWebhookController;
|
use Modules\Core\Payment\Http\Controllers\StripeWebhookController;
|
||||||
|
use Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware;
|
||||||
|
|
||||||
Route::post(
|
Route::post(
|
||||||
config('payment.stripe.webhook_path', 'payments/stripe/webhook'),
|
config('payment.stripe.webhook_path', 'payments/stripe/webhook'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Modules\Core\Providers;
|
|||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Modules\Core\Command\AnonymizeCommand;
|
use Modules\Core\Command\AnonymizeCommand;
|
||||||
|
use Modules\Core\Command\BackfillMissingSkusCommand;
|
||||||
use Modules\Core\Command\ExportCleanupCommand;
|
use Modules\Core\Command\ExportCleanupCommand;
|
||||||
use Modules\Core\Command\ExportCommand;
|
use Modules\Core\Command\ExportCommand;
|
||||||
use Modules\Core\Command\ImportCommand;
|
use Modules\Core\Command\ImportCommand;
|
||||||
@@ -24,6 +25,7 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
|
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
|
||||||
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
|
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
||||||
|
$this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core');
|
||||||
|
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
||||||
@@ -36,7 +38,7 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
], 'core-assets');
|
], 'core-assets');
|
||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]);
|
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class]);
|
||||||
|
|
||||||
//Overriding lunar:install
|
//Overriding lunar:install
|
||||||
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
|
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
|||||||
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
||||||
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
|
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
|
||||||
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
|
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
|
||||||
|
use Modules\Core\Shipping\Support\FulfillmentType;
|
||||||
use Modules\Core\Shipping\Models\Shipment;
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
class ShippingServiceProvider extends ServiceProvider
|
class ShippingServiceProvider extends ServiceProvider
|
||||||
@@ -80,8 +81,10 @@ class ShippingServiceProvider extends ServiceProvider
|
|||||||
// resolveCarrier() for the same lookup pattern already used to
|
// resolveCarrier() for the same lookup pattern already used to
|
||||||
// resolve a carrier driver from it).
|
// resolve a carrier driver from it).
|
||||||
//
|
//
|
||||||
// Reads ShippingMethod.data['fulfillment_type'] directly rather
|
// Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver-
|
||||||
// than through a ShippingMethod::macro('isStorePickup', ...) —
|
// declared for acs/box-now, merchant-configured data['fulfillment_type']
|
||||||
|
// fallback for table-rate-shipping's generic drivers) rather than
|
||||||
|
// through a ShippingMethod::macro('isStorePickup', ...) —
|
||||||
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
|
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
|
||||||
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
|
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
|
||||||
// intercepts EVERY unmatched static call, including macro()
|
// intercepts EVERY unmatched static call, including macro()
|
||||||
@@ -91,8 +94,7 @@ class ShippingServiceProvider extends ServiceProvider
|
|||||||
// false. (Lunar\Models\Order is unaffected because it declares
|
// false. (Lunar\Models\Order is unaffected because it declares
|
||||||
// its own macro() method directly, bypassing __callStatic
|
// its own macro() method directly, bypassing __callStatic
|
||||||
// entirely — that's why Order::macro('isStorePickupOrder', ...)
|
// entirely — that's why Order::macro('isStorePickupOrder', ...)
|
||||||
// below still works.) Defaults to 'carrier' (false) for any row
|
// below still works.)
|
||||||
// saved before this field existed.
|
|
||||||
Order::macro('isStorePickupOrder', function () {
|
Order::macro('isStorePickupOrder', function () {
|
||||||
/** @var Order $this */
|
/** @var Order $this */
|
||||||
$code = $this->shippingAddress?->shipping_option;
|
$code = $this->shippingAddress?->shipping_option;
|
||||||
@@ -108,7 +110,7 @@ class ShippingServiceProvider extends ServiceProvider
|
|||||||
// attribute avoids that entirely.
|
// attribute avoids that entirely.
|
||||||
$method = ShippingMethod::where('code', $code)->first();
|
$method = ShippingMethod::where('code', $code)->first();
|
||||||
|
|
||||||
return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup';
|
return $method && FulfillmentType::isStorePickup($method);
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
|
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ use Lunar\Shipping\Models\ShippingRate;
|
|||||||
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
use Modules\Core\Shipping\Concerns\CachesLivePricing;
|
use Modules\Core\Shipping\Concerns\CachesLivePricing;
|
||||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||||
|
use Modules\Core\Shipping\Support\ShippingMethodName;
|
||||||
use Modules\Core\Shipping\Support\WeightCalculator;
|
use Modules\Core\Shipping\Support\WeightCalculator;
|
||||||
|
|
||||||
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType
|
||||||
{
|
{
|
||||||
use ResolvesFixedPricing;
|
use ResolvesFixedPricing;
|
||||||
use CachesLivePricing;
|
use CachesLivePricing;
|
||||||
@@ -30,6 +32,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
|||||||
return 'ACS Courier';
|
return 'ACS Courier';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fulfillmentType(): string
|
||||||
|
{
|
||||||
|
return 'carrier';
|
||||||
|
}
|
||||||
|
|
||||||
public function description(): string
|
public function description(): string
|
||||||
{
|
{
|
||||||
return 'Live rate quote from ACS Courier.';
|
return 'Live rate quote from ACS Courier.';
|
||||||
@@ -84,7 +91,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
|||||||
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
|
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
|
||||||
|
|
||||||
return new ShippingOption(
|
return new ShippingOption(
|
||||||
name: $shippingMethod->name ?: $this->name(),
|
name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
|
||||||
description: $shippingMethod->description ?: $this->description(),
|
description: $shippingMethod->description ?: $this->description(),
|
||||||
identifier: $shippingRate->getIdentifier(),
|
identifier: $shippingRate->getIdentifier(),
|
||||||
price: new Price($amount, $cart->currency, 1),
|
price: new Price($amount, $cart->currency, 1),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
|||||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||||
use Lunar\Shipping\Models\ShippingRate;
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Box Now has no pricing API, so this always resolves the method's normal
|
* Box Now has no pricing API, so this always resolves the method's normal
|
||||||
@@ -14,7 +15,7 @@ use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
|||||||
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
|
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
|
||||||
* there is no live option to offer.
|
* there is no live option to offer.
|
||||||
*/
|
*/
|
||||||
class BoxNowRateDriver implements ShippingRateInterface
|
class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
|
||||||
{
|
{
|
||||||
use ResolvesFixedPricing;
|
use ResolvesFixedPricing;
|
||||||
|
|
||||||
@@ -25,6 +26,11 @@ class BoxNowRateDriver implements ShippingRateInterface
|
|||||||
return 'Box Now Locker Delivery';
|
return 'Box Now Locker Delivery';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fulfillmentType(): string
|
||||||
|
{
|
||||||
|
return 'carrier';
|
||||||
|
}
|
||||||
|
|
||||||
public function description(): string
|
public function description(): string
|
||||||
{
|
{
|
||||||
return 'Deliver to a Box Now parcel locker.';
|
return 'Deliver to a Box Now parcel locker.';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use Lunar\DataTypes\ShippingOption;
|
|||||||
use Lunar\Facades\Pricing;
|
use Lunar\Facades\Pricing;
|
||||||
use Lunar\Shipping\Models\ShippingMethod;
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
use Lunar\Shipping\Models\ShippingRate;
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
use Modules\Core\Shipping\Support\FulfillmentType;
|
||||||
|
use Modules\Core\Shipping\Support\ShippingMethodName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared by any carrier driver that also supports Lunar's own price-break
|
* Shared by any carrier driver that also supports Lunar's own price-break
|
||||||
@@ -31,12 +33,13 @@ trait ResolvesFixedPricing
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new ShippingOption(
|
return new ShippingOption(
|
||||||
name: $shippingMethod->name ?: $this->name(),
|
name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
|
||||||
description: $shippingMethod->description ?: $this->description(),
|
description: $shippingMethod->description ?: $this->description(),
|
||||||
identifier: $shippingRate->getIdentifier(),
|
identifier: $shippingRate->getIdentifier(),
|
||||||
price: $pricing->matched->price,
|
price: $pricing->matched->price,
|
||||||
taxClass: $shippingRate->getTaxClass(),
|
taxClass: $shippingRate->getTaxClass(),
|
||||||
taxReference: $shippingRate->getTaxReference(),
|
taxReference: $shippingRate->getTaxReference(),
|
||||||
|
collect: FulfillmentType::isStorePickup($shippingMethod),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional contract a shipping rate driver implements to declare whether
|
||||||
|
* it fulfils via carrier delivery or in-store pickup — e.g.
|
||||||
|
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver
|
||||||
|
* are unambiguously carrier-only, so this is a hardcoded fact about the
|
||||||
|
* driver, not something a merchant should have to configure per row.
|
||||||
|
*
|
||||||
|
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
|
||||||
|
* free-shipping) don't implement this — they're genuinely ambiguous (a
|
||||||
|
* merchant could configure one for either carrier delivery or store
|
||||||
|
* pickup), so Modules\Core\Shipping\Support\FulfillmentType::resolve()
|
||||||
|
* falls back to ShippingMethod.data['fulfillment_type'] (still merchant-
|
||||||
|
* overridable) only for drivers that don't implement this contract.
|
||||||
|
*/
|
||||||
|
interface DeclaresFulfillmentType
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return 'carrier'|'store_pickup'
|
||||||
|
*/
|
||||||
|
public function fulfillmentType(): string;
|
||||||
|
}
|
||||||
@@ -11,37 +11,110 @@ use Filament\Forms\Components\Select;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||||
|
use Lunar\Admin\Support\Forms\Components\TranslatedText;
|
||||||
use Lunar\Shipping\Facades\Shipping;
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||||
|
use Modules\Core\Shipping\Support\ShippingMethodName;
|
||||||
|
|
||||||
class ShippingMethodResourceExtension extends ResourceExtension
|
class ShippingMethodResourceExtension extends ResourceExtension
|
||||||
{
|
{
|
||||||
public function extendForm(Schema $schema): Schema
|
public function extendForm(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components(
|
||||||
...$this->replaceChargeByField(
|
$this->replaceFulfillmentTypeField(
|
||||||
$this->replaceDriverField($schema->getComponents())
|
$this->replaceChargeByField(
|
||||||
),
|
$this->replaceNameField(
|
||||||
$this->fulfillmentTypeSelect(),
|
$this->replaceDriverField($schema->getComponents())
|
||||||
]);
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ShippingMethod.data['fulfillment_type'] — 'carrier' (default) or
|
* Replaces the vendor's plain-string `name` TextInput with
|
||||||
* 'store_pickup'. Same free-form-`data`-column pattern as charge_by
|
* Lunar's own TranslatedText — `name` is now a locale-keyed JSON
|
||||||
* above, not a migrated column: ShippingMethod is a vendor
|
* column (see database/migrations/..._make_shipping_methods_name_translatable.php),
|
||||||
* (lunarphp/table-rate-shipping) table, and this codebase avoids
|
* same shape/resolution as PaymentMethod.name and Product/Collection
|
||||||
* forking vendor migrations for a merchant-configurable extra (see
|
* names (Lunar\Base\Traits\HasTranslations).
|
||||||
* PaymentMethod.data.fee for the same convention on a different
|
|
||||||
* vendor-adjacent model).
|
|
||||||
*
|
|
||||||
* What this actually gates: Modules\Core\Shipping\Extensions\
|
|
||||||
* OrderViewExtension's "Create Shipment" action only makes sense for
|
|
||||||
* a 'carrier' method (it books a real carrier voucher) — a
|
|
||||||
* 'store_pickup' order instead moves through Order.status
|
|
||||||
* 'ready-for-pickup' -> a staff "Mark Picked Up" action, no shipment
|
|
||||||
* ever created. See docs/checkout.md for the full status-flow design.
|
|
||||||
*/
|
*/
|
||||||
|
private function replaceNameField(array $components): array
|
||||||
|
{
|
||||||
|
return array_map(function (Component $component) {
|
||||||
|
if (method_exists($component, 'getName') && $component->getName() === 'name') {
|
||||||
|
return $this->translatedNameField();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
|
||||||
|
$component->schema($this->replaceNameField($component->getChildComponents()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $component;
|
||||||
|
}, $components);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShippingMethod.name is a locale-keyed JSON column (see database/
|
||||||
|
* migrations/..._make_shipping_methods_name_translatable.php), but
|
||||||
|
* ShippingMethod is a vendor Eloquent model with no cast declared for
|
||||||
|
* it — Lunar\Shipping\Models\ShippingMethod only casts `data`, and
|
||||||
|
* there's no ModelManifest contract wired up to swap in a first-party
|
||||||
|
* subclass that adds one (Contracts\ShippingMethod exists but is
|
||||||
|
* never bound — see this class's own git history/nameColumn() for
|
||||||
|
* the same gap on the read side). TranslatedText itself round-trips
|
||||||
|
* plain array state, so afterStateHydrated()/dehydrateStateUsing()
|
||||||
|
* decode/encode the raw JSON string at the field boundary instead —
|
||||||
|
* the model attribute is a string on the way in and out, only ever
|
||||||
|
* an array while Filament's schema state holds it.
|
||||||
|
*/
|
||||||
|
private function translatedNameField(): TranslatedText
|
||||||
|
{
|
||||||
|
$field = TranslatedText::make('name')
|
||||||
|
->label('Name')
|
||||||
|
->required()
|
||||||
|
->afterStateHydrated(function (TranslatedText $component, $state) {
|
||||||
|
$decoded = json_decode((string) $state, true);
|
||||||
|
$component->state(is_array($decoded) ? $decoded : []);
|
||||||
|
})
|
||||||
|
->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : []));
|
||||||
|
|
||||||
|
$field->expanded = true;
|
||||||
|
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts the `fulfillment_type` Select right after `charge_by`, in
|
||||||
|
* the SAME Group (vendor's own `Group::make([getChargeByFormComponent()])
|
||||||
|
* ->columns(2)`) — formerly appended at the very end of the whole
|
||||||
|
* form, disconnected from `driver`/`charge_by`, the decisions it
|
||||||
|
* actually relates to. Only rendered at all for a driver that DOESN'T
|
||||||
|
* already declare its own fulfillment type (see Modules\Core\Shipping\
|
||||||
|
* Contracts\DeclaresFulfillmentType, Modules\Core\Shipping\Support\
|
||||||
|
* FulfillmentType) — acs/box-now are unambiguously carrier-only, so
|
||||||
|
* asking a merchant to also pick "Carrier delivery" for every ACS/Box
|
||||||
|
* Now method was redundant, error-prone config with no real decision
|
||||||
|
* behind it. Still offered for table-rate-shipping's generic drivers
|
||||||
|
* (flat-rate, ship-by, free-shipping), which are genuinely ambiguous.
|
||||||
|
*/
|
||||||
|
private function replaceFulfillmentTypeField(array $components): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
$result[] = $component;
|
||||||
|
|
||||||
|
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
|
||||||
|
$result[] = $this->fulfillmentTypeSelect();
|
||||||
|
} elseif (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
|
||||||
|
$component->schema($this->replaceFulfillmentTypeField($component->getChildComponents()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
private function fulfillmentTypeSelect(): Select
|
private function fulfillmentTypeSelect(): Select
|
||||||
{
|
{
|
||||||
return Select::make('data.fulfillment_type')
|
return Select::make('data.fulfillment_type')
|
||||||
@@ -52,9 +125,23 @@ class ShippingMethodResourceExtension extends ResourceExtension
|
|||||||
])
|
])
|
||||||
->default('carrier')
|
->default('carrier')
|
||||||
->required()
|
->required()
|
||||||
|
->visible(fn (Get $get) => $this->driverIsFulfillmentAmbiguous($get('../driver')))
|
||||||
->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.');
|
->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function driverIsFulfillmentAmbiguous(?string $driver): bool
|
||||||
|
{
|
||||||
|
if (! $driver) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ! Shipping::driver($driver) instanceof DeclaresFulfillmentType;
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extend the vendor's cart_total/weight charge_by Select with a third
|
* Extend the vendor's cart_total/weight charge_by Select with a third
|
||||||
* "live" option — only offered when the currently selected driver
|
* "live" option — only offered when the currently selected driver
|
||||||
@@ -127,6 +214,10 @@ class ShippingMethodResourceExtension extends ResourceExtension
|
|||||||
return $this->driverColumn();
|
return $this->driverColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method_exists($column, 'getName') && $column->getName() === 'name') {
|
||||||
|
return $this->nameColumn();
|
||||||
|
}
|
||||||
|
|
||||||
return $column;
|
return $column;
|
||||||
}, $table->getColumns())
|
}, $table->getColumns())
|
||||||
);
|
);
|
||||||
@@ -139,6 +230,21 @@ class ShippingMethodResourceExtension extends ResourceExtension
|
|||||||
->formatStateUsing(fn ($state) => $this->driverLabel($state));
|
->formatStateUsing(fn ($state) => $this->driverLabel($state));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `name` is a locale-keyed JSON column (see Modules\Core\Shipping\
|
||||||
|
* Support\ShippingMethodName's own docblock for why it needs manual
|
||||||
|
* decoding rather than a model cast). Uses ->state() rather than
|
||||||
|
* ->formatStateUsing(), which would otherwise have Filament iterate a
|
||||||
|
* would-be array state as a multi-value list (one formatted cell per
|
||||||
|
* locale) instead of a single string.
|
||||||
|
*/
|
||||||
|
private function nameColumn(): TextColumn
|
||||||
|
{
|
||||||
|
return TextColumn::make('name')
|
||||||
|
->label('Name')
|
||||||
|
->state(fn ($record) => ShippingMethodName::resolve($record));
|
||||||
|
}
|
||||||
|
|
||||||
private function driverLabel(string $key): string
|
private function driverLabel(string $key): string
|
||||||
{
|
{
|
||||||
$driver = collect(Shipping::getSupportedDrivers())->get($key);
|
$driver = collect(Shipping::getSupportedDrivers())->get($key);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Support;
|
||||||
|
|
||||||
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single source of truth for "is this ShippingMethod a carrier
|
||||||
|
* delivery or an in-store pickup" — replaces a merchant-facing
|
||||||
|
* data['fulfillment_type'] Select that used to exist for every method
|
||||||
|
* regardless of driver. Modules\Core\Shipping\Carriers\Acs\AcsRateDriver
|
||||||
|
* and BoxNowRateDriver are unambiguously carrier-only (see
|
||||||
|
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType's own
|
||||||
|
* docblock), so asking a merchant to also pick "Carrier delivery" for
|
||||||
|
* every ACS/Box Now method was redundant, error-prone config with no
|
||||||
|
* real decision behind it.
|
||||||
|
*
|
||||||
|
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
|
||||||
|
* free-shipping) don't implement DeclaresFulfillmentType — a merchant
|
||||||
|
* could genuinely configure one for either purpose (e.g. "Flat Rate —
|
||||||
|
* Athens Store Pickup") — so those still fall back to the merchant-set
|
||||||
|
* data['fulfillment_type'], defaulting to 'carrier' when unset.
|
||||||
|
*/
|
||||||
|
class FulfillmentType
|
||||||
|
{
|
||||||
|
public static function resolve(ShippingMethod $method): string
|
||||||
|
{
|
||||||
|
$driver = collect(Shipping::getSupportedDrivers())->get($method->driver);
|
||||||
|
|
||||||
|
if ($driver instanceof DeclaresFulfillmentType) {
|
||||||
|
return $driver->fulfillmentType();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $method->data['fulfillment_type'] ?? 'carrier';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isStorePickup(ShippingMethod $method): bool
|
||||||
|
{
|
||||||
|
return static::resolve($method) === 'store_pickup';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the merchant-facing "Fulfillment type" Select should be
|
||||||
|
* shown at all for a given driver — hidden entirely for a driver that
|
||||||
|
* already declares its own fulfillment type, since there is no real
|
||||||
|
* decision left for the merchant to make.
|
||||||
|
*/
|
||||||
|
public static function isConfigurableFor(?string $driverKey): bool
|
||||||
|
{
|
||||||
|
if ($driverKey === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$driver = collect(Shipping::getSupportedDrivers())->get($driverKey);
|
||||||
|
|
||||||
|
return ! $driver instanceof DeclaresFulfillmentType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Support;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShippingMethod.name is a locale-keyed JSON column (see database/
|
||||||
|
* migrations/..._make_shipping_methods_name_translatable.php, and
|
||||||
|
* Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension for
|
||||||
|
* the Filament form/table side), but ShippingMethod is a vendor Eloquent
|
||||||
|
* model with no cast declared for it — Lunar\Shipping\Models\
|
||||||
|
* ShippingMethod only casts `data`, and there's no ModelManifest contract
|
||||||
|
* wired up by the package to swap in a first-party subclass that adds
|
||||||
|
* one (Contracts\ShippingMethod exists but is never bound). So `$method->
|
||||||
|
* name` returns the raw JSON string, not a decoded array — this resolves
|
||||||
|
* it the same way Lunar\Base\Traits\HasTranslations::translate() would,
|
||||||
|
* shared by every rate driver that builds a Lunar\DataTypes\ShippingOption
|
||||||
|
* (Modules\Core\Shipping\Concerns\ResolvesFixedPricing, Modules\Core\
|
||||||
|
* Shipping\Carriers\Acs\AcsRateDriver) plus the Filament table column.
|
||||||
|
*/
|
||||||
|
class ShippingMethodName
|
||||||
|
{
|
||||||
|
public static function resolve(ShippingMethod $method, ?string $locale = null): ?string
|
||||||
|
{
|
||||||
|
$decoded = json_decode((string) $method->getRawOriginal('name'), true);
|
||||||
|
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
return $method->getRawOriginal('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Arr::get($decoded, $locale ?: app()->getLocale()) ?: Arr::first($decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user