From 956e9e88a6a6e54b1f4479d91cb0cdcb668ab03f Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 21:38:16 +0300 Subject: [PATCH] Fix: Stripping Lunar's Stripe Driver with Boboko's Stripe Payment Driver --- composer.json | 2 +- ...01_create_stripe_payment_intents_table.php | 37 +++++ src/Payment/Drivers/StripePaymentDriver.php | 74 ++++------ .../Controllers/StripeWebhookController.php | 23 ++-- .../Middleware/StripeWebhookMiddleware.php | 51 +++++++ src/Payment/Models/StripePaymentIntent.php | 32 +++++ src/Payment/Support/StripeManager.php | 126 ++++++++++++++++++ src/Payment/routes/webhooks.php | 2 +- 8 files changed, 288 insertions(+), 59 deletions(-) create mode 100644 database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php create mode 100644 src/Payment/Http/Middleware/StripeWebhookMiddleware.php create mode 100644 src/Payment/Models/StripePaymentIntent.php create mode 100644 src/Payment/Support/StripeManager.php diff --git a/composer.json b/composer.json index 6787ef5..fe4154d 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "lunarphp/search": "*", "lunarphp/meilisearch": "*", "spatie/laravel-translation-loader": "^2.8", - "lunarphp/stripe": "^1.5" + "stripe/stripe-php": "^16.6" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php b/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php new file mode 100644 index 0000000..f0d9265 --- /dev/null +++ b/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index 65342cf..b304a37 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -4,9 +4,6 @@ namespace Modules\Core\Payment\Drivers; use Lunar\DataTypes\Price; 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\HandlesPaymentCallback; 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\PaymentVoidFailed; use Modules\Core\Payment\Events\PaymentVoided; +use Modules\Core\Payment\Models\StripePaymentIntent; +use Modules\Core\Payment\Support\StripeManager; use Stripe\Exception\ApiErrorException; use Stripe\PaymentIntent; /** * Talks to Stripe's PaymentIntent API directly — deliberately NOT via - * Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which - * take a Lunar\Models\Cart and derive amount/currency from it. Payment - * must never receive a Cart (see docs/payments.md) — pay()/authorize() - * already receive $amount explicitly as their own required Lunar Price - * parameter (see PaymentResult's own docblock), the caller's job to - * assemble, same as every other driver. + * Lunar's own checkout flow (lunarphp/stripe, since removed — see + * Modules\Core\Payment\Support\StripeManager's own docblock), which took a + * Lunar\Models\Cart and derived amount/currency from it. Payment must + * never receive a Cart (see docs/payments.md) — pay()/authorize() already + * receive $amount explicitly as their own required Lunar Price parameter + * (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 * 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. * * Correlating a later handleCallback() (a separate request — a webhook) - * back to whatever $context identified this attempt is solved the same - * way lunarphp/stripe's own StripePaymentType/ProcessStripeWebhook solve - * it: real cart_id/order_id columns on Lunar\Stripe\Models\ - * StripePaymentIntent (a table already owned by lunarphp/stripe, already - * shaped for exactly this), not a generic context blob. See - * docs/payments.md "Async resolution" for the full reasoning. + * back to whatever $context identified this attempt is solved via real + * cart_id/order_id columns on Modules\Core\Payment\Models\ + * StripePaymentIntent (a table this app now owns outright, already shaped + * for exactly this), not a generic context blob. See docs/payments.md + * "Async resolution" for the full reasoning. */ class StripePaymentDriver implements Configurable, @@ -61,10 +60,13 @@ class StripePaymentDriver implements SupportsRefunds, HandlesPaymentCallback { + public function __construct( + private readonly StripeManager $stripe, + ) {} + /** - * Same key lunarphp/stripe's own StripeManager reads its API key from - * (Stripe::setApiKey(config('services.stripe.key'))) — no key, no - * usable driver. + * Same key StripeManager reads its API key from — no key, no usable + * driver. */ public function isConfigured(): bool { @@ -114,7 +116,7 @@ class StripePaymentDriver implements } try { - $paymentIntent = Stripe::getClient()->paymentIntents->create($params); + $paymentIntent = $this->stripe->getClient()->paymentIntents->create($params); } catch (ApiErrorException $e) { return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual'); } @@ -128,7 +130,7 @@ class StripePaymentDriver implements { [$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; @@ -136,7 +138,7 @@ class StripePaymentDriver implements // automatic capture_method, but Stripe stopped short of // capturing (rare, but the API contract allows it) — finish // the job pay() started. - $paymentIntent = Stripe::getClient()->paymentIntents->capture($reference); + $paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference); } $intentModel?->update(['status' => $paymentIntent->status]); @@ -151,7 +153,7 @@ class StripePaymentDriver implements [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { - $paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [ + $paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference, [ 'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency), ]); } catch (ApiErrorException $e) { @@ -185,7 +187,7 @@ class StripePaymentDriver implements [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { - $paymentIntent = Stripe::getClient()->paymentIntents->cancel($reference); + $paymentIntent = $this->stripe->getClient()->paymentIntents->cancel($reference); } catch (ApiErrorException $e) { $result = $this->failure($amount, $e, $reference); PaymentVoidFailed::dispatch($type, $result, $context); @@ -216,7 +218,7 @@ class StripePaymentDriver implements [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { - $refund = Stripe::getClient()->refunds->create([ + $refund = $this->stripe->getClient()->refunds->create([ 'payment_intent' => $reference, 'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency), ]); @@ -253,7 +255,7 @@ class StripePaymentDriver implements 'order_id' => $context['order_id'] ?? null, 'status' => $paymentIntent->status, 'payment_type' => $type, - 'context' => json_encode($context), + 'context' => $context, ]); } @@ -278,28 +280,10 @@ class StripePaymentDriver implements return [ $intentModel, $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|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 * into Lunar's Price — the one place this class reads a Stripe @@ -383,7 +367,7 @@ class StripePaymentDriver implements return []; } - $charge = Stripe::getCharge(is_string($chargeId) ? $chargeId : $chargeId->id); + $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(); diff --git a/src/Payment/Http/Controllers/StripeWebhookController.php b/src/Payment/Http/Controllers/StripeWebhookController.php index f60c534..67fc7f4 100644 --- a/src/Payment/Http/Controllers/StripeWebhookController.php +++ b/src/Payment/Http/Controllers/StripeWebhookController.php @@ -9,18 +9,17 @@ use Modules\Core\Payment\Drivers\StripePaymentDriver; use Stripe\Webhook; /** - * A boboko-owned webhook endpoint for Stripe — deliberately NOT - * lunarphp/stripe's own route (vendor/lunarphp/stripe/routes/webhooks.php), - * which dispatches into Lunar's own Payments::driver('stripe') flow (the - * flow StripePaymentDriver was built to replace, see that class's own - * docblock). Signature verification is handled by - * Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware, registered on this - * route (see src/Payment/routes/webhooks.php) — pure Stripe SDK - * verification + event-type filtering, safe to reuse even though this - * controller never touches the rest of that vendor package's flow. This - * controller verifies the signature again itself (Webhook::constructEvent()) - * to get the constructed Event object — the middleware doesn't stash one - * anywhere reusable, it only gates the request through. + * A boboko-owned webhook endpoint for Stripe — never went through Lunar's + * own Payments::driver('stripe') flow (the flow StripePaymentDriver was + * built to replace, see that class's own docblock), and lunarphp/stripe + * has since been removed entirely (see Modules\Core\Payment\Support\ + * StripeManager's own docblock). Signature verification is handled by + * Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware, registered + * on this route (see src/Payment/routes/webhooks.php) — pure Stripe SDK + * verification + event-type filtering. This controller verifies the + * signature again itself (Webhook::constructEvent()) to get the + * constructed Event object — the middleware doesn't stash one anywhere + * reusable, it only gates the request through. * * Resolves the driver directly by class, not via * Modules\Core\Payment\Services\PaymentDriverRegistry — this endpoint is diff --git a/src/Payment/Http/Middleware/StripeWebhookMiddleware.php b/src/Payment/Http/Middleware/StripeWebhookMiddleware.php new file mode 100644 index 0000000..6a1fd4c --- /dev/null +++ b/src/Payment/Http/Middleware/StripeWebhookMiddleware.php @@ -0,0 +1,51 @@ +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); + } +} diff --git a/src/Payment/Models/StripePaymentIntent.php b/src/Payment/Models/StripePaymentIntent.php new file mode 100644 index 0000000..afefe12 --- /dev/null +++ b/src/Payment/Models/StripePaymentIntent.php @@ -0,0 +1,32 @@ + 'array', + ]; +} diff --git a/src/Payment/Support/StripeManager.php b/src/Payment/Support/StripeManager.php new file mode 100644 index 0000000..dc2f82e --- /dev/null +++ b/src/Payment/Support/StripeManager.php @@ -0,0 +1,126 @@ + 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); + } +} diff --git a/src/Payment/routes/webhooks.php b/src/Payment/routes/webhooks.php index 96899be..2036925 100644 --- a/src/Payment/routes/webhooks.php +++ b/src/Payment/routes/webhooks.php @@ -2,8 +2,8 @@ use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Support\Facades\Route; -use Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware; use Modules\Core\Payment\Http\Controllers\StripeWebhookController; +use Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware; Route::post( config('payment.stripe.webhook_path', 'payments/stripe/webhook'),