From db892fc4f51828c57623b7c4d2f27744cb9281f5 Mon Sep 17 00:00:00 2001 From: ct Date: Thu, 3 Jul 2025 00:48:06 +0800 Subject: [PATCH] Update --- .../FirstParty/Credits/CreditsHelper.php | 212 ++++++++++++++++++ .../FirstParty/Credits/CreditsService.php | 48 ++++ .../FirstParty/Credits/UserCreditHelper.php | 67 ++++++ .../Purchase/CreditsPurchaseHelper.php | 73 ++++++ .../FirstParty/Purchase/PurchaseHelper.php | 10 +- .../Purchase/SubscriptionHelper.php | 36 ++- .../FirstParty/Stripe/StripeHelper.php | 16 ++ .../Controllers/UserAccountController.php | 5 +- .../Controllers/UserPurchaseController.php | 13 +- app/Listeners/StripeEventListener.php | 4 +- app/Models/PaymentWebhookProcessedLog.php | 16 ++ app/Models/User.php | 2 +- app/Models/UserCredit.php | 39 ++++ app/Models/UserCreditTransaction.php | 57 +++++ app/Models/UserUsage.php | 2 +- config/platform/purchases/one_time.php | 4 +- config/services.php | 6 + ...07_02_103410_create_user_credits_table.php | 33 +++ ..._create_user_credit_transactions_table.php | 39 ++++ ...e_payment_webhook_processed_logs_table.php | 32 +++ .../js/modules/upgrade/upgrade-sheet.jsx | 97 ++++---- resources/js/stores/PricingStore.js | 27 +++ resources/js/stores/UserStore.js | 20 ++ resources/js/ziggy.js | 2 +- routes/api.php | 2 + routes/web.php | 4 +- 26 files changed, 807 insertions(+), 59 deletions(-) create mode 100644 app/Helpers/FirstParty/Credits/CreditsHelper.php create mode 100644 app/Helpers/FirstParty/Credits/CreditsService.php create mode 100644 app/Helpers/FirstParty/Credits/UserCreditHelper.php create mode 100644 app/Helpers/FirstParty/Purchase/CreditsPurchaseHelper.php create mode 100644 app/Models/PaymentWebhookProcessedLog.php create mode 100644 app/Models/UserCredit.php create mode 100644 app/Models/UserCreditTransaction.php create mode 100644 database/migrations/2025_07_02_103410_create_user_credits_table.php create mode 100644 database/migrations/2025_07_02_103423_create_user_credit_transactions_table.php create mode 100644 database/migrations/2025_07_02_103435_create_payment_webhook_processed_logs_table.php diff --git a/app/Helpers/FirstParty/Credits/CreditsHelper.php b/app/Helpers/FirstParty/Credits/CreditsHelper.php new file mode 100644 index 0000000..0ae5bdd --- /dev/null +++ b/app/Helpers/FirstParty/Credits/CreditsHelper.php @@ -0,0 +1,212 @@ +first(); + + if (! $user_credit) { + $user_credit = UserCredit::create([ + 'user_id' => $user_id, + 'subscription_credits' => 0, + 'alacarte_credits' => 0, + 'spend_subscription_first' => true, + ]); + } + + return $user_credit; + } + + // when user purchase annual subscription, need to 12x the deposit amount + public static function deposit(int $user_id, int $amount, string $type, ?string $description = null): bool + { + if ($amount <= 0) { + throw new \InvalidArgumentException('Amount must be positive'); + } + + if (! in_array($type, [UserCredit::TYPE_SUBSCRIPTION, UserCredit::TYPE_ALACARTE])) { + throw new \InvalidArgumentException('Type must be subscription or alacarte'); + } + + return DB::transaction(function () use ($user_id, $amount, $type, $description) { + $user_credit = self::getUserCredits($user_id); + + if ($type === UserCredit::TYPE_SUBSCRIPTION) { + $user_credit->subscription_credits += $amount; + } else { + $user_credit->alacarte_credits += $amount; + } + + $user_credit->save(); + self::logTransaction($user_id, $type, 'deposit', $amount, $user_credit, $description); + + return true; + }); + } + + public static function spend(int $user_id, int $amount, ?string $description = null, ?bool $use_subscription_first = null): bool + { + if ($amount <= 0) { + throw new \InvalidArgumentException('Amount must be positive'); + } + + if (! self::canSpend($user_id, $amount)) { + return false; + } + + return DB::transaction(function () use ($user_id, $amount, $description, $use_subscription_first) { + $user_credit = self::getUserCredits($user_id); + $remaining = $amount; + $subscription_first = $use_subscription_first ?? $user_credit->spend_subscription_first; + + $spend_order = $subscription_first + ? [UserCredit::TYPE_SUBSCRIPTION, UserCredit::TYPE_ALACARTE] + : [UserCredit::TYPE_ALACARTE, UserCredit::TYPE_SUBSCRIPTION]; + + foreach ($spend_order as $type) { + if ($remaining <= 0) { + break; + } + + $available = $type === UserCredit::TYPE_SUBSCRIPTION + ? $user_credit->subscription_credits + : $user_credit->alacarte_credits; + + if ($available > 0) { + $to_spend = min($remaining, $available); + + if ($type === UserCredit::TYPE_SUBSCRIPTION) { + $user_credit->subscription_credits -= $to_spend; + } else { + $user_credit->alacarte_credits -= $to_spend; + } + + self::logTransaction($user_id, $type, 'spend', -$to_spend, $user_credit, $description); + $remaining -= $to_spend; + } + } + + $user_credit->save(); + + return true; + }); + } + + public static function canSpend(int $user_id, int $amount): bool + { + $user_credit = self::getUserCredits($user_id); + + return self::_getTotalBalance($user_credit) >= $amount; + } + + public static function summary(int $user_id): array + { + $user_credit = self::getUserCredits($user_id); + + return [ + 'subscription' => $user_credit->subscription_credits, + 'alacarte' => $user_credit->alacarte_credits, + 'total' => self::_getTotalBalance($user_credit), + 'preference' => $user_credit->spend_subscription_first ? 'subscription_first' : 'alacarte_first', + ]; + } + + public static function balance(int $user_id, ?string $type = null): int + { + $user_credit = self::getUserCredits($user_id); + + return match ($type) { + UserCredit::TYPE_SUBSCRIPTION => $user_credit->subscription_credits, + UserCredit::TYPE_ALACARTE => $user_credit->alacarte_credits, + default => self::_getTotalBalance($user_credit), + }; + } + + private static function _getTotalBalance(UserCredit $user_credit): int + { + return $user_credit->subscription_credits + $user_credit->alacarte_credits; + } + + public static function setSpendSubscriptionFirst(int $user_id, bool $spendSubscriptionFirst): bool + { + $user_credit = self::getUserCredits($user_id); + $user_credit->spend_subscription_first = $spendSubscriptionFirst; + + return $user_credit->save(); + } + + public static function transactionHistory(int $user_id, int $limit = 20): \Illuminate\Database\Eloquent\Collection + { + $user_credit = self::getUserCredits($user_id); + + return $user_credit->transactions()->limit($limit)->get(); + } + + private static function logTransaction(int $user_id, string $type, string $operation, int $amount, UserCredit $user_credit, ?string $description = null): void + { + UserCreditTransaction::create([ + 'user_id' => $user_id, + 'credit_type' => $type, + 'operation' => $operation, + 'amount' => $amount, + 'subscription_balance_after' => $user_credit->subscription_credits, + 'alacarte_balance_after' => $user_credit->alacarte_credits, + 'description' => $description, + ]); + } +} diff --git a/app/Helpers/FirstParty/Credits/CreditsService.php b/app/Helpers/FirstParty/Credits/CreditsService.php new file mode 100644 index 0000000..5a781e0 --- /dev/null +++ b/app/Helpers/FirstParty/Credits/CreditsService.php @@ -0,0 +1,48 @@ +payload; + + switch ($payload['type']) { + case 'checkout.session.completed': + self::handleCheckoutSessionCompleted($payload['data']['object']); + break; + } + } + + private static function handleCheckoutSessionCompleted($checkout_session) + { + // Check if the webhook has already been processed + $payment_webhook_processed_log = PaymentWebhookProcessedLog::where('provider', 'stripe') + ->where('log_id', $checkout_session['id']) + ->where('log_type', 'checkout_session') + ->first(); + + if (! is_null($payment_webhook_processed_log)) { + return; + } + + // Get the user associated with the checkout session + $user = StripeHelper::getUserByStripeCustomerId($checkout_session['customer']); + + if ($user) { + + // Get the line items from the checkout session + $lineItems = \Stripe\Checkout\Session::allLineItems($checkout_session['id']); + + foreach ($lineItems->data as $lineItem) { + $stripe_price_id = $lineItem->price->id; + $stripe_product_id = $lineItem->price->product; + + // Get the credit pack associated with the price ID + $credit_pack = CreditsHelper::getCreditPackByStripePriceId($stripe_product_id, $stripe_price_id, get_stripe_env()); + + if (is_null($credit_pack)) { + continue; + } + + $total_credits = $credit_pack['purchased_credits'] + $credit_pack['bonus_credits']; + + // Deposit the credits to the user's account + CreditsService::depositAlacarte($user->id, $total_credits, "Purchased {$credit_pack['name']}"); + } + } + + // Mark the webhook as processed + PaymentWebhookProcessedLog::create([ + 'provider' => 'stripe', + 'log_id' => $checkout_session['id'], + 'log_type' => 'checkout_session', + ]); + } +} diff --git a/app/Helpers/FirstParty/Purchase/CreditsPurchaseHelper.php b/app/Helpers/FirstParty/Purchase/CreditsPurchaseHelper.php new file mode 100644 index 0000000..5ae5141 --- /dev/null +++ b/app/Helpers/FirstParty/Purchase/CreditsPurchaseHelper.php @@ -0,0 +1,73 @@ +where('log_id', $object['id']) + ->where('log_type', 'checkout_session') + ->first(); + + if (! is_null($payment_webhook_processed_log)) { + return; + } + + // Get the user associated with the checkout session + $user = StripeHelper::getUserByStripeID($object['customer']); + + if ($user) { + + // Get the line items from the checkout session + StripeHelper::setStripeApiKey(); + $lineItems = \Stripe\Checkout\Session::allLineItems($object['id']); + + // dd($lineItems); + + foreach ($lineItems->data as $lineItem) { + $stripe_price_id = $lineItem->price->id; + + // Get the credit pack associated with the price ID + $credit_pack = CreditsHelper::getCreditPackByStripePriceId($stripe_price_id); + + if (is_null($credit_pack)) { + continue; + } + + $total_credits = PurchaseHelper::getPlanSystemProperty($credit_pack, 'credits'); + + // Deposit the credits to the user's account + CreditsService::depositAlacarte($user->id, $total_credits, "Purchased {$credit_pack['name']}"); + } + } + + // Mark the webhook as processed + PaymentWebhookProcessedLog::create([ + 'provider' => 'stripe', + 'log_id' => $object['id'], + 'log_type' => 'checkout_session', + ]); + } +} diff --git a/app/Helpers/FirstParty/Purchase/PurchaseHelper.php b/app/Helpers/FirstParty/Purchase/PurchaseHelper.php index 74043c1..1eb15c0 100644 --- a/app/Helpers/FirstParty/Purchase/PurchaseHelper.php +++ b/app/Helpers/FirstParty/Purchase/PurchaseHelper.php @@ -13,7 +13,7 @@ public static function getPricingPageSubscriptions() $subscriptions = self::filterShowInPricing($subscriptions); - $subscriptions = self::setSystemPlan($subscriptions, 'stripe.current_stripe_price_id.month'); + $subscriptions = self::setSystemPlan($subscriptions, 'stripe.current_stripe_price_id.month', 'stripe_monthly_price_id'); $subscriptions = self::filterUnsetSystem($subscriptions); @@ -28,7 +28,7 @@ public static function getPricingPageOneTime() $one_time = PurchaseHelper::filterShowInPricing($one_time); - $one_time = PurchaseHelper::setSystemPlan($one_time, 'stripe.current_stripe_price_id'); + $one_time = PurchaseHelper::setSystemPlan($one_time, 'stripe.current_stripe_price_id', 'stripe_price_id'); $one_time = PurchaseHelper::filterUnsetSystem($one_time); @@ -136,11 +136,11 @@ public static function getOneTimePurchases(?bool $enabled, $system = false) return $tmp_otp; } - public static function setSystemPlan($arr, $property) + public static function setSystemPlan($arr, $property, $key_name) { foreach ($arr as $key => $item) { - $arr[$key]['stripe_monthly_price_id'] = PurchaseHelper::getPlanSystemProperty($item, $property); + $arr[$key][$key_name] = PurchaseHelper::getPlanSystemProperty($item, $property); // $stripe_monthly_price_id = PurchaseHelper::getPlanSystemProperty($subscription, 'stripe.product_id.month'); @@ -173,7 +173,7 @@ public static function filterShowInPricing($arr) }); } - private static function getStripeEnvironment() + public static function getStripeEnvironment() { $env = App::environment(); diff --git a/app/Helpers/FirstParty/Purchase/SubscriptionHelper.php b/app/Helpers/FirstParty/Purchase/SubscriptionHelper.php index 1a04588..d41e3d1 100644 --- a/app/Helpers/FirstParty/Purchase/SubscriptionHelper.php +++ b/app/Helpers/FirstParty/Purchase/SubscriptionHelper.php @@ -9,9 +9,9 @@ class SubscriptionHelper { - public static function handleSubscriptionWebhookEvents(WebhookReceived $event) + public static function handleWebhookEvents(WebhookReceived $event) { - switch ($event->payload['type']) { + switch (StripeHelper::getEventType($event)) { case 'customer.subscription.created': case 'customer.subscription.updated': self::handleSubscriptionUpsert($event); @@ -25,7 +25,7 @@ public static function handleSubscriptionWebhookEvents(WebhookReceived $event) private static function handleSubscriptionUpsert(WebhookReceived $event) { - $object = $event->payload['data']['object']; + $object = StripeHelper::getEventObject($event); // dump($object); @@ -83,6 +83,34 @@ private static function handleSubscriptionUpsert(WebhookReceived $event) private static function handleSubscriptionDelete(WebhookReceived $event) { - // / + $object = StripeHelper::getEventObject($event); + + $user = StripeHelper::getUserByStripeID($object['customer']); + + if ($user) { + + $plan = Plan::where('tier', 'free')->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' => null, + 'cancel_at' => null, + 'canceled_at' => null, + ]); + } else { + $user_plan = UserPlan::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'current_period_end' => null, + 'cancel_at' => null, + 'canceled_at' => null, + ]); + } + } + } } } diff --git a/app/Helpers/FirstParty/Stripe/StripeHelper.php b/app/Helpers/FirstParty/Stripe/StripeHelper.php index b8d1ccd..e864925 100644 --- a/app/Helpers/FirstParty/Stripe/StripeHelper.php +++ b/app/Helpers/FirstParty/Stripe/StripeHelper.php @@ -3,6 +3,7 @@ namespace App\Helpers\FirstParty\Stripe; use App\Models\User; +use Laravel\Cashier\Events\WebhookReceived; class StripeHelper { @@ -10,4 +11,19 @@ public static function getUserByStripeID($customer_id) { return User::where('stripe_id', $customer_id)->first(); } + + public static function getEventObject(WebhookReceived $event) + { + return $event->payload['data']['object']; + } + + public static function getEventType(WebhookReceived $event) + { + return $event->payload['type']; + } + + public static function setStripeApiKey() + { + \Stripe\Stripe::setApiKey(config('services.stripe.secret')); + } } diff --git a/app/Http/Controllers/UserAccountController.php b/app/Http/Controllers/UserAccountController.php index ef0aad8..51acb52 100644 --- a/app/Http/Controllers/UserAccountController.php +++ b/app/Http/Controllers/UserAccountController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Helpers\FirstParty\Credits\CreditsService; use Illuminate\Support\Facades\Auth; class UserAccountController extends Controller @@ -19,8 +20,8 @@ public function index() 'user' => $user, 'billing' => [ 'provider' => 'stripe', - 'portal' => Auth::user()->billingPortalUrl(route('home')) - ] + ], + 'credits' => CreditsService::balance(Auth::id()), ], ], ]); diff --git a/app/Http/Controllers/UserPurchaseController.php b/app/Http/Controllers/UserPurchaseController.php index cc01714..753ffc6 100644 --- a/app/Http/Controllers/UserPurchaseController.php +++ b/app/Http/Controllers/UserPurchaseController.php @@ -9,6 +9,17 @@ class UserPurchaseController extends Controller { + public function billingPortal(Request $request) + { + return response()->json([ + 'success' => [ + 'data' => [ + 'redirect' => Auth::user()->billingPortalUrl(route('home')), + ], + ], + ]); + } + public function pricingPage(Request $request) { @@ -61,7 +72,7 @@ public function subscribeSuccess(Request $request) Session::forget('checkout_session_id'); - return redirect()->route('home')->with('success', 'Thank you for subscribing! Your subscription should be active momentarily. Please refresh the page if you do not see your plan.'); + return redirect()->route('home')->with('success', 'Purchase successful! Your purchase should be active momentarily. Please refresh if you did not see any changes.'); } public function subscribeCancelled(Request $request) diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 240dc3c..bb66524 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -2,6 +2,7 @@ namespace App\Listeners; +use App\Helpers\FirstParty\Purchase\CreditsPurchaseHelper; use App\Helpers\FirstParty\Purchase\SubscriptionHelper; use App\Helpers\FirstParty\Purchase\WatermarkUsageHelper; use Laravel\Cashier\Events\WebhookReceived; @@ -21,7 +22,8 @@ public function __construct() */ public function handle(WebhookReceived $event): void { - SubscriptionHelper::handleSubscriptionWebhookEvents($event); + SubscriptionHelper::handleWebhookEvents($event); + CreditsPurchaseHelper::handleWebhookEvents($event); WatermarkUsageHelper::handleWatermarkUsageWebhookEvents($event); } } diff --git a/app/Models/PaymentWebhookProcessedLog.php b/app/Models/PaymentWebhookProcessedLog.php new file mode 100644 index 0000000..20bdcb1 --- /dev/null +++ b/app/Models/PaymentWebhookProcessedLog.php @@ -0,0 +1,16 @@ + hashids_encode($attributes['id']), + get: fn ($value, $attributes) => hashids_encode($attributes['id']), ); } diff --git a/app/Models/UserCredit.php b/app/Models/UserCredit.php new file mode 100644 index 0000000..e74eba9 --- /dev/null +++ b/app/Models/UserCredit.php @@ -0,0 +1,39 @@ + 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(UserCreditTransaction::class, 'user_id', 'user_id') + ->orderBy('created_at', 'desc'); + } +} diff --git a/app/Models/UserCreditTransaction.php b/app/Models/UserCreditTransaction.php new file mode 100644 index 0000000..0e794cc --- /dev/null +++ b/app/Models/UserCreditTransaction.php @@ -0,0 +1,57 @@ + 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function userCredit(): BelongsTo + { + return $this->belongsTo(UserCredit::class, 'user_id', 'user_id'); + } + + public function scopeDeposits($query) + { + return $query->where('operation', 'deposit'); + } + + public function scopeSpending($query) + { + return $query->where('operation', 'spend'); + } + + public function isCredit(): bool + { + return $this->amount > 0; + } + + public function isDebit(): bool + { + return $this->amount < 0; + } +} diff --git a/app/Models/UserUsage.php b/app/Models/UserUsage.php index 6ea6d7d..e45dec0 100644 --- a/app/Models/UserUsage.php +++ b/app/Models/UserUsage.php @@ -45,7 +45,7 @@ class UserUsage extends Model protected function ids(): Attribute { return Attribute::make( - get: fn($value, $attributes) => hashids_encode($attributes['id']), + get: fn ($value, $attributes) => hashids_encode($attributes['id']), ); } } diff --git a/config/platform/purchases/one_time.php b/config/platform/purchases/one_time.php index a7cc347..01843ef 100644 --- a/config/platform/purchases/one_time.php +++ b/config/platform/purchases/one_time.php @@ -6,8 +6,8 @@ 'show_in_pricing' => true, 'type' => 'alacarte_credits', 'enabled' => true, - 'name' => '500 Credit Packs', - 'description' => 'Use credits to automatically generate captions & backgrounds for your memes.', + 'name' => '500 Credit Pack', + 'description' => 'Approx. 250 AI captions & 250 AI backgrounds', 'amount' => 4, 'currency' => 'usd', 'symbol' => '$', diff --git a/config/services.php b/config/services.php index 5848962..51f7a32 100644 --- a/config/services.php +++ b/config/services.php @@ -53,4 +53,10 @@ 'redirect' => env('GOOGLE_CLIENT_REDIRECT_URI'), ], + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + ], + ]; diff --git a/database/migrations/2025_07_02_103410_create_user_credits_table.php b/database/migrations/2025_07_02_103410_create_user_credits_table.php new file mode 100644 index 0000000..fdbca3f --- /dev/null +++ b/database/migrations/2025_07_02_103410_create_user_credits_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->integer('subscription_credits')->default(0); + $table->integer('alacarte_credits')->default(0); + $table->boolean('spend_subscription_first')->default(true); + $table->timestamps(); + + $table->unique('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_credits'); + } +}; diff --git a/database/migrations/2025_07_02_103423_create_user_credit_transactions_table.php b/database/migrations/2025_07_02_103423_create_user_credit_transactions_table.php new file mode 100644 index 0000000..d866b76 --- /dev/null +++ b/database/migrations/2025_07_02_103423_create_user_credit_transactions_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->enum('credit_type', ['subscription', 'alacarte']); + $table->enum('operation', ['deposit', 'spend']); + $table->integer('amount'); // positive for deposits, negative for spending + $table->integer('subscription_balance_after'); + $table->integer('alacarte_balance_after'); + $table->string('description')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + $table->index(['user_id', 'credit_type']); + $table->index(['operation', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_credit_transactions'); + } +}; diff --git a/database/migrations/2025_07_02_103435_create_payment_webhook_processed_logs_table.php b/database/migrations/2025_07_02_103435_create_payment_webhook_processed_logs_table.php new file mode 100644 index 0000000..0a13108 --- /dev/null +++ b/database/migrations/2025_07_02_103435_create_payment_webhook_processed_logs_table.php @@ -0,0 +1,32 @@ +id(); + $table->enum('provider', ['stripe']); + $table->string('log_id'); + $table->enum('log_type', ['checkout_session']); + $table->timestamps(); + + $table->index(['log_id', 'log_type', 'provider'], 'idx_webhook_logs_search'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payment_webhook_processed_logs'); + } +}; diff --git a/resources/js/modules/upgrade/upgrade-sheet.jsx b/resources/js/modules/upgrade/upgrade-sheet.jsx index 3e5862a..f3818cc 100644 --- a/resources/js/modules/upgrade/upgrade-sheet.jsx +++ b/resources/js/modules/upgrade/upgrade-sheet.jsx @@ -16,12 +16,10 @@ import { useEffect, useState } from 'react'; import UpgradePlanCarousel from './partials/upgrade-plan-carousel.tsx'; const UpgradeSheet = () => { - const { subscription, one_times, isFetchingPricing, fetchPricing, isCheckingOut, checkoutSubscribe } = usePricingStore(); - const { plan, billing, user_usage } = useUserStore(); + const { subscription, one_times, isFetchingPricing, fetchPricing, isCheckingOut, checkoutSubscribe, checkoutPurchase } = usePricingStore(); + const { plan, billing, user_usage, credits, redirectBillingPortal, isRedirectingToBilling } = useUserStore(); const { auth } = usePage().props; - const [isRedirectingToBilling, setIsRedirectingToBilling] = useState(false); - // State to control sheet visibility const [isOpen, setIsOpen] = useState(false); @@ -101,6 +99,14 @@ const UpgradeSheet = () => { checkoutSubscribe(subscription.stripe_monthly_price_id); }; + const handlePurchaseOneTime = (one_time) => { + checkoutPurchase(one_time.stripe_price_id); + }; + + const handleRedirectBillingPortal = () => { + redirectBillingPortal(); + }; + return ( @@ -131,17 +137,19 @@ const UpgradeSheet = () => { )} {/* Credits */} -
-
-
- - Credits + {credits != null && ( +
+
+
+ + Credits +
+
{credits}
+
available
-
999
-
available
+
- -
+ )}
) : ( @@ -159,18 +167,15 @@ const UpgradeSheet = () => {
You're now in the {plan?.name} plan! - {billing?.portal && ( - - )} +
@@ -229,24 +234,38 @@ const UpgradeSheet = () => {
-
-
-
-
- - 500 Credit Pack + {one_times.map((one_time, index) => ( +
+
+
+
+ + {one_time.name} +
+
{one_time.description}
-
- Approx. 250 AI captions & 250 AI backgrounds -
-
-
-
$4.00
+
+
+ {one_time.symbol} + {one_time.amount} per pack +
+
+
- -
+ ))}
diff --git a/resources/js/stores/PricingStore.js b/resources/js/stores/PricingStore.js index 109817f..14d9e14 100644 --- a/resources/js/stores/PricingStore.js +++ b/resources/js/stores/PricingStore.js @@ -14,6 +14,33 @@ const usePricingStore = create( isCheckingOut: false, + checkoutPurchase: async (price_id) => { + console.log('checkoutPurchase', price_id); + + set({ isCheckingOut: true }); + try { + const response = await axiosInstance.post(route('api.user.purchase'), { price_id: price_id }); + console.log(response); + if (response?.data?.success?.data) { + if (response.data.success.data.redirect) { + window.location.href = response.data.success.data.redirect; + } + } else { + throw 'Invalid API response'; + } + } catch (error) { + console.error(route('api.user.purchase')); + console.error('Error fetching:', error); + set({ isCheckingOut: false }); + if (error?.response?.data?.error?.message?.length > 0) { + toast.error(error.response.data.error.message); + } + throw error; + } finally { + set({ isCheckingOut: false }); + } + }, + checkoutSubscribe: async (price_id) => { console.log('checkoutSubscribe', price_id); diff --git a/resources/js/stores/UserStore.js b/resources/js/stores/UserStore.js index 5a5db68..471104e 100644 --- a/resources/js/stores/UserStore.js +++ b/resources/js/stores/UserStore.js @@ -14,9 +14,28 @@ const useUserStore = create( tier: 'free', }, billing: null, + credits: null, isLoadingUser: false, + isRedirectingToBilling: false, + + redirectBillingPortal: async () => { + set({ isRedirectingToBilling: true }); + console.log('redirectBillingPortal'); + try { + const response = await axiosInstance.post(route('api.user.billing_portal')); + if (response?.data?.success?.data) { + window.location.href = response.data.success.data.redirect; + } + } catch (error) { + console.error(route('api.user.billing_portal')); + console.error('Error fetching:', error); + } finally { + set({ isRedirectingToBilling: false }); + } + }, + // Fetch backgrounds fetchUser: async () => { set({ isLoadingUser: true }); @@ -27,6 +46,7 @@ const useUserStore = create( user_usage: response.data.success.data.user.user_usage, plan: response.data.success.data.user.plan, billing: response.data.success.data.billing, + credits: response.data.success.data.credits, }); //return response.data.success.data; } catch (error) { diff --git a/resources/js/ziggy.js b/resources/js/ziggy.js index 2ca9a26..7df5e16 100644 --- a/resources/js/ziggy.js +++ b/resources/js/ziggy.js @@ -1,4 +1,4 @@ -const Ziggy = {"url":"https:\/\/memeaigen.test","port":null,"defaults":{},"routes":{"cashier.payment":{"uri":"stripe\/payment\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"cashier.webhook":{"uri":"stripe\/webhook","methods":["POST"]},"horizon.stats.index":{"uri":"horizon\/api\/stats","methods":["GET","HEAD"]},"horizon.workload.index":{"uri":"horizon\/api\/workload","methods":["GET","HEAD"]},"horizon.masters.index":{"uri":"horizon\/api\/masters","methods":["GET","HEAD"]},"horizon.monitoring.index":{"uri":"horizon\/api\/monitoring","methods":["GET","HEAD"]},"horizon.monitoring.store":{"uri":"horizon\/api\/monitoring","methods":["POST"]},"horizon.monitoring-tag.paginate":{"uri":"horizon\/api\/monitoring\/{tag}","methods":["GET","HEAD"],"parameters":["tag"]},"horizon.monitoring-tag.destroy":{"uri":"horizon\/api\/monitoring\/{tag}","methods":["DELETE"],"wheres":{"tag":".*"},"parameters":["tag"]},"horizon.jobs-metrics.index":{"uri":"horizon\/api\/metrics\/jobs","methods":["GET","HEAD"]},"horizon.jobs-metrics.show":{"uri":"horizon\/api\/metrics\/jobs\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.queues-metrics.index":{"uri":"horizon\/api\/metrics\/queues","methods":["GET","HEAD"]},"horizon.queues-metrics.show":{"uri":"horizon\/api\/metrics\/queues\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.jobs-batches.index":{"uri":"horizon\/api\/batches","methods":["GET","HEAD"]},"horizon.jobs-batches.show":{"uri":"horizon\/api\/batches\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.jobs-batches.retry":{"uri":"horizon\/api\/batches\/retry\/{id}","methods":["POST"],"parameters":["id"]},"horizon.pending-jobs.index":{"uri":"horizon\/api\/jobs\/pending","methods":["GET","HEAD"]},"horizon.completed-jobs.index":{"uri":"horizon\/api\/jobs\/completed","methods":["GET","HEAD"]},"horizon.silenced-jobs.index":{"uri":"horizon\/api\/jobs\/silenced","methods":["GET","HEAD"]},"horizon.failed-jobs.index":{"uri":"horizon\/api\/jobs\/failed","methods":["GET","HEAD"]},"horizon.failed-jobs.show":{"uri":"horizon\/api\/jobs\/failed\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.retry-jobs.show":{"uri":"horizon\/api\/jobs\/retry\/{id}","methods":["POST"],"parameters":["id"]},"horizon.jobs.show":{"uri":"horizon\/api\/jobs\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.index":{"uri":"horizon\/{view?}","methods":["GET","HEAD"],"wheres":{"view":"(.*)"},"parameters":["view"]},"sanctum.csrf-cookie":{"uri":"sanctum\/csrf-cookie","methods":["GET","HEAD"]},"api.pricing_page":{"uri":"api\/pricing","methods":["POST"]},"api.user":{"uri":"api\/user","methods":["POST"]},"api.user.subscribe":{"uri":"api\/user\/subscribe","methods":["POST"]},"api.user.purchase":{"uri":"api\/user\/purchase","methods":["POST"]},"api.app.init":{"uri":"api\/app\/init","methods":["POST"]},"api.app.memes":{"uri":"api\/app\/memes","methods":["POST"]},"api.app.background":{"uri":"api\/app\/background","methods":["POST"]},"auth.google.redirect":{"uri":"auth\/google\/redirect","methods":["GET","HEAD"]},"auth.google.callback":{"uri":"auth\/google\/callback","methods":["GET","HEAD"]},"dashboard":{"uri":"dashboard","methods":["GET","HEAD"]},"subscribe.success":{"uri":"subscribe\/success","methods":["GET","HEAD"]},"subscribe.cancelled":{"uri":"subscribe\/cancelled","methods":["GET","HEAD"]},"purchase.success":{"uri":"purchase\/success","methods":["GET","HEAD"]},"purchase.cancelled":{"uri":"purchase\/cancelled","methods":["GET","HEAD"]},"admin.dashboard":{"uri":"admin","methods":["GET","HEAD"]},"admin.background-generation":{"uri":"admin\/background-generation","methods":["GET","HEAD"]},"admin.background-generation.generate":{"uri":"admin\/background-generation\/generate","methods":["POST"]},"admin.background-generation.save":{"uri":"admin\/background-generation\/save","methods":["POST"]},"admin.background-generation.delete":{"uri":"admin\/background-generation\/delete\/{id}","methods":["POST"],"parameters":["id"]},"profile.edit":{"uri":"settings\/profile","methods":["GET","HEAD"]},"profile.update":{"uri":"settings\/profile","methods":["PATCH"]},"profile.destroy":{"uri":"settings\/profile","methods":["DELETE"]},"password.edit":{"uri":"settings\/password","methods":["GET","HEAD"]},"password.update":{"uri":"settings\/password","methods":["PUT"]},"appearance":{"uri":"settings\/appearance","methods":["GET","HEAD"]},"register":{"uri":"register","methods":["GET","HEAD"]},"login":{"uri":"login","methods":["GET","HEAD"]},"password.request":{"uri":"forgot-password","methods":["GET","HEAD"]},"password.email":{"uri":"forgot-password","methods":["POST"]},"password.reset":{"uri":"reset-password\/{token}","methods":["GET","HEAD"],"parameters":["token"]},"password.store":{"uri":"reset-password","methods":["POST"]},"verification.notice":{"uri":"verify-email","methods":["GET","HEAD"]},"verification.verify":{"uri":"verify-email\/{id}\/{hash}","methods":["GET","HEAD"],"parameters":["id","hash"]},"verification.send":{"uri":"email\/verification-notification","methods":["POST"]},"password.confirm":{"uri":"confirm-password","methods":["GET","HEAD"]},"logout":{"uri":"logout","methods":["POST"]},"home":{"uri":"\/","methods":["GET","HEAD"]},"storage.local":{"uri":"storage\/{path}","methods":["GET","HEAD"],"wheres":{"path":".*"},"parameters":["path"]}}}; +const Ziggy = {"url":"https:\/\/memeaigen.test","port":null,"defaults":{},"routes":{"cashier.payment":{"uri":"stripe\/payment\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"cashier.webhook":{"uri":"stripe\/webhook","methods":["POST"]},"horizon.stats.index":{"uri":"horizon\/api\/stats","methods":["GET","HEAD"]},"horizon.workload.index":{"uri":"horizon\/api\/workload","methods":["GET","HEAD"]},"horizon.masters.index":{"uri":"horizon\/api\/masters","methods":["GET","HEAD"]},"horizon.monitoring.index":{"uri":"horizon\/api\/monitoring","methods":["GET","HEAD"]},"horizon.monitoring.store":{"uri":"horizon\/api\/monitoring","methods":["POST"]},"horizon.monitoring-tag.paginate":{"uri":"horizon\/api\/monitoring\/{tag}","methods":["GET","HEAD"],"parameters":["tag"]},"horizon.monitoring-tag.destroy":{"uri":"horizon\/api\/monitoring\/{tag}","methods":["DELETE"],"wheres":{"tag":".*"},"parameters":["tag"]},"horizon.jobs-metrics.index":{"uri":"horizon\/api\/metrics\/jobs","methods":["GET","HEAD"]},"horizon.jobs-metrics.show":{"uri":"horizon\/api\/metrics\/jobs\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.queues-metrics.index":{"uri":"horizon\/api\/metrics\/queues","methods":["GET","HEAD"]},"horizon.queues-metrics.show":{"uri":"horizon\/api\/metrics\/queues\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.jobs-batches.index":{"uri":"horizon\/api\/batches","methods":["GET","HEAD"]},"horizon.jobs-batches.show":{"uri":"horizon\/api\/batches\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.jobs-batches.retry":{"uri":"horizon\/api\/batches\/retry\/{id}","methods":["POST"],"parameters":["id"]},"horizon.pending-jobs.index":{"uri":"horizon\/api\/jobs\/pending","methods":["GET","HEAD"]},"horizon.completed-jobs.index":{"uri":"horizon\/api\/jobs\/completed","methods":["GET","HEAD"]},"horizon.silenced-jobs.index":{"uri":"horizon\/api\/jobs\/silenced","methods":["GET","HEAD"]},"horizon.failed-jobs.index":{"uri":"horizon\/api\/jobs\/failed","methods":["GET","HEAD"]},"horizon.failed-jobs.show":{"uri":"horizon\/api\/jobs\/failed\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.retry-jobs.show":{"uri":"horizon\/api\/jobs\/retry\/{id}","methods":["POST"],"parameters":["id"]},"horizon.jobs.show":{"uri":"horizon\/api\/jobs\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.index":{"uri":"horizon\/{view?}","methods":["GET","HEAD"],"wheres":{"view":"(.*)"},"parameters":["view"]},"sanctum.csrf-cookie":{"uri":"sanctum\/csrf-cookie","methods":["GET","HEAD"]},"api.pricing_page":{"uri":"api\/pricing","methods":["POST"]},"api.user":{"uri":"api\/user","methods":["POST"]},"api.user.subscribe":{"uri":"api\/user\/subscribe","methods":["POST"]},"api.user.purchase":{"uri":"api\/user\/purchase","methods":["POST"]},"api.user.billing_portal":{"uri":"api\/user\/billing-portal","methods":["POST"]},"api.app.init":{"uri":"api\/app\/init","methods":["POST"]},"api.app.memes":{"uri":"api\/app\/memes","methods":["POST"]},"api.app.background":{"uri":"api\/app\/background","methods":["POST"]},"auth.google.redirect":{"uri":"auth\/google\/redirect","methods":["GET","HEAD"]},"auth.google.callback":{"uri":"auth\/google\/callback","methods":["GET","HEAD"]},"dashboard":{"uri":"dashboard","methods":["GET","HEAD"]},"subscribe.success":{"uri":"subscribe\/success","methods":["GET","HEAD"]},"subscribe.cancelled":{"uri":"subscribe\/cancelled","methods":["GET","HEAD"]},"purchase.success":{"uri":"purchase\/success","methods":["GET","HEAD"]},"purchase.cancelled":{"uri":"purchase\/cancelled","methods":["GET","HEAD"]},"admin.dashboard":{"uri":"admin","methods":["GET","HEAD"]},"admin.background-generation":{"uri":"admin\/background-generation","methods":["GET","HEAD"]},"admin.background-generation.generate":{"uri":"admin\/background-generation\/generate","methods":["POST"]},"admin.background-generation.save":{"uri":"admin\/background-generation\/save","methods":["POST"]},"admin.background-generation.delete":{"uri":"admin\/background-generation\/delete\/{id}","methods":["POST"],"parameters":["id"]},"profile.edit":{"uri":"settings\/profile","methods":["GET","HEAD"]},"profile.update":{"uri":"settings\/profile","methods":["PATCH"]},"profile.destroy":{"uri":"settings\/profile","methods":["DELETE"]},"password.edit":{"uri":"settings\/password","methods":["GET","HEAD"]},"password.update":{"uri":"settings\/password","methods":["PUT"]},"appearance":{"uri":"settings\/appearance","methods":["GET","HEAD"]},"register":{"uri":"register","methods":["GET","HEAD"]},"login":{"uri":"login","methods":["GET","HEAD"]},"password.request":{"uri":"forgot-password","methods":["GET","HEAD"]},"password.email":{"uri":"forgot-password","methods":["POST"]},"password.reset":{"uri":"reset-password\/{token}","methods":["GET","HEAD"],"parameters":["token"]},"password.store":{"uri":"reset-password","methods":["POST"]},"verification.notice":{"uri":"verify-email","methods":["GET","HEAD"]},"verification.verify":{"uri":"verify-email\/{id}\/{hash}","methods":["GET","HEAD"],"parameters":["id","hash"]},"verification.send":{"uri":"email\/verification-notification","methods":["POST"]},"password.confirm":{"uri":"confirm-password","methods":["GET","HEAD"]},"logout":{"uri":"logout","methods":["POST"]},"home":{"uri":"\/","methods":["GET","HEAD"]},"storage.local":{"uri":"storage\/{path}","methods":["GET","HEAD"],"wheres":{"path":".*"},"parameters":["path"]}}}; if (typeof window !== 'undefined' && typeof window.Ziggy !== 'undefined') { Object.assign(Ziggy.routes, window.Ziggy.routes); } diff --git a/routes/api.php b/routes/api.php index b268d4c..3164feb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -23,6 +23,8 @@ Route::post('/purchase', [UserPurchaseController::class, 'purchase'])->name('api.user.purchase'); + Route::post('/billing-portal', [UserPurchaseController::class, 'billingPortal'])->name('api.user.billing_portal'); + Route::post('/logout', [SanctumAuthController::class, 'logout']); }); }); diff --git a/routes/web.php b/routes/web.php index 09049a5..eeeb4f6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -57,8 +57,8 @@ }); }); - require __DIR__ . '/settings.php'; - require __DIR__ . '/auth.php'; + require __DIR__.'/settings.php'; + require __DIR__.'/auth.php'; } Route::get('/', [FrontHomeController::class, 'index'])->name('home');