added cashier subscription, subscription plans

This commit is contained in:
Gitea
2025-05-03 06:13:19 +05:30
parent e04539f1b7
commit 6e2a750c4e
23 changed files with 1087 additions and 21 deletions

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PlanResource\Pages;
use App\Filament\Resources\PlanResource\RelationManagers;
use App\Models\Plan;
use Filament\Forms;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\BooleanColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Str;
use phpDocumentor\Reflection\Types\Boolean;
class PlanResource extends Resource
{
protected static ?string $model = Plan::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $navigationGroup = 'Web Master';
public static function form(Form $form): Form
{
return $form
->schema([
Section::make('Plan Information')
->description('Add a new plan')
->schema([
TextInput::make('name')->label('Page Name')
->required(),
TextInput::make('description'),
TextInput::make('product_id')->required(),
TextInput::make('pricing_id')->required(),
TextInput::make('price')->numeric()->required(),
Select::make('monthly_billing')->options([
1 => 'Monthly',
0 => 'Yearly',
])->default(1)->required(),
KeyValue::make('details')
->label('Plan Details (Optional)')
->keyPlaceholder('Name')
->valuePlaceholder('Content')
->reorderable(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')->label('Name'),
TextColumn::make('product_id')->label('Product'),
TextColumn::make('pricing_id')->label('Pricing'),
TextColumn::make('price')->label('Price'),
BooleanColumn::make('monthly_billing')->label('Monthly Billing'),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPlans::route('/'),
'create' => Pages\CreatePlan::route('/create'),
'edit' => Pages\EditPlan::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\PlanResource\Pages;
use App\Filament\Resources\PlanResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreatePlan extends CreateRecord
{
protected static string $resource = PlanResource::class;
protected function getCreatedNotification(): ?Notification
{
return Notification::make()
->success()
->title('Plan created')
->body('Plan created successfully');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\PlanResource\Pages;
use App\Filament\Resources\PlanResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditPlan extends EditRecord
{
protected static string $resource = PlanResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
protected function getRedirectUrl(): ?string
{
return $this->getResource()::getUrl('index');
}
protected function getSavedNotification(): ?Notification
{
return Notification::make()
->success()
->title('Plan updated')
->body('Plan updated successfully');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PlanResource\Pages;
use App\Filament\Resources\PlanResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPlans extends ListRecords
{
protected static string $resource = PlanResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
use Livewire\Livewire;
class StripeEventListener
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(WebhookReceived $event): void
{
if ($event->payload['type'] === 'invoice.payment_succeeded') {
session()->flash('alert', ['type' => 'success', 'message' => 'Payment completed successfully.']);
Log::info('Payment succeeded');
}
if ($event->payload['type'] === 'customer.subscription.deleted') {
session()->flash('alert', ['type' => 'error', 'message' => 'Subscription canceled.']);
Log::info('Subscription canceled');
}
}
}

View File

@@ -2,12 +2,70 @@
namespace App\Livewire\Dashboard; namespace App\Livewire\Dashboard;
use Illuminate\Http\Request;
use Livewire\Component; use Livewire\Component;
class Dashboard extends Component class Dashboard extends Component
{ {
public $message;
public $subscription;
public $plans;
public function paymentStatus(Request $request)
{
$status = $request->route('status');
$currentUrl = $request->fullUrl();
if ($status == 'success') {
return redirect()->route('dashboard')->with('status', 'success');
} elseif ($status == 'cancel') {
return redirect()->route('dashboard')->with('status', 'cancel');
}
}
public function mount(Request $request)
{
try {
$status = $request->session()->get('status');
if (isset($status)) {
if ($status == 'success') {
$this->message = ['type' => 'success', 'message' => 'Order completed successfully.'];
} else {
$this->message = ['type' => 'error', 'message' => 'Order cancelled.'];
}
$request->session()->forget('status');
}
} catch (\Exception $exception) {
}
if (auth()->user()->subscribedToProduct(config('app.plans')[0]['product_id'])) {
try {
$result = auth()->user()->subscriptions()->where(['stripe_status' => 'active'])->orderByDesc('updated_at')->first();
$userPriceID = $result['items'][0]['stripe_price'];
$subscriptionEnd = $result['ends_at'];
$planName = null; // Default value if not found
foreach (config('app.plans') as $plan) {
if ($plan['pricing_id'] === $userPriceID) {
$planName = $plan['name'];
break;
}
}
$this->subscription['name'] = $planName;
$this->subscription['ends_at'] = $subscriptionEnd;
} catch (\Exception $e) {
\Log::error($e->getMessage());
}
}
}
public function render() public function render()
{ {
return view('livewire.dashboard.dashboard')->layout('components.layouts.dashboard'); return view('livewire.dashboard.dashboard')->layout('components.layouts.dashboard')->with('message', $this->message);
} }
} }

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Dashboard;
use Livewire\Component;
class Pricing extends Component
{
public $plans;
public function mount(): void
{
$this->plans = config('app.plans');
}
public function choosePlan($pricing_id): void
{
$this->redirect(route('checkout', $pricing_id));
}
public function render()
{
return view('livewire.dashboard.pricing');
}
}

23
app/Models/Plan.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Plan extends Model
{
protected $fillable = [
'name',
'description',
'product_id',
'pricing_id',
'price',
'monthly_billing',
'details'
];
protected $casts = [
'details' => 'json',
'monthly_billing' => 'boolean',
];
}

View File

@@ -10,11 +10,12 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Cashier\Billable;
class User extends Authenticatable implements FilamentUser, MustVerifyEmail class User extends Authenticatable implements FilamentUser, MustVerifyEmail
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable, Billable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.

View File

@@ -4,8 +4,10 @@ namespace App\Providers;
use App\Models\Blog; use App\Models\Blog;
use App\Models\Menu; use App\Models\Menu;
use App\Models\Plan;
use DB; use DB;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -33,8 +35,15 @@ class AppServiceProvider extends ServiceProvider
return Blog::where('is_published', 1)->get(); return Blog::where('is_published', 1)->get();
}); });
$plans = cache()->remember('app_plans', now()->addHours(6), function () {
return Plan::all();
});
config(['app.settings' => (array) $settings]); config(['app.settings' => (array) $settings]);
config(['app.menus' => $menus]); config(['app.menus' => $menus]);
config(['app.blogs' => $blogs]); config(['app.blogs' => $blogs]);
config(['app.plans' => $plans]);
Cashier::calculateTaxes();
} }
} }

View File

@@ -14,6 +14,9 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\Locale::class, \App\Http\Middleware\Locale::class,
]); ]);
$middleware->validateCsrfTokens(except: [
'stripe/*',
]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// //

View File

@@ -7,13 +7,14 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-imap": "*",
"ddeboer/imap": "^1.14", "ddeboer/imap": "^1.14",
"filament/filament": "3.3", "filament/filament": "3.3",
"laravel/cashier": "^15.6",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"livewire/flux": "^2.1", "livewire/flux": "^2.1",
"livewire/livewire": "^3.6", "livewire/livewire": "^3.6"
"ext-imap": "*"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.15", "barryvdh/laravel-debugbar": "^3.15",

323
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "b4dcaf149eb8c0291c1de5ccd42c8f94", "content-hash": "f1d41505247807e937b78e4e92b1571e",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -2100,6 +2100,94 @@
}, },
"time": "2025-04-01T14:41:56+00:00" "time": "2025-04-01T14:41:56+00:00"
}, },
{
"name": "laravel/cashier",
"version": "v15.6.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/cashier-stripe.git",
"reference": "8fe60cc71161ef06b6a1b23cffe886abf2a49b29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/8fe60cc71161ef06b6a1b23cffe886abf2a49b29",
"reference": "8fe60cc71161ef06b6a1b23cffe886abf2a49b29",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/contracts": "^10.0|^11.0|^12.0",
"illuminate/database": "^10.0|^11.0|^12.0",
"illuminate/http": "^10.0|^11.0|^12.0",
"illuminate/log": "^10.0|^11.0|^12.0",
"illuminate/notifications": "^10.0|^11.0|^12.0",
"illuminate/pagination": "^10.0|^11.0|^12.0",
"illuminate/routing": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/view": "^10.0|^11.0|^12.0",
"moneyphp/money": "^4.0",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.1",
"stripe/stripe-php": "^16.2",
"symfony/console": "^6.0|^7.0",
"symfony/http-kernel": "^6.0|^7.0",
"symfony/polyfill-intl-icu": "^1.22.1"
},
"require-dev": {
"dompdf/dompdf": "^2.0",
"mockery/mockery": "^1.0",
"orchestra/testbench": "^8.18|^9.0|^10.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.4|^11.5"
},
"suggest": {
"dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^1.0.1|^2.0).",
"ext-intl": "Allows for more locales besides the default \"en\" when formatting money values."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Cashier\\CashierServiceProvider"
]
},
"branch-alias": {
"dev-master": "15.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Cashier\\": "src/",
"Laravel\\Cashier\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Dries Vints",
"email": "dries@laravel.com"
}
],
"description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.",
"keywords": [
"billing",
"laravel",
"stripe"
],
"support": {
"issues": "https://github.com/laravel/cashier/issues",
"source": "https://github.com/laravel/cashier"
},
"time": "2025-04-22T13:59:36+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.10.2", "version": "v12.10.2",
@@ -3349,6 +3437,96 @@
}, },
"time": "2024-03-31T07:05:07+00:00" "time": "2024-03-31T07:05:07+00:00"
}, },
{
"name": "moneyphp/money",
"version": "v4.7.0",
"source": {
"type": "git",
"url": "https://github.com/moneyphp/money.git",
"reference": "af048f0206d3b39b8fad9de6a230cedf765365fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/moneyphp/money/zipball/af048f0206d3b39b8fad9de6a230cedf765365fa",
"reference": "af048f0206d3b39b8fad9de6a230cedf765365fa",
"shasum": ""
},
"require": {
"ext-bcmath": "*",
"ext-filter": "*",
"ext-json": "*",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
},
"require-dev": {
"cache/taggable-cache": "^1.1.0",
"doctrine/coding-standard": "^12.0",
"doctrine/instantiator": "^1.5.0 || ^2.0",
"ext-gmp": "*",
"ext-intl": "*",
"florianv/exchanger": "^2.8.1",
"florianv/swap": "^4.3.0",
"moneyphp/crypto-currencies": "^1.1.0",
"moneyphp/iso-currencies": "^3.4",
"php-http/message": "^1.16.0",
"php-http/mock-client": "^1.6.0",
"phpbench/phpbench": "^1.2.5",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1.9",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5.9",
"psr/cache": "^1.0.1 || ^2.0 || ^3.0",
"ticketswap/phpstan-error-formatter": "^1.1"
},
"suggest": {
"ext-gmp": "Calculate without integer limits",
"ext-intl": "Format Money objects with intl",
"florianv/exchanger": "Exchange rates library for PHP",
"florianv/swap": "Exchange rates library for PHP",
"psr/cache-implementation": "Used for Currency caching"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Money\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mathias Verraes",
"email": "mathias@verraes.net",
"homepage": "http://verraes.net"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
},
{
"name": "Frederik Bosch",
"email": "f.bosch@genkgo.nl"
}
],
"description": "PHP implementation of Fowler's Money pattern",
"homepage": "http://moneyphp.org",
"keywords": [
"Value Object",
"money",
"vo"
],
"support": {
"issues": "https://github.com/moneyphp/money/issues",
"source": "https://github.com/moneyphp/money/tree/v4.7.0"
},
"time": "2025-04-03T08:26:36+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.9.0", "version": "3.9.0",
@@ -5028,6 +5206,65 @@
], ],
"time": "2025-04-11T15:27:14+00:00" "time": "2025-04-11T15:27:14+00:00"
}, },
{
"name": "stripe/stripe-php",
"version": "v16.6.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/d6de0a536f00b5c5c74f36b8f4d0d93b035499ff",
"reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.5.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v16.6.0"
},
"time": "2025-02-24T22:35:29+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v7.2.0", "version": "v7.2.0",
@@ -6204,6 +6441,90 @@
], ],
"time": "2024-09-09T11:45:10+00:00" "time": "2024-09-09T11:45:10+00:00"
}, },
{
"name": "symfony/polyfill-intl-icu",
"version": "v1.32.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
"reference": "763d2a91fea5681509ca01acbc1c5e450d127811"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/763d2a91fea5681509ca01acbc1c5e450d127811",
"reference": "763d2a91fea5681509ca01acbc1c5e450d127811",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance and support of other locales than \"en\""
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Icu\\": ""
},
"classmap": [
"Resources/stubs"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's ICU-related data and classes",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"icu",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.32.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-12-21T18:38:29+00:00"
},
{ {
"name": "symfony/polyfill-intl-idn", "name": "symfony/polyfill-intl-idn",
"version": "v1.31.0", "version": "v1.31.0",

127
config/cashier.php Normal file
View File

@@ -0,0 +1,127 @@
<?php
use Laravel\Cashier\Console\WebhookCommand;
use Laravel\Cashier\Invoices\DompdfInvoiceRenderer;
return [
/*
|--------------------------------------------------------------------------
| Stripe Keys
|--------------------------------------------------------------------------
|
| The Stripe publishable key and secret key give you access to Stripe's
| API. The "publishable" key is typically used when interacting with
| Stripe.js while the "secret" key accesses private API endpoints.
|
*/
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
/*
|--------------------------------------------------------------------------
| Cashier Path
|--------------------------------------------------------------------------
|
| This is the base URI path where Cashier's views, such as the payment
| verification screen, will be available from. You're free to tweak
| this path according to your preferences and application design.
|
*/
'path' => env('CASHIER_PATH', 'stripe'),
/*
|--------------------------------------------------------------------------
| Stripe Webhooks
|--------------------------------------------------------------------------
|
| Your Stripe webhook secret is used to prevent unauthorized requests to
| your Stripe webhook handling controllers. The tolerance setting will
| check the drift between the current time and the signed request's.
|
*/
'webhook' => [
'secret' => env('STRIPE_WEBHOOK_SECRET'),
'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300),
'events' => WebhookCommand::DEFAULT_EVENTS,
],
/*
|--------------------------------------------------------------------------
| Currency
|--------------------------------------------------------------------------
|
| This is the default currency that will be used when generating charges
| from your application. Of course, you are welcome to use any of the
| various world currencies that are currently supported via Stripe.
|
*/
'currency' => env('CASHIER_CURRENCY', 'usd'),
/*
|--------------------------------------------------------------------------
| Currency Locale
|--------------------------------------------------------------------------
|
| This is the default locale in which your money values are formatted in
| for display. To utilize other locales besides the default en locale
| verify you have the "intl" PHP extension installed on the system.
|
*/
'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
| Payment Confirmation Notification
|--------------------------------------------------------------------------
|
| If this setting is enabled, Cashier will automatically notify customers
| whose payments require additional verification. You should listen to
| Stripe's webhooks in order for this feature to function correctly.
|
*/
'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'),
/*
|--------------------------------------------------------------------------
| Invoice Settings
|--------------------------------------------------------------------------
|
| The following options determine how Cashier invoices are converted from
| HTML into PDFs. You're free to change the options based on the needs
| of your application or your preferences regarding invoice styling.
|
*/
'invoices' => [
'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class),
'options' => [
// Supported: 'letter', 'legal', 'A4'
'paper' => env('CASHIER_PAPER', 'letter'),
'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false),
],
],
/*
|--------------------------------------------------------------------------
| Stripe Logger
|--------------------------------------------------------------------------
|
| This setting defines which logging channel will be used by the Stripe
| library to write log messages. You are free to specify any of your
| logging channels listed inside the "logging" configuration file.
|
*/
'logger' => env('CASHIER_LOGGER'),
];

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex([
'stripe_id',
]);
$table->dropColumn([
'stripe_id',
'pm_type',
'pm_last_four',
'trial_ends_at',
]);
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id');
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'stripe_status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscription_items', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->integer('quantity')->nullable();
$table->timestamps();
$table->index(['subscription_id', 'stripe_price']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscription_items');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('product_id')->collation('utf8_bin');
$table->string('pricing_id')->collation('utf8_bin');
$table->integer('price');
$table->boolean('monthly_billing')->default(true);
$table->longText('details')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('plans');
}
};

View File

@@ -8,6 +8,14 @@
background-color: #f72a25; background-color: #f72a25;
} }
.app-primary-bg {
background-color: #F14743;
}
.app-primary {
color: #F14743;
}
.btn-primary { .btn-primary {
color: white; color: white;
background-color: #4361ee; background-color: #4361ee;

View File

@@ -0,0 +1,43 @@
{{-- Credit: Lucide (https://lucide.dev) --}}
@props([
'variant' => 'outline',
])
@php
if ($variant === 'solid') {
throw new \Exception('The "solid" variant is not supported in Lucide.');
}
$classes = Flux::classes('shrink-0')
->add(match($variant) {
'outline' => '[:where(&)]:size-6',
'solid' => '[:where(&)]:size-6',
'mini' => '[:where(&)]:size-5',
'micro' => '[:where(&)]:size-4',
});
$strokeWidth = match ($variant) {
'outline' => 2,
'mini' => 2.25,
'micro' => 2.5,
};
@endphp
<svg
{{ $attributes->class($classes) }}
data-flux-icon
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="{{ $strokeWidth }}"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-slot="icon"
>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>

View File

@@ -1,19 +1,11 @@
@section('title'){{ __('Dashboard') }}@endsection @section('title'){{ __('Dashboard') }}@endsection
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl"> <div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
<div class="grid auto-rows-min gap-4 md:grid-cols-3"> <div class="grid auto-rows-min gap-4 md:grid-cols-2">
<article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]"> <article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]">
<span class="rounded-full bg-blue-100 p-3 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400"> <span class="rounded-full bg-[#F04743]/20 p-3 text-[#F04743] dark:bg-[#F04743]/20 dark:text-[#F04743]">
<flux:icon.circle-dollar-sign />
</span>
<div>
<p class="text-2xl font-medium text-gray-900 dark:text-white">0</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Balance</p>
</div>
</article>
<article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]">
<span class="rounded-full bg-blue-100 p-3 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400">
<flux:icon.at-sign /> <flux:icon.at-sign />
</span> </span>
<div> <div>
@@ -23,7 +15,7 @@
</article> </article>
<article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]"> <article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]">
<span class="rounded-full bg-blue-100 p-3 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400"> <span class="rounded-full bg-[#F04743]/20 p-3 text-[#F04743] dark:bg-[#F04743]/20 dark:text-[#F04743]">
<flux:icon.mails /> <flux:icon.mails />
</span> </span>
<div> <div>
@@ -34,7 +26,35 @@
</div> </div>
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700"> @if(auth()->user()->subscribedToProduct(config('app.plans')[0]['product_id']))
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div> <article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Your <span class="text-accent-content font-bold">{{ $subscription['name'] }}</span> subscription is active and will be
@if($subscription['ends_at']) end at<span class="app-primary"> {{ \Carbon\Carbon::make($subscription['ends_at'])->toFormattedDayDateString() }}.</span>
@else auto-renew as per the plan you chose.
@endif
To manage you subscription <a href="{{ route('billing') }}" target="_blank" class="text-blue-500">Click here</a></p>
</div>
</article>
@else
<div class="flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700 dark:bg-white/[0.03]">
<livewire:dashboard.pricing />
</div>
@endif
<script>
document.addEventListener('DOMContentLoaded', function () {
// Check if session flash data exists
@if(session()->has('alert'))
setTimeout(function() {
// Emitting showAlert event with type and message from session
Livewire.emit('showAlert', {
type: '{{ session('alert')['type'] }}',
message: '{{ session('alert')['message'] }}'
});
}, 2000); // 2000ms = 2 seconds delay
@endif
});
</script>
</div> </div>

View File

@@ -0,0 +1,46 @@
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 ">
<div class="w-full mb-8 items-center flex justify-center">
<h1 class="text-center text-3xl text-gray-900 dark:text-gray-200">Purchase Subscription</h1>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-center md:gap-8">
@if(isset($plans))
@foreach($plans as $plan)
<div class="rounded-2xl border dark:border-white/[0.1] border-black/[0.3] p-6 shadow-xs ring-1 ring-white/[0.5] sm:px-8 lg:p-12">
<div class="text-center">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-400">{{ $plan->name }} @if(!$plan->monthly_billing)
<flux:badge variant="solid" size="sm" color="emerald">2 Months Free</flux:badge>
@endif</h2>
<p class="mt-2 sm:mt-4">
<strong class="text-3xl font-bold text-gray-900 dark:text-gray-200 sm:text-4xl">${{ $plan->price }}</strong>
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">/{{ $plan->monthly_billing ? 'month' : 'year' }}</span>
</p>
</div>
<ul class="mt-6 space-y-2">
@if($plan->details)
@forelse ($plan->details as $key => $value)
@if ($value)
<li class="flex items-center gap-1">
@if($value == "true")<flux:icon.check-circle />
@else <flux:icon.circle-x />
@endif
<span class="text-gray-700 dark:text-gray-400 "> {{ $key }} </span>
</li>
@endif
@empty
@endforelse
@endif
</ul>
<flux:button variant="primary" class="w-full mt-6 cursor-pointer" wire:click="choosePlan('{{ $plan->pricing_id }}')">
Choose Plan
</flux:button>
</div>
@endforeach
@endif
</div>
</div>

View File

@@ -47,6 +47,33 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard/bulk-email-generator', Dashboard::class)->name('dashboard.bulk'); Route::get('dashboard/bulk-email-generator', Dashboard::class)->name('dashboard.bulk');
Route::get('dashboard/compose-email', Dashboard::class)->name('dashboard.compose'); Route::get('dashboard/compose-email', Dashboard::class)->name('dashboard.compose');
// Checkout Routes
Route::get('checkout/{plan}', function ($pricing_id) {
$plans = config('app.plans');
$pricingData = [];
foreach ($plans as $plan) {
$pricingData[] = $plan['pricing_id'];
}
if (in_array($pricing_id, $pricingData)) {
return auth()->user()
->newSubscription('default', $pricing_id)
->allowPromotionCodes()
->checkout([
'success_url' => route('checkout.success'),
'cancel_url' => route('checkout.cancel'),
]);
}
abort(404);
})->name('checkout');
Route::get('dashboard/success', [Dashboard::class, 'paymentStatus'])->name('checkout.success')->defaults('status', 'success');
Route::get('dashboard/cancel', [Dashboard::class, 'paymentStatus'])->name('checkout.cancel')->defaults('status', 'cancel');
Route::get('dashboard/billing', function () {
return auth()->user()->redirectToBillingPortal(route('dashboard'));
})->name('billing');
}); });
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {