diff --git a/MEMEAIGEN/STRIPE WEBHOOKS.bru b/MEMEAIGEN/STRIPE WEBHOOKS.bru new file mode 100644 index 0000000..94474e1 --- /dev/null +++ b/MEMEAIGEN/STRIPE WEBHOOKS.bru @@ -0,0 +1,205 @@ +meta { + name: STRIPE WEBHOOKS + type: http + seq: 3 +} + +post { + url: https://memeaigen.test/stripe/webhook + body: json + auth: none +} + +body:json { + { + "id": "evt_1Rg71IEEXQJo9EEO4GHqZdRZ", + "object": "event", + "api_version": "2025-05-28.basil", + "created": 1751387215, + "data": { + "object": { + "id": "sub_1Rg71FEEXQJo9EEO18sXSEho", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1751387213, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "classic" + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1751387213, + "currency": "usd", + "customer": "cus_SbGYl34MpG4nv5", + "days_until_due": null, + "default_payment_method": "pm_1Rg71EEEXQJo9EEOJWUAU6EQ", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_SbJesuW5WgGoZ7", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1751387213, + "current_period_end": 1754065613, + "current_period_start": 1751387213, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1RfN2VEEXQJo9EEOzjPI2HGt", + "object": "plan", + "active": true, + "amount": 400, + "amount_decimal": "400", + "billing_scheme": "per_unit", + "created": 1751210467, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_SaY8TGjiPi5hWu", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1RfN2VEEXQJo9EEOzjPI2HGt", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1751210467, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_SaY8TGjiPi5hWu", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 400, + "unit_amount_decimal": "400" + }, + "quantity": 1, + "subscription": "sub_1Rg71FEEXQJo9EEO18sXSEho", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1Rg71FEEXQJo9EEO18sXSEho" + }, + "latest_invoice": "in_1Rg71FEEXQJo9EEOmxbGjtdH", + "livemode": false, + "metadata": { + "is_on_session_checkout": "true" + }, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": { + "acss_debit": null, + "bancontact": null, + "card": { + "network": null, + "request_three_d_secure": "automatic" + }, + "customer_balance": null, + "konbini": null, + "sepa_debit": null, + "us_bank_account": null + }, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1RfN2VEEXQJo9EEOzjPI2HGt", + "object": "plan", + "active": true, + "amount": 400, + "amount_decimal": "400", + "billing_scheme": "per_unit", + "created": 1751210467, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_SaY8TGjiPi5hWu", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1751387213, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + }, + "previous_attributes": { + "default_payment_method": null, + "status": "incomplete" + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": null, + "idempotency_key": "e50baede-20b6-4a06-a2c4-15f43ca47dd4" + }, + "type": "customer.subscription.updated" + } +} diff --git a/app/Helpers/FirstParty/Purchase/PurchaseHelper.php b/app/Helpers/FirstParty/Purchase/PurchaseHelper.php index 53f39d0..6fed130 100644 --- a/app/Helpers/FirstParty/Purchase/PurchaseHelper.php +++ b/app/Helpers/FirstParty/Purchase/PurchaseHelper.php @@ -37,6 +37,32 @@ public static function getPricingPageOneTime() return $one_time; } + public static function getSubscriptionPlanByStripePriceID($stripe_price_id) + { + $plans = self::getSubscriptions('subscription_plans', true, true); + + foreach ($plans as $plan) { + + + if ($plan['id'] == 'free') { + continue; + } + + $stripe_price_ids = self::getPlanSystemProperty($plan, 'stripe.stripe_price_ids'); + + if (is_array($stripe_price_ids)) { + if (isset($stripe_price_ids['month']) && in_array($stripe_price_id, $stripe_price_ids['month'])) { + return $plan; + } + if (isset($stripe_price_ids['year']) && in_array($stripe_price_id, $stripe_price_ids['year'])) { + return $plan; + } + } + } + + return null; + } + public static function getPlanSystemProperty($plan, $property) { // Get the environment (test or prod) @@ -50,10 +76,10 @@ public static function getPlanSystemProperty($plan, $property) // Inject environment into the path // stripe.product_id.month becomes system.stripe.product_id.{env}.month array_splice($propertyParts, 2, 0, $environment); - $fullPath = 'system.'.implode('.', $propertyParts); + $fullPath = 'system.' . implode('.', $propertyParts); } else { // For non-stripe properties, just prepend 'system.' - $fullPath = 'system.'.$property; + $fullPath = 'system.' . $property; } return data_get($plan, $fullPath); diff --git a/app/Helpers/FirstParty/Purchase/SubscriptionHelper.php b/app/Helpers/FirstParty/Purchase/SubscriptionHelper.php new file mode 100644 index 0000000..379b2a8 --- /dev/null +++ b/app/Helpers/FirstParty/Purchase/SubscriptionHelper.php @@ -0,0 +1,89 @@ +payload['type']) { + case 'customer.subscription.created': + case 'customer.subscription.updated': + self::handleSubscriptionUpsert($event); + break; + + case 'customer.subscription.deleted': + self::handleSubscriptionDelete($event); + break; + } + } + + private static function handleSubscriptionUpsert(WebhookReceived $event) + { + $object = $event->payload['data']['object']; + + //dump($object); + + $ignore_statuses = ['incomplete_expired', 'incomplete']; + + if (in_array($object['status'], $ignore_statuses)) { + return; + } + + $user = StripeHelper::getUserByStripeID($object['customer']); + + if ($user) { + foreach ($object['items']['data'] as $line_item) { + + //dump($line_item); + + $stripe_price_id = $line_item['plan']['id']; + $current_period_end = $line_item['current_period_end']; + + $cancel_at = null; + $canceled_at = null; + + if ($line_item['subscription'] == $object['id']) { + $cancel_at = $object['cancel_at']; + $canceled_at = $object['canceled_at']; + } + + $subscription_config = PurchaseHelper::getSubscriptionPlanByStripePriceID($stripe_price_id); + + $plan = Plan::where('tier', $subscription_config['id'])->first(); + + if ($plan) { + $user_plan = UserPlan::where('user_id', $user->id)->first(); + + if ($user_plan) { + $user_plan->update([ + 'plan_id' => $plan->id, + 'current_period_end' => $current_period_end, + 'cancel_at' => $cancel_at, + 'canceled_at' => $canceled_at, + ]); + } else { + $user_plan = UserPlan::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'current_period_end' => $current_period_end, + 'cancel_at' => $cancel_at, + 'canceled_at' => $canceled_at, + ]); + } + } + } + } + } + + private static function handleSubscriptionDelete(WebhookReceived $event) + { + // / + } +} diff --git a/app/Helpers/FirstParty/Purchase/WatermarkUsageHelper.php b/app/Helpers/FirstParty/Purchase/WatermarkUsageHelper.php new file mode 100644 index 0000000..617cbb5 --- /dev/null +++ b/app/Helpers/FirstParty/Purchase/WatermarkUsageHelper.php @@ -0,0 +1,65 @@ +payload['type']) { + case 'invoice.paid': + self::handleInvoicePaid($event); + break; + } + } + + private static function handleInvoicePaid(WebhookReceived $event) + { + $object = $event->payload['data']['object']; + + //dump($object); + + $accept_statuses = ['paid', 'partially_paid']; + + if (!in_array($object['status'], $accept_statuses)) { + return; + } + + $user = StripeHelper::getUserByStripeID($object['customer']); + + if ($user) { + foreach ($object['lines']['data'] as $line_item) { + $stripe_price_id = $line_item['pricing']['price_details']['price']; + + //dd($stripe_price_id); + + $subscription_config = PurchaseHelper::getSubscriptionPlanByStripePriceID($stripe_price_id); + + if ($subscription_config) { + + if ($subscription_config['type'] == 'subscription_plans') { + $user_usage = UserUsage::where('user_id', $user->id)->first(); + + if ($user_usage) { + $user_usage->update([ + 'non_watermark_videos_left' => $subscription_config['system']['non_watermark_videos'], + ]); + } else { + $user_usage = UserUsage::create([ + 'user_id' => $user->id, + 'non_watermark_videos_left' => $subscription_config['system']['non_watermark_videos'], + ]); + } + } + + //dd($subscription_config); + } + } + } + } +} diff --git a/app/Helpers/FirstParty/Stripe/StripeHelper.php b/app/Helpers/FirstParty/Stripe/StripeHelper.php index 8893291..6e63b69 100644 --- a/app/Helpers/FirstParty/Stripe/StripeHelper.php +++ b/app/Helpers/FirstParty/Stripe/StripeHelper.php @@ -2,31 +2,13 @@ namespace App\Helpers\FirstParty\Stripe; +use App\Models\User; use Laravel\Cashier\Events\WebhookReceived; class StripeHelper { - public static function handleSubscriptionWebhookEvents(WebhookReceived $event) + public static function getUserByStripeID($customer_id) { - switch ($event->payload['type']) { - case 'customer.subscription.created': - case 'customer.subscription.updated': - self::handleSubscriptionUpsert($event); - break; - - case 'customer.subscription.deleted': - self::handleSubscriptionDelete($event); - break; - } - } - - private static function handleSubscriptionUpsert(WebhookReceived $event) - { - /// - } - - private static function handleSubscriptionDelete(WebhookReceived $event) - { - /// + return User::where('stripe_id', $customer_id)->first(); } } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 10f6ef3..8b6bdb8 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -31,7 +31,7 @@ public function create(): Response public function store(Request $request): RedirectResponse { $request->validate([ - 'email' => 'required|string|lowercase|email|max:255|unique:' . User::class, + 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php index 7674f1a..6283c25 100644 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -15,7 +15,7 @@ class VerifyEmailController extends Controller public function __invoke(EmailVerificationRequest $request): RedirectResponse { if ($request->user()->hasVerifiedEmail()) { - return redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false) . '?verified=1'); + return redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false).'?verified=1'); } if ($request->user()->markEmailAsVerified()) { @@ -25,6 +25,6 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse event(new Verified($user)); } - return redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false) . '?verified=1'); + return redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false).'?verified=1'); } } diff --git a/app/Http/Controllers/SocialAuthController.php b/app/Http/Controllers/SocialAuthController.php index a2325e9..aca487b 100644 --- a/app/Http/Controllers/SocialAuthController.php +++ b/app/Http/Controllers/SocialAuthController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App; +use App\Models\Plan; use App\Models\User; use App\Models\UserPlan; use Illuminate\Support\Facades\Auth; @@ -64,7 +65,7 @@ public function handleGoogleCallback() return redirect()->intended(route('home'))->with('success', "You're now logged in!"); } catch (\Exception $e) { - throw $e; + //throw $e; $error_message = 'Google login failed. Please try again.'; if (config('app.debug')) { $error_message = $e->getMessage(); @@ -78,10 +79,10 @@ private function setupUser($user) { $user_plan = UserPlan::where('user_id', $user->id)->first(); - if (!$user_plan) { + if (! $user_plan) { $user_plan = UserPlan::create([ 'user_id' => $user->id, - 'plan_id' => 'free', + 'plan_id' => Plan::where('tier', 'free')->first()->id, ]); } } diff --git a/app/Http/Controllers/UserPurchaseController.php b/app/Http/Controllers/UserPurchaseController.php index 3fa3729..cc01714 100644 --- a/app/Http/Controllers/UserPurchaseController.php +++ b/app/Http/Controllers/UserPurchaseController.php @@ -32,8 +32,8 @@ public function subscribe(Request $request) $payload = [ 'mode' => 'subscription', - 'success_url' => route('subscribe.success') . '?' . 'session_id={CHECKOUT_SESSION_ID}', - 'cancel_url' => route('subscribe.cancelled') . '?' . 'session_id={CHECKOUT_SESSION_ID}', + 'success_url' => route('subscribe.success').'?'.'session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('subscribe.cancelled').'?'.'session_id={CHECKOUT_SESSION_ID}', 'line_items' => [[ 'price' => $price_id, 'quantity' => 1, @@ -81,8 +81,8 @@ public function purchase(Request $request) $price_id = $request->input('price_id'); $payload = [ - 'success_url' => route('subscribe.success') . '?' . 'session_id={CHECKOUT_SESSION_ID}', - 'cancel_url' => route('subscribe.cancelled') . '?' . 'session_id={CHECKOUT_SESSION_ID}', + 'success_url' => route('subscribe.success').'?'.'session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('subscribe.cancelled').'?'.'session_id={CHECKOUT_SESSION_ID}', ]; $checkout_session = Auth::user()->checkout([$price_id => 1], $payload); @@ -107,7 +107,7 @@ public function purchaseSuccess(Request $request) Session::forget('checkout_session_id'); - return redirect()->route('home')->with('success', "Thank you for purchasing! Your purchase should be active momentarily. Please refresh the page if you do not see your plan."); + return redirect()->route('home')->with('success', 'Thank you for purchasing! Your purchase should be active momentarily. Please refresh the page if you do not see your plan.'); } public function purchaseCancelled(Request $request) diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index ced89c1..61f2564 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -47,15 +47,15 @@ public function share(Request $request): array 'user' => $request->user(), 'user_is_admin' => user_is_master_admin($request->user()), ], - 'ziggy' => fn(): array => [ + 'ziggy' => fn (): array => [ ...(new Ziggy)->toArray(), 'location' => $request->url(), ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'flash' => [ - 'message' => fn() => $request->session()->get('message'), - 'error' => fn() => $request->session()->get('error'), - 'success' => fn() => $request->session()->get('success'), + 'message' => fn () => $request->session()->get('message'), + 'error' => fn () => $request->session()->get('error'), + 'success' => fn () => $request->session()->get('success'), ], ]; } diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 553f081..240dc3c 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -2,10 +2,8 @@ namespace App\Listeners; -use App\Helpers\FirstParty\Stripe\StripeHelper; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Queue\InteractsWithQueue; - +use App\Helpers\FirstParty\Purchase\SubscriptionHelper; +use App\Helpers\FirstParty\Purchase\WatermarkUsageHelper; use Laravel\Cashier\Events\WebhookReceived; class StripeEventListener @@ -23,6 +21,7 @@ public function __construct() */ public function handle(WebhookReceived $event): void { - StripeHelper::handleSubscriptionWebhookEvents($event); + SubscriptionHelper::handleSubscriptionWebhookEvents($event); + WatermarkUsageHelper::handleWatermarkUsageWebhookEvents($event); } } diff --git a/app/Models/Plan.php b/app/Models/Plan.php index 12bc06d..02b1f15 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -11,21 +11,19 @@ /** * Class Plan - * + * * @property int $id * @property string $name * @property string $tier * @property Carbon|null $created_at * @property Carbon|null $updated_at - * - * @package App\Models */ class Plan extends Model { - protected $table = 'plans'; + protected $table = 'plans'; - protected $fillable = [ - 'name', - 'tier' - ]; + protected $fillable = [ + 'name', + 'tier', + ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 6f41d19..4e43720 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,13 +7,14 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Cashier\Billable; use Laravel\Sanctum\HasApiTokens; use Str; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, Notifiable, SoftDeletes; + use HasApiTokens, HasFactory, Notifiable, SoftDeletes, Billable; /** * The attributes that are mass assignable. diff --git a/app/Models/UserPlan.php b/app/Models/UserPlan.php index 7033c71..7b3e150 100644 --- a/app/Models/UserPlan.php +++ b/app/Models/UserPlan.php @@ -11,7 +11,7 @@ /** * Class UserPlan - * + * * @property int $id * @property int $user_id * @property int $plan_id @@ -20,26 +20,24 @@ * @property Carbon|null $canceled_at * @property Carbon|null $created_at * @property Carbon|null $updated_at - * - * @package App\Models */ class UserPlan extends Model { - protected $table = 'user_plans'; + protected $table = 'user_plans'; - protected $casts = [ - 'user_id' => 'int', - 'plan_id' => 'int', - 'current_period_end' => 'datetime', - 'cancel_at' => 'datetime', - 'canceled_at' => 'datetime' - ]; + protected $casts = [ + 'user_id' => 'int', + 'plan_id' => 'int', + 'current_period_end' => 'datetime', + 'cancel_at' => 'datetime', + 'canceled_at' => 'datetime', + ]; - protected $fillable = [ - 'user_id', - 'plan_id', - 'current_period_end', - 'cancel_at', - 'canceled_at' - ]; + protected $fillable = [ + 'user_id', + 'plan_id', + 'current_period_end', + 'cancel_at', + 'canceled_at', + ]; } diff --git a/app/Models/UserUsage.php b/app/Models/UserUsage.php new file mode 100644 index 0000000..171dabe --- /dev/null +++ b/app/Models/UserUsage.php @@ -0,0 +1,36 @@ + 'int', + 'non_watermark_videos_left' => 'int' + ]; + + protected $fillable = [ + 'user_id', + 'non_watermark_videos_left' + ]; +} diff --git a/bootstrap/app.php b/bootstrap/app.php index f2d2562..d3f864c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -9,9 +9,9 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__ . '/../routes/web.php', - api: __DIR__ . '/../routes/api.php', - commands: __DIR__ . '/../routes/console.php', + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', health: '/up', then: function () { if (config('platform.general.enable_test_routes')) { diff --git a/database/migrations/2025_07_01_170551_create_user_usages_table.php b/database/migrations/2025_07_01_170551_create_user_usages_table.php new file mode 100644 index 0000000..e758e13 --- /dev/null +++ b/database/migrations/2025_07_01_170551_create_user_usages_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id'); + $table->integer('non_watermark_videos_left')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_usages'); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index 6bd2b57..6f0a4eb 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -3,7 +3,6 @@ namespace Database\Seeders; use DB; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class PlanSeeder extends Seeder diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 4736e02..7059f4e 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -30,7 +30,7 @@ Event::assertDispatched(Verified::class); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route(config('platform.general.authed_route_redirect'), absolute: false) . '?verified=1'); + $response->assertRedirect(route(config('platform.general.authed_route_redirect'), absolute: false).'?verified=1'); }); test('email is not verified with invalid hash', function () {