Compare commits

...
8 Commits
18 changed files with 545 additions and 113 deletions
+62
View File
@@ -4,6 +4,68 @@ 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.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
View File
@@ -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.4",
"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,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.');
}
}
+2 -2
View File
@@ -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,
]); ]);
@@ -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
@@ -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
+64 -45
View File
@@ -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);
@@ -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);
}
}
@@ -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',
];
}
+126
View File
@@ -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.
+1 -1
View File
@@ -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'),
+2 -1
View File
@@ -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;
@@ -36,7 +37,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]));