Add all files
This commit is contained in:
315
extensions/Gateways/PayPal/PayPal.php
Normal file
315
extensions/Gateways/PayPal/PayPal.php
Normal file
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
namespace Paymenter\Extensions\Gateways\PayPal;
|
||||
|
||||
use App\Classes\Extension\Gateway;
|
||||
use App\Events\Service\Updated;
|
||||
use App\Events\ServiceCancellation\Created;
|
||||
use App\Helpers\ExtensionHelper;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceItem;
|
||||
use App\Models\Service;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
class PayPal extends Gateway
|
||||
{
|
||||
public function boot()
|
||||
{
|
||||
require __DIR__ . '/routes.php';
|
||||
// Register webhook route
|
||||
View::addNamespace('gateways.paypal', __DIR__ . '/resources/views');
|
||||
|
||||
Event::listen(Updated::class, function ($event) {
|
||||
if ($event->service->properties->where('key', 'has_paypal_subscription')->first()?->value !== '1') {
|
||||
return;
|
||||
}
|
||||
if ($event->service->isDirty('price')) {
|
||||
try {
|
||||
$this->updateSubscription($event->service);
|
||||
} catch (Exception $e) {
|
||||
}
|
||||
}
|
||||
if ($event->service->isDirty('status') && $event->service->status === Service::STATUS_CANCELLED) {
|
||||
try {
|
||||
$this->cancelSubscription($event->service);
|
||||
} catch (Exception $e) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Event::listen(Created::class, function (Created $event) {
|
||||
$service = $event->cancellation->service;
|
||||
if ($service->properties->where('key', 'has_paypal_subscription')->first()?->value !== '1') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$this->cancelSubscription($service);
|
||||
} catch (Exception $e) {
|
||||
// Log the error or handle it as needed
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getConfig($values = [])
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => 'client_id',
|
||||
'label' => 'Client ID',
|
||||
'type' => 'text',
|
||||
'description' => 'Find your API keys at https://developer.paypal.com/developer/applications',
|
||||
'required' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'client_secret',
|
||||
'label' => 'Client Secret',
|
||||
'type' => 'text',
|
||||
'description' => 'Find your API keys at https://developer.paypal.com/developer/applications',
|
||||
'required' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'webhook_id',
|
||||
'label' => 'Webhook ID',
|
||||
'type' => 'text',
|
||||
'description' => 'Find your webhook ID at https://developer.paypal.com/developer/webhooks',
|
||||
'required' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'test_mode',
|
||||
'label' => 'Test Mode',
|
||||
'type' => 'checkbox',
|
||||
'description' => 'Enable test mode',
|
||||
'required' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'paypal_use_subscriptions',
|
||||
'label' => 'Use subscriptions',
|
||||
'type' => 'checkbox',
|
||||
'description' => 'Enable this option if you want to use subscriptions with PayPal (if available)',
|
||||
'required' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function generateAccessToken()
|
||||
{
|
||||
return once(function () {
|
||||
$url = $this->config('test_mode') ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
|
||||
|
||||
$result = Http::withHeaders([
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Basic ' . base64_encode($this->config('client_id') . ':' . $this->config('client_secret')),
|
||||
])->asForm()->post($url . '/v1/oauth2/token', [
|
||||
'grant_type' => 'client_credentials',
|
||||
]);
|
||||
|
||||
if ($result->failed()) {
|
||||
throw new Exception('Failed to generate access token: ' . $result->body());
|
||||
}
|
||||
|
||||
return $result->json()['access_token'];
|
||||
});
|
||||
}
|
||||
|
||||
public function request($method, $url, $data = [])
|
||||
{
|
||||
return Http::withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $this->generateAccessToken(),
|
||||
])->$method($url, $data)->object();
|
||||
}
|
||||
|
||||
public function pay($invoice, $total)
|
||||
{
|
||||
$url = $this->config('test_mode') ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
|
||||
$eligableforSubscription = collect($invoice->items)->filter(function ($item) {
|
||||
return $item->reference_type === Service::class;
|
||||
})->count() == $invoice->items->count();
|
||||
|
||||
if ($this->config('paypal_use_subscriptions') && $eligableforSubscription && $invoice->items->map(fn ($item) => $item->reference->plan->billing_period . $item->reference->plan->billing_unit)->unique()->count() === 1) {
|
||||
$paypalProduct = $this->request('post', $url . '/v1/catalogs/products', [
|
||||
'name' => $invoice->items->first()->reference->product->name,
|
||||
'type' => 'SERVICE',
|
||||
]);
|
||||
|
||||
// For each item in the invoice, create a billing cycle
|
||||
$billingCycles[] = [
|
||||
'frequency' => [
|
||||
'interval_unit' => strtoupper($invoice->items->first()->reference->plan->billing_unit),
|
||||
'interval_count' => $invoice->items->first()->reference->plan->billing_period,
|
||||
],
|
||||
'tenure_type' => 'TRIAL',
|
||||
'sequence' => 1,
|
||||
'total_cycles' => 1,
|
||||
'pricing_scheme' => [
|
||||
'fixed_price' => [
|
||||
'value' => $total,
|
||||
'currency_code' => $invoice->currency_code,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$nextSum = $invoice->items->sum(fn ($item) => $item->reference->price * $item->reference->quantity);
|
||||
|
||||
$billingCycles[] = [
|
||||
'frequency' => [
|
||||
'interval_unit' => strtoupper($invoice->items->first()->reference->plan->billing_unit),
|
||||
'interval_count' => $invoice->items->first()->reference->plan->billing_period,
|
||||
],
|
||||
'tenure_type' => 'REGULAR',
|
||||
'sequence' => 2,
|
||||
'total_cycles' => 0,
|
||||
'pricing_scheme' => [
|
||||
'fixed_price' => [
|
||||
'value' => $nextSum,
|
||||
'currency_code' => $invoice->currency_code,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$plan = $this->request('post', $url . '/v1/billing/plans', [
|
||||
'product_id' => $paypalProduct->id,
|
||||
'name' => $invoice->items->first()->reference->plan->name,
|
||||
'description' => $invoice->items->first()->reference->plan->description,
|
||||
'billing_cycles' => $billingCycles,
|
||||
'payment_preferences' => [
|
||||
'auto_bill_outstanding' => true,
|
||||
'setup_fee_failure_action' => 'CONTINUE',
|
||||
'payment_failure_threshold' => 3,
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
$order = $this->request('post', $url . '/v2/checkout/orders', [
|
||||
'intent' => 'CAPTURE',
|
||||
'purchase_units' => [
|
||||
[
|
||||
'reference_id' => $invoice->id,
|
||||
'amount' => [
|
||||
'currency_code' => $invoice->currency_code,
|
||||
'value' => $total,
|
||||
],
|
||||
],
|
||||
],
|
||||
'application_context' => [
|
||||
'return_url' => route('invoices.show', $invoice),
|
||||
'cancel_url' => route('invoices.show', $invoice),
|
||||
// Disable shipping information
|
||||
'shipping_preference' => 'NO_SHIPPING',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return view('gateways.paypal::pay', ['invoice' => $invoice, 'total' => $total, 'order' => $order ?? null, 'plan' => $plan ?? null, 'clientId' => $this->config('client_id')]);
|
||||
}
|
||||
|
||||
public function capture(Request $request)
|
||||
{
|
||||
if (!$request->has('orderID')) {
|
||||
abort(400);
|
||||
}
|
||||
$orderID = $request->input('orderID');
|
||||
$url = $this->config('test_mode') ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
|
||||
// First check if the order is already captured
|
||||
$order = $this->request('get', $url . '/v2/checkout/orders/' . $orderID);
|
||||
if ($order->status === 'COMPLETED') {
|
||||
return $order;
|
||||
}
|
||||
|
||||
$response = $this->request('post', $url . '/v2/checkout/orders/' . $orderID . '/capture', [
|
||||
'intent' => 'CAPTURE',
|
||||
]);
|
||||
|
||||
ExtensionHelper::addPayment($response->purchase_units[0]->reference_id, 'PayPal', $response->purchase_units[0]->payments->captures[0]->amount->value, $response->purchase_units[0]->payments->captures[0]->seller_receivable_breakdown->paypal_fee->value, $response->id);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function webhook(Request $request)
|
||||
{
|
||||
$body = $request->getContent();
|
||||
$sigString = $request->header('PAYPAL-TRANSMISSION-ID') . '|' . $request->header('PAYPAL-TRANSMISSION-TIME') . '|' . $this->config('webhook_id') . '|' . crc32($body);
|
||||
$pubKey = openssl_pkey_get_public(file_get_contents($request->header('PAYPAL-CERT-URL')));
|
||||
$details = openssl_pkey_get_details($pubKey);
|
||||
$verifyResult = openssl_verify(
|
||||
$sigString,
|
||||
base64_decode(
|
||||
$request->header('PAYPAL-TRANSMISSION-SIG')
|
||||
),
|
||||
$details['key'],
|
||||
'sha256WithRSAEncryption'
|
||||
);
|
||||
if ($verifyResult !== 1) {
|
||||
return response()->json(['status' => 'error']);
|
||||
}
|
||||
|
||||
$body = $request->json()->all();
|
||||
|
||||
// Handle the subscription event
|
||||
if ($body['event_type'] === 'BILLING.SUBSCRIPTION.CREATED' && isset($body['resource']['custom_id'])) {
|
||||
// Its activated so we can now add the subscription to the user (custom is the order id)
|
||||
Invoice::findOrFail($body['resource']['custom_id'])->items()->where('reference_type', Service::class)->each(function (InvoiceItem $item) use ($body) {
|
||||
$service = $item->reference;
|
||||
$service->subscription_id = $body['resource']['id'];
|
||||
$service->save();
|
||||
$service->properties()->updateOrCreate([
|
||||
'key' => 'has_paypal_subscription',
|
||||
'name' => 'Has PayPal Subscription',
|
||||
], [
|
||||
'value' => true,
|
||||
]);
|
||||
});
|
||||
} elseif ($body['event_type'] === 'PAYMENT.SALE.COMPLETED' && isset($body['resource']['billing_agreement_id'])) {
|
||||
Service::where('subscription_id', $body['resource']['billing_agreement_id'])->each(function (Service $service) use ($body) {
|
||||
// Add payment to the latest invoice
|
||||
$latestInvoice = $service->invoices()->latest()->first();
|
||||
ExtensionHelper::addPayment($latestInvoice->id, 'PayPal', $body['resource']['amount']['total'], $body['resource']['transaction_fee']['value'], $body['resource']['id']);
|
||||
});
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
public function updateSubscription(Service $service)
|
||||
{
|
||||
if ($service->properties->where('key', 'has_paypal_subscription')->first()?->value !== '1') {
|
||||
return false;
|
||||
}
|
||||
$paypal = new PayPal;
|
||||
// Update subscription price
|
||||
$newPrice = Service::where('subscription_id', $service->subscription_id)->sum('price');
|
||||
// Grab currenct subscription ID
|
||||
$subscriptionId = $service->subscription_id;
|
||||
$url = $paypal->config('test_mode') ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
|
||||
|
||||
// Update subscription price
|
||||
$paypal->request('PATCH', $url . '/v1/billing/subscriptions/' . $subscriptionId, [
|
||||
[
|
||||
'op' => 'replace',
|
||||
'path' => '/plan/billing_cycles/@sequence==2/pricing_scheme/fixed_price',
|
||||
'value' => [
|
||||
'value' => $newPrice,
|
||||
'currency_code' => $service->currency_code,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cancelSubscription(Service $service)
|
||||
{
|
||||
// Cancel subscription
|
||||
$url = $this->config('test_mode') ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
|
||||
$this->request('post', $url . '/v1/billing/subscriptions/' . $service->subscription_id . '/cancel', [
|
||||
'reason' => 'User canceled',
|
||||
]);
|
||||
|
||||
$service->properties()->where('key', 'has_paypal_subscription')->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
76
extensions/Gateways/PayPal/resources/views/pay.blade.php
Normal file
76
extensions/Gateways/PayPal/resources/views/pay.blade.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<div id="paypal-button-container"></div>
|
||||
|
||||
@script
|
||||
<script>
|
||||
const script = document.createElement("script");
|
||||
script.src =
|
||||
"https://www.paypal.com/sdk/js?client-id={{ $clientId }}¤cy={{ $invoice->currency_code }}&components=buttons{!! isset($plan) ? '&intent=subscription&vault=true' : '' !!}";
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
// Render the PayPal button
|
||||
paypal
|
||||
.Buttons({
|
||||
style: {
|
||||
shape: "rect",
|
||||
layout: "vertical",
|
||||
color: "gold",
|
||||
label: "paypal",
|
||||
},
|
||||
message: {
|
||||
amount: {{ $total }},
|
||||
},
|
||||
{!! isset($order) ? 'async createOrder(){return \'' . $order->id . '\';},' : '' !!}
|
||||
{!! isset($plan)
|
||||
? 'async createSubscription(data, actions){return actions.subscription.create({plan_id:\'' .
|
||||
$plan->id .
|
||||
'\',custom_id:\'' .
|
||||
$invoice->id .
|
||||
'\',application_context:{user_action:\'SUBSCRIBE_NOW\',payment_method:{payer_selected:\'PAYPAL\',payee_preferred:\'IMMEDIATE_PAYMENT_REQUIRED\'}},subscriber:{name:{given_name:\'' .
|
||||
auth()->user()->name .
|
||||
'\'},email_address:\'' .
|
||||
auth()->user()->email .
|
||||
'\'}});},'
|
||||
: '' !!}
|
||||
|
||||
async onApprove(data, actions) {
|
||||
try {
|
||||
// If plan is set just redirect to the invoice page
|
||||
{!! isset($plan)
|
||||
? 'return actions.redirect(\'' . route('invoices.show', $invoice) . '?checkPayment=true\');'
|
||||
: '' !!}
|
||||
|
||||
const response = await fetch(
|
||||
`{{ route('extensions.gateways.paypal.capture') }}?orderID=${data.orderID}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const orderData = await response.json();
|
||||
|
||||
|
||||
const errorDetail = orderData?.details?.[0];
|
||||
|
||||
if (errorDetail?.issue === "INSTRUMENT_DECLINED") {
|
||||
return actions.restart();
|
||||
} else if (errorDetail) {
|
||||
throw new Error(`${errorDetail.description} (${orderData.debug_id})`);
|
||||
} else if (!orderData.purchase_units) {
|
||||
throw new Error(JSON.stringify(orderData));
|
||||
} else {
|
||||
actions.redirect('{{ route('invoices.show', $invoice) }}?checkPayment=true');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
resultMessage(
|
||||
`Sorry, your transaction could not be processed...<br><br>${error}`
|
||||
);
|
||||
}
|
||||
},
|
||||
}).render("#paypal-button-container")
|
||||
};
|
||||
</script>
|
||||
@endscript
|
||||
7
extensions/Gateways/PayPal/routes.php
Normal file
7
extensions/Gateways/PayPal/routes.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Paymenter\Extensions\Gateways\PayPal\PayPal;
|
||||
|
||||
Route::post('/extensions/paypal/webhook', [PayPal::class, 'webhook'])->name('extensions.gateways.paypal.webhook');
|
||||
Route::post('/extensions/paypal/capture', [PayPal::class, 'capture'])->name('extensions.gateways.paypal.capture');
|
||||
521
extensions/Gateways/Stripe/Stripe.php
Normal file
521
extensions/Gateways/Stripe/Stripe.php
Normal file
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
namespace Paymenter\Extensions\Gateways\Stripe;
|
||||
|
||||
use App\Classes\Extension\Gateway;
|
||||
use App\Events\Service\Updated;
|
||||
use App\Events\ServiceCancellation\Created;
|
||||
use App\Helpers\ExtensionHelper;
|
||||
use App\Models\Gateway as ModelsGateway;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Service;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
class Stripe extends Gateway
|
||||
{
|
||||
public function boot()
|
||||
{
|
||||
require __DIR__ . '/routes.php';
|
||||
// Register webhook route
|
||||
View::addNamespace('gateways.stripe', __DIR__ . '/resources/views');
|
||||
|
||||
Event::listen(Updated::class, function (Updated $event) {
|
||||
if ($event->service->properties->where('key', 'has_stripe_subscription')->first()?->value !== '1' || !$event->service->subscription_id) {
|
||||
// If the service is not a stripe subscription, skip
|
||||
return;
|
||||
}
|
||||
if ($event->service->isDirty('price') || $event->service->isDirty('expires_at')) {
|
||||
try {
|
||||
$this->updateSubscription($event->service);
|
||||
} catch (Exception $e) {
|
||||
}
|
||||
}
|
||||
// Check if the service is canceled
|
||||
if ($event->service->isDirty('status') && $event->service->status === Service::STATUS_CANCELLED) {
|
||||
try {
|
||||
$this->cancelSubscription($event->service);
|
||||
} catch (Exception $e) {
|
||||
// Ignore exception
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Event::listen(Created::class, function (Created $event) {
|
||||
$service = $event->cancellation->service;
|
||||
if ($service->properties->where('key', 'has_stripe_subscription')->first()?->value !== '1' || !$service->subscription_id) {
|
||||
// If the service is not a stripe subscription, skip
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$this->cancelSubscription($service);
|
||||
} catch (Exception $e) {
|
||||
// Ignore exception
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getConfig($values = [])
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => 'stripe_secret_key',
|
||||
'label' => 'Stripe Restricted key',
|
||||
'placeholder' => 'Enter your Stripe Restricted API key',
|
||||
'type' => 'text',
|
||||
'description' => 'Find your API keys at https://dashboard.stripe.com/apikeys',
|
||||
'required' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'stripe_publishable_key',
|
||||
'label' => 'Stripe Publishable Key',
|
||||
'placeholder' => 'Enter your Stripe Publishable API key',
|
||||
'type' => 'text',
|
||||
'description' => 'Find your API keys at https://dashboard.stripe.com/apikeys',
|
||||
'required' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'stripe_webhook_secret',
|
||||
'label' => 'Stripe webhook secret (auto generated)',
|
||||
'type' => 'text',
|
||||
'description' => 'Stripe webhook secret',
|
||||
'required' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'stripe_use_subscriptions',
|
||||
'label' => 'Use subscriptions',
|
||||
'type' => 'checkbox',
|
||||
'description' => 'Enable this option if you want to use subscriptions with Stripe (if available)',
|
||||
'required' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function updated(ModelsGateway $gateway)
|
||||
{
|
||||
if (!empty($gateway->settings()->where('key', 'stripe_webhook_secret')->first()->value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if webhook already exists
|
||||
$webhooks = $this->request('get', '/webhook_endpoints');
|
||||
foreach ($webhooks->data as $webhook) {
|
||||
if ($webhook->url === route('extensions.gateways.stripe.webhook')) {
|
||||
// Delete webhook
|
||||
$this->request('delete', '/webhook_endpoints/' . $webhook->id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create webhook on stripe
|
||||
$webhook = $this->request('post', '/webhook_endpoints', [
|
||||
'url' => route('extensions.gateways.stripe.webhook'),
|
||||
'description' => 'Paymenter Stripe Webhook',
|
||||
'enabled_events' => [
|
||||
'payment_intent.succeeded',
|
||||
'setup_intent.succeeded',
|
||||
'subscription_schedule.canceled',
|
||||
'invoice.created',
|
||||
'invoice.payment_succeeded',
|
||||
],
|
||||
'api_version' => '2025-02-24.acacia', // Use the latest version
|
||||
]);
|
||||
|
||||
$gateway->settings()->updateOrCreate(['key' => 'stripe_webhook_secret'], ['value' => $webhook->secret]);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Webhook created')
|
||||
->body('We\'ve created a webhook for you on Stripe (refresh the page to see the secret)')
|
||||
->send();
|
||||
}
|
||||
|
||||
private function request($method, $url, $data = [])
|
||||
{
|
||||
return Http::withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->config('stripe_secret_key'),
|
||||
])->asForm()->$method('https://api.stripe.com/v1' . $url, $data)->throw()->object();
|
||||
}
|
||||
|
||||
public function pay($invoice, $total)
|
||||
{
|
||||
$eligableforSubscription = collect($invoice->items)->filter(function ($item) {
|
||||
return $item->reference_type === Service::class && $item->reference->plan->type !== 'one-time';
|
||||
})->count() > 0;
|
||||
if ($this->config('stripe_use_subscriptions') && $eligableforSubscription) {
|
||||
$stripeCustomerId = $invoice->user->properties->where('key', 'stripe_id')->first();
|
||||
if ($stripeCustomerId) {
|
||||
try {
|
||||
$customer = $this->request('get', '/customers/' . $stripeCustomerId->value);
|
||||
if ($customer->deleted) {
|
||||
$customer = null;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Customer not found, create a new one
|
||||
}
|
||||
}
|
||||
if (!isset($customer)) {
|
||||
$customer = $this->request('post', '/customers', [
|
||||
'email' => $invoice->user->email,
|
||||
'name' => $invoice->user->name,
|
||||
'metadata' => ['user_id' => $invoice->user->id],
|
||||
]);
|
||||
$invoice->user->properties()->updateOrCreate(['key' => 'stripe_id'], ['value' => $customer->id]);
|
||||
}
|
||||
|
||||
$intent = $this->request('post', '/setup_intents', [
|
||||
'metadata' => ['invoice_id' => $invoice->id],
|
||||
'usage' => 'on_session',
|
||||
'customer' => $customer->id,
|
||||
]);
|
||||
$type = 'setup';
|
||||
} else {
|
||||
$intent = $this->request('post', '/payment_intents', [
|
||||
'amount' => $total * 100,
|
||||
'currency' => $invoice->currency_code,
|
||||
'automatic_payment_methods' => ['enabled' => 'true'],
|
||||
'metadata' => ['invoice_id' => $invoice->id],
|
||||
]);
|
||||
$type = 'payment';
|
||||
}
|
||||
|
||||
// Pay the invoice using Stripe
|
||||
return view('gateways.stripe::pay', ['invoice' => $invoice, 'total' => $total, 'intent' => $intent, 'type' => $type, 'stripePublishableKey' => $this->config('stripe_publishable_key')]);
|
||||
}
|
||||
|
||||
public function webhook(Request $request)
|
||||
{
|
||||
if (!$this->isValidSignature($request->getContent(), $request->header('Stripe-Signature'), $this->config('stripe_webhook_secret'))) {
|
||||
return response()->json(['error' => 'Invalid signature'], 400);
|
||||
}
|
||||
|
||||
$event = json_decode($request->getContent());
|
||||
|
||||
// Handle the event
|
||||
switch ($event->type) {
|
||||
// Normal payment
|
||||
case 'payment_intent.succeeded':
|
||||
$paymentIntent = $event->data->object; // contains a StripePaymentIntent
|
||||
if (!isset($paymentIntent->metadata->invoice_id)) {
|
||||
return response()->json(['error' => 'Invoice ID not found in payment intent metadata'], 400);
|
||||
}
|
||||
// Get fee from payment intent
|
||||
$fee = 0;
|
||||
if (isset($paymentIntent->charges->data[0]->balance_transaction)) {
|
||||
$balanceTransaction = Http::withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->config('stripe_secret_key'),
|
||||
])->get('https://api.stripe.com/v1/balance_transactions/' . $paymentIntent->charges->data[0]->balance_transaction)->object();
|
||||
$fee = $balanceTransaction->fee / 100;
|
||||
}
|
||||
ExtensionHelper::addPayment($paymentIntent->metadata->invoice_id, 'Stripe', $paymentIntent->amount / 100, $fee ?? null, $paymentIntent->id);
|
||||
break;
|
||||
case 'setup_intent.succeeded':
|
||||
$setupIntent = $event->data->object; // contains a StripeSetupIntent
|
||||
$this->setupSubscription($setupIntent);
|
||||
break;
|
||||
case 'subscription_schedule.canceled':
|
||||
$subscriptionSchedule = $event->data->object; // contains a StripeSubscriptionSchedule
|
||||
$service = Service::where('subscription_id', $subscriptionSchedule->id)->first();
|
||||
if ($service) {
|
||||
$service->update(['subscription_id' => null]);
|
||||
}
|
||||
break;
|
||||
case 'invoice.created':
|
||||
$invoice = $event->data->object; // contains a StripeInvoice
|
||||
// Check if its draft and does exist in our database
|
||||
if ($invoice->status === 'draft') {
|
||||
$service = Service::where('subscription_id', $invoice->subscription)->first();
|
||||
|
||||
if ($service) {
|
||||
$this->request('post', '/invoices/' . $invoice->id . '/finalize');
|
||||
// Pay the invoice using Stripe
|
||||
$this->request('post', '/invoices/' . $invoice->id . '/pay');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'invoice.payment_succeeded':
|
||||
// Mark invoice as paid
|
||||
$invoice = $event->data->object; // contains a StripeInvoice
|
||||
|
||||
$service = Service::where('subscription_id', $invoice->subscription)->first();
|
||||
if ($service) {
|
||||
$invoiceModel = $service->invoiceItems->sortByDesc('created_at')->first()->invoice;
|
||||
$paymentIntent = $this->request('get', '/payment_intents/' . $invoice->payment_intent);
|
||||
$fee = 0;
|
||||
if (isset($paymentIntent->charges->data[0]->balance_transaction)) {
|
||||
$balanceTransaction = $this->request('get', '/balance_transactions/' . $paymentIntent->charges->data[0]->balance_transaction);
|
||||
$fee = $balanceTransaction->fee / 100;
|
||||
}
|
||||
ExtensionHelper::addPayment($invoiceModel->id, 'Stripe', $invoice->amount_paid / 100, $fee ?? null, $invoice->payment_intent);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Not a event type we care about, just return 200
|
||||
}
|
||||
|
||||
http_response_code(200);
|
||||
}
|
||||
|
||||
private function setupSubscription($setupIntent)
|
||||
{
|
||||
$invoice = Invoice::findOrFail($setupIntent->metadata->invoice_id);
|
||||
$user = $invoice->user;
|
||||
$stripeCustomerId = $user->properties->where('key', 'stripe_id')->first();
|
||||
// Create customer if not exists
|
||||
if (!$stripeCustomerId) {
|
||||
throw new Exception('Stripe customer not found', $user);
|
||||
} else {
|
||||
$customer = $this->request('get', '/customers/' . $stripeCustomerId->value);
|
||||
}
|
||||
|
||||
// Make payment method default
|
||||
$paymentMethod = $this->request('post', '/customers/' . $customer->id, [
|
||||
'invoice_settings' => ['default_payment_method' => $setupIntent->payment_method],
|
||||
]);
|
||||
|
||||
// Create subscription
|
||||
foreach ($invoice->items as $item) {
|
||||
if ($item->reference_type !== Service::class) {
|
||||
continue;
|
||||
}
|
||||
$service = $item->reference;
|
||||
$product = $service->product;
|
||||
|
||||
// Check if the service->product already exists in Stripe
|
||||
$stripeProduct = $this->request('get', '/products/search', ['query' => 'metadata[\'product_id\']:\'' . $product->id . '\'']);
|
||||
|
||||
if (empty($stripeProduct->data)) {
|
||||
// Create product
|
||||
$stripeProduct = $this->request('post', '/products', [
|
||||
'name' => $product->name,
|
||||
'metadata' => ['product_id' => $product->id],
|
||||
]);
|
||||
} else {
|
||||
$stripeProduct = $stripeProduct->data[0];
|
||||
}
|
||||
|
||||
$phases = [];
|
||||
// Check if current invoice item price is bigger then service price (then we have a setup fee)
|
||||
if ($item->price != $service->price) {
|
||||
$phases[] = [
|
||||
'items' => [
|
||||
[
|
||||
'price_data' => [
|
||||
'currency' => $invoice->currency_code,
|
||||
'product' => $stripeProduct->id,
|
||||
'unit_amount' => $item->price * 100,
|
||||
'recurring' => [
|
||||
'interval' => $service->plan->billing_unit,
|
||||
'interval_count' => $service->plan->billing_period,
|
||||
],
|
||||
],
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'iterations' => 1,
|
||||
'metadata' => [
|
||||
'service_id' => $service->id,
|
||||
],
|
||||
'proration_behavior' => 'none',
|
||||
];
|
||||
}
|
||||
$phases[] = [
|
||||
'items' => [
|
||||
[
|
||||
'price_data' => [
|
||||
'currency' => $invoice->currency_code,
|
||||
'product' => $stripeProduct->id,
|
||||
'unit_amount' => ($service->price * $service->quantity) * 100,
|
||||
'recurring' => [
|
||||
'interval' => $service->plan->billing_unit,
|
||||
'interval_count' => $service->plan->billing_period,
|
||||
],
|
||||
],
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'service_id' => $service->id,
|
||||
],
|
||||
'proration_behavior' => 'none',
|
||||
];
|
||||
|
||||
$subscription = $this->request('post', '/subscription_schedules', [
|
||||
'customer' => $customer->id,
|
||||
'start_date' => now()->startOfDay()->timestamp,
|
||||
'phases' => $phases,
|
||||
'metadata' => ['service_id' => $service->id],
|
||||
]);
|
||||
|
||||
// Update service with subscription id
|
||||
$service->update(['subscription_id' => $subscription->subscription]);
|
||||
$service->properties()->updateOrCreate(['key' => 'has_stripe_subscription'], ['value' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateSubscription(Service $service)
|
||||
{
|
||||
// Grab the schedule from Stripe
|
||||
$scheduleId = $this->request('get', '/subscriptions/' . $service->subscription_id);
|
||||
|
||||
if ($service->isDirty('price')) {
|
||||
if ($scheduleId->schedule) {
|
||||
|
||||
$oldPhases = $this->request('get', '/subscription_schedules/' . $scheduleId->schedule)->phases;
|
||||
// Overwrite phase 2 item 0 with the new price
|
||||
$phases = [];
|
||||
// Only keep items and end, start date
|
||||
foreach ($oldPhases as $phase) {
|
||||
$phases[] = [
|
||||
'items' => $phase->items,
|
||||
'end_date' => $phase->end_date,
|
||||
'start_date' => $phase->start_date,
|
||||
];
|
||||
}
|
||||
// Check if the service->product already exists in Stripe
|
||||
$product = $service->product;
|
||||
$stripeProduct = $this->request('get', '/products/search', ['query' => 'metadata[\'product_id\']:\'' . $product->id . '\'']);
|
||||
|
||||
if (empty($stripeProduct->data)) {
|
||||
// Create product
|
||||
$stripeProduct = $this->request('post', '/products', [
|
||||
'name' => $product->name,
|
||||
'metadata' => ['product_id' => $product->id],
|
||||
]);
|
||||
} else {
|
||||
$stripeProduct = $stripeProduct->data[0];
|
||||
}
|
||||
// Latest phase is the current one
|
||||
$key = count($phases) - 1;
|
||||
$phases[$key]['items'][0]->price_data = [
|
||||
'currency' => $service->currency->code,
|
||||
'unit_amount' => $service->price * 100,
|
||||
'product' => $stripeProduct->id,
|
||||
'recurring' => [
|
||||
'interval' => $service->plan->billing_unit,
|
||||
'interval_count' => $service->plan->billing_period,
|
||||
],
|
||||
];
|
||||
$phases[$key]['items'][0]->price = null;
|
||||
$phases[$key]['items'][0]->plan = null;
|
||||
|
||||
// Update the schedule
|
||||
$this->request('post', '/subscription_schedules/' . $scheduleId->schedule, [
|
||||
'phases' => $phases,
|
||||
'proration_behavior' => 'none',
|
||||
]);
|
||||
} else {
|
||||
// Get subscription
|
||||
$subscription = $this->request('get', '/subscriptions/' . $service->subscription_id);
|
||||
// Get first item
|
||||
$item = $subscription->items->data[0];
|
||||
// Update price
|
||||
$this->request('post', '/subscription_items/' . $item->id, [
|
||||
'price_data' => [
|
||||
'currency' => $service->currency->code,
|
||||
'unit_amount' => $service->price * 100,
|
||||
'product' => $item->price->product,
|
||||
'recurring' => [
|
||||
'interval' => $service->plan->billing_unit,
|
||||
'interval_count' => $service->plan->billing_period,
|
||||
],
|
||||
],
|
||||
'proration_behavior' => 'none',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($service->isDirty('expires_at')) {
|
||||
$subDate = Carbon::createFromTimestamp($scheduleId->current_period_end)->startOfDay();
|
||||
// Check if current date is before the end date of the subscription
|
||||
if ($subDate == $service->expires_at || $service->expires_at <= $subDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scheduleId->schedule) {
|
||||
// As phases are only used for the setup fee, we can remove the phases
|
||||
$this->request('post', '/subscription_schedules/' . $scheduleId->schedule . '/release', []);
|
||||
|
||||
// Get subscription
|
||||
$subscription = $this->request('get', '/subscriptions/' . $service->subscription_id);
|
||||
// Get first item
|
||||
$item = $subscription->items->data[0];
|
||||
// Update price
|
||||
$this->request('post', '/subscription_items/' . $item->id, [
|
||||
'price_data' => [
|
||||
'currency' => $service->currency->code,
|
||||
'unit_amount' => $service->price * 100,
|
||||
'product' => $item->price->product,
|
||||
'recurring' => [
|
||||
'interval' => $service->plan->billing_unit,
|
||||
'interval_count' => $service->plan->billing_period,
|
||||
],
|
||||
],
|
||||
'proration_behavior' => 'none',
|
||||
]);
|
||||
}
|
||||
// Update the subscription
|
||||
$this->request('post', '/subscriptions/' . $service->subscription_id, [
|
||||
'trial_end' => $service->expires_at->timestamp,
|
||||
'proration_behavior' => 'none',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelSubscription(Service $service)
|
||||
{
|
||||
if (!$service->subscription_id && !$service->properties->where('key', 'has_stripe_subscription')->first()) {
|
||||
return;
|
||||
}
|
||||
$this->request('delete', '/subscriptions/' . $service->subscription_id);
|
||||
|
||||
// Remove subscription id from service
|
||||
$service->update(['subscription_id' => null]);
|
||||
// Remove has_stripe_subscription property
|
||||
$service->properties()->where('key', 'has_stripe_subscription')->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Function to split and decode the Stripe-Signature header
|
||||
private function getHeaderValues($sig_header)
|
||||
{
|
||||
$parts = explode(',', $sig_header);
|
||||
$timestamp = null;
|
||||
$signature = null;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (strpos($part, 't=') === 0) {
|
||||
$timestamp = substr($part, 2);
|
||||
} elseif (strpos($part, 'v1=') === 0) {
|
||||
$signature = substr($part, 3);
|
||||
}
|
||||
}
|
||||
|
||||
return [$timestamp, $signature];
|
||||
}
|
||||
|
||||
// Validate the signature
|
||||
private function isValidSignature($payload, $sig_header, $secret)
|
||||
{
|
||||
[$timestamp, $signature] = $this->getHeaderValues($sig_header);
|
||||
|
||||
if (empty($timestamp) || empty($signature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create the signed payload string
|
||||
$signed_payload = $timestamp . '.' . $payload;
|
||||
|
||||
// Compute the expected signature
|
||||
$expected_signature = hash_hmac('sha256', $signed_payload, $secret);
|
||||
|
||||
// Compare the expected signature to the actual signature
|
||||
return hash_equals($expected_signature, $signature);
|
||||
}
|
||||
}
|
||||
163
extensions/Gateways/Stripe/resources/views/pay.blade.php
Normal file
163
extensions/Gateways/Stripe/resources/views/pay.blade.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<form id="payment-form" class="mt-2 text-white">
|
||||
<div id="error-message" class="hidden text-red-500 mb-2">
|
||||
|
||||
</div>
|
||||
<div id="payment-element">
|
||||
|
||||
</div>
|
||||
<button id="submit"
|
||||
class="mt-4 bg-secondary-500 text-white hover:bg-secondary py-2 px-4 rounded-md w-full bg-gradient-to-tr from-secondary via-50% via-20% via-secondary to-[#5573FD80] duration-300">
|
||||
Pay
|
||||
</button>
|
||||
</form>
|
||||
@script
|
||||
<script>
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://js.stripe.com/v3/';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
script.onload = () => {
|
||||
|
||||
var stripe = Stripe(
|
||||
"{{ $stripePublishableKey }}"
|
||||
);
|
||||
|
||||
const type = '{{ $type }}';
|
||||
|
||||
const options = {
|
||||
clientSecret: '{{ $intent->client_secret }}',
|
||||
appearance: {
|
||||
theme: 'day'
|
||||
},
|
||||
};
|
||||
|
||||
const paymentElementOptions = {
|
||||
layout: {
|
||||
type: 'accordion',
|
||||
defaultCollapsed: false,
|
||||
radios: false,
|
||||
spacedAccordionItems: true
|
||||
},
|
||||
defaultValues: {
|
||||
billingDetails: {
|
||||
name: "{{ auth()->user()->name }}",
|
||||
email: "{{ auth()->user()->email }}",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up Stripe.js and Elements to use in checkout form, passing the client secret obtained in a previous step
|
||||
const elements = stripe.elements(options);
|
||||
const paymentElement = elements.create('payment', paymentElementOptions);
|
||||
paymentElement.mount('#payment-element');
|
||||
|
||||
const form = document.getElementById('payment-form');
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
var error;
|
||||
|
||||
if (type == 'payment') {
|
||||
var {
|
||||
error
|
||||
} = await stripe.confirmPayment({
|
||||
//`Elements` instance that was used to create the Payment Element
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: '{{ route('invoices.show', $invoice) }}?checkPayment=true',
|
||||
},
|
||||
});
|
||||
} else if (type == 'setup') {
|
||||
var {
|
||||
error
|
||||
} = await stripe.confirmSetup({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: '{{ route('invoices.show', $invoice) }}?checkPayment=true',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// This point will only be reached if there is an immediate error when
|
||||
// confirming the payment. Otherwise, your customer will be redirected to
|
||||
// your `return_url`. For some payment methods like iDEAL, your customer will
|
||||
// be redirected to an intermediate site first to authorize the payment, then
|
||||
// redirected to the `return_url`.
|
||||
if (error.type === "card_error" || error.type === "validation_error") {
|
||||
showMessage(error.message);
|
||||
} else {
|
||||
showMessage("An unexpected error occurred.");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
checkStatus();
|
||||
|
||||
|
||||
async function checkStatus() {
|
||||
const clientSecret = new URLSearchParams(window.location.search).get(
|
||||
"payment_intent_client_secret"
|
||||
);
|
||||
|
||||
if (!clientSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
paymentIntent
|
||||
} = await stripe.retrievePaymentIntent(clientSecret);
|
||||
|
||||
switch (paymentIntent.status) {
|
||||
case "succeeded":
|
||||
window.location.href = "{{ route('invoices.show', $invoice) }}";
|
||||
break;
|
||||
case "processing":
|
||||
// Show a spinner and disable the submit button
|
||||
setLoading(true);
|
||||
showMessage("Your payment is processing.");
|
||||
// Hide payment-element
|
||||
document.getElementById("payment-element").classList.add("hidden");
|
||||
setTimeout(checkStatus, 2000);
|
||||
break;
|
||||
case "requires_payment_method":
|
||||
showMessage("Your payment was not successful, please try again.");
|
||||
break;
|
||||
default:
|
||||
showMessage("Something went wrong.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(messageText) {
|
||||
const messageContainer = document.querySelector("#error-message");
|
||||
|
||||
messageContainer.classList.remove("hidden");
|
||||
messageContainer.textContent = messageText;
|
||||
|
||||
setTimeout(function() {
|
||||
messageContainer.classList.add("hidden");
|
||||
messageContainer.textContent = "";
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// Show a spinner on payment submission
|
||||
function setLoading(isLoading) {
|
||||
if (isLoading) {
|
||||
// Disable the button and show a spinner
|
||||
document.querySelector("#submit").disabled = true;
|
||||
document.querySelector("#submit").classList.add("hidden");
|
||||
} else {
|
||||
document.querySelector("#submit").disabled = false;
|
||||
document.querySelector("#submit").classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
$wire.on('invoice.payment.cancelled', () => {
|
||||
// destroy the payment element
|
||||
paymentElement.destroy();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@endscript
|
||||
7
extensions/Gateways/Stripe/routes.php
Normal file
7
extensions/Gateways/Stripe/routes.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Paymenter\Extensions\Gateways\Stripe\Stripe;
|
||||
|
||||
Route::post('/extensions/stripe/webhook', [Stripe::class, 'webhook'])->withoutMiddleware([VerifyCsrfToken::class])->name('extensions.gateways.stripe.webhook');
|
||||
Reference in New Issue
Block a user