Add all files

This commit is contained in:
root
2025-11-30 11:57:22 +01:00
parent 2ee51cac4f
commit 044271db3d
1476 changed files with 142544 additions and 0 deletions

View 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;
}
}

View 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 }}&currency={{ $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

View 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');

View 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);
}
}

View 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

View 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');