68
common/Billing/Billable.php
Executable file
68
common/Billing/Billable.php
Executable file
@@ -0,0 +1,68 @@
|
||||
<?php namespace Common\Billing;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Common\Billing\Models\Price;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Trait Billable
|
||||
* @property-read Collection|Subscription[] $subscriptions
|
||||
*/
|
||||
trait Billable
|
||||
{
|
||||
public function subscribe(
|
||||
string $gateway,
|
||||
string $gatewayId,
|
||||
string $status,
|
||||
Price $price,
|
||||
): Subscription {
|
||||
if (Subscription::where('gateway_id', $gatewayId)->exists()) {
|
||||
throw new LogicException(__('This subscription ID already exists'));
|
||||
}
|
||||
|
||||
if ($price->interval === 'year') {
|
||||
$renewsAt = Carbon::now()->addYears($price->interval_count);
|
||||
} elseif ($price->interval === 'week') {
|
||||
$renewsAt = Carbon::now()->addWeeks($price->interval_count);
|
||||
} else {
|
||||
$renewsAt = Carbon::now()->addMonths($price->interval_count);
|
||||
}
|
||||
|
||||
$subscription = $this->subscriptions()->create([
|
||||
'price_id' => $price->id,
|
||||
'product_id' => $price->product_id,
|
||||
'ends_at' => null,
|
||||
'renews_at' => $renewsAt,
|
||||
'gateway_name' => $gateway,
|
||||
'gateway_id' => $gatewayId,
|
||||
'gateway_status' => $status,
|
||||
]);
|
||||
|
||||
$this->load('subscriptions');
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
public function subscribed(): bool
|
||||
{
|
||||
$subscription = $this->subscriptions->first(function (
|
||||
Subscription $sub,
|
||||
) {
|
||||
return $sub->valid();
|
||||
});
|
||||
|
||||
return !is_null($subscription);
|
||||
}
|
||||
|
||||
public function subscriptions(): HasMany
|
||||
{
|
||||
// always return subscriptions that are not attached to any gateway last
|
||||
return $this->hasMany(Subscription::class, 'user_id')->orderBy(
|
||||
DB::raw('FIELD(gateway_name, "none")'),
|
||||
'asc',
|
||||
);
|
||||
}
|
||||
}
|
||||
5
common/Billing/GatewayException.php
Executable file
5
common/Billing/GatewayException.php
Executable file
@@ -0,0 +1,5 @@
|
||||
<?php namespace Common\Billing;
|
||||
|
||||
class GatewayException extends \Exception {
|
||||
|
||||
}
|
||||
28
common/Billing/Gateways/Actions/SyncProductOnEnabledGateways.php
Executable file
28
common/Billing/Gateways/Actions/SyncProductOnEnabledGateways.php
Executable file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Gateways\Actions;
|
||||
|
||||
use Common\Billing\Gateways\Paypal\Paypal;
|
||||
use Common\Billing\Gateways\Stripe\Stripe;
|
||||
use Common\Billing\Models\Product;
|
||||
|
||||
class SyncProductOnEnabledGateways
|
||||
{
|
||||
public function __construct(
|
||||
protected Stripe $stripe,
|
||||
protected Paypal $paypal
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(Product $product): void
|
||||
{
|
||||
@ini_set('max_execution_time', 300);
|
||||
|
||||
if ($this->stripe->isEnabled()) {
|
||||
$this->stripe->syncPlan($product);
|
||||
}
|
||||
if ($this->paypal->isEnabled()) {
|
||||
$this->paypal->syncPlan($product);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
common/Billing/Gateways/Contracts/CommonSubscriptionGatewayActions.php
Executable file
39
common/Billing/Gateways/Contracts/CommonSubscriptionGatewayActions.php
Executable file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Gateways\Contracts;
|
||||
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscription;
|
||||
|
||||
interface CommonSubscriptionGatewayActions
|
||||
{
|
||||
public function isEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Sync plan from local database with the gateway
|
||||
*/
|
||||
public function syncPlan(Product $product): bool;
|
||||
|
||||
public function deletePlan(Product $product): bool;
|
||||
|
||||
public function changePlan(
|
||||
Subscription $subscription,
|
||||
Product $newProduct,
|
||||
Price $newPrice,
|
||||
): bool;
|
||||
|
||||
public function cancelSubscription(
|
||||
Subscription $subscription,
|
||||
bool $atPeriodEnd = true,
|
||||
): bool;
|
||||
|
||||
public function resumeSubscription(
|
||||
Subscription $subscription,
|
||||
array $gatewayParams = [],
|
||||
): bool;
|
||||
|
||||
public function isSubscriptionIncomplete(Subscription $subscription): bool;
|
||||
|
||||
public function isSubscriptionPastDue(Subscription $subscription): bool;
|
||||
}
|
||||
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';
|
||||
}
|
||||
}
|
||||
28
common/Billing/Gateways/Stripe/FormatsMoney.php
Executable file
28
common/Billing/Gateways/Stripe/FormatsMoney.php
Executable file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use Common\Billing\Models\Price;
|
||||
use Money\Currencies\ISOCurrencies;
|
||||
use Money\Currency;
|
||||
use Money\Parser\IntlLocalizedDecimalParser;
|
||||
use NumberFormatter;
|
||||
|
||||
trait FormatsMoney
|
||||
{
|
||||
protected function priceToCents(Price $price): string
|
||||
{
|
||||
$currencies = new ISOCurrencies();
|
||||
$numberFormatter = new NumberFormatter('en', NumberFormatter::DECIMAL);
|
||||
$moneyParser = new IntlLocalizedDecimalParser(
|
||||
$numberFormatter,
|
||||
$currencies,
|
||||
);
|
||||
$money = $moneyParser->parse(
|
||||
$price->amount,
|
||||
new Currency($price->currency),
|
||||
);
|
||||
|
||||
return $money->getAmount();
|
||||
}
|
||||
}
|
||||
130
common/Billing/Gateways/Stripe/Stripe.php
Executable file
130
common/Billing/Gateways/Stripe/Stripe.php
Executable file
@@ -0,0 +1,130 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Billing\Gateways\Contracts\CommonSubscriptionGatewayActions;
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscription;
|
||||
use Common\Settings\Settings;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class Stripe implements CommonSubscriptionGatewayActions
|
||||
{
|
||||
public StripePlans $plans;
|
||||
public StripeSubscriptions $subscriptions;
|
||||
public StripeClient $client;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new StripeClient([
|
||||
'api_key' => config('services.stripe.secret'),
|
||||
'stripe_version' => '2022-08-01',
|
||||
]);
|
||||
|
||||
$this->plans = new StripePlans($this->client);
|
||||
$this->subscriptions = new StripeSubscriptions($this->client);
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) app(Settings::class)->get('billing.stripe.enable');
|
||||
}
|
||||
|
||||
public function syncPlan(Product $product): bool
|
||||
{
|
||||
return $this->plans->sync($product);
|
||||
}
|
||||
|
||||
public function getAllPlans(): array
|
||||
{
|
||||
return $this->plans->getAll();
|
||||
}
|
||||
|
||||
public function changePlan(
|
||||
Subscription $subscription,
|
||||
Product $newProduct,
|
||||
Price $newPrice,
|
||||
): bool {
|
||||
return $this->subscriptions->changePlan(
|
||||
$subscription,
|
||||
$newProduct,
|
||||
$newPrice,
|
||||
);
|
||||
}
|
||||
|
||||
public function deletePlan(Product $product): bool
|
||||
{
|
||||
return $this->plans->delete($product);
|
||||
}
|
||||
|
||||
public function isSubscriptionIncomplete(Subscription $subscription): bool
|
||||
{
|
||||
return $this->subscriptions->isIncomplete($subscription);
|
||||
}
|
||||
|
||||
public function isSubscriptionPastDue(Subscription $subscription): bool
|
||||
{
|
||||
return $this->subscriptions->isPastDue($subscription);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public function createSetupIntent(User $user): string
|
||||
{
|
||||
$setupIntent = $this->client->setupIntents->create([
|
||||
'customer' => $user->stripe_id,
|
||||
]);
|
||||
|
||||
return $setupIntent->client_secret;
|
||||
}
|
||||
|
||||
public function changeDefaultPaymentMethod(
|
||||
User $user,
|
||||
string $paymentMethodId,
|
||||
): bool {
|
||||
$updatedUser = $this->client->customers->update($user->stripe_id, [
|
||||
'invoice_settings' => [
|
||||
'default_payment_method' => $paymentMethodId,
|
||||
],
|
||||
]);
|
||||
|
||||
$isSuccess =
|
||||
$updatedUser->invoice_settings['default_payment_method'] ==
|
||||
$paymentMethodId;
|
||||
|
||||
if ($isSuccess) {
|
||||
$paymentMethod = $this->client->paymentMethods->retrieve(
|
||||
$paymentMethodId,
|
||||
);
|
||||
if ($paymentMethod->type === 'card') {
|
||||
$this->storeCardDetailsLocally(
|
||||
$user,
|
||||
$paymentMethod->card->toArray(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $isSuccess;
|
||||
}
|
||||
|
||||
public function storeCardDetailsLocally(User $user, array $card): void
|
||||
{
|
||||
$user->update([
|
||||
'card_brand' => $card['brand'],
|
||||
'card_last_four' => $card['last4'],
|
||||
'card_expires' => "{$card['exp_month']}/{$card['exp_year']}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
75
common/Billing/Gateways/Stripe/StripeController.php
Executable file
75
common/Billing/Gateways/Stripe/StripeController.php
Executable file
@@ -0,0 +1,75 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use Auth;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscription;
|
||||
use Common\Core\BaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class StripeController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected Request $request,
|
||||
protected Subscription $subscription,
|
||||
protected Stripe $stripe,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function createPartialSubscription(): Response|JsonResponse
|
||||
{
|
||||
$data = $this->validate($this->request, [
|
||||
'product_id' => 'required|integer|exists:products,id',
|
||||
'price_id' => 'integer|exists:prices,id',
|
||||
'start_date' => 'string',
|
||||
]);
|
||||
|
||||
$product = Product::findOrFail($data['product_id']);
|
||||
$clientSecret = $this->stripe->subscriptions->createPartial(
|
||||
$product,
|
||||
Auth::user(),
|
||||
$data['price_id'] ?? null,
|
||||
);
|
||||
|
||||
return $this->success(['clientSecret' => $clientSecret]);
|
||||
}
|
||||
|
||||
public function createSetupIntent(): Response|JsonResponse
|
||||
{
|
||||
$clientSecret = $this->stripe->createSetupIntent(Auth::user());
|
||||
return $this->success(['clientSecret' => $clientSecret]);
|
||||
}
|
||||
|
||||
public function changeDefaultPaymentMethod(): Response|JsonResponse
|
||||
{
|
||||
$data = $this->validate($this->request, [
|
||||
'payment_method_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$this->stripe->changeDefaultPaymentMethod(
|
||||
$this->request->user(),
|
||||
$data['payment_method_id'],
|
||||
);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
public function storeSubscriptionDetailsLocally(): Response|JsonResponse
|
||||
{
|
||||
$data = $this->validate($this->request, [
|
||||
'payment_intent_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$paymentIntent = $this->stripe->client->paymentIntents->retrieve(
|
||||
$data['payment_intent_id'],
|
||||
);
|
||||
|
||||
$this->stripe->subscriptions->sync(
|
||||
$paymentIntent->invoice->subscription,
|
||||
);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
97
common/Billing/Gateways/Stripe/StripePlans.php
Executable file
97
common/Billing/Gateways/Stripe/StripePlans.php
Executable file
@@ -0,0 +1,97 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\Price as StripePrice;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class StripePlans
|
||||
{
|
||||
use FormatsMoney;
|
||||
|
||||
public function __construct(protected StripeClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
public function sync(Product $product): bool
|
||||
{
|
||||
$product->load('prices');
|
||||
|
||||
// create product on stripe, if it does not exist already
|
||||
try {
|
||||
$stripeProduct = $this->client->products->retrieve($product->uuid);
|
||||
} catch (ApiErrorException $err) {
|
||||
$stripeProduct = null;
|
||||
}
|
||||
|
||||
if (!$stripeProduct) {
|
||||
$this->client->products->create([
|
||||
'id' => $product->uuid,
|
||||
'name' => $product->name,
|
||||
]);
|
||||
}
|
||||
|
||||
// create any local product prices on stripe, that don't exist there already
|
||||
$product->prices->each(function (Price $price) use ($product) {
|
||||
if (
|
||||
!$price->stripe_id ||
|
||||
!$this->priceExistsOnStripe($price->stripe_id)
|
||||
) {
|
||||
$this->createPrice($product, $price);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function createPrice(Product $product, Price $price): StripePrice
|
||||
{
|
||||
$stripePrice = $this->client->prices->create([
|
||||
'product' => $product->uuid,
|
||||
'unit_amount' => $this->priceToCents($price),
|
||||
'currency' => $price->currency,
|
||||
'recurring' => [
|
||||
'interval' => $price->interval,
|
||||
'interval_count' => $price->interval_count,
|
||||
],
|
||||
]);
|
||||
|
||||
$price->fill(['stripe_id' => $stripePrice->id])->save();
|
||||
|
||||
return $stripePrice;
|
||||
}
|
||||
|
||||
public function delete(Product $product): bool
|
||||
{
|
||||
// stripe does not allow deleting product if it has prices attached,
|
||||
// and prices can't be deleted via API, we archive the product instead
|
||||
try {
|
||||
$this->client->products->update($product->uuid, [
|
||||
'active' => false,
|
||||
]);
|
||||
} catch (InvalidRequestException $e) {
|
||||
// if this product is already deleted on stripe, ignore
|
||||
if ($e->getStripeCode() !== 'resource_missing') {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
return $this->client->products->all()->toArray();
|
||||
}
|
||||
|
||||
protected function priceExistsOnStripe(string $stripePriceId): bool
|
||||
{
|
||||
try {
|
||||
$this->client->prices->retrieve($stripePriceId);
|
||||
return true;
|
||||
} catch (InvalidRequestException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
287
common/Billing/Gateways/Stripe/StripeSubscriptions.php
Executable file
287
common/Billing/Gateways/Stripe/StripeSubscriptions.php
Executable file
@@ -0,0 +1,287 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
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 Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\Invoice as StripeInvoice;
|
||||
use Stripe\StripeClient;
|
||||
use Stripe\Subscription as StripeSubscription;
|
||||
|
||||
class StripeSubscriptions
|
||||
{
|
||||
public function __construct(public StripeClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
public function isIncomplete(Subscription $subscription): bool
|
||||
{
|
||||
return $subscription->gateway_status ===
|
||||
StripeSubscription::STATUS_INCOMPLETE ||
|
||||
($subscription->gateway_status ===
|
||||
StripeSubscription::STATUS_INCOMPLETE_EXPIRED &&
|
||||
$subscription->gateway_status !==
|
||||
StripeSubscription::STATUS_UNPAID);
|
||||
}
|
||||
|
||||
public function isPastDue(Subscription $subscription): bool
|
||||
{
|
||||
return $subscription->gateway_status ===
|
||||
StripeSubscription::STATUS_PAST_DUE;
|
||||
}
|
||||
|
||||
public function sync(string $stripeSubscriptionId): void
|
||||
{
|
||||
$stripeSubscription = $this->client->subscriptions->retrieve(
|
||||
$stripeSubscriptionId,
|
||||
['expand' => ['latest_invoice']],
|
||||
);
|
||||
$price = Price::where(
|
||||
'stripe_id',
|
||||
$stripeSubscription->items->data[0]->price->id,
|
||||
)->firstOrFail();
|
||||
$user = User::where(
|
||||
'stripe_id',
|
||||
$stripeSubscription->customer,
|
||||
)->firstOrFail();
|
||||
|
||||
$subscription = $user->subscriptions()->firstOrNew([
|
||||
'gateway_name' => 'stripe',
|
||||
'gateway_id' => $stripeSubscription->id,
|
||||
]);
|
||||
|
||||
// Cancellation date...
|
||||
if ($stripeSubscription->cancel_at_period_end) {
|
||||
$subscription->ends_at = $subscription->onTrial()
|
||||
? $subscription->trial_ends_at
|
||||
: Carbon::createFromTimestamp(
|
||||
$stripeSubscription->current_period_end,
|
||||
);
|
||||
} elseif (
|
||||
$stripeSubscription->cancel_at ||
|
||||
$stripeSubscription->canceled_at
|
||||
) {
|
||||
$subscription->ends_at = Carbon::createFromTimestamp(
|
||||
$stripeSubscription->cancel_at ??
|
||||
$stripeSubscription->canceled_at,
|
||||
);
|
||||
} else {
|
||||
$subscription->ends_at = null;
|
||||
}
|
||||
|
||||
$subscription
|
||||
->fill([
|
||||
'price_id' => $price->id,
|
||||
'product_id' => $price->product_id,
|
||||
'gateway_name' => 'stripe',
|
||||
'gateway_id' => $stripeSubscription->id,
|
||||
'gateway_status' => $stripeSubscription->status,
|
||||
'renews_at' =>
|
||||
$subscription->ends_at ||
|
||||
$stripeSubscription->status ===
|
||||
StripeSubscription::STATUS_INCOMPLETE
|
||||
? null
|
||||
: Carbon::createFromTimestamp(
|
||||
$stripeSubscription->current_period_end,
|
||||
),
|
||||
])
|
||||
->save();
|
||||
|
||||
if ($stripeSubscription->latest_invoice) {
|
||||
$this->createOrUpdateInvoice(
|
||||
$subscription,
|
||||
$stripeSubscription->latest_invoice->id,
|
||||
$stripeSubscription->latest_invoice->status ===
|
||||
StripeInvoice::STATUS_PAID,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function createPartial(
|
||||
Product $product,
|
||||
User $user,
|
||||
?int $priceId = null,
|
||||
): string {
|
||||
$price = $priceId
|
||||
? $product->prices()->findOrFail($priceId)
|
||||
: $product->prices->firstOrFail();
|
||||
|
||||
$user = $this->syncStripeCustomer($user);
|
||||
|
||||
// find incomplete subscriptions for this customer and price
|
||||
$stripeSubscription = $this->client->subscriptions
|
||||
->all([
|
||||
'customer' => $user->stripe_id,
|
||||
'price' => $price->stripe_id,
|
||||
'status' => 'incomplete',
|
||||
'expand' => ['data.latest_invoice.payment_intent'],
|
||||
])
|
||||
->first();
|
||||
|
||||
// if matching subscription was not created yet, do it now
|
||||
if (!$stripeSubscription) {
|
||||
$stripeSubscription = $this->client->subscriptions->create([
|
||||
'customer' => $user->stripe_id,
|
||||
'items' => [
|
||||
[
|
||||
'price' => $price->stripe_id,
|
||||
],
|
||||
],
|
||||
'payment_behavior' => 'default_incomplete',
|
||||
'payment_settings' => [
|
||||
'save_default_payment_method' => 'on_subscription',
|
||||
],
|
||||
'expand' => ['latest_invoice.payment_intent'],
|
||||
]);
|
||||
}
|
||||
|
||||
// return client secret, needed in frontend to complete subscription
|
||||
return $stripeSubscription->latest_invoice->payment_intent
|
||||
->client_secret;
|
||||
}
|
||||
|
||||
public function cancel(
|
||||
Subscription $subscription,
|
||||
bool $atPeriodEnd = true,
|
||||
): bool {
|
||||
if (!$subscription->user->stripe_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $this->client->subscriptions->retrieve(
|
||||
$subscription->gateway_id,
|
||||
);
|
||||
} catch (InvalidRequestException $e) {
|
||||
if ($e->getStripeCode() === 'resource_missing') {
|
||||
return true;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// cancel subscription at current period end and don't delete
|
||||
if ($atPeriodEnd) {
|
||||
$updatedSubscription = $this->client->subscriptions->update(
|
||||
$stripeSubscription->id,
|
||||
[
|
||||
'cancel_at_period_end' => true,
|
||||
],
|
||||
);
|
||||
$subscription
|
||||
->fill([
|
||||
'gateway_status' => $updatedSubscription->status,
|
||||
])
|
||||
->save();
|
||||
return $updatedSubscription->cancel_at_period_end;
|
||||
// cancel and delete subscription instantly
|
||||
} else {
|
||||
try {
|
||||
$stripeSubscription = $this->client->subscriptions->cancel(
|
||||
$stripeSubscription->id,
|
||||
);
|
||||
return $stripeSubscription->status === 'cancelled';
|
||||
} catch (InvalidRequestException $e) {
|
||||
return $e->getStripeCode() === 'resource_missing';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function resume(Subscription $subscription, array $params): bool
|
||||
{
|
||||
$stripeSubscription = $this->client->subscriptions->retrieve(
|
||||
$subscription->gateway_id,
|
||||
);
|
||||
|
||||
$updatedSubscription = $this->client->subscriptions->update(
|
||||
$stripeSubscription->id,
|
||||
array_merge(
|
||||
[
|
||||
'cancel_at_period_end' => false,
|
||||
],
|
||||
$params,
|
||||
),
|
||||
);
|
||||
|
||||
$subscription
|
||||
->fill([
|
||||
'gateway_status' => $updatedSubscription->status,
|
||||
])
|
||||
->save();
|
||||
|
||||
return $updatedSubscription->status === 'active';
|
||||
}
|
||||
|
||||
public function changePlan(
|
||||
Subscription $subscription,
|
||||
Product $newProduct,
|
||||
Price $newPrice,
|
||||
): bool {
|
||||
$stripeSubscription = $this->client->subscriptions->retrieve(
|
||||
$subscription->gateway_id,
|
||||
);
|
||||
|
||||
$updatedSubscription = $this->client->subscriptions->update(
|
||||
$stripeSubscription->id,
|
||||
[
|
||||
'proration_behavior' => 'always_invoice',
|
||||
'items' => [
|
||||
[
|
||||
'id' => $stripeSubscription->items->data[0]->id,
|
||||
'price' => $newPrice->stripe_id,
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $updatedSubscription->status === 'active';
|
||||
}
|
||||
|
||||
public function createOrUpdateInvoice(
|
||||
Subscription $subscription,
|
||||
string $stripeInvoiceId,
|
||||
bool $isPaid,
|
||||
): void {
|
||||
$existing = Invoice::where('uuid', $stripeInvoiceId)->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,
|
||||
'uuid' => $stripeInvoiceId,
|
||||
'paid' => $isPaid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function syncStripeCustomer(User $user): User
|
||||
{
|
||||
// make sure user with stored stripe ID actually exists on stripe
|
||||
if ($user->stripe_id) {
|
||||
try {
|
||||
$this->client->customers->retrieve($user->stripe_id);
|
||||
} catch (InvalidRequestException $e) {
|
||||
$user->stripe_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
// create customer object on stripe, if it does not exist already
|
||||
if (!$user->stripe_id) {
|
||||
$customer = $this->client->customers->create([
|
||||
'email' => $user->email,
|
||||
'metadata' => [
|
||||
'userId' => $user->id,
|
||||
],
|
||||
]);
|
||||
$user->fill(['stripe_id' => $customer->id])->save();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
153
common/Billing/Gateways/Stripe/StripeWebhookController.php
Executable file
153
common/Billing/Gateways/Stripe/StripeWebhookController.php
Executable file
@@ -0,0 +1,153 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Billing\Notifications\PaymentFailed;
|
||||
use Common\Billing\Subscription;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Contracts\Routing\ResponseFactory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Stripe\Invoice as StripeInvoice;
|
||||
use Stripe\Subscription as StripeSubscription;
|
||||
use Stripe\Webhook;
|
||||
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected Stripe $stripe,
|
||||
protected Subscription $subscription,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handleWebhook(Request $request): Response|JsonResponse
|
||||
{
|
||||
$webhookSecret = config('services.stripe.webhook_secret');
|
||||
if ($webhookSecret) {
|
||||
try {
|
||||
$event = Webhook::constructEvent(
|
||||
$request->getContent(),
|
||||
$request->header('stripe-signature'),
|
||||
$webhookSecret,
|
||||
)->toArray();
|
||||
} catch (Exception $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 403);
|
||||
}
|
||||
} else {
|
||||
$event = $request->all();
|
||||
}
|
||||
|
||||
return match ($event['type']) {
|
||||
'invoice.paid' => $this->handleInvoicePaid($event),
|
||||
// sync user payment methods with local database
|
||||
'customer.updated' => $this->handleCustomerUpdated($event),
|
||||
// user subscription ended and can't be resumed
|
||||
'customer.subscription.deleted' => $this->deleteSubscription(
|
||||
$event,
|
||||
),
|
||||
// automatic subscription renewal failed on stripe
|
||||
'invoice.payment_failed' => $this->handleInvoicePaymentFailed(
|
||||
$event,
|
||||
),
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated'
|
||||
=> $this->handleSubscriptionCreatedAndUpdated($event),
|
||||
default => response('Webhook handled', 200),
|
||||
};
|
||||
}
|
||||
|
||||
protected function handleInvoicePaid(
|
||||
array $payload,
|
||||
): Response|Application|ResponseFactory {
|
||||
$stripeInvoice = $payload['data']['object'];
|
||||
$stripeSubscriptionId = $stripeInvoice['subscription'];
|
||||
|
||||
$subscription = Subscription::where(
|
||||
'gateway_id',
|
||||
$stripeSubscriptionId,
|
||||
)->first();
|
||||
|
||||
if ($subscription) {
|
||||
$this->stripe->subscriptions->createOrUpdateInvoice(
|
||||
$subscription,
|
||||
$stripeInvoice['id'],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return response('Webhook Handled', 200);
|
||||
}
|
||||
|
||||
protected function handleCustomerUpdated(
|
||||
array $payload,
|
||||
): Response|Application|ResponseFactory {
|
||||
$stripeCustomer = $payload['data']['object'];
|
||||
$user = User::where('stripe_id', $stripeCustomer['id'])->firstOrFail();
|
||||
|
||||
$stripePaymentMethods = $this->stripe->client->customers
|
||||
->allPaymentMethods($stripeCustomer['id'], ['type' => 'card'])
|
||||
->toArray()['data'];
|
||||
|
||||
if (!empty($stripePaymentMethods)) {
|
||||
$card = $stripePaymentMethods[0]['card'];
|
||||
$this->stripe->storeCardDetailsLocally($user, $card);
|
||||
}
|
||||
|
||||
return response('Webhook Handled', 200);
|
||||
}
|
||||
|
||||
protected function handleInvoicePaymentFailed(array $payload): Response
|
||||
{
|
||||
$stripeUserId = $payload['data']['object']['customer'];
|
||||
$user = User::where('stripe_id', $stripeUserId)->first();
|
||||
|
||||
$reason = $payload['data']['object']['billing_reason'];
|
||||
$shouldNotify =
|
||||
$reason === StripeInvoice::BILLING_REASON_SUBSCRIPTION_CYCLE ||
|
||||
$reason === StripeInvoice::BILLING_REASON_SUBSCRIPTION_THRESHOLD;
|
||||
|
||||
if ($user && $shouldNotify) {
|
||||
$stripeSubscription = $user
|
||||
->subscriptions()
|
||||
->where('gateway_name', 'stripe')
|
||||
->first();
|
||||
if ($stripeSubscription) {
|
||||
$user->notify(new PaymentFailed($stripeSubscription));
|
||||
}
|
||||
}
|
||||
|
||||
return response('Webhook handled', 200);
|
||||
}
|
||||
|
||||
protected function handleSubscriptionCreatedAndUpdated(array $payload)
|
||||
{
|
||||
$stripeSubscriptions = $payload['data']['object'];
|
||||
|
||||
// initial payment failed and 24 hours passed, subscription can't be renewed anymore
|
||||
if (
|
||||
$stripeSubscriptions['status'] ===
|
||||
StripeSubscription::STATUS_INCOMPLETE_EXPIRED
|
||||
) {
|
||||
$this->deleteSubscription($payload);
|
||||
// sync subscription with latest data on stripe, regardless of event type
|
||||
} else {
|
||||
$this->stripe->subscriptions->sync($stripeSubscriptions['id']);
|
||||
}
|
||||
|
||||
return response('Webhook Handled', 200);
|
||||
}
|
||||
|
||||
protected function deleteSubscription(array $payload)
|
||||
{
|
||||
$subscription = Subscription::where(
|
||||
'gateway_id',
|
||||
$payload['data']['object']['id'],
|
||||
)->first();
|
||||
|
||||
$subscription?->cancelAndDelete();
|
||||
|
||||
return response('Webhook handled', 200);
|
||||
}
|
||||
}
|
||||
32
common/Billing/Gateways/SyncProductsController.php
Executable file
32
common/Billing/Gateways/SyncProductsController.php
Executable file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Gateways;
|
||||
|
||||
use Common\Billing\GatewayException;
|
||||
use Common\Billing\Gateways\Actions\SyncProductOnEnabledGateways;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Core\BaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class SyncProductsController extends BaseController
|
||||
{
|
||||
public function syncProducts(): Response|JsonResponse
|
||||
{
|
||||
$products = Product::where('free', false)
|
||||
->whereHas('prices')
|
||||
->get();
|
||||
|
||||
foreach ($products as $product) {
|
||||
try {
|
||||
app(SyncProductOnEnabledGateways::class)->execute($product);
|
||||
} catch (GatewayException $e) {
|
||||
return $this->error(
|
||||
"Could not sync \"$product->name\" product: {$e->getMessage()}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
31
common/Billing/Invoices/CreateInvoice.php
Executable file
31
common/Billing/Invoices/CreateInvoice.php
Executable file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Invoices;
|
||||
|
||||
use Common\Billing\Notifications\NewInvoiceAvailable;
|
||||
use Common\Billing\Subscription;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CreateInvoice
|
||||
{
|
||||
public function execute(array $data): Invoice
|
||||
{
|
||||
$invoice = new Invoice([
|
||||
'subscription_id' => $data['subscription_id'],
|
||||
'paid' => $data['paid'],
|
||||
'uuid' => $data['uuid'] ?? Str::random(10),
|
||||
'notes' => Arr::get($data, 'notes'),
|
||||
]);
|
||||
|
||||
$invoice->save();
|
||||
|
||||
if ($data['paid']) {
|
||||
Subscription::find($data['subscription_id'])->user->notify(
|
||||
new NewInvoiceAvailable($invoice),
|
||||
);
|
||||
}
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
}
|
||||
30
common/Billing/Invoices/Invoice.php
Executable file
30
common/Billing/Invoices/Invoice.php
Executable file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Invoices;
|
||||
|
||||
use Common\Billing\Subscription;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'subscription_id' => 'integer',
|
||||
'paid' => 'boolean',
|
||||
];
|
||||
|
||||
const MODEL_TYPE = 'invoice';
|
||||
|
||||
public static function getModelTypeAttribute(): string
|
||||
{
|
||||
return self::MODEL_TYPE;
|
||||
}
|
||||
|
||||
public function subscription(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subscription::class);
|
||||
}
|
||||
}
|
||||
64
common/Billing/Invoices/InvoiceController.php
Executable file
64
common/Billing/Invoices/InvoiceController.php
Executable file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Invoices;
|
||||
|
||||
use Common\Billing\Subscription;
|
||||
use Common\Core\AppUrl;
|
||||
use Common\Core\BaseController;
|
||||
use Common\Settings\Settings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class InvoiceController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected Request $request,
|
||||
protected Invoice $invoice,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(): Response|JsonResponse
|
||||
{
|
||||
$userId = $this->request->get('userId');
|
||||
$this->authorize('index', [Invoice::class, $userId]);
|
||||
|
||||
if ($userId) {
|
||||
$subscription = Subscription::where(
|
||||
'user_id',
|
||||
$userId,
|
||||
)->firstOrFail();
|
||||
$invoices = $subscription->invoices()->with(
|
||||
'subscription.product',
|
||||
'subscription.price',
|
||||
)->get();
|
||||
} else {
|
||||
$invoices = $this->invoice
|
||||
->with('subscription.product', 'subscription.price')
|
||||
->limit(50)
|
||||
->get();
|
||||
}
|
||||
|
||||
return $this->success(['invoices' => $invoices]);
|
||||
}
|
||||
|
||||
public function show(string $uuid)
|
||||
{
|
||||
$invoice = $this->invoice
|
||||
->where('uuid', $uuid)
|
||||
->with(
|
||||
'subscription.product',
|
||||
'subscription.user',
|
||||
'subscription.price',
|
||||
)
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize('show', $invoice);
|
||||
|
||||
return view('common::billing/invoice')
|
||||
->with('invoice', $invoice)
|
||||
->with('htmlBaseUri', app(AppUrl::class)->htmlBaseUri)
|
||||
->with('user', $invoice->subscription->user)
|
||||
->with('settings', app(Settings::class));
|
||||
}
|
||||
}
|
||||
21
common/Billing/Invoices/InvoicePolicy.php
Executable file
21
common/Billing/Invoices/InvoicePolicy.php
Executable file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Invoices;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Core\Policies\BasePolicy;
|
||||
|
||||
class InvoicePolicy extends BasePolicy
|
||||
{
|
||||
public function index(User $user, $userId = null): bool
|
||||
{
|
||||
return $user->hasPermission('invoices.view') ||
|
||||
$user->id === (int) $userId;
|
||||
}
|
||||
|
||||
public function show(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return $user->hasPermission('invoices.view') ||
|
||||
$invoice->subscription->user_id == $user->id;
|
||||
}
|
||||
}
|
||||
42
common/Billing/Listeners/SyncPlansWhenBillingSettingsChange.php
Executable file
42
common/Billing/Listeners/SyncPlansWhenBillingSettingsChange.php
Executable file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Listeners;
|
||||
|
||||
use Common\Billing\Gateways\Paypal\Paypal;
|
||||
use Common\Billing\Gateways\Stripe\Stripe;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Settings\Events\SettingsSaved;
|
||||
|
||||
class SyncPlansWhenBillingSettingsChange
|
||||
{
|
||||
public function __construct(
|
||||
protected Stripe $stripe,
|
||||
protected Paypal $paypal,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(SettingsSaved $event): void
|
||||
{
|
||||
$s = $event->envSettings;
|
||||
@ini_set('max_execution_time', 300);
|
||||
$products = Product::where('free', false)->get();
|
||||
|
||||
if (
|
||||
array_key_exists('stripe_key', $s) ||
|
||||
array_key_exists('stripe_secret', $s)
|
||||
) {
|
||||
$products->each(
|
||||
fn(Product $product) => $this->stripe->syncPlan($product),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
array_key_exists('paypal_client_id', $s) ||
|
||||
array_key_exists('paypal_secret', $s)
|
||||
) {
|
||||
$products->each(
|
||||
fn(Product $product) => $this->paypal->syncPlan($product),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
common/Billing/Models/Price.php
Executable file
34
common/Billing/Models/Price.php
Executable file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Models;
|
||||
|
||||
use Common\Billing\Subscription;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Price extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'float',
|
||||
'interval_count' => 'int',
|
||||
'default' => 'boolean',
|
||||
'subscriptions_count' => 'int',
|
||||
];
|
||||
|
||||
const MODEL_TYPE = 'price';
|
||||
|
||||
public static function getModelTypeAttribute(): string
|
||||
{
|
||||
return self::MODEL_TYPE;
|
||||
}
|
||||
|
||||
public function subscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subscription::class);
|
||||
}
|
||||
}
|
||||
93
common/Billing/Models/Product.php
Executable file
93
common/Billing/Models/Product.php
Executable file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Models;
|
||||
|
||||
use Common\Auth\Permissions\Traits\HasPermissionsRelation;
|
||||
use Common\Billing\Subscription;
|
||||
use Common\Core\BaseModel;
|
||||
use Common\Files\Traits\SetsAvailableSpaceAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Product extends BaseModel
|
||||
{
|
||||
use HasFactory,
|
||||
HasPermissionsRelation,
|
||||
SetsAvailableSpaceAttribute;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'free' => 'bool',
|
||||
'recommended' => 'bool',
|
||||
'position' => 'int',
|
||||
'available_space' => 'float',
|
||||
'hidden' => 'boolean',
|
||||
];
|
||||
|
||||
public const MODEL_TYPE = 'product';
|
||||
|
||||
protected function featureList(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function ($value): array {
|
||||
if ($value) {
|
||||
return (array) json_decode($value);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
set: function ($value): string|null {
|
||||
if (is_array($value)) {
|
||||
return $this->attributes['feature_list'] = json_encode(
|
||||
$value,
|
||||
);
|
||||
} else {
|
||||
return $this->attributes['feature_list'] = $value;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function prices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Price::class)
|
||||
->orderBy('default')
|
||||
->orderBy('amount');
|
||||
}
|
||||
|
||||
public function subscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subscription::class, 'product_id');
|
||||
}
|
||||
|
||||
public function toNormalizedArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'model_type' => self::MODEL_TYPE,
|
||||
];
|
||||
}
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'feature_list' => implode(', ', $this->feature_list),
|
||||
'created_at' => $this->created_at->timestamp ?? '_null',
|
||||
'updated_at' => $this->updated_at->timestamp ?? '_null',
|
||||
];
|
||||
}
|
||||
|
||||
public static function filterableFields(): array
|
||||
{
|
||||
return ['id', 'created_at', 'updated_at'];
|
||||
}
|
||||
|
||||
public static function getModelTypeAttribute(): string
|
||||
{
|
||||
return static::MODEL_TYPE;
|
||||
}
|
||||
}
|
||||
77
common/Billing/Notifications/NewInvoiceAvailable.php
Executable file
77
common/Billing/Notifications/NewInvoiceAvailable.php
Executable file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Notifications;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Billing\Invoices\Invoice;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class NewInvoiceAvailable extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public Invoice $invoice)
|
||||
{
|
||||
}
|
||||
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject($this->mainLine())
|
||||
->level('error')
|
||||
->greeting(
|
||||
__('Hello, :name', ['name' => $notifiable->display_name]),
|
||||
)
|
||||
->line($this->descriptionLine())
|
||||
->action(__('View receipt'), $this->mainAction());
|
||||
}
|
||||
|
||||
public function toArray(mixed $notifiable): array
|
||||
{
|
||||
return [
|
||||
'lines' => [
|
||||
[
|
||||
'content' => $this->mainLine(),
|
||||
],
|
||||
[
|
||||
'content' => $this->descriptionLine(),
|
||||
],
|
||||
],
|
||||
'buttonActions' => [
|
||||
[
|
||||
'label' => __('View receipt'),
|
||||
'action' => $this->mainAction(),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function mainLine(): string
|
||||
{
|
||||
$siteName = config('app.name');
|
||||
return __(':name payment receipt', [
|
||||
'name' => $siteName,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function descriptionLine(): string
|
||||
{
|
||||
$siteName = config('app.name');
|
||||
return __('This is a receipt for your latest :siteName payment.', [
|
||||
'siteName' => $siteName,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function mainAction(): string
|
||||
{
|
||||
return config('app.url') . '/billing/invoices/' . $this->invoice->uuid;
|
||||
}
|
||||
}
|
||||
79
common/Billing/Notifications/PaymentFailed.php
Executable file
79
common/Billing/Notifications/PaymentFailed.php
Executable file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Notifications;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Billing\Subscription;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class PaymentFailed extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public Subscription $subscription)
|
||||
{
|
||||
}
|
||||
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject($this->mainLine())
|
||||
->level('error')
|
||||
->greeting(
|
||||
__('Hello, :name', ['name' => $notifiable->display_name]),
|
||||
)
|
||||
->line($this->descriptionLine())
|
||||
->action(__('View subscription'), $this->mainAction());
|
||||
}
|
||||
|
||||
public function toArray(mixed $notifiable): array
|
||||
{
|
||||
return [
|
||||
'lines' => [
|
||||
[
|
||||
'content' => $this->mainLine(),
|
||||
],
|
||||
[
|
||||
'content' => $this->descriptionLine(),
|
||||
],
|
||||
],
|
||||
'buttonActions' => [
|
||||
[
|
||||
'label' => __('View subscription'),
|
||||
'action' => $this->mainAction(),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function mainLine(): string
|
||||
{
|
||||
$siteName = config('app.name');
|
||||
return __('Payment for :name subscription failed', [
|
||||
'name' => $siteName,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function descriptionLine(): string
|
||||
{
|
||||
$siteName = config('app.name');
|
||||
$planName = $this->subscription->product->name;
|
||||
return __(
|
||||
'We could not charge your specified payment method for :planName. We will retry it one more time, after which time your subscription on :siteName will be cancelled and you will lose associated benefits.',
|
||||
['siteName' => $siteName, 'planName' => $planName],
|
||||
);
|
||||
}
|
||||
|
||||
protected function mainAction(): string
|
||||
{
|
||||
return config('app.url') . '/billing';
|
||||
}
|
||||
}
|
||||
24
common/Billing/PricingPageController.php
Executable file
24
common/Billing/PricingPageController.php
Executable file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing;
|
||||
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Core\BaseController;
|
||||
|
||||
class PricingPageController extends BaseController
|
||||
{
|
||||
public function __invoke()
|
||||
{
|
||||
$data = [
|
||||
'loader' => 'pricingPage',
|
||||
'products' => Product::with(['permissions', 'prices'])
|
||||
->limit(15)
|
||||
->orderBy('position', 'asc')
|
||||
->get(),
|
||||
];
|
||||
|
||||
return $this->renderClientOrApi([
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
90
common/Billing/Products/Actions/CrupdateProduct.php
Executable file
90
common/Billing/Products/Actions/CrupdateProduct.php
Executable file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Products\Actions;
|
||||
|
||||
use Common\Auth\Permissions\Traits\SyncsPermissions;
|
||||
use Common\Billing\Gateways\Actions\SyncProductOnEnabledGateways;
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CrupdateProduct
|
||||
{
|
||||
use SyncsPermissions;
|
||||
|
||||
public function execute(
|
||||
array $data,
|
||||
Product $originalProduct = null,
|
||||
$syncProduct = true,
|
||||
): Product {
|
||||
$product =
|
||||
$originalProduct?->load('prices') ?:
|
||||
app(Product::class)->newModelInstance([
|
||||
'uuid' => Str::uuid(),
|
||||
]);
|
||||
|
||||
$newData = [
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'hidden' => $data['hidden'] ?? false,
|
||||
'free' => $data['free'] ?? false,
|
||||
'recommended' => $data['recommended'] ?? false,
|
||||
'position' => $data['position'] ?? 0,
|
||||
'available_space' => $data['available_space'] ?? null,
|
||||
'feature_list' => $data['feature_list'] ?? [],
|
||||
];
|
||||
|
||||
$product = $product->fill($newData);
|
||||
$product->save();
|
||||
|
||||
if (
|
||||
array_key_exists('permissions', $data) &&
|
||||
is_array($data['permissions'])
|
||||
) {
|
||||
$this->syncPermissions($product, $data['permissions']);
|
||||
}
|
||||
|
||||
$prices = Arr::get($data, 'prices') ?? [];
|
||||
|
||||
// delete old prices
|
||||
$originalProduct?->prices->each(function (Price $price) use ($prices) {
|
||||
if (
|
||||
!Arr::first(
|
||||
$prices,
|
||||
fn($p) => isset($p['id']) && $p['id'] === $price->id,
|
||||
)
|
||||
) {
|
||||
$price->delete();
|
||||
}
|
||||
});
|
||||
|
||||
// update existing prices and create new ones
|
||||
foreach ($prices as $price) {
|
||||
$isExistingPrice = isset($price['id']);
|
||||
$pricePayload = [
|
||||
'amount' => $price['amount'],
|
||||
'interval_count' => $price['interval_count'],
|
||||
'interval' => $price['interval'],
|
||||
'currency' => $price['currency'],
|
||||
];
|
||||
|
||||
// existing prices can't be updated for existing products, if it has active subscribers. We can add new price though.
|
||||
if ($isExistingPrice && $originalProduct?->subscriptions_count) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isExistingPrice) {
|
||||
Price::where('id', $price['id'])->update($pricePayload);
|
||||
} else {
|
||||
$product->prices()->create($pricePayload);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$product->free && $syncProduct) {
|
||||
app(SyncProductOnEnabledGateways::class)->execute($product);
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
}
|
||||
121
common/Billing/Products/ProductsController.php
Executable file
121
common/Billing/Products/ProductsController.php
Executable file
@@ -0,0 +1,121 @@
|
||||
<?php namespace Common\Billing\Products;
|
||||
|
||||
use Common\Billing\Gateways\Paypal\Paypal;
|
||||
use Common\Billing\Gateways\Stripe\Stripe;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Products\Actions\CrupdateProduct;
|
||||
use Common\Core\BaseController;
|
||||
use Common\Database\Datasource\Datasource;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProductsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected Stripe $stripe,
|
||||
protected Paypal $paypal
|
||||
) {
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->authorize('index', Product::class);
|
||||
|
||||
$dataSource = new Datasource(
|
||||
Product::with(['permissions', 'prices']),
|
||||
request()->all(),
|
||||
);
|
||||
$dataSource->order = ['col' => 'position', 'dir' => 'asc'];
|
||||
|
||||
return $this->success(['pagination' => $dataSource->paginate()]);
|
||||
}
|
||||
|
||||
public function show(Product $product)
|
||||
{
|
||||
$this->authorize('show', $product);
|
||||
|
||||
$product->load([
|
||||
'permissions',
|
||||
'prices' => fn(HasMany $builder) => $builder->withCount(
|
||||
'subscriptions',
|
||||
),
|
||||
]);
|
||||
|
||||
return ['product' => $product];
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$this->authorize('store', Product::class);
|
||||
|
||||
$this->validate(request(), [
|
||||
'name' => 'required|string|max:250',
|
||||
'permissions' => 'array',
|
||||
'recommended' => 'boolean',
|
||||
'position' => 'integer',
|
||||
'available_space' => 'nullable|integer|min:1',
|
||||
'prices' => ['array', Rule::requiredIf(!request('free'))],
|
||||
'prices.*.currency' => 'required|string|max:255',
|
||||
'prices.*.interval' => 'string|max:255',
|
||||
'prices.*.amount' => 'min:1',
|
||||
]);
|
||||
|
||||
$plan = app(CrupdateProduct::class)->execute(request()->all());
|
||||
|
||||
return $this->success(['plan' => $plan]);
|
||||
}
|
||||
|
||||
public function update(Product $product)
|
||||
{
|
||||
$this->authorize('update', $product);
|
||||
|
||||
$this->validate(request(), [
|
||||
'name' => 'required|string|max:250',
|
||||
'permissions' => 'array',
|
||||
'recommended' => 'boolean',
|
||||
'prices' => ['array', Rule::requiredIf(!request('free'))],
|
||||
'prices.*.currency' => 'required|string|max:255',
|
||||
'prices.*.interval' => 'string|max:255',
|
||||
'prices.*.amount' => 'min:1',
|
||||
]);
|
||||
|
||||
$product = app(CrupdateProduct::class)->execute(
|
||||
request()->all(),
|
||||
$product,
|
||||
);
|
||||
|
||||
return $this->success(['product' => $product]);
|
||||
}
|
||||
|
||||
public function destroy(Product $product): Response|JsonResponse
|
||||
{
|
||||
$this->authorize('destroy', $product);
|
||||
|
||||
if ($product->subscriptions_count) {
|
||||
return $this->error(
|
||||
__(
|
||||
"Could not delete ':plan', because it has active subscriptions.",
|
||||
['plan' => $product->name],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->stripe->isEnabled()) {
|
||||
$this->stripe->deletePlan($product);
|
||||
}
|
||||
if ($this->paypal->isEnabled()) {
|
||||
$this->paypal->deletePlan($product);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$product->delete();
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
313
common/Billing/Subscription.php
Executable file
313
common/Billing/Subscription.php
Executable file
@@ -0,0 +1,313 @@
|
||||
<?php namespace Common\Billing;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Billing\Gateways\Contracts\CommonSubscriptionGatewayActions;
|
||||
use Common\Billing\Gateways\Paypal\Paypal;
|
||||
use Common\Billing\Gateways\Stripe\Stripe;
|
||||
use Common\Billing\Invoices\Invoice;
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscriptions\SubscriptionFactory;
|
||||
use Common\Core\BaseModel;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use LogicException;
|
||||
|
||||
class Subscription extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const MODEL_TYPE = 'subscription';
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $appends = [
|
||||
'on_grace_period',
|
||||
'past_due',
|
||||
'on_trial',
|
||||
'valid',
|
||||
'active',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'price_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'quantity' => 'integer',
|
||||
'trial_ends_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'renews_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getOnGracePeriodAttribute(): bool
|
||||
{
|
||||
return $this->onGracePeriod();
|
||||
}
|
||||
|
||||
public function getOnTrialAttribute(): bool
|
||||
{
|
||||
return $this->onTrial();
|
||||
}
|
||||
|
||||
public function getPastDueAttribute(): bool
|
||||
{
|
||||
return $this->gateway()?->isSubscriptionPastDue($this) ?? false;
|
||||
}
|
||||
|
||||
public function getValidAttribute(): bool
|
||||
{
|
||||
return $this->valid();
|
||||
}
|
||||
|
||||
public function getActiveAttribute(): bool
|
||||
{
|
||||
return $this->active();
|
||||
}
|
||||
|
||||
public function getCancelledAttribute(): bool
|
||||
{
|
||||
return $this->cancelled();
|
||||
}
|
||||
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invoice::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function price(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Price::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function onTrial(): bool
|
||||
{
|
||||
return $this->trial_ends_at?->isFuture() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the subscription is active, on trial, or within its grace period.
|
||||
*/
|
||||
public function valid(): bool
|
||||
{
|
||||
return $this->active() || $this->onTrial() || $this->onGracePeriod();
|
||||
}
|
||||
|
||||
public function ended(): bool
|
||||
{
|
||||
return $this->cancelled() && !$this->onGracePeriod();
|
||||
}
|
||||
|
||||
public function active(): bool
|
||||
{
|
||||
if ($this->ended()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// first payment failed, don't show billing page and allow opening checkout routes
|
||||
$gateway = $this->gateway();
|
||||
if ($gateway?->isSubscriptionIncomplete($this)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->ended();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the subscription is no longer active.
|
||||
*/
|
||||
public function cancelled(): bool
|
||||
{
|
||||
return !is_null($this->ends_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the subscription is within its grace period after cancellation.
|
||||
*/
|
||||
public function onGracePeriod(): bool
|
||||
{
|
||||
return $this->ends_at?->isFuture() ?? false;
|
||||
}
|
||||
|
||||
public function changePlan(Product $newProduct, Price $newPrice): self
|
||||
{
|
||||
$isSuccess = $this->gateway()?->changePlan(
|
||||
$this,
|
||||
$newProduct,
|
||||
$newPrice,
|
||||
);
|
||||
|
||||
if ($isSuccess) {
|
||||
$this->fill([
|
||||
'product_id' => $newProduct->id,
|
||||
'price_id' => $newPrice->id,
|
||||
'ends_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function cancel(bool $atPeriodEnd = true): self
|
||||
{
|
||||
if ($this->gateway_name !== 'none') {
|
||||
$this->gateway()->cancelSubscription($this, $atPeriodEnd);
|
||||
}
|
||||
|
||||
// If the user was on trial, we will set the grace period to end when the trial
|
||||
// would have ended. Otherwise, we'll retrieve the end of the billing period
|
||||
// and make that the end of the grace period for this current user.
|
||||
if ($this->onTrial()) {
|
||||
$this->ends_at = $this->trial_ends_at;
|
||||
} else {
|
||||
$this->ends_at = $this->renews_at;
|
||||
}
|
||||
|
||||
$this->renews_at = null;
|
||||
$this->save();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark subscription as cancelled on local database
|
||||
* only, without interacting with payment gateway.
|
||||
*/
|
||||
public function markAsCancelled(): void
|
||||
{
|
||||
$this->fill([
|
||||
'ends_at' => $this->renews_at,
|
||||
'renews_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the subscription immediately and delete it from database.
|
||||
*/
|
||||
public function cancelAndDelete(): self
|
||||
{
|
||||
$this->cancel(false);
|
||||
$this->delete();
|
||||
$this->invoices()->delete();
|
||||
|
||||
$this->user->update([
|
||||
'card_last_four' => null,
|
||||
'card_brand' => null,
|
||||
'card_expires' => null,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function resume(): self
|
||||
{
|
||||
if (!$this->onGracePeriod()) {
|
||||
throw new LogicException(
|
||||
__(
|
||||
'Unable to resume subscription that is not within grace period.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->onTrial()) {
|
||||
$trialEnd = $this->trial_ends_at->getTimestamp();
|
||||
} else {
|
||||
$trialEnd = 'now';
|
||||
}
|
||||
|
||||
// To resume the subscription we need to set the plan parameter on the Stripe
|
||||
// subscription object. This will force Stripe to resume this subscription
|
||||
// where we left off. Then, we'll set the proper trial ending timestamp.
|
||||
if ($this->gateway_name !== 'none') {
|
||||
$this->gateway()->resumeSubscription($this, [
|
||||
'trial_end' => $trialEnd,
|
||||
]);
|
||||
}
|
||||
|
||||
// Finally, we will remove the ending timestamp from the user's record in the
|
||||
// local database to indicate that the subscription is active again and is
|
||||
// no longer "cancelled". Then we will save this record in the database.
|
||||
$this->renews_at = $this->ends_at;
|
||||
$this->ends_at = null;
|
||||
$this->save();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gateway this subscription was created with.
|
||||
*/
|
||||
public function gateway(): ?CommonSubscriptionGatewayActions
|
||||
{
|
||||
if ($this->gateway_name === 'stripe') {
|
||||
return app(Stripe::class);
|
||||
} elseif ($this->gateway_name === 'paypal') {
|
||||
return app(Paypal::class);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function toNormalizedArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->this->gateway_name,
|
||||
'model_type' => self::MODEL_TYPE,
|
||||
];
|
||||
}
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'product_id' => $this->product_id,
|
||||
'price_id' => $this->price_id,
|
||||
'gateway_name' => $this->gateway_name,
|
||||
'user' => $this->user ? $this->user->getSearchableValues() : null,
|
||||
'description' => $this->description,
|
||||
'ends_at' => $this->ends_at,
|
||||
'created_at' => $this->created_at->timestamp ?? '_null',
|
||||
'updated_at' => $this->updated_at->timestamp ?? '_null',
|
||||
];
|
||||
}
|
||||
|
||||
protected function makeAllSearchableUsing($query)
|
||||
{
|
||||
return $query->with(['user']);
|
||||
}
|
||||
|
||||
public static function filterableFields(): array
|
||||
{
|
||||
return [
|
||||
'id',
|
||||
'product_id',
|
||||
'price_id',
|
||||
'gateway_name',
|
||||
'ends_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function newFactory()
|
||||
{
|
||||
return SubscriptionFactory::new();
|
||||
}
|
||||
|
||||
public static function getModelTypeAttribute(): string
|
||||
{
|
||||
return self::MODEL_TYPE;
|
||||
}
|
||||
}
|
||||
31
common/Billing/Subscriptions/SubscriptionFactory.php
Executable file
31
common/Billing/Subscriptions/SubscriptionFactory.php
Executable file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Subscriptions;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Common\Billing\Subscription;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class SubscriptionFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = Subscription::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'plan_id' => 1,
|
||||
'gateway_name' => 'stripe',
|
||||
'renews_at' => Carbon::now()->addDays($this->faker->numberBetween(1, 10)),
|
||||
];
|
||||
}
|
||||
}
|
||||
110
common/Billing/Subscriptions/SubscriptionsController.php
Executable file
110
common/Billing/Subscriptions/SubscriptionsController.php
Executable file
@@ -0,0 +1,110 @@
|
||||
<?php namespace Common\Billing\Subscriptions;
|
||||
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscription;
|
||||
use Common\Core\BaseController;
|
||||
use Common\Database\Datasource\Datasource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SubscriptionsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected Request $request,
|
||||
protected Subscription $subscription
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->authorize('index', Subscription::class);
|
||||
|
||||
$dataSource = new Datasource(
|
||||
$this->subscription->with(['user']),
|
||||
$this->request->all(),
|
||||
);
|
||||
|
||||
$pagination = $dataSource->paginate();
|
||||
|
||||
return $this->success(['pagination' => $pagination]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$this->authorize('update', Subscription::class);
|
||||
|
||||
$data = $this->validate($this->request, [
|
||||
'user_id' => 'required|exists:users,id|unique:subscriptions',
|
||||
'renews_at' => 'required_without:ends_at|date|nullable',
|
||||
'ends_at' => 'required_without:renews_at|date|nullable',
|
||||
'product_id' => 'required|integer|exists:products,id',
|
||||
'price_id' => 'required|integer|exists:prices,id',
|
||||
'description' => 'string|nullable',
|
||||
]);
|
||||
|
||||
$subscription = $this->subscription->create($data);
|
||||
|
||||
return $this->success(['subscription' => $subscription]);
|
||||
}
|
||||
|
||||
public function update(Subscription $subscription)
|
||||
{
|
||||
$this->authorize('update', Subscription::class);
|
||||
|
||||
$data = $this->validate($this->request, [
|
||||
'user_id' => [
|
||||
'required',
|
||||
'exists:users,id',
|
||||
Rule::unique('subscriptions')->ignore($subscription->id),
|
||||
],
|
||||
'renews_at' => 'date|nullable',
|
||||
'ends_at' => 'date|nullable',
|
||||
'product_id' => 'required|integer|exists:products,id',
|
||||
'price_id' => 'required|integer|exists:prices,id',
|
||||
'description' => 'string|nullable',
|
||||
]);
|
||||
|
||||
$subscription->fill($data)->save();
|
||||
|
||||
return $this->success(['subscription' => $subscription]);
|
||||
}
|
||||
|
||||
public function changePlan(Subscription $subscription)
|
||||
{
|
||||
$data = $this->validate($this->request, [
|
||||
'newProductId' => 'required|integer|exists:products,id',
|
||||
'newPriceId' => 'required|integer|exists:prices,id',
|
||||
]);
|
||||
|
||||
$newProduct = Product::findOrFail($data['newProductId']);
|
||||
$newPrice = Price::findOrFail($data['newPriceId']);
|
||||
|
||||
$subscription->changePlan($newProduct, $newPrice);
|
||||
|
||||
$user = $subscription->user()->first();
|
||||
return $this->success(['user' => $user->load('subscriptions.product')]);
|
||||
}
|
||||
|
||||
public function cancel(Subscription $subscription)
|
||||
{
|
||||
$this->validate($this->request, [
|
||||
'delete' => 'boolean',
|
||||
]);
|
||||
|
||||
if ($this->request->get('delete')) {
|
||||
$subscription->cancelAndDelete();
|
||||
} else {
|
||||
$subscription->cancel();
|
||||
}
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
public function resume(Subscription $subscription)
|
||||
{
|
||||
$subscription->resume();
|
||||
return $this->success(['subscription' => $subscription]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user