diff --git a/boost.json b/boost.json index de4fe2af..e1b4cf93 100644 --- a/boost.json +++ b/boost.json @@ -8,6 +8,7 @@ "nightwatch": false, "sail": false, "skills": [ + "cashier-stripe-development", "fortify-development", "laravel-best-practices", "configuring-horizon", diff --git a/composer.json b/composer.json index 67d21ea1..7d95771b 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "guzzlehttp/guzzle": "^7.11", "laravel-lang/common": "^6.8", "laravel/breeze": "^2.4", + "laravel/cashier": "^16.5", "laravel/chisel": "^0.1.0", "laravel/fortify": "^1.37.2", "laravel/framework": "^13.7", diff --git a/config/cashier.php b/config/cashier.php new file mode 100644 index 00000000..574e2ca6 --- /dev/null +++ b/config/cashier.php @@ -0,0 +1,130 @@ + 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' => [ + // Supported: DompdfInvoiceRenderer::class, LaravelPdfInvoiceRenderer::class + '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'), + +]; diff --git a/database/migrations/2026_06_11_105115_create_customer_columns.php b/database/migrations/2026_06_11_105115_create_customer_columns.php new file mode 100644 index 00000000..974b381e --- /dev/null +++ b/database/migrations/2026_06_11_105115_create_customer_columns.php @@ -0,0 +1,40 @@ +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', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_11_105116_create_subscriptions_table.php b/database/migrations/2026_06_11_105116_create_subscriptions_table.php new file mode 100644 index 00000000..ccbcc6dd --- /dev/null +++ b/database/migrations/2026_06_11_105116_create_subscriptions_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_11_105117_create_subscription_items_table.php b/database/migrations/2026_06_11_105117_create_subscription_items_table.php new file mode 100644 index 00000000..420e23f0 --- /dev/null +++ b/database/migrations/2026_06_11_105117_create_subscription_items_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_11_105118_add_meter_id_to_subscription_items_table.php b/database/migrations/2026_06_11_105118_add_meter_id_to_subscription_items_table.php new file mode 100644 index 00000000..033bb829 --- /dev/null +++ b/database/migrations/2026_06_11_105118_add_meter_id_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_id')->nullable()->after('stripe_price'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_id'); + }); + } +}; diff --git a/database/migrations/2026_06_11_105119_add_meter_event_name_to_subscription_items_table.php b/database/migrations/2026_06_11_105119_add_meter_event_name_to_subscription_items_table.php new file mode 100644 index 00000000..b157b3a5 --- /dev/null +++ b/database/migrations/2026_06_11_105119_add_meter_event_name_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_event_name')->nullable()->after('quantity'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_event_name'); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 98e2ca2f..282d540b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -173,7 +173,20 @@ select:focus[data-flux-control] { border: 1px solid rgb(228 228 231); background: white; color: rgb(63 63 70); - transition: background 120ms ease, color 120ms ease, border-color 120ms ease; + box-shadow: 0 10px 24px rgb(0 0 0 / 0.08); + transition: background 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease; + } + + .wms-mobile-menu { + border-color: rgb(14 116 144 / 0.22); + background: linear-gradient(135deg, rgb(255 255 255), rgb(236 254 255)); + color: rgb(14 116 144); + } + + .dark .wms-mobile-menu { + border-color: rgb(103 232 249 / 0.22); + background: linear-gradient(135deg, rgb(24 24 27), rgb(8 47 73)); + color: rgb(165 243 252); } .wms-sidebar-toggle { @@ -643,6 +656,27 @@ select:focus[data-flux-control] { text-decoration: none; } + .wms-mobile-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3.1rem; + height: 2rem; + border-radius: 999px; + border: 1px solid rgb(14 116 144 / 0.24); + background: linear-gradient(135deg, rgb(14 116 144 / 0.14), rgb(37 99 235 / 0.12)); + color: rgb(14 116 144); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.12em; + } + + .dark .wms-mobile-badge { + border-color: rgb(103 232 249 / 0.26); + background: linear-gradient(135deg, rgb(8 145 178 / 0.26), rgb(37 99 235 / 0.20)); + color: rgb(165 243 252); + } + .wms-page-hero { align-items: flex-start; flex-direction: column; @@ -676,3 +710,164 @@ select:focus[data-flux-control] { } } } + + +@layer components { + body.wms-mobile-nav-open { + overflow: hidden; + } + + @media (max-width: 1023px) { + .wms-app-shell { + display: block !important; + min-height: 100vh; + } + + .wms-main-panel { + min-height: 100vh; + } + + .wms-mobile-header { + position: sticky !important; + top: 0; + z-index: 50; + display: flex !important; + min-height: 4.25rem; + align-items: center; + gap: 0.8rem; + border-bottom: 1px solid rgb(228 228 231); + background: rgb(255 255 255 / 0.94); + padding: 0.75rem 1rem; + box-shadow: 0 14px 34px rgb(15 23 42 / 0.08); + backdrop-filter: blur(18px); + } + + .dark .wms-mobile-header { + border-bottom-color: rgb(63 63 70); + background: rgb(24 24 27 / 0.94); + box-shadow: 0 14px 34px rgb(0 0 0 / 0.28); + } + + .wms-mobile-menu { + display: inline-grid !important; + width: 2.65rem; + height: 2.65rem; + flex: 0 0 auto; + place-items: center; + border-radius: 0.9rem; + border: 1px solid rgb(14 116 144 / 0.28); + background: linear-gradient(135deg, rgb(255 255 255), rgb(236 254 255)); + color: rgb(14 116 144); + font-size: 1.05rem; + box-shadow: 0 12px 28px rgb(14 116 144 / 0.16); + } + + .dark .wms-mobile-menu { + border-color: rgb(103 232 249 / 0.30); + background: linear-gradient(135deg, rgb(24 24 27), rgb(8 47 73)); + color: rgb(165 243 252); + } + + .wms-mobile-title { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: rgb(24 24 27); + font-size: 0.98rem; + font-weight: 850; + letter-spacing: -0.02em; + text-decoration: none; + } + + .dark .wms-mobile-title { + color: rgb(244 244 245); + } + + .wms-mobile-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3.15rem; + height: 2rem; + border-radius: 999px; + border: 1px solid rgb(14 116 144 / 0.24); + background: linear-gradient(135deg, rgb(14 116 144 / 0.14), rgb(37 99 235 / 0.12)); + color: rgb(14 116 144); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.12em; + } + + .dark .wms-mobile-badge { + border-color: rgb(103 232 249 / 0.26); + background: linear-gradient(135deg, rgb(8 145 178 / 0.26), rgb(37 99 235 / 0.20)); + color: rgb(165 243 252); + } + + .wms-sidebar { + position: fixed !important; + inset: 0 auto 0 0; + z-index: 70; + width: min(23.5rem, calc(100vw - 2.25rem)) !important; + min-width: min(23.5rem, calc(100vw - 2.25rem)) !important; + height: 100dvh; + transform: translateX(-110%); + box-shadow: 28px 0 70px rgb(0 0 0 / 0.30); + } + + html[data-wms-mobile-sidebar='open'] .wms-sidebar { + transform: translateX(0) !important; + } + + .wms-mobile-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: none; + width: 100%; + height: 100%; + border: 0; + background: transparent; + padding: 0; + } + + html[data-wms-mobile-sidebar='open'] .wms-mobile-overlay { + display: block !important; + background: rgb(0 0 0 / 0.52); + backdrop-filter: blur(3px); + } + + .wms-mobile-close { + display: inline-grid !important; + } + + .wms-sidebar-toggle { + display: none !important; + } + + html[data-wms-sidebar='collapsed'] [data-wms-sidebar-label], + html[data-wms-sidebar='collapsed'] .wms-brand-copy, + html[data-wms-sidebar='collapsed'] .wms-sidebar-heading, + html[data-wms-sidebar='collapsed'] .wms-sidebar-link span { + display: block !important; + } + + html[data-wms-sidebar='collapsed'] .wms-sidebar-link { + justify-content: flex-start !important; + } + + .wms-content { + padding-top: 1rem; + } + } + + @media (min-width: 1024px) { + .wms-mobile-header, + .wms-mobile-overlay, + .wms-mobile-close { + display: none !important; + } + } +} diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index 21aac8d4..57dbbb06 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -5,10 +5,10 @@
-