This commit is contained in:
ct
2025-07-02 01:08:11 +08:00
parent 209c022f1d
commit 68a47ec916
19 changed files with 503 additions and 75 deletions

View File

@@ -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"
}
}

View File

@@ -37,6 +37,32 @@ public static function getPricingPageOneTime()
return $one_time; 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) public static function getPlanSystemProperty($plan, $property)
{ {
// Get the environment (test or prod) // Get the environment (test or prod)

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Helpers\FirstParty\Purchase;
use App\Helpers\FirstParty\Stripe\StripeHelper;
use App\Models\Plan;
use App\Models\UserPlan;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
class SubscriptionHelper
{
public static function handleSubscriptionWebhookEvents(WebhookReceived $event)
{
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)
{
$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)
{
// /
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Helpers\FirstParty\Purchase;
use App\Helpers\FirstParty\Stripe\StripeHelper;
use App\Models\UserUsage;
use Illuminate\Support\Facades\App;
use Laravel\Cashier\Events\WebhookReceived;
class WatermarkUsageHelper
{
public static function handleWatermarkUsageWebhookEvents(WebhookReceived $event)
{
switch ($event->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);
}
}
}
}
}

View File

@@ -2,31 +2,13 @@
namespace App\Helpers\FirstParty\Stripe; namespace App\Helpers\FirstParty\Stripe;
use App\Models\User;
use Laravel\Cashier\Events\WebhookReceived; use Laravel\Cashier\Events\WebhookReceived;
class StripeHelper class StripeHelper
{ {
public static function handleSubscriptionWebhookEvents(WebhookReceived $event) public static function getUserByStripeID($customer_id)
{ {
switch ($event->payload['type']) { return User::where('stripe_id', $customer_id)->first();
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)
{
///
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App; use App;
use App\Models\Plan;
use App\Models\User; use App\Models\User;
use App\Models\UserPlan; use App\Models\UserPlan;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -64,7 +65,7 @@ public function handleGoogleCallback()
return redirect()->intended(route('home'))->with('success', "You're now logged in!"); return redirect()->intended(route('home'))->with('success', "You're now logged in!");
} catch (\Exception $e) { } catch (\Exception $e) {
throw $e; //throw $e;
$error_message = 'Google login failed. Please try again.'; $error_message = 'Google login failed. Please try again.';
if (config('app.debug')) { if (config('app.debug')) {
$error_message = $e->getMessage(); $error_message = $e->getMessage();
@@ -81,7 +82,7 @@ private function setupUser($user)
if (! $user_plan) { if (! $user_plan) {
$user_plan = UserPlan::create([ $user_plan = UserPlan::create([
'user_id' => $user->id, 'user_id' => $user->id,
'plan_id' => 'free', 'plan_id' => Plan::where('tier', 'free')->first()->id,
]); ]);
} }
} }

View File

@@ -107,7 +107,7 @@ public function purchaseSuccess(Request $request)
Session::forget('checkout_session_id'); 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) public function purchaseCancelled(Request $request)

View File

@@ -2,10 +2,8 @@
namespace App\Listeners; namespace App\Listeners;
use App\Helpers\FirstParty\Stripe\StripeHelper; use App\Helpers\FirstParty\Purchase\SubscriptionHelper;
use Illuminate\Contracts\Queue\ShouldQueue; use App\Helpers\FirstParty\Purchase\WatermarkUsageHelper;
use Illuminate\Queue\InteractsWithQueue;
use Laravel\Cashier\Events\WebhookReceived; use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener class StripeEventListener
@@ -23,6 +21,7 @@ public function __construct()
*/ */
public function handle(WebhookReceived $event): void public function handle(WebhookReceived $event): void
{ {
StripeHelper::handleSubscriptionWebhookEvents($event); SubscriptionHelper::handleSubscriptionWebhookEvents($event);
WatermarkUsageHelper::handleWatermarkUsageWebhookEvents($event);
} }
} }

View File

@@ -17,8 +17,6 @@
* @property string $tier * @property string $tier
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
*
* @package App\Models
*/ */
class Plan extends Model class Plan extends Model
{ {
@@ -26,6 +24,6 @@ class Plan extends Model
protected $fillable = [ protected $fillable = [
'name', 'name',
'tier' 'tier',
]; ];
} }

View File

@@ -7,13 +7,14 @@
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Str; use Str;
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable, SoftDeletes; use HasApiTokens, HasFactory, Notifiable, SoftDeletes, Billable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.

View File

@@ -20,8 +20,6 @@
* @property Carbon|null $canceled_at * @property Carbon|null $canceled_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
*
* @package App\Models
*/ */
class UserPlan extends Model class UserPlan extends Model
{ {
@@ -32,7 +30,7 @@ class UserPlan extends Model
'plan_id' => 'int', 'plan_id' => 'int',
'current_period_end' => 'datetime', 'current_period_end' => 'datetime',
'cancel_at' => 'datetime', 'cancel_at' => 'datetime',
'canceled_at' => 'datetime' 'canceled_at' => 'datetime',
]; ];
protected $fillable = [ protected $fillable = [
@@ -40,6 +38,6 @@ class UserPlan extends Model
'plan_id', 'plan_id',
'current_period_end', 'current_period_end',
'cancel_at', 'cancel_at',
'canceled_at' 'canceled_at',
]; ];
} }

36
app/Models/UserUsage.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
/**
* Created by Reliese Model.
*/
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
/**
* Class UserUsage
*
* @property int $id
* @property int $user_id
* @property int $non_watermark_videos_left
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @package App\Models
*/
class UserUsage extends Model
{
protected $table = 'user_usages';
protected $casts = [
'user_id' => 'int',
'non_watermark_videos_left' => 'int'
];
protected $fillable = [
'user_id',
'non_watermark_videos_left'
];
}

View File

@@ -0,0 +1,29 @@
<?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('user_usages', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -3,7 +3,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use DB; use DB;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class PlanSeeder extends Seeder class PlanSeeder extends Seeder