This commit is contained in:
ct
2025-07-03 00:48:06 +08:00
parent 0aa0d9569f
commit db892fc4f5
26 changed files with 807 additions and 59 deletions

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Helpers\FirstParty\Credits;
use App\Helpers\FirstParty\Purchase\PurchaseHelper;
use App\Models\UserCredit;
use App\Models\UserCreditTransaction;
use Illuminate\Support\Facades\DB;
class CreditsHelper
{
public static function getCreditPackByStripePriceId($stripe_price_id)
{
$credit_packs = self::getCreditPacks(null, true);
foreach ($credit_packs as $credit_pack) {
$stripe_price_ids = PurchaseHelper::getPlanSystemProperty($credit_pack, 'stripe.stripe_price_ids');
if (in_array($stripe_price_id, $stripe_price_ids)) {
return $credit_pack;
}
}
return null;
}
public static function getCreditsPackById($id, $enabled = true, $system = false)
{
$credits_packs = self::getCreditPacks($enabled, $system);
foreach ($credits_packs as $credits_pack) {
if ($credits_pack['id'] == $id) {
return $credits_pack;
}
}
return null;
}
public static function getCreditPacks($enabled = null, $system = true)
{
$credit_packs = [];
$one_times = PurchaseHelper::getOneTimePurchases($enabled, $system);
foreach ($one_times as $one_time) {
if ($one_time['type'] == 'alacarte_credits') {
$credit_packs[] = $one_time;
}
}
return $credit_packs;
}
// ////
public static function getUserCredits(int $user_id): UserCredit
{
$user_credit = UserCredit::where('user_id', $user_id)->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,
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Helpers\FirstParty\Credits;
use App\Models\UserCredit;
class CreditsService
{
public static function depositSubscription(int $userId, int $amount, ?string $description = null): bool
{
return CreditsHelper::deposit($userId, $amount, UserCredit::TYPE_SUBSCRIPTION, $description);
}
public static function depositAlacarte(int $userId, int $amount, ?string $description = null): bool
{
return CreditsHelper::deposit($userId, $amount, UserCredit::TYPE_ALACARTE, $description);
}
public static function spend(int $userId, int $amount, ?string $description = null, ?bool $overrideSubscriptionFirst = null): bool
{
return CreditsHelper::spend($userId, $amount, $description, $overrideSubscriptionFirst);
}
public static function setSpendSubscriptionFirst(int $userId, bool $spendSubscriptionFirst): bool
{
return CreditsHelper::setSpendSubscriptionFirst($userId, $spendSubscriptionFirst);
}
public static function balance(int $userId, ?string $type = null): int
{
return CreditsHelper::balance($userId, $type);
}
public static function canSpend(int $userId, int $amount): bool
{
return CreditsHelper::canSpend($userId, $amount);
}
public static function summary(int $userId): array
{
return CreditsHelper::summary($userId);
}
public static function transactionHistory(int $userId, int $limit = 20): \Illuminate\Database\Eloquent\Collection
{
return CreditsHelper::transactionHistory($userId, $limit);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Helpers\FirstParty\Credits;
use App\Helpers\FirstParty\Stripe\StripeHelper;
use App\Models\PaymentWebhookProcessedLog;
use Laravel\Cashier\Events\WebhookReceived;
class UserCreditHelper
{
public static function handleStripeWebhook(WebhookReceived $event)
{
$payload = $event->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',
]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Helpers\FirstParty\Purchase;
use App\Helpers\FirstParty\Credits\CreditsHelper;
use App\Helpers\FirstParty\Credits\CreditsService;
use App\Helpers\FirstParty\Stripe\StripeHelper;
use App\Models\PaymentWebhookProcessedLog;
use Laravel\Cashier\Events\WebhookReceived;
class CreditsPurchaseHelper
{
public static function handleWebhookEvents(WebhookReceived $event)
{
switch (StripeHelper::getEventType($event)) {
case 'checkout.session.completed':
self::handleCheckoutSessionCompleted($event);
break;
}
}
private static function handleCheckoutSessionCompleted($event)
{
$object = StripeHelper::getEventObject($event);
// Check if the webhook has already been processed
$payment_webhook_processed_log = PaymentWebhookProcessedLog::where('provider', 'stripe')
->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',
]);
}
}

View File

@@ -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();

View File

@@ -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,
]);
}
}
}
}
}

View File

@@ -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'));
}
}

View File

@@ -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()),
],
],
]);

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentWebhookProcessedLog extends Model
{
protected $table = 'payment_webhook_processed_logs';
protected $fillable = [
'provider',
'log_id',
'log_type',
];
}

View File

@@ -57,7 +57,7 @@ class User extends Authenticatable
protected function ids(): Attribute
{
return Attribute::make(
get: fn($value, $attributes) => hashids_encode($attributes['id']),
get: fn ($value, $attributes) => hashids_encode($attributes['id']),
);
}

39
app/Models/UserCredit.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class UserCredit extends Model
{
use HasFactory;
const TYPE_SUBSCRIPTION = 'subscription';
const TYPE_ALACARTE = 'alacarte';
protected $fillable = [
'user_id',
'subscription_credits',
'alacarte_credits',
'spend_subscription_first',
];
protected $casts = [
'spend_subscription_first' => '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');
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserCreditTransaction extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'credit_type',
'operation',
'amount',
'subscription_balance_after',
'alacarte_balance_after',
'description',
'metadata',
];
protected $casts = [
'metadata' => '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;
}
}

View File

@@ -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']),
);
}
}

View File

@@ -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' => '$',

View File

@@ -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'),
],
];

View File

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

View File

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

View File

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

View File

@@ -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 (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetContent side="bottom" className="max-h-screen overflow-y-scroll pb-1">
@@ -131,17 +137,19 @@ const UpgradeSheet = () => {
)}
{/* Credits */}
{credits != null && (
<div className="bg-card relative overflow-hidden rounded-xl border p-6 shadow-sm">
<div className="relative z-10">
<div className="mb-2 flex items-center gap-2">
<CoinIcon className="text-primary h-5 w-5" />
<span className="text-muted-foreground text-xs font-medium">Credits</span>
</div>
<div className="text-foreground text-xl font-bold">999</div>
<div className="text-foreground text-xl font-bold">{credits}</div>
<div className="text-muted-foreground text-sm">available</div>
</div>
<CoinIcon className="bg-muted absolute -top-2 -right-2 h-20 w-20 opacity-15" />
</div>
)}
</div>
</div>
) : (
@@ -159,18 +167,15 @@ const UpgradeSheet = () => {
<div className="mx-auto space-y-6 rounded-lg border p-4 text-center sm:p-7">
<SparklesText className="text-xl font-bold sm:text-2xl">You're now in the {plan?.name} plan!</SparklesText>
{billing?.portal && (
<Button
variant="outline"
onClick={() => {
setIsRedirectingToBilling(true);
window.location.replace(billing?.portal);
handleRedirectBillingPortal();
}}
disabled={isRedirectingToBilling}
>
{isRedirectingToBilling ? <Spinner className="text-muted-foreground h-4 w-4" /> : 'Manage Subscription'}
</Button>
)}
<div className="text-muted-foreground block rounded border p-3 text-center text-xs">
<ShieldIcon className="mr-1 mb-1 inline h-4 w-4" />
@@ -229,24 +234,38 @@ const UpgradeSheet = () => {
</div>
<div className="space-y-2">
<div className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
{one_times.map((one_time, index) => (
<div
key={one_time.id}
className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="grid">
<div className="grid text-start">
<div className="text inline-flex items-center font-semibold">
<CoinIcon className="inline h-4 w-4 flex-shrink-0" />
<span className="ml-1">500 Credit Pack</span>
</div>
<div className="text-muted-foreground text-xs font-semibold break-words">
Approx. 250 AI captions & 250 AI backgrounds
<span className="ml-1">{one_time.name}</span>
</div>
<div className="text-muted-foreground text-xs font-semibold break-words">{one_time.description}</div>
</div>
<div className="flex items-center gap-1">
<div className="text-muted-foreground text-sm">$4.00</div>
<div className="text-muted-foreground text-sm">
{one_time.symbol}
{one_time.amount} per pack
</div>
</div>
<Button className="w-full flex-shrink-0 sm:w-auto">Buy</Button>
</div>
<Button
disabled={isCheckingOut}
onClick={() => {
handlePurchaseOneTime(one_time);
}}
className="w-full flex-shrink-0 sm:w-auto"
>
{isCheckingOut ? <Spinner className="text-muted h-4 w-4" /> : <span>Buy</span>}
</Button>
</div>
))}
</div>
</div>

View File

@@ -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);

View File

@@ -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) {

File diff suppressed because one or more lines are too long

View File

@@ -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']);
});
});

View File

@@ -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');