43
common/Billing/Gateways/Paypal/InteractsWithPaypalRestApi.php
Executable file
43
common/Billing/Gateways/Paypal/InteractsWithPaypalRestApi.php
Executable file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Gateways\Paypal;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
trait InteractsWithPaypalRestApi
|
||||
{
|
||||
protected string|null $accessToken = null;
|
||||
protected Carbon|null $tokenExpires = null;
|
||||
|
||||
public function paypal(): PendingRequest
|
||||
{
|
||||
$baseUrl = settings('billing.paypal_test_mode')
|
||||
? 'https://api-m.sandbox.paypal.com/v1'
|
||||
: 'https://api-m.paypal.com/v1';
|
||||
|
||||
if (
|
||||
!$this->accessToken ||
|
||||
$this->tokenExpires->lessThan(Carbon::now())
|
||||
) {
|
||||
$clientId = config('services.paypal.client_id');
|
||||
$secret = config('services.paypal.secret');
|
||||
$response = Http::withBasicAuth($clientId, $secret)
|
||||
->throw()
|
||||
->asForm()
|
||||
->post("$baseUrl/oauth2/token", [
|
||||
'grant_type' => 'client_credentials',
|
||||
]);
|
||||
if (!$response->successful()) {
|
||||
$response->throw();
|
||||
}
|
||||
$this->accessToken = $response['access_token'];
|
||||
$this->tokenExpires = Carbon::now()->addSeconds(
|
||||
$response['expires_in'],
|
||||
);
|
||||
}
|
||||
|
||||
return Http::withToken($this->accessToken)->baseUrl($baseUrl);
|
||||
}
|
||||
}
|
||||
70
common/Billing/Gateways/Paypal/Paypal.php
Executable file
70
common/Billing/Gateways/Paypal/Paypal.php
Executable file
@@ -0,0 +1,70 @@
|
||||
<?php namespace Common\Billing\Gateways\Paypal;
|
||||
|
||||
use Common\Billing\Gateways\Contracts\CommonSubscriptionGatewayActions;
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscription;
|
||||
use Common\Settings\Settings;
|
||||
|
||||
class Paypal implements CommonSubscriptionGatewayActions
|
||||
{
|
||||
use InteractsWithPaypalRestApi;
|
||||
|
||||
public function __construct(
|
||||
protected Settings $settings,
|
||||
protected PaypalPlans $plans,
|
||||
public PaypalSubscriptions $subscriptions,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isSubscriptionIncomplete(Subscription $subscription): bool
|
||||
{
|
||||
return $this->subscriptions->isIncomplete($subscription);
|
||||
}
|
||||
|
||||
public function isSubscriptionPastDue(Subscription $subscription): bool
|
||||
{
|
||||
return $this->subscriptions->isPastDue($subscription);
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) app(Settings::class)->get('billing.paypal.enable');
|
||||
}
|
||||
|
||||
public function syncPlan(Product $product): bool
|
||||
{
|
||||
return $this->plans->sync($product);
|
||||
}
|
||||
|
||||
public function deletePlan(Product $product): bool
|
||||
{
|
||||
return $this->plans->delete($product);
|
||||
}
|
||||
|
||||
public function changePlan(
|
||||
Subscription $subscription,
|
||||
Product $newProduct,
|
||||
Price $newPrice,
|
||||
): bool {
|
||||
return $this->subscriptions->changePlan(
|
||||
$subscription,
|
||||
$newProduct,
|
||||
$newPrice,
|
||||
);
|
||||
}
|
||||
|
||||
public function cancelSubscription(
|
||||
Subscription $subscription,
|
||||
bool $atPeriodEnd = true,
|
||||
): bool {
|
||||
return $this->subscriptions->cancel($subscription, $atPeriodEnd);
|
||||
}
|
||||
|
||||
public function resumeSubscription(
|
||||
Subscription $subscription,
|
||||
array $gatewayParams = [],
|
||||
): bool {
|
||||
return $this->subscriptions->resume($subscription, $gatewayParams);
|
||||
}
|
||||
}
|
||||
33
common/Billing/Gateways/Paypal/PaypalController.php
Executable file
33
common/Billing/Gateways/Paypal/PaypalController.php
Executable file
@@ -0,0 +1,33 @@
|
||||
<?php namespace Common\Billing\Gateways\Paypal;
|
||||
|
||||
use Common\Billing\Subscription;
|
||||
use Common\Core\BaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class PaypalController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected Request $request,
|
||||
protected Subscription $subscription,
|
||||
protected Paypal $paypal,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function storeSubscriptionDetailsLocally(): Response|JsonResponse
|
||||
{
|
||||
$data = $this->validate($this->request, [
|
||||
'paypal_subscription_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$this->paypal->subscriptions->sync(
|
||||
$data['paypal_subscription_id'],
|
||||
Auth::id(),
|
||||
);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
99
common/Billing/Gateways/Paypal/PaypalPlans.php
Executable file
99
common/Billing/Gateways/Paypal/PaypalPlans.php
Executable file
@@ -0,0 +1,99 @@
|
||||
<?php namespace Common\Billing\Gateways\Paypal;
|
||||
|
||||
use Common\Billing\GatewayException;
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaypalPlans
|
||||
{
|
||||
use InteractsWithPaypalRestApi;
|
||||
|
||||
public function sync(Product $product): bool
|
||||
{
|
||||
$product->load('prices');
|
||||
|
||||
// there's only one global product on PayPal and not one per plan as on stripe
|
||||
$productId = config('services.paypal.product_id');
|
||||
$response = $this->paypal()->get("catalogs/products/$productId");
|
||||
if (!$response->successful()) {
|
||||
$this->paypal()->post('catalogs/products', [
|
||||
'id' => $productId,
|
||||
'name' => config('services.paypal.product_name'),
|
||||
'type' => 'DIGITAL',
|
||||
]);
|
||||
}
|
||||
|
||||
// create any local product prices (plans) on PayPal, that don't exist there already
|
||||
$product->prices->each(function (Price $price) use ($product) {
|
||||
if (
|
||||
!$price->paypal_id ||
|
||||
!$this->planExistsOnPaypal($price->paypal_id)
|
||||
) {
|
||||
$this->create($product, $price);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function planExistsOnPaypal(string $paypalPlanId): bool
|
||||
{
|
||||
$response = $this->paypal()->get("billing/plans/{$paypalPlanId}");
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
protected function create(Product $product, Price $price): bool
|
||||
{
|
||||
$response = $this->paypal()->post('billing/plans', [
|
||||
'name' => $product->name,
|
||||
'product_id' => config('services.paypal.product_id'),
|
||||
'status' => 'ACTIVE',
|
||||
'payment_preferences' => [
|
||||
'auto_bill_outstanding' => true,
|
||||
'payment_failure_threshold' => 2,
|
||||
],
|
||||
'billing_cycles' => [
|
||||
[
|
||||
'frequency' => [
|
||||
'interval_unit' => Str::upper($price->interval),
|
||||
'interval_count' => $price->interval_count,
|
||||
],
|
||||
'tenure_type' => 'REGULAR',
|
||||
'sequence' => 1,
|
||||
'total_cycles' => 0, // infinite
|
||||
'pricing_scheme' => [
|
||||
'fixed_price' => [
|
||||
'value' => number_format(
|
||||
$price->amount,
|
||||
2,
|
||||
'.',
|
||||
'',
|
||||
),
|
||||
'currency_code' => Str::upper($price->currency),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new GatewayException('Could not create plan on PayPal');
|
||||
}
|
||||
|
||||
$price->fill(['paypal_id' => $response['id']])->save();
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(Product $product): bool
|
||||
{
|
||||
$statuses = $product->prices->map(function (Price $price) {
|
||||
$response = $this->paypal()->post(
|
||||
"billing/plans/{$price->paypal_id}/deactivate",
|
||||
);
|
||||
return $response->successful();
|
||||
});
|
||||
|
||||
return $statuses->every(fn($status) => $status);
|
||||
}
|
||||
}
|
||||
163
common/Billing/Gateways/Paypal/PaypalSubscriptions.php
Executable file
163
common/Billing/Gateways/Paypal/PaypalSubscriptions.php
Executable file
@@ -0,0 +1,163 @@
|
||||
<?php namespace Common\Billing\Gateways\Paypal;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Billing\Invoices\CreateInvoice;
|
||||
use Common\Billing\Invoices\Invoice;
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscription;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class PaypalSubscriptions
|
||||
{
|
||||
use InteractsWithPaypalRestApi;
|
||||
|
||||
public function isIncomplete(Subscription $subscription): bool
|
||||
{
|
||||
return $subscription->gateway_status === 'APPROVAL_PENDING' ||
|
||||
$subscription->gateway_status === 'APPROVED';
|
||||
}
|
||||
|
||||
public function isPastDue(Subscription $subscription): bool
|
||||
{
|
||||
// no way to check this via PayPal API
|
||||
return false;
|
||||
}
|
||||
|
||||
public function sync(
|
||||
string $paypalSubscriptionId,
|
||||
?int $userId = null,
|
||||
): void {
|
||||
$response = $this->paypal()->get(
|
||||
"billing/subscriptions/$paypalSubscriptionId",
|
||||
);
|
||||
|
||||
$price = Price::where('paypal_id', $response['plan_id'])->firstOrFail();
|
||||
|
||||
if ($userId != null) {
|
||||
$user = User::where('id', $userId)->firstOrFail();
|
||||
$user->update(['paypal_id' => $response['subscriber']['payer_id']]);
|
||||
} else {
|
||||
$user = User::where(
|
||||
'paypal_id',
|
||||
$response['subscriber']['payer_id'],
|
||||
)->firstOrFail();
|
||||
}
|
||||
|
||||
$subscription = $user->subscriptions()->firstOrNew([
|
||||
'gateway_name' => 'paypal',
|
||||
'gateway_id' => $response['id'],
|
||||
]);
|
||||
|
||||
if (
|
||||
in_array($response['status'], ['CANCELLED', 'EXPIRED', 'SUSPENDED'])
|
||||
) {
|
||||
$subscription->markAsCancelled();
|
||||
}
|
||||
|
||||
$data = [
|
||||
'price_id' => $price->id,
|
||||
'product_id' => $price->product_id,
|
||||
'gateway_name' => 'paypal',
|
||||
'gateway_id' => $paypalSubscriptionId,
|
||||
'gateway_status' => $response['status'],
|
||||
'renews_at' =>
|
||||
$response['status'] === 'ACTIVE' &&
|
||||
isset($response['billing_info']['next_billing_time'])
|
||||
? Carbon::parse(
|
||||
$response['billing_info']['next_billing_time'],
|
||||
)
|
||||
: null,
|
||||
];
|
||||
|
||||
if ($response['status'] === 'ACTIVE') {
|
||||
$data['ends_at'] = null;
|
||||
}
|
||||
|
||||
$subscription->fill($data)->save();
|
||||
|
||||
$this->createOrUpdateInvoice($subscription, $response->json());
|
||||
}
|
||||
|
||||
public function createOrUpdateInvoice(
|
||||
Subscription $subscription,
|
||||
array $paypalSubscription,
|
||||
): void {
|
||||
// subscription is no longer active, no need to update invoice
|
||||
if (!isset($paypalSubscription['billing_info']['next_billing_time'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$startTime = Carbon::parse($paypalSubscription['start_time']);
|
||||
$renewsAt = Carbon::parse(
|
||||
$paypalSubscription['billing_info']['next_billing_time'],
|
||||
);
|
||||
$isPaid = $paypalSubscription['status'] === 'ACTIVE';
|
||||
|
||||
$existing = Invoice::whereBetween('created_at', [
|
||||
$startTime,
|
||||
$renewsAt,
|
||||
])->first();
|
||||
if ($existing) {
|
||||
// paid invoices should never be set to unpaid
|
||||
if (!$existing->paid) {
|
||||
$existing->update(['paid' => $isPaid]);
|
||||
}
|
||||
} else {
|
||||
(new CreateInvoice())->execute([
|
||||
'subscription_id' => $subscription->id,
|
||||
'paid' => $isPaid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function changePlan(
|
||||
Subscription $subscription,
|
||||
Product $newProduct,
|
||||
Price $newPrice,
|
||||
): bool {
|
||||
$this->paypal()->post(
|
||||
"billing/subscriptions/$subscription->gateway_id/revise",
|
||||
[
|
||||
'plan_id' => $newPrice->paypal_id,
|
||||
],
|
||||
);
|
||||
|
||||
$this->sync($subscription->gateway_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cancel(
|
||||
Subscription $subscription,
|
||||
$atPeriodEnd = true,
|
||||
): bool {
|
||||
if ($atPeriodEnd) {
|
||||
$this->paypal()->post(
|
||||
"billing/subscriptions/$subscription->gateway_id/suspend",
|
||||
['reason' => 'User requested cancellation.'],
|
||||
);
|
||||
} else {
|
||||
$this->paypal()->post(
|
||||
"billing/subscriptions/$subscription->gateway_id/cancel",
|
||||
['reason' => 'Subscription deleted locally.'],
|
||||
);
|
||||
}
|
||||
|
||||
$this->sync($subscription->gateway_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resume(Subscription $subscription, array $params): bool
|
||||
{
|
||||
$this->paypal()->post(
|
||||
"billing/subscriptions/$subscription->gateway_id/activate",
|
||||
['reason' => 'Subscription resumed by user.'],
|
||||
);
|
||||
|
||||
$this->sync($subscription->gateway_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
103
common/Billing/Gateways/Paypal/PaypalWebhookController.php
Executable file
103
common/Billing/Gateways/Paypal/PaypalWebhookController.php
Executable file
@@ -0,0 +1,103 @@
|
||||
<?php namespace Common\Billing\Gateways\Paypal;
|
||||
|
||||
use Common\Billing\GatewayException;
|
||||
use Common\Billing\Notifications\PaymentFailed;
|
||||
use Common\Billing\Subscription;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Arr;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaypalWebhookController extends Controller
|
||||
{
|
||||
use InteractsWithPaypalRestApi;
|
||||
|
||||
public function __construct(
|
||||
protected Subscription $subscription,
|
||||
protected Paypal $paypal,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handleWebhook(Request $request): Response
|
||||
{
|
||||
$payload = $request->all();
|
||||
|
||||
if (
|
||||
config('common.site.verify_paypal_webhook') &&
|
||||
!$this->webhookIsValid()
|
||||
) {
|
||||
return response('Webhook validation failed', 422);
|
||||
}
|
||||
|
||||
return match ($payload['event_type']) {
|
||||
'BILLING.SUBSCRIPTION.PAYMENT.FAILED'
|
||||
=> $this->handleInvoicePaymentFailed($payload),
|
||||
'BILLING.SUBSCRIPTION.ACTIVATED',
|
||||
'BILLING.SUBSCRIPTION.CANCELLED',
|
||||
'BILLING.SUBSCRIPTION.EXPIRED',
|
||||
'BILLING.SUBSCRIPTION.SUSPENDED'
|
||||
=> $this->handleSubscriptionStateChanged($payload),
|
||||
'PAYMENT.SALE.COMPLETED' => $this->handleSaleCompleted($payload),
|
||||
default => response('Webhook Handled', 200),
|
||||
};
|
||||
}
|
||||
|
||||
protected function handleInvoicePaymentFailed(array $payload): Response
|
||||
{
|
||||
$paypalSubscriptionId = Arr::get(
|
||||
$payload,
|
||||
'resource.billing_agreement_id',
|
||||
);
|
||||
|
||||
$subscription = $this->subscription
|
||||
->where('gateway_id', $paypalSubscriptionId)
|
||||
->first();
|
||||
$subscription?->user->notify(new PaymentFailed($subscription));
|
||||
|
||||
return response('Webhook handled', 200);
|
||||
}
|
||||
|
||||
protected function handleSaleCompleted(array $payload): Response
|
||||
{
|
||||
$this->paypal->subscriptions->sync(
|
||||
$payload['resource']['billing_agreement_id'],
|
||||
);
|
||||
|
||||
return response('Webhook Handled', 200);
|
||||
}
|
||||
|
||||
protected function handleSubscriptionStateChanged(array $payload): Response
|
||||
{
|
||||
$this->paypal->subscriptions->sync($payload['resource']['id']);
|
||||
|
||||
return response('Webhook Handled', 200);
|
||||
}
|
||||
|
||||
protected function webhookIsValid(): bool
|
||||
{
|
||||
$payload = [
|
||||
'auth_algo' => request()->header('PAYPAL-AUTH-ALGO'),
|
||||
'cert_url' => request()->header('PAYPAL-CERT-URL'),
|
||||
'transmission_id' => request()->header('PAYPAL-TRANSMISSION-ID'),
|
||||
'transmission_sig' => request()->header('PAYPAL-TRANSMISSION-SIG'),
|
||||
'transmission_time' => request()->header(
|
||||
'PAYPAL-TRANSMISSION-TIME',
|
||||
),
|
||||
'webhook_id' => config('services.paypal.webhook_id'),
|
||||
'webhook_event' => request()->all(),
|
||||
];
|
||||
|
||||
$response = $this->paypal()->post(
|
||||
'notifications/verify-webhook-signature',
|
||||
$payload,
|
||||
);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new GatewayException(
|
||||
"Could not validate paypal webhook: {$response->body()}",
|
||||
);
|
||||
}
|
||||
|
||||
return $response['verification_status'] === 'SUCCESS';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user